Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5962 lines
227 KiB
TypeScript
5962 lines
227 KiB
TypeScript
import { createApp, h, nextTick, type Component, type Slot } from 'vue'
|
||
import {
|
||
createBlankProject,
|
||
createBlankScene,
|
||
projectFromLegacyScene,
|
||
useSceneStore,
|
||
} from '@/stores/scene'
|
||
import {
|
||
buildMarkerMap,
|
||
evalAction,
|
||
scriptFramesInRange,
|
||
type ActionRuntime,
|
||
} from '@/lib/actions'
|
||
import { parseScript } from '@/lib/scriptDsl'
|
||
import type { Action, ButtonAsset, HitShape } from '@/types/scene'
|
||
import { BUTTON_STATE_TIMES } from '@/types/scene'
|
||
import { isLayerActiveAt, sampleLayer, lerpColor } from '@/lib/interpolate'
|
||
import { snapToCandidates } from '@/lib/timelineSnap'
|
||
import {
|
||
generateBundledScript,
|
||
generateExampleHtml,
|
||
generateRuntimeScript,
|
||
generateSceneScript,
|
||
} from '@/lib/exportPlayer'
|
||
import { ProjectFileError, parseProject, serializeProject } from '@/lib/projectFile'
|
||
import { createHistory } from '@/composables/useHistory'
|
||
import { useKfSelection, kfSelectionKey } from '@/composables/useKfSelection'
|
||
import { APP_NAME, APP_VERSION } from '@/version'
|
||
import type { PolygonAsset } from '@/types/scene'
|
||
import { drawButtonFrame, drawSceneFrame, renderButtonThumbnail } from '@/lib/render'
|
||
import { t as i18nT } from '@/i18n'
|
||
import { expandedTrackOrder } from '@/lib/sceneOrder'
|
||
import {
|
||
deleteStoredProject,
|
||
getStoredProject,
|
||
listRecentProjects,
|
||
saveProjectToStore,
|
||
} from '@/lib/projectStore'
|
||
import Modal from '@/components/ui/Modal.vue'
|
||
import IconButton from '@/components/ui/IconButton.vue'
|
||
import Toast from '@/components/ui/Toast.vue'
|
||
import PropertiesPanel from '@/components/PropertiesPanel.vue'
|
||
import ClipartDrawer from '@/components/ClipartDrawer.vue'
|
||
import SceneDrawer from '@/components/SceneDrawer.vue'
|
||
import ActionEditor from '@/components/ActionEditor.vue'
|
||
import { vTooltip } from '@/directives/tooltip'
|
||
import { expect, suite, test } from './testRunner'
|
||
|
||
interface MountedHandle {
|
||
host: HTMLDivElement
|
||
app: ReturnType<typeof createApp>
|
||
unmount: () => void
|
||
}
|
||
|
||
interface RecordingCtx {
|
||
ctx: CanvasRenderingContext2D
|
||
filterSets: string[]
|
||
compositeOps: string[]
|
||
}
|
||
|
||
function makeRecordingCtx(): RecordingCtx {
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = 200
|
||
canvas.height = 200
|
||
const real = canvas.getContext('2d')!
|
||
const filterSets: string[] = []
|
||
const compositeOps: string[] = []
|
||
const proxy = new Proxy(real, {
|
||
set(target, prop, value) {
|
||
if (prop === 'filter') filterSets.push(value as string)
|
||
if (prop === 'globalCompositeOperation') compositeOps.push(value as string)
|
||
;(target as unknown as Record<string | symbol, unknown>)[prop as string | symbol] = value
|
||
return true
|
||
},
|
||
get(target, prop) {
|
||
const v = (target as unknown as Record<string | symbol, unknown>)[
|
||
prop as string | symbol
|
||
]
|
||
if (typeof v === 'function') return v.bind(target)
|
||
return v
|
||
},
|
||
})
|
||
return { ctx: proxy as CanvasRenderingContext2D, filterSets, compositeOps }
|
||
}
|
||
|
||
function mountComponent(
|
||
component: Component,
|
||
props: Record<string, unknown>,
|
||
slots?: Record<string, Slot>,
|
||
): MountedHandle {
|
||
const host = document.createElement('div')
|
||
host.setAttribute('data-test-host', '')
|
||
document.body.appendChild(host)
|
||
const app = createApp({
|
||
render() {
|
||
return h(component, props, slots)
|
||
},
|
||
})
|
||
// Register the same global directives the real app registers in
|
||
// main.ts — without this, anything using `v-tooltip` (IconButton,
|
||
// the toolbar buttons, …) no-ops inside the test harness because
|
||
// each `mountComponent` spins up an isolated app instance.
|
||
app.directive('tooltip', vTooltip)
|
||
app.mount(host)
|
||
return {
|
||
host,
|
||
app,
|
||
unmount() {
|
||
app.unmount()
|
||
host.remove()
|
||
},
|
||
}
|
||
}
|
||
|
||
function makeFreshStore() {
|
||
const s = useSceneStore()
|
||
s.$reset()
|
||
return s
|
||
}
|
||
|
||
function tick(ms = 40): Promise<void> {
|
||
return new Promise((r) => setTimeout(r, ms))
|
||
}
|
||
|
||
export function registerAllTests(): void {
|
||
suite('scene store — defaults', () => {
|
||
test('starts with empty scene, playhead 0, not playing', () => {
|
||
const s = makeFreshStore()
|
||
expect(s.scene.layers).toHaveLength(0)
|
||
expect(s.scene.assets).toHaveLength(0)
|
||
expect(s.playhead).toBe(0)
|
||
expect(s.playing).toBe(false)
|
||
expect(s.scene.duration).toBe(10)
|
||
expect(s.scene.fps).toBe(60)
|
||
expect(s.selectedLayerId).toBeNull()
|
||
})
|
||
})
|
||
|
||
suite('scene store — playhead', () => {
|
||
test('setPlayhead clamps to [0, duration]', async () => {
|
||
const s = makeFreshStore()
|
||
s.setPlayhead(-5)
|
||
await tick()
|
||
expect(s.playhead).toBe(0)
|
||
s.setPlayhead(999)
|
||
await tick()
|
||
expect(s.playhead).toBe(s.scene.duration)
|
||
s.setPlayhead(3.5)
|
||
await tick()
|
||
expect(s.playhead).toBe(3.5)
|
||
})
|
||
|
||
test('setDuration pulls playhead back if past end', async () => {
|
||
const s = makeFreshStore()
|
||
s.setPlayhead(9)
|
||
await tick()
|
||
s.setDuration(5)
|
||
await tick()
|
||
expect(s.playhead).toBe(5)
|
||
})
|
||
|
||
test('setDuration clamps to min 0.1', () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(0)
|
||
expect(s.scene.duration).toBe(0.1)
|
||
})
|
||
})
|
||
|
||
suite('scene store — layers & selection', () => {
|
||
test('addLayer with rect asset creates layer + initial keyframe + selects it', async () => {
|
||
const s = makeFreshStore()
|
||
const asset = s.addAsset({
|
||
kind: 'rect',
|
||
name: 'TestRect',
|
||
width: 200,
|
||
height: 100,
|
||
fill: '#ff4455',
|
||
})
|
||
await tick()
|
||
const layer = s.addLayer(asset.id)!
|
||
await tick()
|
||
expect(s.scene.layers).toHaveLength(1)
|
||
expect(s.selectedLayerId).toBe(layer.id)
|
||
expect(layer.keyframes).toHaveLength(1)
|
||
expect(layer.keyframes[0].time).toBe(0)
|
||
expect(layer.offsetX).toBe(100)
|
||
expect(layer.offsetY).toBe(50)
|
||
expect(layer.base.fill).toBe('#ff4455')
|
||
})
|
||
|
||
test('removeLayer deselects if it was selected', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'circle', name: 'TestCircle', radius: 40, fill: '#44aaff' })
|
||
const l = s.addLayer(a.id)!
|
||
await tick()
|
||
s.removeLayer(l.id)
|
||
await tick()
|
||
expect(s.scene.layers).toHaveLength(0)
|
||
expect(s.selectedLayerId).toBeNull()
|
||
})
|
||
|
||
test('addLayer inserts at the top (index 0)', async () => {
|
||
const s = makeFreshStore()
|
||
const a1 = s.addAsset({ kind: 'rect', name: 'First', width: 40, height: 40, fill: '#ff4444' })
|
||
const a2 = s.addAsset({ kind: 'rect', name: 'Second', width: 40, height: 40, fill: '#44ff44' })
|
||
const l1 = s.addLayer(a1.id)!
|
||
await tick()
|
||
const l2 = s.addLayer(a2.id)!
|
||
await tick()
|
||
expect(s.scene.layers[0].id).toBe(l2.id)
|
||
expect(s.scene.layers[1].id).toBe(l1.id)
|
||
})
|
||
|
||
test('moveLayer reorders layers', async () => {
|
||
const s = makeFreshStore()
|
||
const a1 = s.addAsset({ kind: 'rect', name: 'A', width: 40, height: 40, fill: '#ff4444' })
|
||
const a2 = s.addAsset({ kind: 'rect', name: 'B', width: 40, height: 40, fill: '#44ff44' })
|
||
const a3 = s.addAsset({ kind: 'rect', name: 'C', width: 40, height: 40, fill: '#4444ff' })
|
||
const l1 = s.addLayer(a1.id)!
|
||
const l2 = s.addLayer(a2.id)!
|
||
const l3 = s.addLayer(a3.id)!
|
||
await tick()
|
||
// Insertion at top → starting order: [l3, l2, l1]
|
||
s.moveLayer(0, 2)
|
||
await tick()
|
||
expect(s.scene.layers[0].id).toBe(l2.id)
|
||
expect(s.scene.layers[1].id).toBe(l1.id)
|
||
expect(s.scene.layers[2].id).toBe(l3.id)
|
||
})
|
||
|
||
test('moveLayer ignores identical from/to', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#000' })
|
||
const l = s.addLayer(a.id)!
|
||
s.moveLayer(0, 0)
|
||
expect(s.scene.layers[0].id).toBe(l.id)
|
||
})
|
||
|
||
test('selectLayer toggles with null', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'circle', name: 'SelC', radius: 30, fill: '#00cc88' })
|
||
const l = s.addLayer(a.id)!
|
||
await tick()
|
||
s.selectLayer(null)
|
||
await tick()
|
||
expect(s.selectedLayerId).toBeNull()
|
||
s.selectLayer(l.id)
|
||
await tick()
|
||
expect(s.selectedLayerId).toBe(l.id)
|
||
})
|
||
|
||
test('selectLayer keeps selectedLayerIds in sync', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
s.selectLayer(l.id)
|
||
expect(s.selectedLayerIds).toEqual([l.id])
|
||
s.selectLayer(null)
|
||
expect(s.selectedLayerIds).toEqual([])
|
||
})
|
||
|
||
test('toggleLayerSelection adds and removes; anchor follows the last click', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const l1 = s.addLayer(a.id)!
|
||
const l2 = s.addLayer(a.id)!
|
||
const l3 = s.addLayer(a.id)!
|
||
s.selectLayer(l1.id)
|
||
s.toggleLayerSelection(l2.id)
|
||
s.toggleLayerSelection(l3.id)
|
||
expect(s.selectedLayerIds).toEqual([l1.id, l2.id, l3.id])
|
||
expect(s.selectedLayerId).toBe(l3.id)
|
||
// Removing the anchor moves the anchor back to the new last entry.
|
||
s.toggleLayerSelection(l3.id)
|
||
expect(s.selectedLayerIds).toEqual([l1.id, l2.id])
|
||
expect(s.selectedLayerId).toBe(l2.id)
|
||
// Removing the last remaining entry drops the anchor to null.
|
||
s.toggleLayerSelection(l2.id)
|
||
s.toggleLayerSelection(l1.id)
|
||
expect(s.selectedLayerIds).toEqual([])
|
||
expect(s.selectedLayerId).toBe(null)
|
||
})
|
||
|
||
test('toggleLayerSelection skips layers locked directly or by group', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const lFree = s.addLayer(a.id)!
|
||
const lDirect = s.addLayer(a.id)!
|
||
const lInGroup = s.addLayer(a.id)!
|
||
s.setTrackLocked(lDirect.id, true)
|
||
const g = s.addTrackGroup('G')
|
||
s.moveTrackIntoGroup(lInGroup.id, g.id)
|
||
s.setTrackLocked(g.id, true)
|
||
s.selectLayer(lFree.id)
|
||
s.toggleLayerSelection(lDirect.id)
|
||
s.toggleLayerSelection(lInGroup.id)
|
||
// Neither locked layer was added; the free one stays as the
|
||
// only selection.
|
||
expect(s.selectedLayerIds).toEqual([lFree.id])
|
||
})
|
||
|
||
test('groupSelectedLayers moves every selected layer into a fresh group', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const l1 = s.addLayer(a.id)!
|
||
const l2 = s.addLayer(a.id)!
|
||
const l3 = s.addLayer(a.id)!
|
||
s.selectLayer(l1.id)
|
||
s.toggleLayerSelection(l2.id)
|
||
s.toggleLayerSelection(l3.id)
|
||
const groupId = s.groupSelectedLayers()!
|
||
expect(groupId).toBeTruthy()
|
||
const g = s.scene.groups!.find((x) => x.id === groupId)!
|
||
expect(g.childIds.includes(l1.id)).toBe(true)
|
||
expect(g.childIds.includes(l2.id)).toBe(true)
|
||
expect(g.childIds.includes(l3.id)).toBe(true)
|
||
// The layers also get removed from the top-level trackOrder
|
||
// since moveTrackIntoGroup handles that.
|
||
const order = s.scene.trackOrder ?? []
|
||
expect(order.includes(l1.id)).toBe(false)
|
||
expect(order.includes(l2.id)).toBe(false)
|
||
expect(order.includes(l3.id)).toBe(false)
|
||
expect(order.includes(groupId)).toBe(true)
|
||
})
|
||
|
||
test('groupSelectedLayers returns null with an empty selection', () => {
|
||
const s = makeFreshStore()
|
||
const groupsBefore = (s.scene.groups ?? []).length
|
||
const result = s.groupSelectedLayers()
|
||
expect(result).toBe(null)
|
||
// Nothing was created.
|
||
expect((s.scene.groups ?? []).length).toBe(groupsBefore)
|
||
})
|
||
|
||
test('groupSelectedLayers replaces the topmost selected track\'s stack position', () => {
|
||
// addLayer inserts at the top (index 0), so after three addLayer
|
||
// calls trackOrder reads top→bottom: [l3, l2, l1]. Picking l2
|
||
// and l1 to group (two layers NOT at the top) should produce a
|
||
// group at index 1, not at the top of the stack.
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const l1 = s.addLayer(a.id)!
|
||
const l2 = s.addLayer(a.id)!
|
||
const l3 = s.addLayer(a.id)!
|
||
const before = [...s.scene.trackOrder!]
|
||
expect(before).toEqual([l3.id, l2.id, l1.id])
|
||
s.selectLayer(l2.id)
|
||
s.toggleLayerSelection(l1.id)
|
||
const groupId = s.groupSelectedLayers()!
|
||
const after = s.scene.trackOrder!
|
||
// Group sits where l2 used to be (index 1); l3 still at top;
|
||
// l1 and l2 are no longer top-level.
|
||
expect(after[0]).toBe(l3.id)
|
||
expect(after[1]).toBe(groupId)
|
||
expect(after.includes(l1.id)).toBe(false)
|
||
expect(after.includes(l2.id)).toBe(false)
|
||
})
|
||
|
||
test('convertSelectedLayersToClip packs selected layers into a fresh clip + instance', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
// Give each layer a unique name up-front so we can look them up
|
||
// unambiguously inside the packed clip. Bare `addLayer(asset.id)`
|
||
// falls back to the asset name — three layers would all read "R"
|
||
// and `find(l => l.name === ...)` would return the first match.
|
||
const l1 = s.addLayer(a.id, 'one')!
|
||
const l2 = s.addLayer(a.id, 'two')!
|
||
const l3 = s.addLayer(a.id, 'three')!
|
||
// Seed a non-default keyframe so we can confirm the clip owns an
|
||
// independent copy of the original layer's data.
|
||
s.upsertKeyframe(l2.id, 1.5, { x: 123 })
|
||
const kfCount = l2.keyframes.length
|
||
s.selectLayer(l1.id)
|
||
s.toggleLayerSelection(l2.id)
|
||
expect(s.selectedLayerIds.length).toBe(2)
|
||
|
||
const clipId = s.convertSelectedLayersToClip('My Clip')!
|
||
expect(clipId).toBeTruthy()
|
||
|
||
// Clip exists with the packed layers and a duration that matches
|
||
// the scene so absolute kf times still land where they were.
|
||
const clip = s.scene.clips!.find((c) => c.id === clipId)!
|
||
expect(clip.name).toBe('My Clip')
|
||
expect(clip.duration).toBe(s.scene.duration)
|
||
expect(clip.layers.length).toBe(2)
|
||
// The kf data survives the round-trip.
|
||
const clonedL2 = clip.layers.find((l) => l.name === 'two')!
|
||
expect(clonedL2).toBeTruthy()
|
||
expect(clonedL2.keyframes.length).toBe(kfCount)
|
||
expect(clonedL2.keyframes.some((k) => Math.abs(k.time - 1.5) < 1e-6)).toBe(true)
|
||
|
||
// Originals removed from scene.layers + trackOrder.
|
||
expect(s.scene.layers.some((l) => l.id === l1.id)).toBe(false)
|
||
expect(s.scene.layers.some((l) => l.id === l2.id)).toBe(false)
|
||
expect(s.scene.layers.some((l) => l.id === l3.id)).toBe(true)
|
||
expect(s.scene.trackOrder!.includes(l1.id)).toBe(false)
|
||
expect(s.scene.trackOrder!.includes(l2.id)).toBe(false)
|
||
|
||
// Exactly one clipTrack + one instance created; the instance
|
||
// sits at startTime 0 so the packed layers' absolute kf times
|
||
// continue to fire at their original scene times.
|
||
const clipTrackIds = (s.scene.clipTracks ?? []).map((t) => t.id)
|
||
const matching = (s.scene.clipInstances ?? []).filter(
|
||
(ci) => ci.clipId === clipId,
|
||
)
|
||
expect(matching.length).toBe(1)
|
||
expect(clipTrackIds.includes(matching[0].clipTrackId)).toBe(true)
|
||
expect(matching[0].startTime).toBe(0)
|
||
|
||
// The clone is independent: mutating the clip's layer doesn't
|
||
// affect the source (l2 is gone, but the data pointer shouldn't
|
||
// have been shared).
|
||
clonedL2.keyframes[0].time = 99
|
||
// (no crash means the refs are decoupled; l2 is already gone)
|
||
})
|
||
|
||
test('convertSelectedLayersToClip drops the clipTrack at the topmost selected slot', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const l1 = s.addLayer(a.id)!
|
||
const l2 = s.addLayer(a.id)!
|
||
const l3 = s.addLayer(a.id)!
|
||
// addLayer prepends → trackOrder reads top-to-bottom [l3, l2, l1].
|
||
s.selectLayer(l2.id)
|
||
s.toggleLayerSelection(l1.id)
|
||
s.convertSelectedLayersToClip()
|
||
const order = s.scene.trackOrder ?? []
|
||
// l3 is still at the top (index 0); the clipTrack replaces l2's
|
||
// old slot (index 1); l1's slot is collapsed away.
|
||
expect(order[0]).toBe(l3.id)
|
||
expect(order.length).toBe(2)
|
||
const clipTrackId = (s.scene.clipTracks ?? []).slice(-1)[0].id
|
||
expect(order[1]).toBe(clipTrackId)
|
||
})
|
||
|
||
test('convertSelectedLayersToClip returns null with an empty selection', () => {
|
||
const s = makeFreshStore()
|
||
expect(s.convertSelectedLayersToClip()).toBe(null)
|
||
expect((s.scene.clips ?? []).length).toBe(0)
|
||
expect((s.scene.clipTracks ?? []).length).toBe(0)
|
||
expect((s.scene.clipInstances ?? []).length).toBe(0)
|
||
})
|
||
|
||
test('convertSelectedLayersToClip skips effectively-locked layers', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const lFree = s.addLayer(a.id)!
|
||
const lLocked = s.addLayer(a.id)!
|
||
s.setTrackLocked(lLocked.id, true)
|
||
s.selectedLayerIds = [lFree.id, lLocked.id]
|
||
s.selectedLayerId = lLocked.id
|
||
const clipId = s.convertSelectedLayersToClip()!
|
||
const clip = s.scene.clips!.find((c) => c.id === clipId)!
|
||
expect(clip.layers.length).toBe(1)
|
||
expect(clip.layers[0].name).toBe(lFree.name)
|
||
// Locked layer stays put.
|
||
expect(s.scene.layers.some((l) => l.id === lLocked.id)).toBe(true)
|
||
})
|
||
|
||
test('groupSelectedLayers skips layers that are effectively locked', () => {
|
||
// Locked ones shouldn't land in the fresh group — they stay
|
||
// wherever they were.
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const lFree1 = s.addLayer(a.id)!
|
||
const lLocked = s.addLayer(a.id)!
|
||
const lFree2 = s.addLayer(a.id)!
|
||
s.setTrackLocked(lLocked.id, true)
|
||
// Locking a layer deselects it, so toggleLayerSelection also
|
||
// silently refuses; build the selection manually to exercise
|
||
// the filter inside `groupSelectedLayers`.
|
||
s.selectedLayerIds = [lFree1.id, lLocked.id, lFree2.id]
|
||
s.selectedLayerId = lFree2.id
|
||
const groupId = s.groupSelectedLayers()!
|
||
const g = s.scene.groups!.find((x) => x.id === groupId)!
|
||
expect(g.childIds.includes(lFree1.id)).toBe(true)
|
||
expect(g.childIds.includes(lFree2.id)).toBe(true)
|
||
expect(g.childIds.includes(lLocked.id)).toBe(false)
|
||
// The locked layer still sits in the top-level track order.
|
||
expect(s.scene.trackOrder!.includes(lLocked.id)).toBe(true)
|
||
})
|
||
})
|
||
|
||
suite('scene store — keyframes', () => {
|
||
test('upsertKeyframe adds new keyframe sorted by time', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'Kfs', width: 80, height: 80, fill: '#ffa500' })
|
||
const l = s.addLayer(a.id)!
|
||
await tick()
|
||
s.upsertKeyframe(l.id, 2, { x: 600 })
|
||
await tick()
|
||
s.upsertKeyframe(l.id, 1, { x: 300 })
|
||
await tick()
|
||
const times = l.keyframes.map((k) => k.time)
|
||
expect(times).toEqual([0, 1, 2])
|
||
})
|
||
|
||
test('upsertKeyframe updates existing keyframe within epsilon', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'Upd', width: 80, height: 80, fill: '#aa44ff' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 1, { x: 500 })
|
||
await tick()
|
||
const before = l.keyframes.length
|
||
s.upsertKeyframe(l.id, 1.0000001, { x: 900 })
|
||
await tick()
|
||
expect(l.keyframes).toHaveLength(before)
|
||
const kf = l.keyframes.find((k) => Math.abs(k.time - 1) < 0.01)!
|
||
expect(kf.props.x).toBe(900)
|
||
})
|
||
|
||
test('setKeyframeTime clamps and sorts', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'Clamp', width: 60, height: 60, fill: '#ff8844' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 3, { x: 400 })
|
||
s.upsertKeyframe(l.id, 5, { x: 800 })
|
||
await tick()
|
||
const target = l.keyframes.find((k) => k.time === 5)!
|
||
s.setKeyframeTime(l.id, target.id, -1)
|
||
await tick()
|
||
expect(target.time).toBe(0)
|
||
const times = l.keyframes.map((k) => k.time).sort((a, b) => a - b)
|
||
expect(times).toEqual(l.keyframes.map((k) => k.time))
|
||
})
|
||
|
||
test('setKeyframeTime clamps to duration upper bound', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'Dur', width: 60, height: 60, fill: '#44ffaa' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 2, { x: 400 })
|
||
await tick()
|
||
const k = l.keyframes[l.keyframes.length - 1]
|
||
s.setKeyframeTime(l.id, k.id, 999)
|
||
await tick()
|
||
expect(k.time).toBe(s.scene.duration)
|
||
})
|
||
|
||
test('removeKeyframe removes by id', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'Rm', width: 50, height: 50, fill: '#ee4488' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 2, { x: 500 })
|
||
await tick()
|
||
const k = l.keyframes.find((k) => k.time === 2)!
|
||
s.removeKeyframe(l.id, k.id)
|
||
await tick()
|
||
expect(l.keyframes.find((x) => x.id === k.id)).toBe(undefined)
|
||
})
|
||
|
||
test('duplicateKeyframe creates offset copy', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'Dup', width: 50, height: 50, fill: '#22aaee' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 1, { x: 400 })
|
||
await tick()
|
||
const src = l.keyframes.find((k) => k.time === 1)!
|
||
const newId = s.duplicateKeyframe(l.id, src.id)
|
||
await tick()
|
||
expect(newId).toBeTruthy()
|
||
const dup = l.keyframes.find((k) => k.id === newId)!
|
||
expect(dup.props.x).toBe(400)
|
||
expect(dup.time).toBeGreaterThan(1)
|
||
})
|
||
|
||
test('duplicateKeyframeAt copies props + easing + hold to an explicit time', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'Src', width: 50, height: 50, fill: '#abcdef' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 1, { x: 123, y: 45 })
|
||
const src = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-6)!
|
||
src.easing = 'easeInOut'
|
||
s.setKeyframeHold(l.id, src.id, true)
|
||
|
||
const newId = s.duplicateKeyframeAt(l.id, src.id, 3)
|
||
expect(newId).toBeTruthy()
|
||
const dup = l.keyframes.find((k) => k.id === newId)!
|
||
expect(dup.time).toBe(3)
|
||
expect(dup.props.x).toBe(123)
|
||
expect(dup.props.y).toBe(45)
|
||
expect(dup.easing).toBe('easeInOut')
|
||
expect(dup.hold).toBe(true)
|
||
// props must be a clone — mutating the duplicate must not touch the source.
|
||
dup.props.x = 999
|
||
expect(src.props.x).toBe(123)
|
||
})
|
||
|
||
test('duplicateKeyframeAt refuses to overwrite an existing keyframe', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'Col', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 1, { x: 10 })
|
||
s.upsertKeyframe(l.id, 2, { x: 20 })
|
||
const src = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-6)!
|
||
const countBefore = l.keyframes.length
|
||
const newId = s.duplicateKeyframeAt(l.id, src.id, 2)
|
||
expect(newId).toBe(null)
|
||
expect(l.keyframes.length).toBe(countBefore)
|
||
})
|
||
|
||
test('duplicateKeyframeAt returns null for unknown layer or keyframe ids', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 1, { x: 10 })
|
||
const src = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-6)!
|
||
expect(s.duplicateKeyframeAt('nope-layer', src.id, 2)).toBe(null)
|
||
expect(s.duplicateKeyframeAt(l.id, 'nope-kf', 2)).toBe(null)
|
||
})
|
||
|
||
test('duplicateKeyframeAt targets the source keyframe\'s own track (position vs main)', () => {
|
||
// Separated layers have independent main / position / rotation
|
||
// arrays. The duplicate must land in the same array the source
|
||
// came from, not accidentally jump to another.
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'Sep', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
s.setLayerSeparated(l.id, true)
|
||
s.upsertKeyframe(l.id, 1, { x: 50, y: 60 })
|
||
const posKf = l.positionKeyframes!.find((k) => Math.abs(k.time - 1) < 1e-6)!
|
||
s.duplicateKeyframeAt(l.id, posKf.id, 2)
|
||
const dup = l.positionKeyframes!.find((k) => Math.abs(k.time - 2) < 1e-6)
|
||
expect(dup).toBeTruthy()
|
||
// Nothing should have been added to the main array.
|
||
expect(l.keyframes.some((k) => Math.abs(k.time - 2) < 1e-6)).toBe(false)
|
||
})
|
||
|
||
test('setKeyframeHold toggles hold flag', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'Hold', width: 50, height: 50, fill: '#ddaa44' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 1, { x: 400 })
|
||
await tick()
|
||
const k = l.keyframes[l.keyframes.length - 1]
|
||
s.setKeyframeHold(l.id, k.id, true)
|
||
await tick()
|
||
expect(k.hold).toBe(true)
|
||
s.setKeyframeHold(l.id, k.id, false)
|
||
await tick()
|
||
expect(k.hold).toBe(undefined)
|
||
})
|
||
|
||
test('setKeyframeEasing updates easing field', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'Ease', width: 50, height: 50, fill: '#88cc44' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 1, { x: 400 })
|
||
await tick()
|
||
const k = l.keyframes[l.keyframes.length - 1]
|
||
s.setKeyframeEasing(l.id, k.id, 'easeInOut')
|
||
expect(k.easing).toBe('easeInOut')
|
||
})
|
||
})
|
||
|
||
suite('scene store — separated tracks', () => {
|
||
test('setLayerSeparated splits x/y and rotation into dedicated tracks', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'Sep', width: 60, height: 60, fill: '#ee66aa' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 1, { x: 400, y: 200, rotation: 45, opacity: 0.5 })
|
||
await tick()
|
||
s.setLayerSeparated(l.id, true)
|
||
await tick()
|
||
expect(l.separated).toBe(true)
|
||
expect((l.positionKeyframes ?? []).length).toBeGreaterThan(0)
|
||
expect((l.rotationKeyframes ?? []).length).toBeGreaterThan(0)
|
||
for (const k of l.keyframes) {
|
||
expect((k.props as Record<string, unknown>).x).toBe(undefined)
|
||
expect((k.props as Record<string, unknown>).y).toBe(undefined)
|
||
expect((k.props as Record<string, unknown>).rotation).toBe(undefined)
|
||
}
|
||
})
|
||
|
||
test('setLayerSeparated(false) merges tracks back together', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'Merge', width: 60, height: 60, fill: '#66ccee' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 1, { x: 400, y: 200, rotation: 45, opacity: 0.5 })
|
||
await tick()
|
||
s.setLayerSeparated(l.id, true)
|
||
await tick()
|
||
s.setLayerSeparated(l.id, false)
|
||
await tick()
|
||
expect(l.separated).toBe(false)
|
||
expect(l.positionKeyframes).toBe(undefined)
|
||
expect(l.rotationKeyframes).toBe(undefined)
|
||
const at1 = l.keyframes.find((k) => Math.abs(k.time - 1) < 0.001)!
|
||
expect((at1.props as Record<string, number>).x).toBe(400)
|
||
expect((at1.props as Record<string, number>).rotation).toBe(45)
|
||
})
|
||
})
|
||
|
||
suite('interpolation — sampleLayer', () => {
|
||
test('no keyframes → returns base', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#000' })
|
||
const l = s.addLayer(a.id)!
|
||
l.keyframes = []
|
||
const p = sampleLayer(l, 0.5)
|
||
expect(p.x).toBe(l.base.x)
|
||
expect(p.y).toBe(l.base.y)
|
||
})
|
||
|
||
test('linear midpoint is halfway', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#000' })
|
||
const l = s.addLayer(a.id)!
|
||
l.keyframes = [
|
||
{ id: 'k1', time: 0, props: { x: 0 }, easing: 'linear' },
|
||
{ id: 'k2', time: 2, props: { x: 100 }, easing: 'linear' },
|
||
]
|
||
l.base = { ...l.base, x: 0, y: 0 }
|
||
const p = sampleLayer(l, 1)
|
||
expect(p.x).toBeCloseTo(50, 2)
|
||
})
|
||
|
||
test('easeIn midpoint is below linear midpoint', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#000' })
|
||
const l = s.addLayer(a.id)!
|
||
l.base = { ...l.base, x: 0 }
|
||
l.keyframes = [
|
||
{ id: 'k1', time: 0, props: { x: 0 }, easing: 'linear' },
|
||
{ id: 'k2', time: 2, props: { x: 100 }, easing: 'easeIn' },
|
||
]
|
||
const p = sampleLayer(l, 1)
|
||
expect(p.x).toBeLessThan(50)
|
||
expect(p.x).toBeGreaterThan(0)
|
||
})
|
||
|
||
test('hold keyframe holds value until next keyframe', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'H', width: 10, height: 10, fill: '#000' })
|
||
const l = s.addLayer(a.id)!
|
||
l.base = { ...l.base, x: 0 }
|
||
l.keyframes = [
|
||
{ id: 'k1', time: 0, props: { x: 0 }, easing: 'linear', hold: true },
|
||
{ id: 'k2', time: 2, props: { x: 100 }, easing: 'linear' },
|
||
]
|
||
expect(sampleLayer(l, 0).x).toBe(0)
|
||
expect(sampleLayer(l, 0.5).x).toBe(0)
|
||
expect(sampleLayer(l, 1.5).x).toBe(0)
|
||
expect(sampleLayer(l, 2).x).toBe(100)
|
||
})
|
||
|
||
test('clamps before first kf to first props, after last to last props', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#000' })
|
||
const l = s.addLayer(a.id)!
|
||
l.base = { ...l.base, x: 0 }
|
||
l.keyframes = [
|
||
{ id: 'k1', time: 1, props: { x: 10 }, easing: 'linear' },
|
||
{ id: 'k2', time: 2, props: { x: 20 }, easing: 'linear' },
|
||
]
|
||
expect(sampleLayer(l, 0).x).toBe(10)
|
||
expect(sampleLayer(l, 5).x).toBe(20)
|
||
})
|
||
})
|
||
|
||
suite('interpolation — color', () => {
|
||
test('lerpColor 0 returns start', () => {
|
||
expect(lerpColor('#000000', '#ffffff', 0)).toBe('#000000')
|
||
})
|
||
test('lerpColor 1 returns end', () => {
|
||
expect(lerpColor('#000000', '#ffffff', 1)).toBe('#ffffff')
|
||
})
|
||
test('lerpColor 0.5 is around middle gray', () => {
|
||
const mid = lerpColor('#000000', '#ffffff', 0.5)
|
||
expect(mid).toBe('#808080')
|
||
})
|
||
})
|
||
|
||
suite('interpolation — shadow properties', () => {
|
||
// Regression: shadow keyframes used to render static (or nothing) in
|
||
// the preview because the runtime's lerpProps dropped shadow fields.
|
||
// The canvas-side sampleLayer must interpolate them correctly; this
|
||
// pins the contract both paths share.
|
||
|
||
function rectWithShadow() {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'Sh', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
// Seed both endpoints so there's a clear interpolation target.
|
||
s.upsertKeyframe(l.id, 0, {
|
||
shadowOpacity: 0,
|
||
shadowBlur: 0,
|
||
shadowDistance: 0,
|
||
shadowAngle: 0,
|
||
shadowColor: '#000000',
|
||
})
|
||
s.upsertKeyframe(l.id, 2, {
|
||
shadowOpacity: 1,
|
||
shadowBlur: 20,
|
||
shadowDistance: 10,
|
||
shadowAngle: 180,
|
||
shadowColor: '#ffffff',
|
||
})
|
||
return l
|
||
}
|
||
|
||
test('shadowOpacity lerps halfway between keyframes', () => {
|
||
const l = rectWithShadow()
|
||
const mid = sampleLayer(l, 1)
|
||
expect(mid.shadowOpacity).toBe(0.5)
|
||
})
|
||
|
||
test('shadowBlur / distance / angle lerp halfway between keyframes', () => {
|
||
const l = rectWithShadow()
|
||
const mid = sampleLayer(l, 1)
|
||
expect(mid.shadowBlur).toBe(10)
|
||
expect(mid.shadowDistance).toBe(5)
|
||
expect(mid.shadowAngle).toBe(90)
|
||
})
|
||
|
||
test('shadowColor is interpolated per channel (not snapped)', () => {
|
||
const l = rectWithShadow()
|
||
const mid = sampleLayer(l, 1)
|
||
// Black→white at t=0.5 lands on mid-gray (same lerpColor the
|
||
// fill-color test above pins).
|
||
expect(mid.shadowColor).toBe('#808080')
|
||
})
|
||
})
|
||
|
||
suite('interpolation — layer visibility (kf range)', () => {
|
||
test('untouched baseline (single kf @0) is always visible', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
expect(isLayerActiveAt(l, 0)).toBe(true)
|
||
expect(isLayerActiveAt(l, 5)).toBe(true)
|
||
expect(isLayerActiveAt(l, 99)).toBe(true)
|
||
})
|
||
|
||
test('single kf at any time is always visible (static layer)', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
l.keyframes[0].time = 3
|
||
expect(isLayerActiveAt(l, 0)).toBe(true)
|
||
expect(isLayerActiveAt(l, 3)).toBe(true)
|
||
expect(isLayerActiveAt(l, 5)).toBe(true)
|
||
})
|
||
|
||
test('multi-kf: visible only in [firstKf, lastKf]', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 2, { scaleX: 2 })
|
||
s.upsertKeyframe(l.id, 5, { scaleX: 3 })
|
||
// kf@0 is still there; firstKf = 0, lastKf = 5
|
||
expect(isLayerActiveAt(l, -0.1)).toBe(false)
|
||
expect(isLayerActiveAt(l, 0)).toBe(true)
|
||
expect(isLayerActiveAt(l, 3)).toBe(true)
|
||
expect(isLayerActiveAt(l, 5)).toBe(true)
|
||
expect(isLayerActiveAt(l, 6)).toBe(false)
|
||
})
|
||
|
||
test('dragging first kf forward hides layer before it', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 5, { scaleX: 2 })
|
||
// Drag the first kf from t=0 to t=2.
|
||
const first = l.keyframes.find((k) => k.time === 0)!
|
||
first.time = 2
|
||
expect(isLayerActiveAt(l, 1)).toBe(false)
|
||
expect(isLayerActiveAt(l, 2)).toBe(true)
|
||
expect(isLayerActiveAt(l, 5)).toBe(true)
|
||
expect(isLayerActiveAt(l, 6)).toBe(false)
|
||
})
|
||
|
||
test('last kf held: layer stays visible past its last keyframe', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 5, { scaleX: 2 })
|
||
// Mark the t=5 kf as hold. It's the last keyframe — used to be a
|
||
// no-op; now it means "stay on stage past the end of the anim".
|
||
const last = l.keyframes.find((k) => k.time === 5)!
|
||
s.setKeyframeHold(l.id, last.id, true)
|
||
expect(isLayerActiveAt(l, 5)).toBe(true)
|
||
expect(isLayerActiveAt(l, 6)).toBe(true)
|
||
expect(isLayerActiveAt(l, 99)).toBe(true)
|
||
// sampleLayer already pins to last.props for time >= last.time —
|
||
// confirm the value is the last keyframe's, not interpolating out.
|
||
expect(sampleLayer(l, 10).scaleX).toBe(2)
|
||
})
|
||
|
||
test('non-last held kf does NOT extend visibility past end', () => {
|
||
// Hold on a middle keyframe only changes how it interpolates TO
|
||
// the following kf; it should not keep the layer alive past the
|
||
// final keyframe when that final kf itself isn't held.
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 2, { scaleX: 2 })
|
||
s.upsertKeyframe(l.id, 5, { scaleX: 3 })
|
||
const middle = l.keyframes.find((k) => k.time === 2)!
|
||
s.setKeyframeHold(l.id, middle.id, true)
|
||
expect(isLayerActiveAt(l, 5)).toBe(true)
|
||
expect(isLayerActiveAt(l, 6)).toBe(false)
|
||
})
|
||
})
|
||
|
||
suite('timeline snap', () => {
|
||
test('disabled: returns t unchanged', () => {
|
||
const r = snapToCandidates(1.7, [2.0], {
|
||
enabled: false,
|
||
pxPerSec: 100,
|
||
pixelThreshold: 8,
|
||
duration: 10,
|
||
})
|
||
expect(r).toBe(1.7)
|
||
})
|
||
|
||
test('enabled: snaps to nearest candidate within threshold', () => {
|
||
const r = snapToCandidates(1.95, [2.0, 5.0], {
|
||
enabled: true,
|
||
pxPerSec: 100,
|
||
pixelThreshold: 8,
|
||
duration: 10,
|
||
})
|
||
expect(r).toBe(2.0)
|
||
})
|
||
|
||
test('enabled: leaves t alone if all candidates are outside threshold', () => {
|
||
const r = snapToCandidates(1.0, [2.0], {
|
||
enabled: true,
|
||
pxPerSec: 100,
|
||
pixelThreshold: 8,
|
||
duration: 10,
|
||
})
|
||
expect(r).toBe(1.0)
|
||
})
|
||
|
||
test('clamps result to [0, duration]', () => {
|
||
const r = snapToCandidates(15, [], {
|
||
enabled: true,
|
||
pxPerSec: 100,
|
||
pixelThreshold: 8,
|
||
duration: 10,
|
||
})
|
||
expect(r).toBe(10)
|
||
})
|
||
})
|
||
|
||
suite('export — runtime & scene split', () => {
|
||
test('generateRuntimeScript exposes AnimatrPlayer', () => {
|
||
const r = generateRuntimeScript()
|
||
expect(r).toContain('window.AnimatrPlayer')
|
||
expect(r).toContain('function mount')
|
||
expect(r).toContain('__animatrPending')
|
||
})
|
||
|
||
test('generateSceneScript embeds scene JSON and uses AnimatrPlayer', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'UniqRect77', width: 10, height: 10, fill: '#abc' })
|
||
s.addLayer(a.id)!
|
||
const src = generateSceneScript(s.scene)
|
||
expect(src).toContain('AnimatrPlayer.mount')
|
||
expect(src).toContain('UniqRect77')
|
||
expect(src).toContain('__animatrPending')
|
||
})
|
||
|
||
test('scene script does NOT inline the mount implementation', () => {
|
||
const s = makeFreshStore()
|
||
const src = generateSceneScript(s.scene)
|
||
expect(src.indexOf('function mount')).toBe(-1)
|
||
})
|
||
|
||
test('generateBundledScript contains runtime + scene boot', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'BundlR', width: 10, height: 10, fill: '#123' })
|
||
s.addLayer(a.id)!
|
||
const src = generateBundledScript(s.scene)
|
||
expect(src).toContain('function mount')
|
||
expect(src).toContain('AnimatrPlayer.mount')
|
||
expect(src).toContain('BundlR')
|
||
})
|
||
|
||
test('runtime lerpProps interpolates shadow + blur keyframes', () => {
|
||
// Regression: shadow properties (shadowBlur/Opacity/Angle/Distance/
|
||
// Color) and blur used to be dropped by the runtime's lerpProps,
|
||
// so an animation that fades a shadow in the editor played flat
|
||
// in the preview / exported HTML. The runtime must carry them
|
||
// through the per-pair interpolation.
|
||
const r = generateRuntimeScript()
|
||
expect(r).toContain('shadowBlur')
|
||
expect(r).toContain('shadowOpacity')
|
||
expect(r).toContain('shadowAngle')
|
||
expect(r).toContain('shadowDistance')
|
||
expect(r).toContain('shadowColor')
|
||
expect(r).toContain('out.blur')
|
||
})
|
||
|
||
test('runtime wraps each clip instance in translate + rotate + scale', () => {
|
||
// Pin that the exported runtime's per-clip draw loop honours
|
||
// ClipInstance.x/y/scaleX/scaleY/rotation — the canvas editor
|
||
// and the exported HTML stay visually identical for any
|
||
// clip-instance transform.
|
||
const r = generateRuntimeScript()
|
||
expect(r).toContain('inst2.x')
|
||
expect(r).toContain('inst2.y')
|
||
expect(r).toContain('inst2.scaleX')
|
||
expect(r).toContain('inst2.scaleY')
|
||
expect(r).toContain('inst2.rotation')
|
||
expect(r).toContain('ctx.rotate(irot2*Math.PI/180)')
|
||
expect(r).toContain('ctx.scale(isx2,isy2)')
|
||
})
|
||
|
||
test('runtime rangeOut falls back to scene.duration (not last keyframe)', () => {
|
||
// Regression: the runtime used to fall back to `effectiveEnd(scene)`
|
||
// — the time of the last keyframe — when scene.outTime was unset.
|
||
// That made fullscreen preview loop at the last kf, ignoring any
|
||
// blank tail before the OUT mark and any still-frame-past-end
|
||
// extension. The fallback is now scene.duration, matching the
|
||
// editor's `playbackOut` getter.
|
||
const r = generateRuntimeScript()
|
||
// The old helper must be gone entirely.
|
||
expect(r.indexOf('effectiveEnd')).toBe(-1)
|
||
// And the current fallback must be scene.duration.
|
||
expect(r).toContain('scene.outTime==null?scene.duration:scene.outTime')
|
||
})
|
||
|
||
test('export comments and console prefix use RUWIO, not Animatr', () => {
|
||
// Rebrand: generated comment headers, the runtime's console warn
|
||
// prefix, and the example-HTML chrome all read RUWIO now. If any
|
||
// of these come back as "Animatr", we regressed.
|
||
const s = makeFreshStore()
|
||
const runtime = generateRuntimeScript()
|
||
expect(runtime).toContain('RUWIO runtime')
|
||
expect(runtime.indexOf('/* Animatr ')).toBe(-1)
|
||
// Console prefix used by the runtime's action evaluator.
|
||
expect(runtime).toContain("'[ruwio]'")
|
||
expect(runtime.indexOf("'[animatr]'")).toBe(-1)
|
||
|
||
const sceneScript = generateSceneScript(s.scene)
|
||
expect(sceneScript).toContain('RUWIO scene')
|
||
expect(sceneScript).toContain('ruwio-runtime.js')
|
||
|
||
const bundled = generateBundledScript(s.scene)
|
||
expect(bundled).toContain('RUWIO bundled export')
|
||
expect(bundled.indexOf('/* Animatr ')).toBe(-1)
|
||
})
|
||
|
||
test('generated example HTML is RUWIO-branded', () => {
|
||
// The example HTML is what users see when they drop the file
|
||
// into a browser. Title, heading, and the embed-usage snippet
|
||
// all need to carry the new brand.
|
||
const s = makeFreshStore()
|
||
const html = generateExampleHtml(s.scene)
|
||
expect(html).toContain('<title>RUWIO — Example</title>')
|
||
expect(html).toContain('<h1>RUWIO Export</h1>')
|
||
// The usage snippet now references ruwio-runtime.js.
|
||
expect(html).toContain('ruwio-runtime.js')
|
||
expect(html.indexOf('animatr-runtime.js')).toBe(-1)
|
||
})
|
||
})
|
||
|
||
suite('playback — transport', () => {
|
||
test('togglePlay toggles playing state', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'Play', width: 60, height: 60, fill: '#55cc66' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 2, { x: 500 })
|
||
await tick()
|
||
s.togglePlay()
|
||
await tick()
|
||
expect(s.playing).toBe(true)
|
||
s.togglePlay()
|
||
await tick()
|
||
expect(s.playing).toBe(false)
|
||
})
|
||
|
||
test('togglePlay rewinds if at end', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'Rew', width: 60, height: 60, fill: '#cc5555' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 2, { x: 600 })
|
||
s.setPlayhead(s.playbackOut)
|
||
await tick()
|
||
s.togglePlay()
|
||
expect(s.playhead).toBe(s.playbackIn)
|
||
expect(s.playing).toBe(true)
|
||
s.pause()
|
||
await tick()
|
||
})
|
||
|
||
test('tick advances playhead while playing', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'Tk', width: 60, height: 60, fill: '#9977dd' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 5, { x: 800 })
|
||
s.play()
|
||
s.tick(1)
|
||
s.pause()
|
||
expect(s.playhead).toBeCloseTo(1, 3)
|
||
await tick()
|
||
})
|
||
|
||
test('tick stops at playbackOut', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'End', width: 60, height: 60, fill: '#dd7799' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 2, { x: 400 })
|
||
s.play()
|
||
await tick()
|
||
s.tick(s.scene.duration + 1)
|
||
await tick()
|
||
expect(s.playing).toBe(false)
|
||
expect(s.playhead).toBe(s.playbackOut)
|
||
})
|
||
})
|
||
|
||
suite('project file — save & load roundtrip', () => {
|
||
test('serialize + parse roundtrip preserves scene', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'PF', width: 80, height: 60, fill: '#66ddaa' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 1, { x: 400, rotation: 30 })
|
||
s.setDuration(7)
|
||
await tick()
|
||
const text = serializeProject(s.project)
|
||
const parsed = parseProject(text)
|
||
expect(parsed.duration).toBe(7)
|
||
expect(parsed.assets).toHaveLength(1)
|
||
const page = parsed.scenes[0]
|
||
expect(page.layers).toHaveLength(1)
|
||
expect(page.layers[0].keyframes.find((k) => k.time === 1)?.props.x).toBe(400)
|
||
})
|
||
|
||
test('loadProject replaces store state visibly', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'L1', width: 80, height: 60, fill: '#aa6666' })
|
||
s.addLayer(a.id)!
|
||
await tick()
|
||
const dump = serializeProject(s.project)
|
||
s.$reset()
|
||
await tick()
|
||
s.loadProject(parseProject(dump))
|
||
await tick()
|
||
expect(s.scene.layers).toHaveLength(1)
|
||
expect(s.scene.assets).toHaveLength(1)
|
||
expect(s.selectedLayerId).toBeNull()
|
||
expect(s.playhead).toBe(0)
|
||
expect(s.playing).toBe(false)
|
||
})
|
||
|
||
test('parseProject rejects non-JSON', () => {
|
||
let caught: unknown = null
|
||
try {
|
||
parseProject('not json at all')
|
||
} catch (e) {
|
||
caught = e
|
||
}
|
||
expect(caught instanceof ProjectFileError).toBe(true)
|
||
})
|
||
|
||
test('parseProject rejects foreign JSON without magic', () => {
|
||
let caught: unknown = null
|
||
try {
|
||
parseProject(JSON.stringify({ foo: 'bar' }))
|
||
} catch (e) {
|
||
caught = e
|
||
}
|
||
expect(caught instanceof ProjectFileError).toBe(true)
|
||
})
|
||
|
||
test('parseProject rejects missing scene fields', () => {
|
||
let caught: unknown = null
|
||
try {
|
||
parseProject(JSON.stringify({ magic: 'animatr-project', fileVersion: 1, scene: {} }))
|
||
} catch (e) {
|
||
caught = e
|
||
}
|
||
expect(caught instanceof ProjectFileError).toBe(true)
|
||
})
|
||
|
||
test('legacy `animatr-project` magic still parses (backward compat)', () => {
|
||
// When we rebranded to RUWIO we kept the on-disk magic as
|
||
// `animatr-project` so older exports continue to load. Pin that
|
||
// so a future cleanup doesn't accidentally break existing user
|
||
// files on disk.
|
||
const s = makeFreshStore()
|
||
s.addAsset({ kind: 'rect', name: 'Legacy', width: 10, height: 10, fill: '#123' })
|
||
const legacyPayload = JSON.parse(serializeProject(s.project))
|
||
expect(legacyPayload.magic).toBe('animatr-project')
|
||
// And a fully-formed legacy file parses without error.
|
||
const parsed = parseProject(JSON.stringify(legacyPayload))
|
||
expect(parsed.assets.some((a) => a.name === 'Legacy')).toBe(true)
|
||
})
|
||
|
||
test('parseProject error mentions RUWIO, not Animatr', () => {
|
||
// The user-visible error when a non-project JSON is dropped in
|
||
// reads "Not a RUWIO project file …" now. If you rebrand again
|
||
// remember to update this test alongside the string.
|
||
let msg = ''
|
||
try {
|
||
parseProject(JSON.stringify({ not: 'a project' }))
|
||
} catch (e) {
|
||
msg = (e as Error).message
|
||
}
|
||
expect(msg).toContain('RUWIO')
|
||
expect(msg.toLowerCase().includes('animatr')).toBe(false)
|
||
})
|
||
|
||
test('parseProject wraps legacy { scene } payload into Project', () => {
|
||
// Pre-v0.9 files had `{ magic, fileVersion: 1, scene }`. The new
|
||
// parser lifts those into a one-page Project so users don't have
|
||
// to re-save. Canvas + assets + layers must all land intact.
|
||
const s = makeFreshStore()
|
||
s.addAsset({ kind: 'rect', name: 'LegacyRect', width: 10, height: 10, fill: '#123' })
|
||
const legacy = {
|
||
magic: 'animatr-project',
|
||
fileVersion: 1,
|
||
scene: JSON.parse(JSON.stringify(s.scene)),
|
||
}
|
||
const parsed = parseProject(JSON.stringify(legacy))
|
||
expect(Array.isArray(parsed.scenes)).toBe(true)
|
||
expect(parsed.scenes).toHaveLength(1)
|
||
expect(parsed.activeSceneId).toBe(parsed.scenes[0].id)
|
||
expect(parsed.assets.some((a) => a.name === 'LegacyRect')).toBe(true)
|
||
expect(parsed.canvas.width).toBe(s.project.canvas.width)
|
||
})
|
||
})
|
||
|
||
suite('scene store — project & scenes', () => {
|
||
test('merged `scene` getter mirrors the active page\'s arrays by reference', () => {
|
||
// Reads through store.scene must share the SAME array references
|
||
// as the active ScenePage so `store.scene.layers.push(x)` hits
|
||
// `activeScene.layers`. If they drift, mutations silently vanish.
|
||
const s = makeFreshStore()
|
||
expect(s.scene.layers).toBe(s.activeScene.layers)
|
||
expect(s.scene.clipInstances).toBe(s.activeScene.clipInstances)
|
||
expect(s.scene.trackOrder).toBe(s.activeScene.trackOrder)
|
||
// Shared fields (asset library, canvas) come from the project.
|
||
expect(s.scene.assets).toBe(s.project.assets)
|
||
expect(s.scene.canvas).toBe(s.project.canvas)
|
||
expect(s.scene.clips).toBe(s.project.clips)
|
||
})
|
||
|
||
test('createBlankProject seeds exactly one page named "Scene 1"', () => {
|
||
const p = createBlankProject()
|
||
expect(p.scenes).toHaveLength(1)
|
||
expect(p.scenes[0].name).toBe('Scene 1')
|
||
expect(p.activeSceneId).toBe(p.scenes[0].id)
|
||
})
|
||
|
||
test('addScene appends, activates, and picks a sensible default name', () => {
|
||
const s = makeFreshStore()
|
||
expect(s.project.scenes).toHaveLength(1)
|
||
const two = s.addScene()
|
||
expect(s.project.scenes).toHaveLength(2)
|
||
expect(two.name).toBe('Scene 2')
|
||
expect(s.project.activeSceneId).toBe(two.id)
|
||
const three = s.addScene()
|
||
expect(three.name).toBe('Scene 3')
|
||
})
|
||
|
||
test('addScene respects an explicit trimmed name', () => {
|
||
const s = makeFreshStore()
|
||
const intro = s.addScene(' Intro ')
|
||
expect(intro.name).toBe('Intro')
|
||
})
|
||
|
||
test('switching scenes swaps layer stacks without touching the shared library', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'Shared', width: 10, height: 10, fill: '#abc' })
|
||
const l1 = s.addLayer(a.id, 'in-scene-1')!
|
||
const two = s.addScene('Two')
|
||
// After switch: active page has no layers; the asset library stays.
|
||
expect(s.scene.layers).toHaveLength(0)
|
||
expect(s.scene.assets.some((x) => x.id === a.id)).toBe(true)
|
||
const l2 = s.addLayer(a.id, 'in-scene-2')!
|
||
// Flip back to scene 1 and confirm its layer is intact.
|
||
s.switchScene(s.project.scenes[0].id)
|
||
expect(s.scene.layers).toHaveLength(1)
|
||
expect(s.scene.layers[0].id).toBe(l1.id)
|
||
// And scene 2 still owns its own layer.
|
||
s.switchScene(two.id)
|
||
expect(s.scene.layers).toHaveLength(1)
|
||
expect(s.scene.layers[0].id).toBe(l2.id)
|
||
})
|
||
|
||
test('switchScene clears editor context (selection, clip-edit, drawing) and auto-plays', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'SW', width: 10, height: 10, fill: '#111' })
|
||
const l = s.addLayer(a.id)!
|
||
s.selectLayer(l.id)
|
||
s.pause()
|
||
const clip = s.addClip('C')
|
||
s.activeClipId = clip.id
|
||
const two = s.addScene()
|
||
// addScene already routed through switchScene — cross-check.
|
||
expect(s.selectedLayerId).toBeNull()
|
||
expect(s.selectedLayerIds).toHaveLength(0)
|
||
expect(s.activeClipId).toBeNull()
|
||
expect(s.activeButtonAssetId).toBeNull()
|
||
expect(s.project.activeSceneId).toBe(two.id)
|
||
// Switching always auto-plays on the new page — even if the
|
||
// editor was paused beforehand.
|
||
expect(s.playing).toBe(true)
|
||
})
|
||
|
||
test('switchScene auto-plays even when the editor was paused', () => {
|
||
const s = makeFreshStore()
|
||
s.pause()
|
||
const second = s.addScene('Two')
|
||
const firstId = s.project.scenes[0].id
|
||
s.pause()
|
||
expect(s.playing).toBe(false)
|
||
s.switchScene(firstId)
|
||
expect(s.playing).toBe(true)
|
||
expect(s.project.activeSceneId).toBe(firstId)
|
||
// And the inverse direction — Two plays too.
|
||
s.pause()
|
||
s.switchScene(second.id)
|
||
expect(s.playing).toBe(true)
|
||
})
|
||
|
||
test('switchScene returns false for an unknown id and leaves state alone', () => {
|
||
const s = makeFreshStore()
|
||
const before = s.project.activeSceneId
|
||
const ok = s.switchScene('nope-no-such-scene')
|
||
expect(ok).toBe(false)
|
||
expect(s.project.activeSceneId).toBe(before)
|
||
})
|
||
|
||
test('renameScene trims and rejects empty names', () => {
|
||
const s = makeFreshStore()
|
||
const id = s.project.activeSceneId
|
||
expect(s.renameScene(id, ' Hello ')).toBe(true)
|
||
expect(s.project.scenes[0].name).toBe('Hello')
|
||
expect(s.renameScene(id, ' ')).toBe(false)
|
||
expect(s.project.scenes[0].name).toBe('Hello')
|
||
})
|
||
|
||
test('removeScene refuses to delete the last remaining page', () => {
|
||
const s = makeFreshStore()
|
||
const only = s.project.activeSceneId
|
||
expect(s.removeScene(only)).toBe(false)
|
||
expect(s.project.scenes).toHaveLength(1)
|
||
})
|
||
|
||
test('removeScene on the last page activates its predecessor', () => {
|
||
// [A, B, C] active=C → remove C → [A, B] active=B.
|
||
// The "neighbour" policy slides the active marker back one slot
|
||
// when there's nothing further down the list.
|
||
const s = makeFreshStore()
|
||
const a = s.project.scenes[0].id
|
||
const b = s.addScene('B').id
|
||
const c = s.addScene('C').id
|
||
expect(s.project.activeSceneId).toBe(c)
|
||
expect(s.removeScene(c)).toBe(true)
|
||
expect(s.project.activeSceneId).toBe(b)
|
||
expect(s.project.scenes.map((p) => p.id)).toEqual([a, b])
|
||
})
|
||
|
||
test('removeScene on a middle page activates the next one', () => {
|
||
// [A, B, C] active=B → remove B → [A, C] active=C.
|
||
// Forward is the default direction; previous is only used when
|
||
// there is no next.
|
||
const s = makeFreshStore()
|
||
const a = s.project.scenes[0].id
|
||
const b = s.addScene('B').id
|
||
const c = s.addScene('C').id
|
||
s.switchScene(b)
|
||
expect(s.project.activeSceneId).toBe(b)
|
||
expect(s.removeScene(b)).toBe(true)
|
||
expect(s.project.activeSceneId).toBe(c)
|
||
expect(s.project.scenes.map((p) => p.id)).toEqual([a, c])
|
||
})
|
||
|
||
test('removeScene on an inactive page leaves the active one untouched', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.project.scenes[0].id
|
||
const b = s.addScene('B').id
|
||
const c = s.addScene('C').id
|
||
expect(s.project.activeSceneId).toBe(c)
|
||
expect(s.removeScene(b)).toBe(true)
|
||
expect(s.project.activeSceneId).toBe(c)
|
||
expect(s.project.scenes.map((p) => p.id)).toEqual([a, c])
|
||
})
|
||
|
||
test('duplicateScene deep-clones with fresh ids and activates the copy', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'D', width: 10, height: 10, fill: '#abc' })
|
||
const l = s.addLayer(a.id, 'Orig')!
|
||
s.upsertKeyframe(l.id, 1, { x: 20 })
|
||
const origId = s.project.activeSceneId
|
||
const origLayerId = l.id
|
||
const origKfIds = new Set(l.keyframes.map((k) => k.id))
|
||
|
||
const copy = s.duplicateScene(origId)!
|
||
expect(copy.id === origId).toBe(false)
|
||
expect(copy.name).toBe(`${s.project.scenes[0].name} copy`)
|
||
expect(s.project.activeSceneId).toBe(copy.id)
|
||
// Clone sits directly after the original.
|
||
const idx = s.project.scenes.findIndex((p) => p.id === origId)
|
||
expect(s.project.scenes[idx + 1].id).toBe(copy.id)
|
||
// Fresh layer + keyframe ids.
|
||
const clonedLayer = copy.layers[0]
|
||
expect(clonedLayer.id === origLayerId).toBe(false)
|
||
for (const k of clonedLayer.keyframes) expect(origKfIds.has(k.id)).toBe(false)
|
||
// Deep-cloned — mutating the clone leaves original untouched.
|
||
clonedLayer.base.x = 999
|
||
const origPage = s.project.scenes.find((p) => p.id === origId)!
|
||
expect(origPage.layers[0].base.x === 999).toBe(false)
|
||
})
|
||
|
||
test('projectFromLegacyScene wraps a single Scene into a one-page Project', () => {
|
||
const s = makeFreshStore()
|
||
s.addAsset({ kind: 'rect', name: 'LG', width: 10, height: 10, fill: '#abc' })
|
||
const project = projectFromLegacyScene(JSON.parse(JSON.stringify(s.scene)), 'My Page')
|
||
expect(project.scenes).toHaveLength(1)
|
||
expect(project.scenes[0].name).toBe('My Page')
|
||
expect(project.activeSceneId).toBe(project.scenes[0].id)
|
||
expect(project.assets.some((a) => a.name === 'LG')).toBe(true)
|
||
})
|
||
})
|
||
|
||
suite('history — undo/redo', () => {
|
||
test('canUndo starts false; becomes true after a committed mutation', async () => {
|
||
const s = makeFreshStore()
|
||
const h = createHistory(s, { debounceMs: 10 })
|
||
h.start()
|
||
expect(h.canUndo.value).toBe(false)
|
||
const a = s.addAsset({ kind: 'rect', name: 'H1', width: 10, height: 10, fill: '#000' })
|
||
s.addLayer(a.id)!
|
||
await tick(60)
|
||
expect(h.canUndo.value).toBe(true)
|
||
h.stop()
|
||
})
|
||
|
||
test('undo reverts a mutation, redo reapplies', async () => {
|
||
const s = makeFreshStore()
|
||
const h = createHistory(s, { debounceMs: 10 })
|
||
h.start()
|
||
const a = s.addAsset({ kind: 'rect', name: 'H2', width: 10, height: 10, fill: '#000' })
|
||
s.addLayer(a.id)!
|
||
await tick(60)
|
||
expect(s.scene.layers).toHaveLength(1)
|
||
h.undo()
|
||
await tick(20)
|
||
expect(s.scene.layers).toHaveLength(0)
|
||
expect(h.canRedo.value).toBe(true)
|
||
h.redo()
|
||
await tick(20)
|
||
expect(s.scene.layers).toHaveLength(1)
|
||
h.stop()
|
||
})
|
||
|
||
test('rapid mutations within debounce window collapse into one step', async () => {
|
||
const s = makeFreshStore()
|
||
const h = createHistory(s, { debounceMs: 50 })
|
||
h.start()
|
||
const a = s.addAsset({ kind: 'rect', name: 'H3', width: 10, height: 10, fill: '#000' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 1, { x: 100 })
|
||
s.upsertKeyframe(l.id, 1, { x: 200 })
|
||
s.upsertKeyframe(l.id, 1, { x: 300 })
|
||
await tick(120)
|
||
h.undo()
|
||
await tick(20)
|
||
expect(s.scene.layers).toHaveLength(0)
|
||
h.stop()
|
||
})
|
||
|
||
test('mutation after undo drops the redo branch', async () => {
|
||
const s = makeFreshStore()
|
||
const h = createHistory(s, { debounceMs: 10 })
|
||
h.start()
|
||
const a = s.addAsset({ kind: 'rect', name: 'H4', width: 10, height: 10, fill: '#000' })
|
||
s.addLayer(a.id)!
|
||
await tick(40)
|
||
h.undo()
|
||
await tick(20)
|
||
expect(h.canRedo.value).toBe(true)
|
||
const b = s.addAsset({ kind: 'circle', name: 'H4c', radius: 10, fill: '#fff' })
|
||
s.addLayer(b.id)!
|
||
await tick(40)
|
||
expect(h.canRedo.value).toBe(false)
|
||
h.stop()
|
||
})
|
||
|
||
test('resetTo clears history and seeds initial snapshot', async () => {
|
||
const s = makeFreshStore()
|
||
const h = createHistory(s, { debounceMs: 10 })
|
||
h.start()
|
||
const a = s.addAsset({ kind: 'rect', name: 'H5', width: 10, height: 10, fill: '#000' })
|
||
s.addLayer(a.id)!
|
||
await tick(40)
|
||
expect(h.canUndo.value).toBe(true)
|
||
h.resetTo(s.scene)
|
||
expect(h.canUndo.value).toBe(false)
|
||
expect(h.canRedo.value).toBe(false)
|
||
h.stop()
|
||
})
|
||
|
||
test('undo clamps playhead when target scene has shorter duration', async () => {
|
||
const s = makeFreshStore()
|
||
const h = createHistory(s, { debounceMs: 10 })
|
||
h.start()
|
||
s.setDuration(3)
|
||
await tick(40)
|
||
s.setDuration(10)
|
||
s.setPlayhead(8)
|
||
await tick(40)
|
||
h.undo()
|
||
await tick(20)
|
||
expect(s.scene.duration).toBe(3)
|
||
expect(s.playhead).toBe(3)
|
||
h.stop()
|
||
})
|
||
|
||
test('undo after switchScene restores the previous activeSceneId', async () => {
|
||
// History snapshots the whole project including activeSceneId, so
|
||
// a scene switch is itself an undoable step. If this regresses the
|
||
// user will notice a sudden loss of "undo back to where I was".
|
||
const s = makeFreshStore()
|
||
const h = createHistory(s, { debounceMs: 10 })
|
||
h.start()
|
||
const a = s.project.scenes[0].id
|
||
const b = s.addScene('B').id
|
||
await tick(40)
|
||
// Now switch back to A; that's a fresh snapshottable step.
|
||
s.switchScene(a)
|
||
await tick(40)
|
||
expect(s.project.activeSceneId).toBe(a)
|
||
h.undo()
|
||
await tick(20)
|
||
expect(s.project.activeSceneId).toBe(b)
|
||
h.stop()
|
||
})
|
||
|
||
test('undo after addScene removes the just-added page', async () => {
|
||
const s = makeFreshStore()
|
||
const h = createHistory(s, { debounceMs: 10 })
|
||
h.start()
|
||
expect(s.project.scenes).toHaveLength(1)
|
||
const origId = s.project.activeSceneId
|
||
s.addScene('B')
|
||
await tick(40)
|
||
expect(s.project.scenes).toHaveLength(2)
|
||
h.undo()
|
||
await tick(20)
|
||
expect(s.project.scenes).toHaveLength(1)
|
||
expect(s.project.activeSceneId).toBe(origId)
|
||
h.stop()
|
||
})
|
||
|
||
test('undo after removeScene restores the deleted page content', async () => {
|
||
const s = makeFreshStore()
|
||
const h = createHistory(s, { debounceMs: 10 })
|
||
h.start()
|
||
const a = s.addAsset({ kind: 'rect', name: 'U', width: 10, height: 10, fill: '#abc' })
|
||
const two = s.addScene('Two')
|
||
s.addLayer(a.id, 'only-in-two')
|
||
await tick(40)
|
||
expect(s.scene.layers).toHaveLength(1)
|
||
const twoId = two.id
|
||
// Flip away, then delete. Both are undoable steps.
|
||
s.switchScene(s.project.scenes[0].id)
|
||
await tick(40)
|
||
s.removeScene(twoId)
|
||
await tick(40)
|
||
expect(s.project.scenes.some((p) => p.id === twoId)).toBe(false)
|
||
h.undo()
|
||
await tick(20)
|
||
const restored = s.project.scenes.find((p) => p.id === twoId)
|
||
expect(restored).toBeTruthy()
|
||
expect(restored!.layers).toHaveLength(1)
|
||
expect(restored!.layers[0].name).toBe('only-in-two')
|
||
h.stop()
|
||
})
|
||
|
||
test('max history cap drops oldest entries', async () => {
|
||
const s = makeFreshStore()
|
||
const h = createHistory(s, { debounceMs: 10, max: 3 })
|
||
h.start()
|
||
s.setDuration(2)
|
||
await tick(30)
|
||
s.setDuration(3)
|
||
await tick(30)
|
||
s.setDuration(4)
|
||
await tick(30)
|
||
s.setDuration(5)
|
||
await tick(30)
|
||
h.undo()
|
||
h.undo()
|
||
h.undo()
|
||
await tick(20)
|
||
expect(s.scene.duration).toBeGreaterThan(2)
|
||
h.stop()
|
||
})
|
||
})
|
||
|
||
suite('ui — Modal', () => {
|
||
// Modal teleports to document.body. If the running app has a
|
||
// dialog open (welcome / export / settings), a plain
|
||
// document.querySelector('[role="dialog"]') would match that
|
||
// instead of what the test just mounted. Snapshot the live
|
||
// dialogs first and diff after mount so each assertion only
|
||
// considers the nodes this test created.
|
||
const snapshotDialogs = () =>
|
||
new Set(Array.from(document.querySelectorAll('[role="dialog"]')))
|
||
const dialogsAddedSince = (baseline: Set<Element>) =>
|
||
Array.from(document.querySelectorAll('[role="dialog"]')).filter(
|
||
(el) => !baseline.has(el),
|
||
)
|
||
|
||
test('renders nothing when modelValue is false', async () => {
|
||
const baseline = snapshotDialogs()
|
||
const handle = mountComponent(Modal, { modelValue: false, title: 'X' })
|
||
await nextTick()
|
||
expect(dialogsAddedSince(baseline).length).toBe(0)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('teleports backdrop + dialog to body when open, renders title', async () => {
|
||
const baseline = snapshotDialogs()
|
||
const handle = mountComponent(Modal, { modelValue: true, title: 'Hallo Modal' })
|
||
await nextTick()
|
||
const added = dialogsAddedSince(baseline)
|
||
expect(added.length).toBe(1)
|
||
const dialog = added[0] as HTMLElement
|
||
expect(dialog.textContent?.includes('Hallo Modal')).toBe(true)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('Esc triggers update:modelValue false', async () => {
|
||
let latest: boolean | null = null
|
||
const handle = mountComponent(Modal, {
|
||
modelValue: true,
|
||
title: 'X',
|
||
'onUpdate:modelValue': (v: boolean) => {
|
||
latest = v
|
||
},
|
||
})
|
||
await nextTick()
|
||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
||
await nextTick()
|
||
expect(latest).toBe(false)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('Esc does nothing when closeOnEsc is false', async () => {
|
||
let latest: boolean | null = null
|
||
const handle = mountComponent(Modal, {
|
||
modelValue: true,
|
||
title: 'X',
|
||
closeOnEsc: false,
|
||
'onUpdate:modelValue': (v: boolean) => {
|
||
latest = v
|
||
},
|
||
})
|
||
await nextTick()
|
||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
||
await nextTick()
|
||
expect(latest).toBeNull()
|
||
handle.unmount()
|
||
})
|
||
|
||
test('dismissable=false hides close button and swallows Esc', async () => {
|
||
let latest: boolean | null = null
|
||
const baseline = snapshotDialogs()
|
||
const handle = mountComponent(Modal, {
|
||
modelValue: true,
|
||
title: 'Busy',
|
||
dismissable: false,
|
||
'onUpdate:modelValue': (v: boolean) => {
|
||
latest = v
|
||
},
|
||
})
|
||
await nextTick()
|
||
const added = dialogsAddedSince(baseline)
|
||
expect(added.length).toBe(1)
|
||
const dialog = added[0] as HTMLElement
|
||
const btn = dialog.querySelector('header button')
|
||
expect(btn).toBeNull()
|
||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
||
await nextTick()
|
||
expect(latest).toBeNull()
|
||
handle.unmount()
|
||
})
|
||
|
||
test('renders footer slot content', async () => {
|
||
const handle = mountComponent(
|
||
Modal,
|
||
{ modelValue: true, title: 'X' },
|
||
{
|
||
footer: () => [h('button', { class: 'my-footer-btn' }, 'Fertig')],
|
||
},
|
||
)
|
||
await nextTick()
|
||
const btn = document.querySelector('.my-footer-btn')
|
||
expect(btn).toBeTruthy()
|
||
expect(btn!.textContent).toBe('Fertig')
|
||
handle.unmount()
|
||
})
|
||
})
|
||
|
||
suite('ui — IconButton', () => {
|
||
test('renders a button element', async () => {
|
||
// IconButton now uses the custom v-tooltip directive instead of
|
||
// the native `title` attribute. The directive still sets an
|
||
// `aria-label` so screen readers get the tooltip text — that's
|
||
// the stable contract this test pins.
|
||
const handle = mountComponent(IconButton, { title: 'hi' })
|
||
await nextTick()
|
||
const btn = handle.host.querySelector('button')
|
||
expect(btn).toBeTruthy()
|
||
expect(btn!.getAttribute('aria-label')).toBe('hi')
|
||
handle.unmount()
|
||
})
|
||
|
||
test('applies variant + size + active classes', async () => {
|
||
const handle = mountComponent(IconButton, {
|
||
variant: 'primary',
|
||
size: 'md',
|
||
active: true,
|
||
})
|
||
await nextTick()
|
||
const btn = handle.host.querySelector('button')!
|
||
expect(btn.className.includes('variant-primary')).toBe(true)
|
||
expect(btn.className.includes('size-md')).toBe(true)
|
||
expect(btn.className.includes('active')).toBe(true)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('disabled prop disables the native button', async () => {
|
||
const handle = mountComponent(IconButton, { disabled: true })
|
||
await nextTick()
|
||
const btn = handle.host.querySelector('button')!
|
||
expect(btn.disabled).toBe(true)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('renders default slot in place of icon prop', async () => {
|
||
const handle = mountComponent(
|
||
IconButton,
|
||
{},
|
||
{ default: () => [h('span', { class: 'custom-icon' }, 'X')] },
|
||
)
|
||
await nextTick()
|
||
const span = handle.host.querySelector('.custom-icon')
|
||
expect(span).toBeTruthy()
|
||
expect(span!.textContent).toBe('X')
|
||
handle.unmount()
|
||
})
|
||
|
||
test('click fires native click event', async () => {
|
||
let clicks = 0
|
||
const handle = mountComponent(IconButton, {
|
||
onClick: () => {
|
||
clicks++
|
||
},
|
||
})
|
||
await nextTick()
|
||
handle.host.querySelector('button')!.click()
|
||
expect(clicks).toBe(1)
|
||
handle.unmount()
|
||
})
|
||
})
|
||
|
||
suite('ui — Toast', () => {
|
||
test('hidden when visible=false, nothing in body', async () => {
|
||
const handle = mountComponent(Toast, { visible: false, message: 'x' })
|
||
await nextTick()
|
||
expect(document.querySelector('.toast')).toBeNull()
|
||
handle.unmount()
|
||
})
|
||
|
||
test('visible=true renders a teleported element with message', async () => {
|
||
const handle = mountComponent(Toast, {
|
||
visible: true,
|
||
variant: 'success',
|
||
message: 'Alles gut',
|
||
})
|
||
await nextTick()
|
||
const el = document.querySelector('.toast') as HTMLElement | null
|
||
expect(el).toBeTruthy()
|
||
expect(el!.className.includes('variant-success')).toBe(true)
|
||
expect(el!.textContent?.includes('Alles gut')).toBe(true)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('applies position class', async () => {
|
||
const handle = mountComponent(Toast, {
|
||
visible: true,
|
||
position: 'top',
|
||
message: 'x',
|
||
})
|
||
await nextTick()
|
||
const el = document.querySelector('.toast')!
|
||
expect(el.className.includes('position-top')).toBe(true)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('default slot overrides title/message', async () => {
|
||
const handle = mountComponent(
|
||
Toast,
|
||
{ visible: true, message: 'ignored' },
|
||
{ default: () => [h('span', { class: 'slot-body' }, 'slot wins')] },
|
||
)
|
||
await nextTick()
|
||
const slot = document.querySelector('.slot-body')
|
||
expect(slot).toBeTruthy()
|
||
expect(document.querySelector('.toast')!.textContent?.includes('slot wins')).toBe(true)
|
||
handle.unmount()
|
||
})
|
||
})
|
||
|
||
suite('project store — IndexedDB recents', () => {
|
||
test('save then getStoredProject returns identical scene', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'RS', width: 42, height: 42, fill: '#123456' })
|
||
s.addLayer(a.id)!
|
||
const rec = await saveProjectToStore(s.project, { name: 'test-save-get' })
|
||
try {
|
||
const back = await getStoredProject(rec.id)
|
||
expect(back).toBeTruthy()
|
||
expect(back!.name).toBe('test-save-get')
|
||
expect(back!.project!.assets).toHaveLength(1)
|
||
expect(back!.project!.scenes[0].layers).toHaveLength(1)
|
||
} finally {
|
||
await deleteStoredProject(rec.id)
|
||
}
|
||
})
|
||
|
||
test('listRecentProjects is sorted by savedAt desc', async () => {
|
||
const s = makeFreshStore()
|
||
const olderId = `test-older-${Date.now()}`
|
||
const newerId = `test-newer-${Date.now()}`
|
||
const older = await saveProjectToStore(s.project, { id: olderId, name: 'older' })
|
||
await tick(5)
|
||
const newer = await saveProjectToStore(s.project, { id: newerId, name: 'newer' })
|
||
try {
|
||
const list = await listRecentProjects()
|
||
const newerIdx = list.findIndex((p) => p.id === newer.id)
|
||
const olderIdx = list.findIndex((p) => p.id === older.id)
|
||
expect(newerIdx).toBeGreaterThan(-1)
|
||
expect(olderIdx).toBeGreaterThan(-1)
|
||
expect(newerIdx).toBeLessThan(olderIdx)
|
||
} finally {
|
||
await deleteStoredProject(olderId)
|
||
await deleteStoredProject(newerId)
|
||
}
|
||
})
|
||
|
||
test('save with same id updates existing record instead of adding', async () => {
|
||
const s = makeFreshStore()
|
||
const id = `test-update-${Date.now()}`
|
||
await saveProjectToStore(s.project, { id, name: 'v1' })
|
||
const before = (await listRecentProjects()).filter((p) => p.id === id).length
|
||
await saveProjectToStore(s.project, { id, name: 'v2' })
|
||
try {
|
||
const list = await listRecentProjects()
|
||
const matches = list.filter((p) => p.id === id)
|
||
expect(matches).toHaveLength(1)
|
||
expect(matches[0].name).toBe('v2')
|
||
expect(before).toBe(1)
|
||
} finally {
|
||
await deleteStoredProject(id)
|
||
}
|
||
})
|
||
|
||
test('deleteStoredProject removes entry', async () => {
|
||
const s = makeFreshStore()
|
||
const rec = await saveProjectToStore(s.project, { name: 'to-delete' })
|
||
await deleteStoredProject(rec.id)
|
||
const back = await getStoredProject(rec.id)
|
||
expect(back).toBeNull()
|
||
})
|
||
|
||
test('getStoredProject returns null for missing id', async () => {
|
||
const back = await getStoredProject('definitely-missing-id-42')
|
||
expect(back).toBeNull()
|
||
})
|
||
})
|
||
|
||
suite('clips — library & scope', () => {
|
||
test('addClip creates empty clip in library', () => {
|
||
const s = makeFreshStore()
|
||
const c = s.addClip('Intro')
|
||
expect(s.scene.clips?.length).toBe(1)
|
||
expect(c.name).toBe('Intro')
|
||
expect(c.layers).toHaveLength(0)
|
||
expect(c.duration).toBeGreaterThan(0)
|
||
})
|
||
|
||
test('enterClip switches activeLayers + activeDuration to clip scope', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'SceneL', width: 20, height: 20, fill: '#aaa' })
|
||
s.addLayer(a.id)!
|
||
const c = s.addClip('C1')
|
||
c.duration = 3.5
|
||
expect(s.activeLayers).toHaveLength(1)
|
||
expect(s.activeDuration).toBe(s.scene.duration)
|
||
s.enterClip(c.id)
|
||
await tick()
|
||
expect(s.activeLayers).toHaveLength(0)
|
||
expect(s.activeDuration).toBe(3.5)
|
||
expect(s.playhead).toBe(0)
|
||
s.exitClip()
|
||
expect(s.activeLayers).toHaveLength(1)
|
||
expect(s.activeDuration).toBe(s.scene.duration)
|
||
})
|
||
|
||
test('addLayer while in clip scope targets clip layers, not scene layers', async () => {
|
||
const s = makeFreshStore()
|
||
const c = s.addClip('C2')
|
||
const a = s.addAsset({ kind: 'rect', name: 'X', width: 20, height: 20, fill: '#888' })
|
||
s.enterClip(c.id)
|
||
await tick()
|
||
s.addLayer(a.id)!
|
||
expect(c.layers).toHaveLength(1)
|
||
expect(s.scene.layers).toHaveLength(0)
|
||
})
|
||
|
||
test('addClip auto-creates a first clip track if none exist', () => {
|
||
const s = makeFreshStore()
|
||
expect(s.scene.clipTracks ?? []).toHaveLength(0)
|
||
s.addClip('Auto')
|
||
expect((s.scene.clipTracks ?? []).length).toBeGreaterThan(0)
|
||
expect((s.scene.trackOrder ?? []).length).toBeGreaterThan(0)
|
||
})
|
||
|
||
test('addClipInstance anchors to a specific clipTrackId', () => {
|
||
const s = makeFreshStore()
|
||
const c = s.addClip('C3')
|
||
const t = s.scene.clipTracks![0]
|
||
const ci = s.addClipInstance(c.id, 2.5, t.id)
|
||
expect(ci).toBeTruthy()
|
||
expect(s.scene.clipInstances).toHaveLength(1)
|
||
expect(ci!.startTime).toBe(2.5)
|
||
expect(ci!.clipTrackId).toBe(t.id)
|
||
})
|
||
|
||
test('moveClipInstance updates position + can switch track', () => {
|
||
const s = makeFreshStore()
|
||
const c = s.addClip('C4')
|
||
const t1 = s.scene.clipTracks![0]
|
||
const t2 = s.addClipTrack()
|
||
const ci = s.addClipInstance(c.id, 0, t1.id)!
|
||
s.moveClipInstance(ci.id, 4, t2.id)
|
||
expect(ci.startTime).toBe(4)
|
||
expect(ci.clipTrackId).toBe(t2.id)
|
||
})
|
||
|
||
test('removeClip removes all instances and exits if active', () => {
|
||
const s = makeFreshStore()
|
||
const c = s.addClip('C5')
|
||
const t = s.scene.clipTracks![0]
|
||
s.addClipInstance(c.id, 0, t.id)
|
||
s.addClipInstance(c.id, 1, t.id)
|
||
s.enterClip(c.id)
|
||
s.removeClip(c.id)
|
||
expect(s.scene.clips).toHaveLength(0)
|
||
expect(s.scene.clipInstances).toHaveLength(0)
|
||
expect(s.activeClipId).toBeNull()
|
||
})
|
||
|
||
test('negative startTime is clamped to 0', () => {
|
||
const s = makeFreshStore()
|
||
const c = s.addClip('C6')
|
||
const t = s.scene.clipTracks![0]
|
||
const ci = s.addClipInstance(c.id, -5, t.id)!
|
||
expect(ci.startTime).toBe(0)
|
||
})
|
||
|
||
test('trimClipInstance clamps trimStart/trimEnd and preserves min visible duration', () => {
|
||
const s = makeFreshStore()
|
||
const c = s.addClip('Trim')
|
||
c.duration = 4
|
||
const t = s.scene.clipTracks![0]
|
||
const ci = s.addClipInstance(c.id, 0, t.id)!
|
||
s.trimClipInstance(ci.id, { trimStart: 1 })
|
||
expect(ci.trimStart).toBe(1)
|
||
s.trimClipInstance(ci.id, { trimEnd: 1 })
|
||
expect(ci.trimEnd).toBe(1)
|
||
// try to over-trim: start + end > duration - MIN_VISIBLE
|
||
s.trimClipInstance(ci.id, { trimStart: 10 })
|
||
const total = (ci.trimStart ?? 0) + (ci.trimEnd ?? 0)
|
||
expect(total).toBeLessThan(c.duration)
|
||
})
|
||
|
||
test('trimClipInstance accepts startTime shift (left-handle drag)', () => {
|
||
const s = makeFreshStore()
|
||
const c = s.addClip('TrimLeft')
|
||
c.duration = 4
|
||
const t = s.scene.clipTracks![0]
|
||
const ci = s.addClipInstance(c.id, 2, t.id)!
|
||
s.trimClipInstance(ci.id, { startTime: 2.5, trimStart: 0.5 })
|
||
expect(ci.startTime).toBe(2.5)
|
||
expect(ci.trimStart).toBe(0.5)
|
||
})
|
||
|
||
test('negative trim values are clamped to zero', () => {
|
||
const s = makeFreshStore()
|
||
const c = s.addClip('Neg')
|
||
c.duration = 3
|
||
const t = s.scene.clipTracks![0]
|
||
const ci = s.addClipInstance(c.id, 0, t.id)!
|
||
s.trimClipInstance(ci.id, { trimStart: -1, trimEnd: -2 })
|
||
expect(ci.trimStart).toBe(0)
|
||
expect(ci.trimEnd).toBe(0)
|
||
})
|
||
|
||
test('setClipInstanceTransform stores x / y / scale / rotation', () => {
|
||
const s = makeFreshStore()
|
||
const c = s.addClip('Xf')
|
||
const t = s.scene.clipTracks![0]
|
||
const ci = s.addClipInstance(c.id, 0, t.id)!
|
||
s.setClipInstanceTransform(ci.id, {
|
||
x: 80,
|
||
y: -30,
|
||
scaleX: 2,
|
||
scaleY: 0.5,
|
||
rotation: 45,
|
||
})
|
||
expect(ci.x).toBe(80)
|
||
expect(ci.y).toBe(-30)
|
||
expect(ci.scaleX).toBe(2)
|
||
expect(ci.scaleY).toBe(0.5)
|
||
expect(ci.rotation).toBe(45)
|
||
})
|
||
|
||
test('setClipInstanceTransform normalises rotation into (-180, 180]', () => {
|
||
// Values outside the canonical range are wrapped so the stored
|
||
// number stays human-readable across multiple transform
|
||
// sessions (Konva's transformer accumulates past 360).
|
||
const s = makeFreshStore()
|
||
const c = s.addClip('Xf')
|
||
const t = s.scene.clipTracks![0]
|
||
const ci = s.addClipInstance(c.id, 0, t.id)!
|
||
s.setClipInstanceTransform(ci.id, { rotation: 370 })
|
||
expect(ci.rotation).toBe(10)
|
||
s.setClipInstanceTransform(ci.id, { rotation: -190 })
|
||
expect(ci.rotation).toBe(170)
|
||
s.setClipInstanceTransform(ci.id, { rotation: 360 })
|
||
// 360 % 360 = 0 → pruned back out of the object.
|
||
expect('rotation' in ci).toBe(false)
|
||
})
|
||
|
||
test('setClipInstanceTransform prunes defaults so JSON stays clean', () => {
|
||
// Defaults (x=0, y=0, scale=1) are absent on a freshly-added
|
||
// instance. Writing them back explicitly mustn't leave the
|
||
// field around — serialized scenes stay minimal and diffable.
|
||
const s = makeFreshStore()
|
||
const c = s.addClip('Xf')
|
||
const t = s.scene.clipTracks![0]
|
||
const ci = s.addClipInstance(c.id, 0, t.id)!
|
||
s.setClipInstanceTransform(ci.id, { x: 50, scaleX: 2 })
|
||
expect(ci.x).toBe(50)
|
||
expect(ci.scaleX).toBe(2)
|
||
// Clear back to defaults — the keys should get deleted.
|
||
s.setClipInstanceTransform(ci.id, { x: 0, scaleX: 1 })
|
||
expect('x' in ci).toBe(false)
|
||
expect('scaleX' in ci).toBe(false)
|
||
})
|
||
|
||
test('setClipInstanceTransform leaves unspecified fields alone', () => {
|
||
const s = makeFreshStore()
|
||
const c = s.addClip('Xf')
|
||
const t = s.scene.clipTracks![0]
|
||
const ci = s.addClipInstance(c.id, 0, t.id)!
|
||
s.setClipInstanceTransform(ci.id, { x: 10, y: 20, scaleX: 3, scaleY: 3 })
|
||
// Partial patch: only x. y / scale must survive.
|
||
s.setClipInstanceTransform(ci.id, { x: 99 })
|
||
expect(ci.x).toBe(99)
|
||
expect(ci.y).toBe(20)
|
||
expect(ci.scaleX).toBe(3)
|
||
expect(ci.scaleY).toBe(3)
|
||
})
|
||
|
||
test('setClipInstanceTransform on an unknown id is a no-op', () => {
|
||
const s = makeFreshStore()
|
||
const c = s.addClip('Xf')
|
||
const t = s.scene.clipTracks![0]
|
||
s.addClipInstance(c.id, 0, t.id)!
|
||
s.setClipInstanceTransform('not-an-id', { x: 5 })
|
||
// No-op; no side effects or throw.
|
||
expect(s.scene.clipInstances!.every((ci) => ci.x === undefined)).toBe(true)
|
||
})
|
||
|
||
test('selectClipInstance clears the layer selection and vice versa', () => {
|
||
// Selection is mutually exclusive — stage transformer only
|
||
// targets one thing at a time.
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
const c = s.addClip('Xf')
|
||
const t = s.scene.clipTracks![0]
|
||
const ci = s.addClipInstance(c.id, 0, t.id)!
|
||
|
||
s.selectLayer(l.id)
|
||
expect(s.selectedLayerId).toBe(l.id)
|
||
expect(s.selectedClipInstanceId).toBe(null)
|
||
|
||
s.selectClipInstance(ci.id)
|
||
expect(s.selectedClipInstanceId).toBe(ci.id)
|
||
expect(s.selectedLayerId).toBe(null)
|
||
expect(s.selectedLayerIds).toEqual([])
|
||
|
||
s.selectLayer(l.id)
|
||
expect(s.selectedLayerId).toBe(l.id)
|
||
expect(s.selectedClipInstanceId).toBe(null)
|
||
|
||
s.selectClipInstance(null)
|
||
expect(s.selectedClipInstanceId).toBe(null)
|
||
// selecting null on the clip branch doesn't touch the layer
|
||
// selection — the user just cleared the clip-side pointer.
|
||
expect(s.selectedLayerId).toBe(l.id)
|
||
})
|
||
|
||
test('removeClipInstance clears selectedClipInstanceId when it matched', () => {
|
||
const s = makeFreshStore()
|
||
const c = s.addClip('Xf')
|
||
const t = s.scene.clipTracks![0]
|
||
const ci = s.addClipInstance(c.id, 0, t.id)!
|
||
s.selectClipInstance(ci.id)
|
||
s.removeClipInstance(ci.id)
|
||
expect(s.selectedClipInstanceId).toBe(null)
|
||
})
|
||
|
||
test('drawSceneFrame rotates the clip-instance group on the canvas', () => {
|
||
// Geometry that keeps the rect on-canvas through a 180° flip:
|
||
// - 20×10 rect anchored at layer origin (0,0) with no offset.
|
||
// - Clip instance at x=50, y=50 → unrotated, rect lives at
|
||
// world (50..70, 50..60).
|
||
// - Rotate 180° → rect flips to world (30..50, 40..50).
|
||
// Both pre and post positions sit comfortably on the 100×100
|
||
// canvas so we can assert concrete pixels on either side.
|
||
const s = makeFreshStore()
|
||
s.scene.canvas.width = 100
|
||
s.scene.canvas.height = 100
|
||
s.scene.canvas.background = 'transparent'
|
||
s.setDuration(5)
|
||
|
||
const rect = s.addAsset({
|
||
kind: 'rect',
|
||
name: 'R',
|
||
width: 20,
|
||
height: 10,
|
||
fill: '#ff0000',
|
||
})
|
||
const clip = s.addClip('C')
|
||
clip.duration = 5
|
||
clip.layers = [
|
||
{
|
||
id: 'clip-layer-r',
|
||
name: 'R',
|
||
assetId: rect.id,
|
||
visible: true,
|
||
base: { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
|
||
keyframes: [],
|
||
offsetX: 0,
|
||
offsetY: 0,
|
||
},
|
||
]
|
||
const track = s.scene.clipTracks![0]
|
||
const ci = s.addClipInstance(clip.id, 0, track.id)!
|
||
s.setClipInstanceTransform(ci.id, { x: 50, y: 50 })
|
||
|
||
// Baseline: unrotated rect covers world (50..70, 50..60). A
|
||
// pixel at (60, 55) must be red; (40, 45) must be transparent.
|
||
const unrotated = document.createElement('canvas')
|
||
unrotated.width = 100
|
||
unrotated.height = 100
|
||
const uCtx = unrotated.getContext('2d')!
|
||
drawSceneFrame(uCtx, s.scene, 0, {})
|
||
const beforeInside = uCtx.getImageData(60, 55, 1, 1).data
|
||
const beforeOutside = uCtx.getImageData(40, 45, 1, 1).data
|
||
expect(beforeInside[0] > 200).toBe(true)
|
||
expect(beforeInside[3] > 200).toBe(true)
|
||
expect(beforeOutside[3]).toBe(0)
|
||
|
||
// Rotate 180°: rect flips around (50, 50) to world (30..50,
|
||
// 40..50). The two probe pixels swap roles.
|
||
s.setClipInstanceTransform(ci.id, { rotation: 180 })
|
||
const rotated = document.createElement('canvas')
|
||
rotated.width = 100
|
||
rotated.height = 100
|
||
const rCtx = rotated.getContext('2d')!
|
||
drawSceneFrame(rCtx, s.scene, 0, {})
|
||
const afterInside = rCtx.getImageData(40, 45, 1, 1).data
|
||
const afterOutside = rCtx.getImageData(60, 55, 1, 1).data
|
||
expect(afterInside[0] > 200).toBe(true)
|
||
expect(afterInside[3] > 200).toBe(true)
|
||
expect(afterOutside[3]).toBe(0)
|
||
})
|
||
|
||
test('drawSceneFrame translates + scales the clip-instance group on the canvas', () => {
|
||
// Seeds a 100×100 scene with a tiny red rect clip; places one
|
||
// instance at the top-left (0,0) with 2× scale so the 10×10
|
||
// rect renders as a 20×20 block. Reads pixels to confirm the
|
||
// transform actually landed on the canvas-path renderer
|
||
// (mask-preview, thumbnails, etc. all go through drawSceneFrame).
|
||
const s = makeFreshStore()
|
||
s.scene.canvas.width = 100
|
||
s.scene.canvas.height = 100
|
||
s.scene.canvas.background = 'transparent'
|
||
s.setDuration(5)
|
||
|
||
const rect = s.addAsset({
|
||
kind: 'rect',
|
||
name: 'R',
|
||
width: 10,
|
||
height: 10,
|
||
fill: '#ff0000',
|
||
})
|
||
const clip = s.addClip('C')
|
||
clip.duration = 5
|
||
// Build the clip's sole layer directly — addLayer would target
|
||
// the scene, not the clip library.
|
||
clip.layers = [
|
||
{
|
||
id: 'clip-layer-1',
|
||
name: 'RL',
|
||
assetId: rect.id,
|
||
visible: true,
|
||
base: {
|
||
x: 5,
|
||
y: 5,
|
||
scaleX: 1,
|
||
scaleY: 1,
|
||
rotation: 0,
|
||
opacity: 1,
|
||
},
|
||
keyframes: [],
|
||
offsetX: 5,
|
||
offsetY: 5,
|
||
},
|
||
]
|
||
const track = s.scene.clipTracks![0]
|
||
const ci = s.addClipInstance(clip.id, 0, track.id)!
|
||
// Transform: nudge to (0,0) origin for the top-left and scale 2×.
|
||
s.setClipInstanceTransform(ci.id, { x: 0, y: 0, scaleX: 2, scaleY: 2 })
|
||
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = 100
|
||
canvas.height = 100
|
||
const ctx = canvas.getContext('2d')!
|
||
drawSceneFrame(ctx, s.scene, 0, {})
|
||
// Without transform the 10×10 rect at (5,5) covers x=0..10, y=0..10
|
||
// (layer's own offset centres it on its base pos). With scale 2×
|
||
// it covers 0..20 in both axes. A pixel at (15, 15) should be red.
|
||
const insideAfterScale = ctx.getImageData(15, 15, 1, 1).data
|
||
expect(insideAfterScale[0] > 200).toBe(true)
|
||
expect(insideAfterScale[3] > 200).toBe(true)
|
||
// A pixel at (25, 25) should still be blank (outside the
|
||
// scaled rect + stage-render still clips to canvas).
|
||
const outsideAfterScale = ctx.getImageData(25, 25, 1, 1).data
|
||
expect(outsideAfterScale[3]).toBe(0)
|
||
})
|
||
|
||
test('setTrackColor applies to layer and can be cleared with null', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'Col', width: 20, height: 20, fill: '#123' })
|
||
const l = s.addLayer(a.id)!
|
||
s.setTrackColor(l.id, '#ff6b6b')
|
||
expect(l.color).toBe('#ff6b6b')
|
||
s.setTrackColor(l.id, null)
|
||
expect(l.color).toBe(undefined)
|
||
})
|
||
|
||
test('setTrackColor applies to clip track', () => {
|
||
const s = makeFreshStore()
|
||
s.addClip('X')
|
||
const t = s.scene.clipTracks![0]
|
||
s.setTrackColor(t.id, '#51cf66')
|
||
expect(t.color).toBe('#51cf66')
|
||
s.setTrackColor(t.id, null)
|
||
expect(t.color).toBe(undefined)
|
||
})
|
||
|
||
test('setTrackLocked toggles layer lock flag', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'L', width: 10, height: 10, fill: '#111' })
|
||
const l = s.addLayer(a.id)!
|
||
s.setTrackLocked(l.id, true)
|
||
expect(l.locked).toBe(true)
|
||
s.setTrackLocked(l.id, false)
|
||
expect(l.locked).toBe(undefined)
|
||
})
|
||
|
||
test('setTrackLocked toggles clip-track lock flag', () => {
|
||
const s = makeFreshStore()
|
||
s.addClip('L')
|
||
const t = s.scene.clipTracks![0]
|
||
s.setTrackLocked(t.id, true)
|
||
expect(t.locked).toBe(true)
|
||
s.setTrackLocked(t.id, false)
|
||
expect(t.locked).toBe(undefined)
|
||
})
|
||
|
||
test('setTrackColor on unknown id is a no-op', () => {
|
||
const s = makeFreshStore()
|
||
s.setTrackColor('nope', '#ff0000')
|
||
// just verify no throw, no layer/clipTrack changed
|
||
expect(s.scene.layers).toHaveLength(0)
|
||
expect(s.scene.clipTracks ?? []).toHaveLength(0)
|
||
})
|
||
|
||
test('moveClipInstance without clipTrackId keeps the same track (x-only move)', () => {
|
||
const s = makeFreshStore()
|
||
const c = s.addClip('MoveX')
|
||
const t1 = s.scene.clipTracks![0]
|
||
const t2 = s.addClipTrack()
|
||
const ci = s.addClipInstance(c.id, 0, t1.id)!
|
||
// sanity: distinct tracks
|
||
expect(t1.id === t2.id).toBe(false)
|
||
s.moveClipInstance(ci.id, 5)
|
||
expect(ci.startTime).toBe(5)
|
||
expect(ci.clipTrackId).toBe(t1.id)
|
||
})
|
||
|
||
test('moveTrack reorders the unified stack (layer above clip track)', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'M', width: 10, height: 10, fill: '#000' })
|
||
const l = s.addLayer(a.id)!
|
||
s.addClip('X')
|
||
const track = s.scene.clipTracks![0]
|
||
// order is [layer, clipTrack]
|
||
expect(s.scene.trackOrder![0]).toBe(l.id)
|
||
expect(s.scene.trackOrder![1]).toBe(track.id)
|
||
s.moveTrack(0, 2)
|
||
expect(s.scene.trackOrder![0]).toBe(track.id)
|
||
expect(s.scene.trackOrder![1]).toBe(l.id)
|
||
})
|
||
})
|
||
|
||
suite('scene store — track groups', () => {
|
||
test('addTrackGroup creates an empty group at the top of trackOrder', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'G1', width: 10, height: 10, fill: '#111' })
|
||
s.addLayer(a.id)!
|
||
const g = s.addTrackGroup('My group')
|
||
expect(s.scene.groups).toHaveLength(1)
|
||
expect(g.childIds).toHaveLength(0)
|
||
expect(s.scene.trackOrder![0]).toBe(g.id)
|
||
})
|
||
|
||
test('moveTrackIntoGroup moves track from top-level to group childIds', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'G2', width: 10, height: 10, fill: '#222' })
|
||
const l = s.addLayer(a.id)!
|
||
const g = s.addTrackGroup('G')
|
||
s.moveTrackIntoGroup(l.id, g.id)
|
||
expect(g.childIds).toContain(l.id)
|
||
expect((s.scene.trackOrder ?? []).includes(l.id)).toBe(false)
|
||
})
|
||
|
||
test('moveTrackOutOfGroup promotes track back to top-level', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'G3', width: 10, height: 10, fill: '#333' })
|
||
const l = s.addLayer(a.id)!
|
||
const g = s.addTrackGroup('G')
|
||
s.moveTrackIntoGroup(l.id, g.id)
|
||
s.moveTrackOutOfGroup(l.id)
|
||
expect(g.childIds.includes(l.id)).toBe(false)
|
||
expect(s.scene.trackOrder).toContain(l.id)
|
||
})
|
||
|
||
test('removeTrackGroup promotes children back to top-level', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'G4', width: 10, height: 10, fill: '#444' })
|
||
const l = s.addLayer(a.id)!
|
||
const g = s.addTrackGroup('G')
|
||
s.moveTrackIntoGroup(l.id, g.id)
|
||
s.removeTrackGroup(g.id)
|
||
expect(s.scene.groups).toHaveLength(0)
|
||
expect(s.scene.trackOrder).toContain(l.id)
|
||
})
|
||
|
||
test('toggleGroupCollapsed flips the collapsed flag', () => {
|
||
const s = makeFreshStore()
|
||
const g = s.addTrackGroup('G')
|
||
expect(!!g.collapsed).toBe(false)
|
||
s.toggleGroupCollapsed(g.id)
|
||
expect(g.collapsed).toBe(true)
|
||
s.toggleGroupCollapsed(g.id)
|
||
expect(g.collapsed).toBe(false)
|
||
})
|
||
|
||
test('renameGroup updates the name', () => {
|
||
const s = makeFreshStore()
|
||
const g = s.addTrackGroup('Old')
|
||
s.renameGroup(g.id, 'New')
|
||
expect(g.name).toBe('New')
|
||
})
|
||
|
||
test('a track can only be in one group at a time', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#555' })
|
||
const l = s.addLayer(a.id)!
|
||
const g1 = s.addTrackGroup('A')
|
||
const g2 = s.addTrackGroup('B')
|
||
s.moveTrackIntoGroup(l.id, g1.id)
|
||
s.moveTrackIntoGroup(l.id, g2.id)
|
||
expect(g1.childIds.includes(l.id)).toBe(false)
|
||
expect(g2.childIds).toContain(l.id)
|
||
})
|
||
|
||
test('expandedTrackOrder expands group IDs to their childIds inline', () => {
|
||
const s = makeFreshStore()
|
||
const a1 = s.addAsset({ kind: 'rect', name: 'A', width: 10, height: 10, fill: '#abc' })
|
||
const a2 = s.addAsset({ kind: 'rect', name: 'B', width: 10, height: 10, fill: '#def' })
|
||
const l1 = s.addLayer(a1.id)!
|
||
const l2 = s.addLayer(a2.id)!
|
||
const g = s.addTrackGroup('G')
|
||
s.moveTrackIntoGroup(l1.id, g.id)
|
||
// scene.trackOrder: [group, l2] (l1 is inside group)
|
||
// expanded: [l1, l2]
|
||
const expanded = expandedTrackOrder(s.scene)
|
||
expect(expanded).toEqual([l1.id, l2.id])
|
||
})
|
||
|
||
test('moveTrackIntoGroup respects atIndex for insertion', () => {
|
||
const s = makeFreshStore()
|
||
const a1 = s.addAsset({ kind: 'rect', name: 'A', width: 10, height: 10, fill: '#111' })
|
||
const a2 = s.addAsset({ kind: 'rect', name: 'B', width: 10, height: 10, fill: '#222' })
|
||
const a3 = s.addAsset({ kind: 'rect', name: 'C', width: 10, height: 10, fill: '#333' })
|
||
const l1 = s.addLayer(a1.id)!
|
||
const l2 = s.addLayer(a2.id)!
|
||
const l3 = s.addLayer(a3.id)!
|
||
const g = s.addTrackGroup('G')
|
||
s.moveTrackIntoGroup(l1.id, g.id) // [l1]
|
||
s.moveTrackIntoGroup(l2.id, g.id) // [l1, l2]
|
||
s.moveTrackIntoGroup(l3.id, g.id, 1) // [l1, l3, l2]
|
||
expect(g.childIds).toEqual([l1.id, l3.id, l2.id])
|
||
})
|
||
|
||
test('removeTrackGroup inserts children at the former group position', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#aaa' })
|
||
const top1 = s.addLayer(a.id)!
|
||
const top2 = s.addLayer(a.id)!
|
||
const childA = s.addLayer(a.id)!
|
||
const childB = s.addLayer(a.id)!
|
||
const g = s.addTrackGroup('G')
|
||
s.moveTrackIntoGroup(childA.id, g.id)
|
||
s.moveTrackIntoGroup(childB.id, g.id)
|
||
// trackOrder currently: [childB? no, children are removed from top-level]
|
||
// trackOrder contains: [g, top2, top1] (newest addLayer unshifts)
|
||
// Wait: addLayer unshifts; the creation order is top2(later), then top1(earlier)
|
||
// Then addLayer(childA), childB — all unshifted. Then moved into group.
|
||
// Let's just position group explicitly to be sure:
|
||
const order = s.scene.trackOrder ?? []
|
||
const gIdx = order.indexOf(g.id)
|
||
expect(gIdx).toBeGreaterThan(-1)
|
||
s.removeTrackGroup(g.id)
|
||
const newOrder = s.scene.trackOrder ?? []
|
||
// children should now sit at the former group position
|
||
expect(newOrder[gIdx]).toBe(childA.id)
|
||
expect(newOrder[gIdx + 1]).toBe(childB.id)
|
||
void top1
|
||
void top2
|
||
})
|
||
|
||
test('renameGroup ignores empty / whitespace names', () => {
|
||
const s = makeFreshStore()
|
||
const g = s.addTrackGroup('Original')
|
||
s.renameGroup(g.id, '')
|
||
expect(g.name).toBe('Original')
|
||
s.renameGroup(g.id, ' ')
|
||
expect(g.name).toBe('Original')
|
||
s.renameGroup(g.id, ' Renamed ')
|
||
expect(g.name).toBe('Renamed')
|
||
})
|
||
|
||
test('removeTrackGroup on unknown id is a no-op', () => {
|
||
const s = makeFreshStore()
|
||
const g = s.addTrackGroup('G')
|
||
const beforeCount = (s.scene.groups ?? []).length
|
||
s.removeTrackGroup('does-not-exist')
|
||
expect((s.scene.groups ?? []).length).toBe(beforeCount)
|
||
// The real group still exists
|
||
expect(s.scene.groups?.find((x) => x.id === g.id)).toBeTruthy()
|
||
})
|
||
|
||
test('setTrackColor applies to groups too', () => {
|
||
const s = makeFreshStore()
|
||
const g = s.addTrackGroup('Col')
|
||
s.setTrackColor(g.id, '#ff0000')
|
||
expect(g.color).toBe('#ff0000')
|
||
s.setTrackColor(g.id, null)
|
||
expect(g.color).toBe(undefined)
|
||
})
|
||
|
||
test('moveTrackIntoGroup with same group twice moves within group', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'M', width: 10, height: 10, fill: '#555' })
|
||
const l1 = s.addLayer(a.id)!
|
||
const l2 = s.addLayer(a.id)!
|
||
const g = s.addTrackGroup('G')
|
||
s.moveTrackIntoGroup(l1.id, g.id)
|
||
s.moveTrackIntoGroup(l2.id, g.id)
|
||
// l1 currently at index 0. Move l1 to index 1.
|
||
s.moveTrackIntoGroup(l1.id, g.id, 1)
|
||
expect(g.childIds).toEqual([l2.id, l1.id])
|
||
})
|
||
|
||
test('group color cascades to non-overridden children', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
|
||
const l1 = s.addLayer(a.id)!
|
||
const l2 = s.addLayer(a.id)!
|
||
const g = s.addTrackGroup('G')
|
||
s.moveTrackIntoGroup(l1.id, g.id)
|
||
s.moveTrackIntoGroup(l2.id, g.id)
|
||
s.setTrackColor(g.id, '#ff0000')
|
||
expect(l1.color).toBe('#ff0000')
|
||
expect(l2.color).toBe('#ff0000')
|
||
// The children inherited — they must NOT be marked as overridden.
|
||
expect(l1.colorOverridden).toBe(undefined)
|
||
expect(l2.colorOverridden).toBe(undefined)
|
||
})
|
||
|
||
test('explicit per-track color pins against later group-color changes', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
|
||
const l1 = s.addLayer(a.id)!
|
||
const l2 = s.addLayer(a.id)!
|
||
const g = s.addTrackGroup('G')
|
||
s.moveTrackIntoGroup(l1.id, g.id)
|
||
s.moveTrackIntoGroup(l2.id, g.id)
|
||
s.setTrackColor(g.id, '#ff0000')
|
||
// User recolours one child AFTER the group — it pins.
|
||
s.setTrackColor(l1.id, '#00ff00')
|
||
expect(l1.colorOverridden).toBe(true)
|
||
// Group colour changes again — pinned child ignores it; other follows.
|
||
s.setTrackColor(g.id, '#0000ff')
|
||
expect(l1.color).toBe('#00ff00')
|
||
expect(l2.color).toBe('#0000ff')
|
||
})
|
||
|
||
test('clearing a child-color (null) re-inherits the group color', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
const g = s.addTrackGroup('G')
|
||
s.moveTrackIntoGroup(l.id, g.id)
|
||
s.setTrackColor(g.id, '#ff0000')
|
||
s.setTrackColor(l.id, '#00ff00')
|
||
expect(l.color).toBe('#00ff00')
|
||
s.setTrackColor(l.id, null)
|
||
// Now inheriting again.
|
||
expect(l.color).toBe('#ff0000')
|
||
expect(l.colorOverridden).toBe(undefined)
|
||
})
|
||
|
||
test('clearing a group color clears cascade on non-overridden children', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
|
||
const l1 = s.addLayer(a.id)!
|
||
const l2 = s.addLayer(a.id)!
|
||
const g = s.addTrackGroup('G')
|
||
s.moveTrackIntoGroup(l1.id, g.id)
|
||
s.moveTrackIntoGroup(l2.id, g.id)
|
||
s.setTrackColor(g.id, '#ff0000')
|
||
s.setTrackColor(l2.id, '#00ff00')
|
||
s.setTrackColor(g.id, null)
|
||
expect(l1.color).toBe(undefined)
|
||
// l2 was pinned, keeps its explicit colour.
|
||
expect(l2.color).toBe('#00ff00')
|
||
})
|
||
|
||
test('moving a track into a coloured group inherits its color', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
const g = s.addTrackGroup('G')
|
||
s.setTrackColor(g.id, '#ff0000')
|
||
s.moveTrackIntoGroup(l.id, g.id)
|
||
expect(l.color).toBe('#ff0000')
|
||
expect(l.colorOverridden).toBe(undefined)
|
||
})
|
||
|
||
test('moving an overridden track into a coloured group keeps its color', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
s.setTrackColor(l.id, '#00ff00')
|
||
const g = s.addTrackGroup('G')
|
||
s.setTrackColor(g.id, '#ff0000')
|
||
s.moveTrackIntoGroup(l.id, g.id)
|
||
expect(l.color).toBe('#00ff00')
|
||
})
|
||
|
||
test('loading an older scene treats pre-existing track colors as overridden', () => {
|
||
// Migration: scenes saved before colorOverridden shipped must
|
||
// preserve their per-track colour choices against future group
|
||
// cascades. normalizeScene stamps colorOverridden=true on any
|
||
// track that arrives with a colour and no flag.
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
const snap = JSON.parse(JSON.stringify(s.scene))
|
||
snap.layers[0].color = '#123456'
|
||
// legacy: no colorOverridden field on the layer
|
||
s.loadScene(snap)
|
||
const loaded = s.scene.layers.find((x) => x.id === l.id)!
|
||
expect(loaded.colorOverridden).toBe(true)
|
||
})
|
||
|
||
test('normalizeScene (via loadScene) removes orphan childIds and deduplicates', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'N', width: 10, height: 10, fill: '#666' })
|
||
const l = s.addLayer(a.id)!
|
||
// Snapshot with a broken state
|
||
const snap = JSON.parse(JSON.stringify(s.scene))
|
||
snap.groups = [
|
||
{ id: 'g1', name: 'G1', childIds: [l.id, 'ghost-layer', l.id] },
|
||
{ id: 'g2', name: 'G2', childIds: [l.id] },
|
||
]
|
||
snap.trackOrder = [l.id, 'g1', 'g2']
|
||
s.loadScene(snap)
|
||
// Orphan 'ghost-layer' filtered out
|
||
const g1 = s.scene.groups?.find((g) => g.id === 'g1')!
|
||
const g2 = s.scene.groups?.find((g) => g.id === 'g2')!
|
||
expect(g1.childIds.includes('ghost-layer')).toBe(false)
|
||
// Dedupe: l.id appears only once across all groups
|
||
const inG1 = g1.childIds.filter((id) => id === l.id).length
|
||
const inG2 = g2.childIds.filter((id) => id === l.id).length
|
||
expect(inG1 + inG2).toBe(1)
|
||
})
|
||
})
|
||
|
||
suite('scene store — duplicateTrack', () => {
|
||
test('duplicates a layer with fresh keyframe ids right below the original', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'D1', width: 10, height: 10, fill: '#abc' })
|
||
const l = s.addLayer(a.id, 'Orig')!
|
||
s.upsertKeyframe(l.id, 1, { x: 10 })
|
||
s.upsertKeyframe(l.id, 2, { x: 20 })
|
||
const origKfIds = new Set(l.keyframes.map((k) => k.id))
|
||
|
||
const newId = s.duplicateTrack(l.id)
|
||
expect(newId).toBeTruthy()
|
||
const dup = s.scene.layers.find((x) => x.id === newId)!
|
||
expect(dup).toBeTruthy()
|
||
expect(dup.id === l.id).toBe(false)
|
||
expect(dup.name).toBe('Orig')
|
||
expect(dup.assetId).toBe(a.id)
|
||
expect(dup.keyframes.length).toBe(l.keyframes.length)
|
||
// Fresh ids for every keyframe so history/undo never aliases.
|
||
for (const k of dup.keyframes) expect(origKfIds.has(k.id)).toBe(false)
|
||
// Clone sits immediately after the original in trackOrder.
|
||
const order = s.scene.trackOrder!
|
||
expect(order.indexOf(newId!)).toBe(order.indexOf(l.id) + 1)
|
||
})
|
||
|
||
test('layer duplicate is deeply independent of the original', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'D2', width: 10, height: 10, fill: '#bcd' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 1, { x: 10 })
|
||
|
||
const newId = s.duplicateTrack(l.id)!
|
||
const dup = s.scene.layers.find((x) => x.id === newId)!
|
||
// Mutate the clone — the original must not see the change.
|
||
dup.base.x = 999
|
||
dup.keyframes[0].props.x = 999
|
||
const origAfter = s.scene.layers.find((x) => x.id === l.id)!
|
||
expect(origAfter.base.x === 999).toBe(false)
|
||
expect(origAfter.keyframes[0].props.x === 999).toBe(false)
|
||
})
|
||
|
||
test('layer duplicate copies separated position/rotation kf arrays with fresh ids', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'D3', width: 10, height: 10, fill: '#cde' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 1, { x: 5, y: 5, rotation: 45 })
|
||
s.setLayerSeparated(l.id, true)
|
||
const before = s.scene.layers.find((x) => x.id === l.id)!
|
||
const origPosIds = new Set((before.positionKeyframes ?? []).map((k) => k.id))
|
||
const origRotIds = new Set((before.rotationKeyframes ?? []).map((k) => k.id))
|
||
|
||
const newId = s.duplicateTrack(l.id)!
|
||
const dup = s.scene.layers.find((x) => x.id === newId)!
|
||
expect(dup.separated).toBe(true)
|
||
expect((dup.positionKeyframes ?? []).length).toBe((before.positionKeyframes ?? []).length)
|
||
expect((dup.rotationKeyframes ?? []).length).toBe((before.rotationKeyframes ?? []).length)
|
||
for (const k of dup.positionKeyframes ?? []) expect(origPosIds.has(k.id)).toBe(false)
|
||
for (const k of dup.rotationKeyframes ?? []) expect(origRotIds.has(k.id)).toBe(false)
|
||
})
|
||
|
||
test('duplicating a grouped layer keeps the clone in the same group', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'D4', width: 10, height: 10, fill: '#def' })
|
||
const l = s.addLayer(a.id)!
|
||
const g = s.addTrackGroup('G')
|
||
s.moveTrackIntoGroup(l.id, g.id)
|
||
const newId = s.duplicateTrack(l.id)!
|
||
const group = s.scene.groups!.find((x) => x.id === g.id)!
|
||
// Duplicate lands in the group, directly after the original.
|
||
expect(group.childIds.indexOf(newId)).toBe(group.childIds.indexOf(l.id) + 1)
|
||
// Grouped children never leak into trackOrder.
|
||
expect((s.scene.trackOrder ?? []).includes(newId)).toBe(false)
|
||
})
|
||
|
||
test('duplicates a clip track along with its clip instances', () => {
|
||
const s = makeFreshStore()
|
||
const clip = s.addClip('C')
|
||
clip.duration = 5
|
||
const origTrack = s.addClipTrack('track-A')
|
||
const ci1 = s.addClipInstance(clip.id, 0, origTrack.id)!
|
||
const ci2 = s.addClipInstance(clip.id, 2, origTrack.id)!
|
||
|
||
const newId = s.duplicateTrack(origTrack.id)
|
||
expect(newId).toBeTruthy()
|
||
expect(newId === origTrack.id).toBe(false)
|
||
const newTrack = s.scene.clipTracks!.find((t) => t.id === newId)!
|
||
expect(newTrack.name).toBe('track-A')
|
||
// Original instances untouched.
|
||
expect(s.scene.clipInstances!.find((ci) => ci.id === ci1.id)!.clipTrackId).toBe(origTrack.id)
|
||
expect(s.scene.clipInstances!.find((ci) => ci.id === ci2.id)!.clipTrackId).toBe(origTrack.id)
|
||
// Clone track carries copies with fresh ids and same clipId/startTime.
|
||
const cloneInstances = s.scene.clipInstances!.filter((ci) => ci.clipTrackId === newId)
|
||
expect(cloneInstances).toHaveLength(2)
|
||
const startTimes = cloneInstances.map((ci) => ci.startTime).sort((a, b) => a - b)
|
||
expect(startTimes).toEqual([0, 2])
|
||
for (const ci of cloneInstances) {
|
||
expect(ci.clipId).toBe(clip.id)
|
||
expect(ci.id === ci1.id).toBe(false)
|
||
expect(ci.id === ci2.id).toBe(false)
|
||
}
|
||
// Slotted right after the original in trackOrder.
|
||
const order = s.scene.trackOrder!
|
||
expect(order.indexOf(newId!)).toBe(order.indexOf(origTrack.id) + 1)
|
||
})
|
||
|
||
test('duplicates a script track along with its frames', () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
const origTrack = s.addScriptTrack('triggers')
|
||
const f1 = s.addScriptFrame(origTrack.id, 1, [{ type: 'pause' }])!
|
||
const f2 = s.addScriptFrame(origTrack.id, 3, [{ type: 'play' }])!
|
||
|
||
const newId = s.duplicateTrack(origTrack.id)!
|
||
expect(newId === origTrack.id).toBe(false)
|
||
const newTrack = s.scene.scriptTracks!.find((t) => t.id === newId)!
|
||
expect(newTrack.name).toBe('triggers')
|
||
// Original frames untouched.
|
||
expect(s.scene.scriptFrames!.find((f) => f.id === f1.id)!.trackId).toBe(origTrack.id)
|
||
expect(s.scene.scriptFrames!.find((f) => f.id === f2.id)!.trackId).toBe(origTrack.id)
|
||
// Clone has its own frames at the same times with matching actions.
|
||
const cloneFrames = s.scene.scriptFrames!.filter((f) => f.trackId === newId)
|
||
expect(cloneFrames).toHaveLength(2)
|
||
const times = cloneFrames.map((f) => f.time).sort((a, b) => a - b)
|
||
expect(times).toEqual([1, 3])
|
||
for (const f of cloneFrames) {
|
||
expect(f.id === f1.id).toBe(false)
|
||
expect(f.id === f2.id).toBe(false)
|
||
}
|
||
})
|
||
|
||
test('returns null for unknown id and for group ids', () => {
|
||
const s = makeFreshStore()
|
||
expect(s.duplicateTrack('nope-no-such-track')).toBe(null)
|
||
const g = s.addTrackGroup('G')
|
||
expect(s.duplicateTrack(g.id)).toBe(null)
|
||
})
|
||
})
|
||
|
||
suite('app smoke — live DOM', () => {
|
||
test('app root, topbar, timeline, stage are mounted', () => {
|
||
expect(document.querySelector('.app')).toBeTruthy()
|
||
expect(document.querySelector('.topbar')).toBeTruthy()
|
||
expect(document.querySelector('.timeline')).toBeTruthy()
|
||
expect(document.querySelector('.stage')).toBeTruthy()
|
||
})
|
||
|
||
test('timeline snap toggle button is present', () => {
|
||
expect(document.querySelector('.snap-btn')).toBeTruthy()
|
||
})
|
||
|
||
test('topbar has save & recents buttons', () => {
|
||
// Tooltips live in `aria-label` now (custom v-tooltip directive
|
||
// replaced the native `title` attribute on every interactive
|
||
// element).
|
||
const saveBtn = document.querySelector('button[aria-label^="Save project"]')
|
||
const recentsBtn = document.querySelector('button[aria-label="Recent projects"]')
|
||
expect(saveBtn).toBeTruthy()
|
||
expect(recentsBtn).toBeTruthy()
|
||
})
|
||
|
||
test('canvas has crop toggle button', () => {
|
||
const cropBtn = document.querySelector('.crop-btn')
|
||
expect(cropBtn).toBeTruthy()
|
||
})
|
||
|
||
test('brand button opens about dialog', () => {
|
||
const brand = document.querySelector('button.brand')
|
||
expect(brand).toBeTruthy()
|
||
})
|
||
|
||
test('new-track dropzone is hidden while the clip library is empty', () => {
|
||
// Regression: the dropzone used to always render and show "Drop a
|
||
// clip here" even when the library had no clips. Timeline now gates
|
||
// `.clip-track-dropzone` behind `scene.clips.length > 0`. The live
|
||
// smoke scene starts blank, so the dropzone must be absent.
|
||
const dz = document.querySelector('.clip-track-dropzone')
|
||
expect(dz).toBeNull()
|
||
})
|
||
|
||
test('timeline has add-group button', () => {
|
||
const btn = document.querySelector('button[aria-label="Create a new track group"]')
|
||
expect(btn).toBeTruthy()
|
||
})
|
||
})
|
||
|
||
suite('timeline — backspace deletes selected layer', () => {
|
||
// Regression: Backspace / Delete used to only remove selected
|
||
// keyframes and markers. The selected layer track had no shortcut
|
||
// — the user had to use the context menu. Timeline's document-level
|
||
// keydown now also deletes the currently-selected layer, skipping
|
||
// locked layers (direct or via a locked parent group).
|
||
|
||
async function clearTimelineSelections() {
|
||
// The timeline's marker selection lives in a component-local ref
|
||
// that the store reset doesn't touch. Two Escape presses clear
|
||
// keyframes first, then markers — matches the handler's priority.
|
||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
||
await nextTick()
|
||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
||
await nextTick()
|
||
}
|
||
|
||
test('Backspace removes the selected layer and clears the selection', async () => {
|
||
const s = makeFreshStore()
|
||
await clearTimelineSelections()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
s.selectLayer(l.id)
|
||
expect(s.scene.layers.some((x) => x.id === l.id)).toBe(true)
|
||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace' }))
|
||
await nextTick()
|
||
expect(s.scene.layers.some((x) => x.id === l.id)).toBe(false)
|
||
expect(s.selectedLayerId).toBe(null)
|
||
})
|
||
|
||
test('Delete also removes the selected layer', async () => {
|
||
const s = makeFreshStore()
|
||
await clearTimelineSelections()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
s.selectLayer(l.id)
|
||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete' }))
|
||
await nextTick()
|
||
expect(s.scene.layers.some((x) => x.id === l.id)).toBe(false)
|
||
})
|
||
|
||
test('Backspace is a no-op while the layer itself is locked', async () => {
|
||
const s = makeFreshStore()
|
||
await clearTimelineSelections()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
s.selectLayer(l.id)
|
||
s.setTrackLocked(l.id, true)
|
||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace' }))
|
||
await nextTick()
|
||
expect(s.scene.layers.some((x) => x.id === l.id)).toBe(true)
|
||
})
|
||
|
||
test('Backspace is a no-op while the parent group is locked', async () => {
|
||
const s = makeFreshStore()
|
||
await clearTimelineSelections()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
const g = s.addTrackGroup('G')
|
||
s.moveTrackIntoGroup(l.id, g.id)
|
||
s.setTrackLocked(g.id, true)
|
||
s.selectLayer(l.id)
|
||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace' }))
|
||
await nextTick()
|
||
expect(s.scene.layers.some((x) => x.id === l.id)).toBe(true)
|
||
})
|
||
|
||
test('Backspace while typing in an input does not touch the selected layer', async () => {
|
||
const s = makeFreshStore()
|
||
await clearTimelineSelections()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
s.selectLayer(l.id)
|
||
// Mount an input, focus it, dispatch Backspace FROM the input.
|
||
const input = document.createElement('input')
|
||
input.setAttribute('data-test-input', '')
|
||
document.body.appendChild(input)
|
||
input.focus()
|
||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true }))
|
||
await nextTick()
|
||
expect(s.scene.layers.some((x) => x.id === l.id)).toBe(true)
|
||
input.remove()
|
||
})
|
||
|
||
test('Backspace with no layer selected is a no-op', async () => {
|
||
const s = makeFreshStore()
|
||
await clearTimelineSelections()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
// Explicitly deselect
|
||
s.selectLayer(null)
|
||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace' }))
|
||
await nextTick()
|
||
expect(s.scene.layers.some((x) => x.id === l.id)).toBe(true)
|
||
})
|
||
})
|
||
|
||
suite('version — app metadata', () => {
|
||
test('APP_VERSION is a semver-like string', () => {
|
||
expect(typeof APP_VERSION === 'string').toBe(true)
|
||
expect(/^\d+\.\d+\.\d+/.test(APP_VERSION)).toBe(true)
|
||
})
|
||
|
||
test('APP_NAME is non-empty', () => {
|
||
expect(APP_NAME.length).toBeGreaterThan(0)
|
||
})
|
||
})
|
||
|
||
suite('kf selection — shared composable', () => {
|
||
test('setting selection reflects across handles', () => {
|
||
const { selectedKfs } = useKfSelection()
|
||
const snapshot = selectedKfs.value
|
||
try {
|
||
selectedKfs.value = [
|
||
{ layerId: 'L1', kind: 'main', keyframeId: 'K1' },
|
||
{ layerId: 'L2', kind: 'position', keyframeId: 'K2' },
|
||
]
|
||
const second = useKfSelection()
|
||
expect(second.selectedKfs.value).toHaveLength(2)
|
||
expect(second.selectedKfSet.value.has('L1:main:K1')).toBe(true)
|
||
expect(second.selectedKfSet.value.has('L2:position:K2')).toBe(true)
|
||
} finally {
|
||
selectedKfs.value = snapshot
|
||
}
|
||
})
|
||
|
||
test('kfSelectionKey builds stable id', () => {
|
||
expect(kfSelectionKey({ layerId: 'a', kind: 'main', keyframeId: 'b' })).toBe('a:main:b')
|
||
expect(kfSelectionKey({ layerId: 'x', kind: 'position', keyframeId: 'y' })).toBe(
|
||
'x:position:y',
|
||
)
|
||
})
|
||
})
|
||
|
||
suite('assets — edge blur', () => {
|
||
test('rect asset stores blur value', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({
|
||
kind: 'rect',
|
||
name: 'R',
|
||
width: 40,
|
||
height: 40,
|
||
fill: '#ff0000',
|
||
blur: 12,
|
||
})
|
||
const stored = s.scene.assets.find((x) => x.id === a.id)!
|
||
expect(stored.kind).toBe('rect')
|
||
expect((stored as Extract<typeof stored, { kind: 'rect' }>).blur).toBe(12)
|
||
})
|
||
|
||
test('circle asset stores blur value', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({
|
||
kind: 'circle',
|
||
name: 'C',
|
||
radius: 30,
|
||
fill: '#0000ff',
|
||
blur: 6,
|
||
})
|
||
const stored = s.scene.assets.find((x) => x.id === a.id)!
|
||
expect(stored.kind).toBe('circle')
|
||
expect((stored as Extract<typeof stored, { kind: 'circle' }>).blur).toBe(6)
|
||
})
|
||
|
||
test('drawSceneFrame sets ctx.filter to blur() for blurred rect', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({
|
||
kind: 'rect',
|
||
name: 'Blurred',
|
||
width: 40,
|
||
height: 40,
|
||
fill: '#ff4488',
|
||
blur: 18,
|
||
})
|
||
s.addLayer(a.id)!
|
||
const { ctx, filterSets } = makeRecordingCtx()
|
||
drawSceneFrame(ctx, s.scene, 0, {})
|
||
expect(filterSets.some((v) => v === 'blur(18px)')).toBe(true)
|
||
})
|
||
|
||
test('drawSceneFrame does not apply blur when blur is unset or zero', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({
|
||
kind: 'rect',
|
||
name: 'Sharp',
|
||
width: 40,
|
||
height: 40,
|
||
fill: '#123456',
|
||
})
|
||
s.addLayer(a.id)!
|
||
const { ctx, filterSets } = makeRecordingCtx()
|
||
drawSceneFrame(ctx, s.scene, 0, {})
|
||
// The only filter writes should be the post-draw reset to 'none'
|
||
expect(filterSets.every((v) => v === 'none')).toBe(true)
|
||
})
|
||
|
||
test('drawSceneFrame applies blur for circle asset', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({
|
||
kind: 'circle',
|
||
name: 'CB',
|
||
radius: 20,
|
||
fill: '#22aa22',
|
||
blur: 9,
|
||
})
|
||
s.addLayer(a.id)!
|
||
const { ctx, filterSets } = makeRecordingCtx()
|
||
drawSceneFrame(ctx, s.scene, 0, {})
|
||
expect(filterSets.some((v) => v === 'blur(9px)')).toBe(true)
|
||
})
|
||
})
|
||
|
||
suite('mask layers — store', () => {
|
||
test('setLayerMask(true) enables negative mask by default', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'M', width: 40, height: 40, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
s.setLayerMask(l.id, true)
|
||
const layer = s.scene.layers.find((x) => x.id === l.id)!
|
||
expect(layer.isMask).toBe(true)
|
||
expect(layer.maskMode).toBe('negative')
|
||
})
|
||
|
||
test('setLayerMask(true, "negative") stores negative mode', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'circle', name: 'N', radius: 20, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
s.setLayerMask(l.id, true, 'negative')
|
||
const layer = s.scene.layers.find((x) => x.id === l.id)!
|
||
expect(layer.maskMode).toBe('negative')
|
||
})
|
||
|
||
test('setLayerMask(false) clears mask flags', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'M', width: 40, height: 40, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
s.setLayerMask(l.id, true, 'negative')
|
||
s.setLayerMask(l.id, false)
|
||
const layer = s.scene.layers.find((x) => x.id === l.id)!
|
||
expect(layer.isMask).toBe(undefined)
|
||
expect(layer.maskMode).toBe(undefined)
|
||
})
|
||
|
||
test('setLayerMaskMode flips mode without changing isMask', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'M', width: 40, height: 40, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
s.setLayerMask(l.id, true)
|
||
s.setLayerMaskMode(l.id, 'negative')
|
||
const layer = s.scene.layers.find((x) => x.id === l.id)!
|
||
expect(layer.isMask).toBe(true)
|
||
expect(layer.maskMode).toBe('negative')
|
||
})
|
||
})
|
||
|
||
suite('mask layers — rendering', () => {
|
||
function setupMaskScene(mode: 'positive' | 'negative') {
|
||
const s = makeFreshStore()
|
||
s.scene.canvas.width = 100
|
||
s.scene.canvas.height = 100
|
||
s.scene.canvas.background = 'transparent'
|
||
// Content: full-canvas red rect
|
||
const content = s.addAsset({
|
||
kind: 'rect',
|
||
name: 'BG',
|
||
width: 100,
|
||
height: 100,
|
||
fill: '#ff0000',
|
||
})
|
||
const contentLayer = s.addLayer(content.id)!
|
||
s.upsertKeyframe(contentLayer.id, 0, { x: 50, y: 50 })
|
||
// Mask: small circle centered at (50,50)
|
||
const mask = s.addAsset({
|
||
kind: 'circle',
|
||
name: 'M',
|
||
radius: 20,
|
||
fill: '#ffffff',
|
||
})
|
||
const maskLayer = s.addLayer(mask.id)!
|
||
s.upsertKeyframe(maskLayer.id, 0, { x: 50, y: 50 })
|
||
s.setLayerMask(maskLayer.id, true, mode)
|
||
const group = s.addTrackGroup('G')
|
||
s.moveTrackIntoGroup(maskLayer.id, group.id)
|
||
s.moveTrackIntoGroup(contentLayer.id, group.id)
|
||
return { store: s }
|
||
}
|
||
|
||
test('positive mask keeps content inside the mask shape and clears outside', () => {
|
||
const { store } = setupMaskScene('positive')
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = 100
|
||
canvas.height = 100
|
||
const ctx = canvas.getContext('2d')!
|
||
drawSceneFrame(ctx, store.scene, 0, {})
|
||
const inside = ctx.getImageData(50, 50, 1, 1).data
|
||
const outside = ctx.getImageData(5, 5, 1, 1).data
|
||
// Inside mask: red content visible
|
||
expect(inside[0] > 200).toBe(true)
|
||
expect(inside[3] > 200).toBe(true)
|
||
// Outside mask: cleared (alpha 0)
|
||
expect(outside[3]).toBe(0)
|
||
})
|
||
|
||
test('negative mask punches a hole in the content where the mask overlaps', () => {
|
||
const { store } = setupMaskScene('negative')
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = 100
|
||
canvas.height = 100
|
||
const ctx = canvas.getContext('2d')!
|
||
drawSceneFrame(ctx, store.scene, 0, {})
|
||
const inside = ctx.getImageData(50, 50, 1, 1).data
|
||
const outside = ctx.getImageData(5, 5, 1, 1).data
|
||
// Inside mask: hole (alpha 0)
|
||
expect(inside[3]).toBe(0)
|
||
// Outside mask: red content still there
|
||
expect(outside[0] > 200).toBe(true)
|
||
expect(outside[3] > 200).toBe(true)
|
||
})
|
||
|
||
test('layer outside a masked group is not affected by the mask', () => {
|
||
const { store } = setupMaskScene('positive')
|
||
// Add a green rect layer OUTSIDE the group
|
||
const outer = store.addAsset({
|
||
kind: 'rect',
|
||
name: 'Outer',
|
||
width: 20,
|
||
height: 20,
|
||
fill: '#00ff00',
|
||
})
|
||
const outerLayer = store.addLayer(outer.id)!
|
||
store.upsertKeyframe(outerLayer.id, 0, { x: 90, y: 90 })
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = 100
|
||
canvas.height = 100
|
||
const ctx = canvas.getContext('2d')!
|
||
drawSceneFrame(ctx, store.scene, 0, {})
|
||
// The outer layer renders fully regardless of mask
|
||
const outerPx = ctx.getImageData(90, 90, 1, 1).data
|
||
expect(outerPx[1] > 200).toBe(true)
|
||
expect(outerPx[3] > 200).toBe(true)
|
||
})
|
||
})
|
||
|
||
suite('scene store — shiftKeyframesPosition', () => {
|
||
test('shifts x/y of a single keyframe by delta', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'Shift', width: 40, height: 40, fill: '#a55' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 1, { x: 100, y: 50 })
|
||
const kf = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
|
||
s.shiftKeyframesPosition(
|
||
[{ layerId: l.id, kind: 'main', keyframeId: kf.id }],
|
||
25,
|
||
-10,
|
||
)
|
||
await tick()
|
||
expect(kf.props.x).toBe(125)
|
||
expect(kf.props.y).toBe(40)
|
||
})
|
||
|
||
test('shifts multiple keyframes across layers by the same delta', async () => {
|
||
const s = makeFreshStore()
|
||
const a1 = s.addAsset({ kind: 'rect', name: 'A', width: 10, height: 10, fill: '#111' })
|
||
const a2 = s.addAsset({ kind: 'rect', name: 'B', width: 10, height: 10, fill: '#222' })
|
||
const l1 = s.addLayer(a1.id)!
|
||
const l2 = s.addLayer(a2.id)!
|
||
s.upsertKeyframe(l1.id, 1, { x: 100, y: 100 })
|
||
s.upsertKeyframe(l2.id, 2, { x: 200, y: 300 })
|
||
const k1 = l1.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
|
||
const k2 = l2.keyframes.find((k) => Math.abs(k.time - 2) < 1e-3)!
|
||
s.shiftKeyframesPosition(
|
||
[
|
||
{ layerId: l1.id, kind: 'main', keyframeId: k1.id },
|
||
{ layerId: l2.id, kind: 'main', keyframeId: k2.id },
|
||
],
|
||
10,
|
||
5,
|
||
)
|
||
await tick()
|
||
expect(k1.props.x).toBe(110)
|
||
expect(k1.props.y).toBe(105)
|
||
expect(k2.props.x).toBe(210)
|
||
expect(k2.props.y).toBe(305)
|
||
})
|
||
|
||
test('skips rotation keyframes (no x/y)', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#333' })
|
||
const l = s.addLayer(a.id)!
|
||
s.setLayerSeparated(l.id, true)
|
||
s.upsertKeyframe(l.id, 1, { rotation: 45 })
|
||
const rotKf = (l.rotationKeyframes ?? []).find((k) => Math.abs(k.time - 1) < 1e-3)!
|
||
s.shiftKeyframesPosition(
|
||
[{ layerId: l.id, kind: 'rotation', keyframeId: rotKf.id }],
|
||
100,
|
||
100,
|
||
)
|
||
await tick()
|
||
expect((rotKf.props as Record<string, unknown>).x).toBe(undefined)
|
||
expect((rotKf.props as Record<string, unknown>).y).toBe(undefined)
|
||
expect(rotKf.props.rotation).toBe(45)
|
||
})
|
||
|
||
test('uses sampled fallback when keyframe has no explicit x/y', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'Fb', width: 10, height: 10, fill: '#444' })
|
||
const l = s.addLayer(a.id)!
|
||
// initial keyframe at t=0 has full base props (including x=640 from canvas center)
|
||
// Create a later keyframe that only sets scaleX
|
||
s.upsertKeyframe(l.id, 2, { scaleX: 2 })
|
||
const kf = l.keyframes.find((k) => Math.abs(k.time - 2) < 1e-3)!
|
||
// ensure x is not explicitly set
|
||
delete (kf.props as Record<string, unknown>).x
|
||
delete (kf.props as Record<string, unknown>).y
|
||
const sampledBefore = sampleLayer(l, 2)
|
||
s.shiftKeyframesPosition([{ layerId: l.id, kind: 'main', keyframeId: kf.id }], 7, 11)
|
||
await tick()
|
||
expect(kf.props.x).toBe(sampledBefore.x + 7)
|
||
expect(kf.props.y).toBe(sampledBefore.y + 11)
|
||
})
|
||
|
||
test('non-existing layer/keyframe refs are safely ignored', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#555' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 1, { x: 50 })
|
||
// Call with mix of valid and invalid refs — should not throw
|
||
s.shiftKeyframesPosition(
|
||
[
|
||
{ layerId: 'missing-layer', kind: 'main', keyframeId: 'missing-kf' },
|
||
{ layerId: l.id, kind: 'main', keyframeId: 'also-missing' },
|
||
],
|
||
10,
|
||
10,
|
||
)
|
||
// Valid keyframe at t=1 remained unshifted (we didn't target it)
|
||
const kf = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
|
||
expect(kf.props.x).toBe(50)
|
||
})
|
||
})
|
||
|
||
suite('polygon — store actions', () => {
|
||
function makePolygonLayer() {
|
||
const s = makeFreshStore()
|
||
const asset = s.addAsset({
|
||
kind: 'polygon',
|
||
name: 'Poly',
|
||
points: [
|
||
{ x: 0, y: -40 },
|
||
{ x: 40, y: 20 },
|
||
{ x: -40, y: 20 },
|
||
],
|
||
fill: '#abc',
|
||
}) as PolygonAsset
|
||
const layer = s.addLayer(asset.id)!
|
||
return { s, asset, layer }
|
||
}
|
||
|
||
test('addLayer seeds initial keyframe with points for polygons', () => {
|
||
const { layer, asset } = makePolygonLayer()
|
||
const kf0 = layer.keyframes[0]
|
||
expect(kf0).toBeTruthy()
|
||
expect(Array.isArray(kf0.props.points)).toBe(true)
|
||
expect(kf0.props.points!.length).toBe(asset.points.length)
|
||
})
|
||
|
||
test('movePolygonVertex writes a points-array kf at playhead', () => {
|
||
const { s, layer, asset } = makePolygonLayer()
|
||
s.setPlayhead(0)
|
||
s.movePolygonVertex(layer.id, 0, 10, -50)
|
||
const kf = layer.keyframes.find((k) => Math.abs(k.time) < 1e-3)!
|
||
expect(kf.props.points![0].x).toBe(10)
|
||
expect(kf.props.points![0].y).toBe(-50)
|
||
// Other points remain unchanged
|
||
expect(kf.props.points![1].x).toBe(asset.points[1].x)
|
||
expect(kf.props.points![1].y).toBe(asset.points[1].y)
|
||
})
|
||
|
||
test('setPolygonPointCount grows by midpoint insertion and keeps KFs in sync', () => {
|
||
const { s, layer, asset } = makePolygonLayer()
|
||
const originalLen = asset.points.length
|
||
s.setPolygonPointCount(asset.id, originalLen + 2)
|
||
expect(asset.points.length).toBe(originalLen + 2)
|
||
for (const kf of layer.keyframes) {
|
||
if (!kf.props.points) continue
|
||
expect(kf.props.points.length).toBe(originalLen + 2)
|
||
}
|
||
})
|
||
|
||
test('setPolygonPointCount shrinks but never below 3', () => {
|
||
const { s, asset } = makePolygonLayer()
|
||
s.setPolygonPointCount(asset.id, 1)
|
||
expect(asset.points.length).toBe(3)
|
||
})
|
||
|
||
test('insertPolygonPointOnEdge inserts at expected index across all keyframes', () => {
|
||
const { s, layer, asset } = makePolygonLayer()
|
||
s.insertPolygonPointOnEdge(asset.id, 1, 0.5)
|
||
expect(asset.points.length).toBe(4)
|
||
const kf = layer.keyframes[0]
|
||
expect(kf.props.points!.length).toBe(4)
|
||
// midpoint between original [0] and [1]
|
||
const expectedX = (0 + 40) / 2
|
||
const expectedY = (-40 + 20) / 2
|
||
expect(asset.points[1].x).toBe(expectedX)
|
||
expect(asset.points[1].y).toBe(expectedY)
|
||
expect(kf.props.points![1].x).toBe(expectedX)
|
||
expect(kf.props.points![1].y).toBe(expectedY)
|
||
})
|
||
|
||
test('reversePolygonPoints reverses asset + keyframe points', () => {
|
||
const { s, layer, asset } = makePolygonLayer()
|
||
const firstBefore = { ...asset.points[0] }
|
||
s.reversePolygonPoints(asset.id)
|
||
expect(asset.points[asset.points.length - 1].x).toBe(firstBefore.x)
|
||
expect(asset.points[asset.points.length - 1].y).toBe(firstBefore.y)
|
||
const kf = layer.keyframes[0]
|
||
expect(kf.props.points![kf.props.points!.length - 1].x).toBe(firstBefore.x)
|
||
})
|
||
|
||
test('setPolygonClosed toggles the closed flag', () => {
|
||
const { s, asset } = makePolygonLayer()
|
||
s.setPolygonClosed(asset.id, false)
|
||
expect(asset.closed).toBe(false)
|
||
s.setPolygonClosed(asset.id, true)
|
||
expect(asset.closed).toBe(true)
|
||
})
|
||
})
|
||
|
||
suite('freeform drawing — lifecycle', () => {
|
||
test('startFreeformDrawing creates asset and layer', () => {
|
||
const s = makeFreshStore()
|
||
s.startFreeformDrawing()
|
||
expect(s.drawingMode).toBe('freeform')
|
||
expect(s.drawingAssetId).toBeTruthy()
|
||
expect(s.drawingLayerId).toBeTruthy()
|
||
const asset = s.scene.assets.find((a) => a.id === s.drawingAssetId)!
|
||
expect(asset.kind).toBe('polygon')
|
||
expect((asset as PolygonAsset).closed).toBe(false)
|
||
})
|
||
|
||
test('addDrawingPoint appends to asset and to KF points', () => {
|
||
const s = makeFreshStore()
|
||
s.startFreeformDrawing()
|
||
s.addDrawingPoint(10, 20)
|
||
s.addDrawingPoint(30, 40)
|
||
const asset = s.scene.assets.find((a) => a.id === s.drawingAssetId) as PolygonAsset
|
||
expect(asset.points.length).toBe(2)
|
||
expect(asset.points[1].x).toBe(30)
|
||
const layer = s.activeLayers.find((l) => l.id === s.drawingLayerId)!
|
||
expect(layer.keyframes[0].props.points!.length).toBe(2)
|
||
})
|
||
|
||
test('finishDrawing with <2 points drops the in-progress asset', () => {
|
||
const s = makeFreshStore()
|
||
s.startFreeformDrawing()
|
||
const assetId = s.drawingAssetId!
|
||
const layerId = s.drawingLayerId!
|
||
s.addDrawingPoint(5, 5) // only 1 point
|
||
s.finishDrawing()
|
||
expect(s.drawingMode).toBeNull()
|
||
expect(s.scene.assets.some((a) => a.id === assetId)).toBe(false)
|
||
expect(s.activeLayers.some((l) => l.id === layerId)).toBe(false)
|
||
})
|
||
|
||
test('finishDrawing with >=2 points keeps the layer and clears drawing state', () => {
|
||
const s = makeFreshStore()
|
||
s.startFreeformDrawing()
|
||
s.addDrawingPoint(0, 0)
|
||
s.addDrawingPoint(50, 50)
|
||
const assetId = s.drawingAssetId!
|
||
const layerId = s.drawingLayerId!
|
||
s.finishDrawing()
|
||
expect(s.drawingMode).toBeNull()
|
||
expect(s.drawingAssetId).toBeNull()
|
||
expect(s.drawingLayerId).toBeNull()
|
||
expect(s.scene.assets.some((a) => a.id === assetId)).toBe(true)
|
||
expect(s.activeLayers.some((l) => l.id === layerId)).toBe(true)
|
||
})
|
||
|
||
test('cancelDrawing removes the in-progress asset and layer', () => {
|
||
const s = makeFreshStore()
|
||
s.startFreeformDrawing()
|
||
s.addDrawingPoint(0, 0)
|
||
s.addDrawingPoint(10, 10)
|
||
const assetId = s.drawingAssetId!
|
||
const layerId = s.drawingLayerId!
|
||
s.cancelDrawing()
|
||
expect(s.drawingMode).toBeNull()
|
||
expect(s.scene.assets.some((a) => a.id === assetId)).toBe(false)
|
||
expect(s.activeLayers.some((l) => l.id === layerId)).toBe(false)
|
||
})
|
||
|
||
test('startFreeformDrawing resumes on selected open polygon instead of creating new', () => {
|
||
const s = makeFreshStore()
|
||
s.startFreeformDrawing()
|
||
s.addDrawingPoint(0, 0)
|
||
s.addDrawingPoint(20, 20)
|
||
const assetId = s.drawingAssetId!
|
||
const layerId = s.drawingLayerId!
|
||
s.finishDrawing()
|
||
// Layer stays selected; invoke start again
|
||
s.selectLayer(layerId)
|
||
s.startFreeformDrawing()
|
||
expect(s.drawingMode).toBe('freeform')
|
||
expect(s.drawingAssetId).toBe(assetId)
|
||
expect(s.drawingLayerId).toBe(layerId)
|
||
})
|
||
})
|
||
|
||
suite('polygon + stroke — rendering', () => {
|
||
test('drawSceneFrame renders a filled closed polygon in fill color', () => {
|
||
const s = makeFreshStore()
|
||
s.scene.canvas.width = 100
|
||
s.scene.canvas.height = 100
|
||
s.scene.canvas.background = 'transparent'
|
||
const a = s.addAsset({
|
||
kind: 'polygon',
|
||
name: 'Tri',
|
||
points: [
|
||
{ x: 0, y: -30 },
|
||
{ x: 30, y: 20 },
|
||
{ x: -30, y: 20 },
|
||
],
|
||
fill: '#ff0000',
|
||
})
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 0, { x: 50, y: 50 })
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = 100
|
||
canvas.height = 100
|
||
const ctx = canvas.getContext('2d')!
|
||
drawSceneFrame(ctx, s.scene, 0, {})
|
||
const inside = ctx.getImageData(50, 55, 1, 1).data
|
||
expect(inside[0] > 200).toBe(true) // red channel
|
||
expect(inside[3] > 200).toBe(true) // alpha
|
||
})
|
||
|
||
test('open polygon with stroke renders only the stroke path', () => {
|
||
const s = makeFreshStore()
|
||
s.scene.canvas.width = 100
|
||
s.scene.canvas.height = 100
|
||
s.scene.canvas.background = 'transparent'
|
||
const a = s.addAsset({
|
||
kind: 'polygon',
|
||
name: 'Line',
|
||
points: [
|
||
{ x: -40, y: 0 },
|
||
{ x: 40, y: 0 },
|
||
],
|
||
fill: 'transparent',
|
||
closed: false,
|
||
stroke: '#00ff00',
|
||
strokeWidth: 4,
|
||
})
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 0, { x: 50, y: 50 })
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = 100
|
||
canvas.height = 100
|
||
const ctx = canvas.getContext('2d')!
|
||
drawSceneFrame(ctx, s.scene, 0, {})
|
||
const onLine = ctx.getImageData(50, 50, 1, 1).data
|
||
const offLine = ctx.getImageData(50, 80, 1, 1).data
|
||
expect(onLine[1] > 200).toBe(true) // green on line
|
||
expect(onLine[3] > 200).toBe(true)
|
||
expect(offLine[3]).toBe(0) // transparent elsewhere
|
||
})
|
||
|
||
test('rect with stroke renders the outline', () => {
|
||
const s = makeFreshStore()
|
||
s.scene.canvas.width = 100
|
||
s.scene.canvas.height = 100
|
||
s.scene.canvas.background = 'transparent'
|
||
const a = s.addAsset({
|
||
kind: 'rect',
|
||
name: 'R',
|
||
width: 40,
|
||
height: 40,
|
||
fill: 'transparent',
|
||
stroke: '#0000ff',
|
||
strokeWidth: 4,
|
||
})
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 0, { x: 50, y: 50 })
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = 100
|
||
canvas.height = 100
|
||
const ctx = canvas.getContext('2d')!
|
||
drawSceneFrame(ctx, s.scene, 0, {})
|
||
// On the top edge of the rect
|
||
const onEdge = ctx.getImageData(50, 30, 1, 1).data
|
||
// Interior
|
||
const inside = ctx.getImageData(50, 50, 1, 1).data
|
||
expect(onEdge[2] > 200).toBe(true) // blue on edge
|
||
expect(inside[3]).toBe(0) // transparent interior
|
||
})
|
||
|
||
test('strokeWidth without explicit stroke falls back to black', () => {
|
||
const s = makeFreshStore()
|
||
s.scene.canvas.width = 100
|
||
s.scene.canvas.height = 100
|
||
s.scene.canvas.background = 'transparent'
|
||
const a = s.addAsset({
|
||
kind: 'rect',
|
||
name: 'R',
|
||
width: 40,
|
||
height: 40,
|
||
fill: 'transparent',
|
||
strokeWidth: 4,
|
||
})
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 0, { x: 50, y: 50 })
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = 100
|
||
canvas.height = 100
|
||
const ctx = canvas.getContext('2d')!
|
||
drawSceneFrame(ctx, s.scene, 0, {})
|
||
const onEdge = ctx.getImageData(50, 30, 1, 1).data
|
||
// Black stroke: RGB all low, alpha high
|
||
expect(onEdge[0] < 40).toBe(true)
|
||
expect(onEdge[1] < 40).toBe(true)
|
||
expect(onEdge[2] < 40).toBe(true)
|
||
expect(onEdge[3] > 200).toBe(true)
|
||
})
|
||
|
||
test('transparent fill on closed polygon skips fill', () => {
|
||
const s = makeFreshStore()
|
||
s.scene.canvas.width = 100
|
||
s.scene.canvas.height = 100
|
||
s.scene.canvas.background = 'transparent'
|
||
const a = s.addAsset({
|
||
kind: 'polygon',
|
||
name: 'Hollow',
|
||
points: [
|
||
{ x: -30, y: -30 },
|
||
{ x: 30, y: -30 },
|
||
{ x: 30, y: 30 },
|
||
{ x: -30, y: 30 },
|
||
],
|
||
fill: 'transparent',
|
||
closed: true,
|
||
stroke: '#ff00ff',
|
||
strokeWidth: 3,
|
||
})
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 0, { x: 50, y: 50 })
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = 100
|
||
canvas.height = 100
|
||
const ctx = canvas.getContext('2d')!
|
||
drawSceneFrame(ctx, s.scene, 0, {})
|
||
// Center should be fully transparent (no fill)
|
||
const center = ctx.getImageData(50, 50, 1, 1).data
|
||
expect(center[3]).toBe(0)
|
||
})
|
||
})
|
||
|
||
suite('bezier path — interpolation', () => {
|
||
test('sampleLayer returns quadratic bezier midpoint when ctrl is set', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'B', width: 10, height: 10, fill: '#111' })
|
||
const l = s.addLayer(a.id)!
|
||
// Two keyframes: (0,0) at t=0 and (100,0) at t=1 with a control at (50,100).
|
||
s.upsertKeyframe(l.id, 0, { x: 0, y: 0 })
|
||
s.upsertKeyframe(l.id, 1, { x: 100, y: 0 })
|
||
const cur = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
|
||
s.setKeyframeControl(l.id, cur.id, 50, 100)
|
||
const samp = sampleLayer(l, 0.5)
|
||
// At u=0.5: B = 0.25*0 + 0.5*50 + 0.25*100 = 25 + 25 = 50 x; y = 0.5*100 = 50
|
||
expect(Math.abs(samp.x - 50) < 1e-6).toBe(true)
|
||
expect(Math.abs(samp.y - 50) < 1e-6).toBe(true)
|
||
})
|
||
|
||
test('bezier affects only x/y; other props remain linear', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'B', width: 10, height: 10, fill: '#111' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 0, { x: 0, y: 0, opacity: 0 })
|
||
s.upsertKeyframe(l.id, 1, { x: 100, y: 0, opacity: 1 })
|
||
const cur = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
|
||
s.setKeyframeControl(l.id, cur.id, 50, 200)
|
||
const samp = sampleLayer(l, 0.5)
|
||
expect(Math.abs(samp.opacity - 0.5) < 1e-6).toBe(true)
|
||
})
|
||
|
||
test('endpoints are not affected by control point', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'B', width: 10, height: 10, fill: '#111' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 0, { x: 10, y: 20 })
|
||
s.upsertKeyframe(l.id, 2, { x: 110, y: 200 })
|
||
const cur = l.keyframes.find((k) => Math.abs(k.time - 2) < 1e-3)!
|
||
s.setKeyframeControl(l.id, cur.id, 500, 500)
|
||
const start = sampleLayer(l, 0)
|
||
const end = sampleLayer(l, 2)
|
||
expect(start.x).toBe(10)
|
||
expect(start.y).toBe(20)
|
||
expect(end.x).toBe(110)
|
||
expect(end.y).toBe(200)
|
||
})
|
||
})
|
||
|
||
suite('keyframe control — store actions', () => {
|
||
test('setKeyframeControl stores ctrlX/Y on the keyframe', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'K', width: 10, height: 10, fill: '#111' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 1, { x: 50, y: 50 })
|
||
const kf = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
|
||
s.setKeyframeControl(l.id, kf.id, 30, 40)
|
||
expect(kf.ctrlX).toBe(30)
|
||
expect(kf.ctrlY).toBe(40)
|
||
})
|
||
|
||
test('clearKeyframeControl removes ctrlX/Y', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'K', width: 10, height: 10, fill: '#111' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 1, { x: 50, y: 50 })
|
||
const kf = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
|
||
s.setKeyframeControl(l.id, kf.id, 30, 40)
|
||
s.clearKeyframeControl(l.id, kf.id)
|
||
expect(kf.ctrlX).toBe(undefined)
|
||
expect(kf.ctrlY).toBe(undefined)
|
||
})
|
||
|
||
test('setKeyframeControlFromPoint solves so the curve passes through the target', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'K', width: 10, height: 10, fill: '#111' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 0, { x: 0, y: 0 })
|
||
s.upsertKeyframe(l.id, 1, { x: 100, y: 0 })
|
||
const cur = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
|
||
// We want the curve at u=0.5 to pass through (50, 80).
|
||
s.setKeyframeControlFromPoint(l.id, cur.id, 0.5, 50, 80)
|
||
const samp = sampleLayer(l, 0.5)
|
||
expect(Math.abs(samp.x - 50) < 1e-6).toBe(true)
|
||
expect(Math.abs(samp.y - 80) < 1e-6).toBe(true)
|
||
})
|
||
|
||
test('setKeyframeControlFromPoint is a no-op for endpoints (u=0 or u=1)', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'K', width: 10, height: 10, fill: '#111' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 0, { x: 0, y: 0 })
|
||
s.upsertKeyframe(l.id, 1, { x: 100, y: 0 })
|
||
const cur = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
|
||
s.setKeyframeControlFromPoint(l.id, cur.id, 0, 10, 10)
|
||
s.setKeyframeControlFromPoint(l.id, cur.id, 1, 99, 99)
|
||
expect(cur.ctrlX).toBe(undefined)
|
||
expect(cur.ctrlY).toBe(undefined)
|
||
})
|
||
|
||
test('setKeyframeControlFromPoint needs a previous keyframe', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'K', width: 10, height: 10, fill: '#111' })
|
||
const l = s.addLayer(a.id)!
|
||
// Single keyframe at t=0 only
|
||
const kf = l.keyframes[0]
|
||
s.setKeyframeControlFromPoint(l.id, kf.id, 0.5, 20, 20)
|
||
expect(kf.ctrlX).toBe(undefined)
|
||
})
|
||
})
|
||
|
||
suite('clipart store — state', () => {
|
||
test('toggle flips open state', async () => {
|
||
const { useClipartStore } = await import('@/stores/clipart')
|
||
const s = useClipartStore()
|
||
s.$reset()
|
||
const start = s.open
|
||
s.toggle()
|
||
expect(s.open).toBe(!start)
|
||
s.toggle()
|
||
expect(s.open).toBe(start)
|
||
})
|
||
|
||
test('setOpen persists to localStorage', async () => {
|
||
const { useClipartStore } = await import('@/stores/clipart')
|
||
const s = useClipartStore()
|
||
s.setOpen(true)
|
||
expect(localStorage.getItem('animatr:clipartDrawerOpen')).toBe('1')
|
||
s.setOpen(false)
|
||
expect(localStorage.getItem('animatr:clipartDrawerOpen')).toBe('0')
|
||
})
|
||
|
||
test('clearFolder resets handle, folderName and items', async () => {
|
||
const { useClipartStore } = await import('@/stores/clipart')
|
||
const s = useClipartStore()
|
||
// simulate a loaded state
|
||
s.folderName = 'mock'
|
||
s.handle = {} as FileSystemDirectoryHandle
|
||
s.items = []
|
||
await s.clearFolder()
|
||
expect(s.handle).toBeNull()
|
||
expect(s.folderName).toBeNull()
|
||
expect(s.items.length).toBe(0)
|
||
})
|
||
|
||
test('supported flag matches FileSystemAccess helper', async () => {
|
||
const { useClipartStore } = await import('@/stores/clipart')
|
||
const { isFileSystemAccessSupported } = await import('@/lib/clipartLibrary')
|
||
const s = useClipartStore()
|
||
expect(s.supported).toBe(isFileSystemAccessSupported())
|
||
})
|
||
})
|
||
|
||
suite('interface prefs — shared composables', () => {
|
||
// Regression: the tooltip + asset-thumbs prefs are shared module-
|
||
// level refs so the Settings dialog and the sidebar/timeline
|
||
// toolbars stay in lock-step. The same refs also have to persist
|
||
// to localStorage. The thumbs pref was originally an inline ref
|
||
// inside AssetSidebar.vue — the settings tab couldn't change it
|
||
// without this extraction.
|
||
|
||
test('useTooltipPref persists to localStorage and shares the ref', async () => {
|
||
const { useTooltipPref } = await import('@/composables/useTooltipPref')
|
||
const a = useTooltipPref()
|
||
const b = useTooltipPref()
|
||
expect(a.enabled).toBe(b.enabled) // same ref instance
|
||
const initial = a.enabled.value
|
||
a.enabled.value = !initial
|
||
await nextTick()
|
||
expect(localStorage.getItem('ruwio:tooltipsEnabled')).toBe(initial ? '0' : '1')
|
||
// Both consumers see the flip because the ref is shared.
|
||
expect(b.enabled.value).toBe(!initial)
|
||
// Restore so other tests don't inherit a toggled state.
|
||
a.enabled.value = initial
|
||
await nextTick()
|
||
})
|
||
|
||
test('useAssetThumbsPref persists to localStorage and shares the ref', async () => {
|
||
const { useAssetThumbsPref } = await import('@/composables/useAssetThumbsPref')
|
||
const a = useAssetThumbsPref()
|
||
const b = useAssetThumbsPref()
|
||
expect(a.enabled).toBe(b.enabled)
|
||
const initial = a.enabled.value
|
||
a.enabled.value = !initial
|
||
await nextTick()
|
||
expect(localStorage.getItem('animatr.assetThumbs')).toBe(String(!initial))
|
||
expect(b.enabled.value).toBe(!initial)
|
||
a.enabled.value = initial
|
||
await nextTick()
|
||
})
|
||
})
|
||
|
||
suite('clipart drawer — grid pre-layout', () => {
|
||
// Regression: the drawer's `.drawer-panel` used to be `v-if`-gated on
|
||
// clipart.open and given `flex: 1; min-width: 0` — which meant the
|
||
// grid mounted at the same moment the drawer started its width
|
||
// transition, so the `repeat(auto-fill, minmax(88px, 1fr))` grid
|
||
// reflowed continuously from ~1 column at 5px → 4 columns at 424px
|
||
// and the thumbs "tumbled" into place. The panel is now always
|
||
// mounted at a rigid 400px, and `.clipart-drawer { overflow: hidden }`
|
||
// just clips the slide.
|
||
|
||
test('.drawer-panel is in the DOM when the drawer is closed', async () => {
|
||
const { useClipartStore } = await import('@/stores/clipart')
|
||
const s = useClipartStore()
|
||
s.setOpen(false)
|
||
const handle = mountComponent(ClipartDrawer, {})
|
||
await nextTick()
|
||
const panel = handle.host.querySelector('.drawer-panel')
|
||
expect(panel).toBeTruthy()
|
||
handle.unmount()
|
||
})
|
||
|
||
test('.drawer-panel is in the DOM when the drawer is open', async () => {
|
||
const { useClipartStore } = await import('@/stores/clipart')
|
||
const s = useClipartStore()
|
||
s.setOpen(true)
|
||
const handle = mountComponent(ClipartDrawer, {})
|
||
await nextTick()
|
||
const panel = handle.host.querySelector('.drawer-panel')
|
||
expect(panel).toBeTruthy()
|
||
handle.unmount()
|
||
})
|
||
|
||
test('.drawer-panel stays mounted across open → closed toggle', async () => {
|
||
const { useClipartStore } = await import('@/stores/clipart')
|
||
const s = useClipartStore()
|
||
s.setOpen(true)
|
||
const handle = mountComponent(ClipartDrawer, {})
|
||
await nextTick()
|
||
expect(handle.host.querySelector('.drawer-panel')).toBeTruthy()
|
||
s.setOpen(false)
|
||
await nextTick()
|
||
expect(handle.host.querySelector('.drawer-panel')).toBeTruthy()
|
||
handle.unmount()
|
||
})
|
||
|
||
test('.drawer-panel has a rigid 400px width regardless of the drawer state', async () => {
|
||
const { useClipartStore } = await import('@/stores/clipart')
|
||
const s = useClipartStore()
|
||
s.setOpen(false)
|
||
const handle = mountComponent(ClipartDrawer, {})
|
||
await nextTick()
|
||
const panel = handle.host.querySelector('.drawer-panel') as HTMLElement
|
||
expect(panel).toBeTruthy()
|
||
const widthClosed = getComputedStyle(panel).width
|
||
s.setOpen(true)
|
||
await nextTick()
|
||
const widthOpen = getComputedStyle(panel).width
|
||
expect(widthClosed).toBe('400px')
|
||
expect(widthOpen).toBe('400px')
|
||
handle.unmount()
|
||
})
|
||
|
||
test('.clipart-drawer clips its contents (overflow: hidden)', async () => {
|
||
const { useClipartStore } = await import('@/stores/clipart')
|
||
const s = useClipartStore()
|
||
s.setOpen(false)
|
||
const handle = mountComponent(ClipartDrawer, {})
|
||
await nextTick()
|
||
const drawer = handle.host.querySelector('.clipart-drawer') as HTMLElement
|
||
expect(drawer).toBeTruthy()
|
||
const overflowX = getComputedStyle(drawer).overflowX
|
||
const overflowY = getComputedStyle(drawer).overflowY
|
||
// `overflow: hidden` applies to both axes. We check each because
|
||
// browsers normalise the shorthand into the long-hand properties.
|
||
expect(overflowX).toBe('hidden')
|
||
expect(overflowY).toBe('hidden')
|
||
handle.unmount()
|
||
})
|
||
})
|
||
|
||
suite('ui — SceneDrawer', () => {
|
||
async function settlePromptCancel() {
|
||
// The drawer's `+` button dispatches addScene and then kicks off a
|
||
// promptDialog for renaming. Tests don't render DialogsHost, so
|
||
// we resolve the pending prompt explicitly to keep the dialog
|
||
// queue empty between tests.
|
||
const { cancelDialog } = await import('@/components/ui/dialogs')
|
||
cancelDialog()
|
||
}
|
||
|
||
test('renders one .scene-row per page, with .active on the current one', async () => {
|
||
const s = makeFreshStore()
|
||
s.addScene('Two')
|
||
s.addScene('Three')
|
||
const activeId = s.project.activeSceneId
|
||
const handle = mountComponent(SceneDrawer, {})
|
||
await nextTick()
|
||
const rows = handle.host.querySelectorAll('.scene-row')
|
||
expect(rows.length).toBe(3)
|
||
// Order matches `project.scenes`.
|
||
const names = Array.from(rows).map((r) => r.querySelector('.scene-name')?.textContent?.trim())
|
||
expect(names).toEqual(s.project.scenes.map((p) => p.name))
|
||
// Exactly one row is .active, and it's the active scene.
|
||
const active = Array.from(rows).filter((r) => r.classList.contains('active'))
|
||
expect(active.length).toBe(1)
|
||
const activeName = active[0].querySelector('.scene-name')?.textContent?.trim()
|
||
expect(activeName).toBe(s.project.scenes.find((p) => p.id === activeId)!.name)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('layer-count badge reflects each scene\'s layer count', async () => {
|
||
const s = makeFreshStore()
|
||
const asset = s.addAsset({ kind: 'rect', name: 'D', width: 10, height: 10, fill: '#abc' })
|
||
s.addLayer(asset.id)
|
||
s.addLayer(asset.id)
|
||
const two = s.addScene('Two')
|
||
s.addLayer(asset.id) // lands on Two (active after addScene)
|
||
// Render AFTER state is set up so the component's reactive read
|
||
// captures the final layer counts.
|
||
const handle = mountComponent(SceneDrawer, {})
|
||
await nextTick()
|
||
const rows = handle.host.querySelectorAll('.scene-row')
|
||
expect(rows.length).toBe(2)
|
||
const metas = Array.from(rows).map((r) =>
|
||
r.querySelector('.scene-meta')?.textContent?.trim(),
|
||
)
|
||
// First row = Scene 1 (2 layers), second row = Two (1 layer).
|
||
expect(metas).toEqual(['2', '1'])
|
||
void two
|
||
handle.unmount()
|
||
})
|
||
|
||
test('clicking a non-active row switches to that page and auto-plays', async () => {
|
||
const s = makeFreshStore()
|
||
const firstId = s.project.activeSceneId
|
||
const second = s.addScene('Two')
|
||
// Go back to Scene 1 and pause so we can observe the auto-play flip.
|
||
s.switchScene(firstId)
|
||
s.pause()
|
||
const handle = mountComponent(SceneDrawer, {})
|
||
await nextTick()
|
||
const rows = handle.host.querySelectorAll('.scene-row') as NodeListOf<HTMLButtonElement>
|
||
// Row index 1 = Two (non-active).
|
||
rows[1].click()
|
||
await nextTick()
|
||
expect(s.project.activeSceneId).toBe(second.id)
|
||
expect(s.playing).toBe(true)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('clicking the already-active row is a no-op', async () => {
|
||
const s = makeFreshStore()
|
||
const firstId = s.project.activeSceneId
|
||
s.pause()
|
||
const handle = mountComponent(SceneDrawer, {})
|
||
await nextTick()
|
||
const rows = handle.host.querySelectorAll('.scene-row') as NodeListOf<HTMLButtonElement>
|
||
// Row index 0 = the active one.
|
||
rows[0].click()
|
||
await nextTick()
|
||
// Still paused — no sneaky re-switch triggered auto-play.
|
||
expect(s.playing).toBe(false)
|
||
expect(s.project.activeSceneId).toBe(firstId)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('+ button appends a new scene and activates it', async () => {
|
||
const s = makeFreshStore()
|
||
expect(s.project.scenes).toHaveLength(1)
|
||
const handle = mountComponent(SceneDrawer, {})
|
||
await nextTick()
|
||
const addBtn = handle.host.querySelector('.add-btn') as HTMLButtonElement
|
||
expect(addBtn).toBeTruthy()
|
||
addBtn.click()
|
||
// Let addScene + the nextTick inside addScene resolve.
|
||
await nextTick()
|
||
await nextTick()
|
||
expect(s.project.scenes).toHaveLength(2)
|
||
expect(s.project.activeSceneId).toBe(s.project.scenes[1].id)
|
||
// Clean up the pending rename prompt.
|
||
await settlePromptCancel()
|
||
handle.unmount()
|
||
})
|
||
|
||
test('right-click on a row surfaces the context menu', async () => {
|
||
const s = makeFreshStore()
|
||
s.addScene('Two')
|
||
const handle = mountComponent(SceneDrawer, {})
|
||
await nextTick()
|
||
expect(handle.host.querySelector('.scene-menu')).toBeNull()
|
||
const row = handle.host.querySelector('.scene-row') as HTMLElement
|
||
row.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))
|
||
await nextTick()
|
||
expect(handle.host.querySelector('.scene-menu')).toBeTruthy()
|
||
// Delete option is disabled only when there's just one scene left.
|
||
const deleteBtn = handle.host.querySelector('.scene-menu .menu-item.danger') as HTMLButtonElement
|
||
expect(deleteBtn).toBeTruthy()
|
||
expect(deleteBtn.disabled).toBe(false)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('context menu disables Delete when only one scene remains', async () => {
|
||
const s = makeFreshStore()
|
||
expect(s.project.scenes.length).toBe(1)
|
||
const handle = mountComponent(SceneDrawer, {})
|
||
await nextTick()
|
||
const row = handle.host.querySelector('.scene-row') as HTMLElement
|
||
row.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))
|
||
await nextTick()
|
||
const deleteBtn = handle.host.querySelector('.scene-menu .menu-item.danger') as HTMLButtonElement
|
||
expect(deleteBtn).toBeTruthy()
|
||
expect(deleteBtn.disabled).toBe(true)
|
||
handle.unmount()
|
||
})
|
||
})
|
||
|
||
suite('ui — ActionEditor (jumpToScene)', () => {
|
||
test('jumpToScene appears in the type dropdown', async () => {
|
||
const s = makeFreshStore()
|
||
s.addScene('Two')
|
||
const handle = mountComponent(
|
||
ActionEditor,
|
||
{ actions: [{ type: 'pause' } as const] },
|
||
)
|
||
await nextTick()
|
||
const select = handle.host.querySelector('.type-select') as HTMLSelectElement
|
||
expect(select).toBeTruthy()
|
||
const values = Array.from(select.options).map((o) => o.value)
|
||
expect(values.includes('jumpToScene')).toBe(true)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('switching a row to jumpToScene seeds it with the first non-active scene', async () => {
|
||
const s = makeFreshStore()
|
||
const activeId = s.project.activeSceneId
|
||
const other = s.addScene('Other')
|
||
s.switchScene(activeId) // pull the active marker back to Scene 1
|
||
let latest: unknown = null
|
||
const handle = mountComponent(ActionEditor, {
|
||
actions: [{ type: 'pause' } as const],
|
||
'onUpdate:actions': (v: unknown) => {
|
||
latest = v
|
||
},
|
||
})
|
||
await nextTick()
|
||
const select = handle.host.querySelector('.type-select') as HTMLSelectElement
|
||
select.value = 'jumpToScene'
|
||
select.dispatchEvent(new Event('change', { bubbles: true }))
|
||
await nextTick()
|
||
expect(Array.isArray(latest)).toBe(true)
|
||
const arr = latest as unknown[]
|
||
expect(arr.length).toBe(1)
|
||
const next = arr[0] as { type: string; sceneId?: string }
|
||
expect(next.type).toBe('jumpToScene')
|
||
// Defaults to the first non-active scene so the action is useful out of the box.
|
||
expect(next.sceneId).toBe(other.id)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('scene dropdown enumerates every scene by name, emits sceneId on change', async () => {
|
||
const s = makeFreshStore()
|
||
const firstId = s.project.scenes[0].id
|
||
const second = s.addScene('Two')
|
||
const third = s.addScene('Three')
|
||
// Active is now Three. The seed action points at Two; changing
|
||
// to Three via the dropdown should emit { sceneId: third.id }.
|
||
let latest: unknown = null
|
||
const handle = mountComponent(ActionEditor, {
|
||
actions: [{ type: 'jumpToScene', sceneId: second.id } as const],
|
||
'onUpdate:actions': (v: unknown) => {
|
||
latest = v
|
||
},
|
||
})
|
||
await nextTick()
|
||
// Find the scene dropdown (second select on the row — the first
|
||
// is the action-type select).
|
||
const selects = handle.host.querySelectorAll('select')
|
||
expect(selects.length).toBe(2)
|
||
const sceneSelect = selects[1] as HTMLSelectElement
|
||
const sceneValues = Array.from(sceneSelect.options).map((o) => o.value)
|
||
expect(sceneValues).toEqual([firstId, second.id, third.id])
|
||
const sceneLabels = Array.from(sceneSelect.options).map((o) => o.textContent?.trim())
|
||
expect(sceneLabels).toEqual(['Scene 1', 'Two', 'Three'])
|
||
sceneSelect.value = third.id
|
||
sceneSelect.dispatchEvent(new Event('change', { bubbles: true }))
|
||
await nextTick()
|
||
const next = (latest as unknown[])[0] as { type: string; sceneId: string }
|
||
expect(next.type).toBe('jumpToScene')
|
||
expect(next.sceneId).toBe(third.id)
|
||
handle.unmount()
|
||
})
|
||
})
|
||
|
||
suite('polygon — vertex bezier actions', () => {
|
||
function makeSquare() {
|
||
const s = makeFreshStore()
|
||
const asset = s.addAsset({
|
||
kind: 'polygon',
|
||
name: 'Sq',
|
||
points: [
|
||
{ x: -40, y: -40 },
|
||
{ x: 40, y: -40 },
|
||
{ x: 40, y: 40 },
|
||
{ x: -40, y: 40 },
|
||
],
|
||
fill: '#abc',
|
||
}) as PolygonAsset
|
||
const layer = s.addLayer(asset.id)!
|
||
return { s, asset, layer }
|
||
}
|
||
|
||
test('setPolygonVertexType curve sets type + default tangents', () => {
|
||
const { s, asset } = makeSquare()
|
||
s.setPolygonVertexType(asset.id, 1, 'curve')
|
||
const p = asset.points[1]
|
||
expect(p.type).toBe('curve')
|
||
expect(typeof p.outX).toBe('number')
|
||
expect(typeof p.outY).toBe('number')
|
||
expect(typeof p.inX).toBe('number')
|
||
expect(typeof p.inY).toBe('number')
|
||
// Curve symmetry: in = -out
|
||
expect(Math.abs((p.inX ?? 0) + (p.outX ?? 0)) < 1e-6).toBe(true)
|
||
expect(Math.abs((p.inY ?? 0) + (p.outY ?? 0)) < 1e-6).toBe(true)
|
||
})
|
||
|
||
test('setPolygonVertexType corner clears tangent fields', () => {
|
||
const { s, asset } = makeSquare()
|
||
s.setPolygonVertexType(asset.id, 2, 'curve')
|
||
s.setPolygonVertexType(asset.id, 2, 'corner')
|
||
const p = asset.points[2]
|
||
expect(p.type).toBe(undefined)
|
||
expect(p.inX).toBe(undefined)
|
||
expect(p.inY).toBe(undefined)
|
||
expect(p.outX).toBe(undefined)
|
||
expect(p.outY).toBe(undefined)
|
||
})
|
||
|
||
test('setPolygonVertexType accepts a list of indices', () => {
|
||
const { s, asset } = makeSquare()
|
||
s.setPolygonVertexType(asset.id, [0, 2], 'splitcurve')
|
||
expect(asset.points[0].type).toBe('splitcurve')
|
||
expect(asset.points[1].type).toBe(undefined)
|
||
expect(asset.points[2].type).toBe('splitcurve')
|
||
expect(asset.points[3].type).toBe(undefined)
|
||
})
|
||
|
||
test('setPolygonVertexTangent writes per-keyframe (curve mirrors sides)', async () => {
|
||
const { s, asset, layer } = makeSquare()
|
||
s.setPolygonVertexType(asset.id, 1, 'curve')
|
||
s.setPlayhead(0.5)
|
||
s.setPolygonVertexTangent(asset.id, 1, 'out', 20, 10)
|
||
await tick()
|
||
const kf = layer.keyframes.find((k) => Math.abs(k.time - 0.5) < 1e-3)!
|
||
const pt = kf.props.points![1]
|
||
expect(pt.outX).toBe(20)
|
||
expect(pt.outY).toBe(10)
|
||
expect(pt.inX).toBe(-20)
|
||
expect(pt.inY).toBe(-10)
|
||
})
|
||
|
||
test('setPolygonVertexTangent splitcurve keeps sides independent', async () => {
|
||
const { s, asset, layer } = makeSquare()
|
||
s.setPolygonVertexType(asset.id, 1, 'splitcurve')
|
||
s.setPlayhead(0)
|
||
s.setPolygonVertexTangent(asset.id, 1, 'out', 10, 0)
|
||
s.setPolygonVertexTangent(asset.id, 1, 'in', -3, 4)
|
||
await tick()
|
||
const kf = layer.keyframes.find((k) => Math.abs(k.time) < 1e-3)!
|
||
const pt = kf.props.points![1]
|
||
expect(pt.outX).toBe(10)
|
||
expect(pt.outY).toBe(0)
|
||
expect(pt.inX).toBe(-3)
|
||
expect(pt.inY).toBe(4)
|
||
})
|
||
|
||
test('tangent interpolation lerps between keyframes', async () => {
|
||
const { s, asset, layer } = makeSquare()
|
||
s.setPolygonVertexType(asset.id, 1, 'splitcurve')
|
||
s.setPlayhead(0)
|
||
s.setPolygonVertexTangent(asset.id, 1, 'out', 10, 0)
|
||
s.setPlayhead(1)
|
||
s.setPolygonVertexTangent(asset.id, 1, 'out', 30, 0)
|
||
await tick()
|
||
const mid = sampleLayer(layer, 0.5)
|
||
expect(Math.abs((mid.points?.[1].outX ?? 0) - 20) < 1e-6).toBe(true)
|
||
})
|
||
|
||
test('setPolygonVertexPositions updates multiple vertices in a single kf', async () => {
|
||
const { s, asset, layer } = makeSquare()
|
||
s.setPlayhead(0.2)
|
||
s.setPolygonVertexPositions(layer.id, [
|
||
{ index: 0, x: 100, y: 100 },
|
||
{ index: 2, x: -100, y: -100 },
|
||
])
|
||
await tick()
|
||
const kf = layer.keyframes.find((k) => Math.abs(k.time - 0.2) < 1e-3)!
|
||
expect(kf.props.points![0].x).toBe(100)
|
||
expect(kf.props.points![0].y).toBe(100)
|
||
expect(kf.props.points![2].x).toBe(-100)
|
||
expect(kf.props.points![2].y).toBe(-100)
|
||
// Others unchanged
|
||
expect(kf.props.points![1].x).toBe(asset.points[1].x)
|
||
expect(kf.props.points![1].y).toBe(asset.points[1].y)
|
||
})
|
||
|
||
test('setPolygonVertexPositions ignores out-of-range indices', async () => {
|
||
const { s, layer } = makeSquare()
|
||
s.setPlayhead(0)
|
||
s.setPolygonVertexPositions(layer.id, [
|
||
{ index: 99, x: 0, y: 0 },
|
||
{ index: -1, x: 0, y: 0 },
|
||
])
|
||
await tick()
|
||
// No keyframe was created or changed at playhead 0 beyond the initial one
|
||
expect(layer.keyframes.length).toBe(1)
|
||
})
|
||
})
|
||
|
||
suite('properties — fill/stroke transparent toggle', () => {
|
||
function findAlphaToggles(host: HTMLElement) {
|
||
return Array.from(
|
||
host.querySelectorAll<HTMLButtonElement>('button.alpha-toggle-btn'),
|
||
)
|
||
}
|
||
|
||
function resetSectionPrefs() {
|
||
// Ensure the Appearance section is open (default) regardless of any
|
||
// previous session's state persisted in localStorage.
|
||
try {
|
||
localStorage.removeItem('animatr:propSections')
|
||
} catch {
|
||
/* noop */
|
||
}
|
||
}
|
||
|
||
test('fill: toggle transparent on writes "transparent" to keyframe', async () => {
|
||
resetSectionPrefs()
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({
|
||
kind: 'rect',
|
||
name: 'R',
|
||
width: 50,
|
||
height: 50,
|
||
fill: '#ff0000',
|
||
})
|
||
const l = s.addLayer(a.id)!
|
||
s.selectLayer(l.id)
|
||
const handle = mountComponent(PropertiesPanel, {})
|
||
await nextTick()
|
||
await tick()
|
||
const toggles = findAlphaToggles(handle.host)
|
||
const fillToggle = toggles[0]
|
||
expect(!!fillToggle).toBe(true)
|
||
fillToggle.click()
|
||
await tick()
|
||
const kf = l.keyframes[0]
|
||
expect(kf.props.fill).toBe('transparent')
|
||
handle.unmount()
|
||
})
|
||
|
||
test('fill: toggle off restores the previous non-transparent color', async () => {
|
||
resetSectionPrefs()
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({
|
||
kind: 'rect',
|
||
name: 'R',
|
||
width: 50,
|
||
height: 50,
|
||
fill: '#ff0000',
|
||
})
|
||
const l = s.addLayer(a.id)!
|
||
s.selectLayer(l.id)
|
||
const handle = mountComponent(PropertiesPanel, {})
|
||
await nextTick()
|
||
await tick()
|
||
const toggles = findAlphaToggles(handle.host)
|
||
const fillToggle = toggles[0]
|
||
// On
|
||
fillToggle.click()
|
||
await tick()
|
||
expect(l.keyframes[0].props.fill).toBe('transparent')
|
||
// Off
|
||
fillToggle.click()
|
||
await tick()
|
||
const restored = l.keyframes[0].props.fill
|
||
expect(typeof restored).toBe('string')
|
||
expect(restored !== 'transparent').toBe(true)
|
||
// Should match the original fill (layer.base.fill was copied from asset)
|
||
expect(restored).toBe('#ff0000')
|
||
handle.unmount()
|
||
})
|
||
|
||
test('stroke: toggle on then off restores to a visible color (auto-black fallback)', async () => {
|
||
resetSectionPrefs()
|
||
const s = makeFreshStore()
|
||
// Rect without an explicit stroke — the restore should fall back to black
|
||
const a = s.addAsset({
|
||
kind: 'rect',
|
||
name: 'R',
|
||
width: 50,
|
||
height: 50,
|
||
fill: '#123456',
|
||
})
|
||
const l = s.addLayer(a.id)!
|
||
s.selectLayer(l.id)
|
||
const handle = mountComponent(PropertiesPanel, {})
|
||
await nextTick()
|
||
await tick()
|
||
const toggles = findAlphaToggles(handle.host)
|
||
const strokeToggle = toggles[1]
|
||
expect(!!strokeToggle).toBe(true)
|
||
strokeToggle.click()
|
||
await tick()
|
||
expect(l.keyframes[0].props.stroke).toBe('transparent')
|
||
strokeToggle.click()
|
||
await tick()
|
||
const restored = l.keyframes[0].props.stroke
|
||
expect(typeof restored).toBe('string')
|
||
expect(restored !== 'transparent').toBe(true)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('fill: toggle off respects a user-picked color before transparency', async () => {
|
||
resetSectionPrefs()
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({
|
||
kind: 'rect',
|
||
name: 'R',
|
||
width: 50,
|
||
height: 50,
|
||
fill: '#6ea8ff',
|
||
})
|
||
const l = s.addLayer(a.id)!
|
||
s.selectLayer(l.id)
|
||
// User explicitly picked green before toggling transparent
|
||
s.upsertKeyframe(l.id, 0, { fill: '#00ff00' })
|
||
const handle = mountComponent(PropertiesPanel, {})
|
||
await nextTick()
|
||
await tick()
|
||
const toggles = findAlphaToggles(handle.host)
|
||
const fillToggle = toggles[0]
|
||
fillToggle.click()
|
||
await tick()
|
||
expect(l.keyframes[0].props.fill).toBe('transparent')
|
||
fillToggle.click()
|
||
await tick()
|
||
// Restore should be the user's green, not the asset default blue
|
||
expect(l.keyframes[0].props.fill).toBe('#00ff00')
|
||
handle.unmount()
|
||
})
|
||
})
|
||
|
||
suite('timeline — show locked tracks', () => {
|
||
test('toggling showLocked persists to localStorage', async () => {
|
||
const { showLocked } = await import('@/composables/useShowLocked')
|
||
const KEY = 'animatr:showLockedTracks'
|
||
showLocked.value = true
|
||
await tick()
|
||
expect(localStorage.getItem(KEY)).toBe('1')
|
||
showLocked.value = false
|
||
await tick()
|
||
expect(localStorage.getItem(KEY)).toBe('0')
|
||
showLocked.value = true
|
||
})
|
||
|
||
test('showLocked ref is shared across importers', async () => {
|
||
const a = await import('@/composables/useShowLocked')
|
||
const b = await import('@/composables/useShowLocked')
|
||
// Same module → same ref instance
|
||
expect(a.showLocked).toBe(b.showLocked)
|
||
a.showLocked.value = false
|
||
await tick()
|
||
expect(b.showLocked.value).toBe(false)
|
||
a.showLocked.value = true
|
||
})
|
||
})
|
||
|
||
suite('scene store — group lock', () => {
|
||
test('setTrackLocked on a group id sets group.locked', () => {
|
||
const s = makeFreshStore()
|
||
const g = s.addTrackGroup('G')
|
||
s.setTrackLocked(g.id, true)
|
||
const grp = s.scene.groups!.find((x) => x.id === g.id)!
|
||
expect(grp.locked).toBe(true)
|
||
})
|
||
|
||
test('setTrackLocked(false) clears group.locked', () => {
|
||
const s = makeFreshStore()
|
||
const g = s.addTrackGroup('G')
|
||
s.setTrackLocked(g.id, true)
|
||
s.setTrackLocked(g.id, false)
|
||
const grp = s.scene.groups!.find((x) => x.id === g.id)!
|
||
expect(grp.locked).toBe(undefined)
|
||
})
|
||
|
||
test('setTrackLocked dispatches to layer, clip or group based on id', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#111' })
|
||
const l = s.addLayer(a.id)!
|
||
const ct = s.addClipTrack()
|
||
const g = s.addTrackGroup('G')
|
||
s.setTrackLocked(l.id, true)
|
||
s.setTrackLocked(ct.id, true)
|
||
s.setTrackLocked(g.id, true)
|
||
expect(l.locked).toBe(true)
|
||
const stored = s.scene.clipTracks!.find((x) => x.id === ct.id)!
|
||
expect(stored.locked).toBe(true)
|
||
const grp = s.scene.groups!.find((x) => x.id === g.id)!
|
||
expect(grp.locked).toBe(true)
|
||
})
|
||
|
||
test('locking a group does not mutate child layer.locked', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#111' })
|
||
const l = s.addLayer(a.id)!
|
||
const g = s.addTrackGroup('G')
|
||
s.moveTrackIntoGroup(l.id, g.id)
|
||
s.setTrackLocked(g.id, true)
|
||
// The layer itself stays unlocked; effective lock is derived in UI code.
|
||
expect(l.locked).toBe(undefined)
|
||
})
|
||
|
||
test('locking a layer directly deselects it', () => {
|
||
// Regression: locking a layer while it was selected used to leave
|
||
// the props panel happily editing the now-inert track. The
|
||
// selection must drop on lock so edits can't leak through.
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
s.selectLayer(l.id)
|
||
expect(s.selectedLayerId).toBe(l.id)
|
||
s.setTrackLocked(l.id, true)
|
||
expect(s.selectedLayerId).toBe(null)
|
||
})
|
||
|
||
test('locking a group deselects a selected child layer', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
const g = s.addTrackGroup('G')
|
||
s.moveTrackIntoGroup(l.id, g.id)
|
||
s.selectLayer(l.id)
|
||
expect(s.selectedLayerId).toBe(l.id)
|
||
s.setTrackLocked(g.id, true)
|
||
expect(s.selectedLayerId).toBe(null)
|
||
})
|
||
|
||
test('locking a group with no selected child leaves selection alone', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'A', width: 10, height: 10, fill: '#f00' })
|
||
const b = s.addAsset({ kind: 'rect', name: 'B', width: 10, height: 10, fill: '#0f0' })
|
||
const la = s.addLayer(a.id)!
|
||
const lb = s.addLayer(b.id)!
|
||
const g = s.addTrackGroup('G')
|
||
// Only move la into the group; lb is selected and stays top-level.
|
||
s.moveTrackIntoGroup(la.id, g.id)
|
||
s.selectLayer(lb.id)
|
||
s.setTrackLocked(g.id, true)
|
||
// Locking the group can't touch the selection that lives outside it.
|
||
expect(s.selectedLayerId).toBe(lb.id)
|
||
})
|
||
|
||
test('unlocking a group does not auto-reselect the previously-selected layer', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
const g = s.addTrackGroup('G')
|
||
s.moveTrackIntoGroup(l.id, g.id)
|
||
s.selectLayer(l.id)
|
||
s.setTrackLocked(g.id, true)
|
||
// Now unlocking — selection stays null; user re-selects explicitly.
|
||
s.setTrackLocked(g.id, false)
|
||
expect(s.selectedLayerId).toBe(null)
|
||
})
|
||
})
|
||
|
||
suite('render — crop option', () => {
|
||
function setupStraddlingScene() {
|
||
const s = makeFreshStore()
|
||
s.scene.canvas.width = 100
|
||
s.scene.canvas.height = 100
|
||
s.scene.canvas.background = 'transparent'
|
||
const a = s.addAsset({
|
||
kind: 'rect',
|
||
name: 'Wide',
|
||
width: 60,
|
||
height: 20,
|
||
fill: '#00ff00',
|
||
})
|
||
const layer = s.addLayer(a.id)!
|
||
// Centered just off the right edge — 40px of the rect spills past x=100.
|
||
s.upsertKeyframe(layer.id, 0, { x: 110, y: 50 })
|
||
return s
|
||
}
|
||
|
||
test('default crop clips content outside stage bounds', () => {
|
||
const s = setupStraddlingScene()
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = 200
|
||
canvas.height = 100
|
||
const ctx = canvas.getContext('2d')!
|
||
drawSceneFrame(ctx, s.scene, 0, {})
|
||
// Sample a pixel ~20px past the right edge — still inside the rect's
|
||
// visual box but outside the stage. Should be cleared by the clip.
|
||
const outside = ctx.getImageData(120, 50, 1, 1).data
|
||
expect(outside[3]).toBe(0)
|
||
})
|
||
|
||
test('crop:false lets content render past the stage edge', () => {
|
||
const s = setupStraddlingScene()
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = 200
|
||
canvas.height = 100
|
||
const ctx = canvas.getContext('2d')!
|
||
drawSceneFrame(ctx, s.scene, 0, {}, { crop: false })
|
||
const outside = ctx.getImageData(120, 50, 1, 1).data
|
||
// Green channel visible in the part of the rect that extends past the stage
|
||
expect(outside[1] > 200).toBe(true)
|
||
expect(outside[3] > 200).toBe(true)
|
||
})
|
||
|
||
test('crop:false still paints stage background inside stage bounds', () => {
|
||
const s = makeFreshStore()
|
||
s.scene.canvas.width = 100
|
||
s.scene.canvas.height = 100
|
||
s.scene.canvas.background = '#ff0000'
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = 100
|
||
canvas.height = 100
|
||
const ctx = canvas.getContext('2d')!
|
||
drawSceneFrame(ctx, s.scene, 0, {}, { crop: false })
|
||
const inside = ctx.getImageData(50, 50, 1, 1).data
|
||
expect(inside[0] > 200).toBe(true)
|
||
expect(inside[3] > 200).toBe(true)
|
||
})
|
||
|
||
test('mask still applies when crop is disabled', () => {
|
||
// Same setup as the positive-mask render test but with crop:false —
|
||
// the mask should still clear content outside the mask shape, and the
|
||
// crop flag should only affect the stage clip, not the masked group.
|
||
const s = makeFreshStore()
|
||
s.scene.canvas.width = 100
|
||
s.scene.canvas.height = 100
|
||
s.scene.canvas.background = 'transparent'
|
||
const content = s.addAsset({
|
||
kind: 'rect',
|
||
name: 'BG',
|
||
width: 100,
|
||
height: 100,
|
||
fill: '#ff0000',
|
||
})
|
||
const contentLayer = s.addLayer(content.id)!
|
||
s.upsertKeyframe(contentLayer.id, 0, { x: 50, y: 50 })
|
||
const mask = s.addAsset({
|
||
kind: 'circle',
|
||
name: 'M',
|
||
radius: 20,
|
||
fill: '#ffffff',
|
||
})
|
||
const maskLayer = s.addLayer(mask.id)!
|
||
s.upsertKeyframe(maskLayer.id, 0, { x: 50, y: 50 })
|
||
s.setLayerMask(maskLayer.id, true, 'positive')
|
||
const group = s.addTrackGroup('G')
|
||
s.moveTrackIntoGroup(maskLayer.id, group.id)
|
||
s.moveTrackIntoGroup(contentLayer.id, group.id)
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = 100
|
||
canvas.height = 100
|
||
const ctx = canvas.getContext('2d')!
|
||
drawSceneFrame(ctx, s.scene, 0, {}, { crop: false })
|
||
const inside = ctx.getImageData(50, 50, 1, 1).data
|
||
const outside = ctx.getImageData(5, 5, 1, 1).data
|
||
// Positive mask keeps content inside
|
||
expect(inside[0] > 200).toBe(true)
|
||
expect(inside[3] > 200).toBe(true)
|
||
// Outside mask cleared by the mask (even without stage clipping)
|
||
expect(outside[3]).toBe(0)
|
||
})
|
||
|
||
test('content outside the stage renders past the edge when crop:false and mask-like groups exist elsewhere', () => {
|
||
// A straddling rect + a totally separate positive-mask group. The
|
||
// straddling rect must still bleed past the stage edge and the mask
|
||
// must still work inside its own group — they don't interfere.
|
||
const s = makeFreshStore()
|
||
s.scene.canvas.width = 100
|
||
s.scene.canvas.height = 100
|
||
s.scene.canvas.background = 'transparent'
|
||
// Straddling rect — spills past right edge
|
||
const strad = s.addAsset({
|
||
kind: 'rect',
|
||
name: 'Wide',
|
||
width: 60,
|
||
height: 20,
|
||
fill: '#00ff00',
|
||
})
|
||
const sLayer = s.addLayer(strad.id)!
|
||
s.upsertKeyframe(sLayer.id, 0, { x: 110, y: 50 })
|
||
// Masked group elsewhere
|
||
const content = s.addAsset({
|
||
kind: 'rect',
|
||
name: 'Blue',
|
||
width: 40,
|
||
height: 40,
|
||
fill: '#0000ff',
|
||
})
|
||
const cLayer = s.addLayer(content.id)!
|
||
s.upsertKeyframe(cLayer.id, 0, { x: 50, y: 50 })
|
||
const mask = s.addAsset({
|
||
kind: 'circle',
|
||
name: 'M',
|
||
radius: 10,
|
||
fill: '#fff',
|
||
})
|
||
const mLayer = s.addLayer(mask.id)!
|
||
s.upsertKeyframe(mLayer.id, 0, { x: 50, y: 50 })
|
||
s.setLayerMask(mLayer.id, true, 'positive')
|
||
const group = s.addTrackGroup('G')
|
||
s.moveTrackIntoGroup(mLayer.id, group.id)
|
||
s.moveTrackIntoGroup(cLayer.id, group.id)
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = 200
|
||
canvas.height = 100
|
||
const ctx = canvas.getContext('2d')!
|
||
drawSceneFrame(ctx, s.scene, 0, {}, { crop: false })
|
||
// Straddling rect's spill past x=100 is visible
|
||
const spill = ctx.getImageData(120, 50, 1, 1).data
|
||
expect(spill[1] > 200).toBe(true)
|
||
expect(spill[3] > 200).toBe(true)
|
||
// Mask still trims the blue rect outside its circle.
|
||
// (32, 50) is inside the blue rect (30–70) but outside the 10-radius
|
||
// mask circle (distance ~18), and well away from the green rect
|
||
// (80–140), so it must be cleared by the mask.
|
||
const maskOutside = ctx.getImageData(32, 50, 1, 1).data
|
||
expect(maskOutside[3]).toBe(0)
|
||
})
|
||
})
|
||
|
||
suite('scene store — playback range (in/out)', () => {
|
||
test('defaults: playbackIn=0, playbackOut=duration', () => {
|
||
const s = makeFreshStore()
|
||
expect(s.playbackIn).toBe(0)
|
||
expect(s.playbackOut).toBe(s.scene.duration)
|
||
expect(s.scene.inTime).toBe(undefined)
|
||
expect(s.scene.outTime).toBe(undefined)
|
||
})
|
||
|
||
test('setPlaybackRange clamps values into [0, duration]', () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
s.setPlaybackRange(-5, 20)
|
||
expect(s.playbackIn).toBe(0)
|
||
expect(s.playbackOut).toBe(10)
|
||
})
|
||
|
||
test('setPlaybackRange keeps out >= in', () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
s.setPlaybackRange(6, null)
|
||
s.setPlaybackRange(null, 3)
|
||
// out is clamped up to in (6), not down to 3
|
||
expect(s.playbackOut).toBe(6)
|
||
})
|
||
|
||
test('setPlaybackRange clamps playhead into new range', () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
s.setPlayhead(1)
|
||
s.setPlaybackRange(4, 8)
|
||
expect(s.playhead).toBe(4)
|
||
s.setPlayhead(9)
|
||
// tick isn't involved; setPlayhead itself doesn't clamp to range, so
|
||
// push it with setPlaybackRange again:
|
||
s.setPlaybackRange(4, 7)
|
||
expect(s.playhead).toBe(7)
|
||
})
|
||
|
||
test('setPlaybackRange null for both leaves scene alone', () => {
|
||
const s = makeFreshStore()
|
||
s.setPlaybackRange(2, 5)
|
||
s.setPlaybackRange(null, null)
|
||
expect(s.playbackIn).toBe(2)
|
||
expect(s.playbackOut).toBe(5)
|
||
})
|
||
|
||
test('togglePlay snaps playhead to playbackIn when outside range', async () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
s.setPlaybackRange(3, 7)
|
||
s.setPlayhead(1) // before in
|
||
await tick()
|
||
s.togglePlay()
|
||
expect(s.playhead).toBe(3)
|
||
expect(s.playing).toBe(true)
|
||
s.pause()
|
||
await tick()
|
||
})
|
||
|
||
test('tick stops exactly at playbackOut within range', async () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
s.setPlaybackRange(2, 5)
|
||
s.setPlayhead(2)
|
||
s.play()
|
||
await tick()
|
||
s.tick(4) // would put playhead at 6, past out=5
|
||
await tick()
|
||
expect(s.playhead).toBe(5)
|
||
expect(s.playing).toBe(false)
|
||
})
|
||
|
||
test('setPlaybackRange(0, null) deletes inTime when outTime is unset', () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
s.setPlaybackRange(3, null)
|
||
expect(s.scene.inTime).toBe(3)
|
||
s.setPlaybackRange(0, null)
|
||
expect(s.scene.inTime).toBe(undefined)
|
||
})
|
||
|
||
test('setPlaybackRange(null, duration) deletes outTime when inTime is unset', () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
s.setPlaybackRange(null, 7)
|
||
expect(s.scene.outTime).toBe(7)
|
||
s.setPlaybackRange(null, 10)
|
||
expect(s.scene.outTime).toBe(undefined)
|
||
})
|
||
})
|
||
|
||
suite('scene store — blank scene & normalization', () => {
|
||
test('createBlankScene has 1280x720 white bg and 10s duration', () => {
|
||
const s = createBlankScene()
|
||
expect(s.version).toBe(1)
|
||
expect(s.canvas.width).toBe(1280)
|
||
expect(s.canvas.height).toBe(720)
|
||
expect(s.canvas.background).toBe('#ffffff')
|
||
expect(s.duration).toBe(10)
|
||
expect(s.fps).toBe(60)
|
||
expect(s.assets).toHaveLength(0)
|
||
expect(s.layers).toHaveLength(0)
|
||
})
|
||
|
||
test('loadScene(createBlankScene()) resets selection, playhead, playing', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#f00' })
|
||
s.addLayer(a.id)!
|
||
s.setPlayhead(3)
|
||
s.play()
|
||
await tick()
|
||
s.loadScene(createBlankScene())
|
||
expect(s.scene.layers).toHaveLength(0)
|
||
expect(s.scene.assets).toHaveLength(0)
|
||
expect(s.scene.canvas.background).toBe('#ffffff')
|
||
expect(s.playhead).toBe(0)
|
||
expect(s.playing).toBe(false)
|
||
expect(s.selectedLayerId).toBe(null)
|
||
})
|
||
|
||
test('loadScene fills in canvas.background when missing', () => {
|
||
const s = makeFreshStore()
|
||
const broken = {
|
||
version: 1 as const,
|
||
canvas: { width: 640, height: 480, background: '' },
|
||
duration: 5,
|
||
fps: 30,
|
||
assets: [],
|
||
layers: [],
|
||
}
|
||
s.loadScene(broken)
|
||
expect(s.scene.canvas.background).toBe('#ffffff')
|
||
})
|
||
})
|
||
|
||
suite('scene store — mask defaults', () => {
|
||
test('setLayerMask(true) defaults to negative mask mode', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'M', width: 40, height: 40, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
s.setLayerMask(l.id, true)
|
||
const layer = s.scene.layers.find((x) => x.id === l.id)!
|
||
expect(layer.isMask).toBe(true)
|
||
expect(layer.maskMode).toBe('negative')
|
||
})
|
||
})
|
||
|
||
suite('render — top-level masks cascade', () => {
|
||
test('top-level positive mask clears content outside the mask shape', () => {
|
||
const s = makeFreshStore()
|
||
s.scene.canvas.width = 100
|
||
s.scene.canvas.height = 100
|
||
s.scene.canvas.background = 'transparent'
|
||
// Full-stage red rect
|
||
const content = s.addAsset({
|
||
kind: 'rect',
|
||
name: 'BG',
|
||
width: 100,
|
||
height: 100,
|
||
fill: '#ff0000',
|
||
})
|
||
const contentLayer = s.addLayer(content.id)!
|
||
s.upsertKeyframe(contentLayer.id, 0, { x: 50, y: 50 })
|
||
// Mask layer above it in the stack (top-level, NO group)
|
||
const mask = s.addAsset({
|
||
kind: 'circle',
|
||
name: 'M',
|
||
radius: 20,
|
||
fill: '#ffffff',
|
||
})
|
||
const maskLayer = s.addLayer(mask.id)!
|
||
s.upsertKeyframe(maskLayer.id, 0, { x: 50, y: 50 })
|
||
s.setLayerMask(maskLayer.id, true, 'positive')
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = 100
|
||
canvas.height = 100
|
||
const ctx = canvas.getContext('2d')!
|
||
drawSceneFrame(ctx, s.scene, 0, {})
|
||
// Inside mask circle → red kept
|
||
const inside = ctx.getImageData(50, 50, 1, 1).data
|
||
expect(inside[0] > 200).toBe(true)
|
||
expect(inside[3] > 200).toBe(true)
|
||
// Outside mask circle → cleared
|
||
const outside = ctx.getImageData(5, 5, 1, 1).data
|
||
expect(outside[3]).toBe(0)
|
||
})
|
||
|
||
test('top-level negative mask cuts a hole in the content below', () => {
|
||
const s = makeFreshStore()
|
||
s.scene.canvas.width = 100
|
||
s.scene.canvas.height = 100
|
||
s.scene.canvas.background = 'transparent'
|
||
const content = s.addAsset({
|
||
kind: 'rect',
|
||
name: 'BG',
|
||
width: 100,
|
||
height: 100,
|
||
fill: '#ff0000',
|
||
})
|
||
const contentLayer = s.addLayer(content.id)!
|
||
s.upsertKeyframe(contentLayer.id, 0, { x: 50, y: 50 })
|
||
const mask = s.addAsset({
|
||
kind: 'circle',
|
||
name: 'M',
|
||
radius: 15,
|
||
fill: '#ffffff',
|
||
})
|
||
const maskLayer = s.addLayer(mask.id)!
|
||
s.upsertKeyframe(maskLayer.id, 0, { x: 50, y: 50 })
|
||
s.setLayerMask(maskLayer.id, true, 'negative')
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = 100
|
||
canvas.height = 100
|
||
const ctx = canvas.getContext('2d')!
|
||
drawSceneFrame(ctx, s.scene, 0, {})
|
||
// Inside the circle → hole (cleared)
|
||
const inside = ctx.getImageData(50, 50, 1, 1).data
|
||
expect(inside[3]).toBe(0)
|
||
// Outside circle → red kept
|
||
const outside = ctx.getImageData(5, 5, 1, 1).data
|
||
expect(outside[0] > 200).toBe(true)
|
||
expect(outside[3] > 200).toBe(true)
|
||
})
|
||
})
|
||
|
||
suite('scene store — markers', () => {
|
||
test('addMarker defaults the name when none provided', () => {
|
||
const s = makeFreshStore()
|
||
const m = s.addMarker(2)
|
||
expect(m.name).toBe('Marker')
|
||
expect(m.time).toBe(2)
|
||
expect(s.scene.markers).toHaveLength(1)
|
||
})
|
||
|
||
test('addMarker de-dupes colliding names with suffix', () => {
|
||
const s = makeFreshStore()
|
||
s.addMarker(1, 'Intro')
|
||
s.addMarker(2, 'Intro')
|
||
s.addMarker(3, 'Intro')
|
||
const names = (s.scene.markers ?? []).map((m) => m.name)
|
||
expect(names).toEqual(['Intro', 'Intro 2', 'Intro 3'])
|
||
})
|
||
|
||
test('addMarker clamps time to [0, duration]', () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
const a = s.addMarker(-5)
|
||
const b = s.addMarker(99)
|
||
expect(a.time).toBe(0)
|
||
expect(b.time).toBe(10)
|
||
})
|
||
|
||
test('addMarker keeps the list sorted by time', () => {
|
||
const s = makeFreshStore()
|
||
s.addMarker(5, 'C')
|
||
s.addMarker(1, 'A')
|
||
s.addMarker(3, 'B')
|
||
const times = (s.scene.markers ?? []).map((m) => m.time)
|
||
expect(times).toEqual([1, 3, 5])
|
||
})
|
||
|
||
test('renameMarker succeeds on unique, rejects duplicates', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addMarker(1, 'A')
|
||
s.addMarker(2, 'B')
|
||
expect(s.renameMarker(a.id, 'A2')).toBe(true)
|
||
expect(s.renameMarker(a.id, 'B')).toBe(false)
|
||
const byId = (s.scene.markers ?? []).find((m) => m.id === a.id)
|
||
expect(byId?.name).toBe('A2')
|
||
})
|
||
|
||
test('removeMarker drops the entry', () => {
|
||
const s = makeFreshStore()
|
||
const m = s.addMarker(1)
|
||
s.removeMarker(m.id)
|
||
expect(s.scene.markers).toHaveLength(0)
|
||
})
|
||
|
||
test('setMarkerTime clamps and re-sorts', () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
const a = s.addMarker(1, 'A')
|
||
const b = s.addMarker(2, 'B')
|
||
s.setMarkerTime(a.id, 99)
|
||
const sortedNames = (s.scene.markers ?? []).map((m) => m.name)
|
||
expect(sortedNames).toEqual(['B', 'A'])
|
||
const updated = (s.scene.markers ?? []).find((m) => m.id === a.id)!
|
||
expect(updated.time).toBe(10)
|
||
void b
|
||
})
|
||
|
||
test('setMarkerColor sets and clears', () => {
|
||
const s = makeFreshStore()
|
||
const m = s.addMarker(1, 'A')
|
||
s.setMarkerColor(m.id, '#ff8800')
|
||
const get = () => s.scene.markers?.find((x) => x.id === m.id)
|
||
expect(get()?.color).toBe('#ff8800')
|
||
s.setMarkerColor(m.id, null)
|
||
expect(get()?.color).toBe(undefined)
|
||
})
|
||
})
|
||
|
||
suite('scene store — script tracks & frames', () => {
|
||
test('addScriptTrack appends and adds to trackOrder', () => {
|
||
const s = makeFreshStore()
|
||
const t = s.addScriptTrack('Triggers')
|
||
expect(s.scene.scriptTracks).toHaveLength(1)
|
||
expect(s.scene.trackOrder?.includes(t.id)).toBe(true)
|
||
})
|
||
|
||
test('addScriptFrame clamps time and returns frame', () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
const t = s.addScriptTrack()
|
||
const f = s.addScriptFrame(t.id, 99, [{ type: 'pause' }])
|
||
expect(f).toBeTruthy()
|
||
expect(f!.time).toBe(10)
|
||
expect(f!.actions).toHaveLength(1)
|
||
})
|
||
|
||
test('addScriptFrame returns null for unknown trackId', () => {
|
||
const s = makeFreshStore()
|
||
const f = s.addScriptFrame('no-such-id', 1)
|
||
expect(f).toBe(null)
|
||
})
|
||
|
||
test('script frames stay sorted by time', () => {
|
||
const s = makeFreshStore()
|
||
const t = s.addScriptTrack()
|
||
s.addScriptFrame(t.id, 5)
|
||
s.addScriptFrame(t.id, 1)
|
||
s.addScriptFrame(t.id, 3)
|
||
const times = (s.scene.scriptFrames ?? []).map((f) => f.time)
|
||
expect(times).toEqual([1, 3, 5])
|
||
})
|
||
|
||
test('removeScriptTrack cascades its frames and trackOrder entry', () => {
|
||
const s = makeFreshStore()
|
||
const t = s.addScriptTrack()
|
||
s.addScriptFrame(t.id, 1)
|
||
s.addScriptFrame(t.id, 2)
|
||
s.removeScriptTrack(t.id)
|
||
expect(s.scene.scriptTracks).toHaveLength(0)
|
||
expect(s.scene.scriptFrames).toHaveLength(0)
|
||
expect(s.scene.trackOrder?.includes(t.id)).toBe(false)
|
||
})
|
||
|
||
test('setScriptFrameActions replaces actions', () => {
|
||
const s = makeFreshStore()
|
||
const t = s.addScriptTrack()
|
||
const f = s.addScriptFrame(t.id, 1, [{ type: 'play' }])!
|
||
s.setScriptFrameActions(f.id, [{ type: 'pause' }, { type: 'rewindToLoop' }])
|
||
const updated = s.scene.scriptFrames?.find((x) => x.id === f.id)
|
||
expect(updated?.actions).toHaveLength(2)
|
||
expect(updated?.actions[0].type).toBe('pause')
|
||
})
|
||
|
||
test('setScriptFrameTime clamps and re-sorts', () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
const t = s.addScriptTrack()
|
||
const a = s.addScriptFrame(t.id, 1)!
|
||
const b = s.addScriptFrame(t.id, 2)!
|
||
s.setScriptFrameTime(a.id, 99)
|
||
const times = (s.scene.scriptFrames ?? []).map((f) => f.time)
|
||
expect(times).toEqual([2, 10])
|
||
void b
|
||
})
|
||
})
|
||
|
||
suite('actions — evalAction with mock runtime', () => {
|
||
function makeMockRuntime(init?: Partial<ActionRuntime>): ActionRuntime & {
|
||
calls: string[]
|
||
playhead: number
|
||
playing: boolean
|
||
warnings: string[]
|
||
} {
|
||
const rt = {
|
||
fps: 60,
|
||
playbackIn: 0,
|
||
playbackOut: 10,
|
||
duration: 10,
|
||
markers: { intro: 2, outro: 8 },
|
||
playhead: 0,
|
||
playing: false,
|
||
warnings: [] as string[],
|
||
calls: [] as string[],
|
||
setPlayhead(t: number) {
|
||
this.calls.push(`setPlayhead:${t}`)
|
||
this.playhead = t
|
||
},
|
||
play() {
|
||
this.calls.push('play')
|
||
this.playing = true
|
||
},
|
||
pause() {
|
||
this.calls.push('pause')
|
||
this.playing = false
|
||
},
|
||
warn(msg: string) {
|
||
this.warnings.push(msg)
|
||
},
|
||
...init,
|
||
}
|
||
return rt as ReturnType<typeof makeMockRuntime>
|
||
}
|
||
|
||
test('play / pause / rewindToLoop', () => {
|
||
const rt = makeMockRuntime({ playbackIn: 2 })
|
||
evalAction({ type: 'play' }, rt)
|
||
evalAction({ type: 'pause' }, rt)
|
||
evalAction({ type: 'rewindToLoop' }, rt)
|
||
expect(rt.playing).toBe(false)
|
||
expect(rt.playhead).toBe(2)
|
||
expect(rt.calls).toEqual(['play', 'pause', 'setPlayhead:2'])
|
||
})
|
||
|
||
test('jumpTo clamps to [0, duration]', () => {
|
||
const rt = makeMockRuntime()
|
||
evalAction({ type: 'jumpTo', time: -3 }, rt)
|
||
expect(rt.playhead).toBe(0)
|
||
evalAction({ type: 'jumpTo', time: 99 }, rt)
|
||
expect(rt.playhead).toBe(10)
|
||
evalAction({ type: 'jumpTo', time: 5 }, rt)
|
||
expect(rt.playhead).toBe(5)
|
||
})
|
||
|
||
test('jumpToFrame divides by fps', () => {
|
||
const rt = makeMockRuntime({ fps: 30 })
|
||
evalAction({ type: 'jumpToFrame', frame: 90 }, rt)
|
||
expect(rt.playhead).toBe(3)
|
||
})
|
||
|
||
test('jumpToMarker resolves via markers map', () => {
|
||
const rt = makeMockRuntime()
|
||
evalAction({ type: 'jumpToMarker', name: 'outro' }, rt)
|
||
expect(rt.playhead).toBe(8)
|
||
})
|
||
|
||
test('jumpToMarker warns on unknown name and does not move playhead', () => {
|
||
const rt = makeMockRuntime()
|
||
const before = rt.playhead
|
||
evalAction({ type: 'jumpToMarker', name: 'missing' }, rt)
|
||
expect(rt.playhead).toBe(before)
|
||
expect(rt.warnings.length).toBe(1)
|
||
expect(rt.warnings[0]).toContain('missing')
|
||
})
|
||
|
||
test('jumpToScene dispatches switchScene on the runtime', () => {
|
||
let switched: string | null = null
|
||
const rt = makeMockRuntime({
|
||
sceneIds: new Set(['s-a', 's-b']),
|
||
switchScene(id: string) {
|
||
switched = id
|
||
},
|
||
})
|
||
evalAction({ type: 'jumpToScene', sceneId: 's-b' }, rt)
|
||
expect(switched).toBe('s-b')
|
||
expect(rt.warnings.length).toBe(0)
|
||
})
|
||
|
||
test('jumpToScene warns on unknown sceneId and does not switch', () => {
|
||
let switched: string | null = null
|
||
const rt = makeMockRuntime({
|
||
sceneIds: new Set(['s-a']),
|
||
switchScene(id: string) {
|
||
switched = id
|
||
},
|
||
})
|
||
evalAction({ type: 'jumpToScene', sceneId: 's-ghost' }, rt)
|
||
expect(switched).toBe(null)
|
||
expect(rt.warnings.length).toBe(1)
|
||
expect(rt.warnings[0]).toContain('s-ghost')
|
||
})
|
||
|
||
test('jumpToScene warns if runtime has no switchScene handler (e.g. exported bundle)', () => {
|
||
const rt = makeMockRuntime() // no switchScene, no sceneIds
|
||
evalAction({ type: 'jumpToScene', sceneId: 's-x' }, rt)
|
||
expect(rt.warnings.length).toBe(1)
|
||
expect(rt.warnings[0].toLowerCase()).toContain('does not support')
|
||
})
|
||
|
||
test('script action parses and dispatches sub-actions (no warn on valid DSL)', () => {
|
||
const rt = makeMockRuntime()
|
||
evalAction({ type: 'script', source: 'play\npause' }, rt)
|
||
expect(rt.warnings.length).toBe(0)
|
||
expect(rt.calls).toEqual(['play', 'pause'])
|
||
})
|
||
|
||
test('script action warns per invalid DSL line', () => {
|
||
const rt = makeMockRuntime()
|
||
evalAction({ type: 'script', source: 'play\nwat\npause' }, rt)
|
||
expect(rt.warnings.length).toBe(1)
|
||
expect(rt.warnings[0]).toContain('wat')
|
||
// Valid lines still fire.
|
||
expect(rt.calls).toEqual(['play', 'pause'])
|
||
})
|
||
|
||
test('buildMarkerMap maps name → time', () => {
|
||
const map = buildMarkerMap([
|
||
{ id: 'a', name: 'intro', time: 1 },
|
||
{ id: 'b', name: 'outro', time: 5 },
|
||
])
|
||
expect(map.intro).toBe(1)
|
||
expect(map.outro).toBe(5)
|
||
})
|
||
|
||
test('scriptFramesInRange returns only forward frames, sorted', () => {
|
||
const frames = [
|
||
{ id: 'a', trackId: 't', time: 3, actions: [] as Action[] },
|
||
{ id: 'b', trackId: 't', time: 1, actions: [] as Action[] },
|
||
{ id: 'c', trackId: 't', time: 6, actions: [] as Action[] },
|
||
]
|
||
// (0, 4] → only 1 and 3, sorted
|
||
const got = scriptFramesInRange(frames, 0, 4).map((f) => f.id)
|
||
expect(got).toEqual(['b', 'a'])
|
||
// backwards → empty
|
||
expect(scriptFramesInRange(frames, 5, 1)).toHaveLength(0)
|
||
})
|
||
|
||
test('scriptFramesInRange uses open-closed interval', () => {
|
||
const frames = [{ id: 'a', trackId: 't', time: 2, actions: [] as Action[] }]
|
||
// prev=2 (exclusive), next=2 → empty
|
||
expect(scriptFramesInRange(frames, 2, 2)).toHaveLength(0)
|
||
// prev=1, next=2 → includes 2
|
||
expect(scriptFramesInRange(frames, 1, 2)).toHaveLength(1)
|
||
})
|
||
})
|
||
|
||
suite('scene store — tick fires script frames forward-only', () => {
|
||
test('tick fires frames crossed during forward advance in time order', async () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
const t = s.addScriptTrack()
|
||
s.addScriptFrame(t.id, 2, [{ type: 'pause' }])
|
||
s.addScriptFrame(t.id, 1, [{ type: 'jumpTo', time: 9 }])
|
||
s.play()
|
||
// From 0 → 2: both frames get crossed; time-1 frame fires first and
|
||
// jumps the playhead to 9, time-2 frame fires second and pauses.
|
||
s.tick(2)
|
||
expect(s.playhead).toBe(9)
|
||
expect(s.playing).toBe(false)
|
||
await tick()
|
||
})
|
||
|
||
test('setPlayhead alone does not fire frames', async () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
const t = s.addScriptTrack()
|
||
s.addScriptFrame(t.id, 2, [{ type: 'pause' }])
|
||
s.play()
|
||
s.setPlayhead(5) // scrub over the frame without ticking
|
||
expect(s.playing).toBe(true)
|
||
s.pause()
|
||
await tick()
|
||
})
|
||
|
||
test('frames fire even after natural stop at playbackOut', async () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
s.setPlaybackRange(0, 5)
|
||
const t = s.addScriptTrack()
|
||
s.addScriptFrame(t.id, 5, [{ type: 'rewindToLoop' }])
|
||
s.play()
|
||
s.tick(6) // lands at playbackOut=5, playing=false, frame at 5 fires
|
||
expect(s.playing).toBe(false)
|
||
expect(s.playhead).toBe(0) // rewindToLoop → playbackIn=0
|
||
await tick()
|
||
})
|
||
|
||
test('a scripted jumpToScene switches the active scene and keeps playing', async () => {
|
||
// User-facing contract: a script track fires a scene-switch in the
|
||
// middle of a running timeline — the active scene flips and the
|
||
// timeline keeps running on the new page. All pages share
|
||
// duration/fps, so the playhead stays put and continues onward.
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
const aId = s.project.activeSceneId
|
||
const bId = s.addScene('B').id
|
||
s.switchScene(aId)
|
||
const track = s.addScriptTrack()
|
||
s.addScriptFrame(track.id, 3, [{ type: 'jumpToScene', sceneId: bId }])
|
||
s.play()
|
||
s.tick(3)
|
||
expect(s.project.activeSceneId).toBe(bId)
|
||
expect(s.playing).toBe(true)
|
||
await tick()
|
||
})
|
||
|
||
test('scripted jumpToScene with an unknown id warns and leaves state alone', async () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
const aId = s.project.activeSceneId
|
||
const track = s.addScriptTrack()
|
||
s.addScriptFrame(track.id, 2, [{ type: 'jumpToScene', sceneId: 'bogus-id' }])
|
||
s.play()
|
||
const warnings: string[] = []
|
||
const origWarn = console.warn
|
||
console.warn = (...args: unknown[]) => {
|
||
warnings.push(args.map(String).join(' '))
|
||
}
|
||
try {
|
||
s.tick(2)
|
||
} finally {
|
||
console.warn = origWarn
|
||
}
|
||
expect(s.project.activeSceneId).toBe(aId)
|
||
expect(s.playing).toBe(true)
|
||
expect(warnings.some((w) => w.includes('bogus-id'))).toBe(true)
|
||
await tick()
|
||
})
|
||
})
|
||
|
||
suite('scriptDsl — parser', () => {
|
||
test('parses bare commands', () => {
|
||
const { actions, errors } = parseScript('play\npause\nrewind')
|
||
expect(errors).toHaveLength(0)
|
||
expect(actions.map((a) => a.type)).toEqual(['play', 'pause', 'rewindToLoop'])
|
||
})
|
||
|
||
test('skips blank lines and comments', () => {
|
||
const src = [
|
||
'// leading comment',
|
||
'',
|
||
'play // trailing comment',
|
||
' ',
|
||
'pause',
|
||
].join('\n')
|
||
const { actions, errors } = parseScript(src)
|
||
expect(errors).toHaveLength(0)
|
||
expect(actions.map((a) => a.type)).toEqual(['play', 'pause'])
|
||
})
|
||
|
||
test('jump accepts seconds, #frame, and @marker', () => {
|
||
const { actions, errors } = parseScript('jump 2.5\njump #60\njump @intro')
|
||
expect(errors).toHaveLength(0)
|
||
expect(actions[0]).toEqual({ type: 'jumpTo', time: 2.5 })
|
||
expect(actions[1]).toEqual({ type: 'jumpToFrame', frame: 60 })
|
||
expect(actions[2]).toEqual({ type: 'jumpToMarker', name: 'intro' })
|
||
})
|
||
|
||
test('jump reports invalid arguments', () => {
|
||
const { actions, errors } = parseScript('jump\njump abc\njump #xx\njump @')
|
||
expect(actions).toHaveLength(0)
|
||
expect(errors).toHaveLength(4)
|
||
})
|
||
|
||
test('unknown commands report error, other lines still parse', () => {
|
||
const { actions, errors } = parseScript('play\nwat\npause')
|
||
expect(errors).toHaveLength(1)
|
||
expect(errors[0].line).toBe(2)
|
||
expect(actions.map((a) => a.type)).toEqual(['play', 'pause'])
|
||
})
|
||
})
|
||
|
||
suite('actions — script type expands via evalAction', () => {
|
||
test('script source is parsed and each action dispatched in order', () => {
|
||
const calls: string[] = []
|
||
const rt: ActionRuntime = {
|
||
fps: 60,
|
||
playbackIn: 0,
|
||
playbackOut: 10,
|
||
duration: 10,
|
||
markers: { intro: 2 },
|
||
setPlayhead: (t) => calls.push(`seek:${t}`),
|
||
play: () => calls.push('play'),
|
||
pause: () => calls.push('pause'),
|
||
warn: () => {},
|
||
}
|
||
evalAction(
|
||
{ type: 'script', source: 'pause\njump @intro\nplay' },
|
||
rt,
|
||
)
|
||
expect(calls).toEqual(['pause', 'seek:2', 'play'])
|
||
})
|
||
|
||
test('script errors surface via rt.warn, valid actions still fire', () => {
|
||
const warnings: string[] = []
|
||
const fired: string[] = []
|
||
const rt: ActionRuntime = {
|
||
fps: 60,
|
||
playbackIn: 0,
|
||
playbackOut: 10,
|
||
duration: 10,
|
||
markers: {},
|
||
setPlayhead: () => {},
|
||
play: () => fired.push('play'),
|
||
pause: () => fired.push('pause'),
|
||
warn: (m) => warnings.push(m),
|
||
}
|
||
evalAction({ type: 'script', source: 'play\nwat\npause' }, rt)
|
||
expect(fired).toEqual(['play', 'pause'])
|
||
expect(warnings).toHaveLength(1)
|
||
expect(warnings[0]).toContain('wat')
|
||
})
|
||
})
|
||
|
||
suite('scene store — macro model normalization', () => {
|
||
test('loadScene initializes new arrays when missing', () => {
|
||
const s = makeFreshStore()
|
||
const bare = {
|
||
version: 1 as const,
|
||
canvas: { width: 640, height: 480, background: '#ffffff' },
|
||
duration: 5,
|
||
fps: 30,
|
||
assets: [],
|
||
layers: [],
|
||
}
|
||
s.loadScene(bare)
|
||
expect(s.scene.scriptTracks).toEqual([])
|
||
expect(s.scene.scriptFrames).toEqual([])
|
||
expect(s.scene.markers).toEqual([])
|
||
})
|
||
|
||
test('setTrackLocked also works on script tracks', () => {
|
||
const s = makeFreshStore()
|
||
const t = s.addScriptTrack()
|
||
s.setTrackLocked(t.id, true)
|
||
expect(s.scene.scriptTracks?.find((x) => x.id === t.id)?.locked).toBe(true)
|
||
s.setTrackLocked(t.id, false)
|
||
expect(s.scene.scriptTracks?.find((x) => x.id === t.id)?.locked).toBe(undefined)
|
||
})
|
||
})
|
||
|
||
suite('macro-clips — store coverage gaps', () => {
|
||
test('removeMarker on unknown id is a no-op', () => {
|
||
const s = makeFreshStore()
|
||
s.addMarker(1, 'A')
|
||
s.removeMarker('no-such-id')
|
||
expect(s.scene.markers).toHaveLength(1)
|
||
})
|
||
|
||
test('setMarkerTime / setMarkerColor on unknown id are no-ops', () => {
|
||
const s = makeFreshStore()
|
||
s.setMarkerTime('ghost', 5)
|
||
s.setMarkerColor('ghost', '#ff0000')
|
||
expect(s.scene.markers).toHaveLength(0)
|
||
})
|
||
|
||
test('addMarker accepts an initial color', () => {
|
||
const s = makeFreshStore()
|
||
const m = s.addMarker(1, 'A', '#ff8800')
|
||
expect(m.color).toBe('#ff8800')
|
||
})
|
||
|
||
test('removeScriptFrame / setScriptFrameActions / setScriptFrameTime handle unknown ids', () => {
|
||
const s = makeFreshStore()
|
||
// None of these should throw or mutate anything.
|
||
s.removeScriptFrame('nope')
|
||
s.setScriptFrameActions('nope', [{ type: 'play' }])
|
||
s.setScriptFrameTime('nope', 1)
|
||
expect(s.scene.scriptFrames).toEqual([])
|
||
})
|
||
|
||
})
|
||
|
||
suite('macro-clips — DSL edge cases', () => {
|
||
test('empty source yields no actions and no errors', () => {
|
||
const { actions, errors } = parseScript('')
|
||
expect(actions).toHaveLength(0)
|
||
expect(errors).toHaveLength(0)
|
||
})
|
||
|
||
test('only comments or whitespace yields no actions', () => {
|
||
const { actions, errors } = parseScript(' \n\n// note\n // more\n')
|
||
expect(actions).toHaveLength(0)
|
||
expect(errors).toHaveLength(0)
|
||
})
|
||
|
||
test('trailing blank lines do not produce phantom errors', () => {
|
||
const { actions, errors } = parseScript('play\n\n\n')
|
||
expect(actions).toHaveLength(1)
|
||
expect(errors).toHaveLength(0)
|
||
})
|
||
|
||
test('jump with empty @marker reports error', () => {
|
||
const { errors } = parseScript('jump @')
|
||
expect(errors).toHaveLength(1)
|
||
expect(errors[0].message).toContain('marker')
|
||
})
|
||
})
|
||
|
||
suite('macro-clips — tick firing robustness', () => {
|
||
test('multiple frames on different tracks fire in time order', async () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
const t1 = s.addScriptTrack('A')
|
||
const t2 = s.addScriptTrack('B')
|
||
const log: number[] = []
|
||
// Three frames: 0.2 (t2), 0.5 (t1), 0.8 (t2)
|
||
// We can verify ordering via jumpTo actions that push into a list…
|
||
// but evalAction dispatches to store.setPlayhead etc. We instead
|
||
// observe the jumpTo time the playhead ends up at after all fire.
|
||
s.addScriptFrame(t2.id, 0.2, [{ type: 'jumpTo', time: 1 }])
|
||
s.addScriptFrame(t1.id, 0.5, [{ type: 'jumpTo', time: 2 }])
|
||
s.addScriptFrame(t2.id, 0.8, [{ type: 'jumpTo', time: 3 }])
|
||
s.play()
|
||
s.tick(1) // advances to 1, all three frames in (0, 1]
|
||
// The last fired wins — since frames fire sorted by time, the last
|
||
// action to run is jumpTo 3.
|
||
expect(s.playhead).toBe(3)
|
||
s.pause()
|
||
await tick()
|
||
void log
|
||
})
|
||
|
||
test('within a single frame, actions fire in list order', async () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
const t = s.addScriptTrack()
|
||
s.addScriptFrame(t.id, 1, [
|
||
{ type: 'jumpTo', time: 5 },
|
||
{ type: 'jumpTo', time: 7 },
|
||
{ type: 'pause' },
|
||
])
|
||
s.play()
|
||
s.tick(2)
|
||
expect(s.playhead).toBe(7)
|
||
expect(s.playing).toBe(false)
|
||
await tick()
|
||
})
|
||
|
||
test('scriptFramesInRange handles exactly-at-next inclusively', () => {
|
||
const frames = [{ id: 'a', trackId: 't', time: 1, actions: [] as Action[] }]
|
||
// next strictly equals frame.time → included (open-closed)
|
||
expect(scriptFramesInRange(frames, 0.5, 1)).toHaveLength(1)
|
||
// next strictly below → excluded
|
||
expect(scriptFramesInRange(frames, 0.5, 0.999)).toHaveLength(0)
|
||
})
|
||
})
|
||
|
||
suite('button asset — data model + edit mode', () => {
|
||
test('addButtonAsset creates asset with empty layers and hitArea', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addButtonAsset('Play', 160, 60)
|
||
expect(a.kind).toBe('button')
|
||
expect(a.name).toBe('Play')
|
||
expect(a.canvas.width).toBe(160)
|
||
expect(a.canvas.height).toBe(60)
|
||
expect(a.layers).toHaveLength(0)
|
||
expect(a.hitArea).toHaveLength(0)
|
||
})
|
||
|
||
test('enter/exit button edit sets mode=states and resets on exit', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addButtonAsset('B')
|
||
s.enterButtonAssetEdit(a.id)
|
||
expect(s.activeButtonAssetId).toBe(a.id)
|
||
expect(s.activeButtonEditMode).toBe('states')
|
||
expect(s.activeButtonState).toBe('normal')
|
||
s.setActiveButtonState('hover')
|
||
expect(s.activeButtonState).toBe('hover')
|
||
s.exitButtonAssetEdit()
|
||
expect(s.activeButtonAssetId).toBe(null)
|
||
expect(s.activeButtonEditMode).toBe(null)
|
||
expect(s.activeButtonState).toBe(null)
|
||
})
|
||
|
||
test('state scrubber syncs playhead to kf-time for mutations', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addButtonAsset('B')
|
||
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 40, height: 20, fill: '#f00' })
|
||
s.enterButtonAssetEdit(a.id)
|
||
const layer = s.addLayer(rect.id)!
|
||
// BUTTON_STATE_TIMES is { normal: 1, hover: 2, pressed: 3, disabled: 4 }
|
||
// so a layer added inside the button edit lands on the "normal" slot.
|
||
expect(layer.keyframes[0].time).toBe(1)
|
||
s.setActiveButtonState('hover')
|
||
expect(s.playhead).toBe(2)
|
||
s.upsertKeyframe(layer.id, s.playhead, { scaleX: 2, scaleY: 2 })
|
||
expect(layer.keyframes.some((k) => Math.abs(k.time - 2) < 1e-6)).toBe(true)
|
||
s.setActiveButtonState('pressed')
|
||
expect(s.playhead).toBe(3)
|
||
s.setActiveButtonState('disabled')
|
||
expect(s.playhead).toBe(4)
|
||
})
|
||
|
||
test('mode toggle: hit parks playhead; returning to states restores it', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addButtonAsset('B')
|
||
s.enterButtonAssetEdit(a.id)
|
||
s.setActiveButtonState('pressed')
|
||
expect(s.playhead).toBe(3)
|
||
s.setButtonEditMode('hit')
|
||
expect(s.activeButtonEditMode).toBe('hit')
|
||
// Hit mode onion-skins the "normal" state (time=1), so the playhead
|
||
// is parked there — not at 0.
|
||
expect(s.playhead).toBe(1)
|
||
// Selected state is preserved across the hit trip.
|
||
expect(s.activeButtonState).toBe('pressed')
|
||
s.setButtonEditMode('states')
|
||
expect(s.playhead).toBe(3)
|
||
})
|
||
|
||
test('addLayer in hit mode returns null — no leak into states', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addButtonAsset('B')
|
||
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 40, height: 20, fill: '#f00' })
|
||
s.enterButtonAssetEdit(a.id)
|
||
s.setButtonEditMode('hit')
|
||
const result = s.addLayer(rect.id)
|
||
expect(result).toBe(null)
|
||
const asset = s.scene.assets.find((x) => x.id === a.id)!
|
||
expect(asset.kind === 'button' ? asset.layers.length : -1).toBe(0)
|
||
})
|
||
|
||
test('hit shape CRUD stays isolated from layers', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addButtonAsset('B')
|
||
s.addButtonHitShape(a.id, { kind: 'rect', x: 0, y: 0, width: 40, height: 20 })
|
||
s.addButtonHitShape(a.id, { kind: 'circle', x: 50, y: 50, radius: 10 })
|
||
const asset = s.scene.assets.find((x) => x.id === a.id)!
|
||
expect(asset.kind === 'button' ? asset.hitArea.length : -1).toBe(2)
|
||
expect(asset.kind === 'button' ? asset.layers.length : -1).toBe(0)
|
||
s.removeButtonHitShape(a.id, 0)
|
||
expect(asset.kind === 'button' ? asset.hitArea.length : -1).toBe(1)
|
||
})
|
||
|
||
test('polygon hit shapes persist and update via updateButtonHitShape', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addButtonAsset('B')
|
||
s.addButtonHitShape(a.id, {
|
||
kind: 'polygon',
|
||
points: [
|
||
{ x: 0, y: 0 },
|
||
{ x: 100, y: 0 },
|
||
{ x: 50, y: 80 },
|
||
],
|
||
})
|
||
const get = () => {
|
||
const asset = s.scene.assets.find((x) => x.id === a.id)
|
||
if (!asset || asset.kind !== 'button') return null
|
||
return asset.hitArea[0]
|
||
}
|
||
const sh0 = get()
|
||
expect(sh0 && sh0.kind === 'polygon' ? sh0.points.length : -1).toBe(3)
|
||
s.updateButtonHitShape(a.id, 0, {
|
||
points: [
|
||
{ x: 1, y: 1 },
|
||
{ x: 101, y: 1 },
|
||
{ x: 51, y: 81 },
|
||
{ x: 2, y: 82 },
|
||
],
|
||
} as Partial<HitShape>)
|
||
const sh1 = get()
|
||
expect(sh1 && sh1.kind === 'polygon' ? sh1.points.length : -1).toBe(4)
|
||
})
|
||
})
|
||
|
||
suite('button asset — state transition runtime', () => {
|
||
// Regression: button state changes used to snap instantly between
|
||
// the normal / hover / pressed / disabled slots. `setState` now
|
||
// records a transition whose sample time lerps between the
|
||
// corresponding `BUTTON_STATE_TIMES` slots over
|
||
// `BUTTON_STATE_TRANSITION_MS`. `getEffectiveTime` is what the
|
||
// renderer reads; it must return an intermediate time during the
|
||
// transition and snap to the target once finished.
|
||
|
||
async function importRt() {
|
||
const mod = await import('@/composables/useButtonRuntime')
|
||
return {
|
||
rt: mod.useButtonRuntime(),
|
||
DURATION: mod.BUTTON_STATE_TRANSITION_MS,
|
||
}
|
||
}
|
||
|
||
test('getEffectiveTime returns the target slot when no transition is running', async () => {
|
||
const { rt } = await importRt()
|
||
rt.clear('btn-a')
|
||
expect(rt.getEffectiveTime('btn-a', false)).toBe(BUTTON_STATE_TIMES.normal)
|
||
rt.clear('btn-a')
|
||
// Fast-path: disabled layers report disabled regardless of the
|
||
// runtime state or transitions.
|
||
expect(rt.getEffectiveTime('btn-a', true)).toBe(BUTTON_STATE_TIMES.disabled)
|
||
rt.clear('btn-a')
|
||
})
|
||
|
||
test('setState kicks off a transition; mid-flight time lies between slots', async () => {
|
||
const { rt, DURATION } = await importRt()
|
||
rt.clear('btn-b')
|
||
rt.setState('btn-b', 'hover')
|
||
// Immediately after setState, time is ~fromTime (normal=1) and
|
||
// approaching toTime (hover=2).
|
||
const t0 = rt.getEffectiveTime('btn-b', false)
|
||
expect(t0).toBeGreaterThan(BUTTON_STATE_TIMES.normal - 0.001)
|
||
expect(t0).toBeLessThan(BUTTON_STATE_TIMES.hover)
|
||
// After half the duration the time should be around halfway.
|
||
await new Promise((r) => setTimeout(r, DURATION / 2))
|
||
const tMid = rt.getEffectiveTime('btn-b', false)
|
||
expect(tMid).toBeGreaterThan(BUTTON_STATE_TIMES.normal)
|
||
expect(tMid).toBeLessThan(BUTTON_STATE_TIMES.hover)
|
||
// After the full duration (+ slack) the transition resolves to
|
||
// the target slot exactly.
|
||
await new Promise((r) => setTimeout(r, DURATION + 40))
|
||
expect(rt.getEffectiveTime('btn-b', false)).toBe(BUTTON_STATE_TIMES.hover)
|
||
rt.clear('btn-b')
|
||
})
|
||
|
||
test('mid-transition setState rebases from the live sample time', async () => {
|
||
// Regression: if hover → pressed flips in-flight from a previous
|
||
// normal → hover transition, the new transition should start at
|
||
// the *current* intermediate sample time instead of snapping
|
||
// back to the old `from` slot. Otherwise a quick hover+press
|
||
// produces a visible jump.
|
||
const { rt, DURATION } = await importRt()
|
||
rt.clear('btn-c')
|
||
rt.setState('btn-c', 'hover')
|
||
await new Promise((r) => setTimeout(r, DURATION / 2))
|
||
// At this point the effective time is roughly halfway between
|
||
// normal (1) and hover (2) — somewhere around 1.4–1.6.
|
||
const midway = rt.getEffectiveTime('btn-c', false)
|
||
rt.setState('btn-c', 'pressed')
|
||
// The freshly-started transition must begin at or just past the
|
||
// midway sample, not drop back toward normal (1).
|
||
const after = rt.getEffectiveTime('btn-c', false)
|
||
// Allow a small drift forward because `setTimeout` and the
|
||
// subsequent assignment both burn a few ms — the important
|
||
// thing is we didn't regress into the [1, midway) interval.
|
||
expect(after).toBeGreaterThan(midway - 0.01)
|
||
rt.clear('btn-c')
|
||
})
|
||
|
||
test('no-op setState (target === current) does not start a transition', async () => {
|
||
const { rt } = await importRt()
|
||
rt.clear('btn-d')
|
||
// Currently normal; asking for normal again is a no-op.
|
||
rt.setState('btn-d', 'normal')
|
||
expect(rt.getEffectiveTime('btn-d', false)).toBe(BUTTON_STATE_TIMES.normal)
|
||
rt.clear('btn-d')
|
||
})
|
||
})
|
||
|
||
suite('button asset — thumbnail rendering', () => {
|
||
// Regression: renderButtonThumbnail used to pass images={} and any image
|
||
// layer inside a button produced a fully-transparent PNG, making
|
||
// sidebar thumbnails appear grey. Shape layers should always render
|
||
// visible pixels; image layers render when an images map is supplied.
|
||
|
||
function pngIsNonBlank(dataUrl: string): boolean {
|
||
// Data URL `data:image/png;base64,...` — non-trivial PNG means the
|
||
// renderer produced pixels. We accept anything over ~200 bytes of
|
||
// base64 payload, since an all-transparent 200×80-scaled canvas
|
||
// compresses to ~220 bytes (IHDR + tiny IDAT). A rect layer that
|
||
// actually paints fill colour balloons the IDAT noticeably.
|
||
const i = dataUrl.indexOf(',')
|
||
return i > 0 && dataUrl.length - i - 1 > 260
|
||
}
|
||
|
||
test('returns null when the button has no layers', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addButtonAsset('B', 200, 80)
|
||
const url = renderButtonThumbnail(a, s.scene)
|
||
expect(url).toBeNull()
|
||
})
|
||
|
||
test('renders shape layers without an images map', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addButtonAsset('B', 200, 80)
|
||
const rect = s.addAsset({
|
||
kind: 'rect',
|
||
name: 'R',
|
||
width: 200,
|
||
height: 80,
|
||
fill: '#6ea8ff',
|
||
})
|
||
s.enterButtonAssetEdit(a.id)
|
||
s.addLayer(rect.id)!
|
||
s.exitButtonAssetEdit()
|
||
const button = s.scene.assets.find((x) => x.id === a.id)!
|
||
const url = renderButtonThumbnail(
|
||
button as Extract<typeof button, { kind: 'button' }>,
|
||
s.scene,
|
||
)
|
||
expect(url).toBeTruthy()
|
||
// Data URL should be a well-formed PNG with actual content.
|
||
expect((url ?? '').startsWith('data:image/png;base64,')).toBe(true)
|
||
expect(pngIsNonBlank(url!)).toBe(true)
|
||
})
|
||
|
||
test('accepts a custom images map for image-kind layers', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addButtonAsset('B', 100, 40)
|
||
const img = s.addAsset({
|
||
kind: 'image',
|
||
name: 'I',
|
||
src: 'data:image/png;base64,iVBORw0KGgo=', // placeholder
|
||
width: 100,
|
||
height: 40,
|
||
})
|
||
s.enterButtonAssetEdit(a.id)
|
||
s.addLayer(img.id)!
|
||
s.exitButtonAssetEdit()
|
||
// Build a trivial HTMLImageElement stand-in. We don't wait for it to
|
||
// load — the point is just to confirm the function accepts the map
|
||
// without throwing, and that the button is not null.
|
||
const fake = new Image()
|
||
fake.width = 100
|
||
fake.height = 40
|
||
const button = s.scene.assets.find((x) => x.id === a.id)!
|
||
const url = renderButtonThumbnail(
|
||
button as Extract<typeof button, { kind: 'button' }>,
|
||
s.scene,
|
||
80,
|
||
{ [img.id]: fake },
|
||
)
|
||
expect(url).toBeTruthy()
|
||
expect((url ?? '').startsWith('data:image/png;base64,')).toBe(true)
|
||
})
|
||
})
|
||
|
||
suite('button asset — keyframes snap to state times', () => {
|
||
// Regression: keyframes inside a button-asset timeline must only
|
||
// exist at the 4 state times (BUTTON_STATE_TIMES = normal=1,
|
||
// hover=2, pressed=3, disabled=4). Any code path that places or
|
||
// moves a keyframe — upsertKeyframe, setKeyframeTime,
|
||
// duplicateKeyframe — snaps the time onto the nearest state. Free
|
||
// placement outside the 4 slots is disallowed.
|
||
|
||
function setupButton() {
|
||
const s = makeFreshStore()
|
||
const btn = s.addButtonAsset('B', 200, 80)
|
||
s.enterButtonAssetEdit(btn.id)
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 200, height: 80, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
// addLayer already put a keyframe at BUTTON_STATE_TIMES.normal (=1).
|
||
return { s, l }
|
||
}
|
||
|
||
test('upsertKeyframe snaps the requested time to the nearest state', () => {
|
||
const { s, l } = setupButton()
|
||
// 1.4 is closer to state 1 than to state 2 → should land at 1
|
||
// (merging with the existing one, rather than creating a new off-state kf).
|
||
s.upsertKeyframe(l.id, 1.4, { x: 10 })
|
||
expect(l.keyframes.every((k) => BUTTON_STATE_TIMES.normal === k.time || BUTTON_STATE_TIMES.hover === k.time || BUTTON_STATE_TIMES.pressed === k.time || BUTTON_STATE_TIMES.disabled === k.time)).toBe(true)
|
||
// 1.6 is closer to state 2 → new keyframe lands at 2
|
||
s.upsertKeyframe(l.id, 1.6, { x: 20 })
|
||
expect(l.keyframes.find((k) => Math.abs(k.time - 2) < 1e-6)).toBeTruthy()
|
||
})
|
||
|
||
test('setKeyframeTime snaps drag targets onto state times', () => {
|
||
const { s, l } = setupButton()
|
||
const kf = l.keyframes[0]
|
||
expect(kf.time).toBe(BUTTON_STATE_TIMES.normal)
|
||
// Drag "toward" hover but not all the way — 1.8 is closer to 2 → snaps to 2.
|
||
s.setKeyframeTime(l.id, kf.id, 1.8)
|
||
expect(kf.time).toBe(BUTTON_STATE_TIMES.hover)
|
||
// And 2.9 snaps to pressed.
|
||
s.setKeyframeTime(l.id, kf.id, 2.9)
|
||
expect(kf.time).toBe(BUTTON_STATE_TIMES.pressed)
|
||
})
|
||
|
||
test('setKeyframeTime refuses to land on an already-occupied state', () => {
|
||
const { s, l } = setupButton()
|
||
// Existing kf at state 1. Create a second at state 2.
|
||
s.upsertKeyframe(l.id, BUTTON_STATE_TIMES.hover, { x: 99 })
|
||
const kfAtHover = l.keyframes.find((k) => Math.abs(k.time - 2) < 1e-6)!
|
||
// Drag the hover kf toward normal. State 1 is already occupied — refuse.
|
||
s.setKeyframeTime(l.id, kfAtHover.id, 1.1)
|
||
expect(kfAtHover.time).toBe(BUTTON_STATE_TIMES.hover)
|
||
})
|
||
|
||
test('duplicateKeyframe picks the nearest free state slot', () => {
|
||
const { s, l } = setupButton()
|
||
// Only state 1 is occupied; duplicate should land on the next
|
||
// closest free slot — state 2 (hover).
|
||
const sourceId = l.keyframes[0].id
|
||
const newId = s.duplicateKeyframe(l.id, sourceId)!
|
||
expect(newId).toBeTruthy()
|
||
const dup = l.keyframes.find((k) => k.id === newId)!
|
||
expect(dup.time).toBe(BUTTON_STATE_TIMES.hover)
|
||
})
|
||
|
||
test('duplicateKeyframe returns null when every state slot is taken', () => {
|
||
const { s, l } = setupButton()
|
||
s.upsertKeyframe(l.id, BUTTON_STATE_TIMES.hover, { x: 2 })
|
||
s.upsertKeyframe(l.id, BUTTON_STATE_TIMES.pressed, { x: 3 })
|
||
s.upsertKeyframe(l.id, BUTTON_STATE_TIMES.disabled, { x: 4 })
|
||
const sourceId = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-6)!.id
|
||
const newId = s.duplicateKeyframe(l.id, sourceId)
|
||
expect(newId).toBe(null)
|
||
})
|
||
|
||
test('on the main timeline, snapping is NOT applied (free placement)', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 1.4, { x: 5 })
|
||
// No button-edit context → the kf lives at the exact requested time.
|
||
expect(l.keyframes.some((k) => Math.abs(k.time - 1.4) < 1e-6)).toBe(true)
|
||
})
|
||
})
|
||
|
||
suite('button asset — mask rendering', () => {
|
||
// Regression: masks inside buttons used to be ignored by every render
|
||
// path — the button branch in drawLayers called drawLayers recursively
|
||
// without the drawMaskedGroup split, so `isMask` layers drew as normal
|
||
// content. That made mask-preview / thumbnail / main-stage / exported
|
||
// runtime all show the mask shape instead of clipping. All paths now
|
||
// route button layers through drawMaskedGroup; these tests pin that.
|
||
function setupMaskedButton(mode: 'positive' | 'negative') {
|
||
const s = makeFreshStore()
|
||
const btn = s.addButtonAsset('B', 100, 100)
|
||
s.enterButtonAssetEdit(btn.id)
|
||
// Content: full-canvas red rect. addLayer already keyframes it at
|
||
// (canvas.width/2, canvas.height/2) at BUTTON_STATE_TIMES.normal,
|
||
// so we don't need to set anything else.
|
||
const red = s.addAsset({
|
||
kind: 'rect',
|
||
name: 'R',
|
||
width: 100,
|
||
height: 100,
|
||
fill: '#ff0000',
|
||
})
|
||
s.addLayer(red.id)!
|
||
// Mask: small circle at canvas center.
|
||
const mask = s.addAsset({
|
||
kind: 'circle',
|
||
name: 'M',
|
||
radius: 20,
|
||
fill: '#ffffff',
|
||
})
|
||
const maskLayer = s.addLayer(mask.id)!
|
||
s.setLayerMask(maskLayer.id, true, mode)
|
||
s.exitButtonAssetEdit()
|
||
const button = s.scene.assets.find((x) => x.id === btn.id) as ButtonAsset
|
||
return { store: s, button }
|
||
}
|
||
|
||
test('drawButtonFrame applies a positive mask inside the button', () => {
|
||
const { store, button } = setupMaskedButton('positive')
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = 100
|
||
canvas.height = 100
|
||
const ctx = canvas.getContext('2d')!
|
||
drawButtonFrame(ctx, button, store.scene, BUTTON_STATE_TIMES.normal, {})
|
||
const inside = ctx.getImageData(50, 50, 1, 1).data
|
||
const outside = ctx.getImageData(5, 5, 1, 1).data
|
||
expect(inside[0] > 200).toBe(true)
|
||
expect(inside[3] > 200).toBe(true)
|
||
expect(outside[3]).toBe(0)
|
||
})
|
||
|
||
test('drawButtonFrame applies a negative mask inside the button', () => {
|
||
const { store, button } = setupMaskedButton('negative')
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = 100
|
||
canvas.height = 100
|
||
const ctx = canvas.getContext('2d')!
|
||
drawButtonFrame(ctx, button, store.scene, BUTTON_STATE_TIMES.normal, {})
|
||
const inside = ctx.getImageData(50, 50, 1, 1).data
|
||
const outside = ctx.getImageData(5, 5, 1, 1).data
|
||
// Inside mask: punched out
|
||
expect(inside[3]).toBe(0)
|
||
// Outside mask: red content still there
|
||
expect(outside[0] > 200).toBe(true)
|
||
expect(outside[3] > 200).toBe(true)
|
||
})
|
||
|
||
test('drawSceneFrame applies button-internal masks when the button is placed on the main stage', () => {
|
||
const { store, button } = setupMaskedButton('positive')
|
||
// Size the main scene to match the button exactly so coordinates line up.
|
||
store.scene.canvas.width = 100
|
||
store.scene.canvas.height = 100
|
||
store.scene.canvas.background = 'transparent'
|
||
// Place the button as a layer on the main stage, centered.
|
||
store.addLayer(button.id)!
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = 100
|
||
canvas.height = 100
|
||
const ctx = canvas.getContext('2d')!
|
||
drawSceneFrame(ctx, store.scene, 0, {})
|
||
const inside = ctx.getImageData(50, 50, 1, 1).data
|
||
const outside = ctx.getImageData(5, 5, 1, 1).data
|
||
// Inside the circular mask: red content visible
|
||
expect(inside[0] > 200).toBe(true)
|
||
expect(inside[3] > 200).toBe(true)
|
||
// Outside the circle: masked away (alpha 0)
|
||
expect(outside[3]).toBe(0)
|
||
})
|
||
})
|
||
|
||
suite('i18n — track-menu rename/delete labels', () => {
|
||
// Regression: the script-track context menu used to surface the raw
|
||
// keys ("timeline.renameScriptTrack" / ".deleteScriptTrack") because
|
||
// the i18n messages were missing. Guard every new key so a future
|
||
// rename of the JSON/TS messages file doesn't silently break the menu.
|
||
test('renameScriptTrack is translated', () => {
|
||
const v = i18nT('timeline.renameScriptTrack')
|
||
expect(v).toBeTruthy()
|
||
expect(v === 'timeline.renameScriptTrack').toBe(false)
|
||
})
|
||
|
||
test('deleteScriptTrack is translated', () => {
|
||
const v = i18nT('timeline.deleteScriptTrack')
|
||
expect(v).toBeTruthy()
|
||
expect(v === 'timeline.deleteScriptTrack').toBe(false)
|
||
})
|
||
|
||
test('renameScriptTrackDialog.title + .confirm are translated', () => {
|
||
const title = i18nT('timeline.renameScriptTrackDialog.title')
|
||
const confirm = i18nT('timeline.renameScriptTrackDialog.confirm')
|
||
expect(title === 'timeline.renameScriptTrackDialog.title').toBe(false)
|
||
expect(confirm === 'timeline.renameScriptTrackDialog.confirm').toBe(false)
|
||
})
|
||
|
||
test('sidebar noClips / noButtons empty-states both suggest "yet"', () => {
|
||
// Regression: the clip library used to read "No clips" while the
|
||
// button library read "No buttons yet". Minor inconsistency that
|
||
// the user called out — both should suggest "more coming", not
|
||
// "there will never be any".
|
||
expect(i18nT('sidebar.noClips')).toContain('yet')
|
||
expect(i18nT('sidebar.noButtons')).toContain('yet')
|
||
})
|
||
|
||
test('holdDisabledHint key is gone (held kfs keep easing now)', () => {
|
||
// Regression: "Still frame — easing disabled" used to appear in
|
||
// the keyframe context menu and the easing buttons were grayed
|
||
// out. A held kf's easing still matters for the segment INTO it
|
||
// from the previous keyframe, so the restriction was wrong.
|
||
// Pin that the note is no longer emitted.
|
||
const v = i18nT('timeline.holdDisabledHint')
|
||
expect(v === 'timeline.holdDisabledHint').toBe(true)
|
||
})
|
||
|
||
test('app.saveProject tooltip mentions the Ctrl/Cmd+S shortcut', () => {
|
||
// Regression: Cmd/Ctrl+S was added as a keyboard save shortcut
|
||
// alongside the dirty-state dot. Surface it in the tooltip so
|
||
// the shortcut is discoverable; assert both the idle and the
|
||
// unsaved-changes variant stay in sync with the handler.
|
||
expect(i18nT('app.saveProject')).toContain('Ctrl/Cmd+S')
|
||
expect(i18nT('app.saveProjectDirty')).toContain('Ctrl/Cmd+S')
|
||
expect(i18nT('app.saveProjectDirty')).toContain('unsaved')
|
||
})
|
||
})
|
||
|
||
suite('timeline — scrub-handle obscured logic', () => {
|
||
// Regression: the handle used to be drawn over the toggle row when the
|
||
// playhead scrolled off the left edge, *or* be half-clipped at t=0
|
||
// depending on a static z-index. Timeline.vue now flips z-index based
|
||
// on whether the playhead is still in the visible ruler area. The
|
||
// rule is `scrollLeft > pxFromTime(playhead)`. Pin that rule so a
|
||
// future refactor of the computed doesn't accidentally break either
|
||
// direction.
|
||
const obscured = (scrollLeft: number, playheadPx: number) =>
|
||
scrollLeft > playheadPx
|
||
|
||
test('t=0 with no horizontal scroll → handle is visible (not obscured)', () => {
|
||
expect(obscured(0, 0)).toBe(false)
|
||
})
|
||
|
||
test('playhead right of the scroll offset → still visible', () => {
|
||
// User scrolled 120px right but the playhead is way out at 800px in
|
||
// ruler coordinates — the handle is comfortably in the visible ruler.
|
||
expect(obscured(120, 800)).toBe(false)
|
||
})
|
||
|
||
test('playhead scrolled left behind the name-spacer → obscured', () => {
|
||
// Playhead at t=0 (0px), scroll at 200px → playhead is off-screen
|
||
// to the left of the visible ruler → hide behind name-spacer.
|
||
expect(obscured(200, 0)).toBe(true)
|
||
})
|
||
})
|
||
}
|