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 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)[prop as string | symbol] = value return true }, get(target, prop) { const v = (target as unknown as Record)[ 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, slots?: Record, ): 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 { 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).x).toBe(undefined) expect((k.props as Record).y).toBe(undefined) expect((k.props as Record).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).x).toBe(400) expect((at1.props as Record).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).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).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).x).toBe(undefined) expect((rotKf.props as Record).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).x delete (kf.props as Record).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('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 & { calls: string[] playhead: number playing: boolean buttonStates: Record buttonDisabled: Record warnings: string[] } { const rt = { fps: 60, playbackIn: 0, playbackOut: 10, duration: 10, markers: { intro: 2, outro: 8 }, playhead: 0, playing: false, buttonStates: {} as Record, buttonDisabled: {} as Record, 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 } 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: