import { createApp, h, nextTick, type Component, type Slot } from 'vue' import { createBlankProject, createBlankScene, projectFromLegacyScene, useSceneStore, } from '@/stores/scene' import { buildMarkerMap, evalAction, scriptFramesInRange, type ActionRuntime, } from '@/lib/actions' import { parseScript } from '@/lib/scriptDsl' import type { Action, ButtonAsset, HitShape } from '@/types/scene' import { BUTTON_STATE_TIMES } from '@/types/scene' import { isLayerActiveAt, sampleLayer, lerpColor } from '@/lib/interpolate' import { snapToCandidates } from '@/lib/timelineSnap' import { generateBundledScript, generateExampleHtml, 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 { drawButtonFrame, drawSceneFrame, renderButtonThumbnail } from '@/lib/render' import { t as i18nT } from '@/i18n' 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 ClipartDrawer from '@/components/ClipartDrawer.vue' import SceneDrawer from '@/components/SceneDrawer.vue' import ActionEditor from '@/components/ActionEditor.vue' import { vTooltip } from '@/directives/tooltip' 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) }, }) // Register the same global directives the real app registers in // main.ts — without this, anything using `v-tooltip` (IconButton, // the toolbar buttons, …) no-ops inside the test harness because // each `mountComponent` spins up an isolated app instance. app.directive('tooltip', vTooltip) 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) }) test('selectLayer keeps selectedLayerIds in sync', () => { const s = makeFreshStore() const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' }) const l = s.addLayer(a.id)! s.selectLayer(l.id) expect(s.selectedLayerIds).toEqual([l.id]) s.selectLayer(null) expect(s.selectedLayerIds).toEqual([]) }) test('toggleLayerSelection adds and removes; anchor follows the last click', () => { const s = makeFreshStore() const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' }) const l1 = s.addLayer(a.id)! const l2 = s.addLayer(a.id)! const l3 = s.addLayer(a.id)! s.selectLayer(l1.id) s.toggleLayerSelection(l2.id) s.toggleLayerSelection(l3.id) expect(s.selectedLayerIds).toEqual([l1.id, l2.id, l3.id]) expect(s.selectedLayerId).toBe(l3.id) // Removing the anchor moves the anchor back to the new last entry. s.toggleLayerSelection(l3.id) expect(s.selectedLayerIds).toEqual([l1.id, l2.id]) expect(s.selectedLayerId).toBe(l2.id) // Removing the last remaining entry drops the anchor to null. s.toggleLayerSelection(l2.id) s.toggleLayerSelection(l1.id) expect(s.selectedLayerIds).toEqual([]) expect(s.selectedLayerId).toBe(null) }) test('toggleLayerSelection skips layers locked directly or by group', () => { const s = makeFreshStore() const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' }) const lFree = s.addLayer(a.id)! const lDirect = s.addLayer(a.id)! const lInGroup = s.addLayer(a.id)! s.setTrackLocked(lDirect.id, true) const g = s.addTrackGroup('G') s.moveTrackIntoGroup(lInGroup.id, g.id) s.setTrackLocked(g.id, true) s.selectLayer(lFree.id) s.toggleLayerSelection(lDirect.id) s.toggleLayerSelection(lInGroup.id) // Neither locked layer was added; the free one stays as the // only selection. expect(s.selectedLayerIds).toEqual([lFree.id]) }) test('groupSelectedLayers moves every selected layer into a fresh group', () => { const s = makeFreshStore() const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' }) const l1 = s.addLayer(a.id)! const l2 = s.addLayer(a.id)! const l3 = s.addLayer(a.id)! s.selectLayer(l1.id) s.toggleLayerSelection(l2.id) s.toggleLayerSelection(l3.id) const groupId = s.groupSelectedLayers()! expect(groupId).toBeTruthy() const g = s.scene.groups!.find((x) => x.id === groupId)! expect(g.childIds.includes(l1.id)).toBe(true) expect(g.childIds.includes(l2.id)).toBe(true) expect(g.childIds.includes(l3.id)).toBe(true) // The layers also get removed from the top-level trackOrder // since moveTrackIntoGroup handles that. const order = s.scene.trackOrder ?? [] expect(order.includes(l1.id)).toBe(false) expect(order.includes(l2.id)).toBe(false) expect(order.includes(l3.id)).toBe(false) expect(order.includes(groupId)).toBe(true) }) test('groupSelectedLayers returns null with an empty selection', () => { const s = makeFreshStore() const groupsBefore = (s.scene.groups ?? []).length const result = s.groupSelectedLayers() expect(result).toBe(null) // Nothing was created. expect((s.scene.groups ?? []).length).toBe(groupsBefore) }) test('groupSelectedLayers replaces the topmost selected track\'s stack position', () => { // addLayer inserts at the top (index 0), so after three addLayer // calls trackOrder reads top→bottom: [l3, l2, l1]. Picking l2 // and l1 to group (two layers NOT at the top) should produce a // group at index 1, not at the top of the stack. const s = makeFreshStore() const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' }) const l1 = s.addLayer(a.id)! const l2 = s.addLayer(a.id)! const l3 = s.addLayer(a.id)! const before = [...s.scene.trackOrder!] expect(before).toEqual([l3.id, l2.id, l1.id]) s.selectLayer(l2.id) s.toggleLayerSelection(l1.id) const groupId = s.groupSelectedLayers()! const after = s.scene.trackOrder! // Group sits where l2 used to be (index 1); l3 still at top; // l1 and l2 are no longer top-level. expect(after[0]).toBe(l3.id) expect(after[1]).toBe(groupId) expect(after.includes(l1.id)).toBe(false) expect(after.includes(l2.id)).toBe(false) }) test('convertSelectedLayersToClip packs selected layers into a fresh clip + instance', () => { const s = makeFreshStore() const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' }) // Give each layer a unique name up-front so we can look them up // unambiguously inside the packed clip. Bare `addLayer(asset.id)` // falls back to the asset name — three layers would all read "R" // and `find(l => l.name === ...)` would return the first match. const l1 = s.addLayer(a.id, 'one')! const l2 = s.addLayer(a.id, 'two')! const l3 = s.addLayer(a.id, 'three')! // Seed a non-default keyframe so we can confirm the clip owns an // independent copy of the original layer's data. s.upsertKeyframe(l2.id, 1.5, { x: 123 }) const kfCount = l2.keyframes.length s.selectLayer(l1.id) s.toggleLayerSelection(l2.id) expect(s.selectedLayerIds.length).toBe(2) const clipId = s.convertSelectedLayersToClip('My Clip')! expect(clipId).toBeTruthy() // Clip exists with the packed layers and a duration that matches // the scene so absolute kf times still land where they were. const clip = s.scene.clips!.find((c) => c.id === clipId)! expect(clip.name).toBe('My Clip') expect(clip.duration).toBe(s.scene.duration) expect(clip.layers.length).toBe(2) // The kf data survives the round-trip. const clonedL2 = clip.layers.find((l) => l.name === 'two')! expect(clonedL2).toBeTruthy() expect(clonedL2.keyframes.length).toBe(kfCount) expect(clonedL2.keyframes.some((k) => Math.abs(k.time - 1.5) < 1e-6)).toBe(true) // Originals removed from scene.layers + trackOrder. expect(s.scene.layers.some((l) => l.id === l1.id)).toBe(false) expect(s.scene.layers.some((l) => l.id === l2.id)).toBe(false) expect(s.scene.layers.some((l) => l.id === l3.id)).toBe(true) expect(s.scene.trackOrder!.includes(l1.id)).toBe(false) expect(s.scene.trackOrder!.includes(l2.id)).toBe(false) // Exactly one clipTrack + one instance created; the instance // sits at startTime 0 so the packed layers' absolute kf times // continue to fire at their original scene times. const clipTrackIds = (s.scene.clipTracks ?? []).map((t) => t.id) const matching = (s.scene.clipInstances ?? []).filter( (ci) => ci.clipId === clipId, ) expect(matching.length).toBe(1) expect(clipTrackIds.includes(matching[0].clipTrackId)).toBe(true) expect(matching[0].startTime).toBe(0) // The clone is independent: mutating the clip's layer doesn't // affect the source (l2 is gone, but the data pointer shouldn't // have been shared). clonedL2.keyframes[0].time = 99 // (no crash means the refs are decoupled; l2 is already gone) }) test('convertSelectedLayersToClip drops the clipTrack at the topmost selected slot', () => { const s = makeFreshStore() const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' }) const l1 = s.addLayer(a.id)! const l2 = s.addLayer(a.id)! const l3 = s.addLayer(a.id)! // addLayer prepends → trackOrder reads top-to-bottom [l3, l2, l1]. s.selectLayer(l2.id) s.toggleLayerSelection(l1.id) s.convertSelectedLayersToClip() const order = s.scene.trackOrder ?? [] // l3 is still at the top (index 0); the clipTrack replaces l2's // old slot (index 1); l1's slot is collapsed away. expect(order[0]).toBe(l3.id) expect(order.length).toBe(2) const clipTrackId = (s.scene.clipTracks ?? []).slice(-1)[0].id expect(order[1]).toBe(clipTrackId) }) test('convertSelectedLayersToClip returns null with an empty selection', () => { const s = makeFreshStore() expect(s.convertSelectedLayersToClip()).toBe(null) expect((s.scene.clips ?? []).length).toBe(0) expect((s.scene.clipTracks ?? []).length).toBe(0) expect((s.scene.clipInstances ?? []).length).toBe(0) }) test('convertSelectedLayersToClip skips effectively-locked layers', () => { const s = makeFreshStore() const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' }) const lFree = s.addLayer(a.id)! const lLocked = s.addLayer(a.id)! s.setTrackLocked(lLocked.id, true) s.selectedLayerIds = [lFree.id, lLocked.id] s.selectedLayerId = lLocked.id const clipId = s.convertSelectedLayersToClip()! const clip = s.scene.clips!.find((c) => c.id === clipId)! expect(clip.layers.length).toBe(1) expect(clip.layers[0].name).toBe(lFree.name) // Locked layer stays put. expect(s.scene.layers.some((l) => l.id === lLocked.id)).toBe(true) }) test('groupSelectedLayers skips layers that are effectively locked', () => { // Locked ones shouldn't land in the fresh group — they stay // wherever they were. const s = makeFreshStore() const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' }) const lFree1 = s.addLayer(a.id)! const lLocked = s.addLayer(a.id)! const lFree2 = s.addLayer(a.id)! s.setTrackLocked(lLocked.id, true) // Locking a layer deselects it, so toggleLayerSelection also // silently refuses; build the selection manually to exercise // the filter inside `groupSelectedLayers`. s.selectedLayerIds = [lFree1.id, lLocked.id, lFree2.id] s.selectedLayerId = lFree2.id const groupId = s.groupSelectedLayers()! const g = s.scene.groups!.find((x) => x.id === groupId)! expect(g.childIds.includes(lFree1.id)).toBe(true) expect(g.childIds.includes(lFree2.id)).toBe(true) expect(g.childIds.includes(lLocked.id)).toBe(false) // The locked layer still sits in the top-level track order. expect(s.scene.trackOrder!.includes(lLocked.id)).toBe(true) }) }) 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('duplicateKeyframeAt copies props + easing + hold to an explicit time', () => { const s = makeFreshStore() const a = s.addAsset({ kind: 'rect', name: 'Src', width: 50, height: 50, fill: '#abcdef' }) const l = s.addLayer(a.id)! s.upsertKeyframe(l.id, 1, { x: 123, y: 45 }) const src = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-6)! src.easing = 'easeInOut' s.setKeyframeHold(l.id, src.id, true) const newId = s.duplicateKeyframeAt(l.id, src.id, 3) expect(newId).toBeTruthy() const dup = l.keyframes.find((k) => k.id === newId)! expect(dup.time).toBe(3) expect(dup.props.x).toBe(123) expect(dup.props.y).toBe(45) expect(dup.easing).toBe('easeInOut') expect(dup.hold).toBe(true) // props must be a clone — mutating the duplicate must not touch the source. dup.props.x = 999 expect(src.props.x).toBe(123) }) test('duplicateKeyframeAt refuses to overwrite an existing keyframe', () => { const s = makeFreshStore() const a = s.addAsset({ kind: 'rect', name: 'Col', width: 10, height: 10, fill: '#fff' }) const l = s.addLayer(a.id)! s.upsertKeyframe(l.id, 1, { x: 10 }) s.upsertKeyframe(l.id, 2, { x: 20 }) const src = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-6)! const countBefore = l.keyframes.length const newId = s.duplicateKeyframeAt(l.id, src.id, 2) expect(newId).toBe(null) expect(l.keyframes.length).toBe(countBefore) }) test('duplicateKeyframeAt returns null for unknown layer or keyframe ids', () => { const s = makeFreshStore() const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' }) const l = s.addLayer(a.id)! s.upsertKeyframe(l.id, 1, { x: 10 }) const src = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-6)! expect(s.duplicateKeyframeAt('nope-layer', src.id, 2)).toBe(null) expect(s.duplicateKeyframeAt(l.id, 'nope-kf', 2)).toBe(null) }) test('duplicateKeyframeAt targets the source keyframe\'s own track (position vs main)', () => { // Separated layers have independent main / position / rotation // arrays. The duplicate must land in the same array the source // came from, not accidentally jump to another. const s = makeFreshStore() const a = s.addAsset({ kind: 'rect', name: 'Sep', width: 10, height: 10, fill: '#fff' }) const l = s.addLayer(a.id)! s.setLayerSeparated(l.id, true) s.upsertKeyframe(l.id, 1, { x: 50, y: 60 }) const posKf = l.positionKeyframes!.find((k) => Math.abs(k.time - 1) < 1e-6)! s.duplicateKeyframeAt(l.id, posKf.id, 2) const dup = l.positionKeyframes!.find((k) => Math.abs(k.time - 2) < 1e-6) expect(dup).toBeTruthy() // Nothing should have been added to the main array. expect(l.keyframes.some((k) => Math.abs(k.time - 2) < 1e-6)).toBe(false) }) 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('interpolation — shadow properties', () => { // Regression: shadow keyframes used to render static (or nothing) in // the preview because the runtime's lerpProps dropped shadow fields. // The canvas-side sampleLayer must interpolate them correctly; this // pins the contract both paths share. function rectWithShadow() { const s = makeFreshStore() const a = s.addAsset({ kind: 'rect', name: 'Sh', width: 10, height: 10, fill: '#fff' }) const l = s.addLayer(a.id)! // Seed both endpoints so there's a clear interpolation target. s.upsertKeyframe(l.id, 0, { shadowOpacity: 0, shadowBlur: 0, shadowDistance: 0, shadowAngle: 0, shadowColor: '#000000', }) s.upsertKeyframe(l.id, 2, { shadowOpacity: 1, shadowBlur: 20, shadowDistance: 10, shadowAngle: 180, shadowColor: '#ffffff', }) return l } test('shadowOpacity lerps halfway between keyframes', () => { const l = rectWithShadow() const mid = sampleLayer(l, 1) expect(mid.shadowOpacity).toBe(0.5) }) test('shadowBlur / distance / angle lerp halfway between keyframes', () => { const l = rectWithShadow() const mid = sampleLayer(l, 1) expect(mid.shadowBlur).toBe(10) expect(mid.shadowDistance).toBe(5) expect(mid.shadowAngle).toBe(90) }) test('shadowColor is interpolated per channel (not snapped)', () => { const l = rectWithShadow() const mid = sampleLayer(l, 1) // Black→white at t=0.5 lands on mid-gray (same lerpColor the // fill-color test above pins). expect(mid.shadowColor).toBe('#808080') }) }) suite('interpolation — layer visibility (kf range)', () => { test('untouched baseline (single kf @0) is always visible', () => { const s = makeFreshStore() const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' }) const l = s.addLayer(a.id)! expect(isLayerActiveAt(l, 0)).toBe(true) expect(isLayerActiveAt(l, 5)).toBe(true) expect(isLayerActiveAt(l, 99)).toBe(true) }) test('single kf at any time is always visible (static layer)', () => { const s = makeFreshStore() const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' }) const l = s.addLayer(a.id)! l.keyframes[0].time = 3 expect(isLayerActiveAt(l, 0)).toBe(true) expect(isLayerActiveAt(l, 3)).toBe(true) expect(isLayerActiveAt(l, 5)).toBe(true) }) test('multi-kf: visible only in [firstKf, lastKf]', () => { const s = makeFreshStore() const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' }) const l = s.addLayer(a.id)! s.upsertKeyframe(l.id, 2, { scaleX: 2 }) s.upsertKeyframe(l.id, 5, { scaleX: 3 }) // kf@0 is still there; firstKf = 0, lastKf = 5 expect(isLayerActiveAt(l, -0.1)).toBe(false) expect(isLayerActiveAt(l, 0)).toBe(true) expect(isLayerActiveAt(l, 3)).toBe(true) expect(isLayerActiveAt(l, 5)).toBe(true) expect(isLayerActiveAt(l, 6)).toBe(false) }) test('dragging first kf forward hides layer before it', () => { const s = makeFreshStore() const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' }) const l = s.addLayer(a.id)! s.upsertKeyframe(l.id, 5, { scaleX: 2 }) // Drag the first kf from t=0 to t=2. const first = l.keyframes.find((k) => k.time === 0)! first.time = 2 expect(isLayerActiveAt(l, 1)).toBe(false) expect(isLayerActiveAt(l, 2)).toBe(true) expect(isLayerActiveAt(l, 5)).toBe(true) expect(isLayerActiveAt(l, 6)).toBe(false) }) test('last kf held: layer stays visible past its last keyframe', () => { const s = makeFreshStore() const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' }) const l = s.addLayer(a.id)! s.upsertKeyframe(l.id, 5, { scaleX: 2 }) // Mark the t=5 kf as hold. It's the last keyframe — used to be a // no-op; now it means "stay on stage past the end of the anim". const last = l.keyframes.find((k) => k.time === 5)! s.setKeyframeHold(l.id, last.id, true) expect(isLayerActiveAt(l, 5)).toBe(true) expect(isLayerActiveAt(l, 6)).toBe(true) expect(isLayerActiveAt(l, 99)).toBe(true) // sampleLayer already pins to last.props for time >= last.time — // confirm the value is the last keyframe's, not interpolating out. expect(sampleLayer(l, 10).scaleX).toBe(2) }) test('non-last held kf does NOT extend visibility past end', () => { // Hold on a middle keyframe only changes how it interpolates TO // the following kf; it should not keep the layer alive past the // final keyframe when that final kf itself isn't held. const s = makeFreshStore() const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' }) const l = s.addLayer(a.id)! s.upsertKeyframe(l.id, 2, { scaleX: 2 }) s.upsertKeyframe(l.id, 5, { scaleX: 3 }) const middle = l.keyframes.find((k) => k.time === 2)! s.setKeyframeHold(l.id, middle.id, true) expect(isLayerActiveAt(l, 5)).toBe(true) expect(isLayerActiveAt(l, 6)).toBe(false) }) }) 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') }) test('runtime lerpProps interpolates shadow + blur keyframes', () => { // Regression: shadow properties (shadowBlur/Opacity/Angle/Distance/ // Color) and blur used to be dropped by the runtime's lerpProps, // so an animation that fades a shadow in the editor played flat // in the preview / exported HTML. The runtime must carry them // through the per-pair interpolation. const r = generateRuntimeScript() expect(r).toContain('shadowBlur') expect(r).toContain('shadowOpacity') expect(r).toContain('shadowAngle') expect(r).toContain('shadowDistance') expect(r).toContain('shadowColor') expect(r).toContain('out.blur') }) test('runtime wraps each clip instance in translate + rotate + scale', () => { // Pin that the exported runtime's per-clip draw loop honours // ClipInstance.x/y/scaleX/scaleY/rotation — the canvas editor // and the exported HTML stay visually identical for any // clip-instance transform. const r = generateRuntimeScript() expect(r).toContain('inst2.x') expect(r).toContain('inst2.y') expect(r).toContain('inst2.scaleX') expect(r).toContain('inst2.scaleY') expect(r).toContain('inst2.rotation') expect(r).toContain('ctx.rotate(irot2*Math.PI/180)') expect(r).toContain('ctx.scale(isx2,isy2)') }) test('runtime rangeOut falls back to scene.duration (not last keyframe)', () => { // Regression: the runtime used to fall back to `effectiveEnd(scene)` // — the time of the last keyframe — when scene.outTime was unset. // That made fullscreen preview loop at the last kf, ignoring any // blank tail before the OUT mark and any still-frame-past-end // extension. The fallback is now scene.duration, matching the // editor's `playbackOut` getter. const r = generateRuntimeScript() // The old helper must be gone entirely. expect(r.indexOf('effectiveEnd')).toBe(-1) // And the current fallback must be scene.duration. expect(r).toContain('scene.outTime==null?scene.duration:scene.outTime') }) test('export comments and console prefix use RUWIO, not Animatr', () => { // Rebrand: generated comment headers, the runtime's console warn // prefix, and the example-HTML chrome all read RUWIO now. If any // of these come back as "Animatr", we regressed. const s = makeFreshStore() const runtime = generateRuntimeScript() expect(runtime).toContain('RUWIO runtime') expect(runtime.indexOf('/* Animatr ')).toBe(-1) // Console prefix used by the runtime's action evaluator. expect(runtime).toContain("'[ruwio]'") expect(runtime.indexOf("'[animatr]'")).toBe(-1) const sceneScript = generateSceneScript(s.scene) expect(sceneScript).toContain('RUWIO scene') expect(sceneScript).toContain('ruwio-runtime.js') const bundled = generateBundledScript(s.scene) expect(bundled).toContain('RUWIO bundled export') expect(bundled.indexOf('/* Animatr ')).toBe(-1) }) test('generated example HTML is RUWIO-branded', () => { // The example HTML is what users see when they drop the file // into a browser. Title, heading, and the embed-usage snippet // all need to carry the new brand. const s = makeFreshStore() const html = generateExampleHtml(s.scene) expect(html).toContain('RUWIO — Example') expect(html).toContain('

RUWIO Export

') // The usage snippet now references ruwio-runtime.js. expect(html).toContain('ruwio-runtime.js') expect(html.indexOf('animatr-runtime.js')).toBe(-1) }) }) 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.project) const parsed = parseProject(text) expect(parsed.duration).toBe(7) expect(parsed.assets).toHaveLength(1) const page = parsed.scenes[0] expect(page.layers).toHaveLength(1) expect(page.layers[0].keyframes.find((k) => k.time === 1)?.props.x).toBe(400) }) test('loadProject 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.project) s.$reset() await tick() s.loadProject(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) }) test('legacy `animatr-project` magic still parses (backward compat)', () => { // When we rebranded to RUWIO we kept the on-disk magic as // `animatr-project` so older exports continue to load. Pin that // so a future cleanup doesn't accidentally break existing user // files on disk. const s = makeFreshStore() s.addAsset({ kind: 'rect', name: 'Legacy', width: 10, height: 10, fill: '#123' }) const legacyPayload = JSON.parse(serializeProject(s.project)) expect(legacyPayload.magic).toBe('animatr-project') // And a fully-formed legacy file parses without error. const parsed = parseProject(JSON.stringify(legacyPayload)) expect(parsed.assets.some((a) => a.name === 'Legacy')).toBe(true) }) test('parseProject error mentions RUWIO, not Animatr', () => { // The user-visible error when a non-project JSON is dropped in // reads "Not a RUWIO project file …" now. If you rebrand again // remember to update this test alongside the string. let msg = '' try { parseProject(JSON.stringify({ not: 'a project' })) } catch (e) { msg = (e as Error).message } expect(msg).toContain('RUWIO') expect(msg.toLowerCase().includes('animatr')).toBe(false) }) test('parseProject wraps legacy { scene } payload into Project', () => { // Pre-v0.9 files had `{ magic, fileVersion: 1, scene }`. The new // parser lifts those into a one-page Project so users don't have // to re-save. Canvas + assets + layers must all land intact. const s = makeFreshStore() s.addAsset({ kind: 'rect', name: 'LegacyRect', width: 10, height: 10, fill: '#123' }) const legacy = { magic: 'animatr-project', fileVersion: 1, scene: JSON.parse(JSON.stringify(s.scene)), } const parsed = parseProject(JSON.stringify(legacy)) expect(Array.isArray(parsed.scenes)).toBe(true) expect(parsed.scenes).toHaveLength(1) expect(parsed.activeSceneId).toBe(parsed.scenes[0].id) expect(parsed.assets.some((a) => a.name === 'LegacyRect')).toBe(true) expect(parsed.canvas.width).toBe(s.project.canvas.width) }) }) suite('scene store — project & scenes', () => { test('merged `scene` getter mirrors the active page\'s arrays by reference', () => { // Reads through store.scene must share the SAME array references // as the active ScenePage so `store.scene.layers.push(x)` hits // `activeScene.layers`. If they drift, mutations silently vanish. const s = makeFreshStore() expect(s.scene.layers).toBe(s.activeScene.layers) expect(s.scene.clipInstances).toBe(s.activeScene.clipInstances) expect(s.scene.trackOrder).toBe(s.activeScene.trackOrder) // Shared fields (asset library, canvas) come from the project. expect(s.scene.assets).toBe(s.project.assets) expect(s.scene.canvas).toBe(s.project.canvas) expect(s.scene.clips).toBe(s.project.clips) }) test('createBlankProject seeds exactly one page named "Scene 1"', () => { const p = createBlankProject() expect(p.scenes).toHaveLength(1) expect(p.scenes[0].name).toBe('Scene 1') expect(p.activeSceneId).toBe(p.scenes[0].id) }) test('addScene appends, activates, and picks a sensible default name', () => { const s = makeFreshStore() expect(s.project.scenes).toHaveLength(1) const two = s.addScene() expect(s.project.scenes).toHaveLength(2) expect(two.name).toBe('Scene 2') expect(s.project.activeSceneId).toBe(two.id) const three = s.addScene() expect(three.name).toBe('Scene 3') }) test('addScene respects an explicit trimmed name', () => { const s = makeFreshStore() const intro = s.addScene(' Intro ') expect(intro.name).toBe('Intro') }) test('switching scenes swaps layer stacks without touching the shared library', () => { const s = makeFreshStore() const a = s.addAsset({ kind: 'rect', name: 'Shared', width: 10, height: 10, fill: '#abc' }) const l1 = s.addLayer(a.id, 'in-scene-1')! const two = s.addScene('Two') // After switch: active page has no layers; the asset library stays. expect(s.scene.layers).toHaveLength(0) expect(s.scene.assets.some((x) => x.id === a.id)).toBe(true) const l2 = s.addLayer(a.id, 'in-scene-2')! // Flip back to scene 1 and confirm its layer is intact. s.switchScene(s.project.scenes[0].id) expect(s.scene.layers).toHaveLength(1) expect(s.scene.layers[0].id).toBe(l1.id) // And scene 2 still owns its own layer. s.switchScene(two.id) expect(s.scene.layers).toHaveLength(1) expect(s.scene.layers[0].id).toBe(l2.id) }) test('switchScene clears editor context (selection, clip-edit, drawing) and auto-plays', () => { const s = makeFreshStore() const a = s.addAsset({ kind: 'rect', name: 'SW', width: 10, height: 10, fill: '#111' }) const l = s.addLayer(a.id)! s.selectLayer(l.id) s.pause() const clip = s.addClip('C') s.activeClipId = clip.id const two = s.addScene() // addScene already routed through switchScene — cross-check. expect(s.selectedLayerId).toBeNull() expect(s.selectedLayerIds).toHaveLength(0) expect(s.activeClipId).toBeNull() expect(s.activeButtonAssetId).toBeNull() expect(s.project.activeSceneId).toBe(two.id) // Switching always auto-plays on the new page — even if the // editor was paused beforehand. expect(s.playing).toBe(true) }) test('switchScene auto-plays even when the editor was paused', () => { const s = makeFreshStore() s.pause() const second = s.addScene('Two') const firstId = s.project.scenes[0].id s.pause() expect(s.playing).toBe(false) s.switchScene(firstId) expect(s.playing).toBe(true) expect(s.project.activeSceneId).toBe(firstId) // And the inverse direction — Two plays too. s.pause() s.switchScene(second.id) expect(s.playing).toBe(true) }) test('switchScene returns false for an unknown id and leaves state alone', () => { const s = makeFreshStore() const before = s.project.activeSceneId const ok = s.switchScene('nope-no-such-scene') expect(ok).toBe(false) expect(s.project.activeSceneId).toBe(before) }) test('renameScene trims and rejects empty names', () => { const s = makeFreshStore() const id = s.project.activeSceneId expect(s.renameScene(id, ' Hello ')).toBe(true) expect(s.project.scenes[0].name).toBe('Hello') expect(s.renameScene(id, ' ')).toBe(false) expect(s.project.scenes[0].name).toBe('Hello') }) test('removeScene refuses to delete the last remaining page', () => { const s = makeFreshStore() const only = s.project.activeSceneId expect(s.removeScene(only)).toBe(false) expect(s.project.scenes).toHaveLength(1) }) test('removeScene on the last page activates its predecessor', () => { // [A, B, C] active=C → remove C → [A, B] active=B. // The "neighbour" policy slides the active marker back one slot // when there's nothing further down the list. const s = makeFreshStore() const a = s.project.scenes[0].id const b = s.addScene('B').id const c = s.addScene('C').id expect(s.project.activeSceneId).toBe(c) expect(s.removeScene(c)).toBe(true) expect(s.project.activeSceneId).toBe(b) expect(s.project.scenes.map((p) => p.id)).toEqual([a, b]) }) test('removeScene on a middle page activates the next one', () => { // [A, B, C] active=B → remove B → [A, C] active=C. // Forward is the default direction; previous is only used when // there is no next. const s = makeFreshStore() const a = s.project.scenes[0].id const b = s.addScene('B').id const c = s.addScene('C').id s.switchScene(b) expect(s.project.activeSceneId).toBe(b) expect(s.removeScene(b)).toBe(true) expect(s.project.activeSceneId).toBe(c) expect(s.project.scenes.map((p) => p.id)).toEqual([a, c]) }) test('removeScene on an inactive page leaves the active one untouched', () => { const s = makeFreshStore() const a = s.project.scenes[0].id const b = s.addScene('B').id const c = s.addScene('C').id expect(s.project.activeSceneId).toBe(c) expect(s.removeScene(b)).toBe(true) expect(s.project.activeSceneId).toBe(c) expect(s.project.scenes.map((p) => p.id)).toEqual([a, c]) }) test('duplicateScene deep-clones with fresh ids and activates the copy', () => { const s = makeFreshStore() const a = s.addAsset({ kind: 'rect', name: 'D', width: 10, height: 10, fill: '#abc' }) const l = s.addLayer(a.id, 'Orig')! s.upsertKeyframe(l.id, 1, { x: 20 }) const origId = s.project.activeSceneId const origLayerId = l.id const origKfIds = new Set(l.keyframes.map((k) => k.id)) const copy = s.duplicateScene(origId)! expect(copy.id === origId).toBe(false) expect(copy.name).toBe(`${s.project.scenes[0].name} copy`) expect(s.project.activeSceneId).toBe(copy.id) // Clone sits directly after the original. const idx = s.project.scenes.findIndex((p) => p.id === origId) expect(s.project.scenes[idx + 1].id).toBe(copy.id) // Fresh layer + keyframe ids. const clonedLayer = copy.layers[0] expect(clonedLayer.id === origLayerId).toBe(false) for (const k of clonedLayer.keyframes) expect(origKfIds.has(k.id)).toBe(false) // Deep-cloned — mutating the clone leaves original untouched. clonedLayer.base.x = 999 const origPage = s.project.scenes.find((p) => p.id === origId)! expect(origPage.layers[0].base.x === 999).toBe(false) }) test('projectFromLegacyScene wraps a single Scene into a one-page Project', () => { const s = makeFreshStore() s.addAsset({ kind: 'rect', name: 'LG', width: 10, height: 10, fill: '#abc' }) const project = projectFromLegacyScene(JSON.parse(JSON.stringify(s.scene)), 'My Page') expect(project.scenes).toHaveLength(1) expect(project.scenes[0].name).toBe('My Page') expect(project.activeSceneId).toBe(project.scenes[0].id) expect(project.assets.some((a) => a.name === 'LG')).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('undo after switchScene restores the previous activeSceneId', async () => { // History snapshots the whole project including activeSceneId, so // a scene switch is itself an undoable step. If this regresses the // user will notice a sudden loss of "undo back to where I was". const s = makeFreshStore() const h = createHistory(s, { debounceMs: 10 }) h.start() const a = s.project.scenes[0].id const b = s.addScene('B').id await tick(40) // Now switch back to A; that's a fresh snapshottable step. s.switchScene(a) await tick(40) expect(s.project.activeSceneId).toBe(a) h.undo() await tick(20) expect(s.project.activeSceneId).toBe(b) h.stop() }) test('undo after addScene removes the just-added page', async () => { const s = makeFreshStore() const h = createHistory(s, { debounceMs: 10 }) h.start() expect(s.project.scenes).toHaveLength(1) const origId = s.project.activeSceneId s.addScene('B') await tick(40) expect(s.project.scenes).toHaveLength(2) h.undo() await tick(20) expect(s.project.scenes).toHaveLength(1) expect(s.project.activeSceneId).toBe(origId) h.stop() }) test('undo after removeScene restores the deleted page content', async () => { const s = makeFreshStore() const h = createHistory(s, { debounceMs: 10 }) h.start() const a = s.addAsset({ kind: 'rect', name: 'U', width: 10, height: 10, fill: '#abc' }) const two = s.addScene('Two') s.addLayer(a.id, 'only-in-two') await tick(40) expect(s.scene.layers).toHaveLength(1) const twoId = two.id // Flip away, then delete. Both are undoable steps. s.switchScene(s.project.scenes[0].id) await tick(40) s.removeScene(twoId) await tick(40) expect(s.project.scenes.some((p) => p.id === twoId)).toBe(false) h.undo() await tick(20) const restored = s.project.scenes.find((p) => p.id === twoId) expect(restored).toBeTruthy() expect(restored!.layers).toHaveLength(1) expect(restored!.layers[0].name).toBe('only-in-two') 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', () => { // Modal teleports to document.body. If the running app has a // dialog open (welcome / export / settings), a plain // document.querySelector('[role="dialog"]') would match that // instead of what the test just mounted. Snapshot the live // dialogs first and diff after mount so each assertion only // considers the nodes this test created. const snapshotDialogs = () => new Set(Array.from(document.querySelectorAll('[role="dialog"]'))) const dialogsAddedSince = (baseline: Set) => Array.from(document.querySelectorAll('[role="dialog"]')).filter( (el) => !baseline.has(el), ) test('renders nothing when modelValue is false', async () => { const baseline = snapshotDialogs() const handle = mountComponent(Modal, { modelValue: false, title: 'X' }) await nextTick() expect(dialogsAddedSince(baseline).length).toBe(0) handle.unmount() }) test('teleports backdrop + dialog to body when open, renders title', async () => { const baseline = snapshotDialogs() const handle = mountComponent(Modal, { modelValue: true, title: 'Hallo Modal' }) await nextTick() const added = dialogsAddedSince(baseline) expect(added.length).toBe(1) const dialog = added[0] as HTMLElement 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 baseline = snapshotDialogs() const handle = mountComponent(Modal, { modelValue: true, title: 'Busy', dismissable: false, 'onUpdate:modelValue': (v: boolean) => { latest = v }, }) await nextTick() const added = dialogsAddedSince(baseline) expect(added.length).toBe(1) const dialog = added[0] as HTMLElement const btn = dialog.querySelector('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 () => { // IconButton now uses the custom v-tooltip directive instead of // the native `title` attribute. The directive still sets an // `aria-label` so screen readers get the tooltip text — that's // the stable contract this test pins. const handle = mountComponent(IconButton, { title: 'hi' }) await nextTick() const btn = handle.host.querySelector('button') expect(btn).toBeTruthy() expect(btn!.getAttribute('aria-label')).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.project, { name: 'test-save-get' }) try { const back = await getStoredProject(rec.id) expect(back).toBeTruthy() expect(back!.name).toBe('test-save-get') expect(back!.project!.assets).toHaveLength(1) expect(back!.project!.scenes[0].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.project, { id: olderId, name: 'older' }) await tick(5) const newer = await saveProjectToStore(s.project, { 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.project, { id, name: 'v1' }) const before = (await listRecentProjects()).filter((p) => p.id === id).length await saveProjectToStore(s.project, { 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.project, { 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('setClipInstanceTransform stores x / y / scale / rotation', () => { const s = makeFreshStore() const c = s.addClip('Xf') const t = s.scene.clipTracks![0] const ci = s.addClipInstance(c.id, 0, t.id)! s.setClipInstanceTransform(ci.id, { x: 80, y: -30, scaleX: 2, scaleY: 0.5, rotation: 45, }) expect(ci.x).toBe(80) expect(ci.y).toBe(-30) expect(ci.scaleX).toBe(2) expect(ci.scaleY).toBe(0.5) expect(ci.rotation).toBe(45) }) test('setClipInstanceTransform normalises rotation into (-180, 180]', () => { // Values outside the canonical range are wrapped so the stored // number stays human-readable across multiple transform // sessions (Konva's transformer accumulates past 360). const s = makeFreshStore() const c = s.addClip('Xf') const t = s.scene.clipTracks![0] const ci = s.addClipInstance(c.id, 0, t.id)! s.setClipInstanceTransform(ci.id, { rotation: 370 }) expect(ci.rotation).toBe(10) s.setClipInstanceTransform(ci.id, { rotation: -190 }) expect(ci.rotation).toBe(170) s.setClipInstanceTransform(ci.id, { rotation: 360 }) // 360 % 360 = 0 → pruned back out of the object. expect('rotation' in ci).toBe(false) }) test('setClipInstanceTransform prunes defaults so JSON stays clean', () => { // Defaults (x=0, y=0, scale=1) are absent on a freshly-added // instance. Writing them back explicitly mustn't leave the // field around — serialized scenes stay minimal and diffable. const s = makeFreshStore() const c = s.addClip('Xf') const t = s.scene.clipTracks![0] const ci = s.addClipInstance(c.id, 0, t.id)! s.setClipInstanceTransform(ci.id, { x: 50, scaleX: 2 }) expect(ci.x).toBe(50) expect(ci.scaleX).toBe(2) // Clear back to defaults — the keys should get deleted. s.setClipInstanceTransform(ci.id, { x: 0, scaleX: 1 }) expect('x' in ci).toBe(false) expect('scaleX' in ci).toBe(false) }) test('setClipInstanceTransform leaves unspecified fields alone', () => { const s = makeFreshStore() const c = s.addClip('Xf') const t = s.scene.clipTracks![0] const ci = s.addClipInstance(c.id, 0, t.id)! s.setClipInstanceTransform(ci.id, { x: 10, y: 20, scaleX: 3, scaleY: 3 }) // Partial patch: only x. y / scale must survive. s.setClipInstanceTransform(ci.id, { x: 99 }) expect(ci.x).toBe(99) expect(ci.y).toBe(20) expect(ci.scaleX).toBe(3) expect(ci.scaleY).toBe(3) }) test('setClipInstanceTransform on an unknown id is a no-op', () => { const s = makeFreshStore() const c = s.addClip('Xf') const t = s.scene.clipTracks![0] s.addClipInstance(c.id, 0, t.id)! s.setClipInstanceTransform('not-an-id', { x: 5 }) // No-op; no side effects or throw. expect(s.scene.clipInstances!.every((ci) => ci.x === undefined)).toBe(true) }) test('selectClipInstance clears the layer selection and vice versa', () => { // Selection is mutually exclusive — stage transformer only // targets one thing at a time. const s = makeFreshStore() const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' }) const l = s.addLayer(a.id)! const c = s.addClip('Xf') const t = s.scene.clipTracks![0] const ci = s.addClipInstance(c.id, 0, t.id)! s.selectLayer(l.id) expect(s.selectedLayerId).toBe(l.id) expect(s.selectedClipInstanceId).toBe(null) s.selectClipInstance(ci.id) expect(s.selectedClipInstanceId).toBe(ci.id) expect(s.selectedLayerId).toBe(null) expect(s.selectedLayerIds).toEqual([]) s.selectLayer(l.id) expect(s.selectedLayerId).toBe(l.id) expect(s.selectedClipInstanceId).toBe(null) s.selectClipInstance(null) expect(s.selectedClipInstanceId).toBe(null) // selecting null on the clip branch doesn't touch the layer // selection — the user just cleared the clip-side pointer. expect(s.selectedLayerId).toBe(l.id) }) test('removeClipInstance clears selectedClipInstanceId when it matched', () => { const s = makeFreshStore() const c = s.addClip('Xf') const t = s.scene.clipTracks![0] const ci = s.addClipInstance(c.id, 0, t.id)! s.selectClipInstance(ci.id) s.removeClipInstance(ci.id) expect(s.selectedClipInstanceId).toBe(null) }) test('drawSceneFrame rotates the clip-instance group on the canvas', () => { // Geometry that keeps the rect on-canvas through a 180° flip: // - 20×10 rect anchored at layer origin (0,0) with no offset. // - Clip instance at x=50, y=50 → unrotated, rect lives at // world (50..70, 50..60). // - Rotate 180° → rect flips to world (30..50, 40..50). // Both pre and post positions sit comfortably on the 100×100 // canvas so we can assert concrete pixels on either side. const s = makeFreshStore() s.scene.canvas.width = 100 s.scene.canvas.height = 100 s.scene.canvas.background = 'transparent' s.setDuration(5) const rect = s.addAsset({ kind: 'rect', name: 'R', width: 20, height: 10, fill: '#ff0000', }) const clip = s.addClip('C') clip.duration = 5 clip.layers = [ { id: 'clip-layer-r', name: 'R', assetId: rect.id, visible: true, base: { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 }, keyframes: [], offsetX: 0, offsetY: 0, }, ] const track = s.scene.clipTracks![0] const ci = s.addClipInstance(clip.id, 0, track.id)! s.setClipInstanceTransform(ci.id, { x: 50, y: 50 }) // Baseline: unrotated rect covers world (50..70, 50..60). A // pixel at (60, 55) must be red; (40, 45) must be transparent. const unrotated = document.createElement('canvas') unrotated.width = 100 unrotated.height = 100 const uCtx = unrotated.getContext('2d')! drawSceneFrame(uCtx, s.scene, 0, {}) const beforeInside = uCtx.getImageData(60, 55, 1, 1).data const beforeOutside = uCtx.getImageData(40, 45, 1, 1).data expect(beforeInside[0] > 200).toBe(true) expect(beforeInside[3] > 200).toBe(true) expect(beforeOutside[3]).toBe(0) // Rotate 180°: rect flips around (50, 50) to world (30..50, // 40..50). The two probe pixels swap roles. s.setClipInstanceTransform(ci.id, { rotation: 180 }) const rotated = document.createElement('canvas') rotated.width = 100 rotated.height = 100 const rCtx = rotated.getContext('2d')! drawSceneFrame(rCtx, s.scene, 0, {}) const afterInside = rCtx.getImageData(40, 45, 1, 1).data const afterOutside = rCtx.getImageData(60, 55, 1, 1).data expect(afterInside[0] > 200).toBe(true) expect(afterInside[3] > 200).toBe(true) expect(afterOutside[3]).toBe(0) }) test('drawSceneFrame translates + scales the clip-instance group on the canvas', () => { // Seeds a 100×100 scene with a tiny red rect clip; places one // instance at the top-left (0,0) with 2× scale so the 10×10 // rect renders as a 20×20 block. Reads pixels to confirm the // transform actually landed on the canvas-path renderer // (mask-preview, thumbnails, etc. all go through drawSceneFrame). const s = makeFreshStore() s.scene.canvas.width = 100 s.scene.canvas.height = 100 s.scene.canvas.background = 'transparent' s.setDuration(5) const rect = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#ff0000', }) const clip = s.addClip('C') clip.duration = 5 // Build the clip's sole layer directly — addLayer would target // the scene, not the clip library. clip.layers = [ { id: 'clip-layer-1', name: 'RL', assetId: rect.id, visible: true, base: { x: 5, y: 5, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1, }, keyframes: [], offsetX: 5, offsetY: 5, }, ] const track = s.scene.clipTracks![0] const ci = s.addClipInstance(clip.id, 0, track.id)! // Transform: nudge to (0,0) origin for the top-left and scale 2×. s.setClipInstanceTransform(ci.id, { x: 0, y: 0, scaleX: 2, scaleY: 2 }) const canvas = document.createElement('canvas') canvas.width = 100 canvas.height = 100 const ctx = canvas.getContext('2d')! drawSceneFrame(ctx, s.scene, 0, {}) // Without transform the 10×10 rect at (5,5) covers x=0..10, y=0..10 // (layer's own offset centres it on its base pos). With scale 2× // it covers 0..20 in both axes. A pixel at (15, 15) should be red. const insideAfterScale = ctx.getImageData(15, 15, 1, 1).data expect(insideAfterScale[0] > 200).toBe(true) expect(insideAfterScale[3] > 200).toBe(true) // A pixel at (25, 25) should still be blank (outside the // scaled rect + stage-render still clips to canvas). const outsideAfterScale = ctx.getImageData(25, 25, 1, 1).data expect(outsideAfterScale[3]).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('group color cascades to non-overridden children', () => { const s = makeFreshStore() const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' }) 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) s.setTrackColor(g.id, '#ff0000') expect(l1.color).toBe('#ff0000') expect(l2.color).toBe('#ff0000') // The children inherited — they must NOT be marked as overridden. expect(l1.colorOverridden).toBe(undefined) expect(l2.colorOverridden).toBe(undefined) }) test('explicit per-track color pins against later group-color changes', () => { const s = makeFreshStore() const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' }) 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) s.setTrackColor(g.id, '#ff0000') // User recolours one child AFTER the group — it pins. s.setTrackColor(l1.id, '#00ff00') expect(l1.colorOverridden).toBe(true) // Group colour changes again — pinned child ignores it; other follows. s.setTrackColor(g.id, '#0000ff') expect(l1.color).toBe('#00ff00') expect(l2.color).toBe('#0000ff') }) test('clearing a child-color (null) re-inherits the group color', () => { const s = makeFreshStore() const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' }) const l = s.addLayer(a.id)! const g = s.addTrackGroup('G') s.moveTrackIntoGroup(l.id, g.id) s.setTrackColor(g.id, '#ff0000') s.setTrackColor(l.id, '#00ff00') expect(l.color).toBe('#00ff00') s.setTrackColor(l.id, null) // Now inheriting again. expect(l.color).toBe('#ff0000') expect(l.colorOverridden).toBe(undefined) }) test('clearing a group color clears cascade on non-overridden children', () => { const s = makeFreshStore() const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' }) 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) s.setTrackColor(g.id, '#ff0000') s.setTrackColor(l2.id, '#00ff00') s.setTrackColor(g.id, null) expect(l1.color).toBe(undefined) // l2 was pinned, keeps its explicit colour. expect(l2.color).toBe('#00ff00') }) test('moving a track into a coloured group inherits its color', () => { const s = makeFreshStore() const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' }) const l = s.addLayer(a.id)! const g = s.addTrackGroup('G') s.setTrackColor(g.id, '#ff0000') s.moveTrackIntoGroup(l.id, g.id) expect(l.color).toBe('#ff0000') expect(l.colorOverridden).toBe(undefined) }) test('moving an overridden track into a coloured group keeps its color', () => { const s = makeFreshStore() const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' }) const l = s.addLayer(a.id)! s.setTrackColor(l.id, '#00ff00') const g = s.addTrackGroup('G') s.setTrackColor(g.id, '#ff0000') s.moveTrackIntoGroup(l.id, g.id) expect(l.color).toBe('#00ff00') }) test('loading an older scene treats pre-existing track colors as overridden', () => { // Migration: scenes saved before colorOverridden shipped must // preserve their per-track colour choices against future group // cascades. normalizeScene stamps colorOverridden=true on any // track that arrives with a colour and no flag. const s = makeFreshStore() const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' }) const l = s.addLayer(a.id)! const snap = JSON.parse(JSON.stringify(s.scene)) snap.layers[0].color = '#123456' // legacy: no colorOverridden field on the layer s.loadScene(snap) const loaded = s.scene.layers.find((x) => x.id === l.id)! expect(loaded.colorOverridden).toBe(true) }) 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('scene store — duplicateTrack', () => { test('duplicates a layer with fresh keyframe ids right below the original', () => { const s = makeFreshStore() const a = s.addAsset({ kind: 'rect', name: 'D1', width: 10, height: 10, fill: '#abc' }) const l = s.addLayer(a.id, 'Orig')! s.upsertKeyframe(l.id, 1, { x: 10 }) s.upsertKeyframe(l.id, 2, { x: 20 }) const origKfIds = new Set(l.keyframes.map((k) => k.id)) const newId = s.duplicateTrack(l.id) expect(newId).toBeTruthy() const dup = s.scene.layers.find((x) => x.id === newId)! expect(dup).toBeTruthy() expect(dup.id === l.id).toBe(false) expect(dup.name).toBe('Orig') expect(dup.assetId).toBe(a.id) expect(dup.keyframes.length).toBe(l.keyframes.length) // Fresh ids for every keyframe so history/undo never aliases. for (const k of dup.keyframes) expect(origKfIds.has(k.id)).toBe(false) // Clone sits immediately after the original in trackOrder. const order = s.scene.trackOrder! expect(order.indexOf(newId!)).toBe(order.indexOf(l.id) + 1) }) test('layer duplicate is deeply independent of the original', () => { const s = makeFreshStore() const a = s.addAsset({ kind: 'rect', name: 'D2', width: 10, height: 10, fill: '#bcd' }) const l = s.addLayer(a.id)! s.upsertKeyframe(l.id, 1, { x: 10 }) const newId = s.duplicateTrack(l.id)! const dup = s.scene.layers.find((x) => x.id === newId)! // Mutate the clone — the original must not see the change. dup.base.x = 999 dup.keyframes[0].props.x = 999 const origAfter = s.scene.layers.find((x) => x.id === l.id)! expect(origAfter.base.x === 999).toBe(false) expect(origAfter.keyframes[0].props.x === 999).toBe(false) }) test('layer duplicate copies separated position/rotation kf arrays with fresh ids', () => { const s = makeFreshStore() const a = s.addAsset({ kind: 'rect', name: 'D3', width: 10, height: 10, fill: '#cde' }) const l = s.addLayer(a.id)! s.upsertKeyframe(l.id, 1, { x: 5, y: 5, rotation: 45 }) s.setLayerSeparated(l.id, true) const before = s.scene.layers.find((x) => x.id === l.id)! const origPosIds = new Set((before.positionKeyframes ?? []).map((k) => k.id)) const origRotIds = new Set((before.rotationKeyframes ?? []).map((k) => k.id)) const newId = s.duplicateTrack(l.id)! const dup = s.scene.layers.find((x) => x.id === newId)! expect(dup.separated).toBe(true) expect((dup.positionKeyframes ?? []).length).toBe((before.positionKeyframes ?? []).length) expect((dup.rotationKeyframes ?? []).length).toBe((before.rotationKeyframes ?? []).length) for (const k of dup.positionKeyframes ?? []) expect(origPosIds.has(k.id)).toBe(false) for (const k of dup.rotationKeyframes ?? []) expect(origRotIds.has(k.id)).toBe(false) }) test('duplicating a grouped layer keeps the clone in the same group', () => { const s = makeFreshStore() const a = s.addAsset({ kind: 'rect', name: 'D4', width: 10, height: 10, fill: '#def' }) const l = s.addLayer(a.id)! const g = s.addTrackGroup('G') s.moveTrackIntoGroup(l.id, g.id) const newId = s.duplicateTrack(l.id)! const group = s.scene.groups!.find((x) => x.id === g.id)! // Duplicate lands in the group, directly after the original. expect(group.childIds.indexOf(newId)).toBe(group.childIds.indexOf(l.id) + 1) // Grouped children never leak into trackOrder. expect((s.scene.trackOrder ?? []).includes(newId)).toBe(false) }) test('duplicates a clip track along with its clip instances', () => { const s = makeFreshStore() const clip = s.addClip('C') clip.duration = 5 const origTrack = s.addClipTrack('track-A') const ci1 = s.addClipInstance(clip.id, 0, origTrack.id)! const ci2 = s.addClipInstance(clip.id, 2, origTrack.id)! const newId = s.duplicateTrack(origTrack.id) expect(newId).toBeTruthy() expect(newId === origTrack.id).toBe(false) const newTrack = s.scene.clipTracks!.find((t) => t.id === newId)! expect(newTrack.name).toBe('track-A') // Original instances untouched. expect(s.scene.clipInstances!.find((ci) => ci.id === ci1.id)!.clipTrackId).toBe(origTrack.id) expect(s.scene.clipInstances!.find((ci) => ci.id === ci2.id)!.clipTrackId).toBe(origTrack.id) // Clone track carries copies with fresh ids and same clipId/startTime. const cloneInstances = s.scene.clipInstances!.filter((ci) => ci.clipTrackId === newId) expect(cloneInstances).toHaveLength(2) const startTimes = cloneInstances.map((ci) => ci.startTime).sort((a, b) => a - b) expect(startTimes).toEqual([0, 2]) for (const ci of cloneInstances) { expect(ci.clipId).toBe(clip.id) expect(ci.id === ci1.id).toBe(false) expect(ci.id === ci2.id).toBe(false) } // Slotted right after the original in trackOrder. const order = s.scene.trackOrder! expect(order.indexOf(newId!)).toBe(order.indexOf(origTrack.id) + 1) }) test('duplicates a script track along with its frames', () => { const s = makeFreshStore() s.setDuration(10) const origTrack = s.addScriptTrack('triggers') const f1 = s.addScriptFrame(origTrack.id, 1, [{ type: 'pause' }])! const f2 = s.addScriptFrame(origTrack.id, 3, [{ type: 'play' }])! const newId = s.duplicateTrack(origTrack.id)! expect(newId === origTrack.id).toBe(false) const newTrack = s.scene.scriptTracks!.find((t) => t.id === newId)! expect(newTrack.name).toBe('triggers') // Original frames untouched. expect(s.scene.scriptFrames!.find((f) => f.id === f1.id)!.trackId).toBe(origTrack.id) expect(s.scene.scriptFrames!.find((f) => f.id === f2.id)!.trackId).toBe(origTrack.id) // Clone has its own frames at the same times with matching actions. const cloneFrames = s.scene.scriptFrames!.filter((f) => f.trackId === newId) expect(cloneFrames).toHaveLength(2) const times = cloneFrames.map((f) => f.time).sort((a, b) => a - b) expect(times).toEqual([1, 3]) for (const f of cloneFrames) { expect(f.id === f1.id).toBe(false) expect(f.id === f2.id).toBe(false) } }) test('returns null for unknown id and for group ids', () => { const s = makeFreshStore() expect(s.duplicateTrack('nope-no-such-track')).toBe(null) const g = s.addTrackGroup('G') expect(s.duplicateTrack(g.id)).toBe(null) }) }) 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', () => { // Tooltips live in `aria-label` now (custom v-tooltip directive // replaced the native `title` attribute on every interactive // element). const saveBtn = document.querySelector('button[aria-label^="Save project"]') const recentsBtn = document.querySelector('button[aria-label="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('new-track dropzone is hidden while the clip library is empty', () => { // Regression: the dropzone used to always render and show "Drop a // clip here" even when the library had no clips. Timeline now gates // `.clip-track-dropzone` behind `scene.clips.length > 0`. The live // smoke scene starts blank, so the dropzone must be absent. const dz = document.querySelector('.clip-track-dropzone') expect(dz).toBeNull() }) test('timeline has add-group button', () => { const btn = document.querySelector('button[aria-label="Create a new track group"]') expect(btn).toBeTruthy() }) }) suite('timeline — backspace deletes selected layer', () => { // Regression: Backspace / Delete used to only remove selected // keyframes and markers. The selected layer track had no shortcut // — the user had to use the context menu. Timeline's document-level // keydown now also deletes the currently-selected layer, skipping // locked layers (direct or via a locked parent group). async function clearTimelineSelections() { // The timeline's marker selection lives in a component-local ref // that the store reset doesn't touch. Two Escape presses clear // keyframes first, then markers — matches the handler's priority. document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) await nextTick() document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) await nextTick() } test('Backspace removes the selected layer and clears the selection', async () => { const s = makeFreshStore() await clearTimelineSelections() const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' }) const l = s.addLayer(a.id)! s.selectLayer(l.id) expect(s.scene.layers.some((x) => x.id === l.id)).toBe(true) document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace' })) await nextTick() expect(s.scene.layers.some((x) => x.id === l.id)).toBe(false) expect(s.selectedLayerId).toBe(null) }) test('Delete also removes the selected layer', async () => { const s = makeFreshStore() await clearTimelineSelections() const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' }) const l = s.addLayer(a.id)! s.selectLayer(l.id) document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete' })) await nextTick() expect(s.scene.layers.some((x) => x.id === l.id)).toBe(false) }) test('Backspace is a no-op while the layer itself is locked', async () => { const s = makeFreshStore() await clearTimelineSelections() const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' }) const l = s.addLayer(a.id)! s.selectLayer(l.id) s.setTrackLocked(l.id, true) document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace' })) await nextTick() expect(s.scene.layers.some((x) => x.id === l.id)).toBe(true) }) test('Backspace is a no-op while the parent group is locked', async () => { const s = makeFreshStore() await clearTimelineSelections() const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' }) const l = s.addLayer(a.id)! const g = s.addTrackGroup('G') s.moveTrackIntoGroup(l.id, g.id) s.setTrackLocked(g.id, true) s.selectLayer(l.id) document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace' })) await nextTick() expect(s.scene.layers.some((x) => x.id === l.id)).toBe(true) }) test('Backspace while typing in an input does not touch the selected layer', async () => { const s = makeFreshStore() await clearTimelineSelections() const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' }) const l = s.addLayer(a.id)! s.selectLayer(l.id) // Mount an input, focus it, dispatch Backspace FROM the input. const input = document.createElement('input') input.setAttribute('data-test-input', '') document.body.appendChild(input) input.focus() input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true })) await nextTick() expect(s.scene.layers.some((x) => x.id === l.id)).toBe(true) input.remove() }) test('Backspace with no layer selected is a no-op', async () => { const s = makeFreshStore() await clearTimelineSelections() const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' }) const l = s.addLayer(a.id)! // Explicitly deselect s.selectLayer(null) document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace' })) await nextTick() expect(s.scene.layers.some((x) => x.id === l.id)).toBe(true) }) }) 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('interface prefs — shared composables', () => { // Regression: the tooltip + asset-thumbs prefs are shared module- // level refs so the Settings dialog and the sidebar/timeline // toolbars stay in lock-step. The same refs also have to persist // to localStorage. The thumbs pref was originally an inline ref // inside AssetSidebar.vue — the settings tab couldn't change it // without this extraction. test('useTooltipPref persists to localStorage and shares the ref', async () => { const { useTooltipPref } = await import('@/composables/useTooltipPref') const a = useTooltipPref() const b = useTooltipPref() expect(a.enabled).toBe(b.enabled) // same ref instance const initial = a.enabled.value a.enabled.value = !initial await nextTick() expect(localStorage.getItem('ruwio:tooltipsEnabled')).toBe(initial ? '0' : '1') // Both consumers see the flip because the ref is shared. expect(b.enabled.value).toBe(!initial) // Restore so other tests don't inherit a toggled state. a.enabled.value = initial await nextTick() }) test('useAssetThumbsPref persists to localStorage and shares the ref', async () => { const { useAssetThumbsPref } = await import('@/composables/useAssetThumbsPref') const a = useAssetThumbsPref() const b = useAssetThumbsPref() expect(a.enabled).toBe(b.enabled) const initial = a.enabled.value a.enabled.value = !initial await nextTick() expect(localStorage.getItem('animatr.assetThumbs')).toBe(String(!initial)) expect(b.enabled.value).toBe(!initial) a.enabled.value = initial await nextTick() }) }) suite('clipart drawer — grid pre-layout', () => { // Regression: the drawer's `.drawer-panel` used to be `v-if`-gated on // clipart.open and given `flex: 1; min-width: 0` — which meant the // grid mounted at the same moment the drawer started its width // transition, so the `repeat(auto-fill, minmax(88px, 1fr))` grid // reflowed continuously from ~1 column at 5px → 4 columns at 424px // and the thumbs "tumbled" into place. The panel is now always // mounted at a rigid 400px, and `.clipart-drawer { overflow: hidden }` // just clips the slide. test('.drawer-panel is in the DOM when the drawer is closed', async () => { const { useClipartStore } = await import('@/stores/clipart') const s = useClipartStore() s.setOpen(false) const handle = mountComponent(ClipartDrawer, {}) await nextTick() const panel = handle.host.querySelector('.drawer-panel') expect(panel).toBeTruthy() handle.unmount() }) test('.drawer-panel is in the DOM when the drawer is open', async () => { const { useClipartStore } = await import('@/stores/clipart') const s = useClipartStore() s.setOpen(true) const handle = mountComponent(ClipartDrawer, {}) await nextTick() const panel = handle.host.querySelector('.drawer-panel') expect(panel).toBeTruthy() handle.unmount() }) test('.drawer-panel stays mounted across open → closed toggle', async () => { const { useClipartStore } = await import('@/stores/clipart') const s = useClipartStore() s.setOpen(true) const handle = mountComponent(ClipartDrawer, {}) await nextTick() expect(handle.host.querySelector('.drawer-panel')).toBeTruthy() s.setOpen(false) await nextTick() expect(handle.host.querySelector('.drawer-panel')).toBeTruthy() handle.unmount() }) test('.drawer-panel has a rigid 400px width regardless of the drawer state', async () => { const { useClipartStore } = await import('@/stores/clipart') const s = useClipartStore() s.setOpen(false) const handle = mountComponent(ClipartDrawer, {}) await nextTick() const panel = handle.host.querySelector('.drawer-panel') as HTMLElement expect(panel).toBeTruthy() const widthClosed = getComputedStyle(panel).width s.setOpen(true) await nextTick() const widthOpen = getComputedStyle(panel).width expect(widthClosed).toBe('400px') expect(widthOpen).toBe('400px') handle.unmount() }) test('.clipart-drawer clips its contents (overflow: hidden)', async () => { const { useClipartStore } = await import('@/stores/clipart') const s = useClipartStore() s.setOpen(false) const handle = mountComponent(ClipartDrawer, {}) await nextTick() const drawer = handle.host.querySelector('.clipart-drawer') as HTMLElement expect(drawer).toBeTruthy() const overflowX = getComputedStyle(drawer).overflowX const overflowY = getComputedStyle(drawer).overflowY // `overflow: hidden` applies to both axes. We check each because // browsers normalise the shorthand into the long-hand properties. expect(overflowX).toBe('hidden') expect(overflowY).toBe('hidden') handle.unmount() }) }) suite('ui — SceneDrawer', () => { async function settlePromptCancel() { // The drawer's `+` button dispatches addScene and then kicks off a // promptDialog for renaming. Tests don't render DialogsHost, so // we resolve the pending prompt explicitly to keep the dialog // queue empty between tests. const { cancelDialog } = await import('@/components/ui/dialogs') cancelDialog() } test('renders one .scene-row per page, with .active on the current one', async () => { const s = makeFreshStore() s.addScene('Two') s.addScene('Three') const activeId = s.project.activeSceneId const handle = mountComponent(SceneDrawer, {}) await nextTick() const rows = handle.host.querySelectorAll('.scene-row') expect(rows.length).toBe(3) // Order matches `project.scenes`. const names = Array.from(rows).map((r) => r.querySelector('.scene-name')?.textContent?.trim()) expect(names).toEqual(s.project.scenes.map((p) => p.name)) // Exactly one row is .active, and it's the active scene. const active = Array.from(rows).filter((r) => r.classList.contains('active')) expect(active.length).toBe(1) const activeName = active[0].querySelector('.scene-name')?.textContent?.trim() expect(activeName).toBe(s.project.scenes.find((p) => p.id === activeId)!.name) handle.unmount() }) test('layer-count badge reflects each scene\'s layer count', async () => { const s = makeFreshStore() const asset = s.addAsset({ kind: 'rect', name: 'D', width: 10, height: 10, fill: '#abc' }) s.addLayer(asset.id) s.addLayer(asset.id) const two = s.addScene('Two') s.addLayer(asset.id) // lands on Two (active after addScene) // Render AFTER state is set up so the component's reactive read // captures the final layer counts. const handle = mountComponent(SceneDrawer, {}) await nextTick() const rows = handle.host.querySelectorAll('.scene-row') expect(rows.length).toBe(2) const metas = Array.from(rows).map((r) => r.querySelector('.scene-meta')?.textContent?.trim(), ) // First row = Scene 1 (2 layers), second row = Two (1 layer). expect(metas).toEqual(['2', '1']) void two handle.unmount() }) test('clicking a non-active row switches to that page and auto-plays', async () => { const s = makeFreshStore() const firstId = s.project.activeSceneId const second = s.addScene('Two') // Go back to Scene 1 and pause so we can observe the auto-play flip. s.switchScene(firstId) s.pause() const handle = mountComponent(SceneDrawer, {}) await nextTick() const rows = handle.host.querySelectorAll('.scene-row') as NodeListOf // Row index 1 = Two (non-active). rows[1].click() await nextTick() expect(s.project.activeSceneId).toBe(second.id) expect(s.playing).toBe(true) handle.unmount() }) test('clicking the already-active row is a no-op', async () => { const s = makeFreshStore() const firstId = s.project.activeSceneId s.pause() const handle = mountComponent(SceneDrawer, {}) await nextTick() const rows = handle.host.querySelectorAll('.scene-row') as NodeListOf // Row index 0 = the active one. rows[0].click() await nextTick() // Still paused — no sneaky re-switch triggered auto-play. expect(s.playing).toBe(false) expect(s.project.activeSceneId).toBe(firstId) handle.unmount() }) test('+ button appends a new scene and activates it', async () => { const s = makeFreshStore() expect(s.project.scenes).toHaveLength(1) const handle = mountComponent(SceneDrawer, {}) await nextTick() const addBtn = handle.host.querySelector('.add-btn') as HTMLButtonElement expect(addBtn).toBeTruthy() addBtn.click() // Let addScene + the nextTick inside addScene resolve. await nextTick() await nextTick() expect(s.project.scenes).toHaveLength(2) expect(s.project.activeSceneId).toBe(s.project.scenes[1].id) // Clean up the pending rename prompt. await settlePromptCancel() handle.unmount() }) test('right-click on a row surfaces the context menu', async () => { const s = makeFreshStore() s.addScene('Two') const handle = mountComponent(SceneDrawer, {}) await nextTick() expect(handle.host.querySelector('.scene-menu')).toBeNull() const row = handle.host.querySelector('.scene-row') as HTMLElement row.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true })) await nextTick() expect(handle.host.querySelector('.scene-menu')).toBeTruthy() // Delete option is disabled only when there's just one scene left. const deleteBtn = handle.host.querySelector('.scene-menu .menu-item.danger') as HTMLButtonElement expect(deleteBtn).toBeTruthy() expect(deleteBtn.disabled).toBe(false) handle.unmount() }) test('context menu disables Delete when only one scene remains', async () => { const s = makeFreshStore() expect(s.project.scenes.length).toBe(1) const handle = mountComponent(SceneDrawer, {}) await nextTick() const row = handle.host.querySelector('.scene-row') as HTMLElement row.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true })) await nextTick() const deleteBtn = handle.host.querySelector('.scene-menu .menu-item.danger') as HTMLButtonElement expect(deleteBtn).toBeTruthy() expect(deleteBtn.disabled).toBe(true) handle.unmount() }) }) suite('ui — ActionEditor (jumpToScene)', () => { test('jumpToScene appears in the type dropdown', async () => { const s = makeFreshStore() s.addScene('Two') const handle = mountComponent( ActionEditor, { actions: [{ type: 'pause' } as const] }, ) await nextTick() const select = handle.host.querySelector('.type-select') as HTMLSelectElement expect(select).toBeTruthy() const values = Array.from(select.options).map((o) => o.value) expect(values.includes('jumpToScene')).toBe(true) handle.unmount() }) test('switching a row to jumpToScene seeds it with the first non-active scene', async () => { const s = makeFreshStore() const activeId = s.project.activeSceneId const other = s.addScene('Other') s.switchScene(activeId) // pull the active marker back to Scene 1 let latest: unknown = null const handle = mountComponent(ActionEditor, { actions: [{ type: 'pause' } as const], 'onUpdate:actions': (v: unknown) => { latest = v }, }) await nextTick() const select = handle.host.querySelector('.type-select') as HTMLSelectElement select.value = 'jumpToScene' select.dispatchEvent(new Event('change', { bubbles: true })) await nextTick() expect(Array.isArray(latest)).toBe(true) const arr = latest as unknown[] expect(arr.length).toBe(1) const next = arr[0] as { type: string; sceneId?: string } expect(next.type).toBe('jumpToScene') // Defaults to the first non-active scene so the action is useful out of the box. expect(next.sceneId).toBe(other.id) handle.unmount() }) test('scene dropdown enumerates every scene by name, emits sceneId on change', async () => { const s = makeFreshStore() const firstId = s.project.scenes[0].id const second = s.addScene('Two') const third = s.addScene('Three') // Active is now Three. The seed action points at Two; changing // to Three via the dropdown should emit { sceneId: third.id }. let latest: unknown = null const handle = mountComponent(ActionEditor, { actions: [{ type: 'jumpToScene', sceneId: second.id } as const], 'onUpdate:actions': (v: unknown) => { latest = v }, }) await nextTick() // Find the scene dropdown (second select on the row — the first // is the action-type select). const selects = handle.host.querySelectorAll('select') expect(selects.length).toBe(2) const sceneSelect = selects[1] as HTMLSelectElement const sceneValues = Array.from(sceneSelect.options).map((o) => o.value) expect(sceneValues).toEqual([firstId, second.id, third.id]) const sceneLabels = Array.from(sceneSelect.options).map((o) => o.textContent?.trim()) expect(sceneLabels).toEqual(['Scene 1', 'Two', 'Three']) sceneSelect.value = third.id sceneSelect.dispatchEvent(new Event('change', { bubbles: true })) await nextTick() const next = (latest as unknown[])[0] as { type: string; sceneId: string } expect(next.type).toBe('jumpToScene') expect(next.sceneId).toBe(third.id) handle.unmount() }) }) 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) }) test('locking a layer directly deselects it', () => { // Regression: locking a layer while it was selected used to leave // the props panel happily editing the now-inert track. The // selection must drop on lock so edits can't leak through. const s = makeFreshStore() const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' }) const l = s.addLayer(a.id)! s.selectLayer(l.id) expect(s.selectedLayerId).toBe(l.id) s.setTrackLocked(l.id, true) expect(s.selectedLayerId).toBe(null) }) test('locking a group deselects a selected child layer', () => { const s = makeFreshStore() const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' }) const l = s.addLayer(a.id)! const g = s.addTrackGroup('G') s.moveTrackIntoGroup(l.id, g.id) s.selectLayer(l.id) expect(s.selectedLayerId).toBe(l.id) s.setTrackLocked(g.id, true) expect(s.selectedLayerId).toBe(null) }) test('locking a group with no selected child leaves selection alone', () => { const s = makeFreshStore() const a = s.addAsset({ kind: 'rect', name: 'A', width: 10, height: 10, fill: '#f00' }) const b = s.addAsset({ kind: 'rect', name: 'B', width: 10, height: 10, fill: '#0f0' }) const la = s.addLayer(a.id)! const lb = s.addLayer(b.id)! const g = s.addTrackGroup('G') // Only move la into the group; lb is selected and stays top-level. s.moveTrackIntoGroup(la.id, g.id) s.selectLayer(lb.id) s.setTrackLocked(g.id, true) // Locking the group can't touch the selection that lives outside it. expect(s.selectedLayerId).toBe(lb.id) }) test('unlocking a group does not auto-reselect the previously-selected layer', () => { const s = makeFreshStore() const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' }) const l = s.addLayer(a.id)! const g = s.addTrackGroup('G') s.moveTrackIntoGroup(l.id, g.id) s.selectLayer(l.id) s.setTrackLocked(g.id, true) // Now unlocking — selection stays null; user re-selects explicitly. s.setTrackLocked(g.id, false) expect(s.selectedLayerId).toBe(null) }) }) 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('actions — evalAction with mock runtime', () => { function makeMockRuntime(init?: Partial): ActionRuntime & { calls: string[] playhead: number playing: boolean warnings: string[] } { const rt = { fps: 60, playbackIn: 0, playbackOut: 10, duration: 10, markers: { intro: 2, outro: 8 }, playhead: 0, playing: false, 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 }, 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('jumpToScene dispatches switchScene on the runtime', () => { let switched: string | null = null const rt = makeMockRuntime({ sceneIds: new Set(['s-a', 's-b']), switchScene(id: string) { switched = id }, }) evalAction({ type: 'jumpToScene', sceneId: 's-b' }, rt) expect(switched).toBe('s-b') expect(rt.warnings.length).toBe(0) }) test('jumpToScene warns on unknown sceneId and does not switch', () => { let switched: string | null = null const rt = makeMockRuntime({ sceneIds: new Set(['s-a']), switchScene(id: string) { switched = id }, }) evalAction({ type: 'jumpToScene', sceneId: 's-ghost' }, rt) expect(switched).toBe(null) expect(rt.warnings.length).toBe(1) expect(rt.warnings[0]).toContain('s-ghost') }) test('jumpToScene warns if runtime has no switchScene handler (e.g. exported bundle)', () => { const rt = makeMockRuntime() // no switchScene, no sceneIds evalAction({ type: 'jumpToScene', sceneId: 's-x' }, rt) expect(rt.warnings.length).toBe(1) expect(rt.warnings[0].toLowerCase()).toContain('does not support') }) test('script action parses and dispatches sub-actions (no warn on valid DSL)', () => { const rt = makeMockRuntime() evalAction({ type: 'script', source: 'play\npause' }, rt) expect(rt.warnings.length).toBe(0) expect(rt.calls).toEqual(['play', 'pause']) }) test('script action warns per invalid DSL line', () => { const rt = makeMockRuntime() evalAction({ type: 'script', source: 'play\nwat\npause' }, rt) expect(rt.warnings.length).toBe(1) expect(rt.warnings[0]).toContain('wat') // Valid lines still fire. expect(rt.calls).toEqual(['play', 'pause']) }) 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() }) test('a scripted jumpToScene switches the active scene and keeps playing', async () => { // User-facing contract: a script track fires a scene-switch in the // middle of a running timeline — the active scene flips and the // timeline keeps running on the new page. All pages share // duration/fps, so the playhead stays put and continues onward. const s = makeFreshStore() s.setDuration(10) const aId = s.project.activeSceneId const bId = s.addScene('B').id s.switchScene(aId) const track = s.addScriptTrack() s.addScriptFrame(track.id, 3, [{ type: 'jumpToScene', sceneId: bId }]) s.play() s.tick(3) expect(s.project.activeSceneId).toBe(bId) expect(s.playing).toBe(true) await tick() }) test('scripted jumpToScene with an unknown id warns and leaves state alone', async () => { const s = makeFreshStore() s.setDuration(10) const aId = s.project.activeSceneId const track = s.addScriptTrack() s.addScriptFrame(track.id, 2, [{ type: 'jumpToScene', sceneId: 'bogus-id' }]) s.play() const warnings: string[] = [] const origWarn = console.warn console.warn = (...args: unknown[]) => { warnings.push(args.map(String).join(' ')) } try { s.tick(2) } finally { console.warn = origWarn } expect(s.project.activeSceneId).toBe(aId) expect(s.playing).toBe(true) expect(warnings.some((w) => w.includes('bogus-id'))).toBe(true) 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('unknown commands report error, other lines still parse', () => { const { actions, errors } = parseScript('play\nwat\npause') expect(errors).toHaveLength(1) expect(errors[0].line).toBe(2) expect(actions.map((a) => a.type)).toEqual(['play', 'pause']) }) }) suite('actions — script type expands via evalAction', () => { test('script source is parsed and each action dispatched in order', () => { const calls: string[] = [] const rt: ActionRuntime = { fps: 60, playbackIn: 0, playbackOut: 10, duration: 10, markers: { intro: 2 }, setPlayhead: (t) => calls.push(`seek:${t}`), play: () => calls.push('play'), pause: () => calls.push('pause'), warn: () => {}, } evalAction( { type: 'script', source: 'pause\njump @intro\nplay' }, rt, ) expect(calls).toEqual(['pause', 'seek:2', 'play']) }) test('script errors surface via rt.warn, valid actions still fire', () => { const warnings: string[] = [] const fired: string[] = [] const rt: ActionRuntime = { fps: 60, playbackIn: 0, playbackOut: 10, duration: 10, markers: {}, setPlayhead: () => {}, play: () => fired.push('play'), pause: () => fired.push('pause'), warn: (m) => warnings.push(m), } evalAction({ type: 'script', source: 'play\nwat\npause' }, rt) expect(fired).toEqual(['play', 'pause']) expect(warnings).toHaveLength(1) expect(warnings[0]).toContain('wat') }) }) suite('scene store — macro model normalization', () => { test('loadScene initializes new arrays when missing', () => { const s = makeFreshStore() const bare = { version: 1 as const, canvas: { width: 640, height: 480, background: '#ffffff' }, duration: 5, fps: 30, assets: [], layers: [], } s.loadScene(bare) expect(s.scene.scriptTracks).toEqual([]) expect(s.scene.scriptFrames).toEqual([]) expect(s.scene.markers).toEqual([]) }) test('setTrackLocked also works on script tracks', () => { const s = makeFreshStore() const t = s.addScriptTrack() s.setTrackLocked(t.id, true) expect(s.scene.scriptTracks?.find((x) => x.id === t.id)?.locked).toBe(true) s.setTrackLocked(t.id, false) expect(s.scene.scriptTracks?.find((x) => x.id === t.id)?.locked).toBe(undefined) }) }) suite('macro-clips — store coverage gaps', () => { test('removeMarker on unknown id is a no-op', () => { const s = makeFreshStore() s.addMarker(1, 'A') s.removeMarker('no-such-id') expect(s.scene.markers).toHaveLength(1) }) test('setMarkerTime / setMarkerColor on unknown id are no-ops', () => { const s = makeFreshStore() s.setMarkerTime('ghost', 5) s.setMarkerColor('ghost', '#ff0000') expect(s.scene.markers).toHaveLength(0) }) test('addMarker accepts an initial color', () => { const s = makeFreshStore() const m = s.addMarker(1, 'A', '#ff8800') expect(m.color).toBe('#ff8800') }) test('removeScriptFrame / setScriptFrameActions / setScriptFrameTime handle unknown ids', () => { const s = makeFreshStore() // None of these should throw or mutate anything. s.removeScriptFrame('nope') s.setScriptFrameActions('nope', [{ type: 'play' }]) s.setScriptFrameTime('nope', 1) expect(s.scene.scriptFrames).toEqual([]) }) }) suite('macro-clips — DSL edge cases', () => { test('empty source yields no actions and no errors', () => { const { actions, errors } = parseScript('') expect(actions).toHaveLength(0) expect(errors).toHaveLength(0) }) test('only comments or whitespace yields no actions', () => { const { actions, errors } = parseScript(' \n\n// note\n // more\n') expect(actions).toHaveLength(0) expect(errors).toHaveLength(0) }) test('trailing blank lines do not produce phantom errors', () => { const { actions, errors } = parseScript('play\n\n\n') expect(actions).toHaveLength(1) expect(errors).toHaveLength(0) }) test('jump with empty @marker reports error', () => { const { errors } = parseScript('jump @') expect(errors).toHaveLength(1) expect(errors[0].message).toContain('marker') }) }) suite('macro-clips — tick firing robustness', () => { test('multiple frames on different tracks fire in time order', async () => { const s = makeFreshStore() s.setDuration(10) const t1 = s.addScriptTrack('A') const t2 = s.addScriptTrack('B') const log: number[] = [] // Three frames: 0.2 (t2), 0.5 (t1), 0.8 (t2) // We can verify ordering via jumpTo actions that push into a list… // but evalAction dispatches to store.setPlayhead etc. We instead // observe the jumpTo time the playhead ends up at after all fire. s.addScriptFrame(t2.id, 0.2, [{ type: 'jumpTo', time: 1 }]) s.addScriptFrame(t1.id, 0.5, [{ type: 'jumpTo', time: 2 }]) s.addScriptFrame(t2.id, 0.8, [{ type: 'jumpTo', time: 3 }]) s.play() s.tick(1) // advances to 1, all three frames in (0, 1] // The last fired wins — since frames fire sorted by time, the last // action to run is jumpTo 3. expect(s.playhead).toBe(3) s.pause() await tick() void log }) test('within a single frame, actions fire in list order', async () => { const s = makeFreshStore() s.setDuration(10) const t = s.addScriptTrack() s.addScriptFrame(t.id, 1, [ { type: 'jumpTo', time: 5 }, { type: 'jumpTo', time: 7 }, { type: 'pause' }, ]) s.play() s.tick(2) expect(s.playhead).toBe(7) expect(s.playing).toBe(false) await tick() }) test('scriptFramesInRange handles exactly-at-next inclusively', () => { const frames = [{ id: 'a', trackId: 't', time: 1, actions: [] as Action[] }] // next strictly equals frame.time → included (open-closed) expect(scriptFramesInRange(frames, 0.5, 1)).toHaveLength(1) // next strictly below → excluded expect(scriptFramesInRange(frames, 0.5, 0.999)).toHaveLength(0) }) }) suite('button asset — data model + edit mode', () => { test('addButtonAsset creates asset with empty layers and hitArea', () => { const s = makeFreshStore() const a = s.addButtonAsset('Play', 160, 60) expect(a.kind).toBe('button') expect(a.name).toBe('Play') expect(a.canvas.width).toBe(160) expect(a.canvas.height).toBe(60) expect(a.layers).toHaveLength(0) expect(a.hitArea).toHaveLength(0) }) test('enter/exit button edit sets mode=states and resets on exit', () => { const s = makeFreshStore() const a = s.addButtonAsset('B') s.enterButtonAssetEdit(a.id) expect(s.activeButtonAssetId).toBe(a.id) expect(s.activeButtonEditMode).toBe('states') expect(s.activeButtonState).toBe('normal') s.setActiveButtonState('hover') expect(s.activeButtonState).toBe('hover') s.exitButtonAssetEdit() expect(s.activeButtonAssetId).toBe(null) expect(s.activeButtonEditMode).toBe(null) expect(s.activeButtonState).toBe(null) }) test('state scrubber syncs playhead to kf-time for mutations', () => { const s = makeFreshStore() const a = s.addButtonAsset('B') const rect = s.addAsset({ kind: 'rect', name: 'R', width: 40, height: 20, fill: '#f00' }) s.enterButtonAssetEdit(a.id) const layer = s.addLayer(rect.id)! // BUTTON_STATE_TIMES is { normal: 1, hover: 2, pressed: 3, disabled: 4 } // so a layer added inside the button edit lands on the "normal" slot. expect(layer.keyframes[0].time).toBe(1) s.setActiveButtonState('hover') expect(s.playhead).toBe(2) s.upsertKeyframe(layer.id, s.playhead, { scaleX: 2, scaleY: 2 }) expect(layer.keyframes.some((k) => Math.abs(k.time - 2) < 1e-6)).toBe(true) s.setActiveButtonState('pressed') expect(s.playhead).toBe(3) s.setActiveButtonState('disabled') expect(s.playhead).toBe(4) }) test('mode toggle: hit parks playhead; returning to states restores it', () => { const s = makeFreshStore() const a = s.addButtonAsset('B') s.enterButtonAssetEdit(a.id) s.setActiveButtonState('pressed') expect(s.playhead).toBe(3) s.setButtonEditMode('hit') expect(s.activeButtonEditMode).toBe('hit') // Hit mode onion-skins the "normal" state (time=1), so the playhead // is parked there — not at 0. expect(s.playhead).toBe(1) // Selected state is preserved across the hit trip. expect(s.activeButtonState).toBe('pressed') s.setButtonEditMode('states') expect(s.playhead).toBe(3) }) test('addLayer in hit mode returns null — no leak into states', () => { const s = makeFreshStore() const a = s.addButtonAsset('B') const rect = s.addAsset({ kind: 'rect', name: 'R', width: 40, height: 20, fill: '#f00' }) s.enterButtonAssetEdit(a.id) s.setButtonEditMode('hit') const result = s.addLayer(rect.id) expect(result).toBe(null) const asset = s.scene.assets.find((x) => x.id === a.id)! expect(asset.kind === 'button' ? asset.layers.length : -1).toBe(0) }) test('hit shape CRUD stays isolated from layers', () => { const s = makeFreshStore() const a = s.addButtonAsset('B') s.addButtonHitShape(a.id, { kind: 'rect', x: 0, y: 0, width: 40, height: 20 }) s.addButtonHitShape(a.id, { kind: 'circle', x: 50, y: 50, radius: 10 }) const asset = s.scene.assets.find((x) => x.id === a.id)! expect(asset.kind === 'button' ? asset.hitArea.length : -1).toBe(2) expect(asset.kind === 'button' ? asset.layers.length : -1).toBe(0) s.removeButtonHitShape(a.id, 0) expect(asset.kind === 'button' ? asset.hitArea.length : -1).toBe(1) }) test('polygon hit shapes persist and update via updateButtonHitShape', () => { const s = makeFreshStore() const a = s.addButtonAsset('B') s.addButtonHitShape(a.id, { kind: 'polygon', points: [ { x: 0, y: 0 }, { x: 100, y: 0 }, { x: 50, y: 80 }, ], }) const get = () => { const asset = s.scene.assets.find((x) => x.id === a.id) if (!asset || asset.kind !== 'button') return null return asset.hitArea[0] } const sh0 = get() expect(sh0 && sh0.kind === 'polygon' ? sh0.points.length : -1).toBe(3) s.updateButtonHitShape(a.id, 0, { points: [ { x: 1, y: 1 }, { x: 101, y: 1 }, { x: 51, y: 81 }, { x: 2, y: 82 }, ], } as Partial) const sh1 = get() expect(sh1 && sh1.kind === 'polygon' ? sh1.points.length : -1).toBe(4) }) }) suite('button asset — state transition runtime', () => { // Regression: button state changes used to snap instantly between // the normal / hover / pressed / disabled slots. `setState` now // records a transition whose sample time lerps between the // corresponding `BUTTON_STATE_TIMES` slots over // `BUTTON_STATE_TRANSITION_MS`. `getEffectiveTime` is what the // renderer reads; it must return an intermediate time during the // transition and snap to the target once finished. async function importRt() { const mod = await import('@/composables/useButtonRuntime') return { rt: mod.useButtonRuntime(), DURATION: mod.BUTTON_STATE_TRANSITION_MS, } } test('getEffectiveTime returns the target slot when no transition is running', async () => { const { rt } = await importRt() rt.clear('btn-a') expect(rt.getEffectiveTime('btn-a', false)).toBe(BUTTON_STATE_TIMES.normal) rt.clear('btn-a') // Fast-path: disabled layers report disabled regardless of the // runtime state or transitions. expect(rt.getEffectiveTime('btn-a', true)).toBe(BUTTON_STATE_TIMES.disabled) rt.clear('btn-a') }) test('setState kicks off a transition; mid-flight time lies between slots', async () => { const { rt, DURATION } = await importRt() rt.clear('btn-b') rt.setState('btn-b', 'hover') // Immediately after setState, time is ~fromTime (normal=1) and // approaching toTime (hover=2). const t0 = rt.getEffectiveTime('btn-b', false) expect(t0).toBeGreaterThan(BUTTON_STATE_TIMES.normal - 0.001) expect(t0).toBeLessThan(BUTTON_STATE_TIMES.hover) // After half the duration the time should be around halfway. await new Promise((r) => setTimeout(r, DURATION / 2)) const tMid = rt.getEffectiveTime('btn-b', false) expect(tMid).toBeGreaterThan(BUTTON_STATE_TIMES.normal) expect(tMid).toBeLessThan(BUTTON_STATE_TIMES.hover) // After the full duration (+ slack) the transition resolves to // the target slot exactly. await new Promise((r) => setTimeout(r, DURATION + 40)) expect(rt.getEffectiveTime('btn-b', false)).toBe(BUTTON_STATE_TIMES.hover) rt.clear('btn-b') }) test('mid-transition setState rebases from the live sample time', async () => { // Regression: if hover → pressed flips in-flight from a previous // normal → hover transition, the new transition should start at // the *current* intermediate sample time instead of snapping // back to the old `from` slot. Otherwise a quick hover+press // produces a visible jump. const { rt, DURATION } = await importRt() rt.clear('btn-c') rt.setState('btn-c', 'hover') await new Promise((r) => setTimeout(r, DURATION / 2)) // At this point the effective time is roughly halfway between // normal (1) and hover (2) — somewhere around 1.4–1.6. const midway = rt.getEffectiveTime('btn-c', false) rt.setState('btn-c', 'pressed') // The freshly-started transition must begin at or just past the // midway sample, not drop back toward normal (1). const after = rt.getEffectiveTime('btn-c', false) // Allow a small drift forward because `setTimeout` and the // subsequent assignment both burn a few ms — the important // thing is we didn't regress into the [1, midway) interval. expect(after).toBeGreaterThan(midway - 0.01) rt.clear('btn-c') }) test('no-op setState (target === current) does not start a transition', async () => { const { rt } = await importRt() rt.clear('btn-d') // Currently normal; asking for normal again is a no-op. rt.setState('btn-d', 'normal') expect(rt.getEffectiveTime('btn-d', false)).toBe(BUTTON_STATE_TIMES.normal) rt.clear('btn-d') }) }) suite('button asset — thumbnail rendering', () => { // Regression: renderButtonThumbnail used to pass images={} and any image // layer inside a button produced a fully-transparent PNG, making // sidebar thumbnails appear grey. Shape layers should always render // visible pixels; image layers render when an images map is supplied. function pngIsNonBlank(dataUrl: string): boolean { // Data URL `data:image/png;base64,...` — non-trivial PNG means the // renderer produced pixels. We accept anything over ~200 bytes of // base64 payload, since an all-transparent 200×80-scaled canvas // compresses to ~220 bytes (IHDR + tiny IDAT). A rect layer that // actually paints fill colour balloons the IDAT noticeably. const i = dataUrl.indexOf(',') return i > 0 && dataUrl.length - i - 1 > 260 } test('returns null when the button has no layers', () => { const s = makeFreshStore() const a = s.addButtonAsset('B', 200, 80) const url = renderButtonThumbnail(a, s.scene) expect(url).toBeNull() }) test('renders shape layers without an images map', () => { const s = makeFreshStore() const a = s.addButtonAsset('B', 200, 80) const rect = s.addAsset({ kind: 'rect', name: 'R', width: 200, height: 80, fill: '#6ea8ff', }) s.enterButtonAssetEdit(a.id) s.addLayer(rect.id)! s.exitButtonAssetEdit() const button = s.scene.assets.find((x) => x.id === a.id)! const url = renderButtonThumbnail( button as Extract, s.scene, ) expect(url).toBeTruthy() // Data URL should be a well-formed PNG with actual content. expect((url ?? '').startsWith('data:image/png;base64,')).toBe(true) expect(pngIsNonBlank(url!)).toBe(true) }) test('accepts a custom images map for image-kind layers', () => { const s = makeFreshStore() const a = s.addButtonAsset('B', 100, 40) const img = s.addAsset({ kind: 'image', name: 'I', src: 'data:image/png;base64,iVBORw0KGgo=', // placeholder width: 100, height: 40, }) s.enterButtonAssetEdit(a.id) s.addLayer(img.id)! s.exitButtonAssetEdit() // Build a trivial HTMLImageElement stand-in. We don't wait for it to // load — the point is just to confirm the function accepts the map // without throwing, and that the button is not null. const fake = new Image() fake.width = 100 fake.height = 40 const button = s.scene.assets.find((x) => x.id === a.id)! const url = renderButtonThumbnail( button as Extract, s.scene, 80, { [img.id]: fake }, ) expect(url).toBeTruthy() expect((url ?? '').startsWith('data:image/png;base64,')).toBe(true) }) }) suite('button asset — keyframes snap to state times', () => { // Regression: keyframes inside a button-asset timeline must only // exist at the 4 state times (BUTTON_STATE_TIMES = normal=1, // hover=2, pressed=3, disabled=4). Any code path that places or // moves a keyframe — upsertKeyframe, setKeyframeTime, // duplicateKeyframe — snaps the time onto the nearest state. Free // placement outside the 4 slots is disallowed. function setupButton() { const s = makeFreshStore() const btn = s.addButtonAsset('B', 200, 80) s.enterButtonAssetEdit(btn.id) const a = s.addAsset({ kind: 'rect', name: 'R', width: 200, height: 80, fill: '#fff' }) const l = s.addLayer(a.id)! // addLayer already put a keyframe at BUTTON_STATE_TIMES.normal (=1). return { s, l } } test('upsertKeyframe snaps the requested time to the nearest state', () => { const { s, l } = setupButton() // 1.4 is closer to state 1 than to state 2 → should land at 1 // (merging with the existing one, rather than creating a new off-state kf). s.upsertKeyframe(l.id, 1.4, { x: 10 }) expect(l.keyframes.every((k) => BUTTON_STATE_TIMES.normal === k.time || BUTTON_STATE_TIMES.hover === k.time || BUTTON_STATE_TIMES.pressed === k.time || BUTTON_STATE_TIMES.disabled === k.time)).toBe(true) // 1.6 is closer to state 2 → new keyframe lands at 2 s.upsertKeyframe(l.id, 1.6, { x: 20 }) expect(l.keyframes.find((k) => Math.abs(k.time - 2) < 1e-6)).toBeTruthy() }) test('setKeyframeTime snaps drag targets onto state times', () => { const { s, l } = setupButton() const kf = l.keyframes[0] expect(kf.time).toBe(BUTTON_STATE_TIMES.normal) // Drag "toward" hover but not all the way — 1.8 is closer to 2 → snaps to 2. s.setKeyframeTime(l.id, kf.id, 1.8) expect(kf.time).toBe(BUTTON_STATE_TIMES.hover) // And 2.9 snaps to pressed. s.setKeyframeTime(l.id, kf.id, 2.9) expect(kf.time).toBe(BUTTON_STATE_TIMES.pressed) }) test('setKeyframeTime refuses to land on an already-occupied state', () => { const { s, l } = setupButton() // Existing kf at state 1. Create a second at state 2. s.upsertKeyframe(l.id, BUTTON_STATE_TIMES.hover, { x: 99 }) const kfAtHover = l.keyframes.find((k) => Math.abs(k.time - 2) < 1e-6)! // Drag the hover kf toward normal. State 1 is already occupied — refuse. s.setKeyframeTime(l.id, kfAtHover.id, 1.1) expect(kfAtHover.time).toBe(BUTTON_STATE_TIMES.hover) }) test('duplicateKeyframe picks the nearest free state slot', () => { const { s, l } = setupButton() // Only state 1 is occupied; duplicate should land on the next // closest free slot — state 2 (hover). const sourceId = l.keyframes[0].id const newId = s.duplicateKeyframe(l.id, sourceId)! expect(newId).toBeTruthy() const dup = l.keyframes.find((k) => k.id === newId)! expect(dup.time).toBe(BUTTON_STATE_TIMES.hover) }) test('duplicateKeyframe returns null when every state slot is taken', () => { const { s, l } = setupButton() s.upsertKeyframe(l.id, BUTTON_STATE_TIMES.hover, { x: 2 }) s.upsertKeyframe(l.id, BUTTON_STATE_TIMES.pressed, { x: 3 }) s.upsertKeyframe(l.id, BUTTON_STATE_TIMES.disabled, { x: 4 }) const sourceId = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-6)!.id const newId = s.duplicateKeyframe(l.id, sourceId) expect(newId).toBe(null) }) test('on the main timeline, snapping is NOT applied (free placement)', () => { const s = makeFreshStore() const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' }) const l = s.addLayer(a.id)! s.upsertKeyframe(l.id, 1.4, { x: 5 }) // No button-edit context → the kf lives at the exact requested time. expect(l.keyframes.some((k) => Math.abs(k.time - 1.4) < 1e-6)).toBe(true) }) }) suite('button asset — mask rendering', () => { // Regression: masks inside buttons used to be ignored by every render // path — the button branch in drawLayers called drawLayers recursively // without the drawMaskedGroup split, so `isMask` layers drew as normal // content. That made mask-preview / thumbnail / main-stage / exported // runtime all show the mask shape instead of clipping. All paths now // route button layers through drawMaskedGroup; these tests pin that. function setupMaskedButton(mode: 'positive' | 'negative') { const s = makeFreshStore() const btn = s.addButtonAsset('B', 100, 100) s.enterButtonAssetEdit(btn.id) // Content: full-canvas red rect. addLayer already keyframes it at // (canvas.width/2, canvas.height/2) at BUTTON_STATE_TIMES.normal, // so we don't need to set anything else. const red = s.addAsset({ kind: 'rect', name: 'R', width: 100, height: 100, fill: '#ff0000', }) s.addLayer(red.id)! // Mask: small circle at canvas center. const mask = s.addAsset({ kind: 'circle', name: 'M', radius: 20, fill: '#ffffff', }) const maskLayer = s.addLayer(mask.id)! s.setLayerMask(maskLayer.id, true, mode) s.exitButtonAssetEdit() const button = s.scene.assets.find((x) => x.id === btn.id) as ButtonAsset return { store: s, button } } test('drawButtonFrame applies a positive mask inside the button', () => { const { store, button } = setupMaskedButton('positive') const canvas = document.createElement('canvas') canvas.width = 100 canvas.height = 100 const ctx = canvas.getContext('2d')! drawButtonFrame(ctx, button, store.scene, BUTTON_STATE_TIMES.normal, {}) const inside = ctx.getImageData(50, 50, 1, 1).data const outside = ctx.getImageData(5, 5, 1, 1).data expect(inside[0] > 200).toBe(true) expect(inside[3] > 200).toBe(true) expect(outside[3]).toBe(0) }) test('drawButtonFrame applies a negative mask inside the button', () => { const { store, button } = setupMaskedButton('negative') const canvas = document.createElement('canvas') canvas.width = 100 canvas.height = 100 const ctx = canvas.getContext('2d')! drawButtonFrame(ctx, button, store.scene, BUTTON_STATE_TIMES.normal, {}) const inside = ctx.getImageData(50, 50, 1, 1).data const outside = ctx.getImageData(5, 5, 1, 1).data // Inside mask: punched out expect(inside[3]).toBe(0) // Outside mask: red content still there expect(outside[0] > 200).toBe(true) expect(outside[3] > 200).toBe(true) }) test('drawSceneFrame applies button-internal masks when the button is placed on the main stage', () => { const { store, button } = setupMaskedButton('positive') // Size the main scene to match the button exactly so coordinates line up. store.scene.canvas.width = 100 store.scene.canvas.height = 100 store.scene.canvas.background = 'transparent' // Place the button as a layer on the main stage, centered. store.addLayer(button.id)! 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 the circular mask: red content visible expect(inside[0] > 200).toBe(true) expect(inside[3] > 200).toBe(true) // Outside the circle: masked away (alpha 0) expect(outside[3]).toBe(0) }) }) suite('i18n — track-menu rename/delete labels', () => { // Regression: the script-track context menu used to surface the raw // keys ("timeline.renameScriptTrack" / ".deleteScriptTrack") because // the i18n messages were missing. Guard every new key so a future // rename of the JSON/TS messages file doesn't silently break the menu. test('renameScriptTrack is translated', () => { const v = i18nT('timeline.renameScriptTrack') expect(v).toBeTruthy() expect(v === 'timeline.renameScriptTrack').toBe(false) }) test('deleteScriptTrack is translated', () => { const v = i18nT('timeline.deleteScriptTrack') expect(v).toBeTruthy() expect(v === 'timeline.deleteScriptTrack').toBe(false) }) test('renameScriptTrackDialog.title + .confirm are translated', () => { const title = i18nT('timeline.renameScriptTrackDialog.title') const confirm = i18nT('timeline.renameScriptTrackDialog.confirm') expect(title === 'timeline.renameScriptTrackDialog.title').toBe(false) expect(confirm === 'timeline.renameScriptTrackDialog.confirm').toBe(false) }) test('sidebar noClips / noButtons empty-states both suggest "yet"', () => { // Regression: the clip library used to read "No clips" while the // button library read "No buttons yet". Minor inconsistency that // the user called out — both should suggest "more coming", not // "there will never be any". expect(i18nT('sidebar.noClips')).toContain('yet') expect(i18nT('sidebar.noButtons')).toContain('yet') }) test('holdDisabledHint key is gone (held kfs keep easing now)', () => { // Regression: "Still frame — easing disabled" used to appear in // the keyframe context menu and the easing buttons were grayed // out. A held kf's easing still matters for the segment INTO it // from the previous keyframe, so the restriction was wrong. // Pin that the note is no longer emitted. const v = i18nT('timeline.holdDisabledHint') expect(v === 'timeline.holdDisabledHint').toBe(true) }) test('app.saveProject tooltip mentions the Ctrl/Cmd+S shortcut', () => { // Regression: Cmd/Ctrl+S was added as a keyboard save shortcut // alongside the dirty-state dot. Surface it in the tooltip so // the shortcut is discoverable; assert both the idle and the // unsaved-changes variant stay in sync with the handler. expect(i18nT('app.saveProject')).toContain('Ctrl/Cmd+S') expect(i18nT('app.saveProjectDirty')).toContain('Ctrl/Cmd+S') expect(i18nT('app.saveProjectDirty')).toContain('unsaved') }) }) suite('timeline — scrub-handle obscured logic', () => { // Regression: the handle used to be drawn over the toggle row when the // playhead scrolled off the left edge, *or* be half-clipped at t=0 // depending on a static z-index. Timeline.vue now flips z-index based // on whether the playhead is still in the visible ruler area. The // rule is `scrollLeft > pxFromTime(playhead)`. Pin that rule so a // future refactor of the computed doesn't accidentally break either // direction. const obscured = (scrollLeft: number, playheadPx: number) => scrollLeft > playheadPx test('t=0 with no horizontal scroll → handle is visible (not obscured)', () => { expect(obscured(0, 0)).toBe(false) }) test('playhead right of the scroll offset → still visible', () => { // User scrolled 120px right but the playhead is way out at 800px in // ruler coordinates — the handle is comfortably in the visible ruler. expect(obscured(120, 800)).toBe(false) }) test('playhead scrolled left behind the name-spacer → obscured', () => { // Playhead at t=0 (0px), scroll at 200px → playhead is off-screen // to the left of the visible ruler → hide behind name-spacer. expect(obscured(200, 0)).toBe(true) }) }) }