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