import { createApp, defineComponent, h, nextTick, ref, 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, Layer } 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 { renderFrames } from '@/lib/frameExport' import { renderSceneToGif } from '@/lib/gifExport' import { generateApngPreviewHtml, renderSceneToApng } from '@/lib/apngExport' 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 { CLIP_FALLBACK_BOUNDS, computeClipBounds } from '@/lib/clipBounds' 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 DocumentDrawer from '@/components/DocumentDrawer.vue' import ActionEditor from '@/components/ActionEditor.vue' import Timeline from '@/components/Timeline.vue' import AssetSidebar from '@/components/AssetSidebar.vue' import CanvasStage from '@/components/CanvasStage.vue' import ExportDialog from '@/components/ExportDialog.vue' import WelcomeDialog from '@/components/WelcomeDialog.vue' import FullscreenPreview from '@/components/FullscreenPreview.vue' import HelpDialog from '@/components/HelpDialog.vue' import { createAudioRuntime } from '@/composables/useAudioRuntime' import { clearWaveformCache, drawWaveform, getWaveformSlot, slicePeaksForClip, } from '@/composables/useAudioWaveform' import { vTooltip } from '@/directives/tooltip' import { beforeEach, 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('renderFrames yields fps*duration frames across the playback range', async () => { const s = makeFreshStore() s.setDuration(1) s.project.fps = 10 // Tiny 4×4 canvas keeps the loop fast — we only care about counts. s.scene.canvas.width = 4 s.scene.canvas.height = 4 const a = s.addAsset({ kind: 'rect', name: 'R', width: 4, height: 4, fill: '#111' }) s.addLayer(a.id)! const frames: number[] = [] await renderFrames(s.scene, ({ frameIndex, width, height }) => { expect(width).toBe(4) expect(height).toBe(4) frames.push(frameIndex) }) // 1 second at 10fps → 10 frames (indices 0..9). expect(frames.length).toBe(10) expect(frames[0]).toBe(0) expect(frames[frames.length - 1]).toBe(9) }) test('renderFrames aborts when the signal fires', async () => { const s = makeFreshStore() s.setDuration(1) s.project.fps = 10 s.scene.canvas.width = 4 s.scene.canvas.height = 4 const a = s.addAsset({ kind: 'rect', name: 'R', width: 4, height: 4, fill: '#111' }) s.addLayer(a.id)! const controller = new AbortController() let rendered = 0 const promise = renderFrames( s.scene, () => { rendered += 1 if (rendered === 2) controller.abort() }, { signal: controller.signal }, ) let threw = false try { await promise } catch (err) { threw = (err as Error).name === 'AbortError' } expect(threw).toBe(true) expect(rendered).toBeLessThan(10) }) test('renderSceneToGif emits a GIF89a blob', async () => { const s = makeFreshStore() s.setDuration(0.5) s.project.fps = 4 s.scene.canvas.width = 8 s.scene.canvas.height = 8 const a = s.addAsset({ kind: 'rect', name: 'R', width: 8, height: 8, fill: '#22cc88' }) s.addLayer(a.id)! const blob = await renderSceneToGif(s.scene) expect(blob.type).toBe('image/gif') expect(blob.size > 0).toBe(true) const bytes = new Uint8Array(await blob.arrayBuffer()) // GIF magic: "GIF" + "87a" or "89a" const header = String.fromCharCode(bytes[0], bytes[1], bytes[2]) expect(header).toBe('GIF') }) test('generateApngPreviewHtml embeds the data URL + checkerboard CSS', () => { const dataUrl = 'data:image/png;base64,AAAA' const html = generateApngPreviewHtml(dataUrl, 320, 180) // Payload is embedded so the exported HTML works as a single file. expect(html).toContain(dataUrl) // Stage + image render at the canvas's original pixel size — // no auto-fit / aspect-ratio box so a 4K export looks 4K. expect(html).toContain('width: 320px') expect(html).toContain('height: 180px') expect(html.includes('aspect-ratio')).toBe(false) // Checkerboard + toggle buttons are present. expect(html).toContain('.stage.checker') expect(html).toContain('data-bg="checker"') expect(html).toContain('data-bg="solid-dark"') expect(html).toContain('data-bg="solid-light"') }) test('transparent background survives the APNG round-trip (alpha preserved)', async () => { // A transparent canvas with no layers should encode a fully // transparent animation. We decode the produced blob and verify // at least one pixel's alpha channel is zero. const s = makeFreshStore() s.setDuration(0.5) s.project.fps = 4 s.scene.canvas.width = 8 s.scene.canvas.height = 8 s.setCanvasBackground('transparent') const blob = await renderSceneToApng(s.scene) const buf = await blob.arrayBuffer() const { default: UPNG } = await import('upng-js') const decoded = UPNG.decode(buf) const rgbaFrames = UPNG.toRGBA8(decoded) expect(rgbaFrames.length > 0).toBe(true) const first = new Uint8Array(rgbaFrames[0]) // Scan every pixel's alpha byte — at least one should be 0 since // nothing painted on a transparent canvas. let sawTransparent = false for (let i = 3; i < first.length; i += 4) { if (first[i] === 0) { sawTransparent = true break } } expect(sawTransparent).toBe(true) }) test('renderSceneToApng emits a PNG blob with an acTL animation chunk', async () => { const s = makeFreshStore() s.setDuration(0.5) s.project.fps = 4 s.scene.canvas.width = 8 s.scene.canvas.height = 8 const a = s.addAsset({ kind: 'rect', name: 'R', width: 8, height: 8, fill: '#22cc88' }) s.addLayer(a.id)! const blob = await renderSceneToApng(s.scene) expect(blob.type).toBe('image/png') expect(blob.size > 0).toBe(true) const bytes = new Uint8Array(await blob.arrayBuffer()) // PNG magic: 0x89 0x50 0x4E 0x47 0x0D 0x0A 0x1A 0x0A expect(bytes[0]).toBe(0x89) expect(String.fromCharCode(bytes[1], bytes[2], bytes[3])).toBe('PNG') // Scan for the 'acTL' chunk type — present only in animated PNGs. let hasActl = false for (let i = 8; i < bytes.length - 4; i++) { if ( bytes[i] === 0x61 && bytes[i + 1] === 0x63 && bytes[i + 2] === 0x54 && bytes[i + 3] === 0x4c ) { hasActl = true break } } expect(hasActl).toBe(true) }) 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 embeds a preloader (spinner + Promise.all) before starting the frame loop', () => { // Loading big images, video metadata and audio `canplaythrough` // must settle before the rAF kicks off — otherwise the first few // frames stutter while the browser decodes. Structural pins: // - A spinner