Files
RUWIO/src/devtools/tests.ts
T
thandClaude Opus 4.7 56cecabaae 0.18.3: grouped clip tracks render, playhead is a snap magnet
- Fix mask-preview / frame-export dropping any group whose children
  are clip tracks. render.ts's old group branch only pulled Layer
  children out of group.childIds; new drawTrackGroup walks both
  layer AND clip-track ids and still applies group-local masks to
  the composite. Same fix mirrored into the HTML export runtime in
  exportPlayer.ts.
- Snapping magnet for the playhead: snapTime now includes
  store.playhead in its candidate list by default. Clip drags, trim
  handles, keyframe drags, marker drags and in/out brackets stick
  to the needle when snap is on. Scrub handlers pass
  ignorePlayhead:true so the needle doesn't stick to its own prior
  position during a scrub.
- Clip trim handles derive the snapped edge first and back out to
  trimStart/trimEnd, so the edge lands exactly on the magnet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:20:18 +02:00

11255 lines
443 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createApp, defineComponent, h, nextTick, ref, type Component, type Slot } from 'vue'
import {
createBlankProject,
createBlankScene,
projectFromLegacyScene,
useSceneStore,
} from '@/stores/scene'
import {
buildMarkerMap,
evalAction,
scriptFramesInRange,
type ActionRuntime,
} from '@/lib/actions'
import { parseScript } from '@/lib/scriptDsl'
import type { Action, ButtonAsset, HitShape, Layer } from '@/types/scene'
import { BUTTON_STATE_TIMES } from '@/types/scene'
import { isLayerActiveAt, sampleLayer, lerpColor } from '@/lib/interpolate'
import { snapToCandidates } from '@/lib/timelineSnap'
import {
generateBundledScript,
generateExampleHtml,
generateRuntimeScript,
generateSceneScript,
} from '@/lib/exportPlayer'
import { renderFrames } from '@/lib/frameExport'
import { renderSceneToGif } from '@/lib/gifExport'
import { generateApngPreviewHtml, renderSceneToApng } from '@/lib/apngExport'
import { ProjectFileError, parseProject, serializeProject } from '@/lib/projectFile'
import { createHistory } from '@/composables/useHistory'
import { useKfSelection, kfSelectionKey } from '@/composables/useKfSelection'
import { APP_NAME, APP_VERSION } from '@/version'
import type { PolygonAsset } from '@/types/scene'
import { drawButtonFrame, drawSceneFrame, renderButtonThumbnail } from '@/lib/render'
import { t as i18nT } from '@/i18n'
import { expandedTrackOrder } from '@/lib/sceneOrder'
import { CLIP_FALLBACK_BOUNDS, computeClipBounds } from '@/lib/clipBounds'
import {
deleteStoredProject,
getStoredProject,
listRecentProjects,
saveProjectToStore,
} from '@/lib/projectStore'
import Modal from '@/components/ui/Modal.vue'
import IconButton from '@/components/ui/IconButton.vue'
import Toast from '@/components/ui/Toast.vue'
import PropertiesPanel from '@/components/PropertiesPanel.vue'
import ClipartDrawer from '@/components/ClipartDrawer.vue'
import SceneDrawer from '@/components/SceneDrawer.vue'
import DocumentDrawer from '@/components/DocumentDrawer.vue'
import ActionEditor from '@/components/ActionEditor.vue'
import Timeline from '@/components/Timeline.vue'
import AssetSidebar from '@/components/AssetSidebar.vue'
import CanvasStage from '@/components/CanvasStage.vue'
import ExportDialog from '@/components/ExportDialog.vue'
import WelcomeDialog from '@/components/WelcomeDialog.vue'
import FullscreenPreview from '@/components/FullscreenPreview.vue'
import HelpDialog from '@/components/HelpDialog.vue'
import { createAudioRuntime } from '@/composables/useAudioRuntime'
import {
clearWaveformCache,
drawWaveform,
getWaveformSlot,
slicePeaksForClip,
} from '@/composables/useAudioWaveform'
import { vTooltip } from '@/directives/tooltip'
import { beforeEach, expect, suite, test } from './testRunner'
interface MountedHandle {
host: HTMLDivElement
app: ReturnType<typeof createApp>
unmount: () => void
}
interface RecordingCtx {
ctx: CanvasRenderingContext2D
filterSets: string[]
compositeOps: string[]
}
function makeRecordingCtx(): RecordingCtx {
const canvas = document.createElement('canvas')
canvas.width = 200
canvas.height = 200
const real = canvas.getContext('2d')!
const filterSets: string[] = []
const compositeOps: string[] = []
const proxy = new Proxy(real, {
set(target, prop, value) {
if (prop === 'filter') filterSets.push(value as string)
if (prop === 'globalCompositeOperation') compositeOps.push(value as string)
;(target as unknown as Record<string | symbol, unknown>)[prop as string | symbol] = value
return true
},
get(target, prop) {
const v = (target as unknown as Record<string | symbol, unknown>)[
prop as string | symbol
]
if (typeof v === 'function') return v.bind(target)
return v
},
})
return { ctx: proxy as CanvasRenderingContext2D, filterSets, compositeOps }
}
function mountComponent(
component: Component,
props: Record<string, unknown>,
slots?: Record<string, Slot>,
): MountedHandle {
const host = document.createElement('div')
host.setAttribute('data-test-host', '')
document.body.appendChild(host)
const app = createApp({
render() {
return h(component, props, slots)
},
})
// Register the same global directives the real app registers in
// main.ts — without this, anything using `v-tooltip` (IconButton,
// the toolbar buttons, …) no-ops inside the test harness because
// each `mountComponent` spins up an isolated app instance.
app.directive('tooltip', vTooltip)
app.mount(host)
return {
host,
app,
unmount() {
app.unmount()
host.remove()
},
}
}
function makeFreshStore() {
const s = useSceneStore()
s.$reset()
return s
}
function tick(ms = 40): Promise<void> {
return new Promise((r) => setTimeout(r, ms))
}
export function registerAllTests(): void {
suite('scene store — defaults', () => {
test('starts with empty scene, playhead 0, not playing', () => {
const s = makeFreshStore()
expect(s.scene.layers).toHaveLength(0)
expect(s.scene.assets).toHaveLength(0)
expect(s.playhead).toBe(0)
expect(s.playing).toBe(false)
expect(s.scene.duration).toBe(10)
expect(s.scene.fps).toBe(60)
expect(s.selectedLayerId).toBeNull()
})
})
suite('scene store — playhead', () => {
test('setPlayhead clamps to [0, duration]', async () => {
const s = makeFreshStore()
s.setPlayhead(-5)
await tick()
expect(s.playhead).toBe(0)
s.setPlayhead(999)
await tick()
expect(s.playhead).toBe(s.scene.duration)
s.setPlayhead(3.5)
await tick()
expect(s.playhead).toBe(3.5)
})
test('setDuration pulls playhead back if past end', async () => {
const s = makeFreshStore()
s.setPlayhead(9)
await tick()
s.setDuration(5)
await tick()
expect(s.playhead).toBe(5)
})
test('setDuration clamps to min 0.1', () => {
const s = makeFreshStore()
s.setDuration(0)
expect(s.scene.duration).toBe(0.1)
})
})
suite('scene store — layers & selection', () => {
test('addLayer with rect asset creates layer + initial keyframe + selects it', async () => {
const s = makeFreshStore()
const asset = s.addAsset({
kind: 'rect',
name: 'TestRect',
width: 200,
height: 100,
fill: '#ff4455',
})
await tick()
const layer = s.addLayer(asset.id)!
await tick()
expect(s.scene.layers).toHaveLength(1)
expect(s.selectedLayerId).toBe(layer.id)
expect(layer.keyframes).toHaveLength(1)
expect(layer.keyframes[0].time).toBe(0)
expect(layer.offsetX).toBe(100)
expect(layer.offsetY).toBe(50)
expect(layer.base.fill).toBe('#ff4455')
})
test('removeLayer deselects if it was selected', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'circle', name: 'TestCircle', radius: 40, fill: '#44aaff' })
const l = s.addLayer(a.id)!
await tick()
s.removeLayer(l.id)
await tick()
expect(s.scene.layers).toHaveLength(0)
expect(s.selectedLayerId).toBeNull()
})
test('addLayer inserts at the top (index 0)', async () => {
const s = makeFreshStore()
const a1 = s.addAsset({ kind: 'rect', name: 'First', width: 40, height: 40, fill: '#ff4444' })
const a2 = s.addAsset({ kind: 'rect', name: 'Second', width: 40, height: 40, fill: '#44ff44' })
const l1 = s.addLayer(a1.id)!
await tick()
const l2 = s.addLayer(a2.id)!
await tick()
expect(s.scene.layers[0].id).toBe(l2.id)
expect(s.scene.layers[1].id).toBe(l1.id)
})
test('moveLayer reorders layers', async () => {
const s = makeFreshStore()
const a1 = s.addAsset({ kind: 'rect', name: 'A', width: 40, height: 40, fill: '#ff4444' })
const a2 = s.addAsset({ kind: 'rect', name: 'B', width: 40, height: 40, fill: '#44ff44' })
const a3 = s.addAsset({ kind: 'rect', name: 'C', width: 40, height: 40, fill: '#4444ff' })
const l1 = s.addLayer(a1.id)!
const l2 = s.addLayer(a2.id)!
const l3 = s.addLayer(a3.id)!
await tick()
// Insertion at top → starting order: [l3, l2, l1]
s.moveLayer(0, 2)
await tick()
expect(s.scene.layers[0].id).toBe(l2.id)
expect(s.scene.layers[1].id).toBe(l1.id)
expect(s.scene.layers[2].id).toBe(l3.id)
})
test('moveLayer ignores identical from/to', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#000' })
const l = s.addLayer(a.id)!
s.moveLayer(0, 0)
expect(s.scene.layers[0].id).toBe(l.id)
})
test('selectLayer toggles with null', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'circle', name: 'SelC', radius: 30, fill: '#00cc88' })
const l = s.addLayer(a.id)!
await tick()
s.selectLayer(null)
await tick()
expect(s.selectedLayerId).toBeNull()
s.selectLayer(l.id)
await tick()
expect(s.selectedLayerId).toBe(l.id)
})
test('selectLayer keeps selectedLayerIds in sync', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.selectLayer(l.id)
expect(s.selectedLayerIds).toEqual([l.id])
s.selectLayer(null)
expect(s.selectedLayerIds).toEqual([])
})
test('toggleLayerSelection adds and removes; anchor follows the last click', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l1 = s.addLayer(a.id)!
const l2 = s.addLayer(a.id)!
const l3 = s.addLayer(a.id)!
s.selectLayer(l1.id)
s.toggleLayerSelection(l2.id)
s.toggleLayerSelection(l3.id)
expect(s.selectedLayerIds).toEqual([l1.id, l2.id, l3.id])
expect(s.selectedLayerId).toBe(l3.id)
// Removing the anchor moves the anchor back to the new last entry.
s.toggleLayerSelection(l3.id)
expect(s.selectedLayerIds).toEqual([l1.id, l2.id])
expect(s.selectedLayerId).toBe(l2.id)
// Removing the last remaining entry drops the anchor to null.
s.toggleLayerSelection(l2.id)
s.toggleLayerSelection(l1.id)
expect(s.selectedLayerIds).toEqual([])
expect(s.selectedLayerId).toBe(null)
})
test('toggleLayerSelection skips layers locked directly or by group', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const lFree = s.addLayer(a.id)!
const lDirect = s.addLayer(a.id)!
const lInGroup = s.addLayer(a.id)!
s.setTrackLocked(lDirect.id, true)
const g = s.addTrackGroup('G')
s.moveTrackIntoGroup(lInGroup.id, g.id)
s.setTrackLocked(g.id, true)
s.selectLayer(lFree.id)
s.toggleLayerSelection(lDirect.id)
s.toggleLayerSelection(lInGroup.id)
// Neither locked layer was added; the free one stays as the
// only selection.
expect(s.selectedLayerIds).toEqual([lFree.id])
})
test('groupSelectedLayers moves every selected layer into a fresh group', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l1 = s.addLayer(a.id)!
const l2 = s.addLayer(a.id)!
const l3 = s.addLayer(a.id)!
s.selectLayer(l1.id)
s.toggleLayerSelection(l2.id)
s.toggleLayerSelection(l3.id)
const groupId = s.groupSelectedLayers()!
expect(groupId).toBeTruthy()
const g = s.scene.groups!.find((x) => x.id === groupId)!
expect(g.childIds.includes(l1.id)).toBe(true)
expect(g.childIds.includes(l2.id)).toBe(true)
expect(g.childIds.includes(l3.id)).toBe(true)
// The layers also get removed from the top-level trackOrder
// since moveTrackIntoGroup handles that.
const order = s.scene.trackOrder ?? []
expect(order.includes(l1.id)).toBe(false)
expect(order.includes(l2.id)).toBe(false)
expect(order.includes(l3.id)).toBe(false)
expect(order.includes(groupId)).toBe(true)
})
test('groupSelectedLayers returns null with an empty selection', () => {
const s = makeFreshStore()
const groupsBefore = (s.scene.groups ?? []).length
const result = s.groupSelectedLayers()
expect(result).toBe(null)
// Nothing was created.
expect((s.scene.groups ?? []).length).toBe(groupsBefore)
})
test('groupSelectedLayers replaces the topmost selected track\'s stack position', () => {
// addLayer inserts at the top (index 0), so after three addLayer
// calls trackOrder reads top→bottom: [l3, l2, l1]. Picking l2
// and l1 to group (two layers NOT at the top) should produce a
// group at index 1, not at the top of the stack.
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l1 = s.addLayer(a.id)!
const l2 = s.addLayer(a.id)!
const l3 = s.addLayer(a.id)!
const before = [...s.scene.trackOrder!]
expect(before).toEqual([l3.id, l2.id, l1.id])
s.selectLayer(l2.id)
s.toggleLayerSelection(l1.id)
const groupId = s.groupSelectedLayers()!
const after = s.scene.trackOrder!
// Group sits where l2 used to be (index 1); l3 still at top;
// l1 and l2 are no longer top-level.
expect(after[0]).toBe(l3.id)
expect(after[1]).toBe(groupId)
expect(after.includes(l1.id)).toBe(false)
expect(after.includes(l2.id)).toBe(false)
})
test('convertSelectedLayersToClip packs selected layers into a fresh clip + instance', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
// Give each layer a unique name up-front so we can look them up
// unambiguously inside the packed clip. Bare `addLayer(asset.id)`
// falls back to the asset name — three layers would all read "R"
// and `find(l => l.name === ...)` would return the first match.
const l1 = s.addLayer(a.id, 'one')!
const l2 = s.addLayer(a.id, 'two')!
const l3 = s.addLayer(a.id, 'three')!
// Seed a non-default keyframe so we can confirm the clip owns an
// independent copy of the original layer's data.
s.upsertKeyframe(l2.id, 1.5, { x: 123 })
const kfCount = l2.keyframes.length
s.selectLayer(l1.id)
s.toggleLayerSelection(l2.id)
expect(s.selectedLayerIds.length).toBe(2)
const clipId = s.convertSelectedLayersToClip('My Clip')!
expect(clipId).toBeTruthy()
// Clip exists with the packed layers and a duration that matches
// the scene so absolute kf times still land where they were.
const clip = s.scene.clips!.find((c) => c.id === clipId)!
expect(clip.name).toBe('My Clip')
expect(clip.duration).toBe(s.scene.duration)
expect(clip.layers.length).toBe(2)
// The kf data survives the round-trip.
const clonedL2 = clip.layers.find((l) => l.name === 'two')!
expect(clonedL2).toBeTruthy()
expect(clonedL2.keyframes.length).toBe(kfCount)
expect(clonedL2.keyframes.some((k) => Math.abs(k.time - 1.5) < 1e-6)).toBe(true)
// Originals removed from scene.layers + trackOrder.
expect(s.scene.layers.some((l) => l.id === l1.id)).toBe(false)
expect(s.scene.layers.some((l) => l.id === l2.id)).toBe(false)
expect(s.scene.layers.some((l) => l.id === l3.id)).toBe(true)
expect(s.scene.trackOrder!.includes(l1.id)).toBe(false)
expect(s.scene.trackOrder!.includes(l2.id)).toBe(false)
// Exactly one clipTrack + one instance created; the instance
// sits at startTime 0 so the packed layers' absolute kf times
// continue to fire at their original scene times.
const clipTrackIds = (s.scene.clipTracks ?? []).map((t) => t.id)
const matching = (s.scene.clipInstances ?? []).filter(
(ci) => ci.clipId === clipId,
)
expect(matching.length).toBe(1)
expect(clipTrackIds.includes(matching[0].clipTrackId)).toBe(true)
expect(matching[0].startTime).toBe(0)
// The clone is independent: mutating the clip's layer doesn't
// affect the source (l2 is gone, but the data pointer shouldn't
// have been shared).
clonedL2.keyframes[0].time = 99
// (no crash means the refs are decoupled; l2 is already gone)
})
test('convertSelectedLayersToClip drops the clipTrack at the topmost selected slot', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l1 = s.addLayer(a.id)!
const l2 = s.addLayer(a.id)!
const l3 = s.addLayer(a.id)!
// addLayer prepends → trackOrder reads top-to-bottom [l3, l2, l1].
s.selectLayer(l2.id)
s.toggleLayerSelection(l1.id)
s.convertSelectedLayersToClip()
const order = s.scene.trackOrder ?? []
// l3 is still at the top (index 0); the clipTrack replaces l2's
// old slot (index 1); l1's slot is collapsed away.
expect(order[0]).toBe(l3.id)
expect(order.length).toBe(2)
const clipTrackId = (s.scene.clipTracks ?? []).slice(-1)[0].id
expect(order[1]).toBe(clipTrackId)
})
test('convertSelectedLayersToClip returns null with an empty selection', () => {
const s = makeFreshStore()
expect(s.convertSelectedLayersToClip()).toBe(null)
expect((s.scene.clips ?? []).length).toBe(0)
expect((s.scene.clipTracks ?? []).length).toBe(0)
expect((s.scene.clipInstances ?? []).length).toBe(0)
})
test('convertSelectedLayersToClip skips effectively-locked layers', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const lFree = s.addLayer(a.id)!
const lLocked = s.addLayer(a.id)!
s.setTrackLocked(lLocked.id, true)
s.selectedLayerIds = [lFree.id, lLocked.id]
s.selectedLayerId = lLocked.id
const clipId = s.convertSelectedLayersToClip()!
const clip = s.scene.clips!.find((c) => c.id === clipId)!
expect(clip.layers.length).toBe(1)
expect(clip.layers[0].name).toBe(lFree.name)
// Locked layer stays put.
expect(s.scene.layers.some((l) => l.id === lLocked.id)).toBe(true)
})
test('groupSelectedLayers skips layers that are effectively locked', () => {
// Locked ones shouldn't land in the fresh group — they stay
// wherever they were.
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const lFree1 = s.addLayer(a.id)!
const lLocked = s.addLayer(a.id)!
const lFree2 = s.addLayer(a.id)!
s.setTrackLocked(lLocked.id, true)
// Locking a layer deselects it, so toggleLayerSelection also
// silently refuses; build the selection manually to exercise
// the filter inside `groupSelectedLayers`.
s.selectedLayerIds = [lFree1.id, lLocked.id, lFree2.id]
s.selectedLayerId = lFree2.id
const groupId = s.groupSelectedLayers()!
const g = s.scene.groups!.find((x) => x.id === groupId)!
expect(g.childIds.includes(lFree1.id)).toBe(true)
expect(g.childIds.includes(lFree2.id)).toBe(true)
expect(g.childIds.includes(lLocked.id)).toBe(false)
// The locked layer still sits in the top-level track order.
expect(s.scene.trackOrder!.includes(lLocked.id)).toBe(true)
})
})
suite('scene store — keyframes', () => {
test('upsertKeyframe adds new keyframe sorted by time', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Kfs', width: 80, height: 80, fill: '#ffa500' })
const l = s.addLayer(a.id)!
await tick()
s.upsertKeyframe(l.id, 2, { x: 600 })
await tick()
s.upsertKeyframe(l.id, 1, { x: 300 })
await tick()
const times = l.keyframes.map((k) => k.time)
expect(times).toEqual([0, 1, 2])
})
test('upsertKeyframe updates existing keyframe within epsilon', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Upd', width: 80, height: 80, fill: '#aa44ff' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 1, { x: 500 })
await tick()
const before = l.keyframes.length
s.upsertKeyframe(l.id, 1.0000001, { x: 900 })
await tick()
expect(l.keyframes).toHaveLength(before)
const kf = l.keyframes.find((k) => Math.abs(k.time - 1) < 0.01)!
expect(kf.props.x).toBe(900)
})
test('setKeyframeTime clamps and sorts', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Clamp', width: 60, height: 60, fill: '#ff8844' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 3, { x: 400 })
s.upsertKeyframe(l.id, 5, { x: 800 })
await tick()
const target = l.keyframes.find((k) => k.time === 5)!
s.setKeyframeTime(l.id, target.id, -1)
await tick()
expect(target.time).toBe(0)
const times = l.keyframes.map((k) => k.time).sort((a, b) => a - b)
expect(times).toEqual(l.keyframes.map((k) => k.time))
})
test('setKeyframeTime clamps to duration upper bound', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Dur', width: 60, height: 60, fill: '#44ffaa' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 2, { x: 400 })
await tick()
const k = l.keyframes[l.keyframes.length - 1]
s.setKeyframeTime(l.id, k.id, 999)
await tick()
expect(k.time).toBe(s.scene.duration)
})
test('removeKeyframe removes by id', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Rm', width: 50, height: 50, fill: '#ee4488' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 2, { x: 500 })
await tick()
const k = l.keyframes.find((k) => k.time === 2)!
s.removeKeyframe(l.id, k.id)
await tick()
expect(l.keyframes.find((x) => x.id === k.id)).toBe(undefined)
})
test('duplicateKeyframe creates offset copy', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Dup', width: 50, height: 50, fill: '#22aaee' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 1, { x: 400 })
await tick()
const src = l.keyframes.find((k) => k.time === 1)!
const newId = s.duplicateKeyframe(l.id, src.id)
await tick()
expect(newId).toBeTruthy()
const dup = l.keyframes.find((k) => k.id === newId)!
expect(dup.props.x).toBe(400)
expect(dup.time).toBeGreaterThan(1)
})
test('duplicateKeyframeAt copies props + easing + hold to an explicit time', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Src', width: 50, height: 50, fill: '#abcdef' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 1, { x: 123, y: 45 })
const src = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-6)!
src.easing = 'easeInOut'
s.setKeyframeHold(l.id, src.id, true)
const newId = s.duplicateKeyframeAt(l.id, src.id, 3)
expect(newId).toBeTruthy()
const dup = l.keyframes.find((k) => k.id === newId)!
expect(dup.time).toBe(3)
expect(dup.props.x).toBe(123)
expect(dup.props.y).toBe(45)
expect(dup.easing).toBe('easeInOut')
expect(dup.hold).toBe(true)
// props must be a clone — mutating the duplicate must not touch the source.
dup.props.x = 999
expect(src.props.x).toBe(123)
})
test('duplicateKeyframeAt refuses to overwrite an existing keyframe', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Col', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 1, { x: 10 })
s.upsertKeyframe(l.id, 2, { x: 20 })
const src = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-6)!
const countBefore = l.keyframes.length
const newId = s.duplicateKeyframeAt(l.id, src.id, 2)
expect(newId).toBe(null)
expect(l.keyframes.length).toBe(countBefore)
})
test('duplicateKeyframeAt returns null for unknown layer or keyframe ids', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 1, { x: 10 })
const src = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-6)!
expect(s.duplicateKeyframeAt('nope-layer', src.id, 2)).toBe(null)
expect(s.duplicateKeyframeAt(l.id, 'nope-kf', 2)).toBe(null)
})
test('duplicateKeyframeAt targets the source keyframe\'s own track (position vs main)', () => {
// Separated layers have independent main / position / rotation
// arrays. The duplicate must land in the same array the source
// came from, not accidentally jump to another.
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Sep', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.setLayerSeparated(l.id, true)
s.upsertKeyframe(l.id, 1, { x: 50, y: 60 })
const posKf = l.positionKeyframes!.find((k) => Math.abs(k.time - 1) < 1e-6)!
s.duplicateKeyframeAt(l.id, posKf.id, 2)
const dup = l.positionKeyframes!.find((k) => Math.abs(k.time - 2) < 1e-6)
expect(dup).toBeTruthy()
// Nothing should have been added to the main array.
expect(l.keyframes.some((k) => Math.abs(k.time - 2) < 1e-6)).toBe(false)
})
test('setKeyframeHold toggles hold flag', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Hold', width: 50, height: 50, fill: '#ddaa44' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 1, { x: 400 })
await tick()
const k = l.keyframes[l.keyframes.length - 1]
s.setKeyframeHold(l.id, k.id, true)
await tick()
expect(k.hold).toBe(true)
s.setKeyframeHold(l.id, k.id, false)
await tick()
expect(k.hold).toBe(undefined)
})
test('setKeyframeEasing updates easing field', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Ease', width: 50, height: 50, fill: '#88cc44' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 1, { x: 400 })
await tick()
const k = l.keyframes[l.keyframes.length - 1]
s.setKeyframeEasing(l.id, k.id, 'easeInOut')
expect(k.easing).toBe('easeInOut')
})
})
suite('scene store — separated tracks', () => {
test('setLayerSeparated splits x/y and rotation into dedicated tracks', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Sep', width: 60, height: 60, fill: '#ee66aa' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 1, { x: 400, y: 200, rotation: 45, opacity: 0.5 })
await tick()
s.setLayerSeparated(l.id, true)
await tick()
expect(l.separated).toBe(true)
expect((l.positionKeyframes ?? []).length).toBeGreaterThan(0)
expect((l.rotationKeyframes ?? []).length).toBeGreaterThan(0)
for (const k of l.keyframes) {
expect((k.props as Record<string, unknown>).x).toBe(undefined)
expect((k.props as Record<string, unknown>).y).toBe(undefined)
expect((k.props as Record<string, unknown>).rotation).toBe(undefined)
}
})
test('setLayerSeparated(false) merges tracks back together', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Merge', width: 60, height: 60, fill: '#66ccee' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 1, { x: 400, y: 200, rotation: 45, opacity: 0.5 })
await tick()
s.setLayerSeparated(l.id, true)
await tick()
s.setLayerSeparated(l.id, false)
await tick()
expect(l.separated).toBe(false)
expect(l.positionKeyframes).toBe(undefined)
expect(l.rotationKeyframes).toBe(undefined)
const at1 = l.keyframes.find((k) => Math.abs(k.time - 1) < 0.001)!
expect((at1.props as Record<string, number>).x).toBe(400)
expect((at1.props as Record<string, number>).rotation).toBe(45)
})
})
suite('interpolation — sampleLayer', () => {
test('no keyframes → returns base', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#000' })
const l = s.addLayer(a.id)!
l.keyframes = []
const p = sampleLayer(l, 0.5)
expect(p.x).toBe(l.base.x)
expect(p.y).toBe(l.base.y)
})
test('linear midpoint is halfway', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#000' })
const l = s.addLayer(a.id)!
l.keyframes = [
{ id: 'k1', time: 0, props: { x: 0 }, easing: 'linear' },
{ id: 'k2', time: 2, props: { x: 100 }, easing: 'linear' },
]
l.base = { ...l.base, x: 0, y: 0 }
const p = sampleLayer(l, 1)
expect(p.x).toBeCloseTo(50, 2)
})
test('easeIn midpoint is below linear midpoint', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#000' })
const l = s.addLayer(a.id)!
l.base = { ...l.base, x: 0 }
l.keyframes = [
{ id: 'k1', time: 0, props: { x: 0 }, easing: 'linear' },
{ id: 'k2', time: 2, props: { x: 100 }, easing: 'easeIn' },
]
const p = sampleLayer(l, 1)
expect(p.x).toBeLessThan(50)
expect(p.x).toBeGreaterThan(0)
})
test('hold keyframe holds value until next keyframe', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'H', width: 10, height: 10, fill: '#000' })
const l = s.addLayer(a.id)!
l.base = { ...l.base, x: 0 }
l.keyframes = [
{ id: 'k1', time: 0, props: { x: 0 }, easing: 'linear', hold: true },
{ id: 'k2', time: 2, props: { x: 100 }, easing: 'linear' },
]
expect(sampleLayer(l, 0).x).toBe(0)
expect(sampleLayer(l, 0.5).x).toBe(0)
expect(sampleLayer(l, 1.5).x).toBe(0)
expect(sampleLayer(l, 2).x).toBe(100)
})
test('clamps before first kf to first props, after last to last props', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#000' })
const l = s.addLayer(a.id)!
l.base = { ...l.base, x: 0 }
l.keyframes = [
{ id: 'k1', time: 1, props: { x: 10 }, easing: 'linear' },
{ id: 'k2', time: 2, props: { x: 20 }, easing: 'linear' },
]
expect(sampleLayer(l, 0).x).toBe(10)
expect(sampleLayer(l, 5).x).toBe(20)
})
})
suite('interpolation — color', () => {
test('lerpColor 0 returns start', () => {
expect(lerpColor('#000000', '#ffffff', 0)).toBe('#000000')
})
test('lerpColor 1 returns end', () => {
expect(lerpColor('#000000', '#ffffff', 1)).toBe('#ffffff')
})
test('lerpColor 0.5 is around middle gray', () => {
const mid = lerpColor('#000000', '#ffffff', 0.5)
expect(mid).toBe('#808080')
})
})
suite('interpolation — shadow properties', () => {
// Regression: shadow keyframes used to render static (or nothing) in
// the preview because the runtime's lerpProps dropped shadow fields.
// The canvas-side sampleLayer must interpolate them correctly; this
// pins the contract both paths share.
function rectWithShadow() {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Sh', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
// Seed both endpoints so there's a clear interpolation target.
s.upsertKeyframe(l.id, 0, {
shadowOpacity: 0,
shadowBlur: 0,
shadowDistance: 0,
shadowAngle: 0,
shadowColor: '#000000',
})
s.upsertKeyframe(l.id, 2, {
shadowOpacity: 1,
shadowBlur: 20,
shadowDistance: 10,
shadowAngle: 180,
shadowColor: '#ffffff',
})
return l
}
test('shadowOpacity lerps halfway between keyframes', () => {
const l = rectWithShadow()
const mid = sampleLayer(l, 1)
expect(mid.shadowOpacity).toBe(0.5)
})
test('shadowBlur / distance / angle lerp halfway between keyframes', () => {
const l = rectWithShadow()
const mid = sampleLayer(l, 1)
expect(mid.shadowBlur).toBe(10)
expect(mid.shadowDistance).toBe(5)
expect(mid.shadowAngle).toBe(90)
})
test('shadowColor is interpolated per channel (not snapped)', () => {
const l = rectWithShadow()
const mid = sampleLayer(l, 1)
// Black→white at t=0.5 lands on mid-gray (same lerpColor the
// fill-color test above pins).
expect(mid.shadowColor).toBe('#808080')
})
})
suite('interpolation — layer visibility (kf range)', () => {
test('untouched baseline (single kf @0) is always visible', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
expect(isLayerActiveAt(l, 0)).toBe(true)
expect(isLayerActiveAt(l, 5)).toBe(true)
expect(isLayerActiveAt(l, 99)).toBe(true)
})
test('single kf at any time is always visible (static layer)', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
l.keyframes[0].time = 3
expect(isLayerActiveAt(l, 0)).toBe(true)
expect(isLayerActiveAt(l, 3)).toBe(true)
expect(isLayerActiveAt(l, 5)).toBe(true)
})
test('multi-kf: visible only in [firstKf, lastKf]', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 2, { scaleX: 2 })
s.upsertKeyframe(l.id, 5, { scaleX: 3 })
// kf@0 is still there; firstKf = 0, lastKf = 5
expect(isLayerActiveAt(l, -0.1)).toBe(false)
expect(isLayerActiveAt(l, 0)).toBe(true)
expect(isLayerActiveAt(l, 3)).toBe(true)
expect(isLayerActiveAt(l, 5)).toBe(true)
expect(isLayerActiveAt(l, 6)).toBe(false)
})
test('dragging first kf forward hides layer before it', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 5, { scaleX: 2 })
// Drag the first kf from t=0 to t=2.
const first = l.keyframes.find((k) => k.time === 0)!
first.time = 2
expect(isLayerActiveAt(l, 1)).toBe(false)
expect(isLayerActiveAt(l, 2)).toBe(true)
expect(isLayerActiveAt(l, 5)).toBe(true)
expect(isLayerActiveAt(l, 6)).toBe(false)
})
test('last kf held: layer stays visible past its last keyframe', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 5, { scaleX: 2 })
// Mark the t=5 kf as hold. It's the last keyframe — used to be a
// no-op; now it means "stay on stage past the end of the anim".
const last = l.keyframes.find((k) => k.time === 5)!
s.setKeyframeHold(l.id, last.id, true)
expect(isLayerActiveAt(l, 5)).toBe(true)
expect(isLayerActiveAt(l, 6)).toBe(true)
expect(isLayerActiveAt(l, 99)).toBe(true)
// sampleLayer already pins to last.props for time >= last.time —
// confirm the value is the last keyframe's, not interpolating out.
expect(sampleLayer(l, 10).scaleX).toBe(2)
})
test('non-last held kf does NOT extend visibility past end', () => {
// Hold on a middle keyframe only changes how it interpolates TO
// the following kf; it should not keep the layer alive past the
// final keyframe when that final kf itself isn't held.
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 2, { scaleX: 2 })
s.upsertKeyframe(l.id, 5, { scaleX: 3 })
const middle = l.keyframes.find((k) => k.time === 2)!
s.setKeyframeHold(l.id, middle.id, true)
expect(isLayerActiveAt(l, 5)).toBe(true)
expect(isLayerActiveAt(l, 6)).toBe(false)
})
})
suite('timeline snap', () => {
test('disabled: returns t unchanged', () => {
const r = snapToCandidates(1.7, [2.0], {
enabled: false,
pxPerSec: 100,
pixelThreshold: 8,
duration: 10,
})
expect(r).toBe(1.7)
})
test('enabled: snaps to nearest candidate within threshold', () => {
const r = snapToCandidates(1.95, [2.0, 5.0], {
enabled: true,
pxPerSec: 100,
pixelThreshold: 8,
duration: 10,
})
expect(r).toBe(2.0)
})
test('enabled: leaves t alone if all candidates are outside threshold', () => {
const r = snapToCandidates(1.0, [2.0], {
enabled: true,
pxPerSec: 100,
pixelThreshold: 8,
duration: 10,
})
expect(r).toBe(1.0)
})
test('clamps result to [0, duration]', () => {
const r = snapToCandidates(15, [], {
enabled: true,
pxPerSec: 100,
pixelThreshold: 8,
duration: 10,
})
expect(r).toBe(10)
})
})
suite('export — runtime & scene split', () => {
test('generateRuntimeScript exposes AnimatrPlayer', () => {
const r = generateRuntimeScript()
expect(r).toContain('window.AnimatrPlayer')
expect(r).toContain('function mount')
expect(r).toContain('__animatrPending')
})
test('generateSceneScript embeds scene JSON and uses AnimatrPlayer', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'UniqRect77', width: 10, height: 10, fill: '#abc' })
s.addLayer(a.id)!
const src = generateSceneScript(s.scene)
expect(src).toContain('AnimatrPlayer.mount')
expect(src).toContain('UniqRect77')
expect(src).toContain('__animatrPending')
})
test('scene script does NOT inline the mount implementation', () => {
const s = makeFreshStore()
const src = generateSceneScript(s.scene)
expect(src.indexOf('function mount')).toBe(-1)
})
test('generateBundledScript contains runtime + scene boot', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'BundlR', width: 10, height: 10, fill: '#123' })
s.addLayer(a.id)!
const src = generateBundledScript(s.scene)
expect(src).toContain('function mount')
expect(src).toContain('AnimatrPlayer.mount')
expect(src).toContain('BundlR')
})
test('renderFrames yields fps*duration frames across the playback range', async () => {
const s = makeFreshStore()
s.setDuration(1)
s.project.fps = 10
// Tiny 4×4 canvas keeps the loop fast — we only care about counts.
s.scene.canvas.width = 4
s.scene.canvas.height = 4
const a = s.addAsset({ kind: 'rect', name: 'R', width: 4, height: 4, fill: '#111' })
s.addLayer(a.id)!
const frames: number[] = []
await renderFrames(s.scene, ({ frameIndex, width, height }) => {
expect(width).toBe(4)
expect(height).toBe(4)
frames.push(frameIndex)
})
// 1 second at 10fps → 10 frames (indices 0..9).
expect(frames.length).toBe(10)
expect(frames[0]).toBe(0)
expect(frames[frames.length - 1]).toBe(9)
})
test('renderFrames aborts when the signal fires', async () => {
const s = makeFreshStore()
s.setDuration(1)
s.project.fps = 10
s.scene.canvas.width = 4
s.scene.canvas.height = 4
const a = s.addAsset({ kind: 'rect', name: 'R', width: 4, height: 4, fill: '#111' })
s.addLayer(a.id)!
const controller = new AbortController()
let rendered = 0
const promise = renderFrames(
s.scene,
() => {
rendered += 1
if (rendered === 2) controller.abort()
},
{ signal: controller.signal },
)
let threw = false
try {
await promise
} catch (err) {
threw = (err as Error).name === 'AbortError'
}
expect(threw).toBe(true)
expect(rendered).toBeLessThan(10)
})
test('renderSceneToGif emits a GIF89a blob', async () => {
const s = makeFreshStore()
s.setDuration(0.5)
s.project.fps = 4
s.scene.canvas.width = 8
s.scene.canvas.height = 8
const a = s.addAsset({ kind: 'rect', name: 'R', width: 8, height: 8, fill: '#22cc88' })
s.addLayer(a.id)!
const blob = await renderSceneToGif(s.scene)
expect(blob.type).toBe('image/gif')
expect(blob.size > 0).toBe(true)
const bytes = new Uint8Array(await blob.arrayBuffer())
// GIF magic: "GIF" + "87a" or "89a"
const header = String.fromCharCode(bytes[0], bytes[1], bytes[2])
expect(header).toBe('GIF')
})
test('generateApngPreviewHtml embeds the data URL + checkerboard CSS', () => {
const dataUrl = 'data:image/png;base64,AAAA'
const html = generateApngPreviewHtml(dataUrl, 320, 180)
// Payload is embedded so the exported HTML works as a single file.
expect(html).toContain(dataUrl)
// Aspect ratio drives the stage box.
expect(html).toContain('aspect-ratio: 320 / 180')
// Checkerboard + toggle buttons are present.
expect(html).toContain('.stage.checker')
expect(html).toContain('data-bg="checker"')
expect(html).toContain('data-bg="solid-dark"')
expect(html).toContain('data-bg="solid-light"')
})
test('transparent background survives the APNG round-trip (alpha preserved)', async () => {
// A transparent canvas with no layers should encode a fully
// transparent animation. We decode the produced blob and verify
// at least one pixel's alpha channel is zero.
const s = makeFreshStore()
s.setDuration(0.5)
s.project.fps = 4
s.scene.canvas.width = 8
s.scene.canvas.height = 8
s.setCanvasBackground('transparent')
const blob = await renderSceneToApng(s.scene)
const buf = await blob.arrayBuffer()
const { default: UPNG } = await import('upng-js')
const decoded = UPNG.decode(buf)
const rgbaFrames = UPNG.toRGBA8(decoded)
expect(rgbaFrames.length > 0).toBe(true)
const first = new Uint8Array(rgbaFrames[0])
// Scan every pixel's alpha byte — at least one should be 0 since
// nothing painted on a transparent canvas.
let sawTransparent = false
for (let i = 3; i < first.length; i += 4) {
if (first[i] === 0) {
sawTransparent = true
break
}
}
expect(sawTransparent).toBe(true)
})
test('renderSceneToApng emits a PNG blob with an acTL animation chunk', async () => {
const s = makeFreshStore()
s.setDuration(0.5)
s.project.fps = 4
s.scene.canvas.width = 8
s.scene.canvas.height = 8
const a = s.addAsset({ kind: 'rect', name: 'R', width: 8, height: 8, fill: '#22cc88' })
s.addLayer(a.id)!
const blob = await renderSceneToApng(s.scene)
expect(blob.type).toBe('image/png')
expect(blob.size > 0).toBe(true)
const bytes = new Uint8Array(await blob.arrayBuffer())
// PNG magic: 0x89 0x50 0x4E 0x47 0x0D 0x0A 0x1A 0x0A
expect(bytes[0]).toBe(0x89)
expect(String.fromCharCode(bytes[1], bytes[2], bytes[3])).toBe('PNG')
// Scan for the 'acTL' chunk type — present only in animated PNGs.
let hasActl = false
for (let i = 8; i < bytes.length - 4; i++) {
if (
bytes[i] === 0x61 &&
bytes[i + 1] === 0x63 &&
bytes[i + 2] === 0x54 &&
bytes[i + 3] === 0x4c
) {
hasActl = true
break
}
}
expect(hasActl).toBe(true)
})
test('runtime lerpProps interpolates shadow + blur keyframes', () => {
// Regression: shadow properties (shadowBlur/Opacity/Angle/Distance/
// Color) and blur used to be dropped by the runtime's lerpProps,
// so an animation that fades a shadow in the editor played flat
// in the preview / exported HTML. The runtime must carry them
// through the per-pair interpolation.
const r = generateRuntimeScript()
expect(r).toContain('shadowBlur')
expect(r).toContain('shadowOpacity')
expect(r).toContain('shadowAngle')
expect(r).toContain('shadowDistance')
expect(r).toContain('shadowColor')
expect(r).toContain('out.blur')
})
test('runtime wraps each clip instance in translate + rotate + scale', () => {
// Pin that the exported runtime's per-clip draw loop honours
// ClipInstance.x/y/scaleX/scaleY/rotation — the canvas editor
// and the exported HTML stay visually identical for any
// clip-instance transform.
const r = generateRuntimeScript()
expect(r).toContain('inst2.x')
expect(r).toContain('inst2.y')
expect(r).toContain('inst2.scaleX')
expect(r).toContain('inst2.scaleY')
expect(r).toContain('inst2.rotation')
expect(r).toContain('ctx.rotate(irot2*Math.PI/180)')
expect(r).toContain('ctx.scale(isx2,isy2)')
})
test('runtime embeds a preloader (spinner + Promise.all) before starting the frame loop', () => {
// Loading big images, video metadata and audio `canplaythrough`
// must settle before the rAF kicks off — otherwise the first few
// frames stutter while the browser decodes. Structural pins:
// - A spinner <style> is injected.
// - A preloader overlay is appended.
// - Image/audio/video promises are awaited together.
// - canplaythrough is the event we wait on for audio.
const r = generateRuntimeScript()
expect(r).toContain('ruwio-spinner-style')
expect(r).toContain('ruwio-preloader')
expect(r).toContain('Promise.all')
expect(r).toContain('canplaythrough')
expect(r).toContain('preloadAudioClips')
expect(r).toContain('preloadBgVideos')
})
test('runtime honours separated position / rotation keyframes', () => {
// Parity regression: the editor samples separated position and
// rotation tracks via `sampleLayer()` when `layer.separated`;
// the runtime used to only look at the main keyframes array.
// Animations set up with Separate-toggled layers played flat
// in exports. Pin the helper and the per-branch merge.
const r = generateRuntimeScript()
expect(r).toContain('sampleKeyframeSet')
expect(r).toContain('layer.separated')
expect(r).toContain('layer.positionKeyframes')
expect(r).toContain('layer.rotationKeyframes')
})
test('runtime supports multi-scene switching (jumpToScene + applySceneSwitch)', () => {
// Exported bundles now carry the whole Project and handle
// jumpToScene internally. The runtime must expose a scene-
// switch helper and NOT fall through to the "unsupported"
// warning message anymore.
const r = generateRuntimeScript()
expect(r).toContain('applySceneSwitch')
expect(r).toContain('project.scenes')
expect(r).toContain('currentSceneId')
expect(r.indexOf('does not support multi-scene')).toBe(-1)
})
test('runtime rangeOut falls back to scene.duration (not last keyframe)', () => {
// Regression: the runtime used to fall back to `effectiveEnd(scene)`
// — the time of the last keyframe — when scene.outTime was unset.
// That made fullscreen preview loop at the last kf, ignoring any
// blank tail before the OUT mark and any still-frame-past-end
// extension. The fallback is now scene.duration, matching the
// editor's `playbackOut` getter.
const r = generateRuntimeScript()
// The old helper must be gone entirely.
expect(r.indexOf('effectiveEnd')).toBe(-1)
// And the current fallback must be scene.duration.
expect(r).toContain('scene.outTime==null?scene.duration:scene.outTime')
})
test('export comments and console prefix use RUWIO, not Animatr', () => {
// Rebrand: generated comment headers, the runtime's console warn
// prefix, and the example-HTML chrome all read RUWIO now. If any
// of these come back as "Animatr", we regressed.
const s = makeFreshStore()
const runtime = generateRuntimeScript()
expect(runtime).toContain('RUWIO runtime')
expect(runtime.indexOf('/* Animatr ')).toBe(-1)
// Console prefix used by the runtime's action evaluator.
expect(runtime).toContain("'[ruwio]'")
expect(runtime.indexOf("'[animatr]'")).toBe(-1)
const sceneScript = generateSceneScript(s.scene)
expect(sceneScript).toContain('RUWIO scene')
expect(sceneScript).toContain('ruwio-runtime.js')
const bundled = generateBundledScript(s.scene)
expect(bundled).toContain('RUWIO bundled export')
expect(bundled.indexOf('/* Animatr ')).toBe(-1)
})
test('generated example HTML is RUWIO-branded', () => {
// The example HTML is what users see when they drop the file
// into a browser. Title, heading, and the embed-usage snippet
// all need to carry the new brand.
const s = makeFreshStore()
const html = generateExampleHtml(s.scene)
expect(html).toContain('<title>RUWIO — Example</title>')
expect(html).toContain('<h1>RUWIO Export</h1>')
// The usage snippet now references ruwio-runtime.js.
expect(html).toContain('ruwio-runtime.js')
expect(html.indexOf('animatr-runtime.js')).toBe(-1)
})
test('generated scene script strips data-URL audio assets and warns', () => {
// Exports carry a single scene over the wire — embedding a data-URL
// mp3 in that payload would balloon the bundle. `jsonForExport`
// empties the src so the runtime still has the asset record (for
// name / duration lookups by audio clips) but won't try to load
// unshippable data.
const s = makeFreshStore()
s.addAudioAsset({ name: 'song', src: 'data:audio/mp3;base64,AAAA', duration: 4 })
let warned = false
const originalWarn = console.warn
console.warn = (...args: unknown[]) => {
const msg = args.map(String).join(' ')
if (msg.toLowerCase().includes('audio')) warned = true
}
try {
const sceneScript = generateSceneScript(s.scene)
expect(sceneScript.indexOf('data:audio/mp3')).toBe(-1)
// The asset record itself IS still present (its id + name + duration),
// just with an emptied src.
expect(sceneScript.indexOf('"kind":"audio"')).toBeGreaterThan(-1)
expect(warned).toBe(true)
} finally {
console.warn = originalWarn
}
})
test('generated scene script passes through external-URL audio intact', () => {
const s = makeFreshStore()
const ext = 'https://cdn.example.com/intro.mp3'
s.addAudioAsset({ name: 'intro', src: ext, duration: 3 })
const sceneScript = generateSceneScript(s.scene)
expect(sceneScript).toContain(ext)
})
test('opening the fullscreen preview pauses the editor playback', async () => {
// Two runtimes fighting for the same audio output = garbled.
// FullscreenPreview pauses `store.playing` so the editor's own
// useAudioRuntime quiets down while the preview owns playback.
const s = makeFreshStore()
s.play()
expect(s.playing).toBe(true)
const handle = mountComponent(FullscreenPreview, { modelValue: true })
await nextTick()
await nextTick()
expect(s.playing).toBe(false)
handle.unmount()
})
})
suite('ui — ExportDialog', () => {
// ExportDialog teleports through Modal so query through document
// rather than the handle's host root. Each test snapshots the
// live dialogs first and diffs after mount to isolate its own.
const snapshotDialogs = () =>
new Set(Array.from(document.querySelectorAll('[role="dialog"]')))
const dialogsAddedSince = (baseline: Set<Element>) =>
Array.from(document.querySelectorAll('[role="dialog"]')).filter(
(el) => !baseline.has(el),
) as HTMLElement[]
function findOptionByTitle(dialog: HTMLElement, title: string): HTMLButtonElement | null {
const items = dialog.querySelectorAll('button.opt')
for (const it of Array.from(items)) {
const t = it.querySelector('.opt-title')
if (t && t.textContent && t.textContent.includes(title)) {
return it as HTMLButtonElement
}
}
return null
}
test('renders a GIF, APNG, and APNG preview option row', async () => {
makeFreshStore()
const baseline = snapshotDialogs()
const handle = mountComponent(ExportDialog, { modelValue: true })
await nextTick()
const added = dialogsAddedSince(baseline)
expect(added.length).toBe(1)
const dialog = added[0]
// The three image-export rows we just added must all be present.
expect(findOptionByTitle(dialog, 'Animated GIF')).toBeTruthy()
expect(findOptionByTitle(dialog, 'Animated PNG')).toBeTruthy()
expect(findOptionByTitle(dialog, i18nT('export.apngPreviewTitle'))).toBeTruthy()
handle.unmount()
})
test('clicking APNG preview transitions the dialog to the progress view', async () => {
// Tiny scene so the async render stays short. We assert the
// synchronous state transition right after the click — the
// template flips to the progress bar before the render loop
// finishes. The outstanding work is cancelled via the controller
// afterwards so no file gets saved.
const s = makeFreshStore()
s.setDuration(0.25)
s.project.fps = 4
s.scene.canvas.width = 8
s.scene.canvas.height = 8
const a = s.addAsset({
kind: 'rect',
name: 'Preview',
width: 8,
height: 8,
fill: '#22cc88',
})
s.addLayer(a.id)!
// Silence actual file downloads during the test.
const originalClick = HTMLAnchorElement.prototype.click
HTMLAnchorElement.prototype.click = function () {}
const baseline = snapshotDialogs()
const handle = mountComponent(ExportDialog, { modelValue: true })
try {
await nextTick()
const dialog = dialogsAddedSince(baseline)[0]
const btn = findOptionByTitle(dialog, i18nT('export.apngPreviewTitle'))
expect(btn).toBeTruthy()
btn!.click()
await nextTick()
const rendering = dialog.querySelector('.rendering') as HTMLElement | null
expect(rendering).toBeTruthy()
const label = dialog.querySelector('.r-text')?.textContent ?? ''
expect(label).toBe(i18nT('export.labelApngPreview'))
const cancelBtn = dialog.querySelector('.r-actions button') as HTMLButtonElement | null
cancelBtn?.click()
await nextTick()
} finally {
HTMLAnchorElement.prototype.click = originalClick
handle.unmount()
}
})
})
suite('playback — transport', () => {
test('togglePlay toggles playing state', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Play', width: 60, height: 60, fill: '#55cc66' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 2, { x: 500 })
await tick()
s.togglePlay()
await tick()
expect(s.playing).toBe(true)
s.togglePlay()
await tick()
expect(s.playing).toBe(false)
})
test('togglePlay rewinds if at end', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Rew', width: 60, height: 60, fill: '#cc5555' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 2, { x: 600 })
s.setPlayhead(s.playbackOut)
await tick()
s.togglePlay()
expect(s.playhead).toBe(s.playbackIn)
expect(s.playing).toBe(true)
s.pause()
await tick()
})
test('tick advances playhead while playing', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Tk', width: 60, height: 60, fill: '#9977dd' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 5, { x: 800 })
s.play()
s.tick(1)
s.pause()
expect(s.playhead).toBeCloseTo(1, 3)
await tick()
})
test('tick stops at playbackOut', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'End', width: 60, height: 60, fill: '#dd7799' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 2, { x: 400 })
s.play()
await tick()
s.tick(s.scene.duration + 1)
await tick()
expect(s.playing).toBe(false)
expect(s.playhead).toBe(s.playbackOut)
})
})
suite('project file — save & load roundtrip', () => {
test('serialize + parse roundtrip preserves scene', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'PF', width: 80, height: 60, fill: '#66ddaa' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 1, { x: 400, rotation: 30 })
s.setDuration(7)
await tick()
const text = serializeProject(s.project)
const parsed = parseProject(text)
expect(parsed.duration).toBe(7)
expect(parsed.assets).toHaveLength(1)
const page = parsed.scenes[0]
expect(page.layers).toHaveLength(1)
expect(page.layers[0].keyframes.find((k) => k.time === 1)?.props.x).toBe(400)
})
test('loadProject replaces store state visibly', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'L1', width: 80, height: 60, fill: '#aa6666' })
s.addLayer(a.id)!
await tick()
const dump = serializeProject(s.project)
s.$reset()
await tick()
s.loadProject(parseProject(dump))
await tick()
expect(s.scene.layers).toHaveLength(1)
expect(s.scene.assets).toHaveLength(1)
expect(s.selectedLayerId).toBeNull()
expect(s.playhead).toBe(0)
expect(s.playing).toBe(false)
})
test('parseProject rejects non-JSON', () => {
let caught: unknown = null
try {
parseProject('not json at all')
} catch (e) {
caught = e
}
expect(caught instanceof ProjectFileError).toBe(true)
})
test('parseProject rejects foreign JSON without magic', () => {
let caught: unknown = null
try {
parseProject(JSON.stringify({ foo: 'bar' }))
} catch (e) {
caught = e
}
expect(caught instanceof ProjectFileError).toBe(true)
})
test('parseProject rejects missing scene fields', () => {
let caught: unknown = null
try {
parseProject(JSON.stringify({ magic: 'animatr-project', fileVersion: 1, scene: {} }))
} catch (e) {
caught = e
}
expect(caught instanceof ProjectFileError).toBe(true)
})
test('legacy `animatr-project` magic still parses (backward compat)', () => {
// When we rebranded to RUWIO we kept the on-disk magic as
// `animatr-project` so older exports continue to load. Pin that
// so a future cleanup doesn't accidentally break existing user
// files on disk.
const s = makeFreshStore()
s.addAsset({ kind: 'rect', name: 'Legacy', width: 10, height: 10, fill: '#123' })
const legacyPayload = JSON.parse(serializeProject(s.project))
expect(legacyPayload.magic).toBe('animatr-project')
// And a fully-formed legacy file parses without error.
const parsed = parseProject(JSON.stringify(legacyPayload))
expect(parsed.assets.some((a) => a.name === 'Legacy')).toBe(true)
})
test('parseProject error mentions RUWIO, not Animatr', () => {
// The user-visible error when a non-project JSON is dropped in
// reads "Not a RUWIO project file …" now. If you rebrand again
// remember to update this test alongside the string.
let msg = ''
try {
parseProject(JSON.stringify({ not: 'a project' }))
} catch (e) {
msg = (e as Error).message
}
expect(msg).toContain('RUWIO')
expect(msg.toLowerCase().includes('animatr')).toBe(false)
})
test('parseProject wraps legacy { scene } payload into Project', () => {
// Pre-v0.9 files had `{ magic, fileVersion: 1, scene }`. The new
// parser lifts those into a one-page Project so users don't have
// to re-save. Canvas + assets + layers must all land intact.
const s = makeFreshStore()
s.addAsset({ kind: 'rect', name: 'LegacyRect', width: 10, height: 10, fill: '#123' })
const legacy = {
magic: 'animatr-project',
fileVersion: 1,
scene: JSON.parse(JSON.stringify(s.scene)),
}
const parsed = parseProject(JSON.stringify(legacy))
expect(Array.isArray(parsed.scenes)).toBe(true)
expect(parsed.scenes).toHaveLength(1)
expect(parsed.activeSceneId).toBe(parsed.scenes[0].id)
expect(parsed.assets.some((a) => a.name === 'LegacyRect')).toBe(true)
expect(parsed.canvas.width).toBe(s.project.canvas.width)
})
})
suite('scene store — project & scenes', () => {
test('merged `scene` getter mirrors the active page\'s arrays by reference', () => {
// Reads through store.scene must share the SAME array references
// as the active ScenePage so `store.scene.layers.push(x)` hits
// `activeScene.layers`. If they drift, mutations silently vanish.
const s = makeFreshStore()
expect(s.scene.layers).toBe(s.activeScene.layers)
expect(s.scene.clipInstances).toBe(s.activeScene.clipInstances)
expect(s.scene.trackOrder).toBe(s.activeScene.trackOrder)
// Shared fields (asset library, canvas) come from the project.
expect(s.scene.assets).toBe(s.project.assets)
expect(s.scene.canvas).toBe(s.project.canvas)
expect(s.scene.clips).toBe(s.project.clips)
})
test('createBlankProject seeds exactly one page named "Scene 1"', () => {
const p = createBlankProject()
expect(p.scenes).toHaveLength(1)
expect(p.scenes[0].name).toBe('Scene 1')
expect(p.activeSceneId).toBe(p.scenes[0].id)
})
test('addScene appends, activates, and picks a sensible default name', () => {
const s = makeFreshStore()
expect(s.project.scenes).toHaveLength(1)
const two = s.addScene()
expect(s.project.scenes).toHaveLength(2)
expect(two.name).toBe('Scene 2')
expect(s.project.activeSceneId).toBe(two.id)
const three = s.addScene()
expect(three.name).toBe('Scene 3')
})
test('addScene respects an explicit trimmed name', () => {
const s = makeFreshStore()
const intro = s.addScene(' Intro ')
expect(intro.name).toBe('Intro')
})
test('switching scenes swaps layer stacks without touching the shared library', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Shared', width: 10, height: 10, fill: '#abc' })
const l1 = s.addLayer(a.id, 'in-scene-1')!
const two = s.addScene('Two')
// After switch: active page has no layers; the asset library stays.
expect(s.scene.layers).toHaveLength(0)
expect(s.scene.assets.some((x) => x.id === a.id)).toBe(true)
const l2 = s.addLayer(a.id, 'in-scene-2')!
// Flip back to scene 1 and confirm its layer is intact.
s.switchScene(s.project.scenes[0].id)
expect(s.scene.layers).toHaveLength(1)
expect(s.scene.layers[0].id).toBe(l1.id)
// And scene 2 still owns its own layer.
s.switchScene(two.id)
expect(s.scene.layers).toHaveLength(1)
expect(s.scene.layers[0].id).toBe(l2.id)
})
test('switchScene clears editor context (selection, clip-edit, drawing)', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'SW', width: 10, height: 10, fill: '#111' })
const l = s.addLayer(a.id)!
s.selectLayer(l.id)
const clip = s.addClip('C')
s.activeClipId = clip.id
const two = s.addScene()
// addScene already routed through switchScene — cross-check.
expect(s.selectedLayerId).toBeNull()
expect(s.selectedLayerIds).toHaveLength(0)
expect(s.activeClipId).toBeNull()
expect(s.activeButtonAssetId).toBeNull()
expect(s.project.activeSceneId).toBe(two.id)
})
test('switchScene with resetPlayhead: true rewinds to the in-point', () => {
// Script and button jumps should restart the target scene at
// its in-point — otherwise chapter-like transitions play from
// wherever the previous scene was paused.
const s = makeFreshStore()
s.setDuration(10)
const two = s.addScene('Two')
s.setPlayhead(4.5)
expect(s.playhead).toBe(4.5)
s.switchScene(two.id, { resetPlayhead: true })
expect(s.playhead).toBe(0)
})
test('switchScene leaves playback state alone — editor navigation does not force play', () => {
// The editor treats scene-switch as a navigation gesture. The
// fullscreen preview is the one that auto-plays on switch (via
// its activeSceneId watcher). If this contract regresses, the
// user would find drawer clicks silently starting playback.
const s = makeFreshStore()
const second = s.addScene('Two')
const firstId = s.project.scenes[0].id
// Paused → stays paused across a switch.
s.pause()
s.switchScene(firstId)
expect(s.playing).toBe(false)
expect(s.project.activeSceneId).toBe(firstId)
// Playing → stays playing.
s.play()
s.switchScene(second.id)
expect(s.playing).toBe(true)
expect(s.project.activeSceneId).toBe(second.id)
})
test('switchScene returns false for an unknown id and leaves state alone', () => {
const s = makeFreshStore()
const before = s.project.activeSceneId
const ok = s.switchScene('nope-no-such-scene')
expect(ok).toBe(false)
expect(s.project.activeSceneId).toBe(before)
})
test('renameScene trims and rejects empty names', () => {
const s = makeFreshStore()
const id = s.project.activeSceneId
expect(s.renameScene(id, ' Hello ')).toBe(true)
expect(s.project.scenes[0].name).toBe('Hello')
expect(s.renameScene(id, ' ')).toBe(false)
expect(s.project.scenes[0].name).toBe('Hello')
})
test('removeScene refuses to delete the last remaining page', () => {
const s = makeFreshStore()
const only = s.project.activeSceneId
expect(s.removeScene(only)).toBe(false)
expect(s.project.scenes).toHaveLength(1)
})
test('removeScene on the last page activates its predecessor', () => {
// [A, B, C] active=C → remove C → [A, B] active=B.
// The "neighbour" policy slides the active marker back one slot
// when there's nothing further down the list.
const s = makeFreshStore()
const a = s.project.scenes[0].id
const b = s.addScene('B').id
const c = s.addScene('C').id
expect(s.project.activeSceneId).toBe(c)
expect(s.removeScene(c)).toBe(true)
expect(s.project.activeSceneId).toBe(b)
expect(s.project.scenes.map((p) => p.id)).toEqual([a, b])
})
test('removeScene on a middle page activates the next one', () => {
// [A, B, C] active=B → remove B → [A, C] active=C.
// Forward is the default direction; previous is only used when
// there is no next.
const s = makeFreshStore()
const a = s.project.scenes[0].id
const b = s.addScene('B').id
const c = s.addScene('C').id
s.switchScene(b)
expect(s.project.activeSceneId).toBe(b)
expect(s.removeScene(b)).toBe(true)
expect(s.project.activeSceneId).toBe(c)
expect(s.project.scenes.map((p) => p.id)).toEqual([a, c])
})
test('removeScene on an inactive page leaves the active one untouched', () => {
const s = makeFreshStore()
const a = s.project.scenes[0].id
const b = s.addScene('B').id
const c = s.addScene('C').id
expect(s.project.activeSceneId).toBe(c)
expect(s.removeScene(b)).toBe(true)
expect(s.project.activeSceneId).toBe(c)
expect(s.project.scenes.map((p) => p.id)).toEqual([a, c])
})
test('duplicateScene deep-clones with fresh ids and activates the copy', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'D', width: 10, height: 10, fill: '#abc' })
const l = s.addLayer(a.id, 'Orig')!
s.upsertKeyframe(l.id, 1, { x: 20 })
const origId = s.project.activeSceneId
const origLayerId = l.id
const origKfIds = new Set(l.keyframes.map((k) => k.id))
const copy = s.duplicateScene(origId)!
expect(copy.id === origId).toBe(false)
expect(copy.name).toBe(`${s.project.scenes[0].name} copy`)
expect(s.project.activeSceneId).toBe(copy.id)
// Clone sits directly after the original.
const idx = s.project.scenes.findIndex((p) => p.id === origId)
expect(s.project.scenes[idx + 1].id).toBe(copy.id)
// Fresh layer + keyframe ids.
const clonedLayer = copy.layers[0]
expect(clonedLayer.id === origLayerId).toBe(false)
for (const k of clonedLayer.keyframes) expect(origKfIds.has(k.id)).toBe(false)
// Deep-cloned — mutating the clone leaves original untouched.
clonedLayer.base.x = 999
const origPage = s.project.scenes.find((p) => p.id === origId)!
expect(origPage.layers[0].base.x === 999).toBe(false)
})
test('projectFromLegacyScene wraps a single Scene into a one-page Project', () => {
const s = makeFreshStore()
s.addAsset({ kind: 'rect', name: 'LG', width: 10, height: 10, fill: '#abc' })
const project = projectFromLegacyScene(JSON.parse(JSON.stringify(s.scene)), 'My Page')
expect(project.scenes).toHaveLength(1)
expect(project.scenes[0].name).toBe('My Page')
expect(project.activeSceneId).toBe(project.scenes[0].id)
expect(project.assets.some((a) => a.name === 'LG')).toBe(true)
})
})
suite('history — undo/redo', () => {
test('canUndo starts false; becomes true after a committed mutation', async () => {
const s = makeFreshStore()
const h = createHistory(s, { debounceMs: 10 })
h.start()
expect(h.canUndo.value).toBe(false)
const a = s.addAsset({ kind: 'rect', name: 'H1', width: 10, height: 10, fill: '#000' })
s.addLayer(a.id)!
await tick(60)
expect(h.canUndo.value).toBe(true)
h.stop()
})
test('undo reverts a mutation, redo reapplies', async () => {
const s = makeFreshStore()
const h = createHistory(s, { debounceMs: 10 })
h.start()
const a = s.addAsset({ kind: 'rect', name: 'H2', width: 10, height: 10, fill: '#000' })
s.addLayer(a.id)!
await tick(60)
expect(s.scene.layers).toHaveLength(1)
h.undo()
await tick(20)
expect(s.scene.layers).toHaveLength(0)
expect(h.canRedo.value).toBe(true)
h.redo()
await tick(20)
expect(s.scene.layers).toHaveLength(1)
h.stop()
})
test('rapid mutations within debounce window collapse into one step', async () => {
const s = makeFreshStore()
const h = createHistory(s, { debounceMs: 50 })
h.start()
const a = s.addAsset({ kind: 'rect', name: 'H3', width: 10, height: 10, fill: '#000' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 1, { x: 100 })
s.upsertKeyframe(l.id, 1, { x: 200 })
s.upsertKeyframe(l.id, 1, { x: 300 })
await tick(120)
h.undo()
await tick(20)
expect(s.scene.layers).toHaveLength(0)
h.stop()
})
test('mutation after undo drops the redo branch', async () => {
const s = makeFreshStore()
const h = createHistory(s, { debounceMs: 10 })
h.start()
const a = s.addAsset({ kind: 'rect', name: 'H4', width: 10, height: 10, fill: '#000' })
s.addLayer(a.id)!
await tick(40)
h.undo()
await tick(20)
expect(h.canRedo.value).toBe(true)
const b = s.addAsset({ kind: 'circle', name: 'H4c', radius: 10, fill: '#fff' })
s.addLayer(b.id)!
await tick(40)
expect(h.canRedo.value).toBe(false)
h.stop()
})
test('resetTo clears history and seeds initial snapshot', async () => {
const s = makeFreshStore()
const h = createHistory(s, { debounceMs: 10 })
h.start()
const a = s.addAsset({ kind: 'rect', name: 'H5', width: 10, height: 10, fill: '#000' })
s.addLayer(a.id)!
await tick(40)
expect(h.canUndo.value).toBe(true)
h.resetTo(s.scene)
expect(h.canUndo.value).toBe(false)
expect(h.canRedo.value).toBe(false)
h.stop()
})
test('undo clamps playhead when target scene has shorter duration', async () => {
const s = makeFreshStore()
const h = createHistory(s, { debounceMs: 10 })
h.start()
s.setDuration(3)
await tick(40)
s.setDuration(10)
s.setPlayhead(8)
await tick(40)
h.undo()
await tick(20)
expect(s.scene.duration).toBe(3)
expect(s.playhead).toBe(3)
h.stop()
})
test('undo after switchScene restores the previous activeSceneId', async () => {
// History snapshots the whole project including activeSceneId, so
// a scene switch is itself an undoable step. If this regresses the
// user will notice a sudden loss of "undo back to where I was".
const s = makeFreshStore()
const h = createHistory(s, { debounceMs: 10 })
h.start()
const a = s.project.scenes[0].id
const b = s.addScene('B').id
await tick(40)
// Now switch back to A; that's a fresh snapshottable step.
s.switchScene(a)
await tick(40)
expect(s.project.activeSceneId).toBe(a)
h.undo()
await tick(20)
expect(s.project.activeSceneId).toBe(b)
h.stop()
})
test('undo after addScene removes the just-added page', async () => {
const s = makeFreshStore()
const h = createHistory(s, { debounceMs: 10 })
h.start()
expect(s.project.scenes).toHaveLength(1)
const origId = s.project.activeSceneId
s.addScene('B')
await tick(40)
expect(s.project.scenes).toHaveLength(2)
h.undo()
await tick(20)
expect(s.project.scenes).toHaveLength(1)
expect(s.project.activeSceneId).toBe(origId)
h.stop()
})
test('undo after removeScene restores the deleted page content', async () => {
const s = makeFreshStore()
const h = createHistory(s, { debounceMs: 10 })
h.start()
const a = s.addAsset({ kind: 'rect', name: 'U', width: 10, height: 10, fill: '#abc' })
const two = s.addScene('Two')
s.addLayer(a.id, 'only-in-two')
await tick(40)
expect(s.scene.layers).toHaveLength(1)
const twoId = two.id
// Flip away, then delete. Both are undoable steps.
s.switchScene(s.project.scenes[0].id)
await tick(40)
s.removeScene(twoId)
await tick(40)
expect(s.project.scenes.some((p) => p.id === twoId)).toBe(false)
h.undo()
await tick(20)
const restored = s.project.scenes.find((p) => p.id === twoId)
expect(restored).toBeTruthy()
expect(restored!.layers).toHaveLength(1)
expect(restored!.layers[0].name).toBe('only-in-two')
h.stop()
})
test('max history cap drops oldest entries', async () => {
const s = makeFreshStore()
const h = createHistory(s, { debounceMs: 10, max: 3 })
h.start()
s.setDuration(2)
await tick(30)
s.setDuration(3)
await tick(30)
s.setDuration(4)
await tick(30)
s.setDuration(5)
await tick(30)
h.undo()
h.undo()
h.undo()
await tick(20)
expect(s.scene.duration).toBeGreaterThan(2)
h.stop()
})
})
suite('ui — Modal', () => {
// Modal teleports to document.body. If the running app has a
// dialog open (welcome / export / settings), a plain
// document.querySelector('[role="dialog"]') would match that
// instead of what the test just mounted. Snapshot the live
// dialogs first and diff after mount so each assertion only
// considers the nodes this test created.
const snapshotDialogs = () =>
new Set(Array.from(document.querySelectorAll('[role="dialog"]')))
const dialogsAddedSince = (baseline: Set<Element>) =>
Array.from(document.querySelectorAll('[role="dialog"]')).filter(
(el) => !baseline.has(el),
)
test('renders nothing when modelValue is false', async () => {
const baseline = snapshotDialogs()
const handle = mountComponent(Modal, { modelValue: false, title: 'X' })
await nextTick()
expect(dialogsAddedSince(baseline).length).toBe(0)
handle.unmount()
})
test('teleports backdrop + dialog to body when open, renders title', async () => {
const baseline = snapshotDialogs()
const handle = mountComponent(Modal, { modelValue: true, title: 'Hallo Modal' })
await nextTick()
const added = dialogsAddedSince(baseline)
expect(added.length).toBe(1)
const dialog = added[0] as HTMLElement
expect(dialog.textContent?.includes('Hallo Modal')).toBe(true)
handle.unmount()
})
test('Esc triggers update:modelValue false', async () => {
let latest: boolean | null = null
const handle = mountComponent(Modal, {
modelValue: true,
title: 'X',
'onUpdate:modelValue': (v: boolean) => {
latest = v
},
})
await nextTick()
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
await nextTick()
expect(latest).toBe(false)
handle.unmount()
})
test('Esc does nothing when closeOnEsc is false', async () => {
let latest: boolean | null = null
const handle = mountComponent(Modal, {
modelValue: true,
title: 'X',
closeOnEsc: false,
'onUpdate:modelValue': (v: boolean) => {
latest = v
},
})
await nextTick()
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
await nextTick()
expect(latest).toBeNull()
handle.unmount()
})
test('dismissable=false hides close button and swallows Esc', async () => {
let latest: boolean | null = null
const baseline = snapshotDialogs()
const handle = mountComponent(Modal, {
modelValue: true,
title: 'Busy',
dismissable: false,
'onUpdate:modelValue': (v: boolean) => {
latest = v
},
})
await nextTick()
const added = dialogsAddedSince(baseline)
expect(added.length).toBe(1)
const dialog = added[0] as HTMLElement
const btn = dialog.querySelector('header button')
expect(btn).toBeNull()
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
await nextTick()
expect(latest).toBeNull()
handle.unmount()
})
test('renders footer slot content', async () => {
const handle = mountComponent(
Modal,
{ modelValue: true, title: 'X' },
{
footer: () => [h('button', { class: 'my-footer-btn' }, 'Fertig')],
},
)
await nextTick()
const btn = document.querySelector('.my-footer-btn')
expect(btn).toBeTruthy()
expect(btn!.textContent).toBe('Fertig')
handle.unmount()
})
})
suite('ui — HelpDialog', () => {
const snapshotDialogs = () =>
new Set(Array.from(document.querySelectorAll('[role="dialog"]')))
const dialogsAddedSince = (baseline: Set<Element>) =>
Array.from(document.querySelectorAll('[role="dialog"]')).filter(
(el) => !baseline.has(el),
) as HTMLElement[]
test('opens on modelValue=true and renders a TOC with all 13 chapters', async () => {
const baseline = snapshotDialogs()
const handle = mountComponent(HelpDialog, { modelValue: true })
await nextTick()
const added = dialogsAddedSince(baseline)
expect(added.length).toBe(1)
const tocItems = added[0].querySelectorAll('.toc-item')
expect(tocItems.length).toBe(13)
handle.unmount()
})
test('defaults to the Getting started chapter', async () => {
const baseline = snapshotDialogs()
const handle = mountComponent(HelpDialog, { modelValue: true })
await nextTick()
const dialog = dialogsAddedSince(baseline)[0]
const activeToc = dialog.querySelector('.toc-item.active') as HTMLElement
expect(activeToc).toBeTruthy()
expect(activeToc.textContent?.trim()).toBe('Getting started')
// Content heading matches.
const heading = dialog.querySelector('.chapter h3')
expect(heading?.textContent?.trim()).toBe('Getting started')
handle.unmount()
})
test('clicking a TOC item swaps the right-pane content', async () => {
const baseline = snapshotDialogs()
const handle = mountComponent(HelpDialog, { modelValue: true })
await nextTick()
const dialog = dialogsAddedSince(baseline)[0]
const items = Array.from(dialog.querySelectorAll('.toc-item')) as HTMLButtonElement[]
const scripting = items.find((el) => el.textContent?.includes('Script language'))
expect(scripting).toBeTruthy()
scripting!.click()
await nextTick()
const heading = dialog.querySelector('.chapter h3')
expect(heading?.textContent?.trim()).toBe('Script language')
// Active highlight moved.
const active = dialog.querySelector('.toc-item.active')
expect(active?.textContent?.trim()).toBe('Script language')
handle.unmount()
})
test('Script language chapter documents every DSL command', async () => {
// Pin the parser/help alignment — if a new command ships in
// scriptDsl.ts, this test reminds us to document it here too.
const baseline = snapshotDialogs()
const handle = mountComponent(HelpDialog, { modelValue: true })
await nextTick()
const dialog = dialogsAddedSince(baseline)[0]
const items = Array.from(dialog.querySelectorAll('.toc-item')) as HTMLButtonElement[]
items.find((el) => el.textContent?.includes('Script language'))!.click()
await nextTick()
const body = dialog.querySelector('.chapter')!.textContent ?? ''
expect(body).toContain('play')
expect(body).toContain('pause')
expect(body).toContain('rewind')
expect(body).toContain('jump')
expect(body).toContain('#')
expect(body).toContain('@')
expect(body).toContain('//')
handle.unmount()
})
test('Shortcuts chapter renders one row per shortcut with a kbd key', async () => {
const baseline = snapshotDialogs()
const handle = mountComponent(HelpDialog, { modelValue: true })
await nextTick()
const dialog = dialogsAddedSince(baseline)[0]
const items = Array.from(dialog.querySelectorAll('.toc-item')) as HTMLButtonElement[]
items.find((el) => el.textContent?.includes('Keyboard shortcuts'))!.click()
await nextTick()
const rows = dialog.querySelectorAll('.shortcuts li')
expect(rows.length).toBeGreaterThan(9)
// Every row ends with a kbd element.
for (const li of Array.from(rows)) {
expect(li.querySelector('kbd')).toBeTruthy()
}
handle.unmount()
})
test('reopening resets to the first chapter (no stale selection)', async () => {
// Regression: a user who leaves the dialog on "Script language"
// and reopens it expects to land on the intro, not mid-content.
const open = ref(true)
const Wrapper = defineComponent({
render: () => h(HelpDialog, { modelValue: open.value }),
})
const handle = mountComponent(Wrapper, {})
await nextTick()
const items = Array.from(
document.querySelectorAll('.toc-item'),
) as HTMLButtonElement[]
items.find((el) => el.textContent?.includes('Script language'))!.click()
await nextTick()
open.value = false
await nextTick()
open.value = true
await nextTick()
await nextTick()
const heading = document.querySelector('.chapter h3')
expect(heading?.textContent?.trim()).toBe('Getting started')
handle.unmount()
})
})
suite('ui — IconButton', () => {
test('renders a button element', async () => {
// IconButton now uses the custom v-tooltip directive instead of
// the native `title` attribute. The directive still sets an
// `aria-label` so screen readers get the tooltip text — that's
// the stable contract this test pins.
const handle = mountComponent(IconButton, { title: 'hi' })
await nextTick()
const btn = handle.host.querySelector('button')
expect(btn).toBeTruthy()
expect(btn!.getAttribute('aria-label')).toBe('hi')
handle.unmount()
})
test('applies variant + size + active classes', async () => {
const handle = mountComponent(IconButton, {
variant: 'primary',
size: 'md',
active: true,
})
await nextTick()
const btn = handle.host.querySelector('button')!
expect(btn.className.includes('variant-primary')).toBe(true)
expect(btn.className.includes('size-md')).toBe(true)
expect(btn.className.includes('active')).toBe(true)
handle.unmount()
})
test('disabled prop disables the native button', async () => {
const handle = mountComponent(IconButton, { disabled: true })
await nextTick()
const btn = handle.host.querySelector('button')!
expect(btn.disabled).toBe(true)
handle.unmount()
})
test('renders default slot in place of icon prop', async () => {
const handle = mountComponent(
IconButton,
{},
{ default: () => [h('span', { class: 'custom-icon' }, 'X')] },
)
await nextTick()
const span = handle.host.querySelector('.custom-icon')
expect(span).toBeTruthy()
expect(span!.textContent).toBe('X')
handle.unmount()
})
test('click fires native click event', async () => {
let clicks = 0
const handle = mountComponent(IconButton, {
onClick: () => {
clicks++
},
})
await nextTick()
handle.host.querySelector('button')!.click()
expect(clicks).toBe(1)
handle.unmount()
})
})
suite('ui — Toast', () => {
test('hidden when visible=false, nothing in body', async () => {
const handle = mountComponent(Toast, { visible: false, message: 'x' })
await nextTick()
expect(document.querySelector('.toast')).toBeNull()
handle.unmount()
})
test('visible=true renders a teleported element with message', async () => {
const handle = mountComponent(Toast, {
visible: true,
variant: 'success',
message: 'Alles gut',
})
await nextTick()
const el = document.querySelector('.toast') as HTMLElement | null
expect(el).toBeTruthy()
expect(el!.className.includes('variant-success')).toBe(true)
expect(el!.textContent?.includes('Alles gut')).toBe(true)
handle.unmount()
})
test('applies position class', async () => {
const handle = mountComponent(Toast, {
visible: true,
position: 'top',
message: 'x',
})
await nextTick()
const el = document.querySelector('.toast')!
expect(el.className.includes('position-top')).toBe(true)
handle.unmount()
})
test('default slot overrides title/message', async () => {
const handle = mountComponent(
Toast,
{ visible: true, message: 'ignored' },
{ default: () => [h('span', { class: 'slot-body' }, 'slot wins')] },
)
await nextTick()
const slot = document.querySelector('.slot-body')
expect(slot).toBeTruthy()
expect(document.querySelector('.toast')!.textContent?.includes('slot wins')).toBe(true)
handle.unmount()
})
})
suite('project store — IndexedDB recents', () => {
test('save then getStoredProject returns identical scene', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'RS', width: 42, height: 42, fill: '#123456' })
s.addLayer(a.id)!
const rec = await saveProjectToStore(s.project, { name: 'test-save-get' })
try {
const back = await getStoredProject(rec.id)
expect(back).toBeTruthy()
expect(back!.name).toBe('test-save-get')
expect(back!.project!.assets).toHaveLength(1)
expect(back!.project!.scenes[0].layers).toHaveLength(1)
} finally {
await deleteStoredProject(rec.id)
}
})
test('listRecentProjects is sorted by savedAt desc', async () => {
const s = makeFreshStore()
const olderId = `test-older-${Date.now()}`
const newerId = `test-newer-${Date.now()}`
const older = await saveProjectToStore(s.project, { id: olderId, name: 'older' })
await tick(5)
const newer = await saveProjectToStore(s.project, { id: newerId, name: 'newer' })
try {
const list = await listRecentProjects()
const newerIdx = list.findIndex((p) => p.id === newer.id)
const olderIdx = list.findIndex((p) => p.id === older.id)
expect(newerIdx).toBeGreaterThan(-1)
expect(olderIdx).toBeGreaterThan(-1)
expect(newerIdx).toBeLessThan(olderIdx)
} finally {
await deleteStoredProject(olderId)
await deleteStoredProject(newerId)
}
})
test('save with same id updates existing record instead of adding', async () => {
const s = makeFreshStore()
const id = `test-update-${Date.now()}`
await saveProjectToStore(s.project, { id, name: 'v1' })
const before = (await listRecentProjects()).filter((p) => p.id === id).length
await saveProjectToStore(s.project, { id, name: 'v2' })
try {
const list = await listRecentProjects()
const matches = list.filter((p) => p.id === id)
expect(matches).toHaveLength(1)
expect(matches[0].name).toBe('v2')
expect(before).toBe(1)
} finally {
await deleteStoredProject(id)
}
})
test('deleteStoredProject removes entry', async () => {
const s = makeFreshStore()
const rec = await saveProjectToStore(s.project, { name: 'to-delete' })
await deleteStoredProject(rec.id)
const back = await getStoredProject(rec.id)
expect(back).toBeNull()
})
test('getStoredProject returns null for missing id', async () => {
const back = await getStoredProject('definitely-missing-id-42')
expect(back).toBeNull()
})
})
suite('ui — WelcomeDialog', () => {
// Modal teleports to body — snapshot/diff dialogs to isolate.
const snapshotDialogs = () =>
new Set(Array.from(document.querySelectorAll('[role="dialog"]')))
const dialogsAddedSince = (baseline: Set<Element>) =>
Array.from(document.querySelectorAll('[role="dialog"]')).filter(
(el) => !baseline.has(el),
) as HTMLElement[]
/** Poll until the predicate matches or the budget elapses. The
* dialog's IDB load + Vue render cycle is fully async, so fixed
* sleeps were flaky on slower machines. */
async function waitUntil(predicate: () => boolean, budgetMs = 1000, stepMs = 10) {
const start = performance.now()
while (!predicate()) {
if (performance.now() - start > budgetMs) return
await tick(stepMs)
}
}
test('ArrowDown / ArrowUp move focus through the recents list', async () => {
// Seed two recents via the real IDB path so the dialog's own
// `listRecentProjects` call finds them.
const s = makeFreshStore()
const idA = `test-arrow-a-${Date.now()}`
const idB = `test-arrow-b-${Date.now()}`
await saveProjectToStore(s.project, { id: idA, name: 'A' })
await tick(5)
await saveProjectToStore(s.project, { id: idB, name: 'B' })
const baseline = snapshotDialogs()
const handle = mountComponent(WelcomeDialog, { modelValue: true })
try {
await waitUntil(() => {
const d = dialogsAddedSince(baseline)[0]
return !!d && d.querySelectorAll('.recent').length >= 2
})
const dialog = dialogsAddedSince(baseline)[0]
expect(dialog).toBeTruthy()
const items = dialog.querySelectorAll('.recent') as NodeListOf<HTMLElement>
expect(items.length >= 2).toBe(true)
items[0].focus()
expect(document.activeElement).toBe(items[0])
items[0].dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }),
)
await nextTick()
expect(document.activeElement).toBe(items[1])
items[1].dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }),
)
await nextTick()
expect(document.activeElement).toBe(items[0])
} finally {
handle.unmount()
await deleteStoredProject(idA).catch(() => {})
await deleteStoredProject(idB).catch(() => {})
}
})
test('Home and End jump to the first / last recent', async () => {
const s = makeFreshStore()
const ids = [
`test-home-end-a-${Date.now()}`,
`test-home-end-b-${Date.now()}`,
`test-home-end-c-${Date.now()}`,
]
for (const id of ids) {
await saveProjectToStore(s.project, { id, name: id })
await tick(5)
}
const baseline = snapshotDialogs()
const handle = mountComponent(WelcomeDialog, { modelValue: true })
try {
await waitUntil(() => {
const d = dialogsAddedSince(baseline)[0]
return !!d && d.querySelectorAll('.recent').length >= 3
})
const dialog = dialogsAddedSince(baseline)[0]
const items = dialog.querySelectorAll('.recent') as NodeListOf<HTMLElement>
expect(items.length >= 3).toBe(true)
items[1].focus()
items[1].dispatchEvent(
new KeyboardEvent('keydown', { key: 'End', bubbles: true }),
)
await nextTick()
expect(document.activeElement).toBe(items[items.length - 1])
items[items.length - 1].dispatchEvent(
new KeyboardEvent('keydown', { key: 'Home', bubbles: true }),
)
await nextTick()
expect(document.activeElement).toBe(items[0])
} finally {
handle.unmount()
for (const id of ids) await deleteStoredProject(id).catch(() => {})
}
})
test('Enter on a focused recent opens the project (when no confirm is up)', async () => {
// Regression guard: Enter must open the focused project while
// the welcome is the active modal. Delete-confirm (next test)
// verifies that Enter doesn't leak through to this handler while
// the confirm is on top.
const s = makeFreshStore()
const idA = `test-enter-a-${Date.now()}`
await saveProjectToStore(s.project, { id: idA, name: 'Open me' })
const baseline = snapshotDialogs()
let opened: { name: string } | null = null
const handle = mountComponent(WelcomeDialog, {
modelValue: true,
'onOpen': (p: { name: string }) => {
opened = p
},
})
try {
await waitUntil(() => {
const d = dialogsAddedSince(baseline)[0]
return !!d && d.querySelectorAll('.recent').length >= 1
})
const welcome = dialogsAddedSince(baseline)[0]
const top = welcome.querySelectorAll('.recent')[0] as HTMLElement
top.focus()
top.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
)
await nextTick()
expect(opened).toBeTruthy()
} finally {
handle.unmount()
await deleteStoredProject(idA).catch(() => {})
}
})
test('Enter while the delete-confirm is up triggers confirm (not openProject)', async () => {
// After Backspace pops the confirm, focus must move to the
// confirm button so the next Enter fires its click instead of
// bubbling back to the welcome row and opening the project.
const s = makeFreshStore()
const idA = `test-enter-confirm-a-${Date.now()}`
const idB = `test-enter-confirm-b-${Date.now()}`
await saveProjectToStore(s.project, { id: idA, name: 'Keep' })
await tick(5)
await saveProjectToStore(s.project, { id: idB, name: 'DropMe' })
const baseline = snapshotDialogs()
let openCalls = 0
const handle = mountComponent(WelcomeDialog, {
modelValue: true,
'onOpen': () => {
openCalls += 1
},
})
try {
await waitUntil(() => {
const d = dialogsAddedSince(baseline)[0]
return !!d && d.querySelectorAll('.recent').length >= 2
})
const welcome = dialogsAddedSince(baseline)[0]
// The newest seed ('DropMe' / idB) is at the top of the list.
// We assert by name rather than by count because the browser's
// IDB may already hold MAX_RECENT real projects; in that case
// deleting the top entry just unmasks a hidden record and the
// count stays the same even though the seed is gone.
const top = welcome.querySelectorAll('.recent')[0] as HTMLElement
top.focus()
top.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true }),
)
await waitUntil(() => !!document.querySelector('.backdrop.topmost'))
// Confirm dialog is up; focus should now be on its primary
// button so Enter fires the confirm instead of bubbling back
// to the welcome row's Enter handler (which would openProject).
const topmost = document.querySelector('.backdrop.topmost') as HTMLElement
const primary = topmost.querySelector('footer .btn.primary') as HTMLButtonElement
await waitUntil(() => document.activeElement === primary)
expect(document.activeElement).toBe(primary)
primary.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
)
// Native browser behaviour: Enter on a focused button synthesises
// a click. Simulate that click directly to keep jsdom/Chrome
// behaviour consistent inside the test.
primary.click()
await waitUntil(() => !welcome.textContent?.includes('DropMe'))
expect(welcome.textContent?.includes('DropMe')).toBe(false)
// The welcome's openProject handler must NOT have fired.
expect(openCalls).toBe(0)
} finally {
handle.unmount()
await deleteStoredProject(idA).catch(() => {})
await deleteStoredProject(idB).catch(() => {})
}
})
test('Backspace on a focused recent opens the confirm and removes it on OK', async () => {
// Seed two recents; mount welcome; focus the top one; press
// Backspace. A confirm dialog is expected to pop (topmost layer),
// and clicking OK should drop the entry from the welcome list.
const s = makeFreshStore()
const idA = `test-backspace-a-${Date.now()}`
const idB = `test-backspace-b-${Date.now()}`
await saveProjectToStore(s.project, { id: idA, name: 'Keep' })
await tick(5)
await saveProjectToStore(s.project, { id: idB, name: 'DropMe' })
const baseline = snapshotDialogs()
const handle = mountComponent(WelcomeDialog, { modelValue: true })
try {
await waitUntil(() => {
const d = dialogsAddedSince(baseline)[0]
return !!d && d.querySelectorAll('.recent').length >= 2
})
const welcome = dialogsAddedSince(baseline)[0]
// Assert by seed name rather than count — MAX_RECENT caps the
// visible list at 8, so if the browser's IDB already has enough
// real projects, deleting the top seed just unmasks a hidden
// record and the count does not drop.
const top = welcome.querySelectorAll('.recent')[0] as HTMLElement
top.focus()
expect(document.activeElement).toBe(top)
top.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true }),
)
await waitUntil(() => !!document.querySelector('.backdrop.topmost'))
const topmost = document.querySelector('.backdrop.topmost') as HTMLElement
const confirmBtn = topmost.querySelector('footer .btn.primary') as HTMLButtonElement
expect(confirmBtn).toBeTruthy()
confirmBtn.click()
// reload() briefly flips loading=true, which hides .recents
// behind the .empty "loading…" placeholder and also unmounts
// the <li>s (so 'DropMe' is transiently absent from textContent
// too). Wait for the list to fully settle before asserting.
await waitUntil(
() =>
!welcome.textContent?.includes('DropMe') &&
!!welcome.querySelector('.recents'),
)
expect(welcome.textContent?.includes('DropMe')).toBe(false)
// Focus is placed by deleteRecentWithConfirm AFTER reload
// returns, so poll until it lands inside welcome.
await waitUntil(() => welcome.contains(document.activeElement))
expect(welcome.contains(document.activeElement)).toBe(true)
} finally {
handle.unmount()
await deleteStoredProject(idA).catch(() => {})
await deleteStoredProject(idB).catch(() => {})
}
})
test('delete confirm floats above the welcome dialog and the list updates afterwards', async () => {
// Reproduces: clicking the trash icon used to pop the confirm
// behind welcome (both z-index 900); pressing ESC then dismissed
// welcome as well because both modals ate the key. The topmost
// flag fixes stacking and the stack-based ESC filter keeps the
// confirm from dragging welcome down with it.
const s = makeFreshStore()
const idA = `test-del-a-${Date.now()}`
const idB = `test-del-b-${Date.now()}`
await saveProjectToStore(s.project, { id: idA, name: 'Keep' })
await tick(5)
await saveProjectToStore(s.project, { id: idB, name: 'DropMe' })
const baseline = snapshotDialogs()
const handle = mountComponent(WelcomeDialog, { modelValue: true })
try {
await waitUntil(() => {
const d = dialogsAddedSince(baseline)[0]
return !!d && d.querySelectorAll('.recent').length >= 2
})
const welcome = dialogsAddedSince(baseline)[0]
expect(welcome).toBeTruthy()
expect(welcome.querySelectorAll('.recent').length >= 2).toBe(true)
const target = welcome.querySelectorAll('.recent')[0] as HTMLElement
const delBtn = target.querySelector('.del') as HTMLButtonElement
expect(delBtn).toBeTruthy()
delBtn.click()
await waitUntil(() => !!document.querySelector('.backdrop.topmost'))
const topmost = document.querySelector('.backdrop.topmost') as HTMLElement
expect(topmost).toBeTruthy()
const confirmBtn = topmost.querySelector(
'footer .btn.primary',
) as HTMLButtonElement
expect(confirmBtn).toBeTruthy()
confirmBtn.click()
// Assert by seed name rather than count — MAX_RECENT=8 caps the
// visible list, so deleting the top seed may just unmask a
// hidden record instead of dropping the total. Also wait for
// the list to rerender: reload() briefly flips loading=true,
// which hides .recents behind the "loading…" placeholder.
await waitUntil(
() =>
!welcome.textContent?.includes('DropMe') &&
!!welcome.querySelector('.recents'),
)
expect(welcome.textContent?.includes('DropMe')).toBe(false)
expect(welcome.isConnected).toBe(true)
expect(welcome.querySelector('.recents')).toBeTruthy()
} finally {
handle.unmount()
await deleteStoredProject(idA).catch(() => {})
await deleteStoredProject(idB).catch(() => {})
}
})
})
suite('clips — library & scope', () => {
test('addClip creates empty clip in library', () => {
const s = makeFreshStore()
const c = s.addClip('Intro')
expect(s.scene.clips?.length).toBe(1)
expect(c.name).toBe('Intro')
expect(c.layers).toHaveLength(0)
expect(c.duration).toBeGreaterThan(0)
})
test('enterClip switches activeLayers + activeDuration to clip scope', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'SceneL', width: 20, height: 20, fill: '#aaa' })
s.addLayer(a.id)!
const c = s.addClip('C1')
c.duration = 3.5
expect(s.activeLayers).toHaveLength(1)
expect(s.activeDuration).toBe(s.scene.duration)
s.enterClip(c.id)
await tick()
expect(s.activeLayers).toHaveLength(0)
expect(s.activeDuration).toBe(3.5)
expect(s.playhead).toBe(0)
s.exitClip()
expect(s.activeLayers).toHaveLength(1)
expect(s.activeDuration).toBe(s.scene.duration)
})
test('addLayer while in clip scope targets clip layers, not scene layers', async () => {
const s = makeFreshStore()
const c = s.addClip('C2')
const a = s.addAsset({ kind: 'rect', name: 'X', width: 20, height: 20, fill: '#888' })
s.enterClip(c.id)
await tick()
s.addLayer(a.id)!
expect(c.layers).toHaveLength(1)
expect(s.scene.layers).toHaveLength(0)
})
test('addClip auto-creates a first clip track if none exist', () => {
const s = makeFreshStore()
expect(s.scene.clipTracks ?? []).toHaveLength(0)
s.addClip('Auto')
expect((s.scene.clipTracks ?? []).length).toBeGreaterThan(0)
expect((s.scene.trackOrder ?? []).length).toBeGreaterThan(0)
})
test('addClipInstance anchors to a specific clipTrackId', () => {
const s = makeFreshStore()
const c = s.addClip('C3')
const t = s.scene.clipTracks![0]
const ci = s.addClipInstance(c.id, 2.5, t.id)
expect(ci).toBeTruthy()
expect(s.scene.clipInstances).toHaveLength(1)
expect(ci!.startTime).toBe(2.5)
expect(ci!.clipTrackId).toBe(t.id)
})
test('moveClipInstance updates position + can switch track', () => {
const s = makeFreshStore()
const c = s.addClip('C4')
const t1 = s.scene.clipTracks![0]
const t2 = s.addClipTrack()
const ci = s.addClipInstance(c.id, 0, t1.id)!
s.moveClipInstance(ci.id, 4, t2.id)
expect(ci.startTime).toBe(4)
expect(ci.clipTrackId).toBe(t2.id)
})
test('removeClip removes all instances and exits if active', () => {
const s = makeFreshStore()
const c = s.addClip('C5')
const t = s.scene.clipTracks![0]
s.addClipInstance(c.id, 0, t.id)
s.addClipInstance(c.id, 1, t.id)
s.enterClip(c.id)
s.removeClip(c.id)
expect(s.scene.clips).toHaveLength(0)
expect(s.scene.clipInstances).toHaveLength(0)
expect(s.activeClipId).toBeNull()
})
test('negative startTime is clamped to 0', () => {
const s = makeFreshStore()
const c = s.addClip('C6')
const t = s.scene.clipTracks![0]
const ci = s.addClipInstance(c.id, -5, t.id)!
expect(ci.startTime).toBe(0)
})
test('trimClipInstance clamps trimStart/trimEnd and preserves min visible duration', () => {
const s = makeFreshStore()
const c = s.addClip('Trim')
c.duration = 4
const t = s.scene.clipTracks![0]
const ci = s.addClipInstance(c.id, 0, t.id)!
s.trimClipInstance(ci.id, { trimStart: 1 })
expect(ci.trimStart).toBe(1)
s.trimClipInstance(ci.id, { trimEnd: 1 })
expect(ci.trimEnd).toBe(1)
// try to over-trim: start + end > duration - MIN_VISIBLE
s.trimClipInstance(ci.id, { trimStart: 10 })
const total = (ci.trimStart ?? 0) + (ci.trimEnd ?? 0)
expect(total).toBeLessThan(c.duration)
})
test('trimClipInstance accepts startTime shift (left-handle drag)', () => {
const s = makeFreshStore()
const c = s.addClip('TrimLeft')
c.duration = 4
const t = s.scene.clipTracks![0]
const ci = s.addClipInstance(c.id, 2, t.id)!
s.trimClipInstance(ci.id, { startTime: 2.5, trimStart: 0.5 })
expect(ci.startTime).toBe(2.5)
expect(ci.trimStart).toBe(0.5)
})
test('negative trim values are clamped to zero', () => {
const s = makeFreshStore()
const c = s.addClip('Neg')
c.duration = 3
const t = s.scene.clipTracks![0]
const ci = s.addClipInstance(c.id, 0, t.id)!
s.trimClipInstance(ci.id, { trimStart: -1, trimEnd: -2 })
expect(ci.trimStart).toBe(0)
expect(ci.trimEnd).toBe(0)
})
test('setClipInstanceTransform stores x / y / scale / rotation', () => {
const s = makeFreshStore()
const c = s.addClip('Xf')
const t = s.scene.clipTracks![0]
const ci = s.addClipInstance(c.id, 0, t.id)!
s.setClipInstanceTransform(ci.id, {
x: 80,
y: -30,
scaleX: 2,
scaleY: 0.5,
rotation: 45,
})
expect(ci.x).toBe(80)
expect(ci.y).toBe(-30)
expect(ci.scaleX).toBe(2)
expect(ci.scaleY).toBe(0.5)
expect(ci.rotation).toBe(45)
})
test('setClipInstanceTransform normalises rotation into (-180, 180]', () => {
// Values outside the canonical range are wrapped so the stored
// number stays human-readable across multiple transform
// sessions (Konva's transformer accumulates past 360).
const s = makeFreshStore()
const c = s.addClip('Xf')
const t = s.scene.clipTracks![0]
const ci = s.addClipInstance(c.id, 0, t.id)!
s.setClipInstanceTransform(ci.id, { rotation: 370 })
expect(ci.rotation).toBe(10)
s.setClipInstanceTransform(ci.id, { rotation: -190 })
expect(ci.rotation).toBe(170)
s.setClipInstanceTransform(ci.id, { rotation: 360 })
// 360 % 360 = 0 → pruned back out of the object.
expect('rotation' in ci).toBe(false)
})
test('setClipInstanceTransform prunes defaults so JSON stays clean', () => {
// Defaults (x=0, y=0, scale=1) are absent on a freshly-added
// instance. Writing them back explicitly mustn't leave the
// field around — serialized scenes stay minimal and diffable.
const s = makeFreshStore()
const c = s.addClip('Xf')
const t = s.scene.clipTracks![0]
const ci = s.addClipInstance(c.id, 0, t.id)!
s.setClipInstanceTransform(ci.id, { x: 50, scaleX: 2 })
expect(ci.x).toBe(50)
expect(ci.scaleX).toBe(2)
// Clear back to defaults — the keys should get deleted.
s.setClipInstanceTransform(ci.id, { x: 0, scaleX: 1 })
expect('x' in ci).toBe(false)
expect('scaleX' in ci).toBe(false)
})
test('setClipInstanceTransform leaves unspecified fields alone', () => {
const s = makeFreshStore()
const c = s.addClip('Xf')
const t = s.scene.clipTracks![0]
const ci = s.addClipInstance(c.id, 0, t.id)!
s.setClipInstanceTransform(ci.id, { x: 10, y: 20, scaleX: 3, scaleY: 3 })
// Partial patch: only x. y / scale must survive.
s.setClipInstanceTransform(ci.id, { x: 99 })
expect(ci.x).toBe(99)
expect(ci.y).toBe(20)
expect(ci.scaleX).toBe(3)
expect(ci.scaleY).toBe(3)
})
test('setClipInstanceTransform on an unknown id is a no-op', () => {
const s = makeFreshStore()
const c = s.addClip('Xf')
const t = s.scene.clipTracks![0]
s.addClipInstance(c.id, 0, t.id)!
s.setClipInstanceTransform('not-an-id', { x: 5 })
// No-op; no side effects or throw.
expect(s.scene.clipInstances!.every((ci) => ci.x === undefined)).toBe(true)
})
test('selectClipInstance clears the layer selection and vice versa', () => {
// Selection is mutually exclusive — stage transformer only
// targets one thing at a time.
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
const c = s.addClip('Xf')
const t = s.scene.clipTracks![0]
const ci = s.addClipInstance(c.id, 0, t.id)!
s.selectLayer(l.id)
expect(s.selectedLayerId).toBe(l.id)
expect(s.selectedClipInstanceId).toBe(null)
s.selectClipInstance(ci.id)
expect(s.selectedClipInstanceId).toBe(ci.id)
expect(s.selectedLayerId).toBe(null)
expect(s.selectedLayerIds).toEqual([])
s.selectLayer(l.id)
expect(s.selectedLayerId).toBe(l.id)
expect(s.selectedClipInstanceId).toBe(null)
s.selectClipInstance(null)
expect(s.selectedClipInstanceId).toBe(null)
// selecting null on the clip branch doesn't touch the layer
// selection — the user just cleared the clip-side pointer.
expect(s.selectedLayerId).toBe(l.id)
})
test('removeClipInstance clears selectedClipInstanceId when it matched', () => {
const s = makeFreshStore()
const c = s.addClip('Xf')
const t = s.scene.clipTracks![0]
const ci = s.addClipInstance(c.id, 0, t.id)!
s.selectClipInstance(ci.id)
s.removeClipInstance(ci.id)
expect(s.selectedClipInstanceId).toBe(null)
})
test('drawSceneFrame rotates the clip-instance group on the canvas', () => {
// Geometry that keeps the rect on-canvas through a 180° flip:
// - 20×10 rect anchored at layer origin (0,0) with no offset.
// - Clip instance at x=50, y=50 → unrotated, rect lives at
// world (50..70, 50..60).
// - Rotate 180° → rect flips to world (30..50, 40..50).
// Both pre and post positions sit comfortably on the 100×100
// canvas so we can assert concrete pixels on either side.
const s = makeFreshStore()
s.scene.canvas.width = 100
s.scene.canvas.height = 100
s.scene.canvas.background = 'transparent'
s.setDuration(5)
const rect = s.addAsset({
kind: 'rect',
name: 'R',
width: 20,
height: 10,
fill: '#ff0000',
})
const clip = s.addClip('C')
clip.duration = 5
clip.layers = [
{
id: 'clip-layer-r',
name: 'R',
assetId: rect.id,
visible: true,
base: { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
keyframes: [],
offsetX: 0,
offsetY: 0,
},
]
const track = s.scene.clipTracks![0]
const ci = s.addClipInstance(clip.id, 0, track.id)!
s.setClipInstanceTransform(ci.id, { x: 50, y: 50 })
// Baseline: unrotated rect covers world (50..70, 50..60). A
// pixel at (60, 55) must be red; (40, 45) must be transparent.
const unrotated = document.createElement('canvas')
unrotated.width = 100
unrotated.height = 100
const uCtx = unrotated.getContext('2d')!
drawSceneFrame(uCtx, s.scene, 0, {})
const beforeInside = uCtx.getImageData(60, 55, 1, 1).data
const beforeOutside = uCtx.getImageData(40, 45, 1, 1).data
expect(beforeInside[0] > 200).toBe(true)
expect(beforeInside[3] > 200).toBe(true)
expect(beforeOutside[3]).toBe(0)
// Rotate 180°: rect flips around (50, 50) to world (30..50,
// 40..50). The two probe pixels swap roles.
s.setClipInstanceTransform(ci.id, { rotation: 180 })
const rotated = document.createElement('canvas')
rotated.width = 100
rotated.height = 100
const rCtx = rotated.getContext('2d')!
drawSceneFrame(rCtx, s.scene, 0, {})
const afterInside = rCtx.getImageData(40, 45, 1, 1).data
const afterOutside = rCtx.getImageData(60, 55, 1, 1).data
expect(afterInside[0] > 200).toBe(true)
expect(afterInside[3] > 200).toBe(true)
expect(afterOutside[3]).toBe(0)
})
test('drawSceneFrame translates + scales the clip-instance group on the canvas', () => {
// Seeds a 100×100 scene with a tiny red rect clip; places one
// instance at the top-left (0,0) with 2× scale so the 10×10
// rect renders as a 20×20 block. Reads pixels to confirm the
// transform actually landed on the canvas-path renderer
// (mask-preview, thumbnails, etc. all go through drawSceneFrame).
const s = makeFreshStore()
s.scene.canvas.width = 100
s.scene.canvas.height = 100
s.scene.canvas.background = 'transparent'
s.setDuration(5)
const rect = s.addAsset({
kind: 'rect',
name: 'R',
width: 10,
height: 10,
fill: '#ff0000',
})
const clip = s.addClip('C')
clip.duration = 5
// Build the clip's sole layer directly — addLayer would target
// the scene, not the clip library.
clip.layers = [
{
id: 'clip-layer-1',
name: 'RL',
assetId: rect.id,
visible: true,
base: {
x: 5,
y: 5,
scaleX: 1,
scaleY: 1,
rotation: 0,
opacity: 1,
},
keyframes: [],
offsetX: 5,
offsetY: 5,
},
]
const track = s.scene.clipTracks![0]
const ci = s.addClipInstance(clip.id, 0, track.id)!
// Transform: nudge to (0,0) origin for the top-left and scale 2×.
s.setClipInstanceTransform(ci.id, { x: 0, y: 0, scaleX: 2, scaleY: 2 })
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, s.scene, 0, {})
// Without transform the 10×10 rect at (5,5) covers x=0..10, y=0..10
// (layer's own offset centres it on its base pos). With scale 2×
// it covers 0..20 in both axes. A pixel at (15, 15) should be red.
const insideAfterScale = ctx.getImageData(15, 15, 1, 1).data
expect(insideAfterScale[0] > 200).toBe(true)
expect(insideAfterScale[3] > 200).toBe(true)
// A pixel at (25, 25) should still be blank (outside the
// scaled rect + stage-render still clips to canvas).
const outsideAfterScale = ctx.getImageData(25, 25, 1, 1).data
expect(outsideAfterScale[3]).toBe(0)
})
test('setTrackColor applies to layer and can be cleared with null', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Col', width: 20, height: 20, fill: '#123' })
const l = s.addLayer(a.id)!
s.setTrackColor(l.id, '#ff6b6b')
expect(l.color).toBe('#ff6b6b')
s.setTrackColor(l.id, null)
expect(l.color).toBe(undefined)
})
test('setTrackColor applies to clip track', () => {
const s = makeFreshStore()
s.addClip('X')
const t = s.scene.clipTracks![0]
s.setTrackColor(t.id, '#51cf66')
expect(t.color).toBe('#51cf66')
s.setTrackColor(t.id, null)
expect(t.color).toBe(undefined)
})
test('setTrackLocked toggles layer lock flag', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'L', width: 10, height: 10, fill: '#111' })
const l = s.addLayer(a.id)!
s.setTrackLocked(l.id, true)
expect(l.locked).toBe(true)
s.setTrackLocked(l.id, false)
expect(l.locked).toBe(undefined)
})
test('setTrackLocked toggles clip-track lock flag', () => {
const s = makeFreshStore()
s.addClip('L')
const t = s.scene.clipTracks![0]
s.setTrackLocked(t.id, true)
expect(t.locked).toBe(true)
s.setTrackLocked(t.id, false)
expect(t.locked).toBe(undefined)
})
test('setTrackColor on unknown id is a no-op', () => {
const s = makeFreshStore()
s.setTrackColor('nope', '#ff0000')
// just verify no throw, no layer/clipTrack changed
expect(s.scene.layers).toHaveLength(0)
expect(s.scene.clipTracks ?? []).toHaveLength(0)
})
test('moveClipInstance without clipTrackId keeps the same track (x-only move)', () => {
const s = makeFreshStore()
const c = s.addClip('MoveX')
const t1 = s.scene.clipTracks![0]
const t2 = s.addClipTrack()
const ci = s.addClipInstance(c.id, 0, t1.id)!
// sanity: distinct tracks
expect(t1.id === t2.id).toBe(false)
s.moveClipInstance(ci.id, 5)
expect(ci.startTime).toBe(5)
expect(ci.clipTrackId).toBe(t1.id)
})
test('setClipInstancePosition pushes neighbours forward when the moved clip collides on the right', () => {
const s = makeFreshStore()
const c = s.addClip('P')
c.duration = 2
const t = s.scene.clipTracks![0]
const a = s.addClipInstance(c.id, 0, t.id)! // 0..2
const b = s.addClipInstance(c.id, 3, t.id)! // 3..5
const cc = s.addClipInstance(c.id, 6, t.id)! // 6..8
// Move `a` forward onto `b`. Expect `b` to shunt to 2, `cc` stays.
expect(s.setClipInstancePosition(a.id, 2)).toBe(true)
expect(a.startTime).toBe(2)
expect(b.startTime).toBe(4)
expect(cc.startTime).toBe(6)
})
test('setClipInstancePosition pushes neighbours backward when the moved clip collides on the left', () => {
const s = makeFreshStore()
const c = s.addClip('P')
c.duration = 2
const t = s.scene.clipTracks![0]
const a = s.addClipInstance(c.id, 0, t.id)! // 0..2
const b = s.addClipInstance(c.id, 3, t.id)! // 3..5
const cc = s.addClipInstance(c.id, 6, t.id)! // 6..8
// Move `cc` backward onto `b`. Expect `b` to shunt left to 2 (abut
// `a` end) and `a` shifts to 0 (already there).
expect(s.setClipInstancePosition(cc.id, 4)).toBe(true)
expect(cc.startTime).toBe(4)
expect(b.startTime).toBe(2)
expect(a.startTime).toBe(0)
})
test('setClipInstancePosition clamps moved clip forward when backward cascade hits 0', () => {
// Pack the lane: three 2s clips abutting from 0. Drag the right-
// most left to 1 — there's no room for the chain at 0, so the
// moved clip should land at the first fitting slot (4) instead.
const s = makeFreshStore()
const c = s.addClip('Pack')
c.duration = 2
const t = s.scene.clipTracks![0]
const a = s.addClipInstance(c.id, 0, t.id)! // 0..2
const b = s.addClipInstance(c.id, 2, t.id)! // 2..4
const cc = s.addClipInstance(c.id, 4, t.id)! // 4..6
expect(s.setClipInstancePosition(cc.id, 1)).toBe(true)
expect(cc.startTime).toBe(4)
expect(a.startTime).toBe(0)
expect(b.startTime).toBe(2)
})
test('setClipInstancePosition returns false for unknown instance or unknown track', () => {
const s = makeFreshStore()
expect(s.setClipInstancePosition('no-such-instance', 0)).toBe(false)
const c = s.addClip('X')
const t = s.scene.clipTracks![0]
const ci = s.addClipInstance(c.id, 0, t.id)!
expect(s.setClipInstancePosition(ci.id, 0, 'ghost-track')).toBe(false)
})
test('duplicateClipInstance clones onto the same track, offset by visible duration', () => {
const s = makeFreshStore()
const c = s.addClip('D')
c.duration = 3
const t = s.scene.clipTracks![0]
const ci = s.addClipInstance(c.id, 1, t.id)!
s.setClipInstanceTransform(ci.id, { x: 42, scaleX: 2 })
const newId = s.duplicateClipInstance(ci.id)!
expect(typeof newId).toBe('string')
expect(newId === ci.id).toBe(false)
const copy = s.activeScene.clipInstances.find((x) => x.id === newId)!
expect(copy.clipId).toBe(c.id)
expect(copy.clipTrackId).toBe(t.id)
expect(copy.startTime).toBe(1 + 3) // original.startTime + visible length
expect(copy.x).toBe(42)
expect(copy.scaleX).toBe(2)
})
test('duplicateClipInstance honours trim when computing the offset', () => {
const s = makeFreshStore()
const c = s.addClip('D')
c.duration = 4
const t = s.scene.clipTracks![0]
const ci = s.addClipInstance(c.id, 0, t.id)!
s.trimClipInstance(ci.id, { trimStart: 0.5, trimEnd: 0.5 })
const newId = s.duplicateClipInstance(ci.id)!
const copy = s.activeScene.clipInstances.find((x) => x.id === newId)!
// visible length = 4 - 0.5 - 0.5 = 3 → copy lands at 3
expect(copy.startTime).toBe(3)
expect(copy.trimStart).toBe(0.5)
expect(copy.trimEnd).toBe(0.5)
})
test('duplicateClipInstance returns null for unknown id', () => {
const s = makeFreshStore()
expect(s.duplicateClipInstance('nope')).toBe(null)
})
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('addTrackGroup with an anchor inserts directly above that track', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#111' })
const l1 = s.addLayer(a.id)! // trackOrder ends up [l2, l1] via unshift
const l2 = s.addLayer(a.id)!
// Anchor on the second layer → group lands at its index, shoving it down.
const g = s.addTrackGroup('G', l1.id)
const order = s.scene.trackOrder ?? []
const gIdx = order.indexOf(g.id)
const l1Idx = order.indexOf(l1.id)
expect(gIdx).toBe(l1Idx - 1)
void l2
})
test('addTrackGroup with an anchor inside an existing group lands above the parent group', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#111' })
const l = s.addLayer(a.id)!
const parent = s.addTrackGroup('Parent')
s.moveTrackIntoGroup(l.id, parent.id)
// Anchor is nested inside `parent` — new group must sit above `parent`,
// not attempt to nest itself inside another group.
const fresh = s.addTrackGroup('Fresh', l.id)
const order = s.scene.trackOrder ?? []
expect(order.indexOf(fresh.id)).toBe(order.indexOf(parent.id) - 1)
})
test('addTrackGroup with an unknown anchor falls back to the top', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#111' })
s.addLayer(a.id)!
const g = s.addTrackGroup('G', 'ghost-id')
expect(s.scene.trackOrder![0]).toBe(g.id)
})
test('moveTrackIntoGroup moves track from top-level to group childIds', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'G2', width: 10, height: 10, fill: '#222' })
const l = s.addLayer(a.id)!
const g = s.addTrackGroup('G')
s.moveTrackIntoGroup(l.id, g.id)
expect(g.childIds).toContain(l.id)
expect((s.scene.trackOrder ?? []).includes(l.id)).toBe(false)
})
test('moveTrackOutOfGroup promotes track back to top-level', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'G3', width: 10, height: 10, fill: '#333' })
const l = s.addLayer(a.id)!
const g = s.addTrackGroup('G')
s.moveTrackIntoGroup(l.id, g.id)
s.moveTrackOutOfGroup(l.id)
expect(g.childIds.includes(l.id)).toBe(false)
expect(s.scene.trackOrder).toContain(l.id)
})
test('removeTrackGroup promotes children back to top-level', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'G4', width: 10, height: 10, fill: '#444' })
const l = s.addLayer(a.id)!
const g = s.addTrackGroup('G')
s.moveTrackIntoGroup(l.id, g.id)
s.removeTrackGroup(g.id)
expect(s.scene.groups).toHaveLength(0)
expect(s.scene.trackOrder).toContain(l.id)
})
test('toggleGroupCollapsed flips the collapsed flag', () => {
const s = makeFreshStore()
const g = s.addTrackGroup('G')
expect(!!g.collapsed).toBe(false)
s.toggleGroupCollapsed(g.id)
expect(g.collapsed).toBe(true)
s.toggleGroupCollapsed(g.id)
expect(g.collapsed).toBe(false)
})
test('renameGroup updates the name', () => {
const s = makeFreshStore()
const g = s.addTrackGroup('Old')
s.renameGroup(g.id, 'New')
expect(g.name).toBe('New')
})
test('a track can only be in one group at a time', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#555' })
const l = s.addLayer(a.id)!
const g1 = s.addTrackGroup('A')
const g2 = s.addTrackGroup('B')
s.moveTrackIntoGroup(l.id, g1.id)
s.moveTrackIntoGroup(l.id, g2.id)
expect(g1.childIds.includes(l.id)).toBe(false)
expect(g2.childIds).toContain(l.id)
})
test('expandedTrackOrder expands group IDs to their childIds inline', () => {
const s = makeFreshStore()
const a1 = s.addAsset({ kind: 'rect', name: 'A', width: 10, height: 10, fill: '#abc' })
const a2 = s.addAsset({ kind: 'rect', name: 'B', width: 10, height: 10, fill: '#def' })
const l1 = s.addLayer(a1.id)!
const l2 = s.addLayer(a2.id)!
const g = s.addTrackGroup('G')
s.moveTrackIntoGroup(l1.id, g.id)
// scene.trackOrder: [group, l2] (l1 is inside group)
// expanded: [l1, l2]
const expanded = expandedTrackOrder(s.scene)
expect(expanded).toEqual([l1.id, l2.id])
})
test('moveTrackIntoGroup respects atIndex for insertion', () => {
const s = makeFreshStore()
const a1 = s.addAsset({ kind: 'rect', name: 'A', width: 10, height: 10, fill: '#111' })
const a2 = s.addAsset({ kind: 'rect', name: 'B', width: 10, height: 10, fill: '#222' })
const a3 = s.addAsset({ kind: 'rect', name: 'C', width: 10, height: 10, fill: '#333' })
const l1 = s.addLayer(a1.id)!
const l2 = s.addLayer(a2.id)!
const l3 = s.addLayer(a3.id)!
const g = s.addTrackGroup('G')
s.moveTrackIntoGroup(l1.id, g.id) // [l1]
s.moveTrackIntoGroup(l2.id, g.id) // [l1, l2]
s.moveTrackIntoGroup(l3.id, g.id, 1) // [l1, l3, l2]
expect(g.childIds).toEqual([l1.id, l3.id, l2.id])
})
test('removeTrackGroup inserts children at the former group position', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#aaa' })
const top1 = s.addLayer(a.id)!
const top2 = s.addLayer(a.id)!
const childA = s.addLayer(a.id)!
const childB = s.addLayer(a.id)!
const g = s.addTrackGroup('G')
s.moveTrackIntoGroup(childA.id, g.id)
s.moveTrackIntoGroup(childB.id, g.id)
// trackOrder currently: [childB? no, children are removed from top-level]
// trackOrder contains: [g, top2, top1] (newest addLayer unshifts)
// Wait: addLayer unshifts; the creation order is top2(later), then top1(earlier)
// Then addLayer(childA), childB — all unshifted. Then moved into group.
// Let's just position group explicitly to be sure:
const order = s.scene.trackOrder ?? []
const gIdx = order.indexOf(g.id)
expect(gIdx).toBeGreaterThan(-1)
s.removeTrackGroup(g.id)
const newOrder = s.scene.trackOrder ?? []
// children should now sit at the former group position
expect(newOrder[gIdx]).toBe(childA.id)
expect(newOrder[gIdx + 1]).toBe(childB.id)
void top1
void top2
})
test('renameGroup ignores empty / whitespace names', () => {
const s = makeFreshStore()
const g = s.addTrackGroup('Original')
s.renameGroup(g.id, '')
expect(g.name).toBe('Original')
s.renameGroup(g.id, ' ')
expect(g.name).toBe('Original')
s.renameGroup(g.id, ' Renamed ')
expect(g.name).toBe('Renamed')
})
test('removeTrackGroup on unknown id is a no-op', () => {
const s = makeFreshStore()
const g = s.addTrackGroup('G')
const beforeCount = (s.scene.groups ?? []).length
s.removeTrackGroup('does-not-exist')
expect((s.scene.groups ?? []).length).toBe(beforeCount)
// The real group still exists
expect(s.scene.groups?.find((x) => x.id === g.id)).toBeTruthy()
})
test('setTrackColor applies to groups too', () => {
const s = makeFreshStore()
const g = s.addTrackGroup('Col')
s.setTrackColor(g.id, '#ff0000')
expect(g.color).toBe('#ff0000')
s.setTrackColor(g.id, null)
expect(g.color).toBe(undefined)
})
test('moveTrackIntoGroup with same group twice moves within group', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'M', width: 10, height: 10, fill: '#555' })
const l1 = s.addLayer(a.id)!
const l2 = s.addLayer(a.id)!
const g = s.addTrackGroup('G')
s.moveTrackIntoGroup(l1.id, g.id)
s.moveTrackIntoGroup(l2.id, g.id)
// l1 currently at index 0. Move l1 to index 1.
s.moveTrackIntoGroup(l1.id, g.id, 1)
expect(g.childIds).toEqual([l2.id, l1.id])
})
test('group color cascades to non-overridden children', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
const l1 = s.addLayer(a.id)!
const l2 = s.addLayer(a.id)!
const g = s.addTrackGroup('G')
s.moveTrackIntoGroup(l1.id, g.id)
s.moveTrackIntoGroup(l2.id, g.id)
s.setTrackColor(g.id, '#ff0000')
expect(l1.color).toBe('#ff0000')
expect(l2.color).toBe('#ff0000')
// The children inherited — they must NOT be marked as overridden.
expect(l1.colorOverridden).toBe(undefined)
expect(l2.colorOverridden).toBe(undefined)
})
test('explicit per-track color pins against later group-color changes', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
const l1 = s.addLayer(a.id)!
const l2 = s.addLayer(a.id)!
const g = s.addTrackGroup('G')
s.moveTrackIntoGroup(l1.id, g.id)
s.moveTrackIntoGroup(l2.id, g.id)
s.setTrackColor(g.id, '#ff0000')
// User recolours one child AFTER the group — it pins.
s.setTrackColor(l1.id, '#00ff00')
expect(l1.colorOverridden).toBe(true)
// Group colour changes again — pinned child ignores it; other follows.
s.setTrackColor(g.id, '#0000ff')
expect(l1.color).toBe('#00ff00')
expect(l2.color).toBe('#0000ff')
})
test('clearing a child-color (null) re-inherits the group color', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
const g = s.addTrackGroup('G')
s.moveTrackIntoGroup(l.id, g.id)
s.setTrackColor(g.id, '#ff0000')
s.setTrackColor(l.id, '#00ff00')
expect(l.color).toBe('#00ff00')
s.setTrackColor(l.id, null)
// Now inheriting again.
expect(l.color).toBe('#ff0000')
expect(l.colorOverridden).toBe(undefined)
})
test('clearing a group color clears cascade on non-overridden children', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
const l1 = s.addLayer(a.id)!
const l2 = s.addLayer(a.id)!
const g = s.addTrackGroup('G')
s.moveTrackIntoGroup(l1.id, g.id)
s.moveTrackIntoGroup(l2.id, g.id)
s.setTrackColor(g.id, '#ff0000')
s.setTrackColor(l2.id, '#00ff00')
s.setTrackColor(g.id, null)
expect(l1.color).toBe(undefined)
// l2 was pinned, keeps its explicit colour.
expect(l2.color).toBe('#00ff00')
})
test('moving a track into a coloured group inherits its color', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
const g = s.addTrackGroup('G')
s.setTrackColor(g.id, '#ff0000')
s.moveTrackIntoGroup(l.id, g.id)
expect(l.color).toBe('#ff0000')
expect(l.colorOverridden).toBe(undefined)
})
test('moving an overridden track into a coloured group keeps its color', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.setTrackColor(l.id, '#00ff00')
const g = s.addTrackGroup('G')
s.setTrackColor(g.id, '#ff0000')
s.moveTrackIntoGroup(l.id, g.id)
expect(l.color).toBe('#00ff00')
})
test('loading an older scene treats pre-existing track colors as overridden', () => {
// Migration: scenes saved before colorOverridden shipped must
// preserve their per-track colour choices against future group
// cascades. normalizeScene stamps colorOverridden=true on any
// track that arrives with a colour and no flag.
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
const snap = JSON.parse(JSON.stringify(s.scene))
snap.layers[0].color = '#123456'
// legacy: no colorOverridden field on the layer
s.loadScene(snap)
const loaded = s.scene.layers.find((x) => x.id === l.id)!
expect(loaded.colorOverridden).toBe(true)
})
test('normalizeScene (via loadScene) removes orphan childIds and deduplicates', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'N', width: 10, height: 10, fill: '#666' })
const l = s.addLayer(a.id)!
// Snapshot with a broken state
const snap = JSON.parse(JSON.stringify(s.scene))
snap.groups = [
{ id: 'g1', name: 'G1', childIds: [l.id, 'ghost-layer', l.id] },
{ id: 'g2', name: 'G2', childIds: [l.id] },
]
snap.trackOrder = [l.id, 'g1', 'g2']
s.loadScene(snap)
// Orphan 'ghost-layer' filtered out
const g1 = s.scene.groups?.find((g) => g.id === 'g1')!
const g2 = s.scene.groups?.find((g) => g.id === 'g2')!
expect(g1.childIds.includes('ghost-layer')).toBe(false)
// Dedupe: l.id appears only once across all groups
const inG1 = g1.childIds.filter((id) => id === l.id).length
const inG2 = g2.childIds.filter((id) => id === l.id).length
expect(inG1 + inG2).toBe(1)
})
test('moveTrackIntoGroup accepts script tracks as children', () => {
const s = makeFreshStore()
const st = s.addScriptTrack('Logic')
const g = s.addTrackGroup('G')
s.moveTrackIntoGroup(st.id, g.id)
expect(g.childIds).toContain(st.id)
expect((s.scene.trackOrder ?? []).includes(st.id)).toBe(false)
})
test('moveTrackIntoGroup accepts audio tracks as children', () => {
const s = makeFreshStore()
const at = s.addAudioTrack('FX')
const g = s.addTrackGroup('G')
s.moveTrackIntoGroup(at.id, g.id)
expect(g.childIds).toContain(at.id)
expect((s.scene.trackOrder ?? []).includes(at.id)).toBe(false)
})
test('group color cascades to script + audio children alongside layers', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Mix', width: 10, height: 10, fill: '#000' })
const l = s.addLayer(a.id)!
const st = s.addScriptTrack('Script')
const at = s.addAudioTrack('Audio')
const g = s.addTrackGroup('G')
s.setTrackColor(g.id, '#ff8800')
s.moveTrackIntoGroup(l.id, g.id)
s.moveTrackIntoGroup(st.id, g.id)
s.moveTrackIntoGroup(at.id, g.id)
expect(l.color).toBe('#ff8800')
expect(st.color).toBe('#ff8800')
expect(at.color).toBe('#ff8800')
// None of them were explicitly recoloured — all inherit, none pin.
expect(l.colorOverridden).toBe(undefined)
expect(st.colorOverridden).toBe(undefined)
expect(at.colorOverridden).toBe(undefined)
})
test('normalizeScene preserves script + audio tracks as group children', () => {
// Legacy loads used to strip anything other than layers / clip
// tracks from childIds. All four non-group kinds must survive
// the roundtrip now.
const s = makeFreshStore()
const st = s.addScriptTrack('S')
const at = s.addAudioTrack('A')
const g = s.addTrackGroup('G')
const snap = JSON.parse(JSON.stringify(s.scene))
snap.groups = [{ id: g.id, name: 'G', childIds: [st.id, at.id] }]
snap.trackOrder = [g.id]
s.loadScene(snap)
const loaded = s.scene.groups?.find((x) => x.id === g.id)!
expect(loaded.childIds).toContain(st.id)
expect(loaded.childIds).toContain(at.id)
expect((s.scene.trackOrder ?? []).includes(st.id)).toBe(false)
expect((s.scene.trackOrder ?? []).includes(at.id)).toBe(false)
})
})
suite('scene store — duplicateTrack', () => {
test('duplicates a layer with fresh keyframe ids right below the original', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'D1', width: 10, height: 10, fill: '#abc' })
const l = s.addLayer(a.id, 'Orig')!
s.upsertKeyframe(l.id, 1, { x: 10 })
s.upsertKeyframe(l.id, 2, { x: 20 })
const origKfIds = new Set(l.keyframes.map((k) => k.id))
const newId = s.duplicateTrack(l.id)
expect(newId).toBeTruthy()
const dup = s.scene.layers.find((x) => x.id === newId)!
expect(dup).toBeTruthy()
expect(dup.id === l.id).toBe(false)
expect(dup.name).toBe('Orig')
expect(dup.assetId).toBe(a.id)
expect(dup.keyframes.length).toBe(l.keyframes.length)
// Fresh ids for every keyframe so history/undo never aliases.
for (const k of dup.keyframes) expect(origKfIds.has(k.id)).toBe(false)
// Clone sits immediately after the original in trackOrder.
const order = s.scene.trackOrder!
expect(order.indexOf(newId!)).toBe(order.indexOf(l.id) + 1)
})
test('layer duplicate is deeply independent of the original', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'D2', width: 10, height: 10, fill: '#bcd' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 1, { x: 10 })
const newId = s.duplicateTrack(l.id)!
const dup = s.scene.layers.find((x) => x.id === newId)!
// Mutate the clone — the original must not see the change.
dup.base.x = 999
dup.keyframes[0].props.x = 999
const origAfter = s.scene.layers.find((x) => x.id === l.id)!
expect(origAfter.base.x === 999).toBe(false)
expect(origAfter.keyframes[0].props.x === 999).toBe(false)
})
test('layer duplicate copies separated position/rotation kf arrays with fresh ids', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'D3', width: 10, height: 10, fill: '#cde' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 1, { x: 5, y: 5, rotation: 45 })
s.setLayerSeparated(l.id, true)
const before = s.scene.layers.find((x) => x.id === l.id)!
const origPosIds = new Set((before.positionKeyframes ?? []).map((k) => k.id))
const origRotIds = new Set((before.rotationKeyframes ?? []).map((k) => k.id))
const newId = s.duplicateTrack(l.id)!
const dup = s.scene.layers.find((x) => x.id === newId)!
expect(dup.separated).toBe(true)
expect((dup.positionKeyframes ?? []).length).toBe((before.positionKeyframes ?? []).length)
expect((dup.rotationKeyframes ?? []).length).toBe((before.rotationKeyframes ?? []).length)
for (const k of dup.positionKeyframes ?? []) expect(origPosIds.has(k.id)).toBe(false)
for (const k of dup.rotationKeyframes ?? []) expect(origRotIds.has(k.id)).toBe(false)
})
test('duplicating a grouped layer keeps the clone in the same group', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'D4', width: 10, height: 10, fill: '#def' })
const l = s.addLayer(a.id)!
const g = s.addTrackGroup('G')
s.moveTrackIntoGroup(l.id, g.id)
const newId = s.duplicateTrack(l.id)!
const group = s.scene.groups!.find((x) => x.id === g.id)!
// Duplicate lands in the group, directly after the original.
expect(group.childIds.indexOf(newId)).toBe(group.childIds.indexOf(l.id) + 1)
// Grouped children never leak into trackOrder.
expect((s.scene.trackOrder ?? []).includes(newId)).toBe(false)
})
test('duplicates a clip track along with its clip instances', () => {
const s = makeFreshStore()
const clip = s.addClip('C')
clip.duration = 5
const origTrack = s.addClipTrack('track-A')
const ci1 = s.addClipInstance(clip.id, 0, origTrack.id)!
const ci2 = s.addClipInstance(clip.id, 2, origTrack.id)!
const newId = s.duplicateTrack(origTrack.id)
expect(newId).toBeTruthy()
expect(newId === origTrack.id).toBe(false)
const newTrack = s.scene.clipTracks!.find((t) => t.id === newId)!
expect(newTrack.name).toBe('track-A')
// Original instances untouched.
expect(s.scene.clipInstances!.find((ci) => ci.id === ci1.id)!.clipTrackId).toBe(origTrack.id)
expect(s.scene.clipInstances!.find((ci) => ci.id === ci2.id)!.clipTrackId).toBe(origTrack.id)
// Clone track carries copies with fresh ids and same clipId/startTime.
const cloneInstances = s.scene.clipInstances!.filter((ci) => ci.clipTrackId === newId)
expect(cloneInstances).toHaveLength(2)
const startTimes = cloneInstances.map((ci) => ci.startTime).sort((a, b) => a - b)
expect(startTimes).toEqual([0, 2])
for (const ci of cloneInstances) {
expect(ci.clipId).toBe(clip.id)
expect(ci.id === ci1.id).toBe(false)
expect(ci.id === ci2.id).toBe(false)
}
// Slotted right after the original in trackOrder.
const order = s.scene.trackOrder!
expect(order.indexOf(newId!)).toBe(order.indexOf(origTrack.id) + 1)
})
test('duplicates a script track along with its frames', () => {
const s = makeFreshStore()
s.setDuration(10)
const origTrack = s.addScriptTrack('triggers')
const f1 = s.addScriptFrame(origTrack.id, 1, [{ type: 'pause' }])!
const f2 = s.addScriptFrame(origTrack.id, 3, [{ type: 'play' }])!
const newId = s.duplicateTrack(origTrack.id)!
expect(newId === origTrack.id).toBe(false)
const newTrack = s.scene.scriptTracks!.find((t) => t.id === newId)!
expect(newTrack.name).toBe('triggers')
// Original frames untouched.
expect(s.scene.scriptFrames!.find((f) => f.id === f1.id)!.trackId).toBe(origTrack.id)
expect(s.scene.scriptFrames!.find((f) => f.id === f2.id)!.trackId).toBe(origTrack.id)
// Clone has its own frames at the same times with matching actions.
const cloneFrames = s.scene.scriptFrames!.filter((f) => f.trackId === newId)
expect(cloneFrames).toHaveLength(2)
const times = cloneFrames.map((f) => f.time).sort((a, b) => a - b)
expect(times).toEqual([1, 3])
for (const f of cloneFrames) {
expect(f.id === f1.id).toBe(false)
expect(f.id === f2.id).toBe(false)
}
})
test('returns null for unknown id and for group ids', () => {
const s = makeFreshStore()
expect(s.duplicateTrack('nope-no-such-track')).toBe(null)
const g = s.addTrackGroup('G')
expect(s.duplicateTrack(g.id)).toBe(null)
})
})
suite('app smoke — live DOM', () => {
test('app root, topbar, timeline, stage are mounted', () => {
expect(document.querySelector('.app')).toBeTruthy()
expect(document.querySelector('.topbar')).toBeTruthy()
expect(document.querySelector('.timeline')).toBeTruthy()
expect(document.querySelector('.stage')).toBeTruthy()
})
test('timeline snap toggle button is present', () => {
expect(document.querySelector('.snap-btn')).toBeTruthy()
})
test('topbar has save & recents buttons', () => {
// Tooltips live in `aria-label` now (custom v-tooltip directive
// replaced the native `title` attribute on every interactive
// element).
const saveBtn = document.querySelector('button[aria-label^="Save project"]')
const recentsBtn = document.querySelector('button[aria-label="Recent projects"]')
expect(saveBtn).toBeTruthy()
expect(recentsBtn).toBeTruthy()
})
test('canvas has crop toggle button', () => {
const cropBtn = document.querySelector('.crop-btn')
expect(cropBtn).toBeTruthy()
})
test('brand button opens about dialog', () => {
const brand = document.querySelector('button.brand')
expect(brand).toBeTruthy()
})
test('new-track dropzone is hidden while the clip library is empty', () => {
// Regression: the dropzone used to always render and show "Drop a
// clip here" even when the library had no clips. Timeline now gates
// `.clip-track-dropzone` behind `scene.clips.length > 0`. The live
// smoke scene starts blank, so the dropzone must be absent.
const dz = document.querySelector('.clip-track-dropzone')
expect(dz).toBeNull()
})
test('timeline has add-group button', () => {
const btn = document.querySelector('button[aria-label="Create a new track group"]')
expect(btn).toBeTruthy()
})
})
suite('timeline — backspace deletes selected layer', () => {
// Regression: Backspace / Delete used to only remove selected
// keyframes and markers. The selected layer track had no shortcut
// — the user had to use the context menu. Timeline's document-level
// keydown now also deletes the currently-selected layer, skipping
// locked layers (direct or via a locked parent group).
async function clearTimelineSelections() {
// The timeline's marker selection lives in a component-local ref
// that the store reset doesn't touch. Two Escape presses clear
// keyframes first, then markers — matches the handler's priority.
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
await nextTick()
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
await nextTick()
}
test('Backspace removes the selected layer and clears the selection', async () => {
const s = makeFreshStore()
await clearTimelineSelections()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.selectLayer(l.id)
expect(s.scene.layers.some((x) => x.id === l.id)).toBe(true)
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace' }))
await nextTick()
expect(s.scene.layers.some((x) => x.id === l.id)).toBe(false)
expect(s.selectedLayerId).toBe(null)
})
test('Delete also removes the selected layer', async () => {
const s = makeFreshStore()
await clearTimelineSelections()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.selectLayer(l.id)
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete' }))
await nextTick()
expect(s.scene.layers.some((x) => x.id === l.id)).toBe(false)
})
test('Backspace is a no-op while the layer itself is locked', async () => {
const s = makeFreshStore()
await clearTimelineSelections()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.selectLayer(l.id)
s.setTrackLocked(l.id, true)
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace' }))
await nextTick()
expect(s.scene.layers.some((x) => x.id === l.id)).toBe(true)
})
test('Backspace is a no-op while the parent group is locked', async () => {
const s = makeFreshStore()
await clearTimelineSelections()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
const g = s.addTrackGroup('G')
s.moveTrackIntoGroup(l.id, g.id)
s.setTrackLocked(g.id, true)
s.selectLayer(l.id)
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace' }))
await nextTick()
expect(s.scene.layers.some((x) => x.id === l.id)).toBe(true)
})
test('Backspace while typing in an input does not touch the selected layer', async () => {
const s = makeFreshStore()
await clearTimelineSelections()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.selectLayer(l.id)
// Mount an input, focus it, dispatch Backspace FROM the input.
const input = document.createElement('input')
input.setAttribute('data-test-input', '')
document.body.appendChild(input)
input.focus()
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true }))
await nextTick()
expect(s.scene.layers.some((x) => x.id === l.id)).toBe(true)
input.remove()
})
test('Backspace with no layer selected is a no-op', async () => {
const s = makeFreshStore()
await clearTimelineSelections()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
// Explicitly deselect
s.selectLayer(null)
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace' }))
await nextTick()
expect(s.scene.layers.some((x) => x.id === l.id)).toBe(true)
})
})
suite('version — app metadata', () => {
test('APP_VERSION is a semver-like string', () => {
expect(typeof APP_VERSION === 'string').toBe(true)
expect(/^\d+\.\d+\.\d+/.test(APP_VERSION)).toBe(true)
})
test('APP_NAME is non-empty', () => {
expect(APP_NAME.length).toBeGreaterThan(0)
})
})
suite('kf selection — shared composable', () => {
test('setting selection reflects across handles', () => {
const { selectedKfs } = useKfSelection()
const snapshot = selectedKfs.value
try {
selectedKfs.value = [
{ layerId: 'L1', kind: 'main', keyframeId: 'K1' },
{ layerId: 'L2', kind: 'position', keyframeId: 'K2' },
]
const second = useKfSelection()
expect(second.selectedKfs.value).toHaveLength(2)
expect(second.selectedKfSet.value.has('L1:main:K1')).toBe(true)
expect(second.selectedKfSet.value.has('L2:position:K2')).toBe(true)
} finally {
selectedKfs.value = snapshot
}
})
test('kfSelectionKey builds stable id', () => {
expect(kfSelectionKey({ layerId: 'a', kind: 'main', keyframeId: 'b' })).toBe('a:main:b')
expect(kfSelectionKey({ layerId: 'x', kind: 'position', keyframeId: 'y' })).toBe(
'x:position:y',
)
})
})
suite('assets — edge blur', () => {
test('rect asset stores blur value', () => {
const s = makeFreshStore()
const a = s.addAsset({
kind: 'rect',
name: 'R',
width: 40,
height: 40,
fill: '#ff0000',
blur: 12,
})
const stored = s.scene.assets.find((x) => x.id === a.id)!
expect(stored.kind).toBe('rect')
expect((stored as Extract<typeof stored, { kind: 'rect' }>).blur).toBe(12)
})
test('circle asset stores blur value', () => {
const s = makeFreshStore()
const a = s.addAsset({
kind: 'circle',
name: 'C',
radius: 30,
fill: '#0000ff',
blur: 6,
})
const stored = s.scene.assets.find((x) => x.id === a.id)!
expect(stored.kind).toBe('circle')
expect((stored as Extract<typeof stored, { kind: 'circle' }>).blur).toBe(6)
})
test('drawSceneFrame sets ctx.filter to blur() for blurred rect', () => {
const s = makeFreshStore()
const a = s.addAsset({
kind: 'rect',
name: 'Blurred',
width: 40,
height: 40,
fill: '#ff4488',
blur: 18,
})
s.addLayer(a.id)!
const { ctx, filterSets } = makeRecordingCtx()
drawSceneFrame(ctx, s.scene, 0, {})
expect(filterSets.some((v) => v === 'blur(18px)')).toBe(true)
})
test('drawSceneFrame does not apply blur when blur is unset or zero', () => {
const s = makeFreshStore()
const a = s.addAsset({
kind: 'rect',
name: 'Sharp',
width: 40,
height: 40,
fill: '#123456',
})
s.addLayer(a.id)!
const { ctx, filterSets } = makeRecordingCtx()
drawSceneFrame(ctx, s.scene, 0, {})
// The only filter writes should be the post-draw reset to 'none'
expect(filterSets.every((v) => v === 'none')).toBe(true)
})
test('drawSceneFrame applies blur for circle asset', () => {
const s = makeFreshStore()
const a = s.addAsset({
kind: 'circle',
name: 'CB',
radius: 20,
fill: '#22aa22',
blur: 9,
})
s.addLayer(a.id)!
const { ctx, filterSets } = makeRecordingCtx()
drawSceneFrame(ctx, s.scene, 0, {})
expect(filterSets.some((v) => v === 'blur(9px)')).toBe(true)
})
})
suite('mask layers — store', () => {
test('setLayerMask(true) enables negative mask by default', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'M', width: 40, height: 40, fill: '#fff' })
const l = s.addLayer(a.id)!
s.setLayerMask(l.id, true)
const layer = s.scene.layers.find((x) => x.id === l.id)!
expect(layer.isMask).toBe(true)
expect(layer.maskMode).toBe('negative')
})
test('setLayerMask(true, "negative") stores negative mode', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'circle', name: 'N', radius: 20, fill: '#fff' })
const l = s.addLayer(a.id)!
s.setLayerMask(l.id, true, 'negative')
const layer = s.scene.layers.find((x) => x.id === l.id)!
expect(layer.maskMode).toBe('negative')
})
test('setLayerMask(false) clears mask flags', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'M', width: 40, height: 40, fill: '#fff' })
const l = s.addLayer(a.id)!
s.setLayerMask(l.id, true, 'negative')
s.setLayerMask(l.id, false)
const layer = s.scene.layers.find((x) => x.id === l.id)!
expect(layer.isMask).toBe(undefined)
expect(layer.maskMode).toBe(undefined)
})
test('setLayerMaskMode flips mode without changing isMask', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'M', width: 40, height: 40, fill: '#fff' })
const l = s.addLayer(a.id)!
s.setLayerMask(l.id, true)
s.setLayerMaskMode(l.id, 'negative')
const layer = s.scene.layers.find((x) => x.id === l.id)!
expect(layer.isMask).toBe(true)
expect(layer.maskMode).toBe('negative')
})
})
suite('mask layers — rendering', () => {
function setupMaskScene(mode: 'positive' | 'negative') {
const s = makeFreshStore()
s.scene.canvas.width = 100
s.scene.canvas.height = 100
s.scene.canvas.background = 'transparent'
// Content: full-canvas red rect
const content = s.addAsset({
kind: 'rect',
name: 'BG',
width: 100,
height: 100,
fill: '#ff0000',
})
const contentLayer = s.addLayer(content.id)!
s.upsertKeyframe(contentLayer.id, 0, { x: 50, y: 50 })
// Mask: small circle centered at (50,50)
const mask = s.addAsset({
kind: 'circle',
name: 'M',
radius: 20,
fill: '#ffffff',
})
const maskLayer = s.addLayer(mask.id)!
s.upsertKeyframe(maskLayer.id, 0, { x: 50, y: 50 })
s.setLayerMask(maskLayer.id, true, mode)
const group = s.addTrackGroup('G')
s.moveTrackIntoGroup(maskLayer.id, group.id)
s.moveTrackIntoGroup(contentLayer.id, group.id)
return { store: s }
}
test('positive mask keeps content inside the mask shape and clears outside', () => {
const { store } = setupMaskScene('positive')
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, store.scene, 0, {})
const inside = ctx.getImageData(50, 50, 1, 1).data
const outside = ctx.getImageData(5, 5, 1, 1).data
// Inside mask: red content visible
expect(inside[0] > 200).toBe(true)
expect(inside[3] > 200).toBe(true)
// Outside mask: cleared (alpha 0)
expect(outside[3]).toBe(0)
})
test('negative mask punches a hole in the content where the mask overlaps', () => {
const { store } = setupMaskScene('negative')
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, store.scene, 0, {})
const inside = ctx.getImageData(50, 50, 1, 1).data
const outside = ctx.getImageData(5, 5, 1, 1).data
// Inside mask: hole (alpha 0)
expect(inside[3]).toBe(0)
// Outside mask: red content still there
expect(outside[0] > 200).toBe(true)
expect(outside[3] > 200).toBe(true)
})
test('layer outside a masked group is not affected by the mask', () => {
const { store } = setupMaskScene('positive')
// Add a green rect layer OUTSIDE the group
const outer = store.addAsset({
kind: 'rect',
name: 'Outer',
width: 20,
height: 20,
fill: '#00ff00',
})
const outerLayer = store.addLayer(outer.id)!
store.upsertKeyframe(outerLayer.id, 0, { x: 90, y: 90 })
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, store.scene, 0, {})
// The outer layer renders fully regardless of mask
const outerPx = ctx.getImageData(90, 90, 1, 1).data
expect(outerPx[1] > 200).toBe(true)
expect(outerPx[3] > 200).toBe(true)
})
})
suite('scene store — shiftKeyframesPosition', () => {
test('shifts x/y of a single keyframe by delta', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Shift', width: 40, height: 40, fill: '#a55' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 1, { x: 100, y: 50 })
const kf = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
s.shiftKeyframesPosition(
[{ layerId: l.id, kind: 'main', keyframeId: kf.id }],
25,
-10,
)
await tick()
expect(kf.props.x).toBe(125)
expect(kf.props.y).toBe(40)
})
test('shifts multiple keyframes across layers by the same delta', async () => {
const s = makeFreshStore()
const a1 = s.addAsset({ kind: 'rect', name: 'A', width: 10, height: 10, fill: '#111' })
const a2 = s.addAsset({ kind: 'rect', name: 'B', width: 10, height: 10, fill: '#222' })
const l1 = s.addLayer(a1.id)!
const l2 = s.addLayer(a2.id)!
s.upsertKeyframe(l1.id, 1, { x: 100, y: 100 })
s.upsertKeyframe(l2.id, 2, { x: 200, y: 300 })
const k1 = l1.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
const k2 = l2.keyframes.find((k) => Math.abs(k.time - 2) < 1e-3)!
s.shiftKeyframesPosition(
[
{ layerId: l1.id, kind: 'main', keyframeId: k1.id },
{ layerId: l2.id, kind: 'main', keyframeId: k2.id },
],
10,
5,
)
await tick()
expect(k1.props.x).toBe(110)
expect(k1.props.y).toBe(105)
expect(k2.props.x).toBe(210)
expect(k2.props.y).toBe(305)
})
test('skips rotation keyframes (no x/y)', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#333' })
const l = s.addLayer(a.id)!
s.setLayerSeparated(l.id, true)
s.upsertKeyframe(l.id, 1, { rotation: 45 })
const rotKf = (l.rotationKeyframes ?? []).find((k) => Math.abs(k.time - 1) < 1e-3)!
s.shiftKeyframesPosition(
[{ layerId: l.id, kind: 'rotation', keyframeId: rotKf.id }],
100,
100,
)
await tick()
expect((rotKf.props as Record<string, unknown>).x).toBe(undefined)
expect((rotKf.props as Record<string, unknown>).y).toBe(undefined)
expect(rotKf.props.rotation).toBe(45)
})
test('uses sampled fallback when keyframe has no explicit x/y', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Fb', width: 10, height: 10, fill: '#444' })
const l = s.addLayer(a.id)!
// initial keyframe at t=0 has full base props (including x=640 from canvas center)
// Create a later keyframe that only sets scaleX
s.upsertKeyframe(l.id, 2, { scaleX: 2 })
const kf = l.keyframes.find((k) => Math.abs(k.time - 2) < 1e-3)!
// ensure x is not explicitly set
delete (kf.props as Record<string, unknown>).x
delete (kf.props as Record<string, unknown>).y
const sampledBefore = sampleLayer(l, 2)
s.shiftKeyframesPosition([{ layerId: l.id, kind: 'main', keyframeId: kf.id }], 7, 11)
await tick()
expect(kf.props.x).toBe(sampledBefore.x + 7)
expect(kf.props.y).toBe(sampledBefore.y + 11)
})
test('non-existing layer/keyframe refs are safely ignored', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#555' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 1, { x: 50 })
// Call with mix of valid and invalid refs — should not throw
s.shiftKeyframesPosition(
[
{ layerId: 'missing-layer', kind: 'main', keyframeId: 'missing-kf' },
{ layerId: l.id, kind: 'main', keyframeId: 'also-missing' },
],
10,
10,
)
// Valid keyframe at t=1 remained unshifted (we didn't target it)
const kf = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
expect(kf.props.x).toBe(50)
})
})
suite('scene store — shiftSceneWorldCoords (resize)', () => {
test('shifts base + keyframe x/y across every scene page', () => {
const s = makeFreshStore()
// Second page so we can check the shift touches pages other
// than the active one too.
s.addScene('Second')
s.switchScene(s.project.scenes[0].id)
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#abc' })
const l1 = s.addLayer(a.id)!
s.upsertKeyframe(l1.id, 0, { x: 100, y: 50 })
s.switchScene(s.project.scenes[1].id)
const l2 = s.addLayer(a.id)!
s.upsertKeyframe(l2.id, 0, { x: 200, y: 80 })
const beforeBase1 = { ...l1.base }
const beforeBase2 = { ...l2.base }
s.shiftSceneWorldCoords(30, -10)
expect(l1.base.x).toBe(beforeBase1.x + 30)
expect(l1.base.y).toBe(beforeBase1.y - 10)
expect(l2.base.x).toBe(beforeBase2.x + 30)
expect(l2.base.y).toBe(beforeBase2.y - 10)
const k1 = l1.keyframes.find((k) => k.time === 0)!
const k2 = l2.keyframes.find((k) => k.time === 0)!
expect(k1.props.x).toBe(130)
expect(k1.props.y).toBe(40)
expect(k2.props.x).toBe(230)
expect(k2.props.y).toBe(70)
})
test('shifts positionKeyframes on separated layers', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#111' })
const l = s.addLayer(a.id)!
s.setLayerSeparated(l.id, true)
s.upsertKeyframe(l.id, 1, { x: 10, y: 20 })
const pk = (l.positionKeyframes ?? []).find((k) => k.time === 1)!
s.shiftSceneWorldCoords(5, 7)
expect(pk.props.x).toBe(15)
expect(pk.props.y).toBe(27)
})
test('shifts keyframe bezier control points (ctrlX / ctrlY)', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#abc' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 1, { x: 100, y: 50 })
const kf = l.keyframes.find((k) => k.time === 1)!
kf.ctrlX = 80
kf.ctrlY = 30
s.shiftSceneWorldCoords(20, 15)
expect(kf.ctrlX).toBe(100)
expect(kf.ctrlY).toBe(45)
})
test('shifts clipInstance x/y but leaves clip-library layer keyframes alone', () => {
const s = makeFreshStore()
const c = s.addClip('Lib')
c.duration = 2
// Clip-library layer: should NOT be shifted (local to clip origin)
c.layers = [
{
id: 'lib-layer',
name: 'L',
assetId: 'none',
visible: true,
base: { x: 10, y: 10, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
keyframes: [
{
id: 'k1',
time: 0,
props: { x: 10, y: 10 },
easing: 'linear',
},
],
},
]
const t = s.scene.clipTracks![0]
const ci = s.addClipInstance(c.id, 0, t.id)!
ci.x = 40
ci.y = 60
s.shiftSceneWorldCoords(7, -3)
// Clip instance's static offset shifted with the world.
expect(ci.x).toBe(47)
expect(ci.y).toBe(57)
// Clip library's inner layer untouched.
expect(c.layers[0].base.x).toBe(10)
expect(c.layers[0].keyframes[0].props.x).toBe(10)
})
test('(0, 0) delta is a no-op', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#abc' })
const l = s.addLayer(a.id)!
const before = { ...l.base }
s.shiftSceneWorldCoords(0, 0)
expect(l.base.x).toBe(before.x)
expect(l.base.y).toBe(before.y)
})
})
suite('clip bounds — stable frame rect', () => {
// The stage wraps each ClipInstance in an invisible frame rect
// sized to the union of every clip layer's footprint across all
// keyframes. Keeps the canvas transformer's handles on the
// clip's own outer constraints instead of chasing a moving child
// shape, and gives empty-playhead frames a clickable hit area.
test('union bbox covers every layer at its extreme keyframes', () => {
const s = makeFreshStore()
const rectAsset = s.addAsset({
kind: 'rect', name: 'R', width: 40, height: 40, fill: '#f00',
})
const clip = s.addClip('CB')
s.enterClip(clip.id)
clip.duration = 5
const l = s.addLayer(rectAsset.id)!
// Move the layer from (0,0) to (200,120) between t=0 and t=clip.duration.
s.upsertKeyframe(l.id, 0, { x: 0, y: 0 })
s.upsertKeyframe(l.id, 4, { x: 200, y: 120 })
s.exitClip()
const assets = new Map(s.scene.assets.map((a) => [a.id, a]))
const bounds = computeClipBounds(clip, assets)
expect(bounds).toBeTruthy()
// The layer is 40×40 centered on its origin (default offset=20,20).
// At t=0 it sits around (0,0); at t=4 it sits around (200,120).
// Union must at least cover both extremes' centers.
expect(bounds!.x).toBeLessThan(-5)
expect(bounds!.y).toBeLessThan(-5)
expect(bounds!.x + bounds!.width).toBeGreaterThan(200 + 5)
expect(bounds!.y + bounds!.height).toBeGreaterThan(120 + 5)
})
test('clip without measurable layers returns null (caller uses fallback)', () => {
const s = makeFreshStore()
const clip = s.addClip('Empty')
const assets = new Map(s.scene.assets.map((a) => [a.id, a]))
expect(computeClipBounds(clip, assets)).toBeNull()
// Fallback is a small centred square so the transformer still
// has something to grab.
expect(CLIP_FALLBACK_BOUNDS.width).toBeGreaterThan(0)
expect(CLIP_FALLBACK_BOUNDS.height).toBeGreaterThan(0)
})
test('bounds scale with per-keyframe scaleX/scaleY', () => {
const s = makeFreshStore()
const rectAsset = s.addAsset({
kind: 'rect', name: 'R', width: 40, height: 40, fill: '#f00',
})
const clip = s.addClip('CB')
s.enterClip(clip.id)
clip.duration = 3
const l = s.addLayer(rectAsset.id)!
// Grow the layer 4× at t=2.
s.upsertKeyframe(l.id, 0, { x: 0, y: 0, scaleX: 1, scaleY: 1 })
s.upsertKeyframe(l.id, 2, { x: 0, y: 0, scaleX: 4, scaleY: 4 })
s.exitClip()
const assets = new Map(s.scene.assets.map((a) => [a.id, a]))
const bounds = computeClipBounds(clip, assets)
expect(bounds).toBeTruthy()
// 4× a 40-wide rect = 160 across → bbox is at least that wide
// (pre-rotation padding pushes it a bit bigger, which is fine).
expect(bounds!.width).toBeGreaterThan(159)
expect(bounds!.height).toBeGreaterThan(159)
})
})
suite('svg split', () => {
// A minimal SVG with two non-overlapping paths. Absolute
// coordinates so getBBox returns predictable values under jsdom/
// Chrome. The split helper should emit two standalone SVG parts.
const sampleSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="100" height="60" viewBox="0 0 100 60">
<rect id="left" x="0" y="0" width="40" height="40" fill="#f00" />
<rect id="right" x="60" y="20" width="40" height="40" fill="#0f0" />
</svg>`
function toDataUrl(svg: string): string {
return 'data:image/svg+xml,' + encodeURIComponent(svg)
}
test('splitSvg returns one part per top-level drawable', async () => {
const { splitSvg } = await import('@/lib/svgSplit')
const out = splitSvg(sampleSvg)
expect(out).toBeTruthy()
expect(out!.parts.length).toBe(2)
expect(out!.parts[0].svgText).toContain('<rect')
expect(out!.parts[0].svgText).toContain('id="left"')
// Each part keeps the ORIGINAL width/height/viewBox so the store
// can reuse the layer's position without any geometry math.
expect(out!.parts[0].svgText).toContain('width="100"')
expect(out!.parts[0].svgText).toContain('viewBox="0 0 100 60"')
expect(out!.parts[0].dataUrl.startsWith('data:image/svg+xml')).toBe(true)
})
test('splitSvg returns null for a single-element SVG (nothing to split)', async () => {
const { splitSvg } = await import('@/lib/svgSplit')
const one = `<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40"><rect width="40" height="40" fill="#333"/></svg>`
expect(splitSvg(one)).toBeNull()
})
test('splitSvg data URLs keep # encoded so hex colours survive (regression)', async () => {
// A raw `#` in the data URL is parsed as a fragment separator
// by every browser, which truncates the SVG at the first colour
// value. The encoder has to leave `#` percent-encoded even
// though it makes the URL a bit longer.
const { splitSvg } = await import('@/lib/svgSplit')
const out = splitSvg(sampleSvg)
expect(out).toBeTruthy()
for (const p of out!.parts) {
// The payload portion (after the comma) must NOT contain a
// literal `#` — otherwise browsers load a truncated SVG.
const payload = p.dataUrl.slice(p.dataUrl.indexOf(',') + 1)
expect(payload.includes('#')).toBe(false)
// It should still encode the hex colour:
expect(payload.includes('%23')).toBe(true)
}
})
test('splitLayerSvg replaces the original layer with one layer per part at the same stage position', () => {
const s = makeFreshStore()
const asset = s.addAsset({
kind: 'image',
name: 'SvgSrc',
src: toDataUrl(sampleSvg),
width: 100,
height: 60,
})
const l = s.addLayer(asset.id)!
const origX = l.base.x
const origY = l.base.y
const result = s.splitLayerSvg(l.id)
expect(result).toBeTruthy()
expect(result!.length).toBe(2)
// Original layer is gone.
expect(s.scene.layers.find((x) => x.id === l.id)).toBe(undefined)
// Both new layers exist and sit on top of the old layer's stage
// position — each part's SVG canvas is the same size as the
// original so no delta is needed.
const [a, b] = result!.map((id) => s.scene.layers.find((x) => x.id === id)!)
expect(a.base.x).toBe(origX)
expect(a.base.y).toBe(origY)
expect(b.base.x).toBe(origX)
expect(b.base.y).toBe(origY)
// New assets carry the full original dimensions.
const assetA = s.scene.assets.find(
(x) => x.id === a.assetId,
) as Extract<typeof s.scene.assets[number], { kind: 'image' }>
expect(assetA.width).toBe(100)
expect(assetA.height).toBe(60)
// New parts replace the original in trackOrder at its original slot.
const order = s.scene.trackOrder ?? []
expect(order.includes(l.id)).toBe(false)
for (const id of result!) expect(order.includes(id)).toBe(true)
})
test('splitLayerSvg clones keyframes verbatim (no x/y drift)', () => {
const s = makeFreshStore()
const asset = s.addAsset({
kind: 'image',
name: 'Anim',
src: toDataUrl(sampleSvg),
width: 100,
height: 60,
})
const l = s.addLayer(asset.id)!
s.upsertKeyframe(l.id, 1, { x: 200, y: 120 })
const result = s.splitLayerSvg(l.id)
expect(result).toBeTruthy()
const first = s.scene.layers.find((x) => x.id === result![0])!
const kf = first.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)
expect(kf).toBeTruthy()
// Same x/y as the source keyframe; no shift math to go wrong.
expect(kf!.props.x).toBe(200)
expect(kf!.props.y).toBe(120)
})
test('splitLayerSvg returns null for a non-SVG image layer', () => {
const s = makeFreshStore()
const asset = s.addAsset({
kind: 'image',
name: 'Raster',
src: 'data:image/png;base64,iVBORw0K==',
width: 40,
height: 40,
})
const l = s.addLayer(asset.id)!
expect(s.splitLayerSvg(l.id)).toBeNull()
})
})
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 handles, active index and items', async () => {
const { useClipartStore } = await import('@/stores/clipart')
const s = useClipartStore()
// simulate a loaded state
s.handles = [{ name: 'mock' } as unknown as FileSystemDirectoryHandle]
s.activeHandleIndex = 0
s.items = []
await s.clearFolder()
expect(s.handles.length).toBe(0)
expect(s.activeHandleIndex).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())
})
test('single-handle auto-drill: activeHandle points at the sole root', async () => {
const { useClipartStore } = await import('@/stores/clipart')
const s = useClipartStore()
await s.clearFolder()
const h = { name: 'only' } as unknown as FileSystemDirectoryHandle
s.handles = [h]
s.activeHandleIndex = null
// Pinia wraps the handle in a reactive proxy, so reference-equal
// fails — compare by the stable `name` field instead.
expect(s.activeHandle?.name).toBe('only')
expect(s.inOverview).toBe(false)
expect(s.canGoBack).toBe(false)
expect(s.folderName).toBe('only')
await s.clearFolder()
})
test('two handles with no active index sits in overview mode', async () => {
const { useClipartStore } = await import('@/stores/clipart')
const s = useClipartStore()
await s.clearFolder()
s.handles = [
{ name: 'a' } as unknown as FileSystemDirectoryHandle,
{ name: 'b' } as unknown as FileSystemDirectoryHandle,
]
s.activeHandleIndex = null
expect(s.inOverview).toBe(true)
expect(s.activeHandle).toBeNull()
expect(s.canGoBack).toBe(false)
expect(s.folderName).toBeNull()
await s.clearFolder()
})
test('enterFolder activates; exitFolder returns to overview when multi-root', async () => {
const { useClipartStore } = await import('@/stores/clipart')
const s = useClipartStore()
await s.clearFolder()
s.handles = [
{ name: 'a' } as unknown as FileSystemDirectoryHandle,
{ name: 'b' } as unknown as FileSystemDirectoryHandle,
]
s.enterFolder(1)
expect(s.activeHandleIndex).toBe(1)
expect(s.canGoBack).toBe(true)
s.exitFolder()
expect(s.activeHandleIndex).toBeNull()
expect(s.inOverview).toBe(true)
await s.clearFolder()
})
test('exitFolder is a no-op when only one root is configured', async () => {
const { useClipartStore } = await import('@/stores/clipart')
const s = useClipartStore()
await s.clearFolder()
s.handles = [{ name: 'only' } as unknown as FileSystemDirectoryHandle]
s.activeHandleIndex = 0
s.exitFolder()
// Single-folder installs always auto-drill — back has no meaning.
expect(s.activeHandleIndex).toBe(0)
expect(s.canGoBack).toBe(false)
await s.clearFolder()
})
test('removeFolder shifts the active index and compacts handles', async () => {
const { useClipartStore } = await import('@/stores/clipart')
const s = useClipartStore()
await s.clearFolder()
s.handles = [
{ name: 'a' } as unknown as FileSystemDirectoryHandle,
{ name: 'b' } as unknown as FileSystemDirectoryHandle,
{ name: 'c' } as unknown as FileSystemDirectoryHandle,
]
s.activeHandleIndex = 2
try {
await s.removeFolder(0) // a removed; active 2 → 1
expect(s.handles.map((h) => h.name)).toEqual(['b', 'c'])
expect(s.activeHandleIndex).toBe(1)
// Removing the active handle drops to overview.
await s.removeFolder(1)
expect(s.handles.map((h) => h.name)).toEqual(['b'])
expect(s.activeHandleIndex).toBeNull()
} finally {
// Ensure state is wiped even if assertions throw — otherwise
// the dangling `error`/`handles` leak into the next clipart test
// and the drawer renders the error branch instead of the grid.
await s.clearFolder()
}
})
test('saveClipartFolders + loadStoredClipartFolders round-trip (array format)', async () => {
// Smoke test for the new IDB schema. The picker returns a
// DirectoryHandle; we fake one with any object since we only
// care about serialisation behaviour, not the permission API.
const { saveClipartFolders, loadStoredClipartFolders } = await import(
'@/lib/clipartLibrary'
)
const fake = { name: 'A' } as unknown as FileSystemDirectoryHandle
await saveClipartFolders([fake])
const loaded = await loadStoredClipartFolders()
expect(loaded.length).toBe(1)
expect(loaded[0].name).toBe('A')
await saveClipartFolders([])
})
})
suite('interface prefs — shared composables', () => {
// Regression: the tooltip + asset-thumbs prefs are shared module-
// level refs so the Settings dialog and the sidebar/timeline
// toolbars stay in lock-step. The same refs also have to persist
// to localStorage. The thumbs pref was originally an inline ref
// inside AssetSidebar.vue — the settings tab couldn't change it
// without this extraction.
test('useTooltipPref persists to localStorage and shares the ref', async () => {
const { useTooltipPref } = await import('@/composables/useTooltipPref')
const a = useTooltipPref()
const b = useTooltipPref()
expect(a.enabled).toBe(b.enabled) // same ref instance
const initial = a.enabled.value
a.enabled.value = !initial
await nextTick()
expect(localStorage.getItem('ruwio:tooltipsEnabled')).toBe(initial ? '0' : '1')
// Both consumers see the flip because the ref is shared.
expect(b.enabled.value).toBe(!initial)
// Restore so other tests don't inherit a toggled state.
a.enabled.value = initial
await nextTick()
})
test('useAssetThumbsPref persists to localStorage and shares the ref', async () => {
const { useAssetThumbsPref } = await import('@/composables/useAssetThumbsPref')
const a = useAssetThumbsPref()
const b = useAssetThumbsPref()
expect(a.enabled).toBe(b.enabled)
const initial = a.enabled.value
a.enabled.value = !initial
await nextTick()
expect(localStorage.getItem('animatr.assetThumbs')).toBe(String(!initial))
expect(b.enabled.value).toBe(!initial)
a.enabled.value = initial
await nextTick()
})
})
suite('clipart drawer — grid pre-layout', () => {
// Regression: the drawer's `.drawer-panel` used to be `v-if`-gated on
// clipart.open and given `flex: 1; min-width: 0` — which meant the
// grid mounted at the same moment the drawer started its width
// transition, so the `repeat(auto-fill, minmax(88px, 1fr))` grid
// reflowed continuously from ~1 column at 5px → 4 columns at 424px
// and the thumbs "tumbled" into place. The panel is now always
// mounted at a rigid 400px, and `.clipart-drawer { overflow: hidden }`
// just clips the slide.
test('.drawer-panel is in the DOM when the drawer is closed', async () => {
const { useClipartStore } = await import('@/stores/clipart')
const s = useClipartStore()
s.setOpen(false)
const handle = mountComponent(ClipartDrawer, {})
await nextTick()
const panel = handle.host.querySelector('.drawer-panel')
expect(panel).toBeTruthy()
handle.unmount()
})
test('.drawer-panel is in the DOM when the drawer is open', async () => {
const { useClipartStore } = await import('@/stores/clipart')
const s = useClipartStore()
s.setOpen(true)
const handle = mountComponent(ClipartDrawer, {})
await nextTick()
const panel = handle.host.querySelector('.drawer-panel')
expect(panel).toBeTruthy()
handle.unmount()
})
test('.drawer-panel stays mounted across open → closed toggle', async () => {
const { useClipartStore } = await import('@/stores/clipart')
const s = useClipartStore()
s.setOpen(true)
const handle = mountComponent(ClipartDrawer, {})
await nextTick()
expect(handle.host.querySelector('.drawer-panel')).toBeTruthy()
s.setOpen(false)
await nextTick()
expect(handle.host.querySelector('.drawer-panel')).toBeTruthy()
handle.unmount()
})
test('.drawer-panel has a rigid 400px width regardless of the drawer state', async () => {
const { useClipartStore } = await import('@/stores/clipart')
const s = useClipartStore()
s.setOpen(false)
const handle = mountComponent(ClipartDrawer, {})
await nextTick()
const panel = handle.host.querySelector('.drawer-panel') as HTMLElement
expect(panel).toBeTruthy()
const widthClosed = getComputedStyle(panel).width
s.setOpen(true)
await nextTick()
const widthOpen = getComputedStyle(panel).width
expect(widthClosed).toBe('400px')
expect(widthOpen).toBe('400px')
handle.unmount()
})
test('.clipart-drawer clips its contents (overflow: hidden)', async () => {
const { useClipartStore } = await import('@/stores/clipart')
const s = useClipartStore()
s.setOpen(false)
const handle = mountComponent(ClipartDrawer, {})
await nextTick()
const drawer = handle.host.querySelector('.clipart-drawer') as HTMLElement
expect(drawer).toBeTruthy()
const overflowX = getComputedStyle(drawer).overflowX
const overflowY = getComputedStyle(drawer).overflowY
// `overflow: hidden` applies to both axes. We check each because
// browsers normalise the shorthand into the long-hand properties.
expect(overflowX).toBe('hidden')
expect(overflowY).toBe('hidden')
handle.unmount()
})
test('empty state shows a centred + button that calls addFolder', async () => {
const { useClipartStore } = await import('@/stores/clipart')
const s = useClipartStore()
s.setOpen(true)
s.supported = true
// No handles → empty-state with the big centred `+` action.
s.handles = []
s.activeHandleIndex = null
s.items = []
const calls: number[] = []
const orig = s.addFolder
s.addFolder = async () => {
calls.push(1)
}
try {
const handle = mountComponent(ClipartDrawer, {})
await nextTick()
const btn = handle.host.querySelector('.empty-add-btn') as HTMLButtonElement | null
expect(btn).toBeTruthy()
btn!.click()
expect(calls.length).toBe(1)
handle.unmount()
} finally {
s.addFolder = orig
s.setOpen(false)
}
})
test('audio clipart items render as .audio-tile with the music-note icon, not an img', async () => {
const { useClipartStore } = await import('@/stores/clipart')
const s = useClipartStore()
// Wipe first so prior tests' dangling error / loading / handles
// can't match the drawer's earlier `v-else-if` branches.
s.error = null
s.loading = false
s.setOpen(true)
// Tile grid is gated on an active folder — seed a stub handle so
// the template skips past the empty-state branch.
s.supported = true
s.handles = [{ name: 'stub' } as unknown as FileSystemDirectoryHandle]
s.activeHandleIndex = 0
const mockFile = new File(['x'], 'x.mp3', { type: 'audio/mpeg' })
s.items = [
{ kind: 'audio', name: 'ding.mp3', file: mockFile, url: 'blob:dummy-audio' },
{ kind: 'image', name: 'img.png', file: mockFile, url: 'blob:dummy-img' },
]
const handle = mountComponent(ClipartDrawer, {})
await nextTick()
const tiles = handle.host.querySelectorAll('.tile')
expect(tiles.length).toBe(2)
const audioTile = handle.host.querySelector('.tile.audio-tile')
expect(audioTile).toBeTruthy()
// The audio tile uses an icon wrapper instead of an <img>.
expect(audioTile!.querySelector('.audio-icon')).toBeTruthy()
expect(audioTile!.querySelector('img')).toBeNull()
// The label still carries the filename.
const label = audioTile!.querySelector('.tile-label')
expect(label?.textContent?.trim()).toBe('ding.mp3')
// Clean up the stub state so later tests get a fresh store —
// especially `open`: without closing the drawer, the main app's
// ClipartDrawer stays visually expanded underneath subsequent
// mountComponent calls, which can lead to confusing stale UI.
s.items = []
s.handles = []
s.activeHandleIndex = null
s.setOpen(false)
handle.unmount()
})
test('overview mode renders folder tiles; clicking one enters that root', async () => {
const { useClipartStore } = await import('@/stores/clipart')
const s = useClipartStore()
s.error = null
s.loading = false
s.setOpen(true)
s.supported = true
const h1 = { name: 'folderA' } as unknown as FileSystemDirectoryHandle
const h2 = { name: 'folderB' } as unknown as FileSystemDirectoryHandle
s.handles = [h1, h2]
s.activeHandleIndex = null
// Overview items = one folder tile per root (the store would
// normally do this via refresh; we set it directly to avoid
// permission calls in the test environment).
s.items = [
{ kind: 'folder', name: 'folderA', handle: h1 },
{ kind: 'folder', name: 'folderB', handle: h2 },
]
const handle = mountComponent(ClipartDrawer, {})
await nextTick()
const tiles = handle.host.querySelectorAll('.tile.folder-tile')
expect(tiles.length).toBe(2)
// Clicking the second folder enters it.
;(tiles[1] as HTMLElement).click()
await nextTick()
expect(s.activeHandleIndex).toBe(1)
s.items = []
s.handles = []
s.activeHandleIndex = null
s.setOpen(false)
handle.unmount()
})
test('back button is shown inside a folder when more than one root is configured', async () => {
const { useClipartStore } = await import('@/stores/clipart')
const s = useClipartStore()
s.setOpen(true)
s.supported = true
s.handles = [
{ name: 'a' } as unknown as FileSystemDirectoryHandle,
{ name: 'b' } as unknown as FileSystemDirectoryHandle,
]
s.activeHandleIndex = 0
s.items = []
const handle = mountComponent(ClipartDrawer, {})
await nextTick()
// The back button carries the exitFolder tooltip as aria-label
// via the v-tooltip directive.
const back = handle.host.querySelector(
`button[aria-label="${i18nT('clipart.backToIndex')}"]`,
) as HTMLButtonElement | null
expect(back).toBeTruthy()
back!.click()
await nextTick()
expect(s.activeHandleIndex).toBeNull()
s.items = []
s.handles = []
s.activeHandleIndex = null
s.setOpen(false)
handle.unmount()
})
test('single-root install hides the back button (auto-drill)', async () => {
const { useClipartStore } = await import('@/stores/clipart')
const s = useClipartStore()
s.setOpen(true)
s.supported = true
s.handles = [{ name: 'only' } as unknown as FileSystemDirectoryHandle]
s.activeHandleIndex = null
s.items = []
const handle = mountComponent(ClipartDrawer, {})
await nextTick()
const back = handle.host.querySelector(
`button[aria-label="${i18nT('clipart.backToIndex')}"]`,
) as HTMLButtonElement | null
expect(back).toBeNull()
s.handles = []
s.setOpen(false)
handle.unmount()
})
test('header + button calls addFolder', async () => {
const { useClipartStore } = await import('@/stores/clipart')
const s = useClipartStore()
s.setOpen(true)
s.supported = true
s.handles = [{ name: 'only' } as unknown as FileSystemDirectoryHandle]
s.activeHandleIndex = null
s.items = []
const calls: number[] = []
const orig = s.addFolder
s.addFolder = async () => {
calls.push(1)
}
try {
const handle = mountComponent(ClipartDrawer, {})
await nextTick()
const btn = handle.host.querySelector(
`button[aria-label="${i18nT('clipart.addFolderTooltip')}"]`,
) as HTMLButtonElement | null
expect(btn).toBeTruthy()
btn!.click()
expect(calls.length).toBe(1)
handle.unmount()
} finally {
s.addFolder = orig
s.handles = []
s.setOpen(false)
}
})
})
suite('ui — SceneDrawer', () => {
async function settlePromptCancel() {
// The drawer's `+` button dispatches addScene and then kicks off a
// promptDialog for renaming. Tests don't render DialogsHost, so
// we resolve the pending prompt explicitly to keep the dialog
// queue empty between tests.
const { cancelDialog } = await import('@/components/ui/dialogs')
cancelDialog()
}
test('renders one .scene-row per page, with .active on the current one', async () => {
const s = makeFreshStore()
s.addScene('Two')
s.addScene('Three')
const activeId = s.project.activeSceneId
const handle = mountComponent(SceneDrawer, {})
await nextTick()
const rows = handle.host.querySelectorAll('.scene-row')
expect(rows.length).toBe(3)
// Order matches `project.scenes`.
const names = Array.from(rows).map((r) => r.querySelector('.scene-name')?.textContent?.trim())
expect(names).toEqual(s.project.scenes.map((p) => p.name))
// Exactly one row is .active, and it's the active scene.
const active = Array.from(rows).filter((r) => r.classList.contains('active'))
expect(active.length).toBe(1)
const activeName = active[0].querySelector('.scene-name')?.textContent?.trim()
expect(activeName).toBe(s.project.scenes.find((p) => p.id === activeId)!.name)
handle.unmount()
})
test('layer-count badge reflects each scene\'s layer count', async () => {
const s = makeFreshStore()
const asset = s.addAsset({ kind: 'rect', name: 'D', width: 10, height: 10, fill: '#abc' })
s.addLayer(asset.id)
s.addLayer(asset.id)
const two = s.addScene('Two')
s.addLayer(asset.id) // lands on Two (active after addScene)
// Render AFTER state is set up so the component's reactive read
// captures the final layer counts.
const handle = mountComponent(SceneDrawer, {})
await nextTick()
const rows = handle.host.querySelectorAll('.scene-row')
expect(rows.length).toBe(2)
const metas = Array.from(rows).map((r) =>
r.querySelector('.scene-meta')?.textContent?.trim(),
)
// First row = Scene 1 (2 layers), second row = Two (1 layer).
expect(metas).toEqual(['2', '1'])
void two
handle.unmount()
})
test('clicking a non-active row switches to that page without forcing play', async () => {
// In the editor, flipping scenes is pure navigation. Auto-play is
// reserved for the fullscreen preview — the drawer must not start
// playback on its own, otherwise the user's paused state dies
// behind their back.
const s = makeFreshStore()
const firstId = s.project.activeSceneId
const second = s.addScene('Two')
s.switchScene(firstId)
s.pause()
const handle = mountComponent(SceneDrawer, {})
await nextTick()
const rows = handle.host.querySelectorAll('.scene-row') as NodeListOf<HTMLButtonElement>
rows[1].click()
await nextTick()
expect(s.project.activeSceneId).toBe(second.id)
expect(s.playing).toBe(false)
handle.unmount()
})
test('clicking the already-active row is a no-op', async () => {
const s = makeFreshStore()
const firstId = s.project.activeSceneId
s.pause()
const handle = mountComponent(SceneDrawer, {})
await nextTick()
const rows = handle.host.querySelectorAll('.scene-row') as NodeListOf<HTMLButtonElement>
// Row index 0 = the active one.
rows[0].click()
await nextTick()
// Still paused — no sneaky re-switch triggered auto-play.
expect(s.playing).toBe(false)
expect(s.project.activeSceneId).toBe(firstId)
handle.unmount()
})
test('+ button appends a new scene and activates it', async () => {
const s = makeFreshStore()
expect(s.project.scenes).toHaveLength(1)
const handle = mountComponent(SceneDrawer, {})
await nextTick()
const addBtn = handle.host.querySelector('.add-btn') as HTMLButtonElement
expect(addBtn).toBeTruthy()
addBtn.click()
// Let addScene + the nextTick inside addScene resolve.
await nextTick()
await nextTick()
expect(s.project.scenes).toHaveLength(2)
expect(s.project.activeSceneId).toBe(s.project.scenes[1].id)
// Clean up the pending rename prompt.
await settlePromptCancel()
handle.unmount()
})
test('right-click on a row surfaces the context menu', async () => {
const s = makeFreshStore()
s.addScene('Two')
const handle = mountComponent(SceneDrawer, {})
await nextTick()
expect(handle.host.querySelector('.scene-menu')).toBeNull()
const row = handle.host.querySelector('.scene-row') as HTMLElement
row.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))
await nextTick()
expect(handle.host.querySelector('.scene-menu')).toBeTruthy()
// Delete option is disabled only when there's just one scene left.
const deleteBtn = handle.host.querySelector('.scene-menu .menu-item.danger') as HTMLButtonElement
expect(deleteBtn).toBeTruthy()
expect(deleteBtn.disabled).toBe(false)
handle.unmount()
})
test('context menu disables Delete when only one scene remains', async () => {
const s = makeFreshStore()
expect(s.project.scenes.length).toBe(1)
const handle = mountComponent(SceneDrawer, {})
await nextTick()
const row = handle.host.querySelector('.scene-row') as HTMLElement
row.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))
await nextTick()
const deleteBtn = handle.host.querySelector('.scene-menu .menu-item.danger') as HTMLButtonElement
expect(deleteBtn).toBeTruthy()
expect(deleteBtn.disabled).toBe(true)
handle.unmount()
})
})
suite('ui — DocumentDrawer', () => {
// The document-settings drawer slides down from under the topbar
// when the project-name button is clicked — no local tab button,
// no hover-to-peek. Opening is driven by the shared
// useDocumentDrawer() composable; outside-click and ESC close it.
// Document / canvas / bg-video / timing settings live here; the
// project name is edited inline in the header.
beforeEach(async () => {
// The drawer's open state + project name are module-scoped
// (shared composables). Reset both so tests don't leak state.
const { useDocumentDrawer } = await import(
'@/composables/useDocumentDrawer'
)
const { useProjectName } = await import('@/composables/useProjectName')
useDocumentDrawer().closeDrawer()
useProjectName().projectName.value = null
useProjectName().projectId.value = null
})
test('renders the drawer panel starting closed, no tab button', async () => {
makeFreshStore()
const handle = mountComponent(DocumentDrawer, {})
await nextTick()
const drawer = handle.host.querySelector('.document-drawer') as HTMLElement
expect(drawer).toBeTruthy()
expect(drawer.classList.contains('open')).toBe(false)
// The panel is always mounted (hidden via transform when closed).
expect(handle.host.querySelector('.drawer-panel')).toBeTruthy()
// The old tab trigger is gone — opening is composable-driven.
expect(handle.host.querySelector('.drawer-tab')).toBe(null)
handle.unmount()
})
test('toggle() via the composable flips .open on and off', async () => {
makeFreshStore()
const handle = mountComponent(DocumentDrawer, {})
await nextTick()
const drawer = handle.host.querySelector('.document-drawer') as HTMLElement
const { useDocumentDrawer } = await import(
'@/composables/useDocumentDrawer'
)
const dd = useDocumentDrawer()
expect(drawer.classList.contains('open')).toBe(false)
dd.toggle()
await nextTick()
expect(drawer.classList.contains('open')).toBe(true)
dd.toggle()
await nextTick()
expect(drawer.classList.contains('open')).toBe(false)
handle.unmount()
})
test('opening externally via useDocumentDrawer().openDrawer() flips the class', async () => {
// Regression: the topbar project-name button opens the drawer
// through the shared composable, so all triggers (topbar click,
// programmatic openDrawer) must land on the same state.
makeFreshStore()
const handle = mountComponent(DocumentDrawer, {})
await nextTick()
const { useDocumentDrawer } = await import(
'@/composables/useDocumentDrawer'
)
useDocumentDrawer().openDrawer()
await nextTick()
const drawer = handle.host.querySelector('.document-drawer') as HTMLElement
expect(drawer.classList.contains('open')).toBe(true)
handle.unmount()
})
test('panel exposes the canvas width / height fields bound to the store', async () => {
const s = makeFreshStore()
const handle = mountComponent(DocumentDrawer, {})
await nextTick()
const { useDocumentDrawer } = await import(
'@/composables/useDocumentDrawer'
)
useDocumentDrawer().openDrawer()
await nextTick()
const inputs = handle.host.querySelectorAll(
'.drawer-panel input[type="number"]',
) as NodeListOf<HTMLInputElement>
// Expected order: width, height, duration, fps.
expect(inputs.length).toBe(4)
expect(Number(inputs[0].value)).toBe(s.scene.canvas.width)
expect(Number(inputs[1].value)).toBe(s.scene.canvas.height)
// Edit width → store updates.
inputs[0].value = '640'
inputs[0].dispatchEvent(new Event('input', { bubbles: true }))
inputs[0].dispatchEvent(new Event('change', { bubbles: true }))
await nextTick()
expect(s.scene.canvas.width).toBe(640)
handle.unmount()
})
test('preset buttons set canvas dimensions and reflect the active preset', async () => {
const s = makeFreshStore()
const handle = mountComponent(DocumentDrawer, {})
await nextTick()
const { useDocumentDrawer } = await import(
'@/composables/useDocumentDrawer'
)
useDocumentDrawer().openDrawer()
await nextTick()
const presets = Array.from(
handle.host.querySelectorAll('.preset'),
) as HTMLButtonElement[]
const fullHd = presets.find((b) => b.textContent?.includes('1920×1080'))
expect(fullHd).toBeTruthy()
fullHd!.click()
await nextTick()
expect(s.scene.canvas.width).toBe(1920)
expect(s.scene.canvas.height).toBe(1080)
expect(fullHd!.classList.contains('active')).toBe(true)
handle.unmount()
})
test('mounting the drawer alone does not open it', async () => {
// Regression: there is no local trigger inside the drawer itself
// (no tab, no hover-peek). The only way in is via the composable,
// so a freshly-mounted drawer must stay closed.
makeFreshStore()
const handle = mountComponent(DocumentDrawer, {})
await nextTick()
const drawer = handle.host.querySelector('.document-drawer') as HTMLElement
expect(drawer.classList.contains('open')).toBe(false)
handle.unmount()
})
test('duration + fps fields are bound to the store', async () => {
const s = makeFreshStore()
const handle = mountComponent(DocumentDrawer, {})
await nextTick()
const { useDocumentDrawer } = await import(
'@/composables/useDocumentDrawer'
)
useDocumentDrawer().openDrawer()
await nextTick()
const inputs = handle.host.querySelectorAll(
'.drawer-panel input[type="number"]',
) as NodeListOf<HTMLInputElement>
// Order is [width, height, duration, fps].
const duration = inputs[2]
const fps = inputs[3]
expect(Number(duration.value)).toBe(s.scene.duration)
expect(Number(fps.value)).toBe(s.scene.fps)
duration.value = '12.5'
duration.dispatchEvent(new Event('input', { bubbles: true }))
duration.dispatchEvent(new Event('change', { bubbles: true }))
await nextTick()
expect(s.scene.duration).toBeCloseTo(12.5, 5)
handle.unmount()
})
test('project name input reflects the shared useProjectName ref', async () => {
// The drawer mirrors the topbar's name label — editing lives in
// the drawer and the topbar just displays. The input defaults to
// empty (with a placeholder) while the name is null; as soon as
// the source ref gets a value, the input follows via the watcher.
makeFreshStore()
const { useProjectName } = await import('@/composables/useProjectName')
const { projectName } = useProjectName()
const handle = mountComponent(DocumentDrawer, {})
await nextTick()
const input = handle.host.querySelector('.name-input') as HTMLInputElement
expect(input).toBeTruthy()
expect(input.value).toBe('')
projectName.value = 'My Project'
await nextTick()
expect(input.value).toBe('My Project')
handle.unmount()
})
test('committing a name via blur writes it to useProjectName.projectName', async () => {
makeFreshStore()
const { useProjectName } = await import('@/composables/useProjectName')
const { projectName } = useProjectName()
projectName.value = 'Old Name'
const handle = mountComponent(DocumentDrawer, {})
await nextTick()
const input = handle.host.querySelector('.name-input') as HTMLInputElement
input.value = ' Fresh Name '
input.dispatchEvent(new Event('input', { bubbles: true }))
input.dispatchEvent(new Event('blur', { bubbles: true }))
await nextTick()
// Trimmed. No surrounding whitespace leaks into the stored name.
expect(projectName.value).toBe('Fresh Name')
handle.unmount()
})
test('Enter commits the name; Escape reverts to the stored value', async () => {
makeFreshStore()
const { useProjectName } = await import('@/composables/useProjectName')
const { projectName } = useProjectName()
projectName.value = 'Stored'
const handle = mountComponent(DocumentDrawer, {})
await nextTick()
const input = handle.host.querySelector('.name-input') as HTMLInputElement
// Enter → commit.
input.value = 'Pressed Enter'
input.dispatchEvent(new Event('input', { bubbles: true }))
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
// The handler blurs the input synchronously; blur fires commit.
input.dispatchEvent(new Event('blur', { bubbles: true }))
await nextTick()
expect(projectName.value).toBe('Pressed Enter')
// Escape → revert the draft, no store change.
input.value = 'Accidentally typed'
input.dispatchEvent(new Event('input', { bubbles: true }))
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
input.dispatchEvent(new Event('blur', { bubbles: true }))
await nextTick()
expect(projectName.value).toBe('Pressed Enter')
expect(input.value).toBe('Pressed Enter')
handle.unmount()
})
test('empty / whitespace-only name is rejected; stored value snaps back', async () => {
makeFreshStore()
const { useProjectName } = await import('@/composables/useProjectName')
const { projectName } = useProjectName()
projectName.value = 'Keep Me'
const handle = mountComponent(DocumentDrawer, {})
await nextTick()
const input = handle.host.querySelector('.name-input') as HTMLInputElement
input.value = ' '
input.dispatchEvent(new Event('input', { bubbles: true }))
input.dispatchEvent(new Event('blur', { bubbles: true }))
await nextTick()
expect(projectName.value).toBe('Keep Me')
expect(input.value).toBe('Keep Me')
handle.unmount()
})
})
suite('ui — ActionEditor (jumpToScene)', () => {
test('jumpToScene appears in the type dropdown', async () => {
const s = makeFreshStore()
s.addScene('Two')
const handle = mountComponent(
ActionEditor,
{ actions: [{ type: 'pause' } as const] },
)
await nextTick()
const select = handle.host.querySelector('.type-select') as HTMLSelectElement
expect(select).toBeTruthy()
const values = Array.from(select.options).map((o) => o.value)
expect(values.includes('jumpToScene')).toBe(true)
handle.unmount()
})
test('switching a row to jumpToScene seeds it with the first non-active scene', async () => {
const s = makeFreshStore()
const activeId = s.project.activeSceneId
const other = s.addScene('Other')
s.switchScene(activeId) // pull the active marker back to Scene 1
let latest: unknown = null
const handle = mountComponent(ActionEditor, {
actions: [{ type: 'pause' } as const],
'onUpdate:actions': (v: unknown) => {
latest = v
},
})
await nextTick()
const select = handle.host.querySelector('.type-select') as HTMLSelectElement
select.value = 'jumpToScene'
select.dispatchEvent(new Event('change', { bubbles: true }))
await nextTick()
expect(Array.isArray(latest)).toBe(true)
const arr = latest as unknown[]
expect(arr.length).toBe(1)
const next = arr[0] as { type: string; sceneId?: string }
expect(next.type).toBe('jumpToScene')
// Defaults to the first non-active scene so the action is useful out of the box.
expect(next.sceneId).toBe(other.id)
handle.unmount()
})
test('scene dropdown enumerates every scene by name, emits sceneId on change', async () => {
const s = makeFreshStore()
const firstId = s.project.scenes[0].id
const second = s.addScene('Two')
const third = s.addScene('Three')
// Active is now Three. The seed action points at Two; changing
// to Three via the dropdown should emit { sceneId: third.id }.
let latest: unknown = null
const handle = mountComponent(ActionEditor, {
actions: [{ type: 'jumpToScene', sceneId: second.id } as const],
'onUpdate:actions': (v: unknown) => {
latest = v
},
})
await nextTick()
// Find the scene dropdown (second select on the row — the first
// is the action-type select).
const selects = handle.host.querySelectorAll('select')
expect(selects.length).toBe(2)
const sceneSelect = selects[1] as HTMLSelectElement
const sceneValues = Array.from(sceneSelect.options).map((o) => o.value)
expect(sceneValues).toEqual([firstId, second.id, third.id])
const sceneLabels = Array.from(sceneSelect.options).map((o) => o.textContent?.trim())
expect(sceneLabels).toEqual(['Scene 1', 'Two', 'Three'])
sceneSelect.value = third.id
sceneSelect.dispatchEvent(new Event('change', { bubbles: true }))
await nextTick()
const next = (latest as unknown[])[0] as { type: string; sceneId: string }
expect(next.type).toBe('jumpToScene')
expect(next.sceneId).toBe(third.id)
handle.unmount()
})
})
suite('ui — ActionEditor (setButtonEnabled)', () => {
test('setButtonEnabled appears in the type dropdown', async () => {
const s = makeFreshStore()
void s
const handle = mountComponent(ActionEditor, {
actions: [{ type: 'pause' } as const],
})
await nextTick()
const select = handle.host.querySelector('.type-select') as HTMLSelectElement
const values = Array.from(select.options).map((o) => o.value)
expect(values.includes('setButtonEnabled')).toBe(true)
handle.unmount()
})
test('switching a row to setButtonEnabled seeds it with the first button layer + enabled=false', async () => {
const s = makeFreshStore()
const btn = s.addButtonAsset('Play', 160, 60)
const layer = s.addLayer(btn.id)!
let latest: unknown = null
const handle = mountComponent(ActionEditor, {
actions: [{ type: 'pause' } as const],
'onUpdate:actions': (v: unknown) => {
latest = v
},
})
await nextTick()
const select = handle.host.querySelector('.type-select') as HTMLSelectElement
select.value = 'setButtonEnabled'
select.dispatchEvent(new Event('change', { bubbles: true }))
await nextTick()
const next = (latest as unknown[])[0] as {
type: string
layerId?: string
enabled?: boolean
}
expect(next.type).toBe('setButtonEnabled')
expect(next.layerId).toBe(layer.id)
// Defaults to "disable" — the common use case.
expect(next.enabled).toBe(false)
handle.unmount()
})
test('button dropdown lists one entry per button layer across scenes', async () => {
const s = makeFreshStore()
const btn1 = s.addButtonAsset('Play', 160, 60)
const layer1 = s.addLayer(btn1.id)!
s.addScene('Other')
const btn2 = s.addButtonAsset('Stop', 160, 60)
const layer2 = s.addLayer(btn2.id)!
let latest: unknown = null
const handle = mountComponent(ActionEditor, {
actions: [{ type: 'setButtonEnabled', layerId: layer1.id, enabled: false } as const],
'onUpdate:actions': (v: unknown) => {
latest = v
},
})
await nextTick()
const selects = handle.host.querySelectorAll('select')
// action-type dropdown + button-layer dropdown
expect(selects.length).toBe(2)
const btnSelect = selects[1] as HTMLSelectElement
const ids = Array.from(btnSelect.options).map((o) => o.value)
expect(ids.sort()).toEqual([layer1.id, layer2.id].sort())
btnSelect.value = layer2.id
btnSelect.dispatchEvent(new Event('change', { bubbles: true }))
await nextTick()
const next = (latest as unknown[])[0] as { layerId: string; enabled: boolean }
expect(next.layerId).toBe(layer2.id)
expect(next.enabled).toBe(false)
handle.unmount()
})
test('enabled checkbox round-trips true/false into the emitted action', async () => {
const s = makeFreshStore()
const btn = s.addButtonAsset('Play', 160, 60)
const layer = s.addLayer(btn.id)!
let latest: unknown = null
const handle = mountComponent(ActionEditor, {
actions: [{ type: 'setButtonEnabled', layerId: layer.id, enabled: false } as const],
'onUpdate:actions': (v: unknown) => {
latest = v
},
})
await nextTick()
const cb = handle.host.querySelector('input[type="checkbox"]') as HTMLInputElement
expect(cb).toBeTruthy()
expect(cb.checked).toBe(false)
cb.checked = true
cb.dispatchEvent(new Event('change', { bubbles: true }))
await nextTick()
const next = (latest as unknown[])[0] as { layerId: string; enabled: boolean }
expect(next.layerId).toBe(layer.id)
expect(next.enabled).toBe(true)
handle.unmount()
})
})
suite('ui — Timeline script-frame context menu', () => {
test('right-click on a script frame opens a menu with Duplicate + Delete', async () => {
const s = makeFreshStore()
s.setDuration(10)
const track = s.addScriptTrack()
s.addScriptFrame(track.id, 2, [{ type: 'pause' }])
const handle = mountComponent(Timeline, {})
await nextTick()
const frame = handle.host.querySelector('.script-frame') as HTMLElement
expect(frame).toBeTruthy()
frame.dispatchEvent(
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 100, clientY: 50 }),
)
await nextTick()
const menus = Array.from(handle.host.querySelectorAll('.kf-menu')) as HTMLElement[]
const visible = menus.find((m) => m.offsetParent !== null || m.style.left !== '')
expect(visible).toBeTruthy()
const labels = Array.from(visible!.querySelectorAll('.kf-menu-item')).map((b) =>
b.textContent?.trim(),
)
expect(labels.length).toBe(2)
handle.unmount()
})
test('menu Duplicate creates a second script frame on the same track', async () => {
const s = makeFreshStore()
s.setDuration(10)
const track = s.addScriptTrack()
const f = s.addScriptFrame(track.id, 2, [{ type: 'pause' }], 'intro')!
const handle = mountComponent(Timeline, {})
await nextTick()
const frameEl = handle.host.querySelector('.script-frame') as HTMLElement
frameEl.dispatchEvent(
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 10, clientY: 10 }),
)
await nextTick()
const menus = Array.from(handle.host.querySelectorAll('.kf-menu')) as HTMLElement[]
const menu = menus[menus.length - 1]
const dup = menu.querySelector('.kf-menu-item') as HTMLButtonElement
dup.click()
await nextTick()
const framesOnTrack = (s.scene.scriptFrames ?? []).filter((x) => x.trackId === track.id)
expect(framesOnTrack).toHaveLength(2)
const clone = framesOnTrack.find((x) => x.id !== f.id)!
expect(clone.time).toBeGreaterThan(f.time)
expect(clone.actions.map((a) => a.type)).toEqual(['pause'])
expect(clone.label).toBe('intro')
handle.unmount()
})
})
suite('ui — Timeline clip-instance context menu', () => {
test('right-click on a clip-block opens a menu with Duplicate + Delete', async () => {
const s = makeFreshStore()
s.setDuration(10)
const clip = s.addClip('CInst')
clip.duration = 2
const track = s.scene.clipTracks![0]
s.addClipInstance(clip.id, 0, track.id)
const handle = mountComponent(Timeline, {})
await nextTick()
const block = handle.host.querySelector('.clip-block') as HTMLElement
expect(block).toBeTruthy()
block.dispatchEvent(
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 30, clientY: 40 }),
)
await nextTick()
const menus = Array.from(handle.host.querySelectorAll('.kf-menu')) as HTMLElement[]
const menu = menus[menus.length - 1]
expect(menu).toBeTruthy()
const items = Array.from(menu.querySelectorAll('.kf-menu-item')) as HTMLButtonElement[]
expect(items).toHaveLength(2)
expect(items[items.length - 1].classList.contains('danger')).toBe(true)
handle.unmount()
})
test('menu Duplicate clones the clip instance on the same track', async () => {
const s = makeFreshStore()
s.setDuration(10)
const clip = s.addClip('CInst')
clip.duration = 2
const track = s.scene.clipTracks![0]
const ci = s.addClipInstance(clip.id, 1, track.id)!
const handle = mountComponent(Timeline, {})
await nextTick()
const block = handle.host.querySelector('.clip-block') as HTMLElement
block.dispatchEvent(
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 10, clientY: 10 }),
)
await nextTick()
const menus = Array.from(handle.host.querySelectorAll('.kf-menu')) as HTMLElement[]
const dup = menus[menus.length - 1].querySelector('.kf-menu-item') as HTMLButtonElement
dup.click()
await nextTick()
const siblings = s.activeScene.clipInstances.filter((x) => x.clipTrackId === track.id)
expect(siblings).toHaveLength(2)
const clone = siblings.find((x) => x.id !== ci.id)!
expect(clone.clipId).toBe(clip.id)
expect(clone.startTime).toBe(1 + 2)
handle.unmount()
})
test('menu Delete removes the clip instance', async () => {
const s = makeFreshStore()
s.setDuration(10)
const clip = s.addClip('CInst')
clip.duration = 2
const track = s.scene.clipTracks![0]
const ci = s.addClipInstance(clip.id, 0, track.id)!
const handle = mountComponent(Timeline, {})
await nextTick()
const block = handle.host.querySelector('.clip-block') as HTMLElement
block.dispatchEvent(
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 10, clientY: 10 }),
)
await nextTick()
const menus = Array.from(handle.host.querySelectorAll('.kf-menu')) as HTMLElement[]
const del = menus[menus.length - 1].querySelector('.kf-menu-item.danger') as HTMLButtonElement
del.click()
await nextTick()
expect(s.activeScene.clipInstances.find((x) => x.id === ci.id)).toBe(undefined)
handle.unmount()
})
test('pure click on a clip-block selects the instance', async () => {
// Regression: a clip whose current playhead content is empty can
// only be grabbed via its timeline block. Pointerdown starts a
// drag-tracker; a pointerup without motion is a pure click and
// must land on selectClipInstance so the canvas transformer
// attaches to its wrapper group.
const s = makeFreshStore()
s.setDuration(10)
const clip = s.addClip('CInst')
clip.duration = 2
const track = s.scene.clipTracks![0]
const ci = s.addClipInstance(clip.id, 0, track.id)!
const handle = mountComponent(Timeline, {})
await nextTick()
const block = handle.host.querySelector('.clip-block') as HTMLElement
expect(block).toBeTruthy()
expect(s.selectedClipInstanceId).toBe(null)
block.dispatchEvent(
new PointerEvent('pointerdown', {
bubbles: true,
cancelable: true,
pointerId: 1,
button: 0,
clientX: 20,
clientY: 10,
}),
)
block.dispatchEvent(
new PointerEvent('pointerup', {
bubbles: true,
cancelable: true,
pointerId: 1,
button: 0,
clientX: 20,
clientY: 10,
}),
)
await nextTick()
expect(s.selectedClipInstanceId).toBe(ci.id)
handle.unmount()
})
test('dragging a clip-block does not trigger selection', async () => {
// If the user actually moves the clip, the pointerup is a drag
// release — not a click — and must not also fire the click-to-
// select path. Otherwise every successful move would overwrite
// whatever the user had selected on the canvas.
const s = makeFreshStore()
s.setDuration(10)
const clip = s.addClip('CInst')
clip.duration = 2
const track = s.scene.clipTracks![0]
s.addClipInstance(clip.id, 0, track.id)!
const handle = mountComponent(Timeline, {})
await nextTick()
const block = handle.host.querySelector('.clip-block') as HTMLElement
block.dispatchEvent(
new PointerEvent('pointerdown', {
bubbles: true, cancelable: true, pointerId: 2, button: 0,
clientX: 20, clientY: 10,
}),
)
// Move past the 2px dead-zone to flip `moved` true.
block.dispatchEvent(
new PointerEvent('pointermove', {
bubbles: true, cancelable: true, pointerId: 2,
clientX: 60, clientY: 10,
}),
)
block.dispatchEvent(
new PointerEvent('pointerup', {
bubbles: true, cancelable: true, pointerId: 2, button: 0,
clientX: 60, clientY: 10,
}),
)
await nextTick()
expect(s.selectedClipInstanceId).toBe(null)
handle.unmount()
})
})
suite('scene store — clip track editor hide', () => {
test('setClipTrackEditorHidden toggles the flag on + off', () => {
const s = makeFreshStore()
s.addClip('CInst')
const track = s.scene.clipTracks![0]
expect(track.editorHidden).toBe(undefined)
s.setClipTrackEditorHidden(track.id, true)
expect(track.editorHidden).toBe(true)
s.setClipTrackEditorHidden(track.id, false)
// Pruned back out so the serialized JSON stays minimal.
expect('editorHidden' in track).toBe(false)
})
test('hiding a track clears selectedClipInstanceId for instances on it', () => {
// The transformer watcher reacts to selectedClipInstanceId; if we
// don't clear it, the handles stay wrapped around a ci-group
// that's no longer in the Konva stack.
const s = makeFreshStore()
const clip = s.addClip('CInst')
clip.duration = 2
const track = s.scene.clipTracks![0]
const ci = s.addClipInstance(clip.id, 0, track.id)!
s.selectClipInstance(ci.id)
expect(s.selectedClipInstanceId).toBe(ci.id)
s.setClipTrackEditorHidden(track.id, true)
expect(s.selectedClipInstanceId).toBe(null)
})
test('locking a clip track drops selection of an instance on it', () => {
// The transformer latches onto the ci-group while selection is
// active. Locking the track makes the ci-group non-listening,
// so the selection has to go too — otherwise the handles stay
// visible on a group the user can no longer interact with.
const s = makeFreshStore()
const clip = s.addClip('CInst')
clip.duration = 2
const track = s.scene.clipTracks![0]
const ci = s.addClipInstance(clip.id, 0, track.id)!
s.selectClipInstance(ci.id)
expect(s.selectedClipInstanceId).toBe(ci.id)
s.setTrackLocked(track.id, true)
expect(s.selectedClipInstanceId).toBe(null)
})
test('locking a group containing a clip track drops the instance selection', () => {
const s = makeFreshStore()
const clip = s.addClip('CInst')
clip.duration = 2
const track = s.scene.clipTracks![0]
const ci = s.addClipInstance(clip.id, 0, track.id)!
const g = s.addTrackGroup('G', track.id)
g.childIds.push(track.id)
const order = s.scene.trackOrder!
const ti = order.indexOf(track.id)
if (ti >= 0) order.splice(ti, 1)
s.selectClipInstance(ci.id)
s.setTrackLocked(g.id, true)
expect(s.selectedClipInstanceId).toBe(null)
})
test('hiding a track leaves selection on a different track alone', () => {
const s = makeFreshStore()
const clip = s.addClip('CInst')
clip.duration = 2
const t1 = s.scene.clipTracks![0]
const t2 = s.addClipTrack('T2')
const ci1 = s.addClipInstance(clip.id, 0, t1.id)!
const ci2 = s.addClipInstance(clip.id, 5, t2.id)!
s.selectClipInstance(ci2.id)
s.setClipTrackEditorHidden(t1.id, true)
// Only ci1 lives on the hidden track; ci2 is on t2 and must
// keep its selection.
expect(s.selectedClipInstanceId).toBe(ci2.id)
void ci1
})
})
suite('scene store — track group editor hide', () => {
test('setGroupEditorHidden toggles the flag on + off', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
const g = s.addTrackGroup(undefined, l.id)
expect(g.editorHidden).toBe(undefined)
s.setGroupEditorHidden(g.id, true)
expect(g.editorHidden).toBe(true)
s.setGroupEditorHidden(g.id, false)
expect('editorHidden' in g).toBe(false)
})
test('hiding a group clears selection of a child layer', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.selectLayer(l.id)
const groupId = s.groupSelectedLayers('G1')!
expect(groupId).toBeTruthy()
// groupSelectedLayers clears selection after consuming it, so
// re-select the layer now that it sits inside the group.
s.selectLayer(l.id)
expect(s.selectedLayerId).toBe(l.id)
s.setGroupEditorHidden(groupId, true)
expect(s.selectedLayerId).toBe(null)
expect(s.selectedLayerIds).toEqual([])
})
test('hiding a group clears clip-instance selection when the instance track is inside', () => {
const s = makeFreshStore()
const clip = s.addClip('CInst')
clip.duration = 2
const track = s.scene.clipTracks![0]
const ci = s.addClipInstance(clip.id, 0, track.id)!
// Wrap the clip track into a fresh group by poking childIds
// directly — there's no public addClipTrackToGroup action, the
// real app nests tracks through drag-drop.
const g = s.addTrackGroup('G2', track.id)
g.childIds.push(track.id)
const order = s.scene.trackOrder!
const ti = order.indexOf(track.id)
if (ti >= 0) order.splice(ti, 1)
s.selectClipInstance(ci.id)
expect(s.selectedClipInstanceId).toBe(ci.id)
s.setGroupEditorHidden(g.id, true)
expect(s.selectedClipInstanceId).toBe(null)
})
})
suite('ui — Timeline eye button stays clickable on locked tracks', () => {
test('locked layer row keeps the eye-btn pointer-events auto', async () => {
// Regression: `.track.locked .track-name { pointer-events: none }`
// was muting every button in the row; only `.lock-btn` was
// re-enabled. The eye toggle has to stay clickable so the user
// can hide / reveal a locked layer without unlocking first.
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.setTrackLocked(l.id, true)
const handle = mountComponent(Timeline, {})
await nextTick()
const lockedRow = handle.host.querySelector('.track.locked') as HTMLElement
expect(lockedRow).toBeTruthy()
const eye = lockedRow.querySelector('.track-name .eye-btn') as HTMLButtonElement
expect(eye).toBeTruthy()
const pe = getComputedStyle(eye).pointerEvents
expect(pe).toBe('auto')
// Sanity — the lane IS inert.
const lane = lockedRow.querySelector('.track-lane') as HTMLElement
expect(getComputedStyle(lane).pointerEvents).toBe('none')
handle.unmount()
})
test('locked clip-track row keeps the eye-btn pointer-events auto', async () => {
const s = makeFreshStore()
s.addClip('CInst')
const track = s.scene.clipTracks![0]
s.setTrackLocked(track.id, true)
const handle = mountComponent(Timeline, {})
await nextTick()
const lockedRow = handle.host.querySelector('.track.clip-track.locked') as HTMLElement
expect(lockedRow).toBeTruthy()
const eye = lockedRow.querySelector('.track-name .eye-btn') as HTMLButtonElement
expect(eye).toBeTruthy()
expect(getComputedStyle(eye).pointerEvents).toBe('auto')
handle.unmount()
})
test('toggling editorHidden on a locked layer still writes the flag', async () => {
// End-to-end: lock the layer, click its eye button, verify
// editorHidden flipped. Complements the CSS test — together
// they cover both the pointer-events rule and the store path.
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.setTrackLocked(l.id, true)
const handle = mountComponent(Timeline, {})
await nextTick()
const eye = handle.host.querySelector(
'.track.locked .track-name .eye-btn',
) as HTMLButtonElement
eye.click()
await nextTick()
expect(l.editorHidden).toBe(true)
handle.unmount()
})
})
suite('ui — Timeline clip-track eye button', () => {
test('clicking the eye toggles editorHidden on the clip track', async () => {
const s = makeFreshStore()
s.addClip('CInst')
const track = s.scene.clipTracks![0]
const handle = mountComponent(Timeline, {})
await nextTick()
const clipTrack = handle.host.querySelector('.track.clip-track') as HTMLElement
expect(clipTrack).toBeTruthy()
const eye = clipTrack.querySelector('.track-name .eye-btn') as HTMLButtonElement
expect(eye).toBeTruthy()
expect(eye.classList.contains('hidden')).toBe(false)
eye.click()
await nextTick()
expect(track.editorHidden).toBe(true)
const eye2 = clipTrack.querySelector('.track-name .eye-btn') as HTMLButtonElement
expect(eye2.classList.contains('hidden')).toBe(true)
eye2.click()
await nextTick()
expect('editorHidden' in track).toBe(false)
handle.unmount()
})
})
suite('scene store — track rename (layer + clip track)', () => {
test('setLayerName applies a trimmed non-empty name', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.setLayerName(l.id, ' Hero ')
expect(l.name).toBe('Hero')
})
test('setLayerName ignores empty / whitespace-only input', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
const original = l.name
s.setLayerName(l.id, '')
expect(l.name).toBe(original)
s.setLayerName(l.id, ' ')
expect(l.name).toBe(original)
})
test('setClipTrackName sets + clears the optional name field', () => {
const s = makeFreshStore()
s.addClip('CInst')
const track = s.scene.clipTracks![0]
s.setClipTrackName(track.id, 'Intro clips')
expect(track.name).toBe('Intro clips')
// Empty clears back to undefined — default label is shown via i18n.
s.setClipTrackName(track.id, '')
expect(track.name).toBe(undefined)
})
})
suite('ui — Timeline rename menu (layer + clip track)', () => {
// Finds the menu item whose label contains the given i18n string.
// Menu items share `.kf-menu-item`, so labels are the only stable
// handle that distinguishes rename from lock / delete / etc.
function findMenuItem(menu: HTMLElement, label: string): HTMLButtonElement | null {
const items = Array.from(menu.querySelectorAll('.kf-menu-item')) as HTMLButtonElement[]
return items.find((b) => b.textContent?.includes(label)) ?? null
}
test('right-click on a layer row → Rename layer → prompt → setLayerName', async () => {
const { dialogState, resolveDialog } = await import(
'@/components/ui/dialogs'
)
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Rect', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
const handle = mountComponent(Timeline, {})
try {
await nextTick()
const row = handle.host.querySelector('.track .track-name') as HTMLElement
expect(row).toBeTruthy()
row.dispatchEvent(
new MouseEvent('contextmenu', {
bubbles: true, cancelable: true, clientX: 10, clientY: 10,
}),
)
await nextTick()
const menu = handle.host.querySelector('.kf-menu') as HTMLElement
expect(menu).toBeTruthy()
const renameBtn = findMenuItem(menu, i18nT('timeline.renameLayer'))
expect(renameBtn).toBeTruthy()
// renameFromMenu is async but runs synchronously up to the
// `await promptDialog(...)`; promptDialog's new Promise cb
// runs in the same tick and writes dialogState.value before
// returning. So the prompt state is observable immediately
// after the click.
renameBtn!.click()
expect(dialogState.value?.kind).toBe('prompt')
resolveDialog('Hero-from-menu')
// The awaited promptDialog now resolves on a microtask — tick
// so the continuation (store.setLayerName) runs.
await nextTick()
expect(l.name).toBe('Hero-from-menu')
} finally {
handle.unmount()
}
})
test('Rename layer prompt cancelled → layer name stays the same', async () => {
// Dismissing the prompt must not clobber the existing name.
const { dialogState, cancelDialog } = await import(
'@/components/ui/dialogs'
)
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Rect', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
const original = l.name
const handle = mountComponent(Timeline, {})
try {
await nextTick()
const row = handle.host.querySelector('.track .track-name') as HTMLElement
row.dispatchEvent(
new MouseEvent('contextmenu', {
bubbles: true, cancelable: true, clientX: 10, clientY: 10,
}),
)
await nextTick()
const menu = handle.host.querySelector('.kf-menu') as HTMLElement
findMenuItem(menu, i18nT('timeline.renameLayer'))!.click()
expect(dialogState.value?.kind).toBe('prompt')
cancelDialog()
await nextTick()
expect(l.name).toBe(original)
} finally {
handle.unmount()
}
})
test('right-click on a clip-track row → Rename clip track → prompt → setClipTrackName', async () => {
const { dialogState, resolveDialog } = await import(
'@/components/ui/dialogs'
)
const s = makeFreshStore()
s.addClip('CInst')
const track = s.scene.clipTracks![0]
const handle = mountComponent(Timeline, {})
try {
await nextTick()
const row = handle.host.querySelector(
'.track.clip-track .track-name',
) as HTMLElement
expect(row).toBeTruthy()
row.dispatchEvent(
new MouseEvent('contextmenu', {
bubbles: true, cancelable: true, clientX: 10, clientY: 10,
}),
)
await nextTick()
const menu = handle.host.querySelector('.kf-menu') as HTMLElement
expect(menu).toBeTruthy()
const renameBtn = findMenuItem(menu, i18nT('timeline.renameClipTrack'))
expect(renameBtn).toBeTruthy()
renameBtn!.click()
expect(dialogState.value?.kind).toBe('prompt')
resolveDialog('Intro clips')
await nextTick()
expect(track.name).toBe('Intro clips')
} finally {
handle.unmount()
}
})
test('right-click on a group row → Rename group → prompt → renameGroup', async () => {
const { dialogState, resolveDialog } = await import(
'@/components/ui/dialogs'
)
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.selectLayer(l.id)
const groupId = s.groupSelectedLayers('G1')!
const group = s.scene.groups!.find((g) => g.id === groupId)!
const handle = mountComponent(Timeline, {})
try {
await nextTick()
const row = handle.host.querySelector(
'.track.group-track .track-name',
) as HTMLElement
expect(row).toBeTruthy()
row.dispatchEvent(
new MouseEvent('contextmenu', {
bubbles: true, cancelable: true, clientX: 10, clientY: 10,
}),
)
await nextTick()
const menu = handle.host.querySelector('.kf-menu') as HTMLElement
expect(menu).toBeTruthy()
const renameBtn = findMenuItem(menu, i18nT('timeline.renameGroup'))
expect(renameBtn).toBeTruthy()
renameBtn!.click()
expect(dialogState.value?.kind).toBe('prompt')
resolveDialog('Hero group')
await nextTick()
expect(group.name).toBe('Hero group')
} finally {
handle.unmount()
}
})
test('dblclick on a group row opens the rename prompt', async () => {
// Groups also support rename via double-click on the track
// name. Covers the second entry point so it stays wired up.
const { dialogState, resolveDialog } = await import(
'@/components/ui/dialogs'
)
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.selectLayer(l.id)
const groupId = s.groupSelectedLayers('Old')!
const group = s.scene.groups!.find((g) => g.id === groupId)!
const handle = mountComponent(Timeline, {})
try {
await nextTick()
const row = handle.host.querySelector(
'.track.group-track .track-name',
) as HTMLElement
row.dispatchEvent(
new MouseEvent('dblclick', { bubbles: true, cancelable: true }),
)
expect(dialogState.value?.kind).toBe('prompt')
resolveDialog('New name')
await nextTick()
expect(group.name).toBe('New name')
} finally {
handle.unmount()
}
})
})
suite('ui — Timeline group eye button', () => {
test('clicking the eye toggles editorHidden on the track group', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.selectLayer(l.id)
const groupId = s.groupSelectedLayers('G1')!
const group = s.scene.groups!.find((g) => g.id === groupId)!
const handle = mountComponent(Timeline, {})
await nextTick()
const groupTrack = handle.host.querySelector('.track.group-track') as HTMLElement
expect(groupTrack).toBeTruthy()
const eye = groupTrack.querySelector('.track-name .eye-btn') as HTMLButtonElement
expect(eye).toBeTruthy()
expect(eye.classList.contains('hidden')).toBe(false)
eye.click()
await nextTick()
expect(group.editorHidden).toBe(true)
const eye2 = groupTrack.querySelector('.track-name .eye-btn') as HTMLButtonElement
expect(eye2.classList.contains('hidden')).toBe(true)
eye2.click()
await nextTick()
expect('editorHidden' in group).toBe(false)
handle.unmount()
})
})
suite('ui — Timeline playhead-magnetic snap', () => {
// Snapping on → dragged clip blocks + clip trim handles stick to
// the playhead (and to keyframes).
/** Measure pps from the clip lane's layout width. The clip lane is
* styled width: laneWidth = duration * effectivePxPerSec, with
* NO minimum floor — unlike the clip-block itself, whose width
* is max(6px, …) and over-reports pps for short clips. */
function measurePxPerSec(root: HTMLElement, sceneDuration: number): number {
const lane = root.querySelector('.clip-lane') as HTMLElement
const w = lane.getBoundingClientRect().width
return w / sceneDuration
}
/** Dispatch a clean pointerdown / pointermove / pointerup sequence
* that drags an element by `deltaPx` horizontally from its
* centre. Real mouse pointers use pointerId 1; reusing that
* keeps setPointerCapture happy across handlers. */
function dragX(el: HTMLElement, deltaPx: number) {
const r = el.getBoundingClientRect()
const cx = r.left + r.width / 2
const cy = r.top + r.height / 2
el.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true, cancelable: true, pointerId: 1, button: 0,
clientX: cx, clientY: cy,
}))
el.dispatchEvent(new PointerEvent('pointermove', {
bubbles: true, cancelable: true, pointerId: 1,
clientX: cx + deltaPx, clientY: cy,
}))
el.dispatchEvent(new PointerEvent('pointerup', {
bubbles: true, cancelable: true, pointerId: 1, button: 0,
clientX: cx + deltaPx, clientY: cy,
}))
}
test('dragging a clip block lands exactly on the playhead when close', async () => {
const sceneDur = 20
const s = makeFreshStore()
s.setDuration(sceneDur)
const clip = s.addClip('C')
clip.duration = 10 // big enough that block width is always > 6px min
const track = s.scene.clipTracks![0]
const ci = s.addClipInstance(clip.id, 0, track.id)!
s.setPlayhead(6)
const handle = mountComponent(Timeline, {})
try {
await nextTick()
const block = handle.host.querySelector('.clip-block') as HTMLElement
expect(block).toBeTruthy()
const pps = measurePxPerSec(handle.host, sceneDur)
expect(pps > 0).toBe(true)
// Land ~0.03s off the playhead = well under the 8px snap
// threshold at any realistic pps.
const targetStart = 5.97
const deltaPx = (targetStart - ci.startTime) * pps
dragX(block, deltaPx)
await nextTick()
expect(ci.startTime).toBe(6)
} finally {
handle.unmount()
}
})
test('dragging the right handle of a clip snaps its end to the playhead', async () => {
// Reproduces the user request literally: "pull the end of a
// clip — it has to snap to the needle."
const sceneDur = 20
const s = makeFreshStore()
s.setDuration(sceneDur)
const clip = s.addClip('C')
clip.duration = 10 // right edge at startTime+10
const track = s.scene.clipTracks![0]
const ci = s.addClipInstance(clip.id, 0, track.id)!
s.setPlayhead(3)
const handle = mountComponent(Timeline, {})
try {
await nextTick()
const block = handle.host.querySelector('.clip-block') as HTMLElement
const rightHandle = block.querySelector(
'.clip-handle.end',
) as HTMLElement
expect(rightHandle).toBeTruthy()
const pps = measurePxPerSec(handle.host, sceneDur)
const targetEdge = 3.05
const deltaPx = (targetEdge - 10) * pps
dragX(rightHandle, deltaPx)
await nextTick()
// Right edge = 0 + 10 - trimEnd, snapped to 3 → trimEnd = 7.
expect(ci.trimEnd).toBeCloseTo(7, 5)
} finally {
handle.unmount()
}
})
test('dragging the left handle of a clip snaps its start to the playhead', async () => {
const sceneDur = 20
const s = makeFreshStore()
s.setDuration(sceneDur)
const clip = s.addClip('C')
clip.duration = 10
const track = s.scene.clipTracks![0]
const ci = s.addClipInstance(clip.id, 1, track.id)!
s.setPlayhead(4)
const handle = mountComponent(Timeline, {})
try {
await nextTick()
const block = handle.host.querySelector('.clip-block') as HTMLElement
const leftHandle = block.querySelector(
'.clip-handle.start',
) as HTMLElement
expect(leftHandle).toBeTruthy()
const pps = measurePxPerSec(handle.host, sceneDur)
const targetEdge = 3.95
const deltaPx = (targetEdge - 1) * pps
dragX(leftHandle, deltaPx)
await nextTick()
// Left edge snapped to 4 → startTime = 4, trimStart absorbs
// the 3s shift (initialTrimStart 0 + delta 3).
expect(ci.startTime).toBeCloseTo(4, 5)
expect(ci.trimStart).toBeCloseTo(3, 5)
} finally {
handle.unmount()
}
})
})
suite('ui — Timeline audio-clip context menu', () => {
test('right-click on an audio-clip-block opens a menu with Duplicate + Delete', async () => {
const s = makeFreshStore()
const asset = s.addAudioAsset({ name: 'beep', src: 'data:audio/mp3;x', duration: 2 })
const track = s.addAudioTrack('FX')
s.addAudioClip(asset.id, 0, track.id)
const handle = mountComponent(Timeline, {})
await nextTick()
const block = handle.host.querySelector('.audio-clip-block') as HTMLElement
expect(block).toBeTruthy()
block.dispatchEvent(
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 30, clientY: 40 }),
)
await nextTick()
const menus = Array.from(handle.host.querySelectorAll('.kf-menu')) as HTMLElement[]
const menu = menus[menus.length - 1]
const items = Array.from(menu.querySelectorAll('.kf-menu-item')) as HTMLButtonElement[]
expect(items).toHaveLength(2)
expect(items[items.length - 1].classList.contains('danger')).toBe(true)
handle.unmount()
})
test('menu Duplicate clones the audio clip on the same track', async () => {
const s = makeFreshStore()
const asset = s.addAudioAsset({ name: 'beep', src: 'data:audio/mp3;x', duration: 2 })
const track = s.addAudioTrack('FX')
const clip = s.addAudioClip(asset.id, 1, track.id)!
const handle = mountComponent(Timeline, {})
await nextTick()
const block = handle.host.querySelector('.audio-clip-block') as HTMLElement
block.dispatchEvent(
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 10, clientY: 10 }),
)
await nextTick()
const menus = Array.from(handle.host.querySelectorAll('.kf-menu')) as HTMLElement[]
const dup = menus[menus.length - 1].querySelector('.kf-menu-item') as HTMLButtonElement
dup.click()
await nextTick()
const siblings = s.activeScene.audioClips.filter((c) => c.audioTrackId === track.id)
expect(siblings).toHaveLength(2)
const clone = siblings.find((c) => c.id !== clip.id)!
expect(clone.audioAssetId).toBe(asset.id)
expect(clone.startTime).toBe(1 + 2)
handle.unmount()
})
test('menu Delete removes the audio clip', async () => {
const s = makeFreshStore()
const asset = s.addAudioAsset({ name: 'beep', src: 'data:audio/mp3;x', duration: 2 })
const track = s.addAudioTrack('FX')
const clip = s.addAudioClip(asset.id, 0, track.id)!
const handle = mountComponent(Timeline, {})
await nextTick()
const block = handle.host.querySelector('.audio-clip-block') as HTMLElement
block.dispatchEvent(
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 10, clientY: 10 }),
)
await nextTick()
const menus = Array.from(handle.host.querySelectorAll('.kf-menu')) as HTMLElement[]
const del = menus[menus.length - 1].querySelector('.kf-menu-item.danger') as HTMLButtonElement
del.click()
await nextTick()
expect(s.activeScene.audioClips.find((c) => c.id === clip.id)).toBe(undefined)
handle.unmount()
})
})
suite('ui — Timeline new-group button', () => {
test('clicking the new-group button with a selected layer inserts the group above it', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Anchor', width: 10, height: 10, fill: '#111' })
const top = s.addLayer(a.id)!
const anchor = s.addLayer(a.id)!
s.selectLayer(anchor.id)
const handle = mountComponent(Timeline, {})
await nextTick()
const btn = handle.host.querySelector(
`button[aria-label="${i18nT('timeline.addGroupTooltip')}"]`,
) as HTMLButtonElement
expect(btn).toBeTruthy()
btn.click()
await nextTick()
const order = s.scene.trackOrder ?? []
const groupId = (s.scene.groups ?? [])[0]?.id
expect(groupId).toBeTruthy()
const gIdx = order.indexOf(groupId!)
const anchorIdx = order.indexOf(anchor.id)
// Group sits right above the anchor layer, pushing it down by one slot.
expect(gIdx).toBe(anchorIdx - 1)
// Unrelated `top` layer keeps its own slot — not dragged along.
expect(order.indexOf(top.id) >= 0).toBe(true)
handle.unmount()
})
test('clicking the new-group button with no selection unshifts to the top (regression)', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#111' })
s.addLayer(a.id)
s.selectLayer(null)
const handle = mountComponent(Timeline, {})
await nextTick()
const btn = handle.host.querySelector(
`button[aria-label="${i18nT('timeline.addGroupTooltip')}"]`,
) as HTMLButtonElement
btn.click()
await nextTick()
const groupId = (s.scene.groups ?? [])[0]?.id
expect(s.scene.trackOrder?.[0]).toBe(groupId)
handle.unmount()
})
})
suite('ui — Timeline split-svg menu entry', () => {
const sampleSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40">
<rect x="0" y="0" width="20" height="20" fill="#f00"/>
<rect x="20" y="20" width="20" height="20" fill="#0f0"/>
</svg>`
function toDataUrl(svg: string): string {
return 'data:image/svg+xml,' + encodeURIComponent(svg)
}
test('Split SVG entry appears in the track menu for an SVG-backed layer', async () => {
const s = makeFreshStore()
const asset = s.addAsset({
kind: 'image',
name: 'Parts',
src: toDataUrl(sampleSvg),
width: 40,
height: 40,
})
s.addLayer(asset.id)
const handle = mountComponent(Timeline, {})
await nextTick()
// Layer tracks render as `.track` without a kind-specific suffix;
// the first track-name in the host is the layer we just added.
const name = handle.host.querySelector('.track .track-name') as HTMLElement | null
expect(name).toBeTruthy()
name!.dispatchEvent(
new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
clientX: 20,
clientY: 20,
}),
)
await nextTick()
const menus = Array.from(handle.host.querySelectorAll('.kf-menu')) as HTMLElement[]
const menu = menus[menus.length - 1]
expect(menu).toBeTruthy()
const items = Array.from(menu.querySelectorAll('.kf-menu-item')) as HTMLButtonElement[]
const hasSplit = items.some((b) => b.textContent?.includes(i18nT('timeline.splitSvg')))
expect(hasSplit).toBe(true)
handle.unmount()
})
test('Split SVG entry is absent for a raster (PNG) layer', async () => {
const s = makeFreshStore()
const asset = s.addAsset({
kind: 'image',
name: 'Raster',
src: 'data:image/png;base64,iVBORw0K==',
width: 40,
height: 40,
})
s.addLayer(asset.id)
const handle = mountComponent(Timeline, {})
await nextTick()
// Layer tracks render as `.track` without a kind-specific suffix;
// the first track-name in the host is the layer we just added.
const name = handle.host.querySelector('.track .track-name') as HTMLElement | null
expect(name).toBeTruthy()
name!.dispatchEvent(
new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
clientX: 20,
clientY: 20,
}),
)
await nextTick()
const menus = Array.from(handle.host.querySelectorAll('.kf-menu')) as HTMLElement[]
const menu = menus[menus.length - 1]
const items = Array.from(menu.querySelectorAll('.kf-menu-item')) as HTMLButtonElement[]
const hasSplit = items.some((b) => b.textContent?.includes(i18nT('timeline.splitSvg')))
expect(hasSplit).toBe(false)
handle.unmount()
})
})
suite('ui — Timeline audio track', () => {
test('audio track renders as an .audio-track lane with one block per clip', async () => {
const s = makeFreshStore()
const asset = s.addAudioAsset({ name: 'beep', src: 'data:audio/mp3;x', duration: 2 })
const track = s.addAudioTrack('FX')
s.addAudioClip(asset.id, 0, track.id)
s.addAudioClip(asset.id, 3, track.id)
const handle = mountComponent(Timeline, {})
await nextTick()
const lanes = handle.host.querySelectorAll('.audio-track')
expect(lanes.length).toBe(1)
const blocks = handle.host.querySelectorAll('.audio-track .audio-clip-block')
expect(blocks.length).toBe(2)
handle.unmount()
})
test('audio-clip-block left position scales with startTime (not hard-coded pps)', async () => {
// The timeline's px-per-second varies with zoom / fit — avoid
// pinning a specific pixel value and only assert the ordering:
// a later startTime produces a larger `left`, a startTime of 0
// sits flush at 0.
const s = makeFreshStore()
const asset = s.addAudioAsset({ name: 'x', src: 'data:audio/mp3;x', duration: 2 })
const track = s.addAudioTrack('A')
s.addAudioClip(asset.id, 0, track.id)
s.addAudioClip(asset.id, 3, track.id)
const handle = mountComponent(Timeline, {})
await nextTick()
const blocks = Array.from(
handle.host.querySelectorAll('.audio-track .audio-clip-block'),
) as HTMLElement[]
expect(blocks).toHaveLength(2)
const left0 = parseFloat(blocks[0].style.left)
const left3 = parseFloat(blocks[1].style.left)
expect(left0).toBe(0)
expect(left3 > 0).toBe(true)
handle.unmount()
})
test('muting a track adds the .muted class', async () => {
const s = makeFreshStore()
const track = s.addAudioTrack('A')
const handle = mountComponent(Timeline, {})
await nextTick()
const lane = handle.host.querySelector('.audio-track') as HTMLElement
expect(lane.classList.contains('muted')).toBe(false)
s.setAudioTrackMuted(track.id, true)
await nextTick()
expect(lane.classList.contains('muted')).toBe(true)
handle.unmount()
})
test('audio track name carries the draggable attribute so it joins drag-reorder', async () => {
// Audio tracks used to render with no @dragstart, which left them
// outside the stack-reorder flow. They now share the same hooks
// as layer / clip / script tracks.
const s = makeFreshStore()
s.addAudioTrack('Draggable')
const handle = mountComponent(Timeline, {})
await nextTick()
const name = handle.host.querySelector('.audio-track .track-name') as HTMLElement
expect(name).toBeTruthy()
expect(name.getAttribute('draggable')).toBe('true')
handle.unmount()
})
test('script track matches the default 30px track height', async () => {
// Regression: scripts used to be 26px, now unified with the other
// kinds so the timeline stack reads as a single visual list.
const s = makeFreshStore()
s.addScriptTrack('S')
const handle = mountComponent(Timeline, {})
await nextTick()
const st = handle.host.querySelector('.script-track') as HTMLElement
expect(st).toBeTruthy()
const h = getComputedStyle(st).height
expect(h).toBe('30px')
handle.unmount()
})
test('audio track co-exists with other track kinds in the tracks container', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#111' })
s.addLayer(a.id, 'layer-one')
s.addAudioTrack('A1')
const handle = mountComponent(Timeline, {})
await nextTick()
// At least two rows in the tracks column; one of them is the audio lane.
const rows = handle.host.querySelectorAll('.tracks .track')
expect(rows.length >= 2).toBe(true)
expect(handle.host.querySelector('.audio-track')).toBeTruthy()
handle.unmount()
})
test('pointer-drag on an audio-clip-block moves the clip in time', async () => {
const s = makeFreshStore()
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 5 })
const track = s.addAudioTrack('T')
const clip = s.addAudioClip(asset.id, 1, track.id)!
const handle = mountComponent(Timeline, {})
await nextTick()
const block = handle.host.querySelector('.audio-track .audio-clip-block') as HTMLElement
expect(block).toBeTruthy()
// The harness doesn't do layout, so we pin the component's pps
// by reading the rendered left value before the drag (startTime=1
// maps to `pps` pixels). That pins the conversion both ways.
const leftBefore = parseFloat(block.style.left)
const pps = leftBefore // because startTime was 1s
expect(pps > 0).toBe(true)
// Patch setPointerCapture / releasePointerCapture / hasPointerCapture
// on the block so pointer capture calls don't throw in the test
// harness (elements aren't attached in a way that supports it).
const captured = new Set<number>()
;(block as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture = (id) => {
captured.add(id)
}
;(block as unknown as { releasePointerCapture: (id: number) => void }).releasePointerCapture = (id) => {
captured.delete(id)
}
;(block as unknown as { hasPointerCapture: (id: number) => boolean }).hasPointerCapture = (id) => captured.has(id)
const startX = 200
const down = new PointerEvent('pointerdown', {
bubbles: true, cancelable: true, button: 0, pointerId: 1, clientX: startX, clientY: 10,
})
block.dispatchEvent(down)
// Drag right by 2*pps px → 2s forward; new startTime should be ~3s.
const move = new PointerEvent('pointermove', {
bubbles: true, cancelable: true, pointerId: 1, clientX: startX + 2 * pps, clientY: 10,
})
block.dispatchEvent(move)
const up = new PointerEvent('pointerup', {
bubbles: true, cancelable: true, pointerId: 1, clientX: startX + 2 * pps, clientY: 10,
})
block.dispatchEvent(up)
await nextTick()
expect(Math.abs(clip.startTime - 3) < 0.05).toBe(true)
handle.unmount()
})
test('dragging clamps startTime at 0 (no negative positions)', async () => {
const s = makeFreshStore()
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 5 })
const track = s.addAudioTrack()
const clip = s.addAudioClip(asset.id, 1, track.id)!
const handle = mountComponent(Timeline, {})
await nextTick()
const block = handle.host.querySelector('.audio-track .audio-clip-block') as HTMLElement
const captured = new Set<number>()
;(block as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture = (id) => {
captured.add(id)
}
;(block as unknown as { releasePointerCapture: (id: number) => void }).releasePointerCapture = (id) => {
captured.delete(id)
}
;(block as unknown as { hasPointerCapture: (id: number) => boolean }).hasPointerCapture = (id) => captured.has(id)
block.dispatchEvent(
new PointerEvent('pointerdown', { bubbles: true, cancelable: true, button: 0, pointerId: 1, clientX: 500 }),
)
block.dispatchEvent(
// Big leftward drag — way past the left edge.
new PointerEvent('pointermove', { bubbles: true, cancelable: true, pointerId: 1, clientX: -5000 }),
)
block.dispatchEvent(
new PointerEvent('pointerup', { bubbles: true, cancelable: true, pointerId: 1, clientX: -5000 }),
)
await nextTick()
expect(clip.startTime).toBe(0)
handle.unmount()
})
test('pointerup over a different audio track switches the clip to that track', async () => {
// The live app (which our tests render alongside) ALSO has a
// Timeline mounted. `document.elementFromPoint` would return
// whatever element is topmost at a given screen coord — often
// belonging to the live Timeline, not the one we just mounted.
// Override the lookup for this test to pin the expected hit.
const s = makeFreshStore()
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 2 })
const t1 = s.addAudioTrack('one')
const t2 = s.addAudioTrack('two')
const clip = s.addAudioClip(asset.id, 1, t1.id)!
const handle = mountComponent(Timeline, {})
await nextTick()
const block = handle.host.querySelector(
`.audio-track[data-audio-track-id="${t1.id}"] .audio-clip-block`,
) as HTMLElement
const targetLane = handle.host.querySelector(
`.audio-track[data-audio-track-id="${t2.id}"]`,
) as HTMLElement
expect(block).toBeTruthy()
expect(targetLane).toBeTruthy()
const origFromPoint = document.elementFromPoint.bind(document)
;(document as unknown as { elementFromPoint: (x: number, y: number) => Element | null }).elementFromPoint = (
_x: number,
y: number,
): Element | null => {
// Only intercept the sentinel Y we use for the drop — let the
// original handle every other call so the rest of the app
// isn't disturbed.
if (y === 42424242) return targetLane
return origFromPoint(_x, y)
}
const captured = new Set<number>()
;(block as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture = (id) => {
captured.add(id)
}
;(block as unknown as { releasePointerCapture: (id: number) => void }).releasePointerCapture = (id) => {
captured.delete(id)
}
;(block as unknown as { hasPointerCapture: (id: number) => boolean }).hasPointerCapture = (id) => captured.has(id)
try {
block.dispatchEvent(
new PointerEvent('pointerdown', { bubbles: true, cancelable: true, button: 0, pointerId: 1, clientX: 100, clientY: 10 }),
)
block.dispatchEvent(
new PointerEvent('pointermove', { bubbles: true, cancelable: true, pointerId: 1, clientX: 110, clientY: 42424242 }),
)
block.dispatchEvent(
new PointerEvent('pointerup', { bubbles: true, cancelable: true, pointerId: 1, clientX: 110, clientY: 42424242 }),
)
await nextTick()
expect(clip.audioTrackId).toBe(t2.id)
} finally {
;(document as unknown as { elementFromPoint: (x: number, y: number) => Element | null }).elementFromPoint =
origFromPoint
handle.unmount()
}
})
test('dragging an audio asset from the library shows a ghost preview on the hovered lane', async () => {
const { setAudioDragSource, clearAudioDragSource } = await import(
'@/composables/useAudioDragSource'
)
const s = makeFreshStore()
const asset = s.addAudioAsset({ name: 'intro', src: 'data:audio/mp3;x', duration: 3 })
const track = s.addAudioTrack()
const handle = mountComponent(Timeline, {})
await nextTick()
const lane = handle.host.querySelector('.audio-track .audio-lane') as HTMLElement
const { timeToX } = stubAudioLane(lane)
// Library dragstart would populate the shared source ref; simulate it.
setAudioDragSource({ duration: asset.duration, name: asset.name })
try {
const dt = new DataTransfer()
dt.setData('application/x-animatr-audio-asset', asset.id)
const over = new DragEvent('dragover', {
bubbles: true, cancelable: true, dataTransfer: dt,
clientX: timeToX(5, s.scene.duration), clientY: 10,
})
lane.dispatchEvent(over)
await nextTick()
const ghost = handle.host.querySelector('.audio-clip-ghost') as HTMLElement
expect(ghost).toBeTruthy()
// The ghost width reflects the asset duration (3s * pps).
const expectedWidthPx = parseFloat(ghost.style.width)
expect(expectedWidthPx).toBeGreaterThan(0)
// And dragleave wipes it.
lane.dispatchEvent(new DragEvent('dragleave', { bubbles: true, cancelable: true, dataTransfer: dt }))
await nextTick()
expect(handle.host.querySelector('.audio-clip-ghost')).toBeNull()
void track
} finally {
clearAudioDragSource()
handle.unmount()
}
})
test('dragging over a different track renders an .audio-clip-ghost preview on that lane', async () => {
const s = makeFreshStore()
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 2 })
const t1 = s.addAudioTrack('one')
const t2 = s.addAudioTrack('two')
s.addAudioClip(asset.id, 1, t1.id)
const handle = mountComponent(Timeline, {})
await nextTick()
const block = handle.host.querySelector(
`.audio-track[data-audio-track-id="${t1.id}"] .audio-clip-block`,
) as HTMLElement
const targetLane = handle.host.querySelector(
`.audio-track[data-audio-track-id="${t2.id}"]`,
) as HTMLElement
expect(block).toBeTruthy()
expect(targetLane).toBeTruthy()
const origFromPoint = document.elementFromPoint.bind(document)
;(document as unknown as { elementFromPoint: (x: number, y: number) => Element | null }).elementFromPoint = (
_x,
y: number,
) => (y === 42424242 ? targetLane : origFromPoint(_x, y))
const captured = new Set<number>()
;(block as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture = (id) => {
captured.add(id)
}
;(block as unknown as { releasePointerCapture: (id: number) => void }).releasePointerCapture = (id) => {
captured.delete(id)
}
;(block as unknown as { hasPointerCapture: (id: number) => boolean }).hasPointerCapture = (id) => captured.has(id)
try {
block.dispatchEvent(
new PointerEvent('pointerdown', { bubbles: true, cancelable: true, button: 0, pointerId: 1, clientX: 100, clientY: 10 }),
)
block.dispatchEvent(
new PointerEvent('pointermove', { bubbles: true, cancelable: true, pointerId: 1, clientX: 110, clientY: 42424242 }),
)
await nextTick()
// The ghost materialises inside t2's lane, NOT t1's.
const ghostInT2 = handle.host.querySelector(
`.audio-track[data-audio-track-id="${t2.id}"] .audio-clip-ghost`,
)
const ghostInT1 = handle.host.querySelector(
`.audio-track[data-audio-track-id="${t1.id}"] .audio-clip-ghost`,
)
expect(ghostInT2).toBeTruthy()
expect(ghostInT1).toBeNull()
// Release disposes the ghost either way.
block.dispatchEvent(
new PointerEvent('pointerup', { bubbles: true, cancelable: true, pointerId: 1, clientX: 110, clientY: 42424242 }),
)
await nextTick()
expect(handle.host.querySelector('.audio-clip-ghost')).toBeNull()
} finally {
;(document as unknown as { elementFromPoint: (x: number, y: number) => Element | null }).elementFromPoint =
origFromPoint
handle.unmount()
}
})
test('with only one audio track, pointerup cannot switch the track', async () => {
const s = makeFreshStore()
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 2 })
const only = s.addAudioTrack()
const clip = s.addAudioClip(asset.id, 1, only.id)!
const handle = mountComponent(Timeline, {})
await nextTick()
const block = handle.host.querySelector('.audio-track .audio-clip-block') as HTMLElement
const captured = new Set<number>()
;(block as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture = (id) => {
captured.add(id)
}
;(block as unknown as { releasePointerCapture: (id: number) => void }).releasePointerCapture = (id) => {
captured.delete(id)
}
;(block as unknown as { hasPointerCapture: (id: number) => boolean }).hasPointerCapture = (id) => captured.has(id)
// Drag horizontally + release anywhere (even way off-lane). The
// clip must stay on its only track — no neighbours to swap to.
block.dispatchEvent(
new PointerEvent('pointerdown', { bubbles: true, cancelable: true, button: 0, pointerId: 1, clientX: 100, clientY: 10 }),
)
block.dispatchEvent(
new PointerEvent('pointermove', { bubbles: true, cancelable: true, pointerId: 1, clientX: 140, clientY: 9999 }),
)
block.dispatchEvent(
new PointerEvent('pointerup', { bubbles: true, cancelable: true, pointerId: 1, clientX: 140, clientY: 9999 }),
)
await nextTick()
expect(clip.audioTrackId).toBe(only.id)
handle.unmount()
})
test('dragging a clip into a neighbour pushes the neighbour forward (cascade)', async () => {
// Two clips on the same track: A at t=5 (length 2, 5..7),
// B at t=0 (length 2, 0..2). Drag B rightward past A — A must
// shift forward so the two don't overlap.
const s = makeFreshStore()
s.setDuration(30)
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 2 })
const track = s.addAudioTrack()
const b = s.addAudioClip(asset.id, 0, track.id)! // B at 0..2
const a = s.addAudioClip(asset.id, 5, track.id)! // A at 5..7
const handle = mountComponent(Timeline, {})
await nextTick()
const blocks = handle.host.querySelectorAll('.audio-track .audio-clip-block') as NodeListOf<HTMLElement>
expect(blocks.length).toBe(2)
const blockB = blocks[0] // rendered in insertion order; B was added first
const captured = new Set<number>()
;(blockB as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture = (id) => {
captured.add(id)
}
;(blockB as unknown as { releasePointerCapture: (id: number) => void }).releasePointerCapture = (id) => {
captured.delete(id)
}
;(blockB as unknown as { hasPointerCapture: (id: number) => boolean }).hasPointerCapture = (id) => captured.has(id)
// A (second block) sits at startTime=5, its style.left = pps*5.
// Read pps back from that so the drag delta we synthesise is in
// timeline-seconds, not pixels.
const pps = parseFloat(blocks[1].style.left) / 5
const startX = 500
blockB.dispatchEvent(
new PointerEvent('pointerdown', { bubbles: true, cancelable: true, button: 0, pointerId: 1, clientX: startX }),
)
// Drag right by 5s → B wants startTime=5, which overlaps A.
// Cascade pushes A from 5 to 5+2=7.
blockB.dispatchEvent(
new PointerEvent('pointermove', { bubbles: true, cancelable: true, pointerId: 1, clientX: startX + 5 * pps }),
)
blockB.dispatchEvent(
new PointerEvent('pointerup', { bubbles: true, cancelable: true, pointerId: 1, clientX: startX + 5 * pps }),
)
await nextTick()
expect(Math.abs(b.startTime - 5) < 0.1).toBe(true)
expect(Math.abs(a.startTime - 7) < 0.1).toBe(true)
handle.unmount()
})
// Helper: pin the audio-lane's bounding rect to a known shape AND
// compute clientX values from the live `scene.duration` so our t
// targets are immune to whatever `duration.value` resolves to in
// the test harness. pixelTimeFromEvent uses `ratio * duration`, so
// clientX = (targetT / duration) * width gives `t ≈ targetT`.
function stubAudioLane(
lane: HTMLElement,
width = 1000,
): { timeToX: (t: number, duration: number) => number } {
const fakeRect = {
left: 0, top: 0, width, height: 20,
right: width, bottom: 20, x: 0, y: 0, toJSON: () => ({}),
} as DOMRect
;(lane as unknown as { getBoundingClientRect: () => DOMRect }).getBoundingClientRect = () =>
fakeRect
return {
timeToX: (t, duration) => Math.round((t / duration) * width),
}
}
test('drop on a free spot adds a new clip to the lane', async () => {
const s = makeFreshStore()
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 2 })
const track = s.addAudioTrack()
s.addAudioClip(asset.id, 0, track.id) // occupies t=0..2
expect(s.activeScene.audioClips).toHaveLength(1)
const handle = mountComponent(Timeline, {})
await nextTick()
const lane = handle.host.querySelector('.audio-track .audio-lane') as HTMLElement
const { timeToX } = stubAudioLane(lane)
const dt = new DataTransfer()
dt.setData('application/x-animatr-audio-asset', asset.id)
const drop = new DragEvent('drop', {
bubbles: true, cancelable: true, dataTransfer: dt,
clientX: timeToX(5, s.scene.duration), clientY: 10,
})
lane.dispatchEvent(drop)
await nextTick()
expect(s.activeScene.audioClips).toHaveLength(2)
const newClip = s.activeScene.audioClips[1]
expect(Math.abs(newClip.startTime - 5) < 0.1).toBe(true)
handle.unmount()
})
test('drop that would overlap an existing clip is silently refused', async () => {
const s = makeFreshStore()
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 3 })
const track = s.addAudioTrack()
s.addAudioClip(asset.id, 1, track.id) // occupies t=1..4
expect(s.activeScene.audioClips).toHaveLength(1)
const handle = mountComponent(Timeline, {})
await nextTick()
const lane = handle.host.querySelector('.audio-track .audio-lane') as HTMLElement
const { timeToX } = stubAudioLane(lane)
const dt = new DataTransfer()
dt.setData('application/x-animatr-audio-asset', asset.id)
// t=2 lands in the middle of the existing clip.
const drop = new DragEvent('drop', {
bubbles: true, cancelable: true, dataTransfer: dt,
clientX: timeToX(2, s.scene.duration), clientY: 10,
})
lane.dispatchEvent(drop)
await nextTick()
expect(s.activeScene.audioClips).toHaveLength(1)
handle.unmount()
})
})
suite('ui — CanvasStage context menu', () => {
// mountComponent doesn't install VueKonva, so the `<v-stage>` tree
// renders as unknown elements. That's fine for these tests — we
// only care about the host `.stage-container`, the contextmenu
// handler on it, and the menu overlay. Konva canvas rendering is
// out of scope here.
function dispatchContextMenu(el: Element, x = 50, y = 50) {
el.dispatchEvent(
new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
clientX: x,
clientY: y,
}),
)
}
test('right-click opens a menu with two actions', async () => {
makeFreshStore()
const { useDocumentDrawer } = await import(
'@/composables/useDocumentDrawer'
)
useDocumentDrawer().closeDrawer()
const handle = mountComponent(CanvasStage, {})
await nextTick()
const container = handle.host.querySelector(
'.stage-container',
) as HTMLElement
expect(container).toBeTruthy()
expect(handle.host.querySelector('.stage-menu')).toBeNull()
dispatchContextMenu(container, 120, 80)
await nextTick()
const menu = handle.host.querySelector('.stage-menu') as HTMLElement
expect(menu).toBeTruthy()
const items = menu.querySelectorAll('.stage-menu-item')
expect(items.length).toBe(2)
// Positioned at the cursor via fixed coordinates.
expect(menu.style.left).toBe('120px')
expect(menu.style.top).toBe('80px')
handle.unmount()
})
test('clicking Document settings opens the document drawer', async () => {
makeFreshStore()
const { useDocumentDrawer } = await import(
'@/composables/useDocumentDrawer'
)
const dd = useDocumentDrawer()
dd.closeDrawer()
expect(dd.open.value).toBe(false)
const handle = mountComponent(CanvasStage, {})
await nextTick()
dispatchContextMenu(
handle.host.querySelector('.stage-container') as HTMLElement,
)
await nextTick()
const firstItem = handle.host.querySelector(
'.stage-menu .stage-menu-item',
) as HTMLButtonElement
expect(firstItem.textContent?.trim()).toBe(
i18nT('canvas.menu.documentSettings'),
)
firstItem.click()
await nextTick()
expect(dd.open.value).toBe(true)
// Menu dismissed on click.
expect(handle.host.querySelector('.stage-menu')).toBeNull()
dd.closeDrawer()
handle.unmount()
})
test('clicking Resize stage closes the menu and switches to resize mode', async () => {
makeFreshStore()
const handle = mountComponent(CanvasStage, {})
await nextTick()
dispatchContextMenu(
handle.host.querySelector('.stage-container') as HTMLElement,
)
await nextTick()
const items = handle.host.querySelectorAll(
'.stage-menu .stage-menu-item',
) as NodeListOf<HTMLButtonElement>
const resize = items[1]
expect(resize.textContent?.trim()).toBe(
i18nT('canvas.menu.stageResize'),
)
resize.click()
await nextTick()
expect(handle.host.querySelector('.stage-menu')).toBeNull()
expect(handle.host.querySelectorAll('.stage-resize-handle').length).toBe(8)
// Clean up so resize mode doesn't leak into following tests.
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
)
await nextTick()
handle.unmount()
})
test('ESC dismisses the menu', async () => {
makeFreshStore()
const handle = mountComponent(CanvasStage, {})
await nextTick()
dispatchContextMenu(
handle.host.querySelector('.stage-container') as HTMLElement,
)
await nextTick()
expect(handle.host.querySelector('.stage-menu')).toBeTruthy()
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
)
await nextTick()
expect(handle.host.querySelector('.stage-menu')).toBeNull()
handle.unmount()
})
test('pointerdown outside the menu dismisses it', async () => {
makeFreshStore()
const handle = mountComponent(CanvasStage, {})
await nextTick()
dispatchContextMenu(
handle.host.querySelector('.stage-container') as HTMLElement,
)
await nextTick()
expect(handle.host.querySelector('.stage-menu')).toBeTruthy()
// Click somewhere outside the menu — `document.body` will do.
document.body.dispatchEvent(
new PointerEvent('pointerdown', { bubbles: true }),
)
await nextTick()
expect(handle.host.querySelector('.stage-menu')).toBeNull()
handle.unmount()
})
test('clicking Resize stage enters resize mode and renders 8 handles', async () => {
makeFreshStore()
const handle = mountComponent(CanvasStage, {})
await nextTick()
dispatchContextMenu(
handle.host.querySelector('.stage-container') as HTMLElement,
)
await nextTick()
const items = handle.host.querySelectorAll(
'.stage-menu .stage-menu-item',
) as NodeListOf<HTMLButtonElement>
items[1].click()
await nextTick()
// Menu is gone, handles appear.
expect(handle.host.querySelector('.stage-menu')).toBeNull()
const handles = handle.host.querySelectorAll('.stage-resize-handle')
expect(handles.length).toBe(8)
const dirs = Array.from(handles).map((h) =>
Array.from(h.classList).find((c) => c.startsWith('dir-')),
)
// Every compass direction is present once.
for (const d of ['dir-n', 'dir-ne', 'dir-e', 'dir-se', 'dir-s', 'dir-sw', 'dir-w', 'dir-nw']) {
expect(dirs.includes(d)).toBe(true)
}
// Clean up — ESC exits so the next test doesn't inherit the mode.
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
)
await nextTick()
handle.unmount()
})
test('ESC exits resize mode and hides the handles', async () => {
makeFreshStore()
const handle = mountComponent(CanvasStage, {})
await nextTick()
dispatchContextMenu(
handle.host.querySelector('.stage-container') as HTMLElement,
)
await nextTick()
const resize = handle.host.querySelectorAll(
'.stage-menu .stage-menu-item',
)[1] as HTMLButtonElement
resize.click()
await nextTick()
expect(handle.host.querySelectorAll('.stage-resize-handle').length).toBe(8)
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
)
await nextTick()
expect(handle.host.querySelectorAll('.stage-resize-handle').length).toBe(0)
handle.unmount()
})
test('Done button in the resize banner exits resize mode', async () => {
makeFreshStore()
const handle = mountComponent(CanvasStage, {})
await nextTick()
dispatchContextMenu(
handle.host.querySelector('.stage-container') as HTMLElement,
)
await nextTick()
;(handle.host.querySelectorAll(
'.stage-menu .stage-menu-item',
)[1] as HTMLButtonElement).click()
await nextTick()
const done = handle.host.querySelector(
'.stage-resize-done',
) as HTMLButtonElement
expect(done).toBeTruthy()
done.click()
await nextTick()
expect(handle.host.querySelectorAll('.stage-resize-handle').length).toBe(0)
expect(handle.host.querySelector('.stage-resize-banner')).toBeNull()
handle.unmount()
})
test('resize mode forces crop ON and restores the prior setting on exit', async () => {
// Crop obscures the effect of the drag — new canvas edges only
// read as new when everything outside is clipped. Enter forces
// crop on; exit restores whatever the user had configured before.
makeFreshStore()
const handle = mountComponent(CanvasStage, {})
await nextTick()
const stage = handle.host.querySelector('.stage-container') as HTMLElement
// Starts with the crop button inactive (default false).
const cropBtn = stage.querySelector('.zoom-chip:has(svg), .zoom-btn') // best-effort
// Easier: check the active class on the crop control via its
// tooltip-carrying chip — the aria-label flips between on/off.
function isCroppedActive() {
const btn = stage.querySelector(
`[aria-label="${i18nT('canvas.cropOn')}"]`,
) ||
stage.querySelector(
`[aria-label="${i18nT('canvas.cropOff')}"]`,
)
return btn?.getAttribute('aria-label') === i18nT('canvas.cropOff')
}
const initiallyCropped = isCroppedActive()
// Enter resize mode via context menu.
dispatchContextMenu(stage)
await nextTick()
;(handle.host.querySelectorAll(
'.stage-menu .stage-menu-item',
)[1] as HTMLButtonElement).click()
await nextTick()
// While in resize mode, crop is forced on.
expect(isCroppedActive()).toBe(true)
// Exit via Done.
;(handle.host.querySelector(
'.stage-resize-done',
) as HTMLButtonElement).click()
await nextTick()
// Crop restored to whatever it was before.
expect(isCroppedActive()).toBe(initiallyCropped)
void cropBtn
handle.unmount()
})
})
suite('ui — CanvasStage centre crosshair', () => {
test('HUD has a crosshair toggle that flips the active class', async () => {
makeFreshStore()
const handle = mountComponent(CanvasStage, {})
await nextTick()
const btn = handle.host.querySelector(
'.zoom-controls .center-cross-btn',
) as HTMLButtonElement
expect(btn).toBeTruthy()
expect(btn.classList.contains('active')).toBe(false)
btn.click()
await nextTick()
expect(btn.classList.contains('active')).toBe(true)
btn.click()
await nextTick()
expect(btn.classList.contains('active')).toBe(false)
handle.unmount()
})
})
suite('ui — CanvasStage centre crosshair (Konva)', () => {
// Verify the lines actually render at the correct coordinates in
// Konva when the toggle is on. Requires a proper VueKonva mount.
async function mountCanvasWithKonva() {
const VueKonva = (await import('vue-konva')).default
const host = document.createElement('div')
host.setAttribute('data-test-host', '')
host.style.width = '800px'
host.style.height = '600px'
document.body.appendChild(host)
const app = createApp({ render() { return h(CanvasStage, {}) } })
app.use(VueKonva)
app.directive('tooltip', vTooltip)
app.mount(host)
return {
host, app,
unmount() { app.unmount(); host.remove() },
}
}
async function getKonvaStage() {
const Konva = (await import('konva')).default
return Konva.stages[Konva.stages.length - 1]
}
test('lines span the scene canvas and are off by default', async () => {
const s = makeFreshStore()
// Pin a deterministic canvas so the assertions are exact.
s.scene.canvas = { width: 800, height: 600, background: '#ffffff' }
const handle = await mountCanvasWithKonva()
try {
await nextTick()
await tick(40)
const stage = await getKonvaStage()
// Off by default — no lines from this feature should be in
// the stack. Match by unique dash pattern fed into our
// computed (hard-coded lengths don't appear elsewhere).
const before = stage.find('Line') as unknown as Array<{
listening: () => boolean
}>
const crosshairBefore = before.filter((ln) => ln.listening() === false)
// This is fuzzy — just make sure the suite mounts cleanly.
expect(Array.isArray(crosshairBefore)).toBe(true)
const btn = handle.host.querySelector(
'.zoom-controls .center-cross-btn',
) as HTMLButtonElement
btn.click()
await nextTick()
await tick(20)
const horiz = stage.findOne('Line') as unknown as {
points: () => number[]
} | null
// At least one line exists after the toggle fires; grab all
// lines and confirm the pair running through (w/2, h/2).
const all = stage.find('Line') as unknown as Array<{
points: () => number[]
}>
const hasHorizontal = all.some((ln) => {
const p = ln.points()
return p.length === 4 && p[1] === 300 && p[3] === 300 && p[0] === 0 && p[2] === 800
})
const hasVertical = all.some((ln) => {
const p = ln.points()
return p.length === 4 && p[0] === 400 && p[2] === 400 && p[1] === 0 && p[3] === 600
})
expect(hasHorizontal).toBe(true)
expect(hasVertical).toBe(true)
void horiz
} finally {
handle.unmount()
}
})
test('lines follow the clip canvas when the user enters a clip', async () => {
// The crosshair uses effectiveCanvas, which for a scene (even
// inside enterClip) is the scene canvas. Verify the lines
// still render while clip-edit mode is active so the user has
// the same compose guide inside a clip.
const s = makeFreshStore()
s.scene.canvas = { width: 640, height: 480, background: '#ffffff' }
const clip = s.addClip('CInst')
s.enterClip(clip.id)
const handle = await mountCanvasWithKonva()
try {
await nextTick()
await tick(40)
const btn = handle.host.querySelector(
'.zoom-controls .center-cross-btn',
) as HTMLButtonElement
btn.click()
await nextTick()
await tick(20)
const stage = await getKonvaStage()
const lines = stage.find('Line') as unknown as Array<{
points: () => number[]
}>
const hasHorizontal = lines.some((ln) => {
const p = ln.points()
return p.length === 4 && p[1] === 240 && p[3] === 240 && p[0] === 0 && p[2] === 640
})
const hasVertical = lines.some((ln) => {
const p = ln.points()
return p.length === 4 && p[0] === 320 && p[2] === 320 && p[1] === 0 && p[3] === 480
})
expect(hasHorizontal).toBe(true)
expect(hasVertical).toBe(true)
} finally {
handle.unmount()
}
})
})
suite('ui — CanvasStage clip-instance transform', () => {
// mountComponent skips VueKonva, which is fine for DOM-overlay
// tests but not for clip-instance selection: we need real Konva
// nodes so `attachTransformer` can find #ci-group:<id> and so the
// Konva event bus bubbles mousedown from a child shape up to the
// group's @mousedown handler.
async function mountCanvasWithKonva() {
const VueKonva = (await import('vue-konva')).default
const host = document.createElement('div')
host.setAttribute('data-test-host', '')
host.style.width = '800px'
host.style.height = '600px'
document.body.appendChild(host)
const app = createApp({
render() {
return h(CanvasStage, {})
},
})
app.use(VueKonva)
app.directive('tooltip', vTooltip)
app.mount(host)
return {
host,
app,
unmount() {
app.unmount()
host.remove()
},
}
}
async function getKonvaStage() {
const Konva = (await import('konva')).default
// Last stage = the one just mounted by this test. The app's
// background CanvasStage also registers a stage, so we can't
// just take stages[0].
return Konva.stages[Konva.stages.length - 1]
}
/** Seed a scene with a single clip holding one rect layer, plus
* one instance of that clip on a new clip track. Returns the
* instance + the inner layer id so tests can target them by id. */
function seedClipInstance() {
const s = makeFreshStore()
s.setDuration(10)
const asset = s.addAsset({
kind: 'rect',
name: 'R',
width: 80,
height: 80,
fill: '#f33',
})
const clip = s.addClip('CInst')
clip.duration = 2
s.enterClip(clip.id)
const innerLayer = s.addLayer(asset.id)!
s.exitClip()
const track = s.scene.clipTracks![0]
const ci = s.addClipInstance(clip.id, 0, track.id)!
return { s, clip, ci, innerLayerId: innerLayer.id }
}
test('selectClipInstance attaches the transformer to #ci-group:<id>', async () => {
// Simplest path: programmatic selection must wire the main
// transformer to the clip-instance wrapper group. If this fails
// the transform handles never appear, regardless of how the
// selection got set (mousedown handler, keyboard, etc.).
const { s, ci } = seedClipInstance()
const handle = await mountCanvasWithKonva()
try {
await nextTick()
await tick(40)
s.selectClipInstance(ci.id)
await nextTick()
await tick(40)
const stage = await getKonvaStage()
const groupId = `ci-group:${ci.id}`
const group = stage.findOne(`#${groupId}`)
expect(group).toBeTruthy()
// The main transformer (not the hit-shape one) must now wrap
// the clip-instance group.
const transformers = stage.find('Transformer')
const attachedToGroup = transformers.some((tr) => {
const nodes = (tr as unknown as { nodes: () => unknown[] }).nodes()
return nodes.length === 1 && (nodes[0] as { id: () => string }).id() === groupId
})
expect(attachedToGroup).toBe(true)
} finally {
handle.unmount()
}
})
test('mousedown on a clip-instance child shape selects the instance', async () => {
// Regression guard: after multi-clip-per-track landed nobody was
// checking that the bubbled-up @mousedown on the wrapper group
// still selects the instance. The inner shape must be listening
// so Konva bubbles the event through to the group's handler.
const { s, ci, innerLayerId } = seedClipInstance()
const handle = await mountCanvasWithKonva()
try {
await nextTick()
await tick(40)
const stage = await getKonvaStage()
const inner = stage.findOne(`#ci:${ci.id}:${innerLayerId}`)
expect(inner).toBeTruthy()
;(inner as unknown as {
fire: (type: string, evt: unknown, bubble: boolean) => void
}).fire(
'mousedown',
{ target: inner, type: 'mousedown', evt: { button: 0 } },
true,
)
await nextTick()
expect(s.selectedClipInstanceId).toBe(ci.id)
} finally {
handle.unmount()
}
})
test('transformend on the ci-group writes back via setClipInstanceTransform', async () => {
// End-to-end: the user dragged a handle, Konva applied the new
// scale/rotation to the group node, and on pointerup the
// transformend handler must persist those values onto the
// instance so the stored scene reflects the transform.
const { s, ci } = seedClipInstance()
const handle = await mountCanvasWithKonva()
try {
await nextTick()
await tick(40)
s.selectClipInstance(ci.id)
await nextTick()
await tick(40)
const stage = await getKonvaStage()
const group = stage.findOne(`#ci-group:${ci.id}`) as unknown as {
scaleX: (v?: number) => number
scaleY: (v?: number) => number
rotation: (v?: number) => number
x: (v?: number) => number
y: (v?: number) => number
fire: (type: string, evt: unknown, bubble: boolean) => void
}
expect(group).toBeTruthy()
group.scaleX(2)
group.scaleY(3)
group.rotation(45)
group.x(17)
group.y(-9)
group.fire(
'transformend',
{ target: group, type: 'transformend' },
true,
)
await nextTick()
expect(ci.scaleX).toBe(2)
expect(ci.scaleY).toBe(3)
expect(ci.rotation).toBe(45)
expect(ci.x).toBe(17)
expect(ci.y).toBe(-9)
} finally {
handle.unmount()
}
})
test('ci-group is framed by an invisible ci-frame rect at the clip bounds', async () => {
// The frame rect locks the transformer onto the clip's own
// outer constraints instead of the currently-visible inner
// shape. Its bbox dominates the group's client rect so handles
// stay put even when inner layers animate.
const { s, ci } = seedClipInstance()
const handle = await mountCanvasWithKonva()
try {
await nextTick()
await tick(40)
const stage = await getKonvaStage()
const frame = stage.findOne(`#ci-frame:${ci.id}`) as unknown as {
width: () => number
height: () => number
name: () => string
listening: () => boolean
} | null
expect(frame).toBeTruthy()
expect(frame!.name()).toBe('ci-frame')
expect(frame!.listening()).toBe(true)
expect(frame!.width()).toBeGreaterThan(0)
expect(frame!.height()).toBeGreaterThan(0)
// The seed clip has a single 80×80 rect, so the frame must be
// at least large enough to enclose it.
expect(frame!.width()).toBeGreaterThan(79)
expect(frame!.height()).toBeGreaterThan(79)
// Sanity: the stage's selection store hasn't flipped just by
// rendering — only an actual click should select.
expect(s.selectedClipInstanceId).toBe(null)
} finally {
handle.unmount()
}
})
test('clicking the ci-frame on an empty-content frame selects the instance', async () => {
// Regression: if the clip has no visible content at the current
// playhead, the old bundle was dropped entirely — no ci-group,
// nothing to click. With the frame rect the bundle always
// renders and empty-frame clips remain selectable.
const s = makeFreshStore()
s.setDuration(10)
const rect = s.addAsset({
kind: 'rect', name: 'R', width: 60, height: 60, fill: '#f00',
})
const clip = s.addClip('Hidden')
clip.duration = 2
s.enterClip(clip.id)
const inner = s.addLayer(rect.id)!
s.exitClip()
// Force the layer to be editorHidden or invisible at playhead 0
// so `items` comes back empty. Flipping `visible` is the
// authoritative hide — stackItems skips `!cl.visible`.
inner.visible = false
const track = s.scene.clipTracks![0]
const ci = s.addClipInstance(clip.id, 0, track.id)!
const handle = await mountCanvasWithKonva()
try {
await nextTick()
await tick(40)
const stage = await getKonvaStage()
const frame = stage.findOne(`#ci-frame:${ci.id}`)
expect(frame).toBeTruthy()
;(frame as unknown as {
fire: (type: string, evt: unknown, bubble: boolean) => void
}).fire(
'mousedown',
{ target: frame, type: 'mousedown', evt: { button: 0 } },
true,
)
await nextTick()
expect(s.selectedClipInstanceId).toBe(ci.id)
} finally {
handle.unmount()
}
})
test('editorHidden group cascades — children vanish from the Konva stack', async () => {
// Regression guard for the cascade: even if the child track's
// own flag is unset, hiding the parent group must drop its
// layers and clip instances from the editor.
const s = makeFreshStore()
s.setDuration(10)
const rect = s.addAsset({
kind: 'rect', name: 'R', width: 40, height: 40, fill: '#f00',
})
const layer = s.addLayer(rect.id)!
s.selectLayer(layer.id)
const groupId = s.groupSelectedLayers('G')!
expect(groupId).toBeTruthy()
s.setGroupEditorHidden(groupId, true)
const handle = await mountCanvasWithKonva()
try {
await nextTick()
await tick(40)
const stage = await getKonvaStage()
// The child layer's Konva node carries the layer id directly.
expect(stage.findOne(`#${layer.id}`)).toBeFalsy()
} finally {
handle.unmount()
}
})
test('locked clip track → ci-group is non-listening + non-draggable', async () => {
// Regression guard: before this, a locked clip track still
// rendered an interactive ci-group. Clicking its shapes would
// select and drag the instance as if nothing were locked. The
// group's `listening: false` cascades to the frame rect and
// every child, so one flag covers select / drag / transform.
const { s, ci } = seedClipInstance()
const track = s.scene.clipTracks![0]
s.setTrackLocked(track.id, true)
const handle = await mountCanvasWithKonva()
try {
await nextTick()
await tick(40)
const stage = await getKonvaStage()
const group = stage.findOne(`#ci-group:${ci.id}`) as unknown as {
listening: () => boolean
draggable: () => boolean
} | null
expect(group).toBeTruthy()
expect(group!.listening()).toBe(false)
expect(group!.draggable()).toBe(false)
} finally {
handle.unmount()
}
})
test('editorHidden clip track drops its instances from the Konva stack', async () => {
// Matches the layer editor-hide semantics: the track stays in
// the scene file (so exports still see it) but no ci-group or
// ci-frame is rendered in the editor.
const { s, ci } = seedClipInstance()
const track = s.scene.clipTracks![0]
s.setClipTrackEditorHidden(track.id, true)
const handle = await mountCanvasWithKonva()
try {
await nextTick()
await tick(40)
const stage = await getKonvaStage()
expect(stage.findOne(`#ci-group:${ci.id}`)).toBeFalsy()
expect(stage.findOne(`#ci-frame:${ci.id}`)).toBeFalsy()
} finally {
handle.unmount()
}
})
test('second clip instance on the same track is independently selectable', async () => {
// The user's own hunch points at multi-clip-per-track: when a
// track carries two instances, picking the second one must
// target that instance, not the first. Guards against a bundle
// / key collision that would route both clicks to the same
// group.
const { s, clip } = seedClipInstance()
const track = s.scene.clipTracks![0]
// Put the second instance at the same startTime so both are in
// range at the default playhead of 0 and both ci-groups are in
// the Konva stack.
const ci2 = s.addClipInstance(clip.id, 0, track.id)!
const handle = await mountCanvasWithKonva()
try {
await nextTick()
await tick(40)
const stage = await getKonvaStage()
const group2 = stage.findOne(`#ci-group:${ci2.id}`)
expect(group2).toBeTruthy()
;(group2 as unknown as {
fire: (type: string, evt: unknown, bubble: boolean) => void
}).fire(
'mousedown',
{ target: group2, type: 'mousedown', evt: { button: 0 } },
true,
)
await nextTick()
expect(s.selectedClipInstanceId).toBe(ci2.id)
} finally {
handle.unmount()
}
})
})
suite('ui — AssetSidebar audio section', () => {
test('lists audio assets with a duration label under the Audio heading', async () => {
const s = makeFreshStore()
s.addAudioAsset({ name: 'intro', src: 'data:audio/mp3;x', duration: 2.75 })
const handle = mountComponent(AssetSidebar, {})
await nextTick()
const audioRows = handle.host.querySelectorAll('.audio-asset')
expect(audioRows.length).toBe(1)
const sub = audioRows[0].querySelector('.clip-sub')
expect(sub?.textContent?.trim()).toBe('2.75s')
handle.unmount()
})
test('empty state shows the "no audio imported" placeholder', async () => {
makeFreshStore()
const handle = mountComponent(AssetSidebar, {})
await nextTick()
const empties = Array.from(handle.host.querySelectorAll('.empty')).map((el) =>
el.textContent?.trim(),
)
expect(empties.some((txt) => (txt ?? '').toLowerCase().includes('audio'))).toBe(true)
handle.unmount()
})
test('sections are expanded by default and collapse on header click', async () => {
try {
localStorage.removeItem('animatr:assetSectionsCollapsed')
} catch {
/* ignore */
}
const s = makeFreshStore()
s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 1 })
const handle = mountComponent(AssetSidebar, {})
await nextTick()
// All four sections present in the header order.
const titles = Array.from(handle.host.querySelectorAll('.section-head-row .section-title'))
.map((el) => el.textContent?.trim())
expect(titles).toEqual(['Images', 'Clips', 'Buttons', 'Audio'])
// Default: audio list is visible.
const audioList = handle.host.querySelector('.audio-asset')
expect(audioList).toBeTruthy()
// Click the Audio section header → list hidden.
const audioHead = Array.from(handle.host.querySelectorAll('.section-head-row .section-head'))
.find((el) => el.querySelector('.section-title')?.textContent?.trim() === 'Audio') as HTMLButtonElement
expect(audioHead).toBeTruthy()
audioHead.click()
await nextTick()
const audioRowAfter = handle.host.querySelector('.audio-asset') as HTMLElement | null
expect(audioRowAfter).toBeTruthy()
expect(audioRowAfter!.offsetParent).toBeNull()
audioHead.click()
await nextTick()
expect((handle.host.querySelector('.audio-asset') as HTMLElement).offsetParent !== null).toBe(true)
handle.unmount()
})
test('section count badge is the last element in every section row (right-aligned)', async () => {
// Both actionless rows (Images / Audio) and action-bearing rows
// (Clips / Buttons) must end with their count badge so the right
// column stays visually aligned across the whole sidebar.
try {
localStorage.removeItem('animatr:assetSectionsCollapsed')
} catch {
/* ignore */
}
makeFreshStore()
const handle = mountComponent(AssetSidebar, {})
await nextTick()
const rows = handle.host.querySelectorAll('.section-head-row')
expect(rows.length).toBe(4)
for (const row of Array.from(rows)) {
const last = row.lastElementChild as HTMLElement
expect(last.classList.contains('section-count')).toBe(true)
}
handle.unmount()
})
test('Library label is gone; "Thumbs" slide switch sits in the Assets header', async () => {
makeFreshStore()
const handle = mountComponent(AssetSidebar, {})
await nextTick()
// Nothing in the sidebar still says "Library".
expect(handle.host.textContent?.includes('Library')).toBe(false)
// The slide switch lives in `.assets-header` next to the <h3> —
// pin that structure so the layout doesn't drift back.
const header = handle.host.querySelector('.assets-header')
expect(header).toBeTruthy()
const h3 = header!.querySelector('h3')
expect(h3?.textContent?.trim()).toBe('Assets')
const label = header!.querySelector('.thumbs-switch .thumbs-switch-label')
expect(label?.textContent?.trim()).toBe('Thumbs')
// And the underlying checkbox input toggles showThumbs.
const input = header!.querySelector('.thumbs-switch-input') as HTMLInputElement
expect(input).toBeTruthy()
const initial = input.checked
input.checked = !initial
input.dispatchEvent(new Event('change', { bubbles: true }))
await nextTick()
// Re-read from a fresh query — the binding updates the checkbox
// via its `:checked` attribute on re-render.
const input2 = handle.host.querySelector('.thumbs-switch-input') as HTMLInputElement
expect(input2.checked).toBe(!initial)
handle.unmount()
})
test('dragstart on an audio row sets the audio-asset DnD payload', async () => {
const s = makeFreshStore()
const asset = s.addAudioAsset({ name: 'beep', src: 'data:audio/mp3;x', duration: 1 })
const handle = mountComponent(AssetSidebar, {})
await nextTick()
const row = handle.host.querySelector('.audio-asset') as HTMLElement
expect(row).toBeTruthy()
const dt = new DataTransfer()
const ev = new DragEvent('dragstart', { bubbles: true, cancelable: true, dataTransfer: dt })
row.dispatchEvent(ev)
expect(dt.getData('application/x-animatr-audio-asset')).toBe(asset.id)
handle.unmount()
})
test('dragover with OS files toggles the .drop-hot outline on the panel', async () => {
makeFreshStore()
const handle = mountComponent(AssetSidebar, {})
await nextTick()
const panel = handle.host.querySelector('.panel') as HTMLElement
expect(panel.classList.contains('drop-hot')).toBe(false)
const dt = new DataTransfer()
// `Files` is the well-known type DataTransfer.types reports for
// OS-initiated file drags. No need to actually attach a file for
// the dragover — the handler only sniffs `types`.
dt.items.add(new File(['x'], 'mock.mp3', { type: 'audio/mpeg' }))
const over = new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer: dt })
panel.dispatchEvent(over)
await nextTick()
expect(panel.classList.contains('drop-hot')).toBe(true)
const leave = new DragEvent('dragleave', { bubbles: true, cancelable: true, dataTransfer: dt })
panel.dispatchEvent(leave)
await nextTick()
expect(panel.classList.contains('drop-hot')).toBe(false)
handle.unmount()
})
})
suite('polygon — vertex bezier actions', () => {
function makeSquare() {
const s = makeFreshStore()
const asset = s.addAsset({
kind: 'polygon',
name: 'Sq',
points: [
{ x: -40, y: -40 },
{ x: 40, y: -40 },
{ x: 40, y: 40 },
{ x: -40, y: 40 },
],
fill: '#abc',
}) as PolygonAsset
const layer = s.addLayer(asset.id)!
return { s, asset, layer }
}
test('setPolygonVertexType curve sets type + default tangents', () => {
const { s, asset } = makeSquare()
s.setPolygonVertexType(asset.id, 1, 'curve')
const p = asset.points[1]
expect(p.type).toBe('curve')
expect(typeof p.outX).toBe('number')
expect(typeof p.outY).toBe('number')
expect(typeof p.inX).toBe('number')
expect(typeof p.inY).toBe('number')
// Curve symmetry: in = -out
expect(Math.abs((p.inX ?? 0) + (p.outX ?? 0)) < 1e-6).toBe(true)
expect(Math.abs((p.inY ?? 0) + (p.outY ?? 0)) < 1e-6).toBe(true)
})
test('setPolygonVertexType corner clears tangent fields', () => {
const { s, asset } = makeSquare()
s.setPolygonVertexType(asset.id, 2, 'curve')
s.setPolygonVertexType(asset.id, 2, 'corner')
const p = asset.points[2]
expect(p.type).toBe(undefined)
expect(p.inX).toBe(undefined)
expect(p.inY).toBe(undefined)
expect(p.outX).toBe(undefined)
expect(p.outY).toBe(undefined)
})
test('setPolygonVertexType accepts a list of indices', () => {
const { s, asset } = makeSquare()
s.setPolygonVertexType(asset.id, [0, 2], 'splitcurve')
expect(asset.points[0].type).toBe('splitcurve')
expect(asset.points[1].type).toBe(undefined)
expect(asset.points[2].type).toBe('splitcurve')
expect(asset.points[3].type).toBe(undefined)
})
test('setPolygonVertexTangent writes per-keyframe (curve mirrors sides)', async () => {
const { s, asset, layer } = makeSquare()
s.setPolygonVertexType(asset.id, 1, 'curve')
s.setPlayhead(0.5)
s.setPolygonVertexTangent(asset.id, 1, 'out', 20, 10)
await tick()
const kf = layer.keyframes.find((k) => Math.abs(k.time - 0.5) < 1e-3)!
const pt = kf.props.points![1]
expect(pt.outX).toBe(20)
expect(pt.outY).toBe(10)
expect(pt.inX).toBe(-20)
expect(pt.inY).toBe(-10)
})
test('setPolygonVertexTangent splitcurve keeps sides independent', async () => {
const { s, asset, layer } = makeSquare()
s.setPolygonVertexType(asset.id, 1, 'splitcurve')
s.setPlayhead(0)
s.setPolygonVertexTangent(asset.id, 1, 'out', 10, 0)
s.setPolygonVertexTangent(asset.id, 1, 'in', -3, 4)
await tick()
const kf = layer.keyframes.find((k) => Math.abs(k.time) < 1e-3)!
const pt = kf.props.points![1]
expect(pt.outX).toBe(10)
expect(pt.outY).toBe(0)
expect(pt.inX).toBe(-3)
expect(pt.inY).toBe(4)
})
test('tangent interpolation lerps between keyframes', async () => {
const { s, asset, layer } = makeSquare()
s.setPolygonVertexType(asset.id, 1, 'splitcurve')
s.setPlayhead(0)
s.setPolygonVertexTangent(asset.id, 1, 'out', 10, 0)
s.setPlayhead(1)
s.setPolygonVertexTangent(asset.id, 1, 'out', 30, 0)
await tick()
const mid = sampleLayer(layer, 0.5)
expect(Math.abs((mid.points?.[1].outX ?? 0) - 20) < 1e-6).toBe(true)
})
test('setPolygonVertexPositions updates multiple vertices in a single kf', async () => {
const { s, asset, layer } = makeSquare()
s.setPlayhead(0.2)
s.setPolygonVertexPositions(layer.id, [
{ index: 0, x: 100, y: 100 },
{ index: 2, x: -100, y: -100 },
])
await tick()
const kf = layer.keyframes.find((k) => Math.abs(k.time - 0.2) < 1e-3)!
expect(kf.props.points![0].x).toBe(100)
expect(kf.props.points![0].y).toBe(100)
expect(kf.props.points![2].x).toBe(-100)
expect(kf.props.points![2].y).toBe(-100)
// Others unchanged
expect(kf.props.points![1].x).toBe(asset.points[1].x)
expect(kf.props.points![1].y).toBe(asset.points[1].y)
})
test('setPolygonVertexPositions ignores out-of-range indices', async () => {
const { s, layer } = makeSquare()
s.setPlayhead(0)
s.setPolygonVertexPositions(layer.id, [
{ index: 99, x: 0, y: 0 },
{ index: -1, x: 0, y: 0 },
])
await tick()
// No keyframe was created or changed at playhead 0 beyond the initial one
expect(layer.keyframes.length).toBe(1)
})
})
suite('properties — fill/stroke transparent toggle', () => {
function findAlphaToggles(host: HTMLElement) {
return Array.from(
host.querySelectorAll<HTMLButtonElement>('button.alpha-toggle-btn'),
)
}
function resetSectionPrefs() {
// Ensure the Appearance section is open (default) regardless of any
// previous session's state persisted in localStorage.
try {
localStorage.removeItem('animatr:propSections')
} catch {
/* noop */
}
}
test('fill: toggle transparent on writes "transparent" to keyframe', async () => {
resetSectionPrefs()
const s = makeFreshStore()
const a = s.addAsset({
kind: 'rect',
name: 'R',
width: 50,
height: 50,
fill: '#ff0000',
})
const l = s.addLayer(a.id)!
s.selectLayer(l.id)
const handle = mountComponent(PropertiesPanel, {})
await nextTick()
await tick()
const toggles = findAlphaToggles(handle.host)
const fillToggle = toggles[0]
expect(!!fillToggle).toBe(true)
fillToggle.click()
await tick()
const kf = l.keyframes[0]
expect(kf.props.fill).toBe('transparent')
handle.unmount()
})
test('fill: toggle off restores the previous non-transparent color', async () => {
resetSectionPrefs()
const s = makeFreshStore()
const a = s.addAsset({
kind: 'rect',
name: 'R',
width: 50,
height: 50,
fill: '#ff0000',
})
const l = s.addLayer(a.id)!
s.selectLayer(l.id)
const handle = mountComponent(PropertiesPanel, {})
await nextTick()
await tick()
const toggles = findAlphaToggles(handle.host)
const fillToggle = toggles[0]
// On
fillToggle.click()
await tick()
expect(l.keyframes[0].props.fill).toBe('transparent')
// Off
fillToggle.click()
await tick()
const restored = l.keyframes[0].props.fill
expect(typeof restored).toBe('string')
expect(restored !== 'transparent').toBe(true)
// Should match the original fill (layer.base.fill was copied from asset)
expect(restored).toBe('#ff0000')
handle.unmount()
})
test('stroke: toggle on then off restores to a visible color (auto-black fallback)', async () => {
resetSectionPrefs()
const s = makeFreshStore()
// Rect without an explicit stroke — the restore should fall back to black
const a = s.addAsset({
kind: 'rect',
name: 'R',
width: 50,
height: 50,
fill: '#123456',
})
const l = s.addLayer(a.id)!
s.selectLayer(l.id)
const handle = mountComponent(PropertiesPanel, {})
await nextTick()
await tick()
const toggles = findAlphaToggles(handle.host)
const strokeToggle = toggles[1]
expect(!!strokeToggle).toBe(true)
strokeToggle.click()
await tick()
expect(l.keyframes[0].props.stroke).toBe('transparent')
strokeToggle.click()
await tick()
const restored = l.keyframes[0].props.stroke
expect(typeof restored).toBe('string')
expect(restored !== 'transparent').toBe(true)
handle.unmount()
})
test('fill: toggle off respects a user-picked color before transparency', async () => {
resetSectionPrefs()
const s = makeFreshStore()
const a = s.addAsset({
kind: 'rect',
name: 'R',
width: 50,
height: 50,
fill: '#6ea8ff',
})
const l = s.addLayer(a.id)!
s.selectLayer(l.id)
// User explicitly picked green before toggling transparent
s.upsertKeyframe(l.id, 0, { fill: '#00ff00' })
const handle = mountComponent(PropertiesPanel, {})
await nextTick()
await tick()
const toggles = findAlphaToggles(handle.host)
const fillToggle = toggles[0]
fillToggle.click()
await tick()
expect(l.keyframes[0].props.fill).toBe('transparent')
fillToggle.click()
await tick()
// Restore should be the user's green, not the asset default blue
expect(l.keyframes[0].props.fill).toBe('#00ff00')
handle.unmount()
})
})
suite('timeline — show locked tracks', () => {
test('toggling showLocked persists to localStorage', async () => {
const { showLocked } = await import('@/composables/useShowLocked')
const KEY = 'animatr:showLockedTracks'
showLocked.value = true
await tick()
expect(localStorage.getItem(KEY)).toBe('1')
showLocked.value = false
await tick()
expect(localStorage.getItem(KEY)).toBe('0')
showLocked.value = true
})
test('showLocked ref is shared across importers', async () => {
const a = await import('@/composables/useShowLocked')
const b = await import('@/composables/useShowLocked')
// Same module → same ref instance
expect(a.showLocked).toBe(b.showLocked)
a.showLocked.value = false
await tick()
expect(b.showLocked.value).toBe(false)
a.showLocked.value = true
})
})
suite('scene store — group lock', () => {
test('setTrackLocked on a group id sets group.locked', () => {
const s = makeFreshStore()
const g = s.addTrackGroup('G')
s.setTrackLocked(g.id, true)
const grp = s.scene.groups!.find((x) => x.id === g.id)!
expect(grp.locked).toBe(true)
})
test('setTrackLocked(false) clears group.locked', () => {
const s = makeFreshStore()
const g = s.addTrackGroup('G')
s.setTrackLocked(g.id, true)
s.setTrackLocked(g.id, false)
const grp = s.scene.groups!.find((x) => x.id === g.id)!
expect(grp.locked).toBe(undefined)
})
test('setTrackLocked dispatches to layer, clip or group based on id', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#111' })
const l = s.addLayer(a.id)!
const ct = s.addClipTrack()
const g = s.addTrackGroup('G')
s.setTrackLocked(l.id, true)
s.setTrackLocked(ct.id, true)
s.setTrackLocked(g.id, true)
expect(l.locked).toBe(true)
const stored = s.scene.clipTracks!.find((x) => x.id === ct.id)!
expect(stored.locked).toBe(true)
const grp = s.scene.groups!.find((x) => x.id === g.id)!
expect(grp.locked).toBe(true)
})
test('locking a group does not mutate child layer.locked', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#111' })
const l = s.addLayer(a.id)!
const g = s.addTrackGroup('G')
s.moveTrackIntoGroup(l.id, g.id)
s.setTrackLocked(g.id, true)
// The layer itself stays unlocked; effective lock is derived in UI code.
expect(l.locked).toBe(undefined)
})
test('locking a layer directly deselects it', () => {
// Regression: locking a layer while it was selected used to leave
// the props panel happily editing the now-inert track. The
// selection must drop on lock so edits can't leak through.
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.selectLayer(l.id)
expect(s.selectedLayerId).toBe(l.id)
s.setTrackLocked(l.id, true)
expect(s.selectedLayerId).toBe(null)
})
test('locking a group deselects a selected child layer', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
const g = s.addTrackGroup('G')
s.moveTrackIntoGroup(l.id, g.id)
s.selectLayer(l.id)
expect(s.selectedLayerId).toBe(l.id)
s.setTrackLocked(g.id, true)
expect(s.selectedLayerId).toBe(null)
})
test('locking a group with no selected child leaves selection alone', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'A', width: 10, height: 10, fill: '#f00' })
const b = s.addAsset({ kind: 'rect', name: 'B', width: 10, height: 10, fill: '#0f0' })
const la = s.addLayer(a.id)!
const lb = s.addLayer(b.id)!
const g = s.addTrackGroup('G')
// Only move la into the group; lb is selected and stays top-level.
s.moveTrackIntoGroup(la.id, g.id)
s.selectLayer(lb.id)
s.setTrackLocked(g.id, true)
// Locking the group can't touch the selection that lives outside it.
expect(s.selectedLayerId).toBe(lb.id)
})
test('unlocking a group does not auto-reselect the previously-selected layer', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
const g = s.addTrackGroup('G')
s.moveTrackIntoGroup(l.id, g.id)
s.selectLayer(l.id)
s.setTrackLocked(g.id, true)
// Now unlocking — selection stays null; user re-selects explicitly.
s.setTrackLocked(g.id, false)
expect(s.selectedLayerId).toBe(null)
})
})
suite('render — crop option', () => {
function setupStraddlingScene() {
const s = makeFreshStore()
s.scene.canvas.width = 100
s.scene.canvas.height = 100
s.scene.canvas.background = 'transparent'
const a = s.addAsset({
kind: 'rect',
name: 'Wide',
width: 60,
height: 20,
fill: '#00ff00',
})
const layer = s.addLayer(a.id)!
// Centered just off the right edge — 40px of the rect spills past x=100.
s.upsertKeyframe(layer.id, 0, { x: 110, y: 50 })
return s
}
test('default crop clips content outside stage bounds', () => {
const s = setupStraddlingScene()
const canvas = document.createElement('canvas')
canvas.width = 200
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, s.scene, 0, {})
// Sample a pixel ~20px past the right edge — still inside the rect's
// visual box but outside the stage. Should be cleared by the clip.
const outside = ctx.getImageData(120, 50, 1, 1).data
expect(outside[3]).toBe(0)
})
test('crop:false lets content render past the stage edge', () => {
const s = setupStraddlingScene()
const canvas = document.createElement('canvas')
canvas.width = 200
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, s.scene, 0, {}, { crop: false })
const outside = ctx.getImageData(120, 50, 1, 1).data
// Green channel visible in the part of the rect that extends past the stage
expect(outside[1] > 200).toBe(true)
expect(outside[3] > 200).toBe(true)
})
test('crop:false still paints stage background inside stage bounds', () => {
const s = makeFreshStore()
s.scene.canvas.width = 100
s.scene.canvas.height = 100
s.scene.canvas.background = '#ff0000'
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, s.scene, 0, {}, { crop: false })
const inside = ctx.getImageData(50, 50, 1, 1).data
expect(inside[0] > 200).toBe(true)
expect(inside[3] > 200).toBe(true)
})
test('mask still applies when crop is disabled', () => {
// Same setup as the positive-mask render test but with crop:false —
// the mask should still clear content outside the mask shape, and the
// crop flag should only affect the stage clip, not the masked group.
const s = makeFreshStore()
s.scene.canvas.width = 100
s.scene.canvas.height = 100
s.scene.canvas.background = 'transparent'
const content = s.addAsset({
kind: 'rect',
name: 'BG',
width: 100,
height: 100,
fill: '#ff0000',
})
const contentLayer = s.addLayer(content.id)!
s.upsertKeyframe(contentLayer.id, 0, { x: 50, y: 50 })
const mask = s.addAsset({
kind: 'circle',
name: 'M',
radius: 20,
fill: '#ffffff',
})
const maskLayer = s.addLayer(mask.id)!
s.upsertKeyframe(maskLayer.id, 0, { x: 50, y: 50 })
s.setLayerMask(maskLayer.id, true, 'positive')
const group = s.addTrackGroup('G')
s.moveTrackIntoGroup(maskLayer.id, group.id)
s.moveTrackIntoGroup(contentLayer.id, group.id)
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, s.scene, 0, {}, { crop: false })
const inside = ctx.getImageData(50, 50, 1, 1).data
const outside = ctx.getImageData(5, 5, 1, 1).data
// Positive mask keeps content inside
expect(inside[0] > 200).toBe(true)
expect(inside[3] > 200).toBe(true)
// Outside mask cleared by the mask (even without stage clipping)
expect(outside[3]).toBe(0)
})
test('content outside the stage renders past the edge when crop:false and mask-like groups exist elsewhere', () => {
// A straddling rect + a totally separate positive-mask group. The
// straddling rect must still bleed past the stage edge and the mask
// must still work inside its own group — they don't interfere.
const s = makeFreshStore()
s.scene.canvas.width = 100
s.scene.canvas.height = 100
s.scene.canvas.background = 'transparent'
// Straddling rect — spills past right edge
const strad = s.addAsset({
kind: 'rect',
name: 'Wide',
width: 60,
height: 20,
fill: '#00ff00',
})
const sLayer = s.addLayer(strad.id)!
s.upsertKeyframe(sLayer.id, 0, { x: 110, y: 50 })
// Masked group elsewhere
const content = s.addAsset({
kind: 'rect',
name: 'Blue',
width: 40,
height: 40,
fill: '#0000ff',
})
const cLayer = s.addLayer(content.id)!
s.upsertKeyframe(cLayer.id, 0, { x: 50, y: 50 })
const mask = s.addAsset({
kind: 'circle',
name: 'M',
radius: 10,
fill: '#fff',
})
const mLayer = s.addLayer(mask.id)!
s.upsertKeyframe(mLayer.id, 0, { x: 50, y: 50 })
s.setLayerMask(mLayer.id, true, 'positive')
const group = s.addTrackGroup('G')
s.moveTrackIntoGroup(mLayer.id, group.id)
s.moveTrackIntoGroup(cLayer.id, group.id)
const canvas = document.createElement('canvas')
canvas.width = 200
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, s.scene, 0, {}, { crop: false })
// Straddling rect's spill past x=100 is visible
const spill = ctx.getImageData(120, 50, 1, 1).data
expect(spill[1] > 200).toBe(true)
expect(spill[3] > 200).toBe(true)
// Mask still trims the blue rect outside its circle.
// (32, 50) is inside the blue rect (30–70) but outside the 10-radius
// mask circle (distance ~18), and well away from the green rect
// (80–140), so it must be cleared by the mask.
const maskOutside = ctx.getImageData(32, 50, 1, 1).data
expect(maskOutside[3]).toBe(0)
})
})
suite('scene store — playback range (in/out)', () => {
test('defaults: playbackIn=0, playbackOut=duration', () => {
const s = makeFreshStore()
expect(s.playbackIn).toBe(0)
expect(s.playbackOut).toBe(s.scene.duration)
expect(s.scene.inTime).toBe(undefined)
expect(s.scene.outTime).toBe(undefined)
})
test('setPlaybackRange clamps values into [0, duration]', () => {
const s = makeFreshStore()
s.setDuration(10)
s.setPlaybackRange(-5, 20)
expect(s.playbackIn).toBe(0)
expect(s.playbackOut).toBe(10)
})
test('setPlaybackRange keeps out >= in', () => {
const s = makeFreshStore()
s.setDuration(10)
s.setPlaybackRange(6, null)
s.setPlaybackRange(null, 3)
// out is clamped up to in (6), not down to 3
expect(s.playbackOut).toBe(6)
})
test('setPlaybackRange clamps playhead into new range', () => {
const s = makeFreshStore()
s.setDuration(10)
s.setPlayhead(1)
s.setPlaybackRange(4, 8)
expect(s.playhead).toBe(4)
s.setPlayhead(9)
// tick isn't involved; setPlayhead itself doesn't clamp to range, so
// push it with setPlaybackRange again:
s.setPlaybackRange(4, 7)
expect(s.playhead).toBe(7)
})
test('setPlaybackRange null for both leaves scene alone', () => {
const s = makeFreshStore()
s.setPlaybackRange(2, 5)
s.setPlaybackRange(null, null)
expect(s.playbackIn).toBe(2)
expect(s.playbackOut).toBe(5)
})
test('togglePlay snaps playhead to playbackIn when outside range', async () => {
const s = makeFreshStore()
s.setDuration(10)
s.setPlaybackRange(3, 7)
s.setPlayhead(1) // before in
await tick()
s.togglePlay()
expect(s.playhead).toBe(3)
expect(s.playing).toBe(true)
s.pause()
await tick()
})
test('tick stops exactly at playbackOut within range', async () => {
const s = makeFreshStore()
s.setDuration(10)
s.setPlaybackRange(2, 5)
s.setPlayhead(2)
s.play()
await tick()
s.tick(4) // would put playhead at 6, past out=5
await tick()
expect(s.playhead).toBe(5)
expect(s.playing).toBe(false)
})
test('setPlaybackRange(0, null) deletes inTime when outTime is unset', () => {
const s = makeFreshStore()
s.setDuration(10)
s.setPlaybackRange(3, null)
expect(s.scene.inTime).toBe(3)
s.setPlaybackRange(0, null)
expect(s.scene.inTime).toBe(undefined)
})
test('setPlaybackRange(null, duration) deletes outTime when inTime is unset', () => {
const s = makeFreshStore()
s.setDuration(10)
s.setPlaybackRange(null, 7)
expect(s.scene.outTime).toBe(7)
s.setPlaybackRange(null, 10)
expect(s.scene.outTime).toBe(undefined)
})
})
suite('scene store — blank scene & normalization', () => {
test('createBlankScene has 1280x720 white bg and 10s duration', () => {
const s = createBlankScene()
expect(s.version).toBe(1)
expect(s.canvas.width).toBe(1280)
expect(s.canvas.height).toBe(720)
expect(s.canvas.background).toBe('#ffffff')
expect(s.duration).toBe(10)
expect(s.fps).toBe(60)
expect(s.assets).toHaveLength(0)
expect(s.layers).toHaveLength(0)
})
test('loadScene(createBlankScene()) resets selection, playhead, playing', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#f00' })
s.addLayer(a.id)!
s.setPlayhead(3)
s.play()
await tick()
s.loadScene(createBlankScene())
expect(s.scene.layers).toHaveLength(0)
expect(s.scene.assets).toHaveLength(0)
expect(s.scene.canvas.background).toBe('#ffffff')
expect(s.playhead).toBe(0)
expect(s.playing).toBe(false)
expect(s.selectedLayerId).toBe(null)
})
test('loadScene fills in canvas.background when missing', () => {
const s = makeFreshStore()
const broken = {
version: 1 as const,
canvas: { width: 640, height: 480, background: '' },
duration: 5,
fps: 30,
assets: [],
layers: [],
}
s.loadScene(broken)
expect(s.scene.canvas.background).toBe('#ffffff')
})
})
suite('scene store — mask defaults', () => {
test('setLayerMask(true) defaults to negative mask mode', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'M', width: 40, height: 40, fill: '#fff' })
const l = s.addLayer(a.id)!
s.setLayerMask(l.id, true)
const layer = s.scene.layers.find((x) => x.id === l.id)!
expect(layer.isMask).toBe(true)
expect(layer.maskMode).toBe('negative')
})
})
suite('render — top-level masks cascade', () => {
test('top-level positive mask clears content outside the mask shape', () => {
const s = makeFreshStore()
s.scene.canvas.width = 100
s.scene.canvas.height = 100
s.scene.canvas.background = 'transparent'
// Full-stage red rect
const content = s.addAsset({
kind: 'rect',
name: 'BG',
width: 100,
height: 100,
fill: '#ff0000',
})
const contentLayer = s.addLayer(content.id)!
s.upsertKeyframe(contentLayer.id, 0, { x: 50, y: 50 })
// Mask layer above it in the stack (top-level, NO group)
const mask = s.addAsset({
kind: 'circle',
name: 'M',
radius: 20,
fill: '#ffffff',
})
const maskLayer = s.addLayer(mask.id)!
s.upsertKeyframe(maskLayer.id, 0, { x: 50, y: 50 })
s.setLayerMask(maskLayer.id, true, 'positive')
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, s.scene, 0, {})
// Inside mask circle → red kept
const inside = ctx.getImageData(50, 50, 1, 1).data
expect(inside[0] > 200).toBe(true)
expect(inside[3] > 200).toBe(true)
// Outside mask circle → cleared
const outside = ctx.getImageData(5, 5, 1, 1).data
expect(outside[3]).toBe(0)
})
test('top-level negative mask cuts a hole in the content below', () => {
const s = makeFreshStore()
s.scene.canvas.width = 100
s.scene.canvas.height = 100
s.scene.canvas.background = 'transparent'
const content = s.addAsset({
kind: 'rect',
name: 'BG',
width: 100,
height: 100,
fill: '#ff0000',
})
const contentLayer = s.addLayer(content.id)!
s.upsertKeyframe(contentLayer.id, 0, { x: 50, y: 50 })
const mask = s.addAsset({
kind: 'circle',
name: 'M',
radius: 15,
fill: '#ffffff',
})
const maskLayer = s.addLayer(mask.id)!
s.upsertKeyframe(maskLayer.id, 0, { x: 50, y: 50 })
s.setLayerMask(maskLayer.id, true, 'negative')
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, s.scene, 0, {})
// Inside the circle → hole (cleared)
const inside = ctx.getImageData(50, 50, 1, 1).data
expect(inside[3]).toBe(0)
// Outside circle → red kept
const outside = ctx.getImageData(5, 5, 1, 1).data
expect(outside[0] > 200).toBe(true)
expect(outside[3] > 200).toBe(true)
})
})
suite('render — grouped clip tracks respect group masks', () => {
// Regression: the render.ts group branch used to extract only
// layer children from group.childIds, dropping clip tracks
// silently. A group containing only clips rendered as empty in
// mask-preview and frame-based exports — even when a SIBLING
// group had a mask, the clip group was invisible too because
// the renderer simply walked past its contents.
function setupWithClipTrackInGroup() {
const s = makeFreshStore()
s.scene.canvas.width = 100
s.scene.canvas.height = 100
s.scene.canvas.background = 'transparent'
const rectAsset = s.addAsset({
kind: 'rect', name: 'R', width: 100, height: 100, fill: '#00ff00',
})
// Clip with a single full-stage green rect, instantiated on a
// clip track tucked inside group A.
const clip = s.addClip('C')
clip.duration = 1
s.enterClip(clip.id)
const inner = s.addLayer(rectAsset.id)!
s.upsertKeyframe(inner.id, 0, { x: 50, y: 50 })
s.exitClip()
const track = s.scene.clipTracks![0]
s.addClipInstance(clip.id, 0, track.id)
// Wrap the track in group A (no mask inside — just content).
const groupA = s.addTrackGroup('A', track.id)
groupA.childIds.push(track.id)
const order = s.scene.trackOrder!
const ti = order.indexOf(track.id)
if (ti >= 0) order.splice(ti, 1)
return { s }
}
test('group containing only clip tracks renders its instance content', () => {
const { s } = setupWithClipTrackInGroup()
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, s.scene, 0, {})
// The clip's green rect should cover the canvas — at least one
// interior pixel is green with full alpha.
const px = ctx.getImageData(50, 50, 1, 1).data
expect(px[1] > 200).toBe(true)
expect(px[3] > 200).toBe(true)
})
test('sibling group with a mask does not erase an unmasked group next to it', () => {
// User-reported scenario: group A (clip) + group G (element B
// + mask C). Before the fix, mask-preview showed nothing for
// A; afterwards, A's content is independent of G's mask.
const { s } = setupWithClipTrackInGroup()
// Group G holds a single red layer + a small positive mask,
// both at the centre. Without the fix A's green would stay
// off-screen because the render path dropped its clip track.
const red = s.addAsset({
kind: 'rect', name: 'B', width: 40, height: 40, fill: '#ff0000',
})
const bLayer = s.addLayer(red.id)!
s.upsertKeyframe(bLayer.id, 0, { x: 50, y: 50 })
const maskAsset = s.addAsset({
kind: 'circle', name: 'M', radius: 10, fill: '#ffffff',
})
const cLayer = s.addLayer(maskAsset.id)!
s.upsertKeyframe(cLayer.id, 0, { x: 50, y: 50 })
s.setLayerMask(cLayer.id, true, 'positive')
// Wrap B + C in a fresh group G. selectLayer + groupSelectedLayers
// groups them together, leaving group A untouched.
s.selectLayer(bLayer.id)
s.toggleLayerSelection(cLayer.id)
const gId = s.groupSelectedLayers('G')!
expect(gId).toBeTruthy()
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, s.scene, 0, {})
// A pixel far from the mask-centre (e.g. corner 10,10) is
// outside G's mask — B would be clipped there. But group A's
// green clip covers the whole canvas and is unaffected by G's
// mask, so the pixel must be green (and opaque).
const corner = ctx.getImageData(10, 10, 1, 1).data
expect(corner[1] > 200).toBe(true)
expect(corner[3] > 200).toBe(true)
})
test('mask inside a group clips clip-track content in that same group', () => {
// The group-local mask still has to apply to clip instances
// that sit alongside it — otherwise grouping a clip with a
// mask would silently render the clip unmasked.
const s = makeFreshStore()
s.scene.canvas.width = 100
s.scene.canvas.height = 100
s.scene.canvas.background = 'transparent'
const rect = s.addAsset({
kind: 'rect', name: 'R', width: 100, height: 100, fill: '#00ff00',
})
const clip = s.addClip('C')
clip.duration = 1
s.enterClip(clip.id)
const inner = s.addLayer(rect.id)!
s.upsertKeyframe(inner.id, 0, { x: 50, y: 50 })
s.exitClip()
const track = s.scene.clipTracks![0]
s.addClipInstance(clip.id, 0, track.id)
// Build a mask circle, then group (clip track + mask) together.
const maskAsset = s.addAsset({
kind: 'circle', name: 'M', radius: 15, fill: '#ffffff',
})
const m = s.addLayer(maskAsset.id)!
s.upsertKeyframe(m.id, 0, { x: 50, y: 50 })
s.setLayerMask(m.id, true, 'positive')
const g = s.addTrackGroup('G', track.id)
g.childIds.push(m.id)
g.childIds.push(track.id)
const order = s.scene.trackOrder!
// Remove the clip track + mask layer from the top-level order
// so only the group sits there.
for (const id of [track.id, m.id]) {
const i = order.indexOf(id)
if (i >= 0) order.splice(i, 1)
}
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, s.scene, 0, {})
// Inside the mask — green kept.
const inside = ctx.getImageData(50, 50, 1, 1).data
expect(inside[1] > 200).toBe(true)
expect(inside[3] > 200).toBe(true)
// Outside the mask — cleared.
const outside = ctx.getImageData(5, 5, 1, 1).data
expect(outside[3]).toBe(0)
})
})
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
})
test('duplicateScriptFrame copies actions + label and steps forward by one offset', () => {
const s = makeFreshStore()
s.setDuration(10)
const t = s.addScriptTrack()
const f = s.addScriptFrame(t.id, 1, [{ type: 'pause' }], 'intro-end')!
const newId = s.duplicateScriptFrame(f.id)
expect(newId).toBeTruthy()
const copy = s.scene.scriptFrames?.find((x) => x.id === newId)
expect(copy).toBeTruthy()
expect(copy!.trackId).toBe(t.id)
expect(copy!.time).toBeGreaterThan(f.time)
expect(copy!.actions).toHaveLength(1)
expect(copy!.actions[0].type).toBe('pause')
expect(copy!.label).toBe('intro-end')
})
test('duplicateScriptFrame hops over occupied slots on the same track', () => {
const s = makeFreshStore()
s.setDuration(10)
const t = s.addScriptTrack()
const fps = s.scene.fps > 0 ? s.scene.fps : 60
const offset = Math.max(3 / fps, 0.1)
const src = s.addScriptFrame(t.id, 1, [{ type: 'pause' }])!
// Pre-populate the first duplicate slot so the store has to step again.
s.addScriptFrame(t.id, 1 + offset, [{ type: 'play' }])
const newId = s.duplicateScriptFrame(src.id)
const copy = s.scene.scriptFrames?.find((x) => x.id === newId)
expect(copy).toBeTruthy()
// Landed beyond the occupied slot.
expect(copy!.time).toBeGreaterThan(1 + offset + 1e-4)
})
test('duplicateScriptFrame returns null for an unknown id', () => {
const s = makeFreshStore()
expect(s.duplicateScriptFrame('nope')).toBeNull()
})
})
suite('scene store — audio model', () => {
function addAsset(s: ReturnType<typeof makeFreshStore>, name = 'beep', duration = 4) {
return s.addAudioAsset({ name, src: 'data:audio/mp3;base64,AAAA', duration })
}
test('addAudioAsset pushes an asset of kind "audio" into the shared library', () => {
const s = makeFreshStore()
const asset = addAsset(s, ' Intro ', 3.5)
expect(asset.kind).toBe('audio')
expect(asset.name).toBe('Intro') // name is trimmed
expect(asset.duration).toBe(3.5)
expect(s.project.assets.some((a) => a.id === asset.id)).toBe(true)
})
test('addAudioAsset clamps a negative duration to 0', () => {
const s = makeFreshStore()
const asset = s.addAudioAsset({ name: 'weird', src: 'x', duration: -5 })
expect(asset.duration).toBe(0)
})
test('addAudioTrack appends and lands in trackOrder', () => {
const s = makeFreshStore()
const t = s.addAudioTrack('Music')
expect(s.activeScene.audioTracks).toHaveLength(1)
expect(s.activeScene.audioTracks[0].name).toBe('Music')
expect(s.activeScene.trackOrder.includes(t.id)).toBe(true)
})
test('removeAudioTrack cascades its clips and trackOrder entry', () => {
const s = makeFreshStore()
const asset = addAsset(s)
const t = s.addAudioTrack()
s.addAudioClip(asset.id, 1, t.id)
s.addAudioClip(asset.id, 3, t.id)
expect(s.activeScene.audioClips).toHaveLength(2)
s.removeAudioTrack(t.id)
expect(s.activeScene.audioTracks).toHaveLength(0)
expect(s.activeScene.audioClips).toHaveLength(0)
expect(s.activeScene.trackOrder.includes(t.id)).toBe(false)
})
test('addAudioClip returns null for unknown asset or unknown track', () => {
const s = makeFreshStore()
const t = s.addAudioTrack()
expect(s.addAudioClip('no-such-asset', 0, t.id)).toBe(null)
const a = addAsset(s)
expect(s.addAudioClip(a.id, 0, 'no-such-track')).toBe(null)
expect(s.activeScene.audioClips).toHaveLength(0)
})
test('addAudioClip refuses to place an image asset as audio (guards kind)', () => {
const s = makeFreshStore()
const t = s.addAudioTrack()
const img = s.addAsset({
kind: 'rect',
name: 'R',
width: 10,
height: 10,
fill: '#000',
})
expect(s.addAudioClip(img.id, 0, t.id)).toBe(null)
})
test('moveAudioClip updates startTime and optional track; negative startTime clamps to 0', () => {
const s = makeFreshStore()
const asset = addAsset(s)
const t1 = s.addAudioTrack('one')
const t2 = s.addAudioTrack('two')
const c = s.addAudioClip(asset.id, 1, t1.id)!
s.moveAudioClip(c.id, -3, t2.id)
expect(c.startTime).toBe(0)
expect(c.audioTrackId).toBe(t2.id)
})
test('trimAudioClip clamps trims so the audible block keeps at least 0.05s', () => {
const s = makeFreshStore()
const asset = addAsset(s, 'x', 4) // asset length 4s
const t = s.addAudioTrack()
const c = s.addAudioClip(asset.id, 0, t.id)!
// Over-ambitious trim from BOTH ends: 3s off the head + 2s off
// the tail on a 4s asset (total 5s, way over the 3.95s budget).
// We don't pin which side absorbs the clamp — only the invariant
// that the audible window stays ≥ MIN_VISIBLE and neither trim
// turns negative.
s.trimAudioClip(c.id, { trimStart: 3, trimEnd: 2 })
const audible = asset.duration - (c.trimStart ?? 0) - (c.trimEnd ?? 0)
expect(audible >= 0.05 - 1e-9).toBe(true)
expect((c.trimStart ?? 0) >= 0).toBe(true)
expect((c.trimEnd ?? 0) >= 0).toBe(true)
})
test('removeAudioClip drops the clip without touching siblings', () => {
const s = makeFreshStore()
const asset = addAsset(s)
const t = s.addAudioTrack()
const a = s.addAudioClip(asset.id, 0, t.id)!
const b = s.addAudioClip(asset.id, 2, t.id)!
s.removeAudioClip(a.id)
expect(s.activeScene.audioClips).toHaveLength(1)
expect(s.activeScene.audioClips[0].id).toBe(b.id)
})
test('removeAsset of an audio asset cascades clips across every scene page', () => {
const s = makeFreshStore()
const asset = addAsset(s)
const firstTrack = s.addAudioTrack()
s.addAudioClip(asset.id, 0, firstTrack.id)
// Add a second scene, and place a clip there too.
const two = s.addScene('Two')
const secondTrack = s.addAudioTrack()
s.addAudioClip(asset.id, 0, secondTrack.id)
expect(s.activeScene.audioClips).toHaveLength(1)
s.removeAsset(asset.id)
// Both pages must have their clips purged, not just the active one.
for (const page of s.project.scenes) {
expect(page.audioClips.filter((c) => c.audioAssetId === asset.id).length).toBe(0)
}
void two
})
test('setAudioTrackMuted flips the muted flag', () => {
const s = makeFreshStore()
const t = s.addAudioTrack()
expect(!!t.muted).toBe(false)
s.setAudioTrackMuted(t.id, true)
expect(t.muted).toBe(true)
s.setAudioTrackMuted(t.id, false)
expect(t.muted).toBe(false)
})
test('duplicateTrack on an audio track clones clips with fresh ids and sits below the original', () => {
const s = makeFreshStore()
const asset = addAsset(s)
const orig = s.addAudioTrack('A')
s.addAudioClip(asset.id, 0, orig.id)
s.addAudioClip(asset.id, 2, orig.id)
const origClipIds = new Set(s.activeScene.audioClips.map((c) => c.id))
const newId = s.duplicateTrack(orig.id)
expect(newId).toBeTruthy()
expect(newId === orig.id).toBe(false)
const order = s.activeScene.trackOrder
expect(order.indexOf(newId!)).toBe(order.indexOf(orig.id) + 1)
const clones = s.activeScene.audioClips.filter((c) => c.audioTrackId === newId)
expect(clones).toHaveLength(2)
for (const c of clones) expect(origClipIds.has(c.id)).toBe(false)
})
test('duplicateScene also clones audio tracks + clips with fresh cross-refs', () => {
const s = makeFreshStore()
const asset = addAsset(s)
const track = s.addAudioTrack('A')
s.addAudioClip(asset.id, 1, track.id)
const origPageId = s.project.activeSceneId
const copy = s.duplicateScene(origPageId)!
// The clone has its own track id and its own clip id, but still
// references the (shared) asset.
expect(copy.audioTracks[0].id === track.id).toBe(false)
expect(copy.audioClips[0].audioTrackId).toBe(copy.audioTracks[0].id)
expect(copy.audioClips[0].audioAssetId).toBe(asset.id)
})
test('setAudioClipPosition cascades pushes on target track, leaves earlier clips alone', () => {
const s = makeFreshStore()
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 2 })
const track = s.addAudioTrack()
const early = s.addAudioClip(asset.id, 0, track.id)! // 0..2
const mid = s.addAudioClip(asset.id, 5, track.id)! // 5..7
const late = s.addAudioClip(asset.id, 8, track.id)! // 8..10
// Move `mid` to startTime=4 — it overlaps nothing before, but
// `late` (at 8) is still OK. Expect `mid` to land at 4 and no
// cascade for `late`.
expect(s.setAudioClipPosition(mid.id, 4)).toBe(true)
expect(mid.startTime).toBe(4)
expect(late.startTime).toBe(8)
expect(early.startTime).toBe(0)
// Now move `early` to startTime=4, which overlaps `mid` at 4..6.
// Cascade: push `mid` to 6, which then overlaps `late` at 8 —
// actually 6+2=8, so late stays at 8 (abutting).
s.setAudioClipPosition(early.id, 4)
expect(early.startTime).toBe(4)
expect(mid.startTime).toBe(6)
expect(late.startTime).toBe(8)
})
test('setAudioClipPosition with audioTrackId swaps the clip to another track', () => {
const s = makeFreshStore()
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 2 })
const t1 = s.addAudioTrack('one')
const t2 = s.addAudioTrack('two')
const clip = s.addAudioClip(asset.id, 1, t1.id)!
s.setAudioClipPosition(clip.id, 3, t2.id)
expect(clip.audioTrackId).toBe(t2.id)
expect(clip.startTime).toBe(3)
})
test('setAudioClipPosition returns false for unknown clip or unknown track', () => {
const s = makeFreshStore()
expect(s.setAudioClipPosition('no-such-clip', 0)).toBe(false)
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 1 })
const track = s.addAudioTrack()
const clip = s.addAudioClip(asset.id, 0, track.id)!
expect(s.setAudioClipPosition(clip.id, 0, 'ghost-track')).toBe(false)
})
test('setAudioClipPosition pushes neighbours backward when moved clip collides on the left', () => {
const s = makeFreshStore()
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 2 })
const track = s.addAudioTrack()
const early = s.addAudioClip(asset.id, 0, track.id)! // 0..2
const mid = s.addAudioClip(asset.id, 3, track.id)! // 3..5
const late = s.addAudioClip(asset.id, 6, track.id)! // 6..8
// Drag `late` back onto `mid`. `mid` must shunt left to 2 (abuts
// `early` end), `early` sits already at 0.
expect(s.setAudioClipPosition(late.id, 4)).toBe(true)
expect(late.startTime).toBe(4)
expect(mid.startTime).toBe(2)
expect(early.startTime).toBe(0)
})
test('setAudioClipPosition clamps moved clip forward when backward cascade hits 0', () => {
const s = makeFreshStore()
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 2 })
const track = s.addAudioTrack()
const a = s.addAudioClip(asset.id, 0, track.id)! // 0..2
const b = s.addAudioClip(asset.id, 2, track.id)! // 2..4
const cc = s.addAudioClip(asset.id, 4, track.id)! // 4..6
// Nothing fits to the left of 4 — drag to 1 clamps cc forward to 4.
expect(s.setAudioClipPosition(cc.id, 1)).toBe(true)
expect(cc.startTime).toBe(4)
expect(a.startTime).toBe(0)
expect(b.startTime).toBe(2)
})
test('duplicateAudioClip clones onto the same track, offset by visible duration', () => {
const s = makeFreshStore()
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 2 })
const track = s.addAudioTrack()
const clip = s.addAudioClip(asset.id, 1, track.id)!
const newId = s.duplicateAudioClip(clip.id)!
expect(typeof newId).toBe('string')
expect(newId === clip.id).toBe(false)
const copy = s.activeScene.audioClips.find((c) => c.id === newId)!
expect(copy.audioAssetId).toBe(asset.id)
expect(copy.audioTrackId).toBe(track.id)
expect(copy.startTime).toBe(1 + 2)
})
test('duplicateAudioClip honours trim when computing the offset', () => {
const s = makeFreshStore()
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 4 })
const track = s.addAudioTrack()
const clip = s.addAudioClip(asset.id, 0, track.id)!
s.trimAudioClip(clip.id, { trimStart: 0.5, trimEnd: 0.5 })
const newId = s.duplicateAudioClip(clip.id)!
const copy = s.activeScene.audioClips.find((c) => c.id === newId)!
// visible length = 4 - 0.5 - 0.5 = 3
expect(copy.startTime).toBe(3)
expect(copy.trimStart).toBe(0.5)
expect(copy.trimEnd).toBe(0.5)
})
test('duplicateAudioClip returns null for unknown id', () => {
const s = makeFreshStore()
expect(s.duplicateAudioClip('nope')).toBe(null)
})
test('normalizeScenePage drops orphan audio clips pointing at a missing track', () => {
const s = makeFreshStore()
const asset = addAsset(s)
const orphan = {
id: 'ghost-clip',
audioAssetId: asset.id,
audioTrackId: 'ghost-track',
startTime: 0,
}
s.activeScene.audioClips.push(orphan)
// Roundtrip through loadProject re-normalises.
s.loadProject(JSON.parse(JSON.stringify(s.project)))
expect(s.activeScene.audioClips.some((c) => c.id === 'ghost-clip')).toBe(false)
})
})
suite('composable — useAudioRuntime (createAudioRuntime)', () => {
interface StubAudio {
src: string
currentTime: number
paused: boolean
preload: string
played: number
paused_count: number
play(): Promise<void>
pause(): void
}
function makeAudioFactory() {
const created: StubAudio[] = []
const factory = () => {
const el: StubAudio = {
src: '',
currentTime: 0,
paused: true,
preload: '',
played: 0,
paused_count: 0,
play() {
this.paused = false
this.played++
return Promise.resolve()
},
pause() {
if (!this.paused) this.paused_count++
this.paused = true
},
}
created.push(el)
return el as unknown as HTMLAudioElement
}
return { factory, created }
}
test('play → the overlapping clip\'s element gets play() and a seek to audioTime', async () => {
const s = makeFreshStore()
s.setDuration(10)
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;X', duration: 5 })
const track = s.addAudioTrack('T')
s.addAudioClip(asset.id, 2, track.id)
const { factory, created } = makeAudioFactory()
const rt = createAudioRuntime(s, { audioFactory: factory })
s.setPlayhead(3) // inside the 2..7 window
s.play()
await tick()
rt.reconcile()
expect(created.length).toBe(1)
const el = created[0]
expect(el.paused).toBe(false)
expect(el.played).toBe(1)
// audioTime = playhead - startTime + trimStart = 3 - 2 + 0 = 1
expect(Math.abs(el.currentTime - 1) < 0.01).toBe(true)
rt.dispose()
})
test('pause → all currently-playing elements get pause()', async () => {
const s = makeFreshStore()
s.setDuration(10)
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;X', duration: 5 })
const track = s.addAudioTrack()
s.addAudioClip(asset.id, 0, track.id)
const { factory, created } = makeAudioFactory()
const rt = createAudioRuntime(s, { audioFactory: factory })
s.play()
await tick()
rt.reconcile()
expect(created[0].paused).toBe(false)
s.pause()
await tick()
expect(created[0].paused).toBe(true)
rt.dispose()
})
test('playhead outside a clip\'s window keeps its element paused', async () => {
const s = makeFreshStore()
s.setDuration(10)
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;X', duration: 3 })
const track = s.addAudioTrack()
s.addAudioClip(asset.id, 4, track.id) // audible 4..7
const { factory, created } = makeAudioFactory()
const rt = createAudioRuntime(s, { audioFactory: factory })
s.setPlayhead(1) // before the clip
s.play()
await tick()
rt.reconcile()
// Element exists (pool seeded) but hasn't been play()'d.
expect(created.length).toBe(1)
expect(created[0].played).toBe(0)
expect(created[0].paused).toBe(true)
rt.dispose()
})
test('muting a track keeps its elements paused even while playing', async () => {
const s = makeFreshStore()
s.setDuration(10)
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;X', duration: 5 })
const track = s.addAudioTrack()
s.addAudioClip(asset.id, 0, track.id)
s.setAudioTrackMuted(track.id, true)
const { factory, created } = makeAudioFactory()
const rt = createAudioRuntime(s, { audioFactory: factory })
s.setPlayhead(1)
s.play()
await tick()
rt.reconcile()
expect(created[0].played).toBe(0)
// Un-mute → element plays on next reconcile.
s.setAudioTrackMuted(track.id, false)
await tick()
rt.reconcile()
expect(created[0].played).toBe(1)
rt.dispose()
})
test('trimStart offsets audioTime by exactly trimStart seconds', async () => {
const s = makeFreshStore()
s.setDuration(10)
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;X', duration: 10 })
const track = s.addAudioTrack()
const clip = s.addAudioClip(asset.id, 0, track.id)!
s.trimAudioClip(clip.id, { trimStart: 2 })
const { factory, created } = makeAudioFactory()
const rt = createAudioRuntime(s, { audioFactory: factory })
s.setPlayhead(1) // 1s into the clip from the timeline's POV
s.play()
await tick()
rt.reconcile()
// audioTime = playhead - startTime + trimStart = 1 - 0 + 2 = 3
expect(Math.abs(created[0].currentTime - 3) < 0.01).toBe(true)
rt.dispose()
})
test('removing a clip evicts its element from the pool', async () => {
const s = makeFreshStore()
s.setDuration(10)
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;X', duration: 5 })
const track = s.addAudioTrack()
const clip = s.addAudioClip(asset.id, 0, track.id)!
const { factory } = makeAudioFactory()
const rt = createAudioRuntime(s, { audioFactory: factory })
s.play()
await tick()
rt.reconcile()
expect(rt.pool.has(clip.id)).toBe(true)
s.removeAudioClip(clip.id)
await tick()
rt.reconcile()
expect(rt.pool.has(clip.id)).toBe(false)
rt.dispose()
})
test('switching scenes drops elements whose clips left with the old page', async () => {
const s = makeFreshStore()
s.setDuration(10)
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;X', duration: 5 })
const trackA = s.addAudioTrack('A')
const clipA = s.addAudioClip(asset.id, 0, trackA.id)!
const { factory } = makeAudioFactory()
const rt = createAudioRuntime(s, { audioFactory: factory })
s.play()
await tick()
rt.reconcile()
expect(rt.pool.has(clipA.id)).toBe(true)
s.addScene('Two') // auto-switches; new page has no audio clips yet
await tick()
rt.reconcile()
expect(rt.pool.has(clipA.id)).toBe(false)
expect(rt.pool.size).toBe(0)
rt.dispose()
})
test('dispose() pauses every element and empties the pool', async () => {
const s = makeFreshStore()
s.setDuration(10)
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;X', duration: 5 })
const track = s.addAudioTrack()
s.addAudioClip(asset.id, 0, track.id)
const { factory, created } = makeAudioFactory()
const rt = createAudioRuntime(s, { audioFactory: factory })
s.play()
await tick()
rt.reconcile()
expect(rt.pool.size).toBe(1)
rt.dispose()
expect(rt.pool.size).toBe(0)
expect(created[0].paused).toBe(true)
})
test('getWaveformSlot returns pending the first time, caches subsequent calls', () => {
clearWaveformCache()
// Un-decodable src — the promise rejects, but the immediate
// synchronous return is what we're testing here.
const slot1 = getWaveformSlot('asset-x', 'not-a-real-url')
expect(slot1?.state).toBe('pending')
// Second call while still pending returns the same pending slot
// (no new decode kicked off).
const slot2 = getWaveformSlot('asset-x', 'not-a-real-url')
expect(slot2).toBe(slot1)
clearWaveformCache()
})
test('slicePeaksForClip returns only the audible range when trimmed', () => {
const full = new Float32Array([0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8])
// Asset is 8s long; trimStart=2 → drop first 25%; trimEnd=2 →
// drop last 25%. Expect roughly the middle half of the peaks.
const slice = slicePeaksForClip(full, 8, 2, 2)
expect(slice.length < full.length).toBe(true)
// Every value in the slice must be within the middle portion.
for (let i = 0; i < slice.length; i++) {
expect(slice[i] >= 0.3 - 1e-6 && slice[i] <= 0.6 + 1e-6).toBe(true)
}
})
test('slicePeaksForClip with no trim returns the whole array', () => {
const full = new Float32Array([0.1, 0.2, 0.3])
const slice = slicePeaksForClip(full, 3, 0, 0)
expect(slice.length).toBe(full.length)
})
test('drawWaveform paints something onto the canvas (no throw on empty)', () => {
const canvas = document.createElement('canvas')
canvas.width = 50
canvas.height = 20
// Non-empty peaks — expect SOME non-zero pixels after drawing.
drawWaveform(canvas, new Float32Array([0.5, 0.8, 0.3]), { pixelRatio: 1 })
const ctx = canvas.getContext('2d')!
const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data
let nonZero = 0
for (let i = 0; i < data.length; i += 4) {
if (data[i + 3] > 0) nonZero++
}
expect(nonZero > 0).toBe(true)
// And a zero-length peaks array must not throw.
drawWaveform(canvas, new Float32Array(0))
})
test('useAudioRuntime composable wires/unwires cleanly inside a component lifecycle', async () => {
// The Vue wrapper is a thin shell over createAudioRuntime — mount
// it via a harness component and expect no throw at mount /
// unmount. This is the integration-level check that the
// onBeforeUnmount → dispose hook is actually reached.
const { defineComponent } = await import('vue')
const { useAudioRuntime } = await import('@/composables/useAudioRuntime')
const factory = () => {
const el = {
src: '',
currentTime: 0,
paused: true,
preload: '',
play() {
this.paused = false
return Promise.resolve()
},
pause() {
this.paused = true
},
}
return el as unknown as HTMLAudioElement
}
const Harness = defineComponent({
setup() {
useAudioRuntime({ audioFactory: factory })
return () => h('div', { 'data-test-audio-host': '' })
},
})
const handle = mountComponent(Harness, {})
await nextTick()
expect(handle.host.querySelector('[data-test-audio-host]')).toBeTruthy()
// Unmount should not throw even if nothing in the pool.
handle.unmount()
})
})
suite('actions — evalAction with mock runtime', () => {
function makeMockRuntime(init?: Partial<ActionRuntime>): ActionRuntime & {
calls: string[]
playhead: number
playing: boolean
warnings: string[]
} {
const rt = {
fps: 60,
playbackIn: 0,
playbackOut: 10,
duration: 10,
markers: { intro: 2, outro: 8 },
playhead: 0,
playing: false,
warnings: [] as string[],
calls: [] as string[],
setPlayhead(t: number) {
this.calls.push(`setPlayhead:${t}`)
this.playhead = t
},
play() {
this.calls.push('play')
this.playing = true
},
pause() {
this.calls.push('pause')
this.playing = false
},
warn(msg: string) {
this.warnings.push(msg)
},
...init,
}
return rt as ReturnType<typeof makeMockRuntime>
}
test('play / pause / rewindToLoop', () => {
const rt = makeMockRuntime({ playbackIn: 2 })
evalAction({ type: 'play' }, rt)
evalAction({ type: 'pause' }, rt)
evalAction({ type: 'rewindToLoop' }, rt)
expect(rt.playing).toBe(false)
expect(rt.playhead).toBe(2)
expect(rt.calls).toEqual(['play', 'pause', 'setPlayhead:2'])
})
test('jumpTo clamps to [0, duration]', () => {
const rt = makeMockRuntime()
evalAction({ type: 'jumpTo', time: -3 }, rt)
expect(rt.playhead).toBe(0)
evalAction({ type: 'jumpTo', time: 99 }, rt)
expect(rt.playhead).toBe(10)
evalAction({ type: 'jumpTo', time: 5 }, rt)
expect(rt.playhead).toBe(5)
})
test('jumpToFrame divides by fps', () => {
const rt = makeMockRuntime({ fps: 30 })
evalAction({ type: 'jumpToFrame', frame: 90 }, rt)
expect(rt.playhead).toBe(3)
})
test('jumpToMarker resolves via markers map', () => {
const rt = makeMockRuntime()
evalAction({ type: 'jumpToMarker', name: 'outro' }, rt)
expect(rt.playhead).toBe(8)
})
test('jumpToMarker warns on unknown name and does not move playhead', () => {
const rt = makeMockRuntime()
const before = rt.playhead
evalAction({ type: 'jumpToMarker', name: 'missing' }, rt)
expect(rt.playhead).toBe(before)
expect(rt.warnings.length).toBe(1)
expect(rt.warnings[0]).toContain('missing')
})
test('jumpToScene dispatches switchScene on the runtime', () => {
let switched: string | null = null
const rt = makeMockRuntime({
sceneIds: new Set(['s-a', 's-b']),
switchScene(id: string) {
switched = id
},
})
evalAction({ type: 'jumpToScene', sceneId: 's-b' }, rt)
expect(switched).toBe('s-b')
expect(rt.warnings.length).toBe(0)
})
test('jumpToScene warns on unknown sceneId and does not switch', () => {
let switched: string | null = null
const rt = makeMockRuntime({
sceneIds: new Set(['s-a']),
switchScene(id: string) {
switched = id
},
})
evalAction({ type: 'jumpToScene', sceneId: 's-ghost' }, rt)
expect(switched).toBe(null)
expect(rt.warnings.length).toBe(1)
expect(rt.warnings[0]).toContain('s-ghost')
})
test('jumpToScene warns if runtime has no switchScene handler (e.g. exported bundle)', () => {
const rt = makeMockRuntime() // no switchScene, no sceneIds
evalAction({ type: 'jumpToScene', sceneId: 's-x' }, rt)
expect(rt.warnings.length).toBe(1)
expect(rt.warnings[0].toLowerCase()).toContain('does not support')
})
test('setButtonEnabled dispatches setButtonEnabled on the runtime', () => {
const flips: { layerId: string; enabled: boolean }[] = []
const rt = makeMockRuntime({
setButtonEnabled(layerId: string, enabled: boolean) {
flips.push({ layerId, enabled })
},
})
evalAction({ type: 'setButtonEnabled', layerId: 'L-1', enabled: false }, rt)
evalAction({ type: 'setButtonEnabled', layerId: 'L-1', enabled: true }, rt)
expect(flips).toEqual([
{ layerId: 'L-1', enabled: false },
{ layerId: 'L-1', enabled: true },
])
expect(rt.warnings.length).toBe(0)
})
test('setButtonEnabled warns when the runtime omits the handler', () => {
const rt = makeMockRuntime() // no setButtonEnabled
evalAction({ type: 'setButtonEnabled', layerId: 'L-1', enabled: false }, rt)
expect(rt.warnings.length).toBe(1)
expect(rt.warnings[0].toLowerCase()).toContain('does not support')
})
test('script action parses and dispatches sub-actions (no warn on valid DSL)', () => {
const rt = makeMockRuntime()
evalAction({ type: 'script', source: 'play\npause' }, rt)
expect(rt.warnings.length).toBe(0)
expect(rt.calls).toEqual(['play', 'pause'])
})
test('script action warns per invalid DSL line', () => {
const rt = makeMockRuntime()
evalAction({ type: 'script', source: 'play\nwat\npause' }, rt)
expect(rt.warnings.length).toBe(1)
expect(rt.warnings[0]).toContain('wat')
// Valid lines still fire.
expect(rt.calls).toEqual(['play', 'pause'])
})
test('buildMarkerMap maps name → time', () => {
const map = buildMarkerMap([
{ id: 'a', name: 'intro', time: 1 },
{ id: 'b', name: 'outro', time: 5 },
])
expect(map.intro).toBe(1)
expect(map.outro).toBe(5)
})
test('scriptFramesInRange returns only forward frames, sorted', () => {
const frames = [
{ id: 'a', trackId: 't', time: 3, actions: [] as Action[] },
{ id: 'b', trackId: 't', time: 1, actions: [] as Action[] },
{ id: 'c', trackId: 't', time: 6, actions: [] as Action[] },
]
// (0, 4] → only 1 and 3, sorted
const got = scriptFramesInRange(frames, 0, 4).map((f) => f.id)
expect(got).toEqual(['b', 'a'])
// backwards → empty
expect(scriptFramesInRange(frames, 5, 1)).toHaveLength(0)
})
test('scriptFramesInRange uses open-closed interval', () => {
const frames = [{ id: 'a', trackId: 't', time: 2, actions: [] as Action[] }]
// prev=2 (exclusive), next=2 → empty
expect(scriptFramesInRange(frames, 2, 2)).toHaveLength(0)
// prev=1, next=2 → includes 2
expect(scriptFramesInRange(frames, 1, 2)).toHaveLength(1)
})
test('scriptFramesInRange with includeStart catches frames exactly at prev', () => {
// Matches the first-tick-after-play path: a script at the starting
// playhead time (e.g. frame 0) would be skipped by the default
// open-left interval; includeStart closes it.
const frames = [
{ id: 'a', trackId: 't', time: 0, actions: [] as Action[] },
{ id: 'b', trackId: 't', time: 0.5, actions: [] as Action[] },
]
// Default: open-left — excludes frame at 0.
expect(scriptFramesInRange(frames, 0, 0.5).map((f) => f.id)).toEqual(['b'])
// includeStart: both fire.
expect(
scriptFramesInRange(frames, 0, 0.5, { includeStart: true }).map((f) => f.id),
).toEqual(['a', 'b'])
// Degenerate prev===next: only fires with includeStart.
expect(scriptFramesInRange(frames, 0, 0, { includeStart: true }).map((f) => f.id)).toEqual([
'a',
])
expect(scriptFramesInRange(frames, 0, 0)).toHaveLength(0)
})
})
suite('scene store — tick fires script frames forward-only', () => {
test('tick fires frames crossed during forward advance in time order', async () => {
const s = makeFreshStore()
s.setDuration(10)
const t = s.addScriptTrack()
s.addScriptFrame(t.id, 2, [{ type: 'pause' }])
s.addScriptFrame(t.id, 1, [{ type: 'jumpTo', time: 9 }])
s.play()
// From 0 → 2: both frames get crossed; time-1 frame fires first and
// jumps the playhead to 9, time-2 frame fires second and pauses.
s.tick(2)
expect(s.playhead).toBe(9)
expect(s.playing).toBe(false)
await tick()
})
test('setPlayhead alone does not fire frames', async () => {
const s = makeFreshStore()
s.setDuration(10)
const t = s.addScriptTrack()
s.addScriptFrame(t.id, 2, [{ type: 'pause' }])
s.play()
s.setPlayhead(5) // scrub over the frame without ticking
expect(s.playing).toBe(true)
s.pause()
await tick()
})
test('frames fire even after natural stop at playbackOut', async () => {
const s = makeFreshStore()
s.setDuration(10)
s.setPlaybackRange(0, 5)
const t = s.addScriptTrack()
s.addScriptFrame(t.id, 5, [{ type: 'rewindToLoop' }])
s.play()
s.tick(6) // lands at playbackOut=5, playing=false, frame at 5 fires
expect(s.playing).toBe(false)
expect(s.playhead).toBe(0) // rewindToLoop → playbackIn=0
await tick()
})
test('a scripted jumpToScene switches the scene, keeps playing, and rewinds to the in-point', async () => {
// Chapter-transition semantics: the incoming scene starts at
// the project's in-point no matter where the outgoing scene was
// paused. Keeps playback running across the switch.
const s = makeFreshStore()
s.setDuration(10)
const aId = s.project.activeSceneId
const bId = s.addScene('B').id
s.switchScene(aId)
const track = s.addScriptTrack()
s.addScriptFrame(track.id, 3, [{ type: 'jumpToScene', sceneId: bId }])
s.play()
s.tick(3)
expect(s.project.activeSceneId).toBe(bId)
expect(s.playing).toBe(true)
expect(s.playhead).toBe(s.playbackIn)
await tick()
})
test('play-start fires a script sitting exactly at the playhead (frame-0 case)', async () => {
// Regression: a script placed at time=0 was silently skipped
// because the forward-firing window is (prev, next] — on the very
// first tick after play(), prev === 0 so the frame at 0 missed.
const s = makeFreshStore()
s.setDuration(10)
const t = s.addScriptTrack()
s.addScriptFrame(t.id, 0, [{ type: 'pause' }])
expect(s.playhead).toBe(0)
s.play()
expect(s.playing).toBe(true)
s.tick(0.5)
// Pause action fired → playing=false. Without the play-start fix
// this assertion flips (playing stays true because the frame-0
// script never ran).
expect(s.playing).toBe(false)
await tick()
})
test('play-start also fires a script at a non-zero starting playhead', async () => {
const s = makeFreshStore()
s.setDuration(10)
const t = s.addScriptTrack()
s.addScriptFrame(t.id, 3, [{ type: 'pause' }])
s.setPlayhead(3)
s.play()
s.tick(0.1)
expect(s.playing).toBe(false)
await tick()
})
test('re-playing from the same playhead re-fires the start-frame script', async () => {
const s = makeFreshStore()
s.setDuration(10)
const t = s.addScriptTrack()
let count = 0
// Stand-in for "a scripted action ran once this play session":
// a real pause action flips playing=false which we re-enable below.
s.addScriptFrame(t.id, 0, [{ type: 'pause' }])
for (let i = 0; i < 3; i++) {
s.setPlayhead(0)
s.play()
s.tick(0.1)
if (!s.playing) count++
}
expect(count).toBe(3)
await tick()
})
test('scripted jumpToScene with an unknown id warns and leaves state alone', async () => {
const s = makeFreshStore()
s.setDuration(10)
const aId = s.project.activeSceneId
const track = s.addScriptTrack()
s.addScriptFrame(track.id, 2, [{ type: 'jumpToScene', sceneId: 'bogus-id' }])
s.play()
const warnings: string[] = []
const origWarn = console.warn
console.warn = (...args: unknown[]) => {
warnings.push(args.map(String).join(' '))
}
try {
s.tick(2)
} finally {
console.warn = origWarn
}
expect(s.project.activeSceneId).toBe(aId)
expect(s.playing).toBe(true)
expect(warnings.some((w) => w.includes('bogus-id'))).toBe(true)
await tick()
})
})
suite('scriptDsl — parser', () => {
test('parses bare commands', () => {
const { actions, errors } = parseScript('play\npause\nrewind')
expect(errors).toHaveLength(0)
expect(actions.map((a) => a.type)).toEqual(['play', 'pause', 'rewindToLoop'])
})
test('skips blank lines and comments', () => {
const src = [
'// leading comment',
'',
'play // trailing comment',
' ',
'pause',
].join('\n')
const { actions, errors } = parseScript(src)
expect(errors).toHaveLength(0)
expect(actions.map((a) => a.type)).toEqual(['play', 'pause'])
})
test('jump accepts seconds, #frame, and @marker', () => {
const { actions, errors } = parseScript('jump 2.5\njump #60\njump @intro')
expect(errors).toHaveLength(0)
expect(actions[0]).toEqual({ type: 'jumpTo', time: 2.5 })
expect(actions[1]).toEqual({ type: 'jumpToFrame', frame: 60 })
expect(actions[2]).toEqual({ type: 'jumpToMarker', name: 'intro' })
})
test('jump reports invalid arguments', () => {
const { actions, errors } = parseScript('jump\njump abc\njump #xx\njump @')
expect(actions).toHaveLength(0)
expect(errors).toHaveLength(4)
})
test('unknown commands report error, other lines still parse', () => {
const { actions, errors } = parseScript('play\nwat\npause')
expect(errors).toHaveLength(1)
expect(errors[0].line).toBe(2)
expect(actions.map((a) => a.type)).toEqual(['play', 'pause'])
})
})
suite('actions — script type expands via evalAction', () => {
test('script source is parsed and each action dispatched in order', () => {
const calls: string[] = []
const rt: ActionRuntime = {
fps: 60,
playbackIn: 0,
playbackOut: 10,
duration: 10,
markers: { intro: 2 },
setPlayhead: (t) => calls.push(`seek:${t}`),
play: () => calls.push('play'),
pause: () => calls.push('pause'),
warn: () => {},
}
evalAction(
{ type: 'script', source: 'pause\njump @intro\nplay' },
rt,
)
expect(calls).toEqual(['pause', 'seek:2', 'play'])
})
test('script errors surface via rt.warn, valid actions still fire', () => {
const warnings: string[] = []
const fired: string[] = []
const rt: ActionRuntime = {
fps: 60,
playbackIn: 0,
playbackOut: 10,
duration: 10,
markers: {},
setPlayhead: () => {},
play: () => fired.push('play'),
pause: () => fired.push('pause'),
warn: (m) => warnings.push(m),
}
evalAction({ type: 'script', source: 'play\nwat\npause' }, rt)
expect(fired).toEqual(['play', 'pause'])
expect(warnings).toHaveLength(1)
expect(warnings[0]).toContain('wat')
})
})
suite('scene store — macro model normalization', () => {
test('loadScene initializes new arrays when missing', () => {
const s = makeFreshStore()
const bare = {
version: 1 as const,
canvas: { width: 640, height: 480, background: '#ffffff' },
duration: 5,
fps: 30,
assets: [],
layers: [],
}
s.loadScene(bare)
expect(s.scene.scriptTracks).toEqual([])
expect(s.scene.scriptFrames).toEqual([])
expect(s.scene.markers).toEqual([])
})
test('setTrackLocked also works on script tracks', () => {
const s = makeFreshStore()
const t = s.addScriptTrack()
s.setTrackLocked(t.id, true)
expect(s.scene.scriptTracks?.find((x) => x.id === t.id)?.locked).toBe(true)
s.setTrackLocked(t.id, false)
expect(s.scene.scriptTracks?.find((x) => x.id === t.id)?.locked).toBe(undefined)
})
})
suite('macro-clips — store coverage gaps', () => {
test('removeMarker on unknown id is a no-op', () => {
const s = makeFreshStore()
s.addMarker(1, 'A')
s.removeMarker('no-such-id')
expect(s.scene.markers).toHaveLength(1)
})
test('setMarkerTime / setMarkerColor on unknown id are no-ops', () => {
const s = makeFreshStore()
s.setMarkerTime('ghost', 5)
s.setMarkerColor('ghost', '#ff0000')
expect(s.scene.markers).toHaveLength(0)
})
test('addMarker accepts an initial color', () => {
const s = makeFreshStore()
const m = s.addMarker(1, 'A', '#ff8800')
expect(m.color).toBe('#ff8800')
})
test('removeScriptFrame / setScriptFrameActions / setScriptFrameTime handle unknown ids', () => {
const s = makeFreshStore()
// None of these should throw or mutate anything.
s.removeScriptFrame('nope')
s.setScriptFrameActions('nope', [{ type: 'play' }])
s.setScriptFrameTime('nope', 1)
expect(s.scene.scriptFrames).toEqual([])
})
})
suite('macro-clips — DSL edge cases', () => {
test('empty source yields no actions and no errors', () => {
const { actions, errors } = parseScript('')
expect(actions).toHaveLength(0)
expect(errors).toHaveLength(0)
})
test('only comments or whitespace yields no actions', () => {
const { actions, errors } = parseScript(' \n\n// note\n // more\n')
expect(actions).toHaveLength(0)
expect(errors).toHaveLength(0)
})
test('trailing blank lines do not produce phantom errors', () => {
const { actions, errors } = parseScript('play\n\n\n')
expect(actions).toHaveLength(1)
expect(errors).toHaveLength(0)
})
test('jump with empty @marker reports error', () => {
const { errors } = parseScript('jump @')
expect(errors).toHaveLength(1)
expect(errors[0].message).toContain('marker')
})
})
suite('macro-clips — tick firing robustness', () => {
test('multiple frames on different tracks fire in time order', async () => {
const s = makeFreshStore()
s.setDuration(10)
const t1 = s.addScriptTrack('A')
const t2 = s.addScriptTrack('B')
const log: number[] = []
// Three frames: 0.2 (t2), 0.5 (t1), 0.8 (t2)
// We can verify ordering via jumpTo actions that push into a list…
// but evalAction dispatches to store.setPlayhead etc. We instead
// observe the jumpTo time the playhead ends up at after all fire.
s.addScriptFrame(t2.id, 0.2, [{ type: 'jumpTo', time: 1 }])
s.addScriptFrame(t1.id, 0.5, [{ type: 'jumpTo', time: 2 }])
s.addScriptFrame(t2.id, 0.8, [{ type: 'jumpTo', time: 3 }])
s.play()
s.tick(1) // advances to 1, all three frames in (0, 1]
// The last fired wins — since frames fire sorted by time, the last
// action to run is jumpTo 3.
expect(s.playhead).toBe(3)
s.pause()
await tick()
void log
})
test('within a single frame, actions fire in list order', async () => {
const s = makeFreshStore()
s.setDuration(10)
const t = s.addScriptTrack()
s.addScriptFrame(t.id, 1, [
{ type: 'jumpTo', time: 5 },
{ type: 'jumpTo', time: 7 },
{ type: 'pause' },
])
s.play()
s.tick(2)
expect(s.playhead).toBe(7)
expect(s.playing).toBe(false)
await tick()
})
test('scriptFramesInRange handles exactly-at-next inclusively', () => {
const frames = [{ id: 'a', trackId: 't', time: 1, actions: [] as Action[] }]
// next strictly equals frame.time → included (open-closed)
expect(scriptFramesInRange(frames, 0.5, 1)).toHaveLength(1)
// next strictly below → excluded
expect(scriptFramesInRange(frames, 0.5, 0.999)).toHaveLength(0)
})
})
suite('button asset — still-frame visibility gate', () => {
// Button assets have 4 discrete state times (1,2,3,4). A still-frame
// (hold) keyframe must only be visible on its exact state frame —
// not held forward to the neighbours as on regular timelines.
test('isButtonLayerActiveAt: hold kf at t=2 only visible at t=2', async () => {
const { isButtonLayerActiveAt } = await import('@/lib/interpolate')
const layer: Layer = {
id: 'l1',
name: '',
assetId: 'a',
visible: true,
base: { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
keyframes: [
{
id: 'k1',
time: 2,
props: { x: 10 },
easing: 'linear',
hold: true,
},
],
}
expect(isButtonLayerActiveAt(layer, 1)).toBe(false)
expect(isButtonLayerActiveAt(layer, 2)).toBe(true)
expect(isButtonLayerActiveAt(layer, 3)).toBe(false)
expect(isButtonLayerActiveAt(layer, 4)).toBe(false)
})
test('isButtonLayerActiveAt: non-hold kfs at t=1 and t=4 still interpolate between states', async () => {
const { isButtonLayerActiveAt } = await import('@/lib/interpolate')
const layer: Layer = {
id: 'l',
name: '',
assetId: 'a',
visible: true,
base: { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
keyframes: [
{ id: 'k1', time: 1, props: { x: 0 }, easing: 'linear' },
{ id: 'k2', time: 4, props: { x: 100 }, easing: 'linear' },
],
}
expect(isButtonLayerActiveAt(layer, 1)).toBe(true)
expect(isButtonLayerActiveAt(layer, 2)).toBe(true)
expect(isButtonLayerActiveAt(layer, 3)).toBe(true)
expect(isButtonLayerActiveAt(layer, 4)).toBe(true)
})
test('isButtonLayerActiveAt: a hold kf followed by a regular kf shows only on both exact frames', async () => {
const { isButtonLayerActiveAt } = await import('@/lib/interpolate')
const layer: Layer = {
id: 'l',
name: '',
assetId: 'a',
visible: true,
base: { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
keyframes: [
{ id: 'k1', time: 2, props: { x: 0 }, easing: 'linear', hold: true },
{ id: 'k2', time: 4, props: { x: 100 }, easing: 'linear' },
],
}
expect(isButtonLayerActiveAt(layer, 1)).toBe(false)
expect(isButtonLayerActiveAt(layer, 2)).toBe(true)
// hold at t=2 → the region (2, 4) is "holding in silence", invisible.
expect(isButtonLayerActiveAt(layer, 3)).toBe(false)
expect(isButtonLayerActiveAt(layer, 4)).toBe(true)
})
test('isButtonLayerActiveAt: no keyframes → always visible (static layer)', async () => {
const { isButtonLayerActiveAt } = await import('@/lib/interpolate')
const layer: Layer = {
id: 'l',
name: '',
assetId: 'a',
visible: true,
base: { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
keyframes: [],
}
expect(isButtonLayerActiveAt(layer, 1)).toBe(true)
expect(isButtonLayerActiveAt(layer, 3)).toBe(true)
})
test('runtime engine embeds button-specific still-frame visibility', () => {
const r = generateRuntimeScript()
expect(r).toContain('buttonLayerActiveAt')
})
test('runtime engine evalAction handles setButtonEnabled via runtime.buttonDisabled', () => {
// Pin the engine source so the setButtonEnabled case both exists
// and writes into the runtime.buttonDisabled map — the same field
// the click-dispatcher reads to early-return on disabled buttons.
const r = generateRuntimeScript()
expect(r).toContain("case 'setButtonEnabled'")
expect(r).toContain('runtime.buttonDisabled[act.layerId]')
})
test('runtime engine fires frame-0 scripts via playStartPending', () => {
// Pin the engine source so the first-tick-after-play firing is
// still wired: variable exists, fireFrames takes an includeStart
// arg, and forcePlay re-arms the flag so every pause→play fires
// the start-frame scripts again.
const r = generateRuntimeScript()
expect(r).toContain('playStartPending')
expect(r).toContain('function fireFrames(prev, next, includeStart)')
expect(r).toContain('playStartPending=true')
})
test('timeline segments skip the connector line before a still-frame kf in button mode', async () => {
// In button-edit the state frames are discrete: the connector
// line that spans between kfs on a regular timeline must NOT
// be drawn from a preceding kf into a hold kf — that would
// suggest the layer is on-screen across the intervening states.
// Pin the button-mode branch of segmentsOf structurally: three
// kfs, middle one hold → no segment coming INTO the still.
const { default: T } = await import('@/components/Timeline.vue')
void T
// Reach into the private helper via a minimal contract: mount
// Timeline inside a button-asset-edit context and inspect the
// rendered .kf-seg elements for the track.
const s = makeFreshStore()
const btn = s.addButtonAsset('B', 100, 100)
s.enterButtonAssetEdit(btn.id)
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#abc' })
const layer = s.addLayer(a.id)!
// Lay out three kfs: normal (non-hold), hover (hold), disabled (non-hold).
layer.keyframes = [
{ id: 'k1', time: BUTTON_STATE_TIMES.normal, props: {}, easing: 'linear' },
{ id: 'k2', time: BUTTON_STATE_TIMES.hover, props: {}, easing: 'linear', hold: true },
{ id: 'k3', time: BUTTON_STATE_TIMES.disabled, props: {}, easing: 'linear' },
]
const handle = mountComponent(Timeline, {})
await nextTick()
const segs = handle.host.querySelectorAll('.kf-seg')
// Expected segments in button-mode:
// 1→2 (non-hold kf1 to hold kf2): drawn (interpolation into still)
// …actually no: hold k2 terminates *emerging* lines, not
// incoming. Only the OUT-segments from a hold are skipped.
// 2→3 (hold kf2 to non-hold kf3): SKIPPED (no line out of still).
expect(segs.length).toBe(1)
s.exitButtonAssetEdit()
handle.unmount()
})
test('addLayer in button-edit seeds the initial kf on the ACTIVE state, not always normal', () => {
const s = makeFreshStore()
const btn = s.addButtonAsset('B', 100, 100)
s.enterButtonAssetEdit(btn.id)
s.setActiveButtonState('disabled')
const asset = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#000' })
const layer = s.addLayer(asset.id)!
expect(layer.keyframes).toHaveLength(1)
expect(layer.keyframes[0].time).toBe(BUTTON_STATE_TIMES.disabled)
s.exitButtonAssetEdit()
void btn
})
test('drawButtonFrame renders a hold-kf layer ONLY on its own state frame', () => {
// End-to-end UI test: construct a button with a single rect
// whose layer has a hold-keyframe at the hover state. Then
// rasterise `drawButtonFrame` against each of the 4 state times
// and verify the rect's centre pixel is red only at hover and
// transparent on every other state.
const s = makeFreshStore()
const btn = s.addButtonAsset('B', 100, 100)
s.enterButtonAssetEdit(btn.id)
const red = s.addAsset({
kind: 'rect',
name: 'R',
width: 100,
height: 100,
fill: '#ff0000',
})
const layer = s.addLayer(red.id)!
// Replace the default normal-state kf with a single hold-kf at hover.
layer.keyframes = [
{
id: 'k',
time: BUTTON_STATE_TIMES.hover,
props: {
x: 50, y: 50, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1,
},
easing: 'linear',
hold: true,
},
]
s.exitButtonAssetEdit()
const button = s.scene.assets.find((x) => x.id === btn.id) as ButtonAsset
function probe(stateTime: number): boolean {
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawButtonFrame(ctx, button, s.scene, stateTime, {})
const data = ctx.getImageData(50, 50, 1, 1).data
// Red + opaque = visible. Any other reading (alpha 0, or colour
// washed out) → not rendered.
return data[0] > 200 && data[3] > 200
}
expect(probe(BUTTON_STATE_TIMES.normal)).toBe(false)
expect(probe(BUTTON_STATE_TIMES.hover)).toBe(true)
expect(probe(BUTTON_STATE_TIMES.pressed)).toBe(false)
expect(probe(BUTTON_STATE_TIMES.disabled)).toBe(false)
})
test('clicking + on an empty button slot inserts the kf AND moves the editor to that state', async () => {
const s = makeFreshStore()
const btn = s.addButtonAsset('B', 100, 100)
s.enterButtonAssetEdit(btn.id)
// addLayer in button-edit seeds a single kf on the current state (normal).
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#abc' })
const layer = s.addLayer(a.id)!
expect(layer.keyframes).toHaveLength(1)
expect(s.activeButtonState).toBe('normal')
const handle = mountComponent(Timeline, {})
await nextTick()
// With a kf at normal, three empty slots remain: hover, pressed, disabled (in that order).
const adders = handle.host.querySelectorAll<HTMLButtonElement>('.kf-add')
expect(adders.length).toBe(3)
// Click the last one — the "disabled" slot.
adders[adders.length - 1].click()
await nextTick()
expect(layer.keyframes).toHaveLength(2)
expect(layer.keyframes.some((k) => Math.abs(k.time - BUTTON_STATE_TIMES.disabled) < 1e-6)).toBe(true)
expect(s.activeButtonState).toBe('disabled')
expect(s.playhead).toBe(BUTTON_STATE_TIMES.disabled)
s.exitButtonAssetEdit()
handle.unmount()
})
test('clicking + falls back to the NEXT kf when no prev exists, and moves the editor', async () => {
// Pins the prev ?? next branch: with a lone kf at disabled, the + slot
// at hover must copy from the disabled kf and park the editor on hover.
const s = makeFreshStore()
const btn = s.addButtonAsset('B', 100, 100)
s.enterButtonAssetEdit(btn.id)
s.setActiveButtonState('disabled')
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#abc' })
const layer = s.addLayer(a.id)!
expect(layer.keyframes).toHaveLength(1)
expect(layer.keyframes[0].time).toBe(BUTTON_STATE_TIMES.disabled)
const handle = mountComponent(Timeline, {})
await nextTick()
// Three empty slots: normal, hover, pressed (in that order).
const adders = handle.host.querySelectorAll<HTMLButtonElement>('.kf-add')
expect(adders.length).toBe(3)
// Second = hover.
adders[1].click()
await nextTick()
expect(layer.keyframes).toHaveLength(2)
expect(layer.keyframes.some((k) => Math.abs(k.time - BUTTON_STATE_TIMES.hover) < 1e-6)).toBe(true)
expect(s.activeButtonState).toBe('hover')
expect(s.playhead).toBe(BUTTON_STATE_TIMES.hover)
s.exitButtonAssetEdit()
handle.unmount()
})
})
suite('button asset — data model + edit mode', () => {
test('addButtonAsset creates asset with empty layers and hitArea', () => {
const s = makeFreshStore()
const a = s.addButtonAsset('Play', 160, 60)
expect(a.kind).toBe('button')
expect(a.name).toBe('Play')
expect(a.canvas.width).toBe(160)
expect(a.canvas.height).toBe(60)
expect(a.layers).toHaveLength(0)
expect(a.hitArea).toHaveLength(0)
})
test('enter/exit button edit sets mode=states and resets on exit', () => {
const s = makeFreshStore()
const a = s.addButtonAsset('B')
s.enterButtonAssetEdit(a.id)
expect(s.activeButtonAssetId).toBe(a.id)
expect(s.activeButtonEditMode).toBe('states')
expect(s.activeButtonState).toBe('normal')
s.setActiveButtonState('hover')
expect(s.activeButtonState).toBe('hover')
s.exitButtonAssetEdit()
expect(s.activeButtonAssetId).toBe(null)
expect(s.activeButtonEditMode).toBe(null)
expect(s.activeButtonState).toBe(null)
})
test('state scrubber syncs playhead to kf-time for mutations', () => {
const s = makeFreshStore()
const a = s.addButtonAsset('B')
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 40, height: 20, fill: '#f00' })
s.enterButtonAssetEdit(a.id)
const layer = s.addLayer(rect.id)!
// BUTTON_STATE_TIMES is { normal: 1, hover: 2, pressed: 3, disabled: 4 }
// so a layer added inside the button edit lands on the "normal" slot.
expect(layer.keyframes[0].time).toBe(1)
s.setActiveButtonState('hover')
expect(s.playhead).toBe(2)
s.upsertKeyframe(layer.id, s.playhead, { scaleX: 2, scaleY: 2 })
expect(layer.keyframes.some((k) => Math.abs(k.time - 2) < 1e-6)).toBe(true)
s.setActiveButtonState('pressed')
expect(s.playhead).toBe(3)
s.setActiveButtonState('disabled')
expect(s.playhead).toBe(4)
})
test('mode toggle: hit parks playhead; returning to states restores it', () => {
const s = makeFreshStore()
const a = s.addButtonAsset('B')
s.enterButtonAssetEdit(a.id)
s.setActiveButtonState('pressed')
expect(s.playhead).toBe(3)
s.setButtonEditMode('hit')
expect(s.activeButtonEditMode).toBe('hit')
// Hit mode onion-skins the "normal" state (time=1), so the playhead
// is parked there — not at 0.
expect(s.playhead).toBe(1)
// Selected state is preserved across the hit trip.
expect(s.activeButtonState).toBe('pressed')
s.setButtonEditMode('states')
expect(s.playhead).toBe(3)
})
test('addLayer in hit mode returns null — no leak into states', () => {
const s = makeFreshStore()
const a = s.addButtonAsset('B')
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 40, height: 20, fill: '#f00' })
s.enterButtonAssetEdit(a.id)
s.setButtonEditMode('hit')
const result = s.addLayer(rect.id)
expect(result).toBe(null)
const asset = s.scene.assets.find((x) => x.id === a.id)!
expect(asset.kind === 'button' ? asset.layers.length : -1).toBe(0)
})
test('hit shape CRUD stays isolated from layers', () => {
const s = makeFreshStore()
const a = s.addButtonAsset('B')
s.addButtonHitShape(a.id, { kind: 'rect', x: 0, y: 0, width: 40, height: 20 })
s.addButtonHitShape(a.id, { kind: 'circle', x: 50, y: 50, radius: 10 })
const asset = s.scene.assets.find((x) => x.id === a.id)!
expect(asset.kind === 'button' ? asset.hitArea.length : -1).toBe(2)
expect(asset.kind === 'button' ? asset.layers.length : -1).toBe(0)
s.removeButtonHitShape(a.id, 0)
expect(asset.kind === 'button' ? asset.hitArea.length : -1).toBe(1)
})
test('polygon hit shapes persist and update via updateButtonHitShape', () => {
const s = makeFreshStore()
const a = s.addButtonAsset('B')
s.addButtonHitShape(a.id, {
kind: 'polygon',
points: [
{ x: 0, y: 0 },
{ x: 100, y: 0 },
{ x: 50, y: 80 },
],
})
const get = () => {
const asset = s.scene.assets.find((x) => x.id === a.id)
if (!asset || asset.kind !== 'button') return null
return asset.hitArea[0]
}
const sh0 = get()
expect(sh0 && sh0.kind === 'polygon' ? sh0.points.length : -1).toBe(3)
s.updateButtonHitShape(a.id, 0, {
points: [
{ x: 1, y: 1 },
{ x: 101, y: 1 },
{ x: 51, y: 81 },
{ x: 2, y: 82 },
],
} as Partial<HitShape>)
const sh1 = get()
expect(sh1 && sh1.kind === 'polygon' ? sh1.points.length : -1).toBe(4)
})
})
suite('button asset — state transition runtime', () => {
// Regression: button state changes used to snap instantly between
// the normal / hover / pressed / disabled slots. `setState` now
// records a transition whose sample time lerps between the
// corresponding `BUTTON_STATE_TIMES` slots over
// `BUTTON_STATE_TRANSITION_MS`. `getEffectiveTime` is what the
// renderer reads; it must return an intermediate time during the
// transition and snap to the target once finished.
async function importRt() {
const mod = await import('@/composables/useButtonRuntime')
return {
rt: mod.useButtonRuntime(),
DURATION: mod.BUTTON_STATE_TRANSITION_MS,
}
}
test('getEffectiveTime returns the target slot when no transition is running', async () => {
const { rt } = await importRt()
rt.clear('btn-a')
expect(rt.getEffectiveTime('btn-a', false)).toBe(BUTTON_STATE_TIMES.normal)
rt.clear('btn-a')
// Fast-path: disabled layers report disabled regardless of the
// runtime state or transitions.
expect(rt.getEffectiveTime('btn-a', true)).toBe(BUTTON_STATE_TIMES.disabled)
rt.clear('btn-a')
})
test('setState kicks off a transition; mid-flight time lies between slots', async () => {
const { rt, DURATION } = await importRt()
rt.clear('btn-b')
rt.setState('btn-b', 'hover')
// Immediately after setState, time is ~fromTime (normal=1) and
// approaching toTime (hover=2).
const t0 = rt.getEffectiveTime('btn-b', false)
expect(t0).toBeGreaterThan(BUTTON_STATE_TIMES.normal - 0.001)
expect(t0).toBeLessThan(BUTTON_STATE_TIMES.hover)
// After half the duration the time should be around halfway.
await new Promise((r) => setTimeout(r, DURATION / 2))
const tMid = rt.getEffectiveTime('btn-b', false)
expect(tMid).toBeGreaterThan(BUTTON_STATE_TIMES.normal)
expect(tMid).toBeLessThan(BUTTON_STATE_TIMES.hover)
// After the full duration (+ slack) the transition resolves to
// the target slot exactly.
await new Promise((r) => setTimeout(r, DURATION + 40))
expect(rt.getEffectiveTime('btn-b', false)).toBe(BUTTON_STATE_TIMES.hover)
rt.clear('btn-b')
})
test('mid-transition setState rebases from the live sample time', async () => {
// Regression: if hover → pressed flips in-flight from a previous
// normal → hover transition, the new transition should start at
// the *current* intermediate sample time instead of snapping
// back to the old `from` slot. Otherwise a quick hover+press
// produces a visible jump.
const { rt, DURATION } = await importRt()
rt.clear('btn-c')
rt.setState('btn-c', 'hover')
await new Promise((r) => setTimeout(r, DURATION / 2))
// At this point the effective time is roughly halfway between
// normal (1) and hover (2) — somewhere around 1.4–1.6.
const midway = rt.getEffectiveTime('btn-c', false)
rt.setState('btn-c', 'pressed')
// The freshly-started transition must begin at or just past the
// midway sample, not drop back toward normal (1).
const after = rt.getEffectiveTime('btn-c', false)
// Allow a small drift forward because `setTimeout` and the
// subsequent assignment both burn a few ms — the important
// thing is we didn't regress into the [1, midway) interval.
expect(after).toBeGreaterThan(midway - 0.01)
rt.clear('btn-c')
})
test('no-op setState (target === current) does not start a transition', async () => {
const { rt } = await importRt()
rt.clear('btn-d')
// Currently normal; asking for normal again is a no-op.
rt.setState('btn-d', 'normal')
expect(rt.getEffectiveTime('btn-d', false)).toBe(BUTTON_STATE_TIMES.normal)
rt.clear('btn-d')
})
test('store actionRuntime.setButtonEnabled toggles useButtonRuntime disabled state', async () => {
const mod = await import('@/composables/useButtonRuntime')
const btnRt = mod.useButtonRuntime()
const s = makeFreshStore()
const btn = s.addButtonAsset('B', 100, 40)
const layer = s.addLayer(btn.id)!
btnRt.clear(layer.id)
const actionRt = s.actionRuntime()
expect(typeof actionRt.setButtonEnabled).toBe('function')
actionRt.setButtonEnabled!(layer.id, false)
expect(btnRt.getEffective(layer.id, false)).toBe('disabled')
actionRt.setButtonEnabled!(layer.id, true)
expect(btnRt.getEffective(layer.id, false)).toBe('normal')
btnRt.clear(layer.id)
})
})
suite('button asset — thumbnail rendering', () => {
// Regression: renderButtonThumbnail used to pass images={} and any image
// layer inside a button produced a fully-transparent PNG, making
// sidebar thumbnails appear grey. Shape layers should always render
// visible pixels; image layers render when an images map is supplied.
function pngIsNonBlank(dataUrl: string): boolean {
// Data URL `data:image/png;base64,...` — non-trivial PNG means the
// renderer produced pixels. We accept anything over ~200 bytes of
// base64 payload, since an all-transparent 200×80-scaled canvas
// compresses to ~220 bytes (IHDR + tiny IDAT). A rect layer that
// actually paints fill colour balloons the IDAT noticeably.
const i = dataUrl.indexOf(',')
return i > 0 && dataUrl.length - i - 1 > 260
}
test('returns null when the button has no layers', () => {
const s = makeFreshStore()
const a = s.addButtonAsset('B', 200, 80)
const url = renderButtonThumbnail(a, s.scene)
expect(url).toBeNull()
})
test('renders shape layers without an images map', () => {
const s = makeFreshStore()
const a = s.addButtonAsset('B', 200, 80)
const rect = s.addAsset({
kind: 'rect',
name: 'R',
width: 200,
height: 80,
fill: '#6ea8ff',
})
s.enterButtonAssetEdit(a.id)
s.addLayer(rect.id)!
s.exitButtonAssetEdit()
const button = s.scene.assets.find((x) => x.id === a.id)!
const url = renderButtonThumbnail(
button as Extract<typeof button, { kind: 'button' }>,
s.scene,
)
expect(url).toBeTruthy()
// Data URL should be a well-formed PNG with actual content.
expect((url ?? '').startsWith('data:image/png;base64,')).toBe(true)
expect(pngIsNonBlank(url!)).toBe(true)
})
test('accepts a custom images map for image-kind layers', () => {
const s = makeFreshStore()
const a = s.addButtonAsset('B', 100, 40)
const img = s.addAsset({
kind: 'image',
name: 'I',
src: 'data:image/png;base64,iVBORw0KGgo=', // placeholder
width: 100,
height: 40,
})
s.enterButtonAssetEdit(a.id)
s.addLayer(img.id)!
s.exitButtonAssetEdit()
// Build a trivial HTMLImageElement stand-in. We don't wait for it to
// load — the point is just to confirm the function accepts the map
// without throwing, and that the button is not null.
const fake = new Image()
fake.width = 100
fake.height = 40
const button = s.scene.assets.find((x) => x.id === a.id)!
const url = renderButtonThumbnail(
button as Extract<typeof button, { kind: 'button' }>,
s.scene,
80,
{ [img.id]: fake },
)
expect(url).toBeTruthy()
expect((url ?? '').startsWith('data:image/png;base64,')).toBe(true)
})
})
suite('button asset — keyframes snap to state times', () => {
// Regression: keyframes inside a button-asset timeline must only
// exist at the 4 state times (BUTTON_STATE_TIMES = normal=1,
// hover=2, pressed=3, disabled=4). Any code path that places or
// moves a keyframe — upsertKeyframe, setKeyframeTime,
// duplicateKeyframe — snaps the time onto the nearest state. Free
// placement outside the 4 slots is disallowed.
function setupButton() {
const s = makeFreshStore()
const btn = s.addButtonAsset('B', 200, 80)
s.enterButtonAssetEdit(btn.id)
const a = s.addAsset({ kind: 'rect', name: 'R', width: 200, height: 80, fill: '#fff' })
const l = s.addLayer(a.id)!
// addLayer already put a keyframe at BUTTON_STATE_TIMES.normal (=1).
return { s, l }
}
test('upsertKeyframe snaps the requested time to the nearest state', () => {
const { s, l } = setupButton()
// 1.4 is closer to state 1 than to state 2 → should land at 1
// (merging with the existing one, rather than creating a new off-state kf).
s.upsertKeyframe(l.id, 1.4, { x: 10 })
expect(l.keyframes.every((k) => BUTTON_STATE_TIMES.normal === k.time || BUTTON_STATE_TIMES.hover === k.time || BUTTON_STATE_TIMES.pressed === k.time || BUTTON_STATE_TIMES.disabled === k.time)).toBe(true)
// 1.6 is closer to state 2 → new keyframe lands at 2
s.upsertKeyframe(l.id, 1.6, { x: 20 })
expect(l.keyframes.find((k) => Math.abs(k.time - 2) < 1e-6)).toBeTruthy()
})
test('setKeyframeTime snaps drag targets onto state times', () => {
const { s, l } = setupButton()
const kf = l.keyframes[0]
expect(kf.time).toBe(BUTTON_STATE_TIMES.normal)
// Drag "toward" hover but not all the way — 1.8 is closer to 2 → snaps to 2.
s.setKeyframeTime(l.id, kf.id, 1.8)
expect(kf.time).toBe(BUTTON_STATE_TIMES.hover)
// And 2.9 snaps to pressed.
s.setKeyframeTime(l.id, kf.id, 2.9)
expect(kf.time).toBe(BUTTON_STATE_TIMES.pressed)
})
test('setKeyframeTime refuses to land on an already-occupied state', () => {
const { s, l } = setupButton()
// Existing kf at state 1. Create a second at state 2.
s.upsertKeyframe(l.id, BUTTON_STATE_TIMES.hover, { x: 99 })
const kfAtHover = l.keyframes.find((k) => Math.abs(k.time - 2) < 1e-6)!
// Drag the hover kf toward normal. State 1 is already occupied — refuse.
s.setKeyframeTime(l.id, kfAtHover.id, 1.1)
expect(kfAtHover.time).toBe(BUTTON_STATE_TIMES.hover)
})
test('duplicateKeyframe picks the nearest free state slot', () => {
const { s, l } = setupButton()
// Only state 1 is occupied; duplicate should land on the next
// closest free slot — state 2 (hover).
const sourceId = l.keyframes[0].id
const newId = s.duplicateKeyframe(l.id, sourceId)!
expect(newId).toBeTruthy()
const dup = l.keyframes.find((k) => k.id === newId)!
expect(dup.time).toBe(BUTTON_STATE_TIMES.hover)
})
test('duplicateKeyframe returns null when every state slot is taken', () => {
const { s, l } = setupButton()
s.upsertKeyframe(l.id, BUTTON_STATE_TIMES.hover, { x: 2 })
s.upsertKeyframe(l.id, BUTTON_STATE_TIMES.pressed, { x: 3 })
s.upsertKeyframe(l.id, BUTTON_STATE_TIMES.disabled, { x: 4 })
const sourceId = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-6)!.id
const newId = s.duplicateKeyframe(l.id, sourceId)
expect(newId).toBe(null)
})
test('on the main timeline, snapping is NOT applied (free placement)', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 1.4, { x: 5 })
// No button-edit context → the kf lives at the exact requested time.
expect(l.keyframes.some((k) => Math.abs(k.time - 1.4) < 1e-6)).toBe(true)
})
})
suite('button asset — mask rendering', () => {
// Regression: masks inside buttons used to be ignored by every render
// path — the button branch in drawLayers called drawLayers recursively
// without the drawMaskedGroup split, so `isMask` layers drew as normal
// content. That made mask-preview / thumbnail / main-stage / exported
// runtime all show the mask shape instead of clipping. All paths now
// route button layers through drawMaskedGroup; these tests pin that.
function setupMaskedButton(mode: 'positive' | 'negative') {
const s = makeFreshStore()
const btn = s.addButtonAsset('B', 100, 100)
s.enterButtonAssetEdit(btn.id)
// Content: full-canvas red rect. addLayer already keyframes it at
// (canvas.width/2, canvas.height/2) at BUTTON_STATE_TIMES.normal,
// so we don't need to set anything else.
const red = s.addAsset({
kind: 'rect',
name: 'R',
width: 100,
height: 100,
fill: '#ff0000',
})
s.addLayer(red.id)!
// Mask: small circle at canvas center.
const mask = s.addAsset({
kind: 'circle',
name: 'M',
radius: 20,
fill: '#ffffff',
})
const maskLayer = s.addLayer(mask.id)!
s.setLayerMask(maskLayer.id, true, mode)
s.exitButtonAssetEdit()
const button = s.scene.assets.find((x) => x.id === btn.id) as ButtonAsset
return { store: s, button }
}
test('drawButtonFrame applies a positive mask inside the button', () => {
const { store, button } = setupMaskedButton('positive')
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawButtonFrame(ctx, button, store.scene, BUTTON_STATE_TIMES.normal, {})
const inside = ctx.getImageData(50, 50, 1, 1).data
const outside = ctx.getImageData(5, 5, 1, 1).data
expect(inside[0] > 200).toBe(true)
expect(inside[3] > 200).toBe(true)
expect(outside[3]).toBe(0)
})
test('drawButtonFrame applies a negative mask inside the button', () => {
const { store, button } = setupMaskedButton('negative')
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawButtonFrame(ctx, button, store.scene, BUTTON_STATE_TIMES.normal, {})
const inside = ctx.getImageData(50, 50, 1, 1).data
const outside = ctx.getImageData(5, 5, 1, 1).data
// Inside mask: punched out
expect(inside[3]).toBe(0)
// Outside mask: red content still there
expect(outside[0] > 200).toBe(true)
expect(outside[3] > 200).toBe(true)
})
test('drawSceneFrame applies button-internal masks when the button is placed on the main stage', () => {
const { store, button } = setupMaskedButton('positive')
// Size the main scene to match the button exactly so coordinates line up.
store.scene.canvas.width = 100
store.scene.canvas.height = 100
store.scene.canvas.background = 'transparent'
// Place the button as a layer on the main stage, centered.
store.addLayer(button.id)!
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, store.scene, 0, {})
const inside = ctx.getImageData(50, 50, 1, 1).data
const outside = ctx.getImageData(5, 5, 1, 1).data
// Inside the circular mask: red content visible
expect(inside[0] > 200).toBe(true)
expect(inside[3] > 200).toBe(true)
// Outside the circle: masked away (alpha 0)
expect(outside[3]).toBe(0)
})
})
suite('i18n — track-menu rename/delete labels', () => {
// Regression: the script-track context menu used to surface the raw
// keys ("timeline.renameScriptTrack" / ".deleteScriptTrack") because
// the i18n messages were missing. Guard every new key so a future
// rename of the JSON/TS messages file doesn't silently break the menu.
test('renameScriptTrack is translated', () => {
const v = i18nT('timeline.renameScriptTrack')
expect(v).toBeTruthy()
expect(v === 'timeline.renameScriptTrack').toBe(false)
})
test('deleteScriptTrack is translated', () => {
const v = i18nT('timeline.deleteScriptTrack')
expect(v).toBeTruthy()
expect(v === 'timeline.deleteScriptTrack').toBe(false)
})
test('renameScriptTrackDialog.title + .confirm are translated', () => {
const title = i18nT('timeline.renameScriptTrackDialog.title')
const confirm = i18nT('timeline.renameScriptTrackDialog.confirm')
expect(title === 'timeline.renameScriptTrackDialog.title').toBe(false)
expect(confirm === 'timeline.renameScriptTrackDialog.confirm').toBe(false)
})
test('renameLayer + renameClipTrack are translated', () => {
expect(i18nT('timeline.renameLayer') === 'timeline.renameLayer').toBe(false)
expect(i18nT('timeline.renameClipTrack') === 'timeline.renameClipTrack').toBe(false)
})
test('renameLayerDialog / renameClipTrackDialog titles + confirms are translated', () => {
expect(i18nT('timeline.renameLayerDialog.title') === 'timeline.renameLayerDialog.title').toBe(false)
expect(i18nT('timeline.renameLayerDialog.confirm') === 'timeline.renameLayerDialog.confirm').toBe(false)
expect(i18nT('timeline.renameClipTrackDialog.title') === 'timeline.renameClipTrackDialog.title').toBe(false)
expect(i18nT('timeline.renameClipTrackDialog.confirm') === 'timeline.renameClipTrackDialog.confirm').toBe(false)
})
test('sidebar noClips / noButtons empty-states both suggest "yet"', () => {
// Regression: the clip library used to read "No clips" while the
// button library read "No buttons yet". Minor inconsistency that
// the user called out — both should suggest "more coming", not
// "there will never be any".
expect(i18nT('sidebar.noClips')).toContain('yet')
expect(i18nT('sidebar.noButtons')).toContain('yet')
})
test('holdDisabledHint key is gone (held kfs keep easing now)', () => {
// Regression: "Still frame — easing disabled" used to appear in
// the keyframe context menu and the easing buttons were grayed
// out. A held kf's easing still matters for the segment INTO it
// from the previous keyframe, so the restriction was wrong.
// Pin that the note is no longer emitted.
const v = i18nT('timeline.holdDisabledHint')
expect(v === 'timeline.holdDisabledHint').toBe(true)
})
test('app.saveProject tooltip mentions the Ctrl/Cmd+S shortcut', () => {
// Regression: Cmd/Ctrl+S was added as a keyboard save shortcut
// alongside the dirty-state dot. Surface it in the tooltip so
// the shortcut is discoverable; assert both the idle and the
// unsaved-changes variant stay in sync with the handler.
expect(i18nT('app.saveProject')).toContain('Ctrl/Cmd+S')
expect(i18nT('app.saveProjectDirty')).toContain('Ctrl/Cmd+S')
expect(i18nT('app.saveProjectDirty')).toContain('unsaved')
})
})
suite('timeline — scrub-handle obscured logic', () => {
// Regression: the handle used to be drawn over the toggle row when the
// playhead scrolled off the left edge, *or* be half-clipped at t=0
// depending on a static z-index. Timeline.vue now flips z-index based
// on whether the playhead is still in the visible ruler area. The
// rule is `scrollLeft > pxFromTime(playhead)`. Pin that rule so a
// future refactor of the computed doesn't accidentally break either
// direction.
const obscured = (scrollLeft: number, playheadPx: number) =>
scrollLeft > playheadPx
test('t=0 with no horizontal scroll → handle is visible (not obscured)', () => {
expect(obscured(0, 0)).toBe(false)
})
test('playhead right of the scroll offset → still visible', () => {
// User scrolled 120px right but the playhead is way out at 800px in
// ruler coordinates — the handle is comfortably in the visible ruler.
expect(obscured(120, 800)).toBe(false)
})
test('playhead scrolled left behind the name-spacer → obscured', () => {
// Playhead at t=0 (0px), scroll at 200px → playhead is off-screen
// to the left of the visible ruler → hide behind name-spacer.
expect(obscured(200, 0)).toBe(true)
})
})
}