Files
RUWIO/src/devtools/tests.ts
T
thandClaude Opus 4.7 024883943b macro-clips slice 4c: button creation in sidebar + drag to clip tracks
- store.seedDefaultButtonClip(name) adds a button clip with a single
  shared rect asset and four pre-populated state stacks in blue shades
  (normal/hover/pressed/disabled). Layer name 'Face' is consistent so
  transitions can later pair layers by name.
- AssetSidebar gets a Buttons section below Clips: create via prompt,
  rename, delete, drag-to-track. Drag uses a new dataTransfer type
  'application/x-animatr-button-clip' to keep it distinct from
  animation-clip drops.
- Timeline clip-lane drop accepts both types and dispatches
  addClipInstance / addButtonInstance accordingly; the new-track
  drop zone handles both too, so dragging a button to an empty area
  creates a fresh clip track + instance in one step.
- i18n for the sidebar strings and three dialogs.
- test covers seedDefaultButtonClip: four populated states, shared
  asset, consistent layer name.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:31:41 +02:00

3553 lines
125 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createApp, h, nextTick, type Component, type Slot } from 'vue'
import { createBlankScene, useSceneStore } from '@/stores/scene'
import {
buildMarkerMap,
evalAction,
scriptFramesInRange,
type ActionRuntime,
} from '@/lib/actions'
import { parseScript } from '@/lib/scriptDsl'
import type { Action } from '@/types/scene'
import { sampleLayer, lerpColor } from '@/lib/interpolate'
import { snapToCandidates } from '@/lib/timelineSnap'
import {
generateBundledScript,
generateRuntimeScript,
generateSceneScript,
} from '@/lib/exportPlayer'
import { ProjectFileError, parseProject, serializeProject } from '@/lib/projectFile'
import { createHistory } from '@/composables/useHistory'
import { useKfSelection, kfSelectionKey } from '@/composables/useKfSelection'
import { APP_NAME, APP_VERSION } from '@/version'
import type { PolygonAsset } from '@/types/scene'
import { drawSceneFrame } from '@/lib/render'
import { expandedTrackOrder } from '@/lib/sceneOrder'
import {
deleteStoredProject,
getStoredProject,
listRecentProjects,
saveProjectToStore,
} from '@/lib/projectStore'
import Modal from '@/components/ui/Modal.vue'
import IconButton from '@/components/ui/IconButton.vue'
import Toast from '@/components/ui/Toast.vue'
import PropertiesPanel from '@/components/PropertiesPanel.vue'
import { expect, suite, test } from './testRunner'
interface MountedHandle {
host: HTMLDivElement
app: ReturnType<typeof createApp>
unmount: () => void
}
interface RecordingCtx {
ctx: CanvasRenderingContext2D
filterSets: string[]
compositeOps: string[]
}
function makeRecordingCtx(): RecordingCtx {
const canvas = document.createElement('canvas')
canvas.width = 200
canvas.height = 200
const real = canvas.getContext('2d')!
const filterSets: string[] = []
const compositeOps: string[] = []
const proxy = new Proxy(real, {
set(target, prop, value) {
if (prop === 'filter') filterSets.push(value as string)
if (prop === 'globalCompositeOperation') compositeOps.push(value as string)
;(target as unknown as Record<string | symbol, unknown>)[prop as string | symbol] = value
return true
},
get(target, prop) {
const v = (target as unknown as Record<string | symbol, unknown>)[
prop as string | symbol
]
if (typeof v === 'function') return v.bind(target)
return v
},
})
return { ctx: proxy as CanvasRenderingContext2D, filterSets, compositeOps }
}
function mountComponent(
component: Component,
props: Record<string, unknown>,
slots?: Record<string, Slot>,
): MountedHandle {
const host = document.createElement('div')
host.setAttribute('data-test-host', '')
document.body.appendChild(host)
const app = createApp({
render() {
return h(component, props, slots)
},
})
app.mount(host)
return {
host,
app,
unmount() {
app.unmount()
host.remove()
},
}
}
function makeFreshStore() {
const s = useSceneStore()
s.$reset()
return s
}
function tick(ms = 40): Promise<void> {
return new Promise((r) => setTimeout(r, ms))
}
export function registerAllTests(): void {
suite('scene store — defaults', () => {
test('starts with empty scene, playhead 0, not playing', () => {
const s = makeFreshStore()
expect(s.scene.layers).toHaveLength(0)
expect(s.scene.assets).toHaveLength(0)
expect(s.playhead).toBe(0)
expect(s.playing).toBe(false)
expect(s.scene.duration).toBe(10)
expect(s.scene.fps).toBe(60)
expect(s.selectedLayerId).toBeNull()
})
})
suite('scene store — playhead', () => {
test('setPlayhead clamps to [0, duration]', async () => {
const s = makeFreshStore()
s.setPlayhead(-5)
await tick()
expect(s.playhead).toBe(0)
s.setPlayhead(999)
await tick()
expect(s.playhead).toBe(s.scene.duration)
s.setPlayhead(3.5)
await tick()
expect(s.playhead).toBe(3.5)
})
test('setDuration pulls playhead back if past end', async () => {
const s = makeFreshStore()
s.setPlayhead(9)
await tick()
s.setDuration(5)
await tick()
expect(s.playhead).toBe(5)
})
test('setDuration clamps to min 0.1', () => {
const s = makeFreshStore()
s.setDuration(0)
expect(s.scene.duration).toBe(0.1)
})
})
suite('scene store — layers & selection', () => {
test('addLayer with rect asset creates layer + initial keyframe + selects it', async () => {
const s = makeFreshStore()
const asset = s.addAsset({
kind: 'rect',
name: 'TestRect',
width: 200,
height: 100,
fill: '#ff4455',
})
await tick()
const layer = s.addLayer(asset.id)
await tick()
expect(s.scene.layers).toHaveLength(1)
expect(s.selectedLayerId).toBe(layer.id)
expect(layer.keyframes).toHaveLength(1)
expect(layer.keyframes[0].time).toBe(0)
expect(layer.offsetX).toBe(100)
expect(layer.offsetY).toBe(50)
expect(layer.base.fill).toBe('#ff4455')
})
test('removeLayer deselects if it was selected', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'circle', name: 'TestCircle', radius: 40, fill: '#44aaff' })
const l = s.addLayer(a.id)
await tick()
s.removeLayer(l.id)
await tick()
expect(s.scene.layers).toHaveLength(0)
expect(s.selectedLayerId).toBeNull()
})
test('addLayer inserts at the top (index 0)', async () => {
const s = makeFreshStore()
const a1 = s.addAsset({ kind: 'rect', name: 'First', width: 40, height: 40, fill: '#ff4444' })
const a2 = s.addAsset({ kind: 'rect', name: 'Second', width: 40, height: 40, fill: '#44ff44' })
const l1 = s.addLayer(a1.id)
await tick()
const l2 = s.addLayer(a2.id)
await tick()
expect(s.scene.layers[0].id).toBe(l2.id)
expect(s.scene.layers[1].id).toBe(l1.id)
})
test('moveLayer reorders layers', async () => {
const s = makeFreshStore()
const a1 = s.addAsset({ kind: 'rect', name: 'A', width: 40, height: 40, fill: '#ff4444' })
const a2 = s.addAsset({ kind: 'rect', name: 'B', width: 40, height: 40, fill: '#44ff44' })
const a3 = s.addAsset({ kind: 'rect', name: 'C', width: 40, height: 40, fill: '#4444ff' })
const l1 = s.addLayer(a1.id)
const l2 = s.addLayer(a2.id)
const l3 = s.addLayer(a3.id)
await tick()
// Insertion at top → starting order: [l3, l2, l1]
s.moveLayer(0, 2)
await tick()
expect(s.scene.layers[0].id).toBe(l2.id)
expect(s.scene.layers[1].id).toBe(l1.id)
expect(s.scene.layers[2].id).toBe(l3.id)
})
test('moveLayer ignores identical from/to', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#000' })
const l = s.addLayer(a.id)
s.moveLayer(0, 0)
expect(s.scene.layers[0].id).toBe(l.id)
})
test('selectLayer toggles with null', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'circle', name: 'SelC', radius: 30, fill: '#00cc88' })
const l = s.addLayer(a.id)
await tick()
s.selectLayer(null)
await tick()
expect(s.selectedLayerId).toBeNull()
s.selectLayer(l.id)
await tick()
expect(s.selectedLayerId).toBe(l.id)
})
})
suite('scene store — keyframes', () => {
test('upsertKeyframe adds new keyframe sorted by time', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Kfs', width: 80, height: 80, fill: '#ffa500' })
const l = s.addLayer(a.id)
await tick()
s.upsertKeyframe(l.id, 2, { x: 600 })
await tick()
s.upsertKeyframe(l.id, 1, { x: 300 })
await tick()
const times = l.keyframes.map((k) => k.time)
expect(times).toEqual([0, 1, 2])
})
test('upsertKeyframe updates existing keyframe within epsilon', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Upd', width: 80, height: 80, fill: '#aa44ff' })
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 1, { x: 500 })
await tick()
const before = l.keyframes.length
s.upsertKeyframe(l.id, 1.0000001, { x: 900 })
await tick()
expect(l.keyframes).toHaveLength(before)
const kf = l.keyframes.find((k) => Math.abs(k.time - 1) < 0.01)!
expect(kf.props.x).toBe(900)
})
test('setKeyframeTime clamps and sorts', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Clamp', width: 60, height: 60, fill: '#ff8844' })
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 3, { x: 400 })
s.upsertKeyframe(l.id, 5, { x: 800 })
await tick()
const target = l.keyframes.find((k) => k.time === 5)!
s.setKeyframeTime(l.id, target.id, -1)
await tick()
expect(target.time).toBe(0)
const times = l.keyframes.map((k) => k.time).sort((a, b) => a - b)
expect(times).toEqual(l.keyframes.map((k) => k.time))
})
test('setKeyframeTime clamps to duration upper bound', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Dur', width: 60, height: 60, fill: '#44ffaa' })
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 2, { x: 400 })
await tick()
const k = l.keyframes[l.keyframes.length - 1]
s.setKeyframeTime(l.id, k.id, 999)
await tick()
expect(k.time).toBe(s.scene.duration)
})
test('removeKeyframe removes by id', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Rm', width: 50, height: 50, fill: '#ee4488' })
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 2, { x: 500 })
await tick()
const k = l.keyframes.find((k) => k.time === 2)!
s.removeKeyframe(l.id, k.id)
await tick()
expect(l.keyframes.find((x) => x.id === k.id)).toBe(undefined)
})
test('duplicateKeyframe creates offset copy', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Dup', width: 50, height: 50, fill: '#22aaee' })
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 1, { x: 400 })
await tick()
const src = l.keyframes.find((k) => k.time === 1)!
const newId = s.duplicateKeyframe(l.id, src.id)
await tick()
expect(newId).toBeTruthy()
const dup = l.keyframes.find((k) => k.id === newId)!
expect(dup.props.x).toBe(400)
expect(dup.time).toBeGreaterThan(1)
})
test('setKeyframeHold toggles hold flag', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Hold', width: 50, height: 50, fill: '#ddaa44' })
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 1, { x: 400 })
await tick()
const k = l.keyframes[l.keyframes.length - 1]
s.setKeyframeHold(l.id, k.id, true)
await tick()
expect(k.hold).toBe(true)
s.setKeyframeHold(l.id, k.id, false)
await tick()
expect(k.hold).toBe(undefined)
})
test('setKeyframeEasing updates easing field', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Ease', width: 50, height: 50, fill: '#88cc44' })
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 1, { x: 400 })
await tick()
const k = l.keyframes[l.keyframes.length - 1]
s.setKeyframeEasing(l.id, k.id, 'easeInOut')
expect(k.easing).toBe('easeInOut')
})
})
suite('scene store — separated tracks', () => {
test('setLayerSeparated splits x/y and rotation into dedicated tracks', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Sep', width: 60, height: 60, fill: '#ee66aa' })
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 1, { x: 400, y: 200, rotation: 45, opacity: 0.5 })
await tick()
s.setLayerSeparated(l.id, true)
await tick()
expect(l.separated).toBe(true)
expect((l.positionKeyframes ?? []).length).toBeGreaterThan(0)
expect((l.rotationKeyframes ?? []).length).toBeGreaterThan(0)
for (const k of l.keyframes) {
expect((k.props as Record<string, unknown>).x).toBe(undefined)
expect((k.props as Record<string, unknown>).y).toBe(undefined)
expect((k.props as Record<string, unknown>).rotation).toBe(undefined)
}
})
test('setLayerSeparated(false) merges tracks back together', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Merge', width: 60, height: 60, fill: '#66ccee' })
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 1, { x: 400, y: 200, rotation: 45, opacity: 0.5 })
await tick()
s.setLayerSeparated(l.id, true)
await tick()
s.setLayerSeparated(l.id, false)
await tick()
expect(l.separated).toBe(false)
expect(l.positionKeyframes).toBe(undefined)
expect(l.rotationKeyframes).toBe(undefined)
const at1 = l.keyframes.find((k) => Math.abs(k.time - 1) < 0.001)!
expect((at1.props as Record<string, number>).x).toBe(400)
expect((at1.props as Record<string, number>).rotation).toBe(45)
})
})
suite('interpolation — sampleLayer', () => {
test('no keyframes → returns base', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#000' })
const l = s.addLayer(a.id)
l.keyframes = []
const p = sampleLayer(l, 0.5)
expect(p.x).toBe(l.base.x)
expect(p.y).toBe(l.base.y)
})
test('linear midpoint is halfway', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#000' })
const l = s.addLayer(a.id)
l.keyframes = [
{ id: 'k1', time: 0, props: { x: 0 }, easing: 'linear' },
{ id: 'k2', time: 2, props: { x: 100 }, easing: 'linear' },
]
l.base = { ...l.base, x: 0, y: 0 }
const p = sampleLayer(l, 1)
expect(p.x).toBeCloseTo(50, 2)
})
test('easeIn midpoint is below linear midpoint', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#000' })
const l = s.addLayer(a.id)
l.base = { ...l.base, x: 0 }
l.keyframes = [
{ id: 'k1', time: 0, props: { x: 0 }, easing: 'linear' },
{ id: 'k2', time: 2, props: { x: 100 }, easing: 'easeIn' },
]
const p = sampleLayer(l, 1)
expect(p.x).toBeLessThan(50)
expect(p.x).toBeGreaterThan(0)
})
test('hold keyframe holds value until next keyframe', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'H', width: 10, height: 10, fill: '#000' })
const l = s.addLayer(a.id)
l.base = { ...l.base, x: 0 }
l.keyframes = [
{ id: 'k1', time: 0, props: { x: 0 }, easing: 'linear', hold: true },
{ id: 'k2', time: 2, props: { x: 100 }, easing: 'linear' },
]
expect(sampleLayer(l, 0).x).toBe(0)
expect(sampleLayer(l, 0.5).x).toBe(0)
expect(sampleLayer(l, 1.5).x).toBe(0)
expect(sampleLayer(l, 2).x).toBe(100)
})
test('clamps before first kf to first props, after last to last props', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#000' })
const l = s.addLayer(a.id)
l.base = { ...l.base, x: 0 }
l.keyframes = [
{ id: 'k1', time: 1, props: { x: 10 }, easing: 'linear' },
{ id: 'k2', time: 2, props: { x: 20 }, easing: 'linear' },
]
expect(sampleLayer(l, 0).x).toBe(10)
expect(sampleLayer(l, 5).x).toBe(20)
})
})
suite('interpolation — color', () => {
test('lerpColor 0 returns start', () => {
expect(lerpColor('#000000', '#ffffff', 0)).toBe('#000000')
})
test('lerpColor 1 returns end', () => {
expect(lerpColor('#000000', '#ffffff', 1)).toBe('#ffffff')
})
test('lerpColor 0.5 is around middle gray', () => {
const mid = lerpColor('#000000', '#ffffff', 0.5)
expect(mid).toBe('#808080')
})
})
suite('timeline snap', () => {
test('disabled: returns t unchanged', () => {
const r = snapToCandidates(1.7, [2.0], {
enabled: false,
pxPerSec: 100,
pixelThreshold: 8,
duration: 10,
})
expect(r).toBe(1.7)
})
test('enabled: snaps to nearest candidate within threshold', () => {
const r = snapToCandidates(1.95, [2.0, 5.0], {
enabled: true,
pxPerSec: 100,
pixelThreshold: 8,
duration: 10,
})
expect(r).toBe(2.0)
})
test('enabled: leaves t alone if all candidates are outside threshold', () => {
const r = snapToCandidates(1.0, [2.0], {
enabled: true,
pxPerSec: 100,
pixelThreshold: 8,
duration: 10,
})
expect(r).toBe(1.0)
})
test('clamps result to [0, duration]', () => {
const r = snapToCandidates(15, [], {
enabled: true,
pxPerSec: 100,
pixelThreshold: 8,
duration: 10,
})
expect(r).toBe(10)
})
})
suite('export — runtime & scene split', () => {
test('generateRuntimeScript exposes AnimatrPlayer', () => {
const r = generateRuntimeScript()
expect(r).toContain('window.AnimatrPlayer')
expect(r).toContain('function mount')
expect(r).toContain('__animatrPending')
})
test('generateSceneScript embeds scene JSON and uses AnimatrPlayer', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'UniqRect77', width: 10, height: 10, fill: '#abc' })
s.addLayer(a.id)
const src = generateSceneScript(s.scene)
expect(src).toContain('AnimatrPlayer.mount')
expect(src).toContain('UniqRect77')
expect(src).toContain('__animatrPending')
})
test('scene script does NOT inline the mount implementation', () => {
const s = makeFreshStore()
const src = generateSceneScript(s.scene)
expect(src.indexOf('function mount')).toBe(-1)
})
test('generateBundledScript contains runtime + scene boot', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'BundlR', width: 10, height: 10, fill: '#123' })
s.addLayer(a.id)
const src = generateBundledScript(s.scene)
expect(src).toContain('function mount')
expect(src).toContain('AnimatrPlayer.mount')
expect(src).toContain('BundlR')
})
})
suite('playback — transport', () => {
test('togglePlay toggles playing state', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Play', width: 60, height: 60, fill: '#55cc66' })
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 2, { x: 500 })
await tick()
s.togglePlay()
await tick()
expect(s.playing).toBe(true)
s.togglePlay()
await tick()
expect(s.playing).toBe(false)
})
test('togglePlay rewinds if at end', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Rew', width: 60, height: 60, fill: '#cc5555' })
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 2, { x: 600 })
s.setPlayhead(s.playbackOut)
await tick()
s.togglePlay()
expect(s.playhead).toBe(s.playbackIn)
expect(s.playing).toBe(true)
s.pause()
await tick()
})
test('tick advances playhead while playing', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Tk', width: 60, height: 60, fill: '#9977dd' })
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 5, { x: 800 })
s.play()
s.tick(1)
s.pause()
expect(s.playhead).toBeCloseTo(1, 3)
await tick()
})
test('tick stops at playbackOut', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'End', width: 60, height: 60, fill: '#dd7799' })
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 2, { x: 400 })
s.play()
await tick()
s.tick(s.scene.duration + 1)
await tick()
expect(s.playing).toBe(false)
expect(s.playhead).toBe(s.playbackOut)
})
})
suite('project file — save & load roundtrip', () => {
test('serialize + parse roundtrip preserves scene', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'PF', width: 80, height: 60, fill: '#66ddaa' })
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 1, { x: 400, rotation: 30 })
s.setDuration(7)
await tick()
const text = serializeProject(s.scene)
const parsed = parseProject(text)
expect(parsed.duration).toBe(7)
expect(parsed.layers).toHaveLength(1)
expect(parsed.assets).toHaveLength(1)
expect(parsed.layers[0].keyframes.find((k) => k.time === 1)?.props.x).toBe(400)
})
test('loadScene replaces store state visibly', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'L1', width: 80, height: 60, fill: '#aa6666' })
s.addLayer(a.id)
await tick()
const dump = serializeProject(s.scene)
s.$reset()
await tick()
s.loadScene(parseProject(dump))
await tick()
expect(s.scene.layers).toHaveLength(1)
expect(s.scene.assets).toHaveLength(1)
expect(s.selectedLayerId).toBeNull()
expect(s.playhead).toBe(0)
expect(s.playing).toBe(false)
})
test('parseProject rejects non-JSON', () => {
let caught: unknown = null
try {
parseProject('not json at all')
} catch (e) {
caught = e
}
expect(caught instanceof ProjectFileError).toBe(true)
})
test('parseProject rejects foreign JSON without magic', () => {
let caught: unknown = null
try {
parseProject(JSON.stringify({ foo: 'bar' }))
} catch (e) {
caught = e
}
expect(caught instanceof ProjectFileError).toBe(true)
})
test('parseProject rejects missing scene fields', () => {
let caught: unknown = null
try {
parseProject(JSON.stringify({ magic: 'animatr-project', fileVersion: 1, scene: {} }))
} catch (e) {
caught = e
}
expect(caught instanceof ProjectFileError).toBe(true)
})
})
suite('history — undo/redo', () => {
test('canUndo starts false; becomes true after a committed mutation', async () => {
const s = makeFreshStore()
const h = createHistory(s, { debounceMs: 10 })
h.start()
expect(h.canUndo.value).toBe(false)
const a = s.addAsset({ kind: 'rect', name: 'H1', width: 10, height: 10, fill: '#000' })
s.addLayer(a.id)
await tick(60)
expect(h.canUndo.value).toBe(true)
h.stop()
})
test('undo reverts a mutation, redo reapplies', async () => {
const s = makeFreshStore()
const h = createHistory(s, { debounceMs: 10 })
h.start()
const a = s.addAsset({ kind: 'rect', name: 'H2', width: 10, height: 10, fill: '#000' })
s.addLayer(a.id)
await tick(60)
expect(s.scene.layers).toHaveLength(1)
h.undo()
await tick(20)
expect(s.scene.layers).toHaveLength(0)
expect(h.canRedo.value).toBe(true)
h.redo()
await tick(20)
expect(s.scene.layers).toHaveLength(1)
h.stop()
})
test('rapid mutations within debounce window collapse into one step', async () => {
const s = makeFreshStore()
const h = createHistory(s, { debounceMs: 50 })
h.start()
const a = s.addAsset({ kind: 'rect', name: 'H3', width: 10, height: 10, fill: '#000' })
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 1, { x: 100 })
s.upsertKeyframe(l.id, 1, { x: 200 })
s.upsertKeyframe(l.id, 1, { x: 300 })
await tick(120)
h.undo()
await tick(20)
expect(s.scene.layers).toHaveLength(0)
h.stop()
})
test('mutation after undo drops the redo branch', async () => {
const s = makeFreshStore()
const h = createHistory(s, { debounceMs: 10 })
h.start()
const a = s.addAsset({ kind: 'rect', name: 'H4', width: 10, height: 10, fill: '#000' })
s.addLayer(a.id)
await tick(40)
h.undo()
await tick(20)
expect(h.canRedo.value).toBe(true)
const b = s.addAsset({ kind: 'circle', name: 'H4c', radius: 10, fill: '#fff' })
s.addLayer(b.id)
await tick(40)
expect(h.canRedo.value).toBe(false)
h.stop()
})
test('resetTo clears history and seeds initial snapshot', async () => {
const s = makeFreshStore()
const h = createHistory(s, { debounceMs: 10 })
h.start()
const a = s.addAsset({ kind: 'rect', name: 'H5', width: 10, height: 10, fill: '#000' })
s.addLayer(a.id)
await tick(40)
expect(h.canUndo.value).toBe(true)
h.resetTo(s.scene)
expect(h.canUndo.value).toBe(false)
expect(h.canRedo.value).toBe(false)
h.stop()
})
test('undo clamps playhead when target scene has shorter duration', async () => {
const s = makeFreshStore()
const h = createHistory(s, { debounceMs: 10 })
h.start()
s.setDuration(3)
await tick(40)
s.setDuration(10)
s.setPlayhead(8)
await tick(40)
h.undo()
await tick(20)
expect(s.scene.duration).toBe(3)
expect(s.playhead).toBe(3)
h.stop()
})
test('max history cap drops oldest entries', async () => {
const s = makeFreshStore()
const h = createHistory(s, { debounceMs: 10, max: 3 })
h.start()
s.setDuration(2)
await tick(30)
s.setDuration(3)
await tick(30)
s.setDuration(4)
await tick(30)
s.setDuration(5)
await tick(30)
h.undo()
h.undo()
h.undo()
await tick(20)
expect(s.scene.duration).toBeGreaterThan(2)
h.stop()
})
})
suite('ui — Modal', () => {
test('renders nothing when modelValue is false', async () => {
const handle = mountComponent(Modal, { modelValue: false, title: 'X' })
await nextTick()
expect(document.querySelector('[role="dialog"]')).toBeNull()
handle.unmount()
})
test('teleports backdrop + dialog to body when open, renders title', async () => {
const handle = mountComponent(Modal, { modelValue: true, title: 'Hallo Modal' })
await nextTick()
const dialog = document.querySelector('[role="dialog"]') as HTMLElement | null
expect(dialog).toBeTruthy()
expect(dialog!.textContent?.includes('Hallo Modal')).toBe(true)
handle.unmount()
})
test('Esc triggers update:modelValue false', async () => {
let latest: boolean | null = null
const handle = mountComponent(Modal, {
modelValue: true,
title: 'X',
'onUpdate:modelValue': (v: boolean) => {
latest = v
},
})
await nextTick()
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
await nextTick()
expect(latest).toBe(false)
handle.unmount()
})
test('Esc does nothing when closeOnEsc is false', async () => {
let latest: boolean | null = null
const handle = mountComponent(Modal, {
modelValue: true,
title: 'X',
closeOnEsc: false,
'onUpdate:modelValue': (v: boolean) => {
latest = v
},
})
await nextTick()
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
await nextTick()
expect(latest).toBeNull()
handle.unmount()
})
test('dismissable=false hides close button and swallows Esc', async () => {
let latest: boolean | null = null
const handle = mountComponent(Modal, {
modelValue: true,
title: 'Busy',
dismissable: false,
'onUpdate:modelValue': (v: boolean) => {
latest = v
},
})
await nextTick()
const btn = document.querySelector('[role="dialog"] header button')
expect(btn).toBeNull()
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
await nextTick()
expect(latest).toBeNull()
handle.unmount()
})
test('renders footer slot content', async () => {
const handle = mountComponent(
Modal,
{ modelValue: true, title: 'X' },
{
footer: () => [h('button', { class: 'my-footer-btn' }, 'Fertig')],
},
)
await nextTick()
const btn = document.querySelector('.my-footer-btn')
expect(btn).toBeTruthy()
expect(btn!.textContent).toBe('Fertig')
handle.unmount()
})
})
suite('ui — IconButton', () => {
test('renders a button element', async () => {
const handle = mountComponent(IconButton, { title: 'hi' })
await nextTick()
const btn = handle.host.querySelector('button')
expect(btn).toBeTruthy()
expect(btn!.getAttribute('title')).toBe('hi')
handle.unmount()
})
test('applies variant + size + active classes', async () => {
const handle = mountComponent(IconButton, {
variant: 'primary',
size: 'md',
active: true,
})
await nextTick()
const btn = handle.host.querySelector('button')!
expect(btn.className.includes('variant-primary')).toBe(true)
expect(btn.className.includes('size-md')).toBe(true)
expect(btn.className.includes('active')).toBe(true)
handle.unmount()
})
test('disabled prop disables the native button', async () => {
const handle = mountComponent(IconButton, { disabled: true })
await nextTick()
const btn = handle.host.querySelector('button')!
expect(btn.disabled).toBe(true)
handle.unmount()
})
test('renders default slot in place of icon prop', async () => {
const handle = mountComponent(
IconButton,
{},
{ default: () => [h('span', { class: 'custom-icon' }, 'X')] },
)
await nextTick()
const span = handle.host.querySelector('.custom-icon')
expect(span).toBeTruthy()
expect(span!.textContent).toBe('X')
handle.unmount()
})
test('click fires native click event', async () => {
let clicks = 0
const handle = mountComponent(IconButton, {
onClick: () => {
clicks++
},
})
await nextTick()
handle.host.querySelector('button')!.click()
expect(clicks).toBe(1)
handle.unmount()
})
})
suite('ui — Toast', () => {
test('hidden when visible=false, nothing in body', async () => {
const handle = mountComponent(Toast, { visible: false, message: 'x' })
await nextTick()
expect(document.querySelector('.toast')).toBeNull()
handle.unmount()
})
test('visible=true renders a teleported element with message', async () => {
const handle = mountComponent(Toast, {
visible: true,
variant: 'success',
message: 'Alles gut',
})
await nextTick()
const el = document.querySelector('.toast') as HTMLElement | null
expect(el).toBeTruthy()
expect(el!.className.includes('variant-success')).toBe(true)
expect(el!.textContent?.includes('Alles gut')).toBe(true)
handle.unmount()
})
test('applies position class', async () => {
const handle = mountComponent(Toast, {
visible: true,
position: 'top',
message: 'x',
})
await nextTick()
const el = document.querySelector('.toast')!
expect(el.className.includes('position-top')).toBe(true)
handle.unmount()
})
test('default slot overrides title/message', async () => {
const handle = mountComponent(
Toast,
{ visible: true, message: 'ignored' },
{ default: () => [h('span', { class: 'slot-body' }, 'slot wins')] },
)
await nextTick()
const slot = document.querySelector('.slot-body')
expect(slot).toBeTruthy()
expect(document.querySelector('.toast')!.textContent?.includes('slot wins')).toBe(true)
handle.unmount()
})
})
suite('project store — IndexedDB recents', () => {
test('save then getStoredProject returns identical scene', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'RS', width: 42, height: 42, fill: '#123456' })
s.addLayer(a.id)
const rec = await saveProjectToStore(s.scene, { name: 'test-save-get' })
try {
const back = await getStoredProject(rec.id)
expect(back).toBeTruthy()
expect(back!.name).toBe('test-save-get')
expect(back!.scene.assets).toHaveLength(1)
expect(back!.scene.layers).toHaveLength(1)
} finally {
await deleteStoredProject(rec.id)
}
})
test('listRecentProjects is sorted by savedAt desc', async () => {
const s = makeFreshStore()
const olderId = `test-older-${Date.now()}`
const newerId = `test-newer-${Date.now()}`
const older = await saveProjectToStore(s.scene, { id: olderId, name: 'older' })
await tick(5)
const newer = await saveProjectToStore(s.scene, { id: newerId, name: 'newer' })
try {
const list = await listRecentProjects()
const newerIdx = list.findIndex((p) => p.id === newer.id)
const olderIdx = list.findIndex((p) => p.id === older.id)
expect(newerIdx).toBeGreaterThan(-1)
expect(olderIdx).toBeGreaterThan(-1)
expect(newerIdx).toBeLessThan(olderIdx)
} finally {
await deleteStoredProject(olderId)
await deleteStoredProject(newerId)
}
})
test('save with same id updates existing record instead of adding', async () => {
const s = makeFreshStore()
const id = `test-update-${Date.now()}`
await saveProjectToStore(s.scene, { id, name: 'v1' })
const before = (await listRecentProjects()).filter((p) => p.id === id).length
await saveProjectToStore(s.scene, { id, name: 'v2' })
try {
const list = await listRecentProjects()
const matches = list.filter((p) => p.id === id)
expect(matches).toHaveLength(1)
expect(matches[0].name).toBe('v2')
expect(before).toBe(1)
} finally {
await deleteStoredProject(id)
}
})
test('deleteStoredProject removes entry', async () => {
const s = makeFreshStore()
const rec = await saveProjectToStore(s.scene, { name: 'to-delete' })
await deleteStoredProject(rec.id)
const back = await getStoredProject(rec.id)
expect(back).toBeNull()
})
test('getStoredProject returns null for missing id', async () => {
const back = await getStoredProject('definitely-missing-id-42')
expect(back).toBeNull()
})
})
suite('clips — library & scope', () => {
test('addClip creates empty clip in library', () => {
const s = makeFreshStore()
const c = s.addClip('Intro')
expect(s.scene.clips?.length).toBe(1)
expect(c.name).toBe('Intro')
expect(c.layers).toHaveLength(0)
expect(c.duration).toBeGreaterThan(0)
})
test('enterClip switches activeLayers + activeDuration to clip scope', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'SceneL', width: 20, height: 20, fill: '#aaa' })
s.addLayer(a.id)
const c = s.addClip('C1')
c.duration = 3.5
expect(s.activeLayers).toHaveLength(1)
expect(s.activeDuration).toBe(s.scene.duration)
s.enterClip(c.id)
await tick()
expect(s.activeLayers).toHaveLength(0)
expect(s.activeDuration).toBe(3.5)
expect(s.playhead).toBe(0)
s.exitClip()
expect(s.activeLayers).toHaveLength(1)
expect(s.activeDuration).toBe(s.scene.duration)
})
test('addLayer while in clip scope targets clip layers, not scene layers', async () => {
const s = makeFreshStore()
const c = s.addClip('C2')
const a = s.addAsset({ kind: 'rect', name: 'X', width: 20, height: 20, fill: '#888' })
s.enterClip(c.id)
await tick()
s.addLayer(a.id)
expect(c.layers).toHaveLength(1)
expect(s.scene.layers).toHaveLength(0)
})
test('addClip auto-creates a first clip track if none exist', () => {
const s = makeFreshStore()
expect(s.scene.clipTracks ?? []).toHaveLength(0)
s.addClip('Auto')
expect((s.scene.clipTracks ?? []).length).toBeGreaterThan(0)
expect((s.scene.trackOrder ?? []).length).toBeGreaterThan(0)
})
test('addClipInstance anchors to a specific clipTrackId', () => {
const s = makeFreshStore()
const c = s.addClip('C3')
const t = s.scene.clipTracks![0]
const ci = s.addClipInstance(c.id, 2.5, t.id)
expect(ci).toBeTruthy()
expect(s.scene.clipInstances).toHaveLength(1)
expect(ci!.startTime).toBe(2.5)
expect(ci!.clipTrackId).toBe(t.id)
})
test('moveClipInstance updates position + can switch track', () => {
const s = makeFreshStore()
const c = s.addClip('C4')
const t1 = s.scene.clipTracks![0]
const t2 = s.addClipTrack()
const ci = s.addClipInstance(c.id, 0, t1.id)!
s.moveClipInstance(ci.id, 4, t2.id)
expect(ci.startTime).toBe(4)
expect(ci.clipTrackId).toBe(t2.id)
})
test('removeClip removes all instances and exits if active', () => {
const s = makeFreshStore()
const c = s.addClip('C5')
const t = s.scene.clipTracks![0]
s.addClipInstance(c.id, 0, t.id)
s.addClipInstance(c.id, 1, t.id)
s.enterClip(c.id)
s.removeClip(c.id)
expect(s.scene.clips).toHaveLength(0)
expect(s.scene.clipInstances).toHaveLength(0)
expect(s.activeClipId).toBeNull()
})
test('negative startTime is clamped to 0', () => {
const s = makeFreshStore()
const c = s.addClip('C6')
const t = s.scene.clipTracks![0]
const ci = s.addClipInstance(c.id, -5, t.id)!
expect(ci.startTime).toBe(0)
})
test('trimClipInstance clamps trimStart/trimEnd and preserves min visible duration', () => {
const s = makeFreshStore()
const c = s.addClip('Trim')
c.duration = 4
const t = s.scene.clipTracks![0]
const ci = s.addClipInstance(c.id, 0, t.id)!
s.trimClipInstance(ci.id, { trimStart: 1 })
expect(ci.trimStart).toBe(1)
s.trimClipInstance(ci.id, { trimEnd: 1 })
expect(ci.trimEnd).toBe(1)
// try to over-trim: start + end > duration - MIN_VISIBLE
s.trimClipInstance(ci.id, { trimStart: 10 })
const total = (ci.trimStart ?? 0) + (ci.trimEnd ?? 0)
expect(total).toBeLessThan(c.duration)
})
test('trimClipInstance accepts startTime shift (left-handle drag)', () => {
const s = makeFreshStore()
const c = s.addClip('TrimLeft')
c.duration = 4
const t = s.scene.clipTracks![0]
const ci = s.addClipInstance(c.id, 2, t.id)!
s.trimClipInstance(ci.id, { startTime: 2.5, trimStart: 0.5 })
expect(ci.startTime).toBe(2.5)
expect(ci.trimStart).toBe(0.5)
})
test('negative trim values are clamped to zero', () => {
const s = makeFreshStore()
const c = s.addClip('Neg')
c.duration = 3
const t = s.scene.clipTracks![0]
const ci = s.addClipInstance(c.id, 0, t.id)!
s.trimClipInstance(ci.id, { trimStart: -1, trimEnd: -2 })
expect(ci.trimStart).toBe(0)
expect(ci.trimEnd).toBe(0)
})
test('setTrackColor applies to layer and can be cleared with null', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Col', width: 20, height: 20, fill: '#123' })
const l = s.addLayer(a.id)
s.setTrackColor(l.id, '#ff6b6b')
expect(l.color).toBe('#ff6b6b')
s.setTrackColor(l.id, null)
expect(l.color).toBe(undefined)
})
test('setTrackColor applies to clip track', () => {
const s = makeFreshStore()
s.addClip('X')
const t = s.scene.clipTracks![0]
s.setTrackColor(t.id, '#51cf66')
expect(t.color).toBe('#51cf66')
s.setTrackColor(t.id, null)
expect(t.color).toBe(undefined)
})
test('setTrackLocked toggles layer lock flag', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'L', width: 10, height: 10, fill: '#111' })
const l = s.addLayer(a.id)
s.setTrackLocked(l.id, true)
expect(l.locked).toBe(true)
s.setTrackLocked(l.id, false)
expect(l.locked).toBe(undefined)
})
test('setTrackLocked toggles clip-track lock flag', () => {
const s = makeFreshStore()
s.addClip('L')
const t = s.scene.clipTracks![0]
s.setTrackLocked(t.id, true)
expect(t.locked).toBe(true)
s.setTrackLocked(t.id, false)
expect(t.locked).toBe(undefined)
})
test('setTrackColor on unknown id is a no-op', () => {
const s = makeFreshStore()
s.setTrackColor('nope', '#ff0000')
// just verify no throw, no layer/clipTrack changed
expect(s.scene.layers).toHaveLength(0)
expect(s.scene.clipTracks ?? []).toHaveLength(0)
})
test('moveClipInstance without clipTrackId keeps the same track (x-only move)', () => {
const s = makeFreshStore()
const c = s.addClip('MoveX')
const t1 = s.scene.clipTracks![0]
const t2 = s.addClipTrack()
const ci = s.addClipInstance(c.id, 0, t1.id)!
// sanity: distinct tracks
expect(t1.id === t2.id).toBe(false)
s.moveClipInstance(ci.id, 5)
expect(ci.startTime).toBe(5)
expect(ci.clipTrackId).toBe(t1.id)
})
test('moveTrack reorders the unified stack (layer above clip track)', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'M', width: 10, height: 10, fill: '#000' })
const l = s.addLayer(a.id)
s.addClip('X')
const track = s.scene.clipTracks![0]
// order is [layer, clipTrack]
expect(s.scene.trackOrder![0]).toBe(l.id)
expect(s.scene.trackOrder![1]).toBe(track.id)
s.moveTrack(0, 2)
expect(s.scene.trackOrder![0]).toBe(track.id)
expect(s.scene.trackOrder![1]).toBe(l.id)
})
})
suite('scene store — track groups', () => {
test('addTrackGroup creates an empty group at the top of trackOrder', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'G1', width: 10, height: 10, fill: '#111' })
s.addLayer(a.id)
const g = s.addTrackGroup('My group')
expect(s.scene.groups).toHaveLength(1)
expect(g.childIds).toHaveLength(0)
expect(s.scene.trackOrder![0]).toBe(g.id)
})
test('moveTrackIntoGroup moves track from top-level to group childIds', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'G2', width: 10, height: 10, fill: '#222' })
const l = s.addLayer(a.id)
const g = s.addTrackGroup('G')
s.moveTrackIntoGroup(l.id, g.id)
expect(g.childIds).toContain(l.id)
expect((s.scene.trackOrder ?? []).includes(l.id)).toBe(false)
})
test('moveTrackOutOfGroup promotes track back to top-level', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'G3', width: 10, height: 10, fill: '#333' })
const l = s.addLayer(a.id)
const g = s.addTrackGroup('G')
s.moveTrackIntoGroup(l.id, g.id)
s.moveTrackOutOfGroup(l.id)
expect(g.childIds.includes(l.id)).toBe(false)
expect(s.scene.trackOrder).toContain(l.id)
})
test('removeTrackGroup promotes children back to top-level', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'G4', width: 10, height: 10, fill: '#444' })
const l = s.addLayer(a.id)
const g = s.addTrackGroup('G')
s.moveTrackIntoGroup(l.id, g.id)
s.removeTrackGroup(g.id)
expect(s.scene.groups).toHaveLength(0)
expect(s.scene.trackOrder).toContain(l.id)
})
test('toggleGroupCollapsed flips the collapsed flag', () => {
const s = makeFreshStore()
const g = s.addTrackGroup('G')
expect(!!g.collapsed).toBe(false)
s.toggleGroupCollapsed(g.id)
expect(g.collapsed).toBe(true)
s.toggleGroupCollapsed(g.id)
expect(g.collapsed).toBe(false)
})
test('renameGroup updates the name', () => {
const s = makeFreshStore()
const g = s.addTrackGroup('Old')
s.renameGroup(g.id, 'New')
expect(g.name).toBe('New')
})
test('a track can only be in one group at a time', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#555' })
const l = s.addLayer(a.id)
const g1 = s.addTrackGroup('A')
const g2 = s.addTrackGroup('B')
s.moveTrackIntoGroup(l.id, g1.id)
s.moveTrackIntoGroup(l.id, g2.id)
expect(g1.childIds.includes(l.id)).toBe(false)
expect(g2.childIds).toContain(l.id)
})
test('expandedTrackOrder expands group IDs to their childIds inline', () => {
const s = makeFreshStore()
const a1 = s.addAsset({ kind: 'rect', name: 'A', width: 10, height: 10, fill: '#abc' })
const a2 = s.addAsset({ kind: 'rect', name: 'B', width: 10, height: 10, fill: '#def' })
const l1 = s.addLayer(a1.id)
const l2 = s.addLayer(a2.id)
const g = s.addTrackGroup('G')
s.moveTrackIntoGroup(l1.id, g.id)
// scene.trackOrder: [group, l2] (l1 is inside group)
// expanded: [l1, l2]
const expanded = expandedTrackOrder(s.scene)
expect(expanded).toEqual([l1.id, l2.id])
})
test('moveTrackIntoGroup respects atIndex for insertion', () => {
const s = makeFreshStore()
const a1 = s.addAsset({ kind: 'rect', name: 'A', width: 10, height: 10, fill: '#111' })
const a2 = s.addAsset({ kind: 'rect', name: 'B', width: 10, height: 10, fill: '#222' })
const a3 = s.addAsset({ kind: 'rect', name: 'C', width: 10, height: 10, fill: '#333' })
const l1 = s.addLayer(a1.id)
const l2 = s.addLayer(a2.id)
const l3 = s.addLayer(a3.id)
const g = s.addTrackGroup('G')
s.moveTrackIntoGroup(l1.id, g.id) // [l1]
s.moveTrackIntoGroup(l2.id, g.id) // [l1, l2]
s.moveTrackIntoGroup(l3.id, g.id, 1) // [l1, l3, l2]
expect(g.childIds).toEqual([l1.id, l3.id, l2.id])
})
test('removeTrackGroup inserts children at the former group position', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#aaa' })
const top1 = s.addLayer(a.id)
const top2 = s.addLayer(a.id)
const childA = s.addLayer(a.id)
const childB = s.addLayer(a.id)
const g = s.addTrackGroup('G')
s.moveTrackIntoGroup(childA.id, g.id)
s.moveTrackIntoGroup(childB.id, g.id)
// trackOrder currently: [childB? no, children are removed from top-level]
// trackOrder contains: [g, top2, top1] (newest addLayer unshifts)
// Wait: addLayer unshifts; the creation order is top2(later), then top1(earlier)
// Then addLayer(childA), childB — all unshifted. Then moved into group.
// Let's just position group explicitly to be sure:
const order = s.scene.trackOrder ?? []
const gIdx = order.indexOf(g.id)
expect(gIdx).toBeGreaterThan(-1)
s.removeTrackGroup(g.id)
const newOrder = s.scene.trackOrder ?? []
// children should now sit at the former group position
expect(newOrder[gIdx]).toBe(childA.id)
expect(newOrder[gIdx + 1]).toBe(childB.id)
void top1
void top2
})
test('renameGroup ignores empty / whitespace names', () => {
const s = makeFreshStore()
const g = s.addTrackGroup('Original')
s.renameGroup(g.id, '')
expect(g.name).toBe('Original')
s.renameGroup(g.id, ' ')
expect(g.name).toBe('Original')
s.renameGroup(g.id, ' Renamed ')
expect(g.name).toBe('Renamed')
})
test('removeTrackGroup on unknown id is a no-op', () => {
const s = makeFreshStore()
const g = s.addTrackGroup('G')
const beforeCount = (s.scene.groups ?? []).length
s.removeTrackGroup('does-not-exist')
expect((s.scene.groups ?? []).length).toBe(beforeCount)
// The real group still exists
expect(s.scene.groups?.find((x) => x.id === g.id)).toBeTruthy()
})
test('setTrackColor applies to groups too', () => {
const s = makeFreshStore()
const g = s.addTrackGroup('Col')
s.setTrackColor(g.id, '#ff0000')
expect(g.color).toBe('#ff0000')
s.setTrackColor(g.id, null)
expect(g.color).toBe(undefined)
})
test('moveTrackIntoGroup with same group twice moves within group', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'M', width: 10, height: 10, fill: '#555' })
const l1 = s.addLayer(a.id)
const l2 = s.addLayer(a.id)
const g = s.addTrackGroup('G')
s.moveTrackIntoGroup(l1.id, g.id)
s.moveTrackIntoGroup(l2.id, g.id)
// l1 currently at index 0. Move l1 to index 1.
s.moveTrackIntoGroup(l1.id, g.id, 1)
expect(g.childIds).toEqual([l2.id, l1.id])
})
test('normalizeScene (via loadScene) removes orphan childIds and deduplicates', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'N', width: 10, height: 10, fill: '#666' })
const l = s.addLayer(a.id)
// Snapshot with a broken state
const snap = JSON.parse(JSON.stringify(s.scene))
snap.groups = [
{ id: 'g1', name: 'G1', childIds: [l.id, 'ghost-layer', l.id] },
{ id: 'g2', name: 'G2', childIds: [l.id] },
]
snap.trackOrder = [l.id, 'g1', 'g2']
s.loadScene(snap)
// Orphan 'ghost-layer' filtered out
const g1 = s.scene.groups?.find((g) => g.id === 'g1')!
const g2 = s.scene.groups?.find((g) => g.id === 'g2')!
expect(g1.childIds.includes('ghost-layer')).toBe(false)
// Dedupe: l.id appears only once across all groups
const inG1 = g1.childIds.filter((id) => id === l.id).length
const inG2 = g2.childIds.filter((id) => id === l.id).length
expect(inG1 + inG2).toBe(1)
})
})
suite('app smoke — live DOM', () => {
test('app root, topbar, timeline, stage are mounted', () => {
expect(document.querySelector('.app')).toBeTruthy()
expect(document.querySelector('.topbar')).toBeTruthy()
expect(document.querySelector('.timeline')).toBeTruthy()
expect(document.querySelector('.stage')).toBeTruthy()
})
test('timeline snap toggle button is present', () => {
expect(document.querySelector('.snap-btn')).toBeTruthy()
})
test('topbar has save & recents buttons', () => {
const saveBtn = document.querySelector('button[title^="Save project"]')
const recentsBtn = document.querySelector('button[title="Recent projects"]')
expect(saveBtn).toBeTruthy()
expect(recentsBtn).toBeTruthy()
})
test('canvas has crop toggle button', () => {
const cropBtn = document.querySelector('.crop-btn')
expect(cropBtn).toBeTruthy()
})
test('brand button opens about dialog', () => {
const brand = document.querySelector('button.brand')
expect(brand).toBeTruthy()
})
test('timeline has new-track dropzone', () => {
const dz = document.querySelector('.clip-track-dropzone')
expect(dz).toBeTruthy()
})
test('timeline has add-group button', () => {
const btn = document.querySelector('button[title="Create a new track group"]')
expect(btn).toBeTruthy()
})
})
suite('version — app metadata', () => {
test('APP_VERSION is a semver-like string', () => {
expect(typeof APP_VERSION === 'string').toBe(true)
expect(/^\d+\.\d+\.\d+/.test(APP_VERSION)).toBe(true)
})
test('APP_NAME is non-empty', () => {
expect(APP_NAME.length).toBeGreaterThan(0)
})
})
suite('kf selection — shared composable', () => {
test('setting selection reflects across handles', () => {
const { selectedKfs } = useKfSelection()
const snapshot = selectedKfs.value
try {
selectedKfs.value = [
{ layerId: 'L1', kind: 'main', keyframeId: 'K1' },
{ layerId: 'L2', kind: 'position', keyframeId: 'K2' },
]
const second = useKfSelection()
expect(second.selectedKfs.value).toHaveLength(2)
expect(second.selectedKfSet.value.has('L1:main:K1')).toBe(true)
expect(second.selectedKfSet.value.has('L2:position:K2')).toBe(true)
} finally {
selectedKfs.value = snapshot
}
})
test('kfSelectionKey builds stable id', () => {
expect(kfSelectionKey({ layerId: 'a', kind: 'main', keyframeId: 'b' })).toBe('a:main:b')
expect(kfSelectionKey({ layerId: 'x', kind: 'position', keyframeId: 'y' })).toBe(
'x:position:y',
)
})
})
suite('assets — edge blur', () => {
test('rect asset stores blur value', () => {
const s = makeFreshStore()
const a = s.addAsset({
kind: 'rect',
name: 'R',
width: 40,
height: 40,
fill: '#ff0000',
blur: 12,
})
const stored = s.scene.assets.find((x) => x.id === a.id)!
expect(stored.kind).toBe('rect')
expect((stored as Extract<typeof stored, { kind: 'rect' }>).blur).toBe(12)
})
test('circle asset stores blur value', () => {
const s = makeFreshStore()
const a = s.addAsset({
kind: 'circle',
name: 'C',
radius: 30,
fill: '#0000ff',
blur: 6,
})
const stored = s.scene.assets.find((x) => x.id === a.id)!
expect(stored.kind).toBe('circle')
expect((stored as Extract<typeof stored, { kind: 'circle' }>).blur).toBe(6)
})
test('drawSceneFrame sets ctx.filter to blur() for blurred rect', () => {
const s = makeFreshStore()
const a = s.addAsset({
kind: 'rect',
name: 'Blurred',
width: 40,
height: 40,
fill: '#ff4488',
blur: 18,
})
s.addLayer(a.id)
const { ctx, filterSets } = makeRecordingCtx()
drawSceneFrame(ctx, s.scene, 0, {})
expect(filterSets.some((v) => v === 'blur(18px)')).toBe(true)
})
test('drawSceneFrame does not apply blur when blur is unset or zero', () => {
const s = makeFreshStore()
const a = s.addAsset({
kind: 'rect',
name: 'Sharp',
width: 40,
height: 40,
fill: '#123456',
})
s.addLayer(a.id)
const { ctx, filterSets } = makeRecordingCtx()
drawSceneFrame(ctx, s.scene, 0, {})
// The only filter writes should be the post-draw reset to 'none'
expect(filterSets.every((v) => v === 'none')).toBe(true)
})
test('drawSceneFrame applies blur for circle asset', () => {
const s = makeFreshStore()
const a = s.addAsset({
kind: 'circle',
name: 'CB',
radius: 20,
fill: '#22aa22',
blur: 9,
})
s.addLayer(a.id)
const { ctx, filterSets } = makeRecordingCtx()
drawSceneFrame(ctx, s.scene, 0, {})
expect(filterSets.some((v) => v === 'blur(9px)')).toBe(true)
})
})
suite('mask layers — store', () => {
test('setLayerMask(true) enables negative mask by default', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'M', width: 40, height: 40, fill: '#fff' })
const l = s.addLayer(a.id)
s.setLayerMask(l.id, true)
const layer = s.scene.layers.find((x) => x.id === l.id)!
expect(layer.isMask).toBe(true)
expect(layer.maskMode).toBe('negative')
})
test('setLayerMask(true, "negative") stores negative mode', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'circle', name: 'N', radius: 20, fill: '#fff' })
const l = s.addLayer(a.id)
s.setLayerMask(l.id, true, 'negative')
const layer = s.scene.layers.find((x) => x.id === l.id)!
expect(layer.maskMode).toBe('negative')
})
test('setLayerMask(false) clears mask flags', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'M', width: 40, height: 40, fill: '#fff' })
const l = s.addLayer(a.id)
s.setLayerMask(l.id, true, 'negative')
s.setLayerMask(l.id, false)
const layer = s.scene.layers.find((x) => x.id === l.id)!
expect(layer.isMask).toBe(undefined)
expect(layer.maskMode).toBe(undefined)
})
test('setLayerMaskMode flips mode without changing isMask', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'M', width: 40, height: 40, fill: '#fff' })
const l = s.addLayer(a.id)
s.setLayerMask(l.id, true)
s.setLayerMaskMode(l.id, 'negative')
const layer = s.scene.layers.find((x) => x.id === l.id)!
expect(layer.isMask).toBe(true)
expect(layer.maskMode).toBe('negative')
})
})
suite('mask layers — rendering', () => {
function setupMaskScene(mode: 'positive' | 'negative') {
const s = makeFreshStore()
s.scene.canvas.width = 100
s.scene.canvas.height = 100
s.scene.canvas.background = 'transparent'
// Content: full-canvas red rect
const content = s.addAsset({
kind: 'rect',
name: 'BG',
width: 100,
height: 100,
fill: '#ff0000',
})
const contentLayer = s.addLayer(content.id)
s.upsertKeyframe(contentLayer.id, 0, { x: 50, y: 50 })
// Mask: small circle centered at (50,50)
const mask = s.addAsset({
kind: 'circle',
name: 'M',
radius: 20,
fill: '#ffffff',
})
const maskLayer = s.addLayer(mask.id)
s.upsertKeyframe(maskLayer.id, 0, { x: 50, y: 50 })
s.setLayerMask(maskLayer.id, true, mode)
const group = s.addTrackGroup('G')
s.moveTrackIntoGroup(maskLayer.id, group.id)
s.moveTrackIntoGroup(contentLayer.id, group.id)
return { store: s }
}
test('positive mask keeps content inside the mask shape and clears outside', () => {
const { store } = setupMaskScene('positive')
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, store.scene, 0, {})
const inside = ctx.getImageData(50, 50, 1, 1).data
const outside = ctx.getImageData(5, 5, 1, 1).data
// Inside mask: red content visible
expect(inside[0] > 200).toBe(true)
expect(inside[3] > 200).toBe(true)
// Outside mask: cleared (alpha 0)
expect(outside[3]).toBe(0)
})
test('negative mask punches a hole in the content where the mask overlaps', () => {
const { store } = setupMaskScene('negative')
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, store.scene, 0, {})
const inside = ctx.getImageData(50, 50, 1, 1).data
const outside = ctx.getImageData(5, 5, 1, 1).data
// Inside mask: hole (alpha 0)
expect(inside[3]).toBe(0)
// Outside mask: red content still there
expect(outside[0] > 200).toBe(true)
expect(outside[3] > 200).toBe(true)
})
test('layer outside a masked group is not affected by the mask', () => {
const { store } = setupMaskScene('positive')
// Add a green rect layer OUTSIDE the group
const outer = store.addAsset({
kind: 'rect',
name: 'Outer',
width: 20,
height: 20,
fill: '#00ff00',
})
const outerLayer = store.addLayer(outer.id)
store.upsertKeyframe(outerLayer.id, 0, { x: 90, y: 90 })
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, store.scene, 0, {})
// The outer layer renders fully regardless of mask
const outerPx = ctx.getImageData(90, 90, 1, 1).data
expect(outerPx[1] > 200).toBe(true)
expect(outerPx[3] > 200).toBe(true)
})
})
suite('scene store — shiftKeyframesPosition', () => {
test('shifts x/y of a single keyframe by delta', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Shift', width: 40, height: 40, fill: '#a55' })
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 1, { x: 100, y: 50 })
const kf = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
s.shiftKeyframesPosition(
[{ layerId: l.id, kind: 'main', keyframeId: kf.id }],
25,
-10,
)
await tick()
expect(kf.props.x).toBe(125)
expect(kf.props.y).toBe(40)
})
test('shifts multiple keyframes across layers by the same delta', async () => {
const s = makeFreshStore()
const a1 = s.addAsset({ kind: 'rect', name: 'A', width: 10, height: 10, fill: '#111' })
const a2 = s.addAsset({ kind: 'rect', name: 'B', width: 10, height: 10, fill: '#222' })
const l1 = s.addLayer(a1.id)
const l2 = s.addLayer(a2.id)
s.upsertKeyframe(l1.id, 1, { x: 100, y: 100 })
s.upsertKeyframe(l2.id, 2, { x: 200, y: 300 })
const k1 = l1.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
const k2 = l2.keyframes.find((k) => Math.abs(k.time - 2) < 1e-3)!
s.shiftKeyframesPosition(
[
{ layerId: l1.id, kind: 'main', keyframeId: k1.id },
{ layerId: l2.id, kind: 'main', keyframeId: k2.id },
],
10,
5,
)
await tick()
expect(k1.props.x).toBe(110)
expect(k1.props.y).toBe(105)
expect(k2.props.x).toBe(210)
expect(k2.props.y).toBe(305)
})
test('skips rotation keyframes (no x/y)', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#333' })
const l = s.addLayer(a.id)
s.setLayerSeparated(l.id, true)
s.upsertKeyframe(l.id, 1, { rotation: 45 })
const rotKf = (l.rotationKeyframes ?? []).find((k) => Math.abs(k.time - 1) < 1e-3)!
s.shiftKeyframesPosition(
[{ layerId: l.id, kind: 'rotation', keyframeId: rotKf.id }],
100,
100,
)
await tick()
expect((rotKf.props as Record<string, unknown>).x).toBe(undefined)
expect((rotKf.props as Record<string, unknown>).y).toBe(undefined)
expect(rotKf.props.rotation).toBe(45)
})
test('uses sampled fallback when keyframe has no explicit x/y', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Fb', width: 10, height: 10, fill: '#444' })
const l = s.addLayer(a.id)
// initial keyframe at t=0 has full base props (including x=640 from canvas center)
// Create a later keyframe that only sets scaleX
s.upsertKeyframe(l.id, 2, { scaleX: 2 })
const kf = l.keyframes.find((k) => Math.abs(k.time - 2) < 1e-3)!
// ensure x is not explicitly set
delete (kf.props as Record<string, unknown>).x
delete (kf.props as Record<string, unknown>).y
const sampledBefore = sampleLayer(l, 2)
s.shiftKeyframesPosition([{ layerId: l.id, kind: 'main', keyframeId: kf.id }], 7, 11)
await tick()
expect(kf.props.x).toBe(sampledBefore.x + 7)
expect(kf.props.y).toBe(sampledBefore.y + 11)
})
test('non-existing layer/keyframe refs are safely ignored', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#555' })
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 1, { x: 50 })
// Call with mix of valid and invalid refs — should not throw
s.shiftKeyframesPosition(
[
{ layerId: 'missing-layer', kind: 'main', keyframeId: 'missing-kf' },
{ layerId: l.id, kind: 'main', keyframeId: 'also-missing' },
],
10,
10,
)
// Valid keyframe at t=1 remained unshifted (we didn't target it)
const kf = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
expect(kf.props.x).toBe(50)
})
})
suite('polygon — store actions', () => {
function makePolygonLayer() {
const s = makeFreshStore()
const asset = s.addAsset({
kind: 'polygon',
name: 'Poly',
points: [
{ x: 0, y: -40 },
{ x: 40, y: 20 },
{ x: -40, y: 20 },
],
fill: '#abc',
}) as PolygonAsset
const layer = s.addLayer(asset.id)
return { s, asset, layer }
}
test('addLayer seeds initial keyframe with points for polygons', () => {
const { layer, asset } = makePolygonLayer()
const kf0 = layer.keyframes[0]
expect(kf0).toBeTruthy()
expect(Array.isArray(kf0.props.points)).toBe(true)
expect(kf0.props.points!.length).toBe(asset.points.length)
})
test('movePolygonVertex writes a points-array kf at playhead', () => {
const { s, layer, asset } = makePolygonLayer()
s.setPlayhead(0)
s.movePolygonVertex(layer.id, 0, 10, -50)
const kf = layer.keyframes.find((k) => Math.abs(k.time) < 1e-3)!
expect(kf.props.points![0].x).toBe(10)
expect(kf.props.points![0].y).toBe(-50)
// Other points remain unchanged
expect(kf.props.points![1].x).toBe(asset.points[1].x)
expect(kf.props.points![1].y).toBe(asset.points[1].y)
})
test('setPolygonPointCount grows by midpoint insertion and keeps KFs in sync', () => {
const { s, layer, asset } = makePolygonLayer()
const originalLen = asset.points.length
s.setPolygonPointCount(asset.id, originalLen + 2)
expect(asset.points.length).toBe(originalLen + 2)
for (const kf of layer.keyframes) {
if (!kf.props.points) continue
expect(kf.props.points.length).toBe(originalLen + 2)
}
})
test('setPolygonPointCount shrinks but never below 3', () => {
const { s, asset } = makePolygonLayer()
s.setPolygonPointCount(asset.id, 1)
expect(asset.points.length).toBe(3)
})
test('insertPolygonPointOnEdge inserts at expected index across all keyframes', () => {
const { s, layer, asset } = makePolygonLayer()
s.insertPolygonPointOnEdge(asset.id, 1, 0.5)
expect(asset.points.length).toBe(4)
const kf = layer.keyframes[0]
expect(kf.props.points!.length).toBe(4)
// midpoint between original [0] and [1]
const expectedX = (0 + 40) / 2
const expectedY = (-40 + 20) / 2
expect(asset.points[1].x).toBe(expectedX)
expect(asset.points[1].y).toBe(expectedY)
expect(kf.props.points![1].x).toBe(expectedX)
expect(kf.props.points![1].y).toBe(expectedY)
})
test('reversePolygonPoints reverses asset + keyframe points', () => {
const { s, layer, asset } = makePolygonLayer()
const firstBefore = { ...asset.points[0] }
s.reversePolygonPoints(asset.id)
expect(asset.points[asset.points.length - 1].x).toBe(firstBefore.x)
expect(asset.points[asset.points.length - 1].y).toBe(firstBefore.y)
const kf = layer.keyframes[0]
expect(kf.props.points![kf.props.points!.length - 1].x).toBe(firstBefore.x)
})
test('setPolygonClosed toggles the closed flag', () => {
const { s, asset } = makePolygonLayer()
s.setPolygonClosed(asset.id, false)
expect(asset.closed).toBe(false)
s.setPolygonClosed(asset.id, true)
expect(asset.closed).toBe(true)
})
})
suite('freeform drawing — lifecycle', () => {
test('startFreeformDrawing creates asset and layer', () => {
const s = makeFreshStore()
s.startFreeformDrawing()
expect(s.drawingMode).toBe('freeform')
expect(s.drawingAssetId).toBeTruthy()
expect(s.drawingLayerId).toBeTruthy()
const asset = s.scene.assets.find((a) => a.id === s.drawingAssetId)!
expect(asset.kind).toBe('polygon')
expect((asset as PolygonAsset).closed).toBe(false)
})
test('addDrawingPoint appends to asset and to KF points', () => {
const s = makeFreshStore()
s.startFreeformDrawing()
s.addDrawingPoint(10, 20)
s.addDrawingPoint(30, 40)
const asset = s.scene.assets.find((a) => a.id === s.drawingAssetId) as PolygonAsset
expect(asset.points.length).toBe(2)
expect(asset.points[1].x).toBe(30)
const layer = s.activeLayers.find((l) => l.id === s.drawingLayerId)!
expect(layer.keyframes[0].props.points!.length).toBe(2)
})
test('finishDrawing with <2 points drops the in-progress asset', () => {
const s = makeFreshStore()
s.startFreeformDrawing()
const assetId = s.drawingAssetId!
const layerId = s.drawingLayerId!
s.addDrawingPoint(5, 5) // only 1 point
s.finishDrawing()
expect(s.drawingMode).toBeNull()
expect(s.scene.assets.some((a) => a.id === assetId)).toBe(false)
expect(s.activeLayers.some((l) => l.id === layerId)).toBe(false)
})
test('finishDrawing with >=2 points keeps the layer and clears drawing state', () => {
const s = makeFreshStore()
s.startFreeformDrawing()
s.addDrawingPoint(0, 0)
s.addDrawingPoint(50, 50)
const assetId = s.drawingAssetId!
const layerId = s.drawingLayerId!
s.finishDrawing()
expect(s.drawingMode).toBeNull()
expect(s.drawingAssetId).toBeNull()
expect(s.drawingLayerId).toBeNull()
expect(s.scene.assets.some((a) => a.id === assetId)).toBe(true)
expect(s.activeLayers.some((l) => l.id === layerId)).toBe(true)
})
test('cancelDrawing removes the in-progress asset and layer', () => {
const s = makeFreshStore()
s.startFreeformDrawing()
s.addDrawingPoint(0, 0)
s.addDrawingPoint(10, 10)
const assetId = s.drawingAssetId!
const layerId = s.drawingLayerId!
s.cancelDrawing()
expect(s.drawingMode).toBeNull()
expect(s.scene.assets.some((a) => a.id === assetId)).toBe(false)
expect(s.activeLayers.some((l) => l.id === layerId)).toBe(false)
})
test('startFreeformDrawing resumes on selected open polygon instead of creating new', () => {
const s = makeFreshStore()
s.startFreeformDrawing()
s.addDrawingPoint(0, 0)
s.addDrawingPoint(20, 20)
const assetId = s.drawingAssetId!
const layerId = s.drawingLayerId!
s.finishDrawing()
// Layer stays selected; invoke start again
s.selectLayer(layerId)
s.startFreeformDrawing()
expect(s.drawingMode).toBe('freeform')
expect(s.drawingAssetId).toBe(assetId)
expect(s.drawingLayerId).toBe(layerId)
})
})
suite('polygon + stroke — rendering', () => {
test('drawSceneFrame renders a filled closed polygon in fill color', () => {
const s = makeFreshStore()
s.scene.canvas.width = 100
s.scene.canvas.height = 100
s.scene.canvas.background = 'transparent'
const a = s.addAsset({
kind: 'polygon',
name: 'Tri',
points: [
{ x: 0, y: -30 },
{ x: 30, y: 20 },
{ x: -30, y: 20 },
],
fill: '#ff0000',
})
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 0, { x: 50, y: 50 })
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, s.scene, 0, {})
const inside = ctx.getImageData(50, 55, 1, 1).data
expect(inside[0] > 200).toBe(true) // red channel
expect(inside[3] > 200).toBe(true) // alpha
})
test('open polygon with stroke renders only the stroke path', () => {
const s = makeFreshStore()
s.scene.canvas.width = 100
s.scene.canvas.height = 100
s.scene.canvas.background = 'transparent'
const a = s.addAsset({
kind: 'polygon',
name: 'Line',
points: [
{ x: -40, y: 0 },
{ x: 40, y: 0 },
],
fill: 'transparent',
closed: false,
stroke: '#00ff00',
strokeWidth: 4,
})
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 0, { x: 50, y: 50 })
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, s.scene, 0, {})
const onLine = ctx.getImageData(50, 50, 1, 1).data
const offLine = ctx.getImageData(50, 80, 1, 1).data
expect(onLine[1] > 200).toBe(true) // green on line
expect(onLine[3] > 200).toBe(true)
expect(offLine[3]).toBe(0) // transparent elsewhere
})
test('rect with stroke renders the outline', () => {
const s = makeFreshStore()
s.scene.canvas.width = 100
s.scene.canvas.height = 100
s.scene.canvas.background = 'transparent'
const a = s.addAsset({
kind: 'rect',
name: 'R',
width: 40,
height: 40,
fill: 'transparent',
stroke: '#0000ff',
strokeWidth: 4,
})
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 0, { x: 50, y: 50 })
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, s.scene, 0, {})
// On the top edge of the rect
const onEdge = ctx.getImageData(50, 30, 1, 1).data
// Interior
const inside = ctx.getImageData(50, 50, 1, 1).data
expect(onEdge[2] > 200).toBe(true) // blue on edge
expect(inside[3]).toBe(0) // transparent interior
})
test('strokeWidth without explicit stroke falls back to black', () => {
const s = makeFreshStore()
s.scene.canvas.width = 100
s.scene.canvas.height = 100
s.scene.canvas.background = 'transparent'
const a = s.addAsset({
kind: 'rect',
name: 'R',
width: 40,
height: 40,
fill: 'transparent',
strokeWidth: 4,
})
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 0, { x: 50, y: 50 })
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, s.scene, 0, {})
const onEdge = ctx.getImageData(50, 30, 1, 1).data
// Black stroke: RGB all low, alpha high
expect(onEdge[0] < 40).toBe(true)
expect(onEdge[1] < 40).toBe(true)
expect(onEdge[2] < 40).toBe(true)
expect(onEdge[3] > 200).toBe(true)
})
test('transparent fill on closed polygon skips fill', () => {
const s = makeFreshStore()
s.scene.canvas.width = 100
s.scene.canvas.height = 100
s.scene.canvas.background = 'transparent'
const a = s.addAsset({
kind: 'polygon',
name: 'Hollow',
points: [
{ x: -30, y: -30 },
{ x: 30, y: -30 },
{ x: 30, y: 30 },
{ x: -30, y: 30 },
],
fill: 'transparent',
closed: true,
stroke: '#ff00ff',
strokeWidth: 3,
})
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 0, { x: 50, y: 50 })
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, s.scene, 0, {})
// Center should be fully transparent (no fill)
const center = ctx.getImageData(50, 50, 1, 1).data
expect(center[3]).toBe(0)
})
})
suite('bezier path — interpolation', () => {
test('sampleLayer returns quadratic bezier midpoint when ctrl is set', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'B', width: 10, height: 10, fill: '#111' })
const l = s.addLayer(a.id)
// Two keyframes: (0,0) at t=0 and (100,0) at t=1 with a control at (50,100).
s.upsertKeyframe(l.id, 0, { x: 0, y: 0 })
s.upsertKeyframe(l.id, 1, { x: 100, y: 0 })
const cur = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
s.setKeyframeControl(l.id, cur.id, 50, 100)
const samp = sampleLayer(l, 0.5)
// At u=0.5: B = 0.25*0 + 0.5*50 + 0.25*100 = 25 + 25 = 50 x; y = 0.5*100 = 50
expect(Math.abs(samp.x - 50) < 1e-6).toBe(true)
expect(Math.abs(samp.y - 50) < 1e-6).toBe(true)
})
test('bezier affects only x/y; other props remain linear', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'B', width: 10, height: 10, fill: '#111' })
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 0, { x: 0, y: 0, opacity: 0 })
s.upsertKeyframe(l.id, 1, { x: 100, y: 0, opacity: 1 })
const cur = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
s.setKeyframeControl(l.id, cur.id, 50, 200)
const samp = sampleLayer(l, 0.5)
expect(Math.abs(samp.opacity - 0.5) < 1e-6).toBe(true)
})
test('endpoints are not affected by control point', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'B', width: 10, height: 10, fill: '#111' })
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 0, { x: 10, y: 20 })
s.upsertKeyframe(l.id, 2, { x: 110, y: 200 })
const cur = l.keyframes.find((k) => Math.abs(k.time - 2) < 1e-3)!
s.setKeyframeControl(l.id, cur.id, 500, 500)
const start = sampleLayer(l, 0)
const end = sampleLayer(l, 2)
expect(start.x).toBe(10)
expect(start.y).toBe(20)
expect(end.x).toBe(110)
expect(end.y).toBe(200)
})
})
suite('keyframe control — store actions', () => {
test('setKeyframeControl stores ctrlX/Y on the keyframe', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'K', width: 10, height: 10, fill: '#111' })
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 1, { x: 50, y: 50 })
const kf = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
s.setKeyframeControl(l.id, kf.id, 30, 40)
expect(kf.ctrlX).toBe(30)
expect(kf.ctrlY).toBe(40)
})
test('clearKeyframeControl removes ctrlX/Y', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'K', width: 10, height: 10, fill: '#111' })
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 1, { x: 50, y: 50 })
const kf = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
s.setKeyframeControl(l.id, kf.id, 30, 40)
s.clearKeyframeControl(l.id, kf.id)
expect(kf.ctrlX).toBe(undefined)
expect(kf.ctrlY).toBe(undefined)
})
test('setKeyframeControlFromPoint solves so the curve passes through the target', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'K', width: 10, height: 10, fill: '#111' })
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 0, { x: 0, y: 0 })
s.upsertKeyframe(l.id, 1, { x: 100, y: 0 })
const cur = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
// We want the curve at u=0.5 to pass through (50, 80).
s.setKeyframeControlFromPoint(l.id, cur.id, 0.5, 50, 80)
const samp = sampleLayer(l, 0.5)
expect(Math.abs(samp.x - 50) < 1e-6).toBe(true)
expect(Math.abs(samp.y - 80) < 1e-6).toBe(true)
})
test('setKeyframeControlFromPoint is a no-op for endpoints (u=0 or u=1)', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'K', width: 10, height: 10, fill: '#111' })
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 0, { x: 0, y: 0 })
s.upsertKeyframe(l.id, 1, { x: 100, y: 0 })
const cur = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
s.setKeyframeControlFromPoint(l.id, cur.id, 0, 10, 10)
s.setKeyframeControlFromPoint(l.id, cur.id, 1, 99, 99)
expect(cur.ctrlX).toBe(undefined)
expect(cur.ctrlY).toBe(undefined)
})
test('setKeyframeControlFromPoint needs a previous keyframe', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'K', width: 10, height: 10, fill: '#111' })
const l = s.addLayer(a.id)
// Single keyframe at t=0 only
const kf = l.keyframes[0]
s.setKeyframeControlFromPoint(l.id, kf.id, 0.5, 20, 20)
expect(kf.ctrlX).toBe(undefined)
})
})
suite('clipart store — state', () => {
test('toggle flips open state', async () => {
const { useClipartStore } = await import('@/stores/clipart')
const s = useClipartStore()
s.$reset()
const start = s.open
s.toggle()
expect(s.open).toBe(!start)
s.toggle()
expect(s.open).toBe(start)
})
test('setOpen persists to localStorage', async () => {
const { useClipartStore } = await import('@/stores/clipart')
const s = useClipartStore()
s.setOpen(true)
expect(localStorage.getItem('animatr:clipartDrawerOpen')).toBe('1')
s.setOpen(false)
expect(localStorage.getItem('animatr:clipartDrawerOpen')).toBe('0')
})
test('clearFolder resets handle, folderName and items', async () => {
const { useClipartStore } = await import('@/stores/clipart')
const s = useClipartStore()
// simulate a loaded state
s.folderName = 'mock'
s.handle = {} as FileSystemDirectoryHandle
s.items = []
await s.clearFolder()
expect(s.handle).toBeNull()
expect(s.folderName).toBeNull()
expect(s.items.length).toBe(0)
})
test('supported flag matches FileSystemAccess helper', async () => {
const { useClipartStore } = await import('@/stores/clipart')
const { isFileSystemAccessSupported } = await import('@/lib/clipartLibrary')
const s = useClipartStore()
expect(s.supported).toBe(isFileSystemAccessSupported())
})
})
suite('polygon — vertex bezier actions', () => {
function makeSquare() {
const s = makeFreshStore()
const asset = s.addAsset({
kind: 'polygon',
name: 'Sq',
points: [
{ x: -40, y: -40 },
{ x: 40, y: -40 },
{ x: 40, y: 40 },
{ x: -40, y: 40 },
],
fill: '#abc',
}) as PolygonAsset
const layer = s.addLayer(asset.id)
return { s, asset, layer }
}
test('setPolygonVertexType curve sets type + default tangents', () => {
const { s, asset } = makeSquare()
s.setPolygonVertexType(asset.id, 1, 'curve')
const p = asset.points[1]
expect(p.type).toBe('curve')
expect(typeof p.outX).toBe('number')
expect(typeof p.outY).toBe('number')
expect(typeof p.inX).toBe('number')
expect(typeof p.inY).toBe('number')
// Curve symmetry: in = -out
expect(Math.abs((p.inX ?? 0) + (p.outX ?? 0)) < 1e-6).toBe(true)
expect(Math.abs((p.inY ?? 0) + (p.outY ?? 0)) < 1e-6).toBe(true)
})
test('setPolygonVertexType corner clears tangent fields', () => {
const { s, asset } = makeSquare()
s.setPolygonVertexType(asset.id, 2, 'curve')
s.setPolygonVertexType(asset.id, 2, 'corner')
const p = asset.points[2]
expect(p.type).toBe(undefined)
expect(p.inX).toBe(undefined)
expect(p.inY).toBe(undefined)
expect(p.outX).toBe(undefined)
expect(p.outY).toBe(undefined)
})
test('setPolygonVertexType accepts a list of indices', () => {
const { s, asset } = makeSquare()
s.setPolygonVertexType(asset.id, [0, 2], 'splitcurve')
expect(asset.points[0].type).toBe('splitcurve')
expect(asset.points[1].type).toBe(undefined)
expect(asset.points[2].type).toBe('splitcurve')
expect(asset.points[3].type).toBe(undefined)
})
test('setPolygonVertexTangent writes per-keyframe (curve mirrors sides)', async () => {
const { s, asset, layer } = makeSquare()
s.setPolygonVertexType(asset.id, 1, 'curve')
s.setPlayhead(0.5)
s.setPolygonVertexTangent(asset.id, 1, 'out', 20, 10)
await tick()
const kf = layer.keyframes.find((k) => Math.abs(k.time - 0.5) < 1e-3)!
const pt = kf.props.points![1]
expect(pt.outX).toBe(20)
expect(pt.outY).toBe(10)
expect(pt.inX).toBe(-20)
expect(pt.inY).toBe(-10)
})
test('setPolygonVertexTangent splitcurve keeps sides independent', async () => {
const { s, asset, layer } = makeSquare()
s.setPolygonVertexType(asset.id, 1, 'splitcurve')
s.setPlayhead(0)
s.setPolygonVertexTangent(asset.id, 1, 'out', 10, 0)
s.setPolygonVertexTangent(asset.id, 1, 'in', -3, 4)
await tick()
const kf = layer.keyframes.find((k) => Math.abs(k.time) < 1e-3)!
const pt = kf.props.points![1]
expect(pt.outX).toBe(10)
expect(pt.outY).toBe(0)
expect(pt.inX).toBe(-3)
expect(pt.inY).toBe(4)
})
test('tangent interpolation lerps between keyframes', async () => {
const { s, asset, layer } = makeSquare()
s.setPolygonVertexType(asset.id, 1, 'splitcurve')
s.setPlayhead(0)
s.setPolygonVertexTangent(asset.id, 1, 'out', 10, 0)
s.setPlayhead(1)
s.setPolygonVertexTangent(asset.id, 1, 'out', 30, 0)
await tick()
const mid = sampleLayer(layer, 0.5)
expect(Math.abs((mid.points?.[1].outX ?? 0) - 20) < 1e-6).toBe(true)
})
test('setPolygonVertexPositions updates multiple vertices in a single kf', async () => {
const { s, asset, layer } = makeSquare()
s.setPlayhead(0.2)
s.setPolygonVertexPositions(layer.id, [
{ index: 0, x: 100, y: 100 },
{ index: 2, x: -100, y: -100 },
])
await tick()
const kf = layer.keyframes.find((k) => Math.abs(k.time - 0.2) < 1e-3)!
expect(kf.props.points![0].x).toBe(100)
expect(kf.props.points![0].y).toBe(100)
expect(kf.props.points![2].x).toBe(-100)
expect(kf.props.points![2].y).toBe(-100)
// Others unchanged
expect(kf.props.points![1].x).toBe(asset.points[1].x)
expect(kf.props.points![1].y).toBe(asset.points[1].y)
})
test('setPolygonVertexPositions ignores out-of-range indices', async () => {
const { s, layer } = makeSquare()
s.setPlayhead(0)
s.setPolygonVertexPositions(layer.id, [
{ index: 99, x: 0, y: 0 },
{ index: -1, x: 0, y: 0 },
])
await tick()
// No keyframe was created or changed at playhead 0 beyond the initial one
expect(layer.keyframes.length).toBe(1)
})
})
suite('properties — fill/stroke transparent toggle', () => {
function findAlphaToggles(host: HTMLElement) {
return Array.from(
host.querySelectorAll<HTMLButtonElement>('button.alpha-toggle-btn'),
)
}
function resetSectionPrefs() {
// Ensure the Appearance section is open (default) regardless of any
// previous session's state persisted in localStorage.
try {
localStorage.removeItem('animatr:propSections')
} catch {
/* noop */
}
}
test('fill: toggle transparent on writes "transparent" to keyframe', async () => {
resetSectionPrefs()
const s = makeFreshStore()
const a = s.addAsset({
kind: 'rect',
name: 'R',
width: 50,
height: 50,
fill: '#ff0000',
})
const l = s.addLayer(a.id)
s.selectLayer(l.id)
const handle = mountComponent(PropertiesPanel, {})
await nextTick()
await tick()
const toggles = findAlphaToggles(handle.host)
const fillToggle = toggles[0]
expect(!!fillToggle).toBe(true)
fillToggle.click()
await tick()
const kf = l.keyframes[0]
expect(kf.props.fill).toBe('transparent')
handle.unmount()
})
test('fill: toggle off restores the previous non-transparent color', async () => {
resetSectionPrefs()
const s = makeFreshStore()
const a = s.addAsset({
kind: 'rect',
name: 'R',
width: 50,
height: 50,
fill: '#ff0000',
})
const l = s.addLayer(a.id)
s.selectLayer(l.id)
const handle = mountComponent(PropertiesPanel, {})
await nextTick()
await tick()
const toggles = findAlphaToggles(handle.host)
const fillToggle = toggles[0]
// On
fillToggle.click()
await tick()
expect(l.keyframes[0].props.fill).toBe('transparent')
// Off
fillToggle.click()
await tick()
const restored = l.keyframes[0].props.fill
expect(typeof restored).toBe('string')
expect(restored !== 'transparent').toBe(true)
// Should match the original fill (layer.base.fill was copied from asset)
expect(restored).toBe('#ff0000')
handle.unmount()
})
test('stroke: toggle on then off restores to a visible color (auto-black fallback)', async () => {
resetSectionPrefs()
const s = makeFreshStore()
// Rect without an explicit stroke — the restore should fall back to black
const a = s.addAsset({
kind: 'rect',
name: 'R',
width: 50,
height: 50,
fill: '#123456',
})
const l = s.addLayer(a.id)
s.selectLayer(l.id)
const handle = mountComponent(PropertiesPanel, {})
await nextTick()
await tick()
const toggles = findAlphaToggles(handle.host)
const strokeToggle = toggles[1]
expect(!!strokeToggle).toBe(true)
strokeToggle.click()
await tick()
expect(l.keyframes[0].props.stroke).toBe('transparent')
strokeToggle.click()
await tick()
const restored = l.keyframes[0].props.stroke
expect(typeof restored).toBe('string')
expect(restored !== 'transparent').toBe(true)
handle.unmount()
})
test('fill: toggle off respects a user-picked color before transparency', async () => {
resetSectionPrefs()
const s = makeFreshStore()
const a = s.addAsset({
kind: 'rect',
name: 'R',
width: 50,
height: 50,
fill: '#6ea8ff',
})
const l = s.addLayer(a.id)
s.selectLayer(l.id)
// User explicitly picked green before toggling transparent
s.upsertKeyframe(l.id, 0, { fill: '#00ff00' })
const handle = mountComponent(PropertiesPanel, {})
await nextTick()
await tick()
const toggles = findAlphaToggles(handle.host)
const fillToggle = toggles[0]
fillToggle.click()
await tick()
expect(l.keyframes[0].props.fill).toBe('transparent')
fillToggle.click()
await tick()
// Restore should be the user's green, not the asset default blue
expect(l.keyframes[0].props.fill).toBe('#00ff00')
handle.unmount()
})
})
suite('timeline — show locked tracks', () => {
test('toggling showLocked persists to localStorage', async () => {
const { showLocked } = await import('@/composables/useShowLocked')
const KEY = 'animatr:showLockedTracks'
showLocked.value = true
await tick()
expect(localStorage.getItem(KEY)).toBe('1')
showLocked.value = false
await tick()
expect(localStorage.getItem(KEY)).toBe('0')
showLocked.value = true
})
test('showLocked ref is shared across importers', async () => {
const a = await import('@/composables/useShowLocked')
const b = await import('@/composables/useShowLocked')
// Same module → same ref instance
expect(a.showLocked).toBe(b.showLocked)
a.showLocked.value = false
await tick()
expect(b.showLocked.value).toBe(false)
a.showLocked.value = true
})
})
suite('scene store — group lock', () => {
test('setTrackLocked on a group id sets group.locked', () => {
const s = makeFreshStore()
const g = s.addTrackGroup('G')
s.setTrackLocked(g.id, true)
const grp = s.scene.groups!.find((x) => x.id === g.id)!
expect(grp.locked).toBe(true)
})
test('setTrackLocked(false) clears group.locked', () => {
const s = makeFreshStore()
const g = s.addTrackGroup('G')
s.setTrackLocked(g.id, true)
s.setTrackLocked(g.id, false)
const grp = s.scene.groups!.find((x) => x.id === g.id)!
expect(grp.locked).toBe(undefined)
})
test('setTrackLocked dispatches to layer, clip or group based on id', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#111' })
const l = s.addLayer(a.id)
const ct = s.addClipTrack()
const g = s.addTrackGroup('G')
s.setTrackLocked(l.id, true)
s.setTrackLocked(ct.id, true)
s.setTrackLocked(g.id, true)
expect(l.locked).toBe(true)
const stored = s.scene.clipTracks!.find((x) => x.id === ct.id)!
expect(stored.locked).toBe(true)
const grp = s.scene.groups!.find((x) => x.id === g.id)!
expect(grp.locked).toBe(true)
})
test('locking a group does not mutate child layer.locked', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#111' })
const l = s.addLayer(a.id)
const g = s.addTrackGroup('G')
s.moveTrackIntoGroup(l.id, g.id)
s.setTrackLocked(g.id, true)
// The layer itself stays unlocked; effective lock is derived in UI code.
expect(l.locked).toBe(undefined)
})
})
suite('render — crop option', () => {
function setupStraddlingScene() {
const s = makeFreshStore()
s.scene.canvas.width = 100
s.scene.canvas.height = 100
s.scene.canvas.background = 'transparent'
const a = s.addAsset({
kind: 'rect',
name: 'Wide',
width: 60,
height: 20,
fill: '#00ff00',
})
const layer = s.addLayer(a.id)
// Centered just off the right edge — 40px of the rect spills past x=100.
s.upsertKeyframe(layer.id, 0, { x: 110, y: 50 })
return s
}
test('default crop clips content outside stage bounds', () => {
const s = setupStraddlingScene()
const canvas = document.createElement('canvas')
canvas.width = 200
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, s.scene, 0, {})
// Sample a pixel ~20px past the right edge — still inside the rect's
// visual box but outside the stage. Should be cleared by the clip.
const outside = ctx.getImageData(120, 50, 1, 1).data
expect(outside[3]).toBe(0)
})
test('crop:false lets content render past the stage edge', () => {
const s = setupStraddlingScene()
const canvas = document.createElement('canvas')
canvas.width = 200
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, s.scene, 0, {}, { crop: false })
const outside = ctx.getImageData(120, 50, 1, 1).data
// Green channel visible in the part of the rect that extends past the stage
expect(outside[1] > 200).toBe(true)
expect(outside[3] > 200).toBe(true)
})
test('crop:false still paints stage background inside stage bounds', () => {
const s = makeFreshStore()
s.scene.canvas.width = 100
s.scene.canvas.height = 100
s.scene.canvas.background = '#ff0000'
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, s.scene, 0, {}, { crop: false })
const inside = ctx.getImageData(50, 50, 1, 1).data
expect(inside[0] > 200).toBe(true)
expect(inside[3] > 200).toBe(true)
})
test('mask still applies when crop is disabled', () => {
// Same setup as the positive-mask render test but with crop:false —
// the mask should still clear content outside the mask shape, and the
// crop flag should only affect the stage clip, not the masked group.
const s = makeFreshStore()
s.scene.canvas.width = 100
s.scene.canvas.height = 100
s.scene.canvas.background = 'transparent'
const content = s.addAsset({
kind: 'rect',
name: 'BG',
width: 100,
height: 100,
fill: '#ff0000',
})
const contentLayer = s.addLayer(content.id)
s.upsertKeyframe(contentLayer.id, 0, { x: 50, y: 50 })
const mask = s.addAsset({
kind: 'circle',
name: 'M',
radius: 20,
fill: '#ffffff',
})
const maskLayer = s.addLayer(mask.id)
s.upsertKeyframe(maskLayer.id, 0, { x: 50, y: 50 })
s.setLayerMask(maskLayer.id, true, 'positive')
const group = s.addTrackGroup('G')
s.moveTrackIntoGroup(maskLayer.id, group.id)
s.moveTrackIntoGroup(contentLayer.id, group.id)
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, s.scene, 0, {}, { crop: false })
const inside = ctx.getImageData(50, 50, 1, 1).data
const outside = ctx.getImageData(5, 5, 1, 1).data
// Positive mask keeps content inside
expect(inside[0] > 200).toBe(true)
expect(inside[3] > 200).toBe(true)
// Outside mask cleared by the mask (even without stage clipping)
expect(outside[3]).toBe(0)
})
test('content outside the stage renders past the edge when crop:false and mask-like groups exist elsewhere', () => {
// A straddling rect + a totally separate positive-mask group. The
// straddling rect must still bleed past the stage edge and the mask
// must still work inside its own group — they don't interfere.
const s = makeFreshStore()
s.scene.canvas.width = 100
s.scene.canvas.height = 100
s.scene.canvas.background = 'transparent'
// Straddling rect — spills past right edge
const strad = s.addAsset({
kind: 'rect',
name: 'Wide',
width: 60,
height: 20,
fill: '#00ff00',
})
const sLayer = s.addLayer(strad.id)
s.upsertKeyframe(sLayer.id, 0, { x: 110, y: 50 })
// Masked group elsewhere
const content = s.addAsset({
kind: 'rect',
name: 'Blue',
width: 40,
height: 40,
fill: '#0000ff',
})
const cLayer = s.addLayer(content.id)
s.upsertKeyframe(cLayer.id, 0, { x: 50, y: 50 })
const mask = s.addAsset({
kind: 'circle',
name: 'M',
radius: 10,
fill: '#fff',
})
const mLayer = s.addLayer(mask.id)
s.upsertKeyframe(mLayer.id, 0, { x: 50, y: 50 })
s.setLayerMask(mLayer.id, true, 'positive')
const group = s.addTrackGroup('G')
s.moveTrackIntoGroup(mLayer.id, group.id)
s.moveTrackIntoGroup(cLayer.id, group.id)
const canvas = document.createElement('canvas')
canvas.width = 200
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, s.scene, 0, {}, { crop: false })
// Straddling rect's spill past x=100 is visible
const spill = ctx.getImageData(120, 50, 1, 1).data
expect(spill[1] > 200).toBe(true)
expect(spill[3] > 200).toBe(true)
// Mask still trims the blue rect outside its circle.
// (32, 50) is inside the blue rect (30–70) but outside the 10-radius
// mask circle (distance ~18), and well away from the green rect
// (80–140), so it must be cleared by the mask.
const maskOutside = ctx.getImageData(32, 50, 1, 1).data
expect(maskOutside[3]).toBe(0)
})
})
suite('scene store — playback range (in/out)', () => {
test('defaults: playbackIn=0, playbackOut=duration', () => {
const s = makeFreshStore()
expect(s.playbackIn).toBe(0)
expect(s.playbackOut).toBe(s.scene.duration)
expect(s.scene.inTime).toBe(undefined)
expect(s.scene.outTime).toBe(undefined)
})
test('setPlaybackRange clamps values into [0, duration]', () => {
const s = makeFreshStore()
s.setDuration(10)
s.setPlaybackRange(-5, 20)
expect(s.playbackIn).toBe(0)
expect(s.playbackOut).toBe(10)
})
test('setPlaybackRange keeps out >= in', () => {
const s = makeFreshStore()
s.setDuration(10)
s.setPlaybackRange(6, null)
s.setPlaybackRange(null, 3)
// out is clamped up to in (6), not down to 3
expect(s.playbackOut).toBe(6)
})
test('setPlaybackRange clamps playhead into new range', () => {
const s = makeFreshStore()
s.setDuration(10)
s.setPlayhead(1)
s.setPlaybackRange(4, 8)
expect(s.playhead).toBe(4)
s.setPlayhead(9)
// tick isn't involved; setPlayhead itself doesn't clamp to range, so
// push it with setPlaybackRange again:
s.setPlaybackRange(4, 7)
expect(s.playhead).toBe(7)
})
test('setPlaybackRange null for both leaves scene alone', () => {
const s = makeFreshStore()
s.setPlaybackRange(2, 5)
s.setPlaybackRange(null, null)
expect(s.playbackIn).toBe(2)
expect(s.playbackOut).toBe(5)
})
test('togglePlay snaps playhead to playbackIn when outside range', async () => {
const s = makeFreshStore()
s.setDuration(10)
s.setPlaybackRange(3, 7)
s.setPlayhead(1) // before in
await tick()
s.togglePlay()
expect(s.playhead).toBe(3)
expect(s.playing).toBe(true)
s.pause()
await tick()
})
test('tick stops exactly at playbackOut within range', async () => {
const s = makeFreshStore()
s.setDuration(10)
s.setPlaybackRange(2, 5)
s.setPlayhead(2)
s.play()
await tick()
s.tick(4) // would put playhead at 6, past out=5
await tick()
expect(s.playhead).toBe(5)
expect(s.playing).toBe(false)
})
test('setPlaybackRange(0, null) deletes inTime when outTime is unset', () => {
const s = makeFreshStore()
s.setDuration(10)
s.setPlaybackRange(3, null)
expect(s.scene.inTime).toBe(3)
s.setPlaybackRange(0, null)
expect(s.scene.inTime).toBe(undefined)
})
test('setPlaybackRange(null, duration) deletes outTime when inTime is unset', () => {
const s = makeFreshStore()
s.setDuration(10)
s.setPlaybackRange(null, 7)
expect(s.scene.outTime).toBe(7)
s.setPlaybackRange(null, 10)
expect(s.scene.outTime).toBe(undefined)
})
})
suite('scene store — blank scene & normalization', () => {
test('createBlankScene has 1280x720 white bg and 10s duration', () => {
const s = createBlankScene()
expect(s.version).toBe(1)
expect(s.canvas.width).toBe(1280)
expect(s.canvas.height).toBe(720)
expect(s.canvas.background).toBe('#ffffff')
expect(s.duration).toBe(10)
expect(s.fps).toBe(60)
expect(s.assets).toHaveLength(0)
expect(s.layers).toHaveLength(0)
})
test('loadScene(createBlankScene()) resets selection, playhead, playing', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#f00' })
s.addLayer(a.id)
s.setPlayhead(3)
s.play()
await tick()
s.loadScene(createBlankScene())
expect(s.scene.layers).toHaveLength(0)
expect(s.scene.assets).toHaveLength(0)
expect(s.scene.canvas.background).toBe('#ffffff')
expect(s.playhead).toBe(0)
expect(s.playing).toBe(false)
expect(s.selectedLayerId).toBe(null)
})
test('loadScene fills in canvas.background when missing', () => {
const s = makeFreshStore()
const broken = {
version: 1 as const,
canvas: { width: 640, height: 480, background: '' },
duration: 5,
fps: 30,
assets: [],
layers: [],
}
s.loadScene(broken)
expect(s.scene.canvas.background).toBe('#ffffff')
})
})
suite('scene store — mask defaults', () => {
test('setLayerMask(true) defaults to negative mask mode', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'M', width: 40, height: 40, fill: '#fff' })
const l = s.addLayer(a.id)
s.setLayerMask(l.id, true)
const layer = s.scene.layers.find((x) => x.id === l.id)!
expect(layer.isMask).toBe(true)
expect(layer.maskMode).toBe('negative')
})
})
suite('render — top-level masks cascade', () => {
test('top-level positive mask clears content outside the mask shape', () => {
const s = makeFreshStore()
s.scene.canvas.width = 100
s.scene.canvas.height = 100
s.scene.canvas.background = 'transparent'
// Full-stage red rect
const content = s.addAsset({
kind: 'rect',
name: 'BG',
width: 100,
height: 100,
fill: '#ff0000',
})
const contentLayer = s.addLayer(content.id)
s.upsertKeyframe(contentLayer.id, 0, { x: 50, y: 50 })
// Mask layer above it in the stack (top-level, NO group)
const mask = s.addAsset({
kind: 'circle',
name: 'M',
radius: 20,
fill: '#ffffff',
})
const maskLayer = s.addLayer(mask.id)
s.upsertKeyframe(maskLayer.id, 0, { x: 50, y: 50 })
s.setLayerMask(maskLayer.id, true, 'positive')
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, s.scene, 0, {})
// Inside mask circle → red kept
const inside = ctx.getImageData(50, 50, 1, 1).data
expect(inside[0] > 200).toBe(true)
expect(inside[3] > 200).toBe(true)
// Outside mask circle → cleared
const outside = ctx.getImageData(5, 5, 1, 1).data
expect(outside[3]).toBe(0)
})
test('top-level negative mask cuts a hole in the content below', () => {
const s = makeFreshStore()
s.scene.canvas.width = 100
s.scene.canvas.height = 100
s.scene.canvas.background = 'transparent'
const content = s.addAsset({
kind: 'rect',
name: 'BG',
width: 100,
height: 100,
fill: '#ff0000',
})
const contentLayer = s.addLayer(content.id)
s.upsertKeyframe(contentLayer.id, 0, { x: 50, y: 50 })
const mask = s.addAsset({
kind: 'circle',
name: 'M',
radius: 15,
fill: '#ffffff',
})
const maskLayer = s.addLayer(mask.id)
s.upsertKeyframe(maskLayer.id, 0, { x: 50, y: 50 })
s.setLayerMask(maskLayer.id, true, 'negative')
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, s.scene, 0, {})
// Inside the circle → hole (cleared)
const inside = ctx.getImageData(50, 50, 1, 1).data
expect(inside[3]).toBe(0)
// Outside circle → red kept
const outside = ctx.getImageData(5, 5, 1, 1).data
expect(outside[0] > 200).toBe(true)
expect(outside[3] > 200).toBe(true)
})
})
suite('scene store — markers', () => {
test('addMarker defaults the name when none provided', () => {
const s = makeFreshStore()
const m = s.addMarker(2)
expect(m.name).toBe('Marker')
expect(m.time).toBe(2)
expect(s.scene.markers).toHaveLength(1)
})
test('addMarker de-dupes colliding names with suffix', () => {
const s = makeFreshStore()
s.addMarker(1, 'Intro')
s.addMarker(2, 'Intro')
s.addMarker(3, 'Intro')
const names = (s.scene.markers ?? []).map((m) => m.name)
expect(names).toEqual(['Intro', 'Intro 2', 'Intro 3'])
})
test('addMarker clamps time to [0, duration]', () => {
const s = makeFreshStore()
s.setDuration(10)
const a = s.addMarker(-5)
const b = s.addMarker(99)
expect(a.time).toBe(0)
expect(b.time).toBe(10)
})
test('addMarker keeps the list sorted by time', () => {
const s = makeFreshStore()
s.addMarker(5, 'C')
s.addMarker(1, 'A')
s.addMarker(3, 'B')
const times = (s.scene.markers ?? []).map((m) => m.time)
expect(times).toEqual([1, 3, 5])
})
test('renameMarker succeeds on unique, rejects duplicates', () => {
const s = makeFreshStore()
const a = s.addMarker(1, 'A')
s.addMarker(2, 'B')
expect(s.renameMarker(a.id, 'A2')).toBe(true)
expect(s.renameMarker(a.id, 'B')).toBe(false)
const byId = (s.scene.markers ?? []).find((m) => m.id === a.id)
expect(byId?.name).toBe('A2')
})
test('removeMarker drops the entry', () => {
const s = makeFreshStore()
const m = s.addMarker(1)
s.removeMarker(m.id)
expect(s.scene.markers).toHaveLength(0)
})
test('setMarkerTime clamps and re-sorts', () => {
const s = makeFreshStore()
s.setDuration(10)
const a = s.addMarker(1, 'A')
const b = s.addMarker(2, 'B')
s.setMarkerTime(a.id, 99)
const sortedNames = (s.scene.markers ?? []).map((m) => m.name)
expect(sortedNames).toEqual(['B', 'A'])
const updated = (s.scene.markers ?? []).find((m) => m.id === a.id)!
expect(updated.time).toBe(10)
void b
})
test('setMarkerColor sets and clears', () => {
const s = makeFreshStore()
const m = s.addMarker(1, 'A')
s.setMarkerColor(m.id, '#ff8800')
const get = () => s.scene.markers?.find((x) => x.id === m.id)
expect(get()?.color).toBe('#ff8800')
s.setMarkerColor(m.id, null)
expect(get()?.color).toBe(undefined)
})
})
suite('scene store — script tracks & frames', () => {
test('addScriptTrack appends and adds to trackOrder', () => {
const s = makeFreshStore()
const t = s.addScriptTrack('Triggers')
expect(s.scene.scriptTracks).toHaveLength(1)
expect(s.scene.trackOrder?.includes(t.id)).toBe(true)
})
test('addScriptFrame clamps time and returns frame', () => {
const s = makeFreshStore()
s.setDuration(10)
const t = s.addScriptTrack()
const f = s.addScriptFrame(t.id, 99, [{ type: 'pause' }])
expect(f).toBeTruthy()
expect(f!.time).toBe(10)
expect(f!.actions).toHaveLength(1)
})
test('addScriptFrame returns null for unknown trackId', () => {
const s = makeFreshStore()
const f = s.addScriptFrame('no-such-id', 1)
expect(f).toBe(null)
})
test('script frames stay sorted by time', () => {
const s = makeFreshStore()
const t = s.addScriptTrack()
s.addScriptFrame(t.id, 5)
s.addScriptFrame(t.id, 1)
s.addScriptFrame(t.id, 3)
const times = (s.scene.scriptFrames ?? []).map((f) => f.time)
expect(times).toEqual([1, 3, 5])
})
test('removeScriptTrack cascades its frames and trackOrder entry', () => {
const s = makeFreshStore()
const t = s.addScriptTrack()
s.addScriptFrame(t.id, 1)
s.addScriptFrame(t.id, 2)
s.removeScriptTrack(t.id)
expect(s.scene.scriptTracks).toHaveLength(0)
expect(s.scene.scriptFrames).toHaveLength(0)
expect(s.scene.trackOrder?.includes(t.id)).toBe(false)
})
test('setScriptFrameActions replaces actions', () => {
const s = makeFreshStore()
const t = s.addScriptTrack()
const f = s.addScriptFrame(t.id, 1, [{ type: 'play' }])!
s.setScriptFrameActions(f.id, [{ type: 'pause' }, { type: 'rewindToLoop' }])
const updated = s.scene.scriptFrames?.find((x) => x.id === f.id)
expect(updated?.actions).toHaveLength(2)
expect(updated?.actions[0].type).toBe('pause')
})
test('setScriptFrameTime clamps and re-sorts', () => {
const s = makeFreshStore()
s.setDuration(10)
const t = s.addScriptTrack()
const a = s.addScriptFrame(t.id, 1)!
const b = s.addScriptFrame(t.id, 2)!
s.setScriptFrameTime(a.id, 99)
const times = (s.scene.scriptFrames ?? []).map((f) => f.time)
expect(times).toEqual([2, 10])
void b
})
})
suite('scene store — button clips & instances', () => {
test('addButtonClip creates 4 empty state stacks', () => {
const s = makeFreshStore()
const bc = s.addButtonClip('Play')
expect(bc.name).toBe('Play')
expect(bc.states.normal).toHaveLength(0)
expect(bc.states.hover).toHaveLength(0)
expect(bc.states.pressed).toHaveLength(0)
expect(bc.states.disabled).toHaveLength(0)
})
test('addButtonInstance validates clip and track ids', () => {
const s = makeFreshStore()
const bc = s.addButtonClip('B')
const ct = s.addClipTrack()
expect(s.addButtonInstance('no-clip', ct.id, 0)).toBe(null)
expect(s.addButtonInstance(bc.id, 'no-track', 0)).toBe(null)
const inst = s.addButtonInstance(bc.id, ct.id, 0)
expect(inst).toBeTruthy()
expect(s.scene.buttonInstances).toHaveLength(1)
})
test('removeButtonClip cascades its instances', () => {
const s = makeFreshStore()
const bc = s.addButtonClip('B')
const ct = s.addClipTrack()
s.addButtonInstance(bc.id, ct.id, 0)
s.addButtonInstance(bc.id, ct.id, 1)
s.removeButtonClip(bc.id)
expect(s.scene.buttonClips).toHaveLength(0)
expect(s.scene.buttonInstances).toHaveLength(0)
})
test('setButtonInstanceLabel enforces scene-wide uniqueness', () => {
const s = makeFreshStore()
const bc = s.addButtonClip('B')
const ct = s.addClipTrack()
const a = s.addButtonInstance(bc.id, ct.id, 0)!
const b = s.addButtonInstance(bc.id, ct.id, 1)!
expect(s.setButtonInstanceLabel(a.id, 'play')).toBe(true)
expect(s.setButtonInstanceLabel(b.id, 'play')).toBe(false)
expect(s.setButtonInstanceLabel(b.id, 'mute')).toBe(true)
const getA = s.scene.buttonInstances?.find((x) => x.id === a.id)
const getB = s.scene.buttonInstances?.find((x) => x.id === b.id)
expect(getA?.label).toBe('play')
expect(getB?.label).toBe('mute')
})
test('seedDefaultButtonClip produces four populated state stacks', () => {
const s = makeFreshStore()
const bc = s.seedDefaultButtonClip('Play')
expect(bc.states.normal).toHaveLength(1)
expect(bc.states.hover).toHaveLength(1)
expect(bc.states.pressed).toHaveLength(1)
expect(bc.states.disabled).toHaveLength(1)
// Each state has its own face layer, all pointing at the same asset.
const assetIds = new Set(
[...bc.states.normal, ...bc.states.hover, ...bc.states.pressed, ...bc.states.disabled]
.map((l) => l.assetId),
)
expect(assetIds.size).toBe(1)
// Layer names consistent so transitions can match across states.
for (const state of ['normal', 'hover', 'pressed', 'disabled'] as const) {
expect(bc.states[state][0].name).toBe('Face')
}
})
test('setButtonInstanceOnClick + setButtonInstanceDisabled', () => {
const s = makeFreshStore()
const bc = s.addButtonClip('B')
const ct = s.addClipTrack()
const inst = s.addButtonInstance(bc.id, ct.id, 0)!
s.setButtonInstanceOnClick(inst.id, [{ type: 'pause' }])
s.setButtonInstanceDisabled(inst.id, true)
const get = () => s.scene.buttonInstances?.find((x) => x.id === inst.id)
expect(get()?.onClick).toHaveLength(1)
expect(get()?.disabled).toBe(true)
s.setButtonInstanceOnClick(inst.id, [])
s.setButtonInstanceDisabled(inst.id, false)
expect(get()?.onClick).toBe(undefined)
expect(get()?.disabled).toBe(undefined)
})
})
suite('actions — evalAction with mock runtime', () => {
function makeMockRuntime(init?: Partial<ActionRuntime>): ActionRuntime & {
calls: string[]
playhead: number
playing: boolean
buttonStates: Record<string, string>
buttonDisabled: Record<string, boolean>
warnings: string[]
} {
const rt = {
fps: 60,
playbackIn: 0,
playbackOut: 10,
duration: 10,
markers: { intro: 2, outro: 8 },
playhead: 0,
playing: false,
buttonStates: {} as Record<string, string>,
buttonDisabled: {} as Record<string, boolean>,
warnings: [] as string[],
calls: [] as string[],
setPlayhead(t: number) {
this.calls.push(`setPlayhead:${t}`)
this.playhead = t
},
play() {
this.calls.push('play')
this.playing = true
},
pause() {
this.calls.push('pause')
this.playing = false
},
setButtonState(id: string, state: string) {
this.calls.push(`btnState:${id}=${state}`)
this.buttonStates[id] = state
},
setButtonDisabled(id: string, d: boolean) {
this.calls.push(`btnDisabled:${id}=${d}`)
this.buttonDisabled[id] = d
},
warn(msg: string) {
this.warnings.push(msg)
},
...init,
}
return rt as ReturnType<typeof makeMockRuntime>
}
test('play / pause / rewindToLoop', () => {
const rt = makeMockRuntime({ playbackIn: 2 })
evalAction({ type: 'play' }, rt)
evalAction({ type: 'pause' }, rt)
evalAction({ type: 'rewindToLoop' }, rt)
expect(rt.playing).toBe(false)
expect(rt.playhead).toBe(2)
expect(rt.calls).toEqual(['play', 'pause', 'setPlayhead:2'])
})
test('jumpTo clamps to [0, duration]', () => {
const rt = makeMockRuntime()
evalAction({ type: 'jumpTo', time: -3 }, rt)
expect(rt.playhead).toBe(0)
evalAction({ type: 'jumpTo', time: 99 }, rt)
expect(rt.playhead).toBe(10)
evalAction({ type: 'jumpTo', time: 5 }, rt)
expect(rt.playhead).toBe(5)
})
test('jumpToFrame divides by fps', () => {
const rt = makeMockRuntime({ fps: 30 })
evalAction({ type: 'jumpToFrame', frame: 90 }, rt)
expect(rt.playhead).toBe(3)
})
test('jumpToMarker resolves via markers map', () => {
const rt = makeMockRuntime()
evalAction({ type: 'jumpToMarker', name: 'outro' }, rt)
expect(rt.playhead).toBe(8)
})
test('jumpToMarker warns on unknown name and does not move playhead', () => {
const rt = makeMockRuntime()
const before = rt.playhead
evalAction({ type: 'jumpToMarker', name: 'missing' }, rt)
expect(rt.playhead).toBe(before)
expect(rt.warnings.length).toBe(1)
expect(rt.warnings[0]).toContain('missing')
})
test('setButtonState + setButtonDisabled dispatch to runtime', () => {
const rt = makeMockRuntime()
evalAction({ type: 'setButtonState', instanceId: 'b1', state: 'pressed' }, rt)
evalAction({ type: 'setButtonDisabled', instanceId: 'b2', disabled: true }, rt)
expect(rt.buttonStates.b1).toBe('pressed')
expect(rt.buttonDisabled.b2).toBe(true)
})
test("script action warns that DSL isn't implemented yet", () => {
const rt = makeMockRuntime()
evalAction({ type: 'script', source: 'play' }, rt)
expect(rt.warnings.length).toBe(1)
})
test('buildMarkerMap maps name → time', () => {
const map = buildMarkerMap([
{ id: 'a', name: 'intro', time: 1 },
{ id: 'b', name: 'outro', time: 5 },
])
expect(map.intro).toBe(1)
expect(map.outro).toBe(5)
})
test('scriptFramesInRange returns only forward frames, sorted', () => {
const frames = [
{ id: 'a', trackId: 't', time: 3, actions: [] as Action[] },
{ id: 'b', trackId: 't', time: 1, actions: [] as Action[] },
{ id: 'c', trackId: 't', time: 6, actions: [] as Action[] },
]
// (0, 4] → only 1 and 3, sorted
const got = scriptFramesInRange(frames, 0, 4).map((f) => f.id)
expect(got).toEqual(['b', 'a'])
// backwards → empty
expect(scriptFramesInRange(frames, 5, 1)).toHaveLength(0)
})
test('scriptFramesInRange uses open-closed interval', () => {
const frames = [{ id: 'a', trackId: 't', time: 2, actions: [] as Action[] }]
// prev=2 (exclusive), next=2 → empty
expect(scriptFramesInRange(frames, 2, 2)).toHaveLength(0)
// prev=1, next=2 → includes 2
expect(scriptFramesInRange(frames, 1, 2)).toHaveLength(1)
})
})
suite('scene store — tick fires script frames forward-only', () => {
test('tick fires frames crossed during forward advance in time order', async () => {
const s = makeFreshStore()
s.setDuration(10)
const t = s.addScriptTrack()
s.addScriptFrame(t.id, 2, [{ type: 'pause' }])
s.addScriptFrame(t.id, 1, [{ type: 'jumpTo', time: 9 }])
s.play()
// From 0 → 2: both frames get crossed; time-1 frame fires first and
// jumps the playhead to 9, time-2 frame fires second and pauses.
s.tick(2)
expect(s.playhead).toBe(9)
expect(s.playing).toBe(false)
await tick()
})
test('setPlayhead alone does not fire frames', async () => {
const s = makeFreshStore()
s.setDuration(10)
const t = s.addScriptTrack()
s.addScriptFrame(t.id, 2, [{ type: 'pause' }])
s.play()
s.setPlayhead(5) // scrub over the frame without ticking
expect(s.playing).toBe(true)
s.pause()
await tick()
})
test('frames fire even after natural stop at playbackOut', async () => {
const s = makeFreshStore()
s.setDuration(10)
s.setPlaybackRange(0, 5)
const t = s.addScriptTrack()
s.addScriptFrame(t.id, 5, [{ type: 'rewindToLoop' }])
s.play()
s.tick(6) // lands at playbackOut=5, playing=false, frame at 5 fires
expect(s.playing).toBe(false)
expect(s.playhead).toBe(0) // rewindToLoop → playbackIn=0
await tick()
})
})
suite('scriptDsl — parser', () => {
test('parses bare commands', () => {
const { actions, errors } = parseScript('play\npause\nrewind')
expect(errors).toHaveLength(0)
expect(actions.map((a) => a.type)).toEqual(['play', 'pause', 'rewindToLoop'])
})
test('skips blank lines and comments', () => {
const src = [
'// leading comment',
'',
'play // trailing comment',
' ',
'pause',
].join('\n')
const { actions, errors } = parseScript(src)
expect(errors).toHaveLength(0)
expect(actions.map((a) => a.type)).toEqual(['play', 'pause'])
})
test('jump accepts seconds, #frame, and @marker', () => {
const { actions, errors } = parseScript('jump 2.5\njump #60\njump @intro')
expect(errors).toHaveLength(0)
expect(actions[0]).toEqual({ type: 'jumpTo', time: 2.5 })
expect(actions[1]).toEqual({ type: 'jumpToFrame', frame: 60 })
expect(actions[2]).toEqual({ type: 'jumpToMarker', name: 'intro' })
})
test('jump reports invalid arguments', () => {
const { actions, errors } = parseScript('jump\njump abc\njump #xx\njump @')
expect(actions).toHaveLength(0)
expect(errors).toHaveLength(4)
})
test('state requires btn:<label> and a valid state', () => {
const good = parseScript('state btn:play pressed')
expect(good.errors).toHaveLength(0)
expect(good.actions[0]).toEqual({
type: 'setButtonState',
instanceId: 'play',
state: 'pressed',
})
const bad1 = parseScript('state play pressed')
expect(bad1.errors).toHaveLength(1)
const bad2 = parseScript('state btn:play weird')
expect(bad2.errors).toHaveLength(1)
})
test('enable / disable map to setButtonDisabled', () => {
const { actions } = parseScript('enable btn:play\ndisable btn:play')
expect(actions[0]).toEqual({
type: 'setButtonDisabled',
instanceId: 'play',
disabled: false,
})
expect(actions[1]).toEqual({
type: 'setButtonDisabled',
instanceId: 'play',
disabled: true,
})
})
test('unknown commands report error, other lines still parse', () => {
const { actions, errors } = parseScript('play\nwat\npause')
expect(errors).toHaveLength(1)
expect(errors[0].line).toBe(2)
expect(actions.map((a) => a.type)).toEqual(['play', 'pause'])
})
})
suite('actions — script type expands via evalAction', () => {
test('script source is parsed and each action dispatched in order', () => {
const calls: string[] = []
const rt: ActionRuntime = {
fps: 60,
playbackIn: 0,
playbackOut: 10,
duration: 10,
markers: { intro: 2 },
setPlayhead: (t) => calls.push(`seek:${t}`),
play: () => calls.push('play'),
pause: () => calls.push('pause'),
setButtonState: (id, s) => calls.push(`state:${id}=${s}`),
setButtonDisabled: (id, d) => calls.push(`disabled:${id}=${d}`),
warn: () => {},
}
evalAction(
{
type: 'script',
source: 'pause\njump @intro\nstate btn:play pressed',
},
rt,
)
expect(calls).toEqual(['pause', 'seek:2', 'state:play=pressed'])
})
test('script errors surface via rt.warn, valid actions still fire', () => {
const warnings: string[] = []
const fired: string[] = []
const rt: ActionRuntime = {
fps: 60,
playbackIn: 0,
playbackOut: 10,
duration: 10,
markers: {},
setPlayhead: () => {},
play: () => fired.push('play'),
pause: () => fired.push('pause'),
setButtonState: () => {},
setButtonDisabled: () => {},
warn: (m) => warnings.push(m),
}
evalAction({ type: 'script', source: 'play\nwat\npause' }, rt)
expect(fired).toEqual(['play', 'pause'])
expect(warnings).toHaveLength(1)
expect(warnings[0]).toContain('wat')
})
})
suite('scene store — macro model normalization', () => {
test('loadScene initializes new arrays when missing', () => {
const s = makeFreshStore()
const bare = {
version: 1 as const,
canvas: { width: 640, height: 480, background: '#ffffff' },
duration: 5,
fps: 30,
assets: [],
layers: [],
}
s.loadScene(bare)
expect(s.scene.buttonClips).toEqual([])
expect(s.scene.buttonInstances).toEqual([])
expect(s.scene.scriptTracks).toEqual([])
expect(s.scene.scriptFrames).toEqual([])
expect(s.scene.markers).toEqual([])
})
test('setTrackLocked also works on script tracks', () => {
const s = makeFreshStore()
const t = s.addScriptTrack()
s.setTrackLocked(t.id, true)
expect(s.scene.scriptTracks?.find((x) => x.id === t.id)?.locked).toBe(true)
s.setTrackLocked(t.id, false)
expect(s.scene.scriptTracks?.find((x) => x.id === t.id)?.locked).toBe(undefined)
})
})
}