Files
RUWIO/src/devtools/tests.ts
T
2026-04-22 07:44:43 +02:00

5962 lines
227 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createApp, h, nextTick, type Component, type Slot } from 'vue'
import {
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<typeof createApp>
unmount: () => void
}
interface RecordingCtx {
ctx: CanvasRenderingContext2D
filterSets: string[]
compositeOps: string[]
}
function makeRecordingCtx(): RecordingCtx {
const canvas = document.createElement('canvas')
canvas.width = 200
canvas.height = 200
const real = canvas.getContext('2d')!
const filterSets: string[] = []
const compositeOps: string[] = []
const proxy = new Proxy(real, {
set(target, prop, value) {
if (prop === 'filter') filterSets.push(value as string)
if (prop === 'globalCompositeOperation') compositeOps.push(value as string)
;(target as unknown as Record<string | symbol, unknown>)[prop as string | symbol] = value
return true
},
get(target, prop) {
const v = (target as unknown as Record<string | symbol, unknown>)[
prop as string | symbol
]
if (typeof v === 'function') return v.bind(target)
return v
},
})
return { ctx: proxy as CanvasRenderingContext2D, filterSets, compositeOps }
}
function mountComponent(
component: Component,
props: Record<string, unknown>,
slots?: Record<string, Slot>,
): MountedHandle {
const host = document.createElement('div')
host.setAttribute('data-test-host', '')
document.body.appendChild(host)
const app = createApp({
render() {
return h(component, props, slots)
},
})
// 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<void> {
return new Promise((r) => setTimeout(r, ms))
}
export function registerAllTests(): void {
suite('scene store — defaults', () => {
test('starts with empty scene, playhead 0, not playing', () => {
const s = makeFreshStore()
expect(s.scene.layers).toHaveLength(0)
expect(s.scene.assets).toHaveLength(0)
expect(s.playhead).toBe(0)
expect(s.playing).toBe(false)
expect(s.scene.duration).toBe(10)
expect(s.scene.fps).toBe(60)
expect(s.selectedLayerId).toBeNull()
})
})
suite('scene store — playhead', () => {
test('setPlayhead clamps to [0, duration]', async () => {
const s = makeFreshStore()
s.setPlayhead(-5)
await tick()
expect(s.playhead).toBe(0)
s.setPlayhead(999)
await tick()
expect(s.playhead).toBe(s.scene.duration)
s.setPlayhead(3.5)
await tick()
expect(s.playhead).toBe(3.5)
})
test('setDuration pulls playhead back if past end', async () => {
const s = makeFreshStore()
s.setPlayhead(9)
await tick()
s.setDuration(5)
await tick()
expect(s.playhead).toBe(5)
})
test('setDuration clamps to min 0.1', () => {
const s = makeFreshStore()
s.setDuration(0)
expect(s.scene.duration).toBe(0.1)
})
})
suite('scene store — layers & selection', () => {
test('addLayer with rect asset creates layer + initial keyframe + selects it', async () => {
const s = makeFreshStore()
const asset = s.addAsset({
kind: 'rect',
name: 'TestRect',
width: 200,
height: 100,
fill: '#ff4455',
})
await tick()
const layer = s.addLayer(asset.id)!
await tick()
expect(s.scene.layers).toHaveLength(1)
expect(s.selectedLayerId).toBe(layer.id)
expect(layer.keyframes).toHaveLength(1)
expect(layer.keyframes[0].time).toBe(0)
expect(layer.offsetX).toBe(100)
expect(layer.offsetY).toBe(50)
expect(layer.base.fill).toBe('#ff4455')
})
test('removeLayer deselects if it was selected', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'circle', name: 'TestCircle', radius: 40, fill: '#44aaff' })
const l = s.addLayer(a.id)!
await tick()
s.removeLayer(l.id)
await tick()
expect(s.scene.layers).toHaveLength(0)
expect(s.selectedLayerId).toBeNull()
})
test('addLayer inserts at the top (index 0)', async () => {
const s = makeFreshStore()
const a1 = s.addAsset({ kind: 'rect', name: 'First', width: 40, height: 40, fill: '#ff4444' })
const a2 = s.addAsset({ kind: 'rect', name: 'Second', width: 40, height: 40, fill: '#44ff44' })
const l1 = s.addLayer(a1.id)!
await tick()
const l2 = s.addLayer(a2.id)!
await tick()
expect(s.scene.layers[0].id).toBe(l2.id)
expect(s.scene.layers[1].id).toBe(l1.id)
})
test('moveLayer reorders layers', async () => {
const s = makeFreshStore()
const a1 = s.addAsset({ kind: 'rect', name: 'A', width: 40, height: 40, fill: '#ff4444' })
const a2 = s.addAsset({ kind: 'rect', name: 'B', width: 40, height: 40, fill: '#44ff44' })
const a3 = s.addAsset({ kind: 'rect', name: 'C', width: 40, height: 40, fill: '#4444ff' })
const l1 = s.addLayer(a1.id)!
const l2 = s.addLayer(a2.id)!
const l3 = s.addLayer(a3.id)!
await tick()
// Insertion at top → starting order: [l3, l2, l1]
s.moveLayer(0, 2)
await tick()
expect(s.scene.layers[0].id).toBe(l2.id)
expect(s.scene.layers[1].id).toBe(l1.id)
expect(s.scene.layers[2].id).toBe(l3.id)
})
test('moveLayer ignores identical from/to', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#000' })
const l = s.addLayer(a.id)!
s.moveLayer(0, 0)
expect(s.scene.layers[0].id).toBe(l.id)
})
test('selectLayer toggles with null', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'circle', name: 'SelC', radius: 30, fill: '#00cc88' })
const l = s.addLayer(a.id)!
await tick()
s.selectLayer(null)
await tick()
expect(s.selectedLayerId).toBeNull()
s.selectLayer(l.id)
await tick()
expect(s.selectedLayerId).toBe(l.id)
})
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<string, unknown>).x).toBe(undefined)
expect((k.props as Record<string, unknown>).y).toBe(undefined)
expect((k.props as Record<string, unknown>).rotation).toBe(undefined)
}
})
test('setLayerSeparated(false) merges tracks back together', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Merge', width: 60, height: 60, fill: '#66ccee' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 1, { x: 400, y: 200, rotation: 45, opacity: 0.5 })
await tick()
s.setLayerSeparated(l.id, true)
await tick()
s.setLayerSeparated(l.id, false)
await tick()
expect(l.separated).toBe(false)
expect(l.positionKeyframes).toBe(undefined)
expect(l.rotationKeyframes).toBe(undefined)
const at1 = l.keyframes.find((k) => Math.abs(k.time - 1) < 0.001)!
expect((at1.props as Record<string, number>).x).toBe(400)
expect((at1.props as Record<string, number>).rotation).toBe(45)
})
})
suite('interpolation — sampleLayer', () => {
test('no keyframes → returns base', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#000' })
const l = s.addLayer(a.id)!
l.keyframes = []
const p = sampleLayer(l, 0.5)
expect(p.x).toBe(l.base.x)
expect(p.y).toBe(l.base.y)
})
test('linear midpoint is halfway', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#000' })
const l = s.addLayer(a.id)!
l.keyframes = [
{ id: 'k1', time: 0, props: { x: 0 }, easing: 'linear' },
{ id: 'k2', time: 2, props: { x: 100 }, easing: 'linear' },
]
l.base = { ...l.base, x: 0, y: 0 }
const p = sampleLayer(l, 1)
expect(p.x).toBeCloseTo(50, 2)
})
test('easeIn midpoint is below linear midpoint', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#000' })
const l = s.addLayer(a.id)!
l.base = { ...l.base, x: 0 }
l.keyframes = [
{ id: 'k1', time: 0, props: { x: 0 }, easing: 'linear' },
{ id: 'k2', time: 2, props: { x: 100 }, easing: 'easeIn' },
]
const p = sampleLayer(l, 1)
expect(p.x).toBeLessThan(50)
expect(p.x).toBeGreaterThan(0)
})
test('hold keyframe holds value until next keyframe', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'H', width: 10, height: 10, fill: '#000' })
const l = s.addLayer(a.id)!
l.base = { ...l.base, x: 0 }
l.keyframes = [
{ id: 'k1', time: 0, props: { x: 0 }, easing: 'linear', hold: true },
{ id: 'k2', time: 2, props: { x: 100 }, easing: 'linear' },
]
expect(sampleLayer(l, 0).x).toBe(0)
expect(sampleLayer(l, 0.5).x).toBe(0)
expect(sampleLayer(l, 1.5).x).toBe(0)
expect(sampleLayer(l, 2).x).toBe(100)
})
test('clamps before first kf to first props, after last to last props', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#000' })
const l = s.addLayer(a.id)!
l.base = { ...l.base, x: 0 }
l.keyframes = [
{ id: 'k1', time: 1, props: { x: 10 }, easing: 'linear' },
{ id: 'k2', time: 2, props: { x: 20 }, easing: 'linear' },
]
expect(sampleLayer(l, 0).x).toBe(10)
expect(sampleLayer(l, 5).x).toBe(20)
})
})
suite('interpolation — color', () => {
test('lerpColor 0 returns start', () => {
expect(lerpColor('#000000', '#ffffff', 0)).toBe('#000000')
})
test('lerpColor 1 returns end', () => {
expect(lerpColor('#000000', '#ffffff', 1)).toBe('#ffffff')
})
test('lerpColor 0.5 is around middle gray', () => {
const mid = lerpColor('#000000', '#ffffff', 0.5)
expect(mid).toBe('#808080')
})
})
suite('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('<title>RUWIO — Example</title>')
expect(html).toContain('<h1>RUWIO Export</h1>')
// 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<Element>) =>
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<typeof stored, { kind: 'rect' }>).blur).toBe(12)
})
test('circle asset stores blur value', () => {
const s = makeFreshStore()
const a = s.addAsset({
kind: 'circle',
name: 'C',
radius: 30,
fill: '#0000ff',
blur: 6,
})
const stored = s.scene.assets.find((x) => x.id === a.id)!
expect(stored.kind).toBe('circle')
expect((stored as Extract<typeof stored, { kind: 'circle' }>).blur).toBe(6)
})
test('drawSceneFrame sets ctx.filter to blur() for blurred rect', () => {
const s = makeFreshStore()
const a = s.addAsset({
kind: 'rect',
name: 'Blurred',
width: 40,
height: 40,
fill: '#ff4488',
blur: 18,
})
s.addLayer(a.id)!
const { ctx, filterSets } = makeRecordingCtx()
drawSceneFrame(ctx, s.scene, 0, {})
expect(filterSets.some((v) => v === 'blur(18px)')).toBe(true)
})
test('drawSceneFrame does not apply blur when blur is unset or zero', () => {
const s = makeFreshStore()
const a = s.addAsset({
kind: 'rect',
name: 'Sharp',
width: 40,
height: 40,
fill: '#123456',
})
s.addLayer(a.id)!
const { ctx, filterSets } = makeRecordingCtx()
drawSceneFrame(ctx, s.scene, 0, {})
// The only filter writes should be the post-draw reset to 'none'
expect(filterSets.every((v) => v === 'none')).toBe(true)
})
test('drawSceneFrame applies blur for circle asset', () => {
const s = makeFreshStore()
const a = s.addAsset({
kind: 'circle',
name: 'CB',
radius: 20,
fill: '#22aa22',
blur: 9,
})
s.addLayer(a.id)!
const { ctx, filterSets } = makeRecordingCtx()
drawSceneFrame(ctx, s.scene, 0, {})
expect(filterSets.some((v) => v === 'blur(9px)')).toBe(true)
})
})
suite('mask layers — store', () => {
test('setLayerMask(true) enables negative mask by default', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'M', width: 40, height: 40, fill: '#fff' })
const l = s.addLayer(a.id)!
s.setLayerMask(l.id, true)
const layer = s.scene.layers.find((x) => x.id === l.id)!
expect(layer.isMask).toBe(true)
expect(layer.maskMode).toBe('negative')
})
test('setLayerMask(true, "negative") stores negative mode', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'circle', name: 'N', radius: 20, fill: '#fff' })
const l = s.addLayer(a.id)!
s.setLayerMask(l.id, true, 'negative')
const layer = s.scene.layers.find((x) => x.id === l.id)!
expect(layer.maskMode).toBe('negative')
})
test('setLayerMask(false) clears mask flags', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'M', width: 40, height: 40, fill: '#fff' })
const l = s.addLayer(a.id)!
s.setLayerMask(l.id, true, 'negative')
s.setLayerMask(l.id, false)
const layer = s.scene.layers.find((x) => x.id === l.id)!
expect(layer.isMask).toBe(undefined)
expect(layer.maskMode).toBe(undefined)
})
test('setLayerMaskMode flips mode without changing isMask', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'M', width: 40, height: 40, fill: '#fff' })
const l = s.addLayer(a.id)!
s.setLayerMask(l.id, true)
s.setLayerMaskMode(l.id, 'negative')
const layer = s.scene.layers.find((x) => x.id === l.id)!
expect(layer.isMask).toBe(true)
expect(layer.maskMode).toBe('negative')
})
})
suite('mask layers — rendering', () => {
function setupMaskScene(mode: 'positive' | 'negative') {
const s = makeFreshStore()
s.scene.canvas.width = 100
s.scene.canvas.height = 100
s.scene.canvas.background = 'transparent'
// Content: full-canvas red rect
const content = s.addAsset({
kind: 'rect',
name: 'BG',
width: 100,
height: 100,
fill: '#ff0000',
})
const contentLayer = s.addLayer(content.id)!
s.upsertKeyframe(contentLayer.id, 0, { x: 50, y: 50 })
// Mask: small circle centered at (50,50)
const mask = s.addAsset({
kind: 'circle',
name: 'M',
radius: 20,
fill: '#ffffff',
})
const maskLayer = s.addLayer(mask.id)!
s.upsertKeyframe(maskLayer.id, 0, { x: 50, y: 50 })
s.setLayerMask(maskLayer.id, true, mode)
const group = s.addTrackGroup('G')
s.moveTrackIntoGroup(maskLayer.id, group.id)
s.moveTrackIntoGroup(contentLayer.id, group.id)
return { store: s }
}
test('positive mask keeps content inside the mask shape and clears outside', () => {
const { store } = setupMaskScene('positive')
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, store.scene, 0, {})
const inside = ctx.getImageData(50, 50, 1, 1).data
const outside = ctx.getImageData(5, 5, 1, 1).data
// Inside mask: red content visible
expect(inside[0] > 200).toBe(true)
expect(inside[3] > 200).toBe(true)
// Outside mask: cleared (alpha 0)
expect(outside[3]).toBe(0)
})
test('negative mask punches a hole in the content where the mask overlaps', () => {
const { store } = setupMaskScene('negative')
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, store.scene, 0, {})
const inside = ctx.getImageData(50, 50, 1, 1).data
const outside = ctx.getImageData(5, 5, 1, 1).data
// Inside mask: hole (alpha 0)
expect(inside[3]).toBe(0)
// Outside mask: red content still there
expect(outside[0] > 200).toBe(true)
expect(outside[3] > 200).toBe(true)
})
test('layer outside a masked group is not affected by the mask', () => {
const { store } = setupMaskScene('positive')
// Add a green rect layer OUTSIDE the group
const outer = store.addAsset({
kind: 'rect',
name: 'Outer',
width: 20,
height: 20,
fill: '#00ff00',
})
const outerLayer = store.addLayer(outer.id)!
store.upsertKeyframe(outerLayer.id, 0, { x: 90, y: 90 })
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, store.scene, 0, {})
// The outer layer renders fully regardless of mask
const outerPx = ctx.getImageData(90, 90, 1, 1).data
expect(outerPx[1] > 200).toBe(true)
expect(outerPx[3] > 200).toBe(true)
})
})
suite('scene store — shiftKeyframesPosition', () => {
test('shifts x/y of a single keyframe by delta', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Shift', width: 40, height: 40, fill: '#a55' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 1, { x: 100, y: 50 })
const kf = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
s.shiftKeyframesPosition(
[{ layerId: l.id, kind: 'main', keyframeId: kf.id }],
25,
-10,
)
await tick()
expect(kf.props.x).toBe(125)
expect(kf.props.y).toBe(40)
})
test('shifts multiple keyframes across layers by the same delta', async () => {
const s = makeFreshStore()
const a1 = s.addAsset({ kind: 'rect', name: 'A', width: 10, height: 10, fill: '#111' })
const a2 = s.addAsset({ kind: 'rect', name: 'B', width: 10, height: 10, fill: '#222' })
const l1 = s.addLayer(a1.id)!
const l2 = s.addLayer(a2.id)!
s.upsertKeyframe(l1.id, 1, { x: 100, y: 100 })
s.upsertKeyframe(l2.id, 2, { x: 200, y: 300 })
const k1 = l1.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
const k2 = l2.keyframes.find((k) => Math.abs(k.time - 2) < 1e-3)!
s.shiftKeyframesPosition(
[
{ layerId: l1.id, kind: 'main', keyframeId: k1.id },
{ layerId: l2.id, kind: 'main', keyframeId: k2.id },
],
10,
5,
)
await tick()
expect(k1.props.x).toBe(110)
expect(k1.props.y).toBe(105)
expect(k2.props.x).toBe(210)
expect(k2.props.y).toBe(305)
})
test('skips rotation keyframes (no x/y)', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#333' })
const l = s.addLayer(a.id)!
s.setLayerSeparated(l.id, true)
s.upsertKeyframe(l.id, 1, { rotation: 45 })
const rotKf = (l.rotationKeyframes ?? []).find((k) => Math.abs(k.time - 1) < 1e-3)!
s.shiftKeyframesPosition(
[{ layerId: l.id, kind: 'rotation', keyframeId: rotKf.id }],
100,
100,
)
await tick()
expect((rotKf.props as Record<string, unknown>).x).toBe(undefined)
expect((rotKf.props as Record<string, unknown>).y).toBe(undefined)
expect(rotKf.props.rotation).toBe(45)
})
test('uses sampled fallback when keyframe has no explicit x/y', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Fb', width: 10, height: 10, fill: '#444' })
const l = s.addLayer(a.id)!
// initial keyframe at t=0 has full base props (including x=640 from canvas center)
// Create a later keyframe that only sets scaleX
s.upsertKeyframe(l.id, 2, { scaleX: 2 })
const kf = l.keyframes.find((k) => Math.abs(k.time - 2) < 1e-3)!
// ensure x is not explicitly set
delete (kf.props as Record<string, unknown>).x
delete (kf.props as Record<string, unknown>).y
const sampledBefore = sampleLayer(l, 2)
s.shiftKeyframesPosition([{ layerId: l.id, kind: 'main', keyframeId: kf.id }], 7, 11)
await tick()
expect(kf.props.x).toBe(sampledBefore.x + 7)
expect(kf.props.y).toBe(sampledBefore.y + 11)
})
test('non-existing layer/keyframe refs are safely ignored', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#555' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 1, { x: 50 })
// Call with mix of valid and invalid refs — should not throw
s.shiftKeyframesPosition(
[
{ layerId: 'missing-layer', kind: 'main', keyframeId: 'missing-kf' },
{ layerId: l.id, kind: 'main', keyframeId: 'also-missing' },
],
10,
10,
)
// Valid keyframe at t=1 remained unshifted (we didn't target it)
const kf = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
expect(kf.props.x).toBe(50)
})
})
suite('polygon — store actions', () => {
function makePolygonLayer() {
const s = makeFreshStore()
const asset = s.addAsset({
kind: 'polygon',
name: 'Poly',
points: [
{ x: 0, y: -40 },
{ x: 40, y: 20 },
{ x: -40, y: 20 },
],
fill: '#abc',
}) as PolygonAsset
const layer = s.addLayer(asset.id)!
return { s, asset, layer }
}
test('addLayer seeds initial keyframe with points for polygons', () => {
const { layer, asset } = makePolygonLayer()
const kf0 = layer.keyframes[0]
expect(kf0).toBeTruthy()
expect(Array.isArray(kf0.props.points)).toBe(true)
expect(kf0.props.points!.length).toBe(asset.points.length)
})
test('movePolygonVertex writes a points-array kf at playhead', () => {
const { s, layer, asset } = makePolygonLayer()
s.setPlayhead(0)
s.movePolygonVertex(layer.id, 0, 10, -50)
const kf = layer.keyframes.find((k) => Math.abs(k.time) < 1e-3)!
expect(kf.props.points![0].x).toBe(10)
expect(kf.props.points![0].y).toBe(-50)
// Other points remain unchanged
expect(kf.props.points![1].x).toBe(asset.points[1].x)
expect(kf.props.points![1].y).toBe(asset.points[1].y)
})
test('setPolygonPointCount grows by midpoint insertion and keeps KFs in sync', () => {
const { s, layer, asset } = makePolygonLayer()
const originalLen = asset.points.length
s.setPolygonPointCount(asset.id, originalLen + 2)
expect(asset.points.length).toBe(originalLen + 2)
for (const kf of layer.keyframes) {
if (!kf.props.points) continue
expect(kf.props.points.length).toBe(originalLen + 2)
}
})
test('setPolygonPointCount shrinks but never below 3', () => {
const { s, asset } = makePolygonLayer()
s.setPolygonPointCount(asset.id, 1)
expect(asset.points.length).toBe(3)
})
test('insertPolygonPointOnEdge inserts at expected index across all keyframes', () => {
const { s, layer, asset } = makePolygonLayer()
s.insertPolygonPointOnEdge(asset.id, 1, 0.5)
expect(asset.points.length).toBe(4)
const kf = layer.keyframes[0]
expect(kf.props.points!.length).toBe(4)
// midpoint between original [0] and [1]
const expectedX = (0 + 40) / 2
const expectedY = (-40 + 20) / 2
expect(asset.points[1].x).toBe(expectedX)
expect(asset.points[1].y).toBe(expectedY)
expect(kf.props.points![1].x).toBe(expectedX)
expect(kf.props.points![1].y).toBe(expectedY)
})
test('reversePolygonPoints reverses asset + keyframe points', () => {
const { s, layer, asset } = makePolygonLayer()
const firstBefore = { ...asset.points[0] }
s.reversePolygonPoints(asset.id)
expect(asset.points[asset.points.length - 1].x).toBe(firstBefore.x)
expect(asset.points[asset.points.length - 1].y).toBe(firstBefore.y)
const kf = layer.keyframes[0]
expect(kf.props.points![kf.props.points!.length - 1].x).toBe(firstBefore.x)
})
test('setPolygonClosed toggles the closed flag', () => {
const { s, asset } = makePolygonLayer()
s.setPolygonClosed(asset.id, false)
expect(asset.closed).toBe(false)
s.setPolygonClosed(asset.id, true)
expect(asset.closed).toBe(true)
})
})
suite('freeform drawing — lifecycle', () => {
test('startFreeformDrawing creates asset and layer', () => {
const s = makeFreshStore()
s.startFreeformDrawing()
expect(s.drawingMode).toBe('freeform')
expect(s.drawingAssetId).toBeTruthy()
expect(s.drawingLayerId).toBeTruthy()
const asset = s.scene.assets.find((a) => a.id === s.drawingAssetId)!
expect(asset.kind).toBe('polygon')
expect((asset as PolygonAsset).closed).toBe(false)
})
test('addDrawingPoint appends to asset and to KF points', () => {
const s = makeFreshStore()
s.startFreeformDrawing()
s.addDrawingPoint(10, 20)
s.addDrawingPoint(30, 40)
const asset = s.scene.assets.find((a) => a.id === s.drawingAssetId) as PolygonAsset
expect(asset.points.length).toBe(2)
expect(asset.points[1].x).toBe(30)
const layer = s.activeLayers.find((l) => l.id === s.drawingLayerId)!
expect(layer.keyframes[0].props.points!.length).toBe(2)
})
test('finishDrawing with <2 points drops the in-progress asset', () => {
const s = makeFreshStore()
s.startFreeformDrawing()
const assetId = s.drawingAssetId!
const layerId = s.drawingLayerId!
s.addDrawingPoint(5, 5) // only 1 point
s.finishDrawing()
expect(s.drawingMode).toBeNull()
expect(s.scene.assets.some((a) => a.id === assetId)).toBe(false)
expect(s.activeLayers.some((l) => l.id === layerId)).toBe(false)
})
test('finishDrawing with >=2 points keeps the layer and clears drawing state', () => {
const s = makeFreshStore()
s.startFreeformDrawing()
s.addDrawingPoint(0, 0)
s.addDrawingPoint(50, 50)
const assetId = s.drawingAssetId!
const layerId = s.drawingLayerId!
s.finishDrawing()
expect(s.drawingMode).toBeNull()
expect(s.drawingAssetId).toBeNull()
expect(s.drawingLayerId).toBeNull()
expect(s.scene.assets.some((a) => a.id === assetId)).toBe(true)
expect(s.activeLayers.some((l) => l.id === layerId)).toBe(true)
})
test('cancelDrawing removes the in-progress asset and layer', () => {
const s = makeFreshStore()
s.startFreeformDrawing()
s.addDrawingPoint(0, 0)
s.addDrawingPoint(10, 10)
const assetId = s.drawingAssetId!
const layerId = s.drawingLayerId!
s.cancelDrawing()
expect(s.drawingMode).toBeNull()
expect(s.scene.assets.some((a) => a.id === assetId)).toBe(false)
expect(s.activeLayers.some((l) => l.id === layerId)).toBe(false)
})
test('startFreeformDrawing resumes on selected open polygon instead of creating new', () => {
const s = makeFreshStore()
s.startFreeformDrawing()
s.addDrawingPoint(0, 0)
s.addDrawingPoint(20, 20)
const assetId = s.drawingAssetId!
const layerId = s.drawingLayerId!
s.finishDrawing()
// Layer stays selected; invoke start again
s.selectLayer(layerId)
s.startFreeformDrawing()
expect(s.drawingMode).toBe('freeform')
expect(s.drawingAssetId).toBe(assetId)
expect(s.drawingLayerId).toBe(layerId)
})
})
suite('polygon + stroke — rendering', () => {
test('drawSceneFrame renders a filled closed polygon in fill color', () => {
const s = makeFreshStore()
s.scene.canvas.width = 100
s.scene.canvas.height = 100
s.scene.canvas.background = 'transparent'
const a = s.addAsset({
kind: 'polygon',
name: 'Tri',
points: [
{ x: 0, y: -30 },
{ x: 30, y: 20 },
{ x: -30, y: 20 },
],
fill: '#ff0000',
})
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 0, { x: 50, y: 50 })
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, s.scene, 0, {})
const inside = ctx.getImageData(50, 55, 1, 1).data
expect(inside[0] > 200).toBe(true) // red channel
expect(inside[3] > 200).toBe(true) // alpha
})
test('open polygon with stroke renders only the stroke path', () => {
const s = makeFreshStore()
s.scene.canvas.width = 100
s.scene.canvas.height = 100
s.scene.canvas.background = 'transparent'
const a = s.addAsset({
kind: 'polygon',
name: 'Line',
points: [
{ x: -40, y: 0 },
{ x: 40, y: 0 },
],
fill: 'transparent',
closed: false,
stroke: '#00ff00',
strokeWidth: 4,
})
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 0, { x: 50, y: 50 })
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, s.scene, 0, {})
const onLine = ctx.getImageData(50, 50, 1, 1).data
const offLine = ctx.getImageData(50, 80, 1, 1).data
expect(onLine[1] > 200).toBe(true) // green on line
expect(onLine[3] > 200).toBe(true)
expect(offLine[3]).toBe(0) // transparent elsewhere
})
test('rect with stroke renders the outline', () => {
const s = makeFreshStore()
s.scene.canvas.width = 100
s.scene.canvas.height = 100
s.scene.canvas.background = 'transparent'
const a = s.addAsset({
kind: 'rect',
name: 'R',
width: 40,
height: 40,
fill: 'transparent',
stroke: '#0000ff',
strokeWidth: 4,
})
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 0, { x: 50, y: 50 })
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, s.scene, 0, {})
// On the top edge of the rect
const onEdge = ctx.getImageData(50, 30, 1, 1).data
// Interior
const inside = ctx.getImageData(50, 50, 1, 1).data
expect(onEdge[2] > 200).toBe(true) // blue on edge
expect(inside[3]).toBe(0) // transparent interior
})
test('strokeWidth without explicit stroke falls back to black', () => {
const s = makeFreshStore()
s.scene.canvas.width = 100
s.scene.canvas.height = 100
s.scene.canvas.background = 'transparent'
const a = s.addAsset({
kind: 'rect',
name: 'R',
width: 40,
height: 40,
fill: 'transparent',
strokeWidth: 4,
})
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 0, { x: 50, y: 50 })
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, s.scene, 0, {})
const onEdge = ctx.getImageData(50, 30, 1, 1).data
// Black stroke: RGB all low, alpha high
expect(onEdge[0] < 40).toBe(true)
expect(onEdge[1] < 40).toBe(true)
expect(onEdge[2] < 40).toBe(true)
expect(onEdge[3] > 200).toBe(true)
})
test('transparent fill on closed polygon skips fill', () => {
const s = makeFreshStore()
s.scene.canvas.width = 100
s.scene.canvas.height = 100
s.scene.canvas.background = 'transparent'
const a = s.addAsset({
kind: 'polygon',
name: 'Hollow',
points: [
{ x: -30, y: -30 },
{ x: 30, y: -30 },
{ x: 30, y: 30 },
{ x: -30, y: 30 },
],
fill: 'transparent',
closed: true,
stroke: '#ff00ff',
strokeWidth: 3,
})
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 0, { x: 50, y: 50 })
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, s.scene, 0, {})
// Center should be fully transparent (no fill)
const center = ctx.getImageData(50, 50, 1, 1).data
expect(center[3]).toBe(0)
})
})
suite('bezier path — interpolation', () => {
test('sampleLayer returns quadratic bezier midpoint when ctrl is set', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'B', width: 10, height: 10, fill: '#111' })
const l = s.addLayer(a.id)!
// Two keyframes: (0,0) at t=0 and (100,0) at t=1 with a control at (50,100).
s.upsertKeyframe(l.id, 0, { x: 0, y: 0 })
s.upsertKeyframe(l.id, 1, { x: 100, y: 0 })
const cur = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
s.setKeyframeControl(l.id, cur.id, 50, 100)
const samp = sampleLayer(l, 0.5)
// At u=0.5: B = 0.25*0 + 0.5*50 + 0.25*100 = 25 + 25 = 50 x; y = 0.5*100 = 50
expect(Math.abs(samp.x - 50) < 1e-6).toBe(true)
expect(Math.abs(samp.y - 50) < 1e-6).toBe(true)
})
test('bezier affects only x/y; other props remain linear', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'B', width: 10, height: 10, fill: '#111' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 0, { x: 0, y: 0, opacity: 0 })
s.upsertKeyframe(l.id, 1, { x: 100, y: 0, opacity: 1 })
const cur = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
s.setKeyframeControl(l.id, cur.id, 50, 200)
const samp = sampleLayer(l, 0.5)
expect(Math.abs(samp.opacity - 0.5) < 1e-6).toBe(true)
})
test('endpoints are not affected by control point', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'B', width: 10, height: 10, fill: '#111' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 0, { x: 10, y: 20 })
s.upsertKeyframe(l.id, 2, { x: 110, y: 200 })
const cur = l.keyframes.find((k) => Math.abs(k.time - 2) < 1e-3)!
s.setKeyframeControl(l.id, cur.id, 500, 500)
const start = sampleLayer(l, 0)
const end = sampleLayer(l, 2)
expect(start.x).toBe(10)
expect(start.y).toBe(20)
expect(end.x).toBe(110)
expect(end.y).toBe(200)
})
})
suite('keyframe control — store actions', () => {
test('setKeyframeControl stores ctrlX/Y on the keyframe', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'K', width: 10, height: 10, fill: '#111' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 1, { x: 50, y: 50 })
const kf = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
s.setKeyframeControl(l.id, kf.id, 30, 40)
expect(kf.ctrlX).toBe(30)
expect(kf.ctrlY).toBe(40)
})
test('clearKeyframeControl removes ctrlX/Y', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'K', width: 10, height: 10, fill: '#111' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 1, { x: 50, y: 50 })
const kf = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
s.setKeyframeControl(l.id, kf.id, 30, 40)
s.clearKeyframeControl(l.id, kf.id)
expect(kf.ctrlX).toBe(undefined)
expect(kf.ctrlY).toBe(undefined)
})
test('setKeyframeControlFromPoint solves so the curve passes through the target', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'K', width: 10, height: 10, fill: '#111' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 0, { x: 0, y: 0 })
s.upsertKeyframe(l.id, 1, { x: 100, y: 0 })
const cur = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
// We want the curve at u=0.5 to pass through (50, 80).
s.setKeyframeControlFromPoint(l.id, cur.id, 0.5, 50, 80)
const samp = sampleLayer(l, 0.5)
expect(Math.abs(samp.x - 50) < 1e-6).toBe(true)
expect(Math.abs(samp.y - 80) < 1e-6).toBe(true)
})
test('setKeyframeControlFromPoint is a no-op for endpoints (u=0 or u=1)', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'K', width: 10, height: 10, fill: '#111' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 0, { x: 0, y: 0 })
s.upsertKeyframe(l.id, 1, { x: 100, y: 0 })
const cur = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
s.setKeyframeControlFromPoint(l.id, cur.id, 0, 10, 10)
s.setKeyframeControlFromPoint(l.id, cur.id, 1, 99, 99)
expect(cur.ctrlX).toBe(undefined)
expect(cur.ctrlY).toBe(undefined)
})
test('setKeyframeControlFromPoint needs a previous keyframe', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'K', width: 10, height: 10, fill: '#111' })
const l = s.addLayer(a.id)!
// Single keyframe at t=0 only
const kf = l.keyframes[0]
s.setKeyframeControlFromPoint(l.id, kf.id, 0.5, 20, 20)
expect(kf.ctrlX).toBe(undefined)
})
})
suite('clipart store — state', () => {
test('toggle flips open state', async () => {
const { useClipartStore } = await import('@/stores/clipart')
const s = useClipartStore()
s.$reset()
const start = s.open
s.toggle()
expect(s.open).toBe(!start)
s.toggle()
expect(s.open).toBe(start)
})
test('setOpen persists to localStorage', async () => {
const { useClipartStore } = await import('@/stores/clipart')
const s = useClipartStore()
s.setOpen(true)
expect(localStorage.getItem('animatr:clipartDrawerOpen')).toBe('1')
s.setOpen(false)
expect(localStorage.getItem('animatr:clipartDrawerOpen')).toBe('0')
})
test('clearFolder resets handle, folderName and items', async () => {
const { useClipartStore } = await import('@/stores/clipart')
const s = useClipartStore()
// simulate a loaded state
s.folderName = 'mock'
s.handle = {} as FileSystemDirectoryHandle
s.items = []
await s.clearFolder()
expect(s.handle).toBeNull()
expect(s.folderName).toBeNull()
expect(s.items.length).toBe(0)
})
test('supported flag matches FileSystemAccess helper', async () => {
const { useClipartStore } = await import('@/stores/clipart')
const { isFileSystemAccessSupported } = await import('@/lib/clipartLibrary')
const s = useClipartStore()
expect(s.supported).toBe(isFileSystemAccessSupported())
})
})
suite('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<HTMLButtonElement>
// 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<HTMLButtonElement>
// 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<HTMLButtonElement>('button.alpha-toggle-btn'),
)
}
function resetSectionPrefs() {
// Ensure the Appearance section is open (default) regardless of any
// previous session's state persisted in localStorage.
try {
localStorage.removeItem('animatr:propSections')
} catch {
/* noop */
}
}
test('fill: toggle transparent on writes "transparent" to keyframe', async () => {
resetSectionPrefs()
const s = makeFreshStore()
const a = s.addAsset({
kind: 'rect',
name: 'R',
width: 50,
height: 50,
fill: '#ff0000',
})
const l = s.addLayer(a.id)!
s.selectLayer(l.id)
const handle = mountComponent(PropertiesPanel, {})
await nextTick()
await tick()
const toggles = findAlphaToggles(handle.host)
const fillToggle = toggles[0]
expect(!!fillToggle).toBe(true)
fillToggle.click()
await tick()
const kf = l.keyframes[0]
expect(kf.props.fill).toBe('transparent')
handle.unmount()
})
test('fill: toggle off restores the previous non-transparent color', async () => {
resetSectionPrefs()
const s = makeFreshStore()
const a = s.addAsset({
kind: 'rect',
name: 'R',
width: 50,
height: 50,
fill: '#ff0000',
})
const l = s.addLayer(a.id)!
s.selectLayer(l.id)
const handle = mountComponent(PropertiesPanel, {})
await nextTick()
await tick()
const toggles = findAlphaToggles(handle.host)
const fillToggle = toggles[0]
// On
fillToggle.click()
await tick()
expect(l.keyframes[0].props.fill).toBe('transparent')
// Off
fillToggle.click()
await tick()
const restored = l.keyframes[0].props.fill
expect(typeof restored).toBe('string')
expect(restored !== 'transparent').toBe(true)
// Should match the original fill (layer.base.fill was copied from asset)
expect(restored).toBe('#ff0000')
handle.unmount()
})
test('stroke: toggle on then off restores to a visible color (auto-black fallback)', async () => {
resetSectionPrefs()
const s = makeFreshStore()
// Rect without an explicit stroke — the restore should fall back to black
const a = s.addAsset({
kind: 'rect',
name: 'R',
width: 50,
height: 50,
fill: '#123456',
})
const l = s.addLayer(a.id)!
s.selectLayer(l.id)
const handle = mountComponent(PropertiesPanel, {})
await nextTick()
await tick()
const toggles = findAlphaToggles(handle.host)
const strokeToggle = toggles[1]
expect(!!strokeToggle).toBe(true)
strokeToggle.click()
await tick()
expect(l.keyframes[0].props.stroke).toBe('transparent')
strokeToggle.click()
await tick()
const restored = l.keyframes[0].props.stroke
expect(typeof restored).toBe('string')
expect(restored !== 'transparent').toBe(true)
handle.unmount()
})
test('fill: toggle off respects a user-picked color before transparency', async () => {
resetSectionPrefs()
const s = makeFreshStore()
const a = s.addAsset({
kind: 'rect',
name: 'R',
width: 50,
height: 50,
fill: '#6ea8ff',
})
const l = s.addLayer(a.id)!
s.selectLayer(l.id)
// User explicitly picked green before toggling transparent
s.upsertKeyframe(l.id, 0, { fill: '#00ff00' })
const handle = mountComponent(PropertiesPanel, {})
await nextTick()
await tick()
const toggles = findAlphaToggles(handle.host)
const fillToggle = toggles[0]
fillToggle.click()
await tick()
expect(l.keyframes[0].props.fill).toBe('transparent')
fillToggle.click()
await tick()
// Restore should be the user's green, not the asset default blue
expect(l.keyframes[0].props.fill).toBe('#00ff00')
handle.unmount()
})
})
suite('timeline — show locked tracks', () => {
test('toggling showLocked persists to localStorage', async () => {
const { showLocked } = await import('@/composables/useShowLocked')
const KEY = 'animatr:showLockedTracks'
showLocked.value = true
await tick()
expect(localStorage.getItem(KEY)).toBe('1')
showLocked.value = false
await tick()
expect(localStorage.getItem(KEY)).toBe('0')
showLocked.value = true
})
test('showLocked ref is shared across importers', async () => {
const a = await import('@/composables/useShowLocked')
const b = await import('@/composables/useShowLocked')
// Same module → same ref instance
expect(a.showLocked).toBe(b.showLocked)
a.showLocked.value = false
await tick()
expect(b.showLocked.value).toBe(false)
a.showLocked.value = true
})
})
suite('scene store — group lock', () => {
test('setTrackLocked on a group id sets group.locked', () => {
const s = makeFreshStore()
const g = s.addTrackGroup('G')
s.setTrackLocked(g.id, true)
const grp = s.scene.groups!.find((x) => x.id === g.id)!
expect(grp.locked).toBe(true)
})
test('setTrackLocked(false) clears group.locked', () => {
const s = makeFreshStore()
const g = s.addTrackGroup('G')
s.setTrackLocked(g.id, true)
s.setTrackLocked(g.id, false)
const grp = s.scene.groups!.find((x) => x.id === g.id)!
expect(grp.locked).toBe(undefined)
})
test('setTrackLocked dispatches to layer, clip or group based on id', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#111' })
const l = s.addLayer(a.id)!
const ct = s.addClipTrack()
const g = s.addTrackGroup('G')
s.setTrackLocked(l.id, true)
s.setTrackLocked(ct.id, true)
s.setTrackLocked(g.id, true)
expect(l.locked).toBe(true)
const stored = s.scene.clipTracks!.find((x) => x.id === ct.id)!
expect(stored.locked).toBe(true)
const grp = s.scene.groups!.find((x) => x.id === g.id)!
expect(grp.locked).toBe(true)
})
test('locking a group does not mutate child layer.locked', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#111' })
const l = s.addLayer(a.id)!
const g = s.addTrackGroup('G')
s.moveTrackIntoGroup(l.id, g.id)
s.setTrackLocked(g.id, true)
// The layer itself stays unlocked; effective lock is derived in UI code.
expect(l.locked).toBe(undefined)
})
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>): 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<typeof makeMockRuntime>
}
test('play / pause / rewindToLoop', () => {
const rt = makeMockRuntime({ playbackIn: 2 })
evalAction({ type: 'play' }, rt)
evalAction({ type: 'pause' }, rt)
evalAction({ type: 'rewindToLoop' }, rt)
expect(rt.playing).toBe(false)
expect(rt.playhead).toBe(2)
expect(rt.calls).toEqual(['play', 'pause', 'setPlayhead:2'])
})
test('jumpTo clamps to [0, duration]', () => {
const rt = makeMockRuntime()
evalAction({ type: 'jumpTo', time: -3 }, rt)
expect(rt.playhead).toBe(0)
evalAction({ type: 'jumpTo', time: 99 }, rt)
expect(rt.playhead).toBe(10)
evalAction({ type: 'jumpTo', time: 5 }, rt)
expect(rt.playhead).toBe(5)
})
test('jumpToFrame divides by fps', () => {
const rt = makeMockRuntime({ fps: 30 })
evalAction({ type: 'jumpToFrame', frame: 90 }, rt)
expect(rt.playhead).toBe(3)
})
test('jumpToMarker resolves via markers map', () => {
const rt = makeMockRuntime()
evalAction({ type: 'jumpToMarker', name: 'outro' }, rt)
expect(rt.playhead).toBe(8)
})
test('jumpToMarker warns on unknown name and does not move playhead', () => {
const rt = makeMockRuntime()
const before = rt.playhead
evalAction({ type: 'jumpToMarker', name: 'missing' }, rt)
expect(rt.playhead).toBe(before)
expect(rt.warnings.length).toBe(1)
expect(rt.warnings[0]).toContain('missing')
})
test('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<HitShape>)
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<typeof button, { kind: 'button' }>,
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<typeof button, { kind: 'button' }>,
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)
})
})
}