- Fix mask-preview / frame-export dropping any group whose children are clip tracks. render.ts's old group branch only pulled Layer children out of group.childIds; new drawTrackGroup walks both layer AND clip-track ids and still applies group-local masks to the composite. Same fix mirrored into the HTML export runtime in exportPlayer.ts. - Snapping magnet for the playhead: snapTime now includes store.playhead in its candidate list by default. Clip drags, trim handles, keyframe drags, marker drags and in/out brackets stick to the needle when snap is on. Scrub handlers pass ignorePlayhead:true so the needle doesn't stick to its own prior position during a scrub. - Clip trim handles derive the snapped edge first and back out to trimStart/trimEnd, so the edge lands exactly on the magnet. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
11255 lines
443 KiB
TypeScript
11255 lines
443 KiB
TypeScript
import { createApp, defineComponent, h, nextTick, ref, type Component, type Slot } from 'vue'
|
||
import {
|
||
createBlankProject,
|
||
createBlankScene,
|
||
projectFromLegacyScene,
|
||
useSceneStore,
|
||
} from '@/stores/scene'
|
||
import {
|
||
buildMarkerMap,
|
||
evalAction,
|
||
scriptFramesInRange,
|
||
type ActionRuntime,
|
||
} from '@/lib/actions'
|
||
import { parseScript } from '@/lib/scriptDsl'
|
||
import type { Action, ButtonAsset, HitShape, Layer } from '@/types/scene'
|
||
import { BUTTON_STATE_TIMES } from '@/types/scene'
|
||
import { isLayerActiveAt, sampleLayer, lerpColor } from '@/lib/interpolate'
|
||
import { snapToCandidates } from '@/lib/timelineSnap'
|
||
import {
|
||
generateBundledScript,
|
||
generateExampleHtml,
|
||
generateRuntimeScript,
|
||
generateSceneScript,
|
||
} from '@/lib/exportPlayer'
|
||
import { renderFrames } from '@/lib/frameExport'
|
||
import { renderSceneToGif } from '@/lib/gifExport'
|
||
import { generateApngPreviewHtml, renderSceneToApng } from '@/lib/apngExport'
|
||
import { ProjectFileError, parseProject, serializeProject } from '@/lib/projectFile'
|
||
import { createHistory } from '@/composables/useHistory'
|
||
import { useKfSelection, kfSelectionKey } from '@/composables/useKfSelection'
|
||
import { APP_NAME, APP_VERSION } from '@/version'
|
||
import type { PolygonAsset } from '@/types/scene'
|
||
import { drawButtonFrame, drawSceneFrame, renderButtonThumbnail } from '@/lib/render'
|
||
import { t as i18nT } from '@/i18n'
|
||
import { expandedTrackOrder } from '@/lib/sceneOrder'
|
||
import { CLIP_FALLBACK_BOUNDS, computeClipBounds } from '@/lib/clipBounds'
|
||
import {
|
||
deleteStoredProject,
|
||
getStoredProject,
|
||
listRecentProjects,
|
||
saveProjectToStore,
|
||
} from '@/lib/projectStore'
|
||
import Modal from '@/components/ui/Modal.vue'
|
||
import IconButton from '@/components/ui/IconButton.vue'
|
||
import Toast from '@/components/ui/Toast.vue'
|
||
import PropertiesPanel from '@/components/PropertiesPanel.vue'
|
||
import ClipartDrawer from '@/components/ClipartDrawer.vue'
|
||
import SceneDrawer from '@/components/SceneDrawer.vue'
|
||
import DocumentDrawer from '@/components/DocumentDrawer.vue'
|
||
import ActionEditor from '@/components/ActionEditor.vue'
|
||
import Timeline from '@/components/Timeline.vue'
|
||
import AssetSidebar from '@/components/AssetSidebar.vue'
|
||
import CanvasStage from '@/components/CanvasStage.vue'
|
||
import ExportDialog from '@/components/ExportDialog.vue'
|
||
import WelcomeDialog from '@/components/WelcomeDialog.vue'
|
||
import FullscreenPreview from '@/components/FullscreenPreview.vue'
|
||
import HelpDialog from '@/components/HelpDialog.vue'
|
||
import { createAudioRuntime } from '@/composables/useAudioRuntime'
|
||
import {
|
||
clearWaveformCache,
|
||
drawWaveform,
|
||
getWaveformSlot,
|
||
slicePeaksForClip,
|
||
} from '@/composables/useAudioWaveform'
|
||
import { vTooltip } from '@/directives/tooltip'
|
||
import { beforeEach, expect, suite, test } from './testRunner'
|
||
|
||
interface MountedHandle {
|
||
host: HTMLDivElement
|
||
app: ReturnType<typeof createApp>
|
||
unmount: () => void
|
||
}
|
||
|
||
interface RecordingCtx {
|
||
ctx: CanvasRenderingContext2D
|
||
filterSets: string[]
|
||
compositeOps: string[]
|
||
}
|
||
|
||
function makeRecordingCtx(): RecordingCtx {
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = 200
|
||
canvas.height = 200
|
||
const real = canvas.getContext('2d')!
|
||
const filterSets: string[] = []
|
||
const compositeOps: string[] = []
|
||
const proxy = new Proxy(real, {
|
||
set(target, prop, value) {
|
||
if (prop === 'filter') filterSets.push(value as string)
|
||
if (prop === 'globalCompositeOperation') compositeOps.push(value as string)
|
||
;(target as unknown as Record<string | symbol, unknown>)[prop as string | symbol] = value
|
||
return true
|
||
},
|
||
get(target, prop) {
|
||
const v = (target as unknown as Record<string | symbol, unknown>)[
|
||
prop as string | symbol
|
||
]
|
||
if (typeof v === 'function') return v.bind(target)
|
||
return v
|
||
},
|
||
})
|
||
return { ctx: proxy as CanvasRenderingContext2D, filterSets, compositeOps }
|
||
}
|
||
|
||
function mountComponent(
|
||
component: Component,
|
||
props: Record<string, unknown>,
|
||
slots?: Record<string, Slot>,
|
||
): MountedHandle {
|
||
const host = document.createElement('div')
|
||
host.setAttribute('data-test-host', '')
|
||
document.body.appendChild(host)
|
||
const app = createApp({
|
||
render() {
|
||
return h(component, props, slots)
|
||
},
|
||
})
|
||
// Register the same global directives the real app registers in
|
||
// main.ts — without this, anything using `v-tooltip` (IconButton,
|
||
// the toolbar buttons, …) no-ops inside the test harness because
|
||
// each `mountComponent` spins up an isolated app instance.
|
||
app.directive('tooltip', vTooltip)
|
||
app.mount(host)
|
||
return {
|
||
host,
|
||
app,
|
||
unmount() {
|
||
app.unmount()
|
||
host.remove()
|
||
},
|
||
}
|
||
}
|
||
|
||
function makeFreshStore() {
|
||
const s = useSceneStore()
|
||
s.$reset()
|
||
return s
|
||
}
|
||
|
||
function tick(ms = 40): Promise<void> {
|
||
return new Promise((r) => setTimeout(r, ms))
|
||
}
|
||
|
||
export function registerAllTests(): void {
|
||
suite('scene store — defaults', () => {
|
||
test('starts with empty scene, playhead 0, not playing', () => {
|
||
const s = makeFreshStore()
|
||
expect(s.scene.layers).toHaveLength(0)
|
||
expect(s.scene.assets).toHaveLength(0)
|
||
expect(s.playhead).toBe(0)
|
||
expect(s.playing).toBe(false)
|
||
expect(s.scene.duration).toBe(10)
|
||
expect(s.scene.fps).toBe(60)
|
||
expect(s.selectedLayerId).toBeNull()
|
||
})
|
||
})
|
||
|
||
suite('scene store — playhead', () => {
|
||
test('setPlayhead clamps to [0, duration]', async () => {
|
||
const s = makeFreshStore()
|
||
s.setPlayhead(-5)
|
||
await tick()
|
||
expect(s.playhead).toBe(0)
|
||
s.setPlayhead(999)
|
||
await tick()
|
||
expect(s.playhead).toBe(s.scene.duration)
|
||
s.setPlayhead(3.5)
|
||
await tick()
|
||
expect(s.playhead).toBe(3.5)
|
||
})
|
||
|
||
test('setDuration pulls playhead back if past end', async () => {
|
||
const s = makeFreshStore()
|
||
s.setPlayhead(9)
|
||
await tick()
|
||
s.setDuration(5)
|
||
await tick()
|
||
expect(s.playhead).toBe(5)
|
||
})
|
||
|
||
test('setDuration clamps to min 0.1', () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(0)
|
||
expect(s.scene.duration).toBe(0.1)
|
||
})
|
||
})
|
||
|
||
suite('scene store — layers & selection', () => {
|
||
test('addLayer with rect asset creates layer + initial keyframe + selects it', async () => {
|
||
const s = makeFreshStore()
|
||
const asset = s.addAsset({
|
||
kind: 'rect',
|
||
name: 'TestRect',
|
||
width: 200,
|
||
height: 100,
|
||
fill: '#ff4455',
|
||
})
|
||
await tick()
|
||
const layer = s.addLayer(asset.id)!
|
||
await tick()
|
||
expect(s.scene.layers).toHaveLength(1)
|
||
expect(s.selectedLayerId).toBe(layer.id)
|
||
expect(layer.keyframes).toHaveLength(1)
|
||
expect(layer.keyframes[0].time).toBe(0)
|
||
expect(layer.offsetX).toBe(100)
|
||
expect(layer.offsetY).toBe(50)
|
||
expect(layer.base.fill).toBe('#ff4455')
|
||
})
|
||
|
||
test('removeLayer deselects if it was selected', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'circle', name: 'TestCircle', radius: 40, fill: '#44aaff' })
|
||
const l = s.addLayer(a.id)!
|
||
await tick()
|
||
s.removeLayer(l.id)
|
||
await tick()
|
||
expect(s.scene.layers).toHaveLength(0)
|
||
expect(s.selectedLayerId).toBeNull()
|
||
})
|
||
|
||
test('addLayer inserts at the top (index 0)', async () => {
|
||
const s = makeFreshStore()
|
||
const a1 = s.addAsset({ kind: 'rect', name: 'First', width: 40, height: 40, fill: '#ff4444' })
|
||
const a2 = s.addAsset({ kind: 'rect', name: 'Second', width: 40, height: 40, fill: '#44ff44' })
|
||
const l1 = s.addLayer(a1.id)!
|
||
await tick()
|
||
const l2 = s.addLayer(a2.id)!
|
||
await tick()
|
||
expect(s.scene.layers[0].id).toBe(l2.id)
|
||
expect(s.scene.layers[1].id).toBe(l1.id)
|
||
})
|
||
|
||
test('moveLayer reorders layers', async () => {
|
||
const s = makeFreshStore()
|
||
const a1 = s.addAsset({ kind: 'rect', name: 'A', width: 40, height: 40, fill: '#ff4444' })
|
||
const a2 = s.addAsset({ kind: 'rect', name: 'B', width: 40, height: 40, fill: '#44ff44' })
|
||
const a3 = s.addAsset({ kind: 'rect', name: 'C', width: 40, height: 40, fill: '#4444ff' })
|
||
const l1 = s.addLayer(a1.id)!
|
||
const l2 = s.addLayer(a2.id)!
|
||
const l3 = s.addLayer(a3.id)!
|
||
await tick()
|
||
// Insertion at top → starting order: [l3, l2, l1]
|
||
s.moveLayer(0, 2)
|
||
await tick()
|
||
expect(s.scene.layers[0].id).toBe(l2.id)
|
||
expect(s.scene.layers[1].id).toBe(l1.id)
|
||
expect(s.scene.layers[2].id).toBe(l3.id)
|
||
})
|
||
|
||
test('moveLayer ignores identical from/to', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#000' })
|
||
const l = s.addLayer(a.id)!
|
||
s.moveLayer(0, 0)
|
||
expect(s.scene.layers[0].id).toBe(l.id)
|
||
})
|
||
|
||
test('selectLayer toggles with null', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'circle', name: 'SelC', radius: 30, fill: '#00cc88' })
|
||
const l = s.addLayer(a.id)!
|
||
await tick()
|
||
s.selectLayer(null)
|
||
await tick()
|
||
expect(s.selectedLayerId).toBeNull()
|
||
s.selectLayer(l.id)
|
||
await tick()
|
||
expect(s.selectedLayerId).toBe(l.id)
|
||
})
|
||
|
||
test('selectLayer keeps selectedLayerIds in sync', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
s.selectLayer(l.id)
|
||
expect(s.selectedLayerIds).toEqual([l.id])
|
||
s.selectLayer(null)
|
||
expect(s.selectedLayerIds).toEqual([])
|
||
})
|
||
|
||
test('toggleLayerSelection adds and removes; anchor follows the last click', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const l1 = s.addLayer(a.id)!
|
||
const l2 = s.addLayer(a.id)!
|
||
const l3 = s.addLayer(a.id)!
|
||
s.selectLayer(l1.id)
|
||
s.toggleLayerSelection(l2.id)
|
||
s.toggleLayerSelection(l3.id)
|
||
expect(s.selectedLayerIds).toEqual([l1.id, l2.id, l3.id])
|
||
expect(s.selectedLayerId).toBe(l3.id)
|
||
// Removing the anchor moves the anchor back to the new last entry.
|
||
s.toggleLayerSelection(l3.id)
|
||
expect(s.selectedLayerIds).toEqual([l1.id, l2.id])
|
||
expect(s.selectedLayerId).toBe(l2.id)
|
||
// Removing the last remaining entry drops the anchor to null.
|
||
s.toggleLayerSelection(l2.id)
|
||
s.toggleLayerSelection(l1.id)
|
||
expect(s.selectedLayerIds).toEqual([])
|
||
expect(s.selectedLayerId).toBe(null)
|
||
})
|
||
|
||
test('toggleLayerSelection skips layers locked directly or by group', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const lFree = s.addLayer(a.id)!
|
||
const lDirect = s.addLayer(a.id)!
|
||
const lInGroup = s.addLayer(a.id)!
|
||
s.setTrackLocked(lDirect.id, true)
|
||
const g = s.addTrackGroup('G')
|
||
s.moveTrackIntoGroup(lInGroup.id, g.id)
|
||
s.setTrackLocked(g.id, true)
|
||
s.selectLayer(lFree.id)
|
||
s.toggleLayerSelection(lDirect.id)
|
||
s.toggleLayerSelection(lInGroup.id)
|
||
// Neither locked layer was added; the free one stays as the
|
||
// only selection.
|
||
expect(s.selectedLayerIds).toEqual([lFree.id])
|
||
})
|
||
|
||
test('groupSelectedLayers moves every selected layer into a fresh group', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const l1 = s.addLayer(a.id)!
|
||
const l2 = s.addLayer(a.id)!
|
||
const l3 = s.addLayer(a.id)!
|
||
s.selectLayer(l1.id)
|
||
s.toggleLayerSelection(l2.id)
|
||
s.toggleLayerSelection(l3.id)
|
||
const groupId = s.groupSelectedLayers()!
|
||
expect(groupId).toBeTruthy()
|
||
const g = s.scene.groups!.find((x) => x.id === groupId)!
|
||
expect(g.childIds.includes(l1.id)).toBe(true)
|
||
expect(g.childIds.includes(l2.id)).toBe(true)
|
||
expect(g.childIds.includes(l3.id)).toBe(true)
|
||
// The layers also get removed from the top-level trackOrder
|
||
// since moveTrackIntoGroup handles that.
|
||
const order = s.scene.trackOrder ?? []
|
||
expect(order.includes(l1.id)).toBe(false)
|
||
expect(order.includes(l2.id)).toBe(false)
|
||
expect(order.includes(l3.id)).toBe(false)
|
||
expect(order.includes(groupId)).toBe(true)
|
||
})
|
||
|
||
test('groupSelectedLayers returns null with an empty selection', () => {
|
||
const s = makeFreshStore()
|
||
const groupsBefore = (s.scene.groups ?? []).length
|
||
const result = s.groupSelectedLayers()
|
||
expect(result).toBe(null)
|
||
// Nothing was created.
|
||
expect((s.scene.groups ?? []).length).toBe(groupsBefore)
|
||
})
|
||
|
||
test('groupSelectedLayers replaces the topmost selected track\'s stack position', () => {
|
||
// addLayer inserts at the top (index 0), so after three addLayer
|
||
// calls trackOrder reads top→bottom: [l3, l2, l1]. Picking l2
|
||
// and l1 to group (two layers NOT at the top) should produce a
|
||
// group at index 1, not at the top of the stack.
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const l1 = s.addLayer(a.id)!
|
||
const l2 = s.addLayer(a.id)!
|
||
const l3 = s.addLayer(a.id)!
|
||
const before = [...s.scene.trackOrder!]
|
||
expect(before).toEqual([l3.id, l2.id, l1.id])
|
||
s.selectLayer(l2.id)
|
||
s.toggleLayerSelection(l1.id)
|
||
const groupId = s.groupSelectedLayers()!
|
||
const after = s.scene.trackOrder!
|
||
// Group sits where l2 used to be (index 1); l3 still at top;
|
||
// l1 and l2 are no longer top-level.
|
||
expect(after[0]).toBe(l3.id)
|
||
expect(after[1]).toBe(groupId)
|
||
expect(after.includes(l1.id)).toBe(false)
|
||
expect(after.includes(l2.id)).toBe(false)
|
||
})
|
||
|
||
test('convertSelectedLayersToClip packs selected layers into a fresh clip + instance', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
// Give each layer a unique name up-front so we can look them up
|
||
// unambiguously inside the packed clip. Bare `addLayer(asset.id)`
|
||
// falls back to the asset name — three layers would all read "R"
|
||
// and `find(l => l.name === ...)` would return the first match.
|
||
const l1 = s.addLayer(a.id, 'one')!
|
||
const l2 = s.addLayer(a.id, 'two')!
|
||
const l3 = s.addLayer(a.id, 'three')!
|
||
// Seed a non-default keyframe so we can confirm the clip owns an
|
||
// independent copy of the original layer's data.
|
||
s.upsertKeyframe(l2.id, 1.5, { x: 123 })
|
||
const kfCount = l2.keyframes.length
|
||
s.selectLayer(l1.id)
|
||
s.toggleLayerSelection(l2.id)
|
||
expect(s.selectedLayerIds.length).toBe(2)
|
||
|
||
const clipId = s.convertSelectedLayersToClip('My Clip')!
|
||
expect(clipId).toBeTruthy()
|
||
|
||
// Clip exists with the packed layers and a duration that matches
|
||
// the scene so absolute kf times still land where they were.
|
||
const clip = s.scene.clips!.find((c) => c.id === clipId)!
|
||
expect(clip.name).toBe('My Clip')
|
||
expect(clip.duration).toBe(s.scene.duration)
|
||
expect(clip.layers.length).toBe(2)
|
||
// The kf data survives the round-trip.
|
||
const clonedL2 = clip.layers.find((l) => l.name === 'two')!
|
||
expect(clonedL2).toBeTruthy()
|
||
expect(clonedL2.keyframes.length).toBe(kfCount)
|
||
expect(clonedL2.keyframes.some((k) => Math.abs(k.time - 1.5) < 1e-6)).toBe(true)
|
||
|
||
// Originals removed from scene.layers + trackOrder.
|
||
expect(s.scene.layers.some((l) => l.id === l1.id)).toBe(false)
|
||
expect(s.scene.layers.some((l) => l.id === l2.id)).toBe(false)
|
||
expect(s.scene.layers.some((l) => l.id === l3.id)).toBe(true)
|
||
expect(s.scene.trackOrder!.includes(l1.id)).toBe(false)
|
||
expect(s.scene.trackOrder!.includes(l2.id)).toBe(false)
|
||
|
||
// Exactly one clipTrack + one instance created; the instance
|
||
// sits at startTime 0 so the packed layers' absolute kf times
|
||
// continue to fire at their original scene times.
|
||
const clipTrackIds = (s.scene.clipTracks ?? []).map((t) => t.id)
|
||
const matching = (s.scene.clipInstances ?? []).filter(
|
||
(ci) => ci.clipId === clipId,
|
||
)
|
||
expect(matching.length).toBe(1)
|
||
expect(clipTrackIds.includes(matching[0].clipTrackId)).toBe(true)
|
||
expect(matching[0].startTime).toBe(0)
|
||
|
||
// The clone is independent: mutating the clip's layer doesn't
|
||
// affect the source (l2 is gone, but the data pointer shouldn't
|
||
// have been shared).
|
||
clonedL2.keyframes[0].time = 99
|
||
// (no crash means the refs are decoupled; l2 is already gone)
|
||
})
|
||
|
||
test('convertSelectedLayersToClip drops the clipTrack at the topmost selected slot', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const l1 = s.addLayer(a.id)!
|
||
const l2 = s.addLayer(a.id)!
|
||
const l3 = s.addLayer(a.id)!
|
||
// addLayer prepends → trackOrder reads top-to-bottom [l3, l2, l1].
|
||
s.selectLayer(l2.id)
|
||
s.toggleLayerSelection(l1.id)
|
||
s.convertSelectedLayersToClip()
|
||
const order = s.scene.trackOrder ?? []
|
||
// l3 is still at the top (index 0); the clipTrack replaces l2's
|
||
// old slot (index 1); l1's slot is collapsed away.
|
||
expect(order[0]).toBe(l3.id)
|
||
expect(order.length).toBe(2)
|
||
const clipTrackId = (s.scene.clipTracks ?? []).slice(-1)[0].id
|
||
expect(order[1]).toBe(clipTrackId)
|
||
})
|
||
|
||
test('convertSelectedLayersToClip returns null with an empty selection', () => {
|
||
const s = makeFreshStore()
|
||
expect(s.convertSelectedLayersToClip()).toBe(null)
|
||
expect((s.scene.clips ?? []).length).toBe(0)
|
||
expect((s.scene.clipTracks ?? []).length).toBe(0)
|
||
expect((s.scene.clipInstances ?? []).length).toBe(0)
|
||
})
|
||
|
||
test('convertSelectedLayersToClip skips effectively-locked layers', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const lFree = s.addLayer(a.id)!
|
||
const lLocked = s.addLayer(a.id)!
|
||
s.setTrackLocked(lLocked.id, true)
|
||
s.selectedLayerIds = [lFree.id, lLocked.id]
|
||
s.selectedLayerId = lLocked.id
|
||
const clipId = s.convertSelectedLayersToClip()!
|
||
const clip = s.scene.clips!.find((c) => c.id === clipId)!
|
||
expect(clip.layers.length).toBe(1)
|
||
expect(clip.layers[0].name).toBe(lFree.name)
|
||
// Locked layer stays put.
|
||
expect(s.scene.layers.some((l) => l.id === lLocked.id)).toBe(true)
|
||
})
|
||
|
||
test('groupSelectedLayers skips layers that are effectively locked', () => {
|
||
// Locked ones shouldn't land in the fresh group — they stay
|
||
// wherever they were.
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const lFree1 = s.addLayer(a.id)!
|
||
const lLocked = s.addLayer(a.id)!
|
||
const lFree2 = s.addLayer(a.id)!
|
||
s.setTrackLocked(lLocked.id, true)
|
||
// Locking a layer deselects it, so toggleLayerSelection also
|
||
// silently refuses; build the selection manually to exercise
|
||
// the filter inside `groupSelectedLayers`.
|
||
s.selectedLayerIds = [lFree1.id, lLocked.id, lFree2.id]
|
||
s.selectedLayerId = lFree2.id
|
||
const groupId = s.groupSelectedLayers()!
|
||
const g = s.scene.groups!.find((x) => x.id === groupId)!
|
||
expect(g.childIds.includes(lFree1.id)).toBe(true)
|
||
expect(g.childIds.includes(lFree2.id)).toBe(true)
|
||
expect(g.childIds.includes(lLocked.id)).toBe(false)
|
||
// The locked layer still sits in the top-level track order.
|
||
expect(s.scene.trackOrder!.includes(lLocked.id)).toBe(true)
|
||
})
|
||
})
|
||
|
||
suite('scene store — keyframes', () => {
|
||
test('upsertKeyframe adds new keyframe sorted by time', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'Kfs', width: 80, height: 80, fill: '#ffa500' })
|
||
const l = s.addLayer(a.id)!
|
||
await tick()
|
||
s.upsertKeyframe(l.id, 2, { x: 600 })
|
||
await tick()
|
||
s.upsertKeyframe(l.id, 1, { x: 300 })
|
||
await tick()
|
||
const times = l.keyframes.map((k) => k.time)
|
||
expect(times).toEqual([0, 1, 2])
|
||
})
|
||
|
||
test('upsertKeyframe updates existing keyframe within epsilon', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'Upd', width: 80, height: 80, fill: '#aa44ff' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 1, { x: 500 })
|
||
await tick()
|
||
const before = l.keyframes.length
|
||
s.upsertKeyframe(l.id, 1.0000001, { x: 900 })
|
||
await tick()
|
||
expect(l.keyframes).toHaveLength(before)
|
||
const kf = l.keyframes.find((k) => Math.abs(k.time - 1) < 0.01)!
|
||
expect(kf.props.x).toBe(900)
|
||
})
|
||
|
||
test('setKeyframeTime clamps and sorts', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'Clamp', width: 60, height: 60, fill: '#ff8844' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 3, { x: 400 })
|
||
s.upsertKeyframe(l.id, 5, { x: 800 })
|
||
await tick()
|
||
const target = l.keyframes.find((k) => k.time === 5)!
|
||
s.setKeyframeTime(l.id, target.id, -1)
|
||
await tick()
|
||
expect(target.time).toBe(0)
|
||
const times = l.keyframes.map((k) => k.time).sort((a, b) => a - b)
|
||
expect(times).toEqual(l.keyframes.map((k) => k.time))
|
||
})
|
||
|
||
test('setKeyframeTime clamps to duration upper bound', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'Dur', width: 60, height: 60, fill: '#44ffaa' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 2, { x: 400 })
|
||
await tick()
|
||
const k = l.keyframes[l.keyframes.length - 1]
|
||
s.setKeyframeTime(l.id, k.id, 999)
|
||
await tick()
|
||
expect(k.time).toBe(s.scene.duration)
|
||
})
|
||
|
||
test('removeKeyframe removes by id', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'Rm', width: 50, height: 50, fill: '#ee4488' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 2, { x: 500 })
|
||
await tick()
|
||
const k = l.keyframes.find((k) => k.time === 2)!
|
||
s.removeKeyframe(l.id, k.id)
|
||
await tick()
|
||
expect(l.keyframes.find((x) => x.id === k.id)).toBe(undefined)
|
||
})
|
||
|
||
test('duplicateKeyframe creates offset copy', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'Dup', width: 50, height: 50, fill: '#22aaee' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 1, { x: 400 })
|
||
await tick()
|
||
const src = l.keyframes.find((k) => k.time === 1)!
|
||
const newId = s.duplicateKeyframe(l.id, src.id)
|
||
await tick()
|
||
expect(newId).toBeTruthy()
|
||
const dup = l.keyframes.find((k) => k.id === newId)!
|
||
expect(dup.props.x).toBe(400)
|
||
expect(dup.time).toBeGreaterThan(1)
|
||
})
|
||
|
||
test('duplicateKeyframeAt copies props + easing + hold to an explicit time', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'Src', width: 50, height: 50, fill: '#abcdef' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 1, { x: 123, y: 45 })
|
||
const src = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-6)!
|
||
src.easing = 'easeInOut'
|
||
s.setKeyframeHold(l.id, src.id, true)
|
||
|
||
const newId = s.duplicateKeyframeAt(l.id, src.id, 3)
|
||
expect(newId).toBeTruthy()
|
||
const dup = l.keyframes.find((k) => k.id === newId)!
|
||
expect(dup.time).toBe(3)
|
||
expect(dup.props.x).toBe(123)
|
||
expect(dup.props.y).toBe(45)
|
||
expect(dup.easing).toBe('easeInOut')
|
||
expect(dup.hold).toBe(true)
|
||
// props must be a clone — mutating the duplicate must not touch the source.
|
||
dup.props.x = 999
|
||
expect(src.props.x).toBe(123)
|
||
})
|
||
|
||
test('duplicateKeyframeAt refuses to overwrite an existing keyframe', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'Col', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 1, { x: 10 })
|
||
s.upsertKeyframe(l.id, 2, { x: 20 })
|
||
const src = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-6)!
|
||
const countBefore = l.keyframes.length
|
||
const newId = s.duplicateKeyframeAt(l.id, src.id, 2)
|
||
expect(newId).toBe(null)
|
||
expect(l.keyframes.length).toBe(countBefore)
|
||
})
|
||
|
||
test('duplicateKeyframeAt returns null for unknown layer or keyframe ids', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 1, { x: 10 })
|
||
const src = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-6)!
|
||
expect(s.duplicateKeyframeAt('nope-layer', src.id, 2)).toBe(null)
|
||
expect(s.duplicateKeyframeAt(l.id, 'nope-kf', 2)).toBe(null)
|
||
})
|
||
|
||
test('duplicateKeyframeAt targets the source keyframe\'s own track (position vs main)', () => {
|
||
// Separated layers have independent main / position / rotation
|
||
// arrays. The duplicate must land in the same array the source
|
||
// came from, not accidentally jump to another.
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'Sep', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
s.setLayerSeparated(l.id, true)
|
||
s.upsertKeyframe(l.id, 1, { x: 50, y: 60 })
|
||
const posKf = l.positionKeyframes!.find((k) => Math.abs(k.time - 1) < 1e-6)!
|
||
s.duplicateKeyframeAt(l.id, posKf.id, 2)
|
||
const dup = l.positionKeyframes!.find((k) => Math.abs(k.time - 2) < 1e-6)
|
||
expect(dup).toBeTruthy()
|
||
// Nothing should have been added to the main array.
|
||
expect(l.keyframes.some((k) => Math.abs(k.time - 2) < 1e-6)).toBe(false)
|
||
})
|
||
|
||
test('setKeyframeHold toggles hold flag', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'Hold', width: 50, height: 50, fill: '#ddaa44' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 1, { x: 400 })
|
||
await tick()
|
||
const k = l.keyframes[l.keyframes.length - 1]
|
||
s.setKeyframeHold(l.id, k.id, true)
|
||
await tick()
|
||
expect(k.hold).toBe(true)
|
||
s.setKeyframeHold(l.id, k.id, false)
|
||
await tick()
|
||
expect(k.hold).toBe(undefined)
|
||
})
|
||
|
||
test('setKeyframeEasing updates easing field', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'Ease', width: 50, height: 50, fill: '#88cc44' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 1, { x: 400 })
|
||
await tick()
|
||
const k = l.keyframes[l.keyframes.length - 1]
|
||
s.setKeyframeEasing(l.id, k.id, 'easeInOut')
|
||
expect(k.easing).toBe('easeInOut')
|
||
})
|
||
})
|
||
|
||
suite('scene store — separated tracks', () => {
|
||
test('setLayerSeparated splits x/y and rotation into dedicated tracks', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'Sep', width: 60, height: 60, fill: '#ee66aa' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 1, { x: 400, y: 200, rotation: 45, opacity: 0.5 })
|
||
await tick()
|
||
s.setLayerSeparated(l.id, true)
|
||
await tick()
|
||
expect(l.separated).toBe(true)
|
||
expect((l.positionKeyframes ?? []).length).toBeGreaterThan(0)
|
||
expect((l.rotationKeyframes ?? []).length).toBeGreaterThan(0)
|
||
for (const k of l.keyframes) {
|
||
expect((k.props as Record<string, unknown>).x).toBe(undefined)
|
||
expect((k.props as Record<string, unknown>).y).toBe(undefined)
|
||
expect((k.props as Record<string, unknown>).rotation).toBe(undefined)
|
||
}
|
||
})
|
||
|
||
test('setLayerSeparated(false) merges tracks back together', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'Merge', width: 60, height: 60, fill: '#66ccee' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 1, { x: 400, y: 200, rotation: 45, opacity: 0.5 })
|
||
await tick()
|
||
s.setLayerSeparated(l.id, true)
|
||
await tick()
|
||
s.setLayerSeparated(l.id, false)
|
||
await tick()
|
||
expect(l.separated).toBe(false)
|
||
expect(l.positionKeyframes).toBe(undefined)
|
||
expect(l.rotationKeyframes).toBe(undefined)
|
||
const at1 = l.keyframes.find((k) => Math.abs(k.time - 1) < 0.001)!
|
||
expect((at1.props as Record<string, number>).x).toBe(400)
|
||
expect((at1.props as Record<string, number>).rotation).toBe(45)
|
||
})
|
||
})
|
||
|
||
suite('interpolation — sampleLayer', () => {
|
||
test('no keyframes → returns base', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#000' })
|
||
const l = s.addLayer(a.id)!
|
||
l.keyframes = []
|
||
const p = sampleLayer(l, 0.5)
|
||
expect(p.x).toBe(l.base.x)
|
||
expect(p.y).toBe(l.base.y)
|
||
})
|
||
|
||
test('linear midpoint is halfway', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#000' })
|
||
const l = s.addLayer(a.id)!
|
||
l.keyframes = [
|
||
{ id: 'k1', time: 0, props: { x: 0 }, easing: 'linear' },
|
||
{ id: 'k2', time: 2, props: { x: 100 }, easing: 'linear' },
|
||
]
|
||
l.base = { ...l.base, x: 0, y: 0 }
|
||
const p = sampleLayer(l, 1)
|
||
expect(p.x).toBeCloseTo(50, 2)
|
||
})
|
||
|
||
test('easeIn midpoint is below linear midpoint', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#000' })
|
||
const l = s.addLayer(a.id)!
|
||
l.base = { ...l.base, x: 0 }
|
||
l.keyframes = [
|
||
{ id: 'k1', time: 0, props: { x: 0 }, easing: 'linear' },
|
||
{ id: 'k2', time: 2, props: { x: 100 }, easing: 'easeIn' },
|
||
]
|
||
const p = sampleLayer(l, 1)
|
||
expect(p.x).toBeLessThan(50)
|
||
expect(p.x).toBeGreaterThan(0)
|
||
})
|
||
|
||
test('hold keyframe holds value until next keyframe', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'H', width: 10, height: 10, fill: '#000' })
|
||
const l = s.addLayer(a.id)!
|
||
l.base = { ...l.base, x: 0 }
|
||
l.keyframes = [
|
||
{ id: 'k1', time: 0, props: { x: 0 }, easing: 'linear', hold: true },
|
||
{ id: 'k2', time: 2, props: { x: 100 }, easing: 'linear' },
|
||
]
|
||
expect(sampleLayer(l, 0).x).toBe(0)
|
||
expect(sampleLayer(l, 0.5).x).toBe(0)
|
||
expect(sampleLayer(l, 1.5).x).toBe(0)
|
||
expect(sampleLayer(l, 2).x).toBe(100)
|
||
})
|
||
|
||
test('clamps before first kf to first props, after last to last props', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#000' })
|
||
const l = s.addLayer(a.id)!
|
||
l.base = { ...l.base, x: 0 }
|
||
l.keyframes = [
|
||
{ id: 'k1', time: 1, props: { x: 10 }, easing: 'linear' },
|
||
{ id: 'k2', time: 2, props: { x: 20 }, easing: 'linear' },
|
||
]
|
||
expect(sampleLayer(l, 0).x).toBe(10)
|
||
expect(sampleLayer(l, 5).x).toBe(20)
|
||
})
|
||
})
|
||
|
||
suite('interpolation — color', () => {
|
||
test('lerpColor 0 returns start', () => {
|
||
expect(lerpColor('#000000', '#ffffff', 0)).toBe('#000000')
|
||
})
|
||
test('lerpColor 1 returns end', () => {
|
||
expect(lerpColor('#000000', '#ffffff', 1)).toBe('#ffffff')
|
||
})
|
||
test('lerpColor 0.5 is around middle gray', () => {
|
||
const mid = lerpColor('#000000', '#ffffff', 0.5)
|
||
expect(mid).toBe('#808080')
|
||
})
|
||
})
|
||
|
||
suite('interpolation — shadow properties', () => {
|
||
// Regression: shadow keyframes used to render static (or nothing) in
|
||
// the preview because the runtime's lerpProps dropped shadow fields.
|
||
// The canvas-side sampleLayer must interpolate them correctly; this
|
||
// pins the contract both paths share.
|
||
|
||
function rectWithShadow() {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'Sh', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
// Seed both endpoints so there's a clear interpolation target.
|
||
s.upsertKeyframe(l.id, 0, {
|
||
shadowOpacity: 0,
|
||
shadowBlur: 0,
|
||
shadowDistance: 0,
|
||
shadowAngle: 0,
|
||
shadowColor: '#000000',
|
||
})
|
||
s.upsertKeyframe(l.id, 2, {
|
||
shadowOpacity: 1,
|
||
shadowBlur: 20,
|
||
shadowDistance: 10,
|
||
shadowAngle: 180,
|
||
shadowColor: '#ffffff',
|
||
})
|
||
return l
|
||
}
|
||
|
||
test('shadowOpacity lerps halfway between keyframes', () => {
|
||
const l = rectWithShadow()
|
||
const mid = sampleLayer(l, 1)
|
||
expect(mid.shadowOpacity).toBe(0.5)
|
||
})
|
||
|
||
test('shadowBlur / distance / angle lerp halfway between keyframes', () => {
|
||
const l = rectWithShadow()
|
||
const mid = sampleLayer(l, 1)
|
||
expect(mid.shadowBlur).toBe(10)
|
||
expect(mid.shadowDistance).toBe(5)
|
||
expect(mid.shadowAngle).toBe(90)
|
||
})
|
||
|
||
test('shadowColor is interpolated per channel (not snapped)', () => {
|
||
const l = rectWithShadow()
|
||
const mid = sampleLayer(l, 1)
|
||
// Black→white at t=0.5 lands on mid-gray (same lerpColor the
|
||
// fill-color test above pins).
|
||
expect(mid.shadowColor).toBe('#808080')
|
||
})
|
||
})
|
||
|
||
suite('interpolation — layer visibility (kf range)', () => {
|
||
test('untouched baseline (single kf @0) is always visible', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
expect(isLayerActiveAt(l, 0)).toBe(true)
|
||
expect(isLayerActiveAt(l, 5)).toBe(true)
|
||
expect(isLayerActiveAt(l, 99)).toBe(true)
|
||
})
|
||
|
||
test('single kf at any time is always visible (static layer)', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
l.keyframes[0].time = 3
|
||
expect(isLayerActiveAt(l, 0)).toBe(true)
|
||
expect(isLayerActiveAt(l, 3)).toBe(true)
|
||
expect(isLayerActiveAt(l, 5)).toBe(true)
|
||
})
|
||
|
||
test('multi-kf: visible only in [firstKf, lastKf]', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 2, { scaleX: 2 })
|
||
s.upsertKeyframe(l.id, 5, { scaleX: 3 })
|
||
// kf@0 is still there; firstKf = 0, lastKf = 5
|
||
expect(isLayerActiveAt(l, -0.1)).toBe(false)
|
||
expect(isLayerActiveAt(l, 0)).toBe(true)
|
||
expect(isLayerActiveAt(l, 3)).toBe(true)
|
||
expect(isLayerActiveAt(l, 5)).toBe(true)
|
||
expect(isLayerActiveAt(l, 6)).toBe(false)
|
||
})
|
||
|
||
test('dragging first kf forward hides layer before it', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 5, { scaleX: 2 })
|
||
// Drag the first kf from t=0 to t=2.
|
||
const first = l.keyframes.find((k) => k.time === 0)!
|
||
first.time = 2
|
||
expect(isLayerActiveAt(l, 1)).toBe(false)
|
||
expect(isLayerActiveAt(l, 2)).toBe(true)
|
||
expect(isLayerActiveAt(l, 5)).toBe(true)
|
||
expect(isLayerActiveAt(l, 6)).toBe(false)
|
||
})
|
||
|
||
test('last kf held: layer stays visible past its last keyframe', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 5, { scaleX: 2 })
|
||
// Mark the t=5 kf as hold. It's the last keyframe — used to be a
|
||
// no-op; now it means "stay on stage past the end of the anim".
|
||
const last = l.keyframes.find((k) => k.time === 5)!
|
||
s.setKeyframeHold(l.id, last.id, true)
|
||
expect(isLayerActiveAt(l, 5)).toBe(true)
|
||
expect(isLayerActiveAt(l, 6)).toBe(true)
|
||
expect(isLayerActiveAt(l, 99)).toBe(true)
|
||
// sampleLayer already pins to last.props for time >= last.time —
|
||
// confirm the value is the last keyframe's, not interpolating out.
|
||
expect(sampleLayer(l, 10).scaleX).toBe(2)
|
||
})
|
||
|
||
test('non-last held kf does NOT extend visibility past end', () => {
|
||
// Hold on a middle keyframe only changes how it interpolates TO
|
||
// the following kf; it should not keep the layer alive past the
|
||
// final keyframe when that final kf itself isn't held.
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 2, { scaleX: 2 })
|
||
s.upsertKeyframe(l.id, 5, { scaleX: 3 })
|
||
const middle = l.keyframes.find((k) => k.time === 2)!
|
||
s.setKeyframeHold(l.id, middle.id, true)
|
||
expect(isLayerActiveAt(l, 5)).toBe(true)
|
||
expect(isLayerActiveAt(l, 6)).toBe(false)
|
||
})
|
||
})
|
||
|
||
suite('timeline snap', () => {
|
||
test('disabled: returns t unchanged', () => {
|
||
const r = snapToCandidates(1.7, [2.0], {
|
||
enabled: false,
|
||
pxPerSec: 100,
|
||
pixelThreshold: 8,
|
||
duration: 10,
|
||
})
|
||
expect(r).toBe(1.7)
|
||
})
|
||
|
||
test('enabled: snaps to nearest candidate within threshold', () => {
|
||
const r = snapToCandidates(1.95, [2.0, 5.0], {
|
||
enabled: true,
|
||
pxPerSec: 100,
|
||
pixelThreshold: 8,
|
||
duration: 10,
|
||
})
|
||
expect(r).toBe(2.0)
|
||
})
|
||
|
||
test('enabled: leaves t alone if all candidates are outside threshold', () => {
|
||
const r = snapToCandidates(1.0, [2.0], {
|
||
enabled: true,
|
||
pxPerSec: 100,
|
||
pixelThreshold: 8,
|
||
duration: 10,
|
||
})
|
||
expect(r).toBe(1.0)
|
||
})
|
||
|
||
test('clamps result to [0, duration]', () => {
|
||
const r = snapToCandidates(15, [], {
|
||
enabled: true,
|
||
pxPerSec: 100,
|
||
pixelThreshold: 8,
|
||
duration: 10,
|
||
})
|
||
expect(r).toBe(10)
|
||
})
|
||
})
|
||
|
||
suite('export — runtime & scene split', () => {
|
||
test('generateRuntimeScript exposes AnimatrPlayer', () => {
|
||
const r = generateRuntimeScript()
|
||
expect(r).toContain('window.AnimatrPlayer')
|
||
expect(r).toContain('function mount')
|
||
expect(r).toContain('__animatrPending')
|
||
})
|
||
|
||
test('generateSceneScript embeds scene JSON and uses AnimatrPlayer', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'UniqRect77', width: 10, height: 10, fill: '#abc' })
|
||
s.addLayer(a.id)!
|
||
const src = generateSceneScript(s.scene)
|
||
expect(src).toContain('AnimatrPlayer.mount')
|
||
expect(src).toContain('UniqRect77')
|
||
expect(src).toContain('__animatrPending')
|
||
})
|
||
|
||
test('scene script does NOT inline the mount implementation', () => {
|
||
const s = makeFreshStore()
|
||
const src = generateSceneScript(s.scene)
|
||
expect(src.indexOf('function mount')).toBe(-1)
|
||
})
|
||
|
||
test('generateBundledScript contains runtime + scene boot', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'BundlR', width: 10, height: 10, fill: '#123' })
|
||
s.addLayer(a.id)!
|
||
const src = generateBundledScript(s.scene)
|
||
expect(src).toContain('function mount')
|
||
expect(src).toContain('AnimatrPlayer.mount')
|
||
expect(src).toContain('BundlR')
|
||
})
|
||
|
||
test('renderFrames yields fps*duration frames across the playback range', async () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(1)
|
||
s.project.fps = 10
|
||
// Tiny 4×4 canvas keeps the loop fast — we only care about counts.
|
||
s.scene.canvas.width = 4
|
||
s.scene.canvas.height = 4
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 4, height: 4, fill: '#111' })
|
||
s.addLayer(a.id)!
|
||
const frames: number[] = []
|
||
await renderFrames(s.scene, ({ frameIndex, width, height }) => {
|
||
expect(width).toBe(4)
|
||
expect(height).toBe(4)
|
||
frames.push(frameIndex)
|
||
})
|
||
// 1 second at 10fps → 10 frames (indices 0..9).
|
||
expect(frames.length).toBe(10)
|
||
expect(frames[0]).toBe(0)
|
||
expect(frames[frames.length - 1]).toBe(9)
|
||
})
|
||
|
||
test('renderFrames aborts when the signal fires', async () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(1)
|
||
s.project.fps = 10
|
||
s.scene.canvas.width = 4
|
||
s.scene.canvas.height = 4
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 4, height: 4, fill: '#111' })
|
||
s.addLayer(a.id)!
|
||
const controller = new AbortController()
|
||
let rendered = 0
|
||
const promise = renderFrames(
|
||
s.scene,
|
||
() => {
|
||
rendered += 1
|
||
if (rendered === 2) controller.abort()
|
||
},
|
||
{ signal: controller.signal },
|
||
)
|
||
let threw = false
|
||
try {
|
||
await promise
|
||
} catch (err) {
|
||
threw = (err as Error).name === 'AbortError'
|
||
}
|
||
expect(threw).toBe(true)
|
||
expect(rendered).toBeLessThan(10)
|
||
})
|
||
|
||
test('renderSceneToGif emits a GIF89a blob', async () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(0.5)
|
||
s.project.fps = 4
|
||
s.scene.canvas.width = 8
|
||
s.scene.canvas.height = 8
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 8, height: 8, fill: '#22cc88' })
|
||
s.addLayer(a.id)!
|
||
const blob = await renderSceneToGif(s.scene)
|
||
expect(blob.type).toBe('image/gif')
|
||
expect(blob.size > 0).toBe(true)
|
||
const bytes = new Uint8Array(await blob.arrayBuffer())
|
||
// GIF magic: "GIF" + "87a" or "89a"
|
||
const header = String.fromCharCode(bytes[0], bytes[1], bytes[2])
|
||
expect(header).toBe('GIF')
|
||
})
|
||
|
||
test('generateApngPreviewHtml embeds the data URL + checkerboard CSS', () => {
|
||
const dataUrl = 'data:image/png;base64,AAAA'
|
||
const html = generateApngPreviewHtml(dataUrl, 320, 180)
|
||
// Payload is embedded so the exported HTML works as a single file.
|
||
expect(html).toContain(dataUrl)
|
||
// Aspect ratio drives the stage box.
|
||
expect(html).toContain('aspect-ratio: 320 / 180')
|
||
// Checkerboard + toggle buttons are present.
|
||
expect(html).toContain('.stage.checker')
|
||
expect(html).toContain('data-bg="checker"')
|
||
expect(html).toContain('data-bg="solid-dark"')
|
||
expect(html).toContain('data-bg="solid-light"')
|
||
})
|
||
|
||
test('transparent background survives the APNG round-trip (alpha preserved)', async () => {
|
||
// A transparent canvas with no layers should encode a fully
|
||
// transparent animation. We decode the produced blob and verify
|
||
// at least one pixel's alpha channel is zero.
|
||
const s = makeFreshStore()
|
||
s.setDuration(0.5)
|
||
s.project.fps = 4
|
||
s.scene.canvas.width = 8
|
||
s.scene.canvas.height = 8
|
||
s.setCanvasBackground('transparent')
|
||
const blob = await renderSceneToApng(s.scene)
|
||
const buf = await blob.arrayBuffer()
|
||
const { default: UPNG } = await import('upng-js')
|
||
const decoded = UPNG.decode(buf)
|
||
const rgbaFrames = UPNG.toRGBA8(decoded)
|
||
expect(rgbaFrames.length > 0).toBe(true)
|
||
const first = new Uint8Array(rgbaFrames[0])
|
||
// Scan every pixel's alpha byte — at least one should be 0 since
|
||
// nothing painted on a transparent canvas.
|
||
let sawTransparent = false
|
||
for (let i = 3; i < first.length; i += 4) {
|
||
if (first[i] === 0) {
|
||
sawTransparent = true
|
||
break
|
||
}
|
||
}
|
||
expect(sawTransparent).toBe(true)
|
||
})
|
||
|
||
test('renderSceneToApng emits a PNG blob with an acTL animation chunk', async () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(0.5)
|
||
s.project.fps = 4
|
||
s.scene.canvas.width = 8
|
||
s.scene.canvas.height = 8
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 8, height: 8, fill: '#22cc88' })
|
||
s.addLayer(a.id)!
|
||
const blob = await renderSceneToApng(s.scene)
|
||
expect(blob.type).toBe('image/png')
|
||
expect(blob.size > 0).toBe(true)
|
||
const bytes = new Uint8Array(await blob.arrayBuffer())
|
||
// PNG magic: 0x89 0x50 0x4E 0x47 0x0D 0x0A 0x1A 0x0A
|
||
expect(bytes[0]).toBe(0x89)
|
||
expect(String.fromCharCode(bytes[1], bytes[2], bytes[3])).toBe('PNG')
|
||
// Scan for the 'acTL' chunk type — present only in animated PNGs.
|
||
let hasActl = false
|
||
for (let i = 8; i < bytes.length - 4; i++) {
|
||
if (
|
||
bytes[i] === 0x61 &&
|
||
bytes[i + 1] === 0x63 &&
|
||
bytes[i + 2] === 0x54 &&
|
||
bytes[i + 3] === 0x4c
|
||
) {
|
||
hasActl = true
|
||
break
|
||
}
|
||
}
|
||
expect(hasActl).toBe(true)
|
||
})
|
||
|
||
test('runtime lerpProps interpolates shadow + blur keyframes', () => {
|
||
// Regression: shadow properties (shadowBlur/Opacity/Angle/Distance/
|
||
// Color) and blur used to be dropped by the runtime's lerpProps,
|
||
// so an animation that fades a shadow in the editor played flat
|
||
// in the preview / exported HTML. The runtime must carry them
|
||
// through the per-pair interpolation.
|
||
const r = generateRuntimeScript()
|
||
expect(r).toContain('shadowBlur')
|
||
expect(r).toContain('shadowOpacity')
|
||
expect(r).toContain('shadowAngle')
|
||
expect(r).toContain('shadowDistance')
|
||
expect(r).toContain('shadowColor')
|
||
expect(r).toContain('out.blur')
|
||
})
|
||
|
||
test('runtime wraps each clip instance in translate + rotate + scale', () => {
|
||
// Pin that the exported runtime's per-clip draw loop honours
|
||
// ClipInstance.x/y/scaleX/scaleY/rotation — the canvas editor
|
||
// and the exported HTML stay visually identical for any
|
||
// clip-instance transform.
|
||
const r = generateRuntimeScript()
|
||
expect(r).toContain('inst2.x')
|
||
expect(r).toContain('inst2.y')
|
||
expect(r).toContain('inst2.scaleX')
|
||
expect(r).toContain('inst2.scaleY')
|
||
expect(r).toContain('inst2.rotation')
|
||
expect(r).toContain('ctx.rotate(irot2*Math.PI/180)')
|
||
expect(r).toContain('ctx.scale(isx2,isy2)')
|
||
})
|
||
|
||
test('runtime embeds a preloader (spinner + Promise.all) before starting the frame loop', () => {
|
||
// Loading big images, video metadata and audio `canplaythrough`
|
||
// must settle before the rAF kicks off — otherwise the first few
|
||
// frames stutter while the browser decodes. Structural pins:
|
||
// - A spinner <style> is injected.
|
||
// - A preloader overlay is appended.
|
||
// - Image/audio/video promises are awaited together.
|
||
// - canplaythrough is the event we wait on for audio.
|
||
const r = generateRuntimeScript()
|
||
expect(r).toContain('ruwio-spinner-style')
|
||
expect(r).toContain('ruwio-preloader')
|
||
expect(r).toContain('Promise.all')
|
||
expect(r).toContain('canplaythrough')
|
||
expect(r).toContain('preloadAudioClips')
|
||
expect(r).toContain('preloadBgVideos')
|
||
})
|
||
|
||
test('runtime honours separated position / rotation keyframes', () => {
|
||
// Parity regression: the editor samples separated position and
|
||
// rotation tracks via `sampleLayer()` when `layer.separated`;
|
||
// the runtime used to only look at the main keyframes array.
|
||
// Animations set up with Separate-toggled layers played flat
|
||
// in exports. Pin the helper and the per-branch merge.
|
||
const r = generateRuntimeScript()
|
||
expect(r).toContain('sampleKeyframeSet')
|
||
expect(r).toContain('layer.separated')
|
||
expect(r).toContain('layer.positionKeyframes')
|
||
expect(r).toContain('layer.rotationKeyframes')
|
||
})
|
||
|
||
test('runtime supports multi-scene switching (jumpToScene + applySceneSwitch)', () => {
|
||
// Exported bundles now carry the whole Project and handle
|
||
// jumpToScene internally. The runtime must expose a scene-
|
||
// switch helper and NOT fall through to the "unsupported"
|
||
// warning message anymore.
|
||
const r = generateRuntimeScript()
|
||
expect(r).toContain('applySceneSwitch')
|
||
expect(r).toContain('project.scenes')
|
||
expect(r).toContain('currentSceneId')
|
||
expect(r.indexOf('does not support multi-scene')).toBe(-1)
|
||
})
|
||
|
||
test('runtime rangeOut falls back to scene.duration (not last keyframe)', () => {
|
||
// Regression: the runtime used to fall back to `effectiveEnd(scene)`
|
||
// — the time of the last keyframe — when scene.outTime was unset.
|
||
// That made fullscreen preview loop at the last kf, ignoring any
|
||
// blank tail before the OUT mark and any still-frame-past-end
|
||
// extension. The fallback is now scene.duration, matching the
|
||
// editor's `playbackOut` getter.
|
||
const r = generateRuntimeScript()
|
||
// The old helper must be gone entirely.
|
||
expect(r.indexOf('effectiveEnd')).toBe(-1)
|
||
// And the current fallback must be scene.duration.
|
||
expect(r).toContain('scene.outTime==null?scene.duration:scene.outTime')
|
||
})
|
||
|
||
test('export comments and console prefix use RUWIO, not Animatr', () => {
|
||
// Rebrand: generated comment headers, the runtime's console warn
|
||
// prefix, and the example-HTML chrome all read RUWIO now. If any
|
||
// of these come back as "Animatr", we regressed.
|
||
const s = makeFreshStore()
|
||
const runtime = generateRuntimeScript()
|
||
expect(runtime).toContain('RUWIO runtime')
|
||
expect(runtime.indexOf('/* Animatr ')).toBe(-1)
|
||
// Console prefix used by the runtime's action evaluator.
|
||
expect(runtime).toContain("'[ruwio]'")
|
||
expect(runtime.indexOf("'[animatr]'")).toBe(-1)
|
||
|
||
const sceneScript = generateSceneScript(s.scene)
|
||
expect(sceneScript).toContain('RUWIO scene')
|
||
expect(sceneScript).toContain('ruwio-runtime.js')
|
||
|
||
const bundled = generateBundledScript(s.scene)
|
||
expect(bundled).toContain('RUWIO bundled export')
|
||
expect(bundled.indexOf('/* Animatr ')).toBe(-1)
|
||
})
|
||
|
||
test('generated example HTML is RUWIO-branded', () => {
|
||
// The example HTML is what users see when they drop the file
|
||
// into a browser. Title, heading, and the embed-usage snippet
|
||
// all need to carry the new brand.
|
||
const s = makeFreshStore()
|
||
const html = generateExampleHtml(s.scene)
|
||
expect(html).toContain('<title>RUWIO — Example</title>')
|
||
expect(html).toContain('<h1>RUWIO Export</h1>')
|
||
// The usage snippet now references ruwio-runtime.js.
|
||
expect(html).toContain('ruwio-runtime.js')
|
||
expect(html.indexOf('animatr-runtime.js')).toBe(-1)
|
||
})
|
||
|
||
test('generated scene script strips data-URL audio assets and warns', () => {
|
||
// Exports carry a single scene over the wire — embedding a data-URL
|
||
// mp3 in that payload would balloon the bundle. `jsonForExport`
|
||
// empties the src so the runtime still has the asset record (for
|
||
// name / duration lookups by audio clips) but won't try to load
|
||
// unshippable data.
|
||
const s = makeFreshStore()
|
||
s.addAudioAsset({ name: 'song', src: 'data:audio/mp3;base64,AAAA', duration: 4 })
|
||
let warned = false
|
||
const originalWarn = console.warn
|
||
console.warn = (...args: unknown[]) => {
|
||
const msg = args.map(String).join(' ')
|
||
if (msg.toLowerCase().includes('audio')) warned = true
|
||
}
|
||
try {
|
||
const sceneScript = generateSceneScript(s.scene)
|
||
expect(sceneScript.indexOf('data:audio/mp3')).toBe(-1)
|
||
// The asset record itself IS still present (its id + name + duration),
|
||
// just with an emptied src.
|
||
expect(sceneScript.indexOf('"kind":"audio"')).toBeGreaterThan(-1)
|
||
expect(warned).toBe(true)
|
||
} finally {
|
||
console.warn = originalWarn
|
||
}
|
||
})
|
||
|
||
test('generated scene script passes through external-URL audio intact', () => {
|
||
const s = makeFreshStore()
|
||
const ext = 'https://cdn.example.com/intro.mp3'
|
||
s.addAudioAsset({ name: 'intro', src: ext, duration: 3 })
|
||
const sceneScript = generateSceneScript(s.scene)
|
||
expect(sceneScript).toContain(ext)
|
||
})
|
||
|
||
test('opening the fullscreen preview pauses the editor playback', async () => {
|
||
// Two runtimes fighting for the same audio output = garbled.
|
||
// FullscreenPreview pauses `store.playing` so the editor's own
|
||
// useAudioRuntime quiets down while the preview owns playback.
|
||
const s = makeFreshStore()
|
||
s.play()
|
||
expect(s.playing).toBe(true)
|
||
const handle = mountComponent(FullscreenPreview, { modelValue: true })
|
||
await nextTick()
|
||
await nextTick()
|
||
expect(s.playing).toBe(false)
|
||
handle.unmount()
|
||
})
|
||
})
|
||
|
||
suite('ui — ExportDialog', () => {
|
||
// ExportDialog teleports through Modal so query through document
|
||
// rather than the handle's host root. Each test snapshots the
|
||
// live dialogs first and diffs after mount to isolate its own.
|
||
const snapshotDialogs = () =>
|
||
new Set(Array.from(document.querySelectorAll('[role="dialog"]')))
|
||
const dialogsAddedSince = (baseline: Set<Element>) =>
|
||
Array.from(document.querySelectorAll('[role="dialog"]')).filter(
|
||
(el) => !baseline.has(el),
|
||
) as HTMLElement[]
|
||
|
||
function findOptionByTitle(dialog: HTMLElement, title: string): HTMLButtonElement | null {
|
||
const items = dialog.querySelectorAll('button.opt')
|
||
for (const it of Array.from(items)) {
|
||
const t = it.querySelector('.opt-title')
|
||
if (t && t.textContent && t.textContent.includes(title)) {
|
||
return it as HTMLButtonElement
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
test('renders a GIF, APNG, and APNG preview option row', async () => {
|
||
makeFreshStore()
|
||
const baseline = snapshotDialogs()
|
||
const handle = mountComponent(ExportDialog, { modelValue: true })
|
||
await nextTick()
|
||
const added = dialogsAddedSince(baseline)
|
||
expect(added.length).toBe(1)
|
||
const dialog = added[0]
|
||
// The three image-export rows we just added must all be present.
|
||
expect(findOptionByTitle(dialog, 'Animated GIF')).toBeTruthy()
|
||
expect(findOptionByTitle(dialog, 'Animated PNG')).toBeTruthy()
|
||
expect(findOptionByTitle(dialog, i18nT('export.apngPreviewTitle'))).toBeTruthy()
|
||
handle.unmount()
|
||
})
|
||
|
||
test('clicking APNG preview transitions the dialog to the progress view', async () => {
|
||
// Tiny scene so the async render stays short. We assert the
|
||
// synchronous state transition right after the click — the
|
||
// template flips to the progress bar before the render loop
|
||
// finishes. The outstanding work is cancelled via the controller
|
||
// afterwards so no file gets saved.
|
||
const s = makeFreshStore()
|
||
s.setDuration(0.25)
|
||
s.project.fps = 4
|
||
s.scene.canvas.width = 8
|
||
s.scene.canvas.height = 8
|
||
const a = s.addAsset({
|
||
kind: 'rect',
|
||
name: 'Preview',
|
||
width: 8,
|
||
height: 8,
|
||
fill: '#22cc88',
|
||
})
|
||
s.addLayer(a.id)!
|
||
|
||
// Silence actual file downloads during the test.
|
||
const originalClick = HTMLAnchorElement.prototype.click
|
||
HTMLAnchorElement.prototype.click = function () {}
|
||
const baseline = snapshotDialogs()
|
||
const handle = mountComponent(ExportDialog, { modelValue: true })
|
||
try {
|
||
await nextTick()
|
||
const dialog = dialogsAddedSince(baseline)[0]
|
||
const btn = findOptionByTitle(dialog, i18nT('export.apngPreviewTitle'))
|
||
expect(btn).toBeTruthy()
|
||
btn!.click()
|
||
await nextTick()
|
||
const rendering = dialog.querySelector('.rendering') as HTMLElement | null
|
||
expect(rendering).toBeTruthy()
|
||
const label = dialog.querySelector('.r-text')?.textContent ?? ''
|
||
expect(label).toBe(i18nT('export.labelApngPreview'))
|
||
const cancelBtn = dialog.querySelector('.r-actions button') as HTMLButtonElement | null
|
||
cancelBtn?.click()
|
||
await nextTick()
|
||
} finally {
|
||
HTMLAnchorElement.prototype.click = originalClick
|
||
handle.unmount()
|
||
}
|
||
})
|
||
})
|
||
|
||
suite('playback — transport', () => {
|
||
test('togglePlay toggles playing state', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'Play', width: 60, height: 60, fill: '#55cc66' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 2, { x: 500 })
|
||
await tick()
|
||
s.togglePlay()
|
||
await tick()
|
||
expect(s.playing).toBe(true)
|
||
s.togglePlay()
|
||
await tick()
|
||
expect(s.playing).toBe(false)
|
||
})
|
||
|
||
test('togglePlay rewinds if at end', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'Rew', width: 60, height: 60, fill: '#cc5555' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 2, { x: 600 })
|
||
s.setPlayhead(s.playbackOut)
|
||
await tick()
|
||
s.togglePlay()
|
||
expect(s.playhead).toBe(s.playbackIn)
|
||
expect(s.playing).toBe(true)
|
||
s.pause()
|
||
await tick()
|
||
})
|
||
|
||
test('tick advances playhead while playing', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'Tk', width: 60, height: 60, fill: '#9977dd' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 5, { x: 800 })
|
||
s.play()
|
||
s.tick(1)
|
||
s.pause()
|
||
expect(s.playhead).toBeCloseTo(1, 3)
|
||
await tick()
|
||
})
|
||
|
||
test('tick stops at playbackOut', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'End', width: 60, height: 60, fill: '#dd7799' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 2, { x: 400 })
|
||
s.play()
|
||
await tick()
|
||
s.tick(s.scene.duration + 1)
|
||
await tick()
|
||
expect(s.playing).toBe(false)
|
||
expect(s.playhead).toBe(s.playbackOut)
|
||
})
|
||
})
|
||
|
||
suite('project file — save & load roundtrip', () => {
|
||
test('serialize + parse roundtrip preserves scene', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'PF', width: 80, height: 60, fill: '#66ddaa' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 1, { x: 400, rotation: 30 })
|
||
s.setDuration(7)
|
||
await tick()
|
||
const text = serializeProject(s.project)
|
||
const parsed = parseProject(text)
|
||
expect(parsed.duration).toBe(7)
|
||
expect(parsed.assets).toHaveLength(1)
|
||
const page = parsed.scenes[0]
|
||
expect(page.layers).toHaveLength(1)
|
||
expect(page.layers[0].keyframes.find((k) => k.time === 1)?.props.x).toBe(400)
|
||
})
|
||
|
||
test('loadProject replaces store state visibly', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'L1', width: 80, height: 60, fill: '#aa6666' })
|
||
s.addLayer(a.id)!
|
||
await tick()
|
||
const dump = serializeProject(s.project)
|
||
s.$reset()
|
||
await tick()
|
||
s.loadProject(parseProject(dump))
|
||
await tick()
|
||
expect(s.scene.layers).toHaveLength(1)
|
||
expect(s.scene.assets).toHaveLength(1)
|
||
expect(s.selectedLayerId).toBeNull()
|
||
expect(s.playhead).toBe(0)
|
||
expect(s.playing).toBe(false)
|
||
})
|
||
|
||
test('parseProject rejects non-JSON', () => {
|
||
let caught: unknown = null
|
||
try {
|
||
parseProject('not json at all')
|
||
} catch (e) {
|
||
caught = e
|
||
}
|
||
expect(caught instanceof ProjectFileError).toBe(true)
|
||
})
|
||
|
||
test('parseProject rejects foreign JSON without magic', () => {
|
||
let caught: unknown = null
|
||
try {
|
||
parseProject(JSON.stringify({ foo: 'bar' }))
|
||
} catch (e) {
|
||
caught = e
|
||
}
|
||
expect(caught instanceof ProjectFileError).toBe(true)
|
||
})
|
||
|
||
test('parseProject rejects missing scene fields', () => {
|
||
let caught: unknown = null
|
||
try {
|
||
parseProject(JSON.stringify({ magic: 'animatr-project', fileVersion: 1, scene: {} }))
|
||
} catch (e) {
|
||
caught = e
|
||
}
|
||
expect(caught instanceof ProjectFileError).toBe(true)
|
||
})
|
||
|
||
test('legacy `animatr-project` magic still parses (backward compat)', () => {
|
||
// When we rebranded to RUWIO we kept the on-disk magic as
|
||
// `animatr-project` so older exports continue to load. Pin that
|
||
// so a future cleanup doesn't accidentally break existing user
|
||
// files on disk.
|
||
const s = makeFreshStore()
|
||
s.addAsset({ kind: 'rect', name: 'Legacy', width: 10, height: 10, fill: '#123' })
|
||
const legacyPayload = JSON.parse(serializeProject(s.project))
|
||
expect(legacyPayload.magic).toBe('animatr-project')
|
||
// And a fully-formed legacy file parses without error.
|
||
const parsed = parseProject(JSON.stringify(legacyPayload))
|
||
expect(parsed.assets.some((a) => a.name === 'Legacy')).toBe(true)
|
||
})
|
||
|
||
test('parseProject error mentions RUWIO, not Animatr', () => {
|
||
// The user-visible error when a non-project JSON is dropped in
|
||
// reads "Not a RUWIO project file …" now. If you rebrand again
|
||
// remember to update this test alongside the string.
|
||
let msg = ''
|
||
try {
|
||
parseProject(JSON.stringify({ not: 'a project' }))
|
||
} catch (e) {
|
||
msg = (e as Error).message
|
||
}
|
||
expect(msg).toContain('RUWIO')
|
||
expect(msg.toLowerCase().includes('animatr')).toBe(false)
|
||
})
|
||
|
||
test('parseProject wraps legacy { scene } payload into Project', () => {
|
||
// Pre-v0.9 files had `{ magic, fileVersion: 1, scene }`. The new
|
||
// parser lifts those into a one-page Project so users don't have
|
||
// to re-save. Canvas + assets + layers must all land intact.
|
||
const s = makeFreshStore()
|
||
s.addAsset({ kind: 'rect', name: 'LegacyRect', width: 10, height: 10, fill: '#123' })
|
||
const legacy = {
|
||
magic: 'animatr-project',
|
||
fileVersion: 1,
|
||
scene: JSON.parse(JSON.stringify(s.scene)),
|
||
}
|
||
const parsed = parseProject(JSON.stringify(legacy))
|
||
expect(Array.isArray(parsed.scenes)).toBe(true)
|
||
expect(parsed.scenes).toHaveLength(1)
|
||
expect(parsed.activeSceneId).toBe(parsed.scenes[0].id)
|
||
expect(parsed.assets.some((a) => a.name === 'LegacyRect')).toBe(true)
|
||
expect(parsed.canvas.width).toBe(s.project.canvas.width)
|
||
})
|
||
})
|
||
|
||
suite('scene store — project & scenes', () => {
|
||
test('merged `scene` getter mirrors the active page\'s arrays by reference', () => {
|
||
// Reads through store.scene must share the SAME array references
|
||
// as the active ScenePage so `store.scene.layers.push(x)` hits
|
||
// `activeScene.layers`. If they drift, mutations silently vanish.
|
||
const s = makeFreshStore()
|
||
expect(s.scene.layers).toBe(s.activeScene.layers)
|
||
expect(s.scene.clipInstances).toBe(s.activeScene.clipInstances)
|
||
expect(s.scene.trackOrder).toBe(s.activeScene.trackOrder)
|
||
// Shared fields (asset library, canvas) come from the project.
|
||
expect(s.scene.assets).toBe(s.project.assets)
|
||
expect(s.scene.canvas).toBe(s.project.canvas)
|
||
expect(s.scene.clips).toBe(s.project.clips)
|
||
})
|
||
|
||
test('createBlankProject seeds exactly one page named "Scene 1"', () => {
|
||
const p = createBlankProject()
|
||
expect(p.scenes).toHaveLength(1)
|
||
expect(p.scenes[0].name).toBe('Scene 1')
|
||
expect(p.activeSceneId).toBe(p.scenes[0].id)
|
||
})
|
||
|
||
test('addScene appends, activates, and picks a sensible default name', () => {
|
||
const s = makeFreshStore()
|
||
expect(s.project.scenes).toHaveLength(1)
|
||
const two = s.addScene()
|
||
expect(s.project.scenes).toHaveLength(2)
|
||
expect(two.name).toBe('Scene 2')
|
||
expect(s.project.activeSceneId).toBe(two.id)
|
||
const three = s.addScene()
|
||
expect(three.name).toBe('Scene 3')
|
||
})
|
||
|
||
test('addScene respects an explicit trimmed name', () => {
|
||
const s = makeFreshStore()
|
||
const intro = s.addScene(' Intro ')
|
||
expect(intro.name).toBe('Intro')
|
||
})
|
||
|
||
test('switching scenes swaps layer stacks without touching the shared library', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'Shared', width: 10, height: 10, fill: '#abc' })
|
||
const l1 = s.addLayer(a.id, 'in-scene-1')!
|
||
const two = s.addScene('Two')
|
||
// After switch: active page has no layers; the asset library stays.
|
||
expect(s.scene.layers).toHaveLength(0)
|
||
expect(s.scene.assets.some((x) => x.id === a.id)).toBe(true)
|
||
const l2 = s.addLayer(a.id, 'in-scene-2')!
|
||
// Flip back to scene 1 and confirm its layer is intact.
|
||
s.switchScene(s.project.scenes[0].id)
|
||
expect(s.scene.layers).toHaveLength(1)
|
||
expect(s.scene.layers[0].id).toBe(l1.id)
|
||
// And scene 2 still owns its own layer.
|
||
s.switchScene(two.id)
|
||
expect(s.scene.layers).toHaveLength(1)
|
||
expect(s.scene.layers[0].id).toBe(l2.id)
|
||
})
|
||
|
||
test('switchScene clears editor context (selection, clip-edit, drawing)', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'SW', width: 10, height: 10, fill: '#111' })
|
||
const l = s.addLayer(a.id)!
|
||
s.selectLayer(l.id)
|
||
const clip = s.addClip('C')
|
||
s.activeClipId = clip.id
|
||
const two = s.addScene()
|
||
// addScene already routed through switchScene — cross-check.
|
||
expect(s.selectedLayerId).toBeNull()
|
||
expect(s.selectedLayerIds).toHaveLength(0)
|
||
expect(s.activeClipId).toBeNull()
|
||
expect(s.activeButtonAssetId).toBeNull()
|
||
expect(s.project.activeSceneId).toBe(two.id)
|
||
})
|
||
|
||
test('switchScene with resetPlayhead: true rewinds to the in-point', () => {
|
||
// Script and button jumps should restart the target scene at
|
||
// its in-point — otherwise chapter-like transitions play from
|
||
// wherever the previous scene was paused.
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
const two = s.addScene('Two')
|
||
s.setPlayhead(4.5)
|
||
expect(s.playhead).toBe(4.5)
|
||
s.switchScene(two.id, { resetPlayhead: true })
|
||
expect(s.playhead).toBe(0)
|
||
})
|
||
|
||
test('switchScene leaves playback state alone — editor navigation does not force play', () => {
|
||
// The editor treats scene-switch as a navigation gesture. The
|
||
// fullscreen preview is the one that auto-plays on switch (via
|
||
// its activeSceneId watcher). If this contract regresses, the
|
||
// user would find drawer clicks silently starting playback.
|
||
const s = makeFreshStore()
|
||
const second = s.addScene('Two')
|
||
const firstId = s.project.scenes[0].id
|
||
// Paused → stays paused across a switch.
|
||
s.pause()
|
||
s.switchScene(firstId)
|
||
expect(s.playing).toBe(false)
|
||
expect(s.project.activeSceneId).toBe(firstId)
|
||
// Playing → stays playing.
|
||
s.play()
|
||
s.switchScene(second.id)
|
||
expect(s.playing).toBe(true)
|
||
expect(s.project.activeSceneId).toBe(second.id)
|
||
})
|
||
|
||
test('switchScene returns false for an unknown id and leaves state alone', () => {
|
||
const s = makeFreshStore()
|
||
const before = s.project.activeSceneId
|
||
const ok = s.switchScene('nope-no-such-scene')
|
||
expect(ok).toBe(false)
|
||
expect(s.project.activeSceneId).toBe(before)
|
||
})
|
||
|
||
test('renameScene trims and rejects empty names', () => {
|
||
const s = makeFreshStore()
|
||
const id = s.project.activeSceneId
|
||
expect(s.renameScene(id, ' Hello ')).toBe(true)
|
||
expect(s.project.scenes[0].name).toBe('Hello')
|
||
expect(s.renameScene(id, ' ')).toBe(false)
|
||
expect(s.project.scenes[0].name).toBe('Hello')
|
||
})
|
||
|
||
test('removeScene refuses to delete the last remaining page', () => {
|
||
const s = makeFreshStore()
|
||
const only = s.project.activeSceneId
|
||
expect(s.removeScene(only)).toBe(false)
|
||
expect(s.project.scenes).toHaveLength(1)
|
||
})
|
||
|
||
test('removeScene on the last page activates its predecessor', () => {
|
||
// [A, B, C] active=C → remove C → [A, B] active=B.
|
||
// The "neighbour" policy slides the active marker back one slot
|
||
// when there's nothing further down the list.
|
||
const s = makeFreshStore()
|
||
const a = s.project.scenes[0].id
|
||
const b = s.addScene('B').id
|
||
const c = s.addScene('C').id
|
||
expect(s.project.activeSceneId).toBe(c)
|
||
expect(s.removeScene(c)).toBe(true)
|
||
expect(s.project.activeSceneId).toBe(b)
|
||
expect(s.project.scenes.map((p) => p.id)).toEqual([a, b])
|
||
})
|
||
|
||
test('removeScene on a middle page activates the next one', () => {
|
||
// [A, B, C] active=B → remove B → [A, C] active=C.
|
||
// Forward is the default direction; previous is only used when
|
||
// there is no next.
|
||
const s = makeFreshStore()
|
||
const a = s.project.scenes[0].id
|
||
const b = s.addScene('B').id
|
||
const c = s.addScene('C').id
|
||
s.switchScene(b)
|
||
expect(s.project.activeSceneId).toBe(b)
|
||
expect(s.removeScene(b)).toBe(true)
|
||
expect(s.project.activeSceneId).toBe(c)
|
||
expect(s.project.scenes.map((p) => p.id)).toEqual([a, c])
|
||
})
|
||
|
||
test('removeScene on an inactive page leaves the active one untouched', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.project.scenes[0].id
|
||
const b = s.addScene('B').id
|
||
const c = s.addScene('C').id
|
||
expect(s.project.activeSceneId).toBe(c)
|
||
expect(s.removeScene(b)).toBe(true)
|
||
expect(s.project.activeSceneId).toBe(c)
|
||
expect(s.project.scenes.map((p) => p.id)).toEqual([a, c])
|
||
})
|
||
|
||
test('duplicateScene deep-clones with fresh ids and activates the copy', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'D', width: 10, height: 10, fill: '#abc' })
|
||
const l = s.addLayer(a.id, 'Orig')!
|
||
s.upsertKeyframe(l.id, 1, { x: 20 })
|
||
const origId = s.project.activeSceneId
|
||
const origLayerId = l.id
|
||
const origKfIds = new Set(l.keyframes.map((k) => k.id))
|
||
|
||
const copy = s.duplicateScene(origId)!
|
||
expect(copy.id === origId).toBe(false)
|
||
expect(copy.name).toBe(`${s.project.scenes[0].name} copy`)
|
||
expect(s.project.activeSceneId).toBe(copy.id)
|
||
// Clone sits directly after the original.
|
||
const idx = s.project.scenes.findIndex((p) => p.id === origId)
|
||
expect(s.project.scenes[idx + 1].id).toBe(copy.id)
|
||
// Fresh layer + keyframe ids.
|
||
const clonedLayer = copy.layers[0]
|
||
expect(clonedLayer.id === origLayerId).toBe(false)
|
||
for (const k of clonedLayer.keyframes) expect(origKfIds.has(k.id)).toBe(false)
|
||
// Deep-cloned — mutating the clone leaves original untouched.
|
||
clonedLayer.base.x = 999
|
||
const origPage = s.project.scenes.find((p) => p.id === origId)!
|
||
expect(origPage.layers[0].base.x === 999).toBe(false)
|
||
})
|
||
|
||
test('projectFromLegacyScene wraps a single Scene into a one-page Project', () => {
|
||
const s = makeFreshStore()
|
||
s.addAsset({ kind: 'rect', name: 'LG', width: 10, height: 10, fill: '#abc' })
|
||
const project = projectFromLegacyScene(JSON.parse(JSON.stringify(s.scene)), 'My Page')
|
||
expect(project.scenes).toHaveLength(1)
|
||
expect(project.scenes[0].name).toBe('My Page')
|
||
expect(project.activeSceneId).toBe(project.scenes[0].id)
|
||
expect(project.assets.some((a) => a.name === 'LG')).toBe(true)
|
||
})
|
||
})
|
||
|
||
suite('history — undo/redo', () => {
|
||
test('canUndo starts false; becomes true after a committed mutation', async () => {
|
||
const s = makeFreshStore()
|
||
const h = createHistory(s, { debounceMs: 10 })
|
||
h.start()
|
||
expect(h.canUndo.value).toBe(false)
|
||
const a = s.addAsset({ kind: 'rect', name: 'H1', width: 10, height: 10, fill: '#000' })
|
||
s.addLayer(a.id)!
|
||
await tick(60)
|
||
expect(h.canUndo.value).toBe(true)
|
||
h.stop()
|
||
})
|
||
|
||
test('undo reverts a mutation, redo reapplies', async () => {
|
||
const s = makeFreshStore()
|
||
const h = createHistory(s, { debounceMs: 10 })
|
||
h.start()
|
||
const a = s.addAsset({ kind: 'rect', name: 'H2', width: 10, height: 10, fill: '#000' })
|
||
s.addLayer(a.id)!
|
||
await tick(60)
|
||
expect(s.scene.layers).toHaveLength(1)
|
||
h.undo()
|
||
await tick(20)
|
||
expect(s.scene.layers).toHaveLength(0)
|
||
expect(h.canRedo.value).toBe(true)
|
||
h.redo()
|
||
await tick(20)
|
||
expect(s.scene.layers).toHaveLength(1)
|
||
h.stop()
|
||
})
|
||
|
||
test('rapid mutations within debounce window collapse into one step', async () => {
|
||
const s = makeFreshStore()
|
||
const h = createHistory(s, { debounceMs: 50 })
|
||
h.start()
|
||
const a = s.addAsset({ kind: 'rect', name: 'H3', width: 10, height: 10, fill: '#000' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 1, { x: 100 })
|
||
s.upsertKeyframe(l.id, 1, { x: 200 })
|
||
s.upsertKeyframe(l.id, 1, { x: 300 })
|
||
await tick(120)
|
||
h.undo()
|
||
await tick(20)
|
||
expect(s.scene.layers).toHaveLength(0)
|
||
h.stop()
|
||
})
|
||
|
||
test('mutation after undo drops the redo branch', async () => {
|
||
const s = makeFreshStore()
|
||
const h = createHistory(s, { debounceMs: 10 })
|
||
h.start()
|
||
const a = s.addAsset({ kind: 'rect', name: 'H4', width: 10, height: 10, fill: '#000' })
|
||
s.addLayer(a.id)!
|
||
await tick(40)
|
||
h.undo()
|
||
await tick(20)
|
||
expect(h.canRedo.value).toBe(true)
|
||
const b = s.addAsset({ kind: 'circle', name: 'H4c', radius: 10, fill: '#fff' })
|
||
s.addLayer(b.id)!
|
||
await tick(40)
|
||
expect(h.canRedo.value).toBe(false)
|
||
h.stop()
|
||
})
|
||
|
||
test('resetTo clears history and seeds initial snapshot', async () => {
|
||
const s = makeFreshStore()
|
||
const h = createHistory(s, { debounceMs: 10 })
|
||
h.start()
|
||
const a = s.addAsset({ kind: 'rect', name: 'H5', width: 10, height: 10, fill: '#000' })
|
||
s.addLayer(a.id)!
|
||
await tick(40)
|
||
expect(h.canUndo.value).toBe(true)
|
||
h.resetTo(s.scene)
|
||
expect(h.canUndo.value).toBe(false)
|
||
expect(h.canRedo.value).toBe(false)
|
||
h.stop()
|
||
})
|
||
|
||
test('undo clamps playhead when target scene has shorter duration', async () => {
|
||
const s = makeFreshStore()
|
||
const h = createHistory(s, { debounceMs: 10 })
|
||
h.start()
|
||
s.setDuration(3)
|
||
await tick(40)
|
||
s.setDuration(10)
|
||
s.setPlayhead(8)
|
||
await tick(40)
|
||
h.undo()
|
||
await tick(20)
|
||
expect(s.scene.duration).toBe(3)
|
||
expect(s.playhead).toBe(3)
|
||
h.stop()
|
||
})
|
||
|
||
test('undo after switchScene restores the previous activeSceneId', async () => {
|
||
// History snapshots the whole project including activeSceneId, so
|
||
// a scene switch is itself an undoable step. If this regresses the
|
||
// user will notice a sudden loss of "undo back to where I was".
|
||
const s = makeFreshStore()
|
||
const h = createHistory(s, { debounceMs: 10 })
|
||
h.start()
|
||
const a = s.project.scenes[0].id
|
||
const b = s.addScene('B').id
|
||
await tick(40)
|
||
// Now switch back to A; that's a fresh snapshottable step.
|
||
s.switchScene(a)
|
||
await tick(40)
|
||
expect(s.project.activeSceneId).toBe(a)
|
||
h.undo()
|
||
await tick(20)
|
||
expect(s.project.activeSceneId).toBe(b)
|
||
h.stop()
|
||
})
|
||
|
||
test('undo after addScene removes the just-added page', async () => {
|
||
const s = makeFreshStore()
|
||
const h = createHistory(s, { debounceMs: 10 })
|
||
h.start()
|
||
expect(s.project.scenes).toHaveLength(1)
|
||
const origId = s.project.activeSceneId
|
||
s.addScene('B')
|
||
await tick(40)
|
||
expect(s.project.scenes).toHaveLength(2)
|
||
h.undo()
|
||
await tick(20)
|
||
expect(s.project.scenes).toHaveLength(1)
|
||
expect(s.project.activeSceneId).toBe(origId)
|
||
h.stop()
|
||
})
|
||
|
||
test('undo after removeScene restores the deleted page content', async () => {
|
||
const s = makeFreshStore()
|
||
const h = createHistory(s, { debounceMs: 10 })
|
||
h.start()
|
||
const a = s.addAsset({ kind: 'rect', name: 'U', width: 10, height: 10, fill: '#abc' })
|
||
const two = s.addScene('Two')
|
||
s.addLayer(a.id, 'only-in-two')
|
||
await tick(40)
|
||
expect(s.scene.layers).toHaveLength(1)
|
||
const twoId = two.id
|
||
// Flip away, then delete. Both are undoable steps.
|
||
s.switchScene(s.project.scenes[0].id)
|
||
await tick(40)
|
||
s.removeScene(twoId)
|
||
await tick(40)
|
||
expect(s.project.scenes.some((p) => p.id === twoId)).toBe(false)
|
||
h.undo()
|
||
await tick(20)
|
||
const restored = s.project.scenes.find((p) => p.id === twoId)
|
||
expect(restored).toBeTruthy()
|
||
expect(restored!.layers).toHaveLength(1)
|
||
expect(restored!.layers[0].name).toBe('only-in-two')
|
||
h.stop()
|
||
})
|
||
|
||
test('max history cap drops oldest entries', async () => {
|
||
const s = makeFreshStore()
|
||
const h = createHistory(s, { debounceMs: 10, max: 3 })
|
||
h.start()
|
||
s.setDuration(2)
|
||
await tick(30)
|
||
s.setDuration(3)
|
||
await tick(30)
|
||
s.setDuration(4)
|
||
await tick(30)
|
||
s.setDuration(5)
|
||
await tick(30)
|
||
h.undo()
|
||
h.undo()
|
||
h.undo()
|
||
await tick(20)
|
||
expect(s.scene.duration).toBeGreaterThan(2)
|
||
h.stop()
|
||
})
|
||
})
|
||
|
||
suite('ui — Modal', () => {
|
||
// Modal teleports to document.body. If the running app has a
|
||
// dialog open (welcome / export / settings), a plain
|
||
// document.querySelector('[role="dialog"]') would match that
|
||
// instead of what the test just mounted. Snapshot the live
|
||
// dialogs first and diff after mount so each assertion only
|
||
// considers the nodes this test created.
|
||
const snapshotDialogs = () =>
|
||
new Set(Array.from(document.querySelectorAll('[role="dialog"]')))
|
||
const dialogsAddedSince = (baseline: Set<Element>) =>
|
||
Array.from(document.querySelectorAll('[role="dialog"]')).filter(
|
||
(el) => !baseline.has(el),
|
||
)
|
||
|
||
test('renders nothing when modelValue is false', async () => {
|
||
const baseline = snapshotDialogs()
|
||
const handle = mountComponent(Modal, { modelValue: false, title: 'X' })
|
||
await nextTick()
|
||
expect(dialogsAddedSince(baseline).length).toBe(0)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('teleports backdrop + dialog to body when open, renders title', async () => {
|
||
const baseline = snapshotDialogs()
|
||
const handle = mountComponent(Modal, { modelValue: true, title: 'Hallo Modal' })
|
||
await nextTick()
|
||
const added = dialogsAddedSince(baseline)
|
||
expect(added.length).toBe(1)
|
||
const dialog = added[0] as HTMLElement
|
||
expect(dialog.textContent?.includes('Hallo Modal')).toBe(true)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('Esc triggers update:modelValue false', async () => {
|
||
let latest: boolean | null = null
|
||
const handle = mountComponent(Modal, {
|
||
modelValue: true,
|
||
title: 'X',
|
||
'onUpdate:modelValue': (v: boolean) => {
|
||
latest = v
|
||
},
|
||
})
|
||
await nextTick()
|
||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
||
await nextTick()
|
||
expect(latest).toBe(false)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('Esc does nothing when closeOnEsc is false', async () => {
|
||
let latest: boolean | null = null
|
||
const handle = mountComponent(Modal, {
|
||
modelValue: true,
|
||
title: 'X',
|
||
closeOnEsc: false,
|
||
'onUpdate:modelValue': (v: boolean) => {
|
||
latest = v
|
||
},
|
||
})
|
||
await nextTick()
|
||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
||
await nextTick()
|
||
expect(latest).toBeNull()
|
||
handle.unmount()
|
||
})
|
||
|
||
test('dismissable=false hides close button and swallows Esc', async () => {
|
||
let latest: boolean | null = null
|
||
const baseline = snapshotDialogs()
|
||
const handle = mountComponent(Modal, {
|
||
modelValue: true,
|
||
title: 'Busy',
|
||
dismissable: false,
|
||
'onUpdate:modelValue': (v: boolean) => {
|
||
latest = v
|
||
},
|
||
})
|
||
await nextTick()
|
||
const added = dialogsAddedSince(baseline)
|
||
expect(added.length).toBe(1)
|
||
const dialog = added[0] as HTMLElement
|
||
const btn = dialog.querySelector('header button')
|
||
expect(btn).toBeNull()
|
||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
||
await nextTick()
|
||
expect(latest).toBeNull()
|
||
handle.unmount()
|
||
})
|
||
|
||
test('renders footer slot content', async () => {
|
||
const handle = mountComponent(
|
||
Modal,
|
||
{ modelValue: true, title: 'X' },
|
||
{
|
||
footer: () => [h('button', { class: 'my-footer-btn' }, 'Fertig')],
|
||
},
|
||
)
|
||
await nextTick()
|
||
const btn = document.querySelector('.my-footer-btn')
|
||
expect(btn).toBeTruthy()
|
||
expect(btn!.textContent).toBe('Fertig')
|
||
handle.unmount()
|
||
})
|
||
})
|
||
|
||
suite('ui — HelpDialog', () => {
|
||
const snapshotDialogs = () =>
|
||
new Set(Array.from(document.querySelectorAll('[role="dialog"]')))
|
||
const dialogsAddedSince = (baseline: Set<Element>) =>
|
||
Array.from(document.querySelectorAll('[role="dialog"]')).filter(
|
||
(el) => !baseline.has(el),
|
||
) as HTMLElement[]
|
||
|
||
test('opens on modelValue=true and renders a TOC with all 13 chapters', async () => {
|
||
const baseline = snapshotDialogs()
|
||
const handle = mountComponent(HelpDialog, { modelValue: true })
|
||
await nextTick()
|
||
const added = dialogsAddedSince(baseline)
|
||
expect(added.length).toBe(1)
|
||
const tocItems = added[0].querySelectorAll('.toc-item')
|
||
expect(tocItems.length).toBe(13)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('defaults to the Getting started chapter', async () => {
|
||
const baseline = snapshotDialogs()
|
||
const handle = mountComponent(HelpDialog, { modelValue: true })
|
||
await nextTick()
|
||
const dialog = dialogsAddedSince(baseline)[0]
|
||
const activeToc = dialog.querySelector('.toc-item.active') as HTMLElement
|
||
expect(activeToc).toBeTruthy()
|
||
expect(activeToc.textContent?.trim()).toBe('Getting started')
|
||
// Content heading matches.
|
||
const heading = dialog.querySelector('.chapter h3')
|
||
expect(heading?.textContent?.trim()).toBe('Getting started')
|
||
handle.unmount()
|
||
})
|
||
|
||
test('clicking a TOC item swaps the right-pane content', async () => {
|
||
const baseline = snapshotDialogs()
|
||
const handle = mountComponent(HelpDialog, { modelValue: true })
|
||
await nextTick()
|
||
const dialog = dialogsAddedSince(baseline)[0]
|
||
const items = Array.from(dialog.querySelectorAll('.toc-item')) as HTMLButtonElement[]
|
||
const scripting = items.find((el) => el.textContent?.includes('Script language'))
|
||
expect(scripting).toBeTruthy()
|
||
scripting!.click()
|
||
await nextTick()
|
||
const heading = dialog.querySelector('.chapter h3')
|
||
expect(heading?.textContent?.trim()).toBe('Script language')
|
||
// Active highlight moved.
|
||
const active = dialog.querySelector('.toc-item.active')
|
||
expect(active?.textContent?.trim()).toBe('Script language')
|
||
handle.unmount()
|
||
})
|
||
|
||
test('Script language chapter documents every DSL command', async () => {
|
||
// Pin the parser/help alignment — if a new command ships in
|
||
// scriptDsl.ts, this test reminds us to document it here too.
|
||
const baseline = snapshotDialogs()
|
||
const handle = mountComponent(HelpDialog, { modelValue: true })
|
||
await nextTick()
|
||
const dialog = dialogsAddedSince(baseline)[0]
|
||
const items = Array.from(dialog.querySelectorAll('.toc-item')) as HTMLButtonElement[]
|
||
items.find((el) => el.textContent?.includes('Script language'))!.click()
|
||
await nextTick()
|
||
const body = dialog.querySelector('.chapter')!.textContent ?? ''
|
||
expect(body).toContain('play')
|
||
expect(body).toContain('pause')
|
||
expect(body).toContain('rewind')
|
||
expect(body).toContain('jump')
|
||
expect(body).toContain('#')
|
||
expect(body).toContain('@')
|
||
expect(body).toContain('//')
|
||
handle.unmount()
|
||
})
|
||
|
||
test('Shortcuts chapter renders one row per shortcut with a kbd key', async () => {
|
||
const baseline = snapshotDialogs()
|
||
const handle = mountComponent(HelpDialog, { modelValue: true })
|
||
await nextTick()
|
||
const dialog = dialogsAddedSince(baseline)[0]
|
||
const items = Array.from(dialog.querySelectorAll('.toc-item')) as HTMLButtonElement[]
|
||
items.find((el) => el.textContent?.includes('Keyboard shortcuts'))!.click()
|
||
await nextTick()
|
||
const rows = dialog.querySelectorAll('.shortcuts li')
|
||
expect(rows.length).toBeGreaterThan(9)
|
||
// Every row ends with a kbd element.
|
||
for (const li of Array.from(rows)) {
|
||
expect(li.querySelector('kbd')).toBeTruthy()
|
||
}
|
||
handle.unmount()
|
||
})
|
||
|
||
test('reopening resets to the first chapter (no stale selection)', async () => {
|
||
// Regression: a user who leaves the dialog on "Script language"
|
||
// and reopens it expects to land on the intro, not mid-content.
|
||
const open = ref(true)
|
||
const Wrapper = defineComponent({
|
||
render: () => h(HelpDialog, { modelValue: open.value }),
|
||
})
|
||
const handle = mountComponent(Wrapper, {})
|
||
await nextTick()
|
||
const items = Array.from(
|
||
document.querySelectorAll('.toc-item'),
|
||
) as HTMLButtonElement[]
|
||
items.find((el) => el.textContent?.includes('Script language'))!.click()
|
||
await nextTick()
|
||
open.value = false
|
||
await nextTick()
|
||
open.value = true
|
||
await nextTick()
|
||
await nextTick()
|
||
const heading = document.querySelector('.chapter h3')
|
||
expect(heading?.textContent?.trim()).toBe('Getting started')
|
||
handle.unmount()
|
||
})
|
||
})
|
||
|
||
suite('ui — IconButton', () => {
|
||
test('renders a button element', async () => {
|
||
// IconButton now uses the custom v-tooltip directive instead of
|
||
// the native `title` attribute. The directive still sets an
|
||
// `aria-label` so screen readers get the tooltip text — that's
|
||
// the stable contract this test pins.
|
||
const handle = mountComponent(IconButton, { title: 'hi' })
|
||
await nextTick()
|
||
const btn = handle.host.querySelector('button')
|
||
expect(btn).toBeTruthy()
|
||
expect(btn!.getAttribute('aria-label')).toBe('hi')
|
||
handle.unmount()
|
||
})
|
||
|
||
test('applies variant + size + active classes', async () => {
|
||
const handle = mountComponent(IconButton, {
|
||
variant: 'primary',
|
||
size: 'md',
|
||
active: true,
|
||
})
|
||
await nextTick()
|
||
const btn = handle.host.querySelector('button')!
|
||
expect(btn.className.includes('variant-primary')).toBe(true)
|
||
expect(btn.className.includes('size-md')).toBe(true)
|
||
expect(btn.className.includes('active')).toBe(true)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('disabled prop disables the native button', async () => {
|
||
const handle = mountComponent(IconButton, { disabled: true })
|
||
await nextTick()
|
||
const btn = handle.host.querySelector('button')!
|
||
expect(btn.disabled).toBe(true)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('renders default slot in place of icon prop', async () => {
|
||
const handle = mountComponent(
|
||
IconButton,
|
||
{},
|
||
{ default: () => [h('span', { class: 'custom-icon' }, 'X')] },
|
||
)
|
||
await nextTick()
|
||
const span = handle.host.querySelector('.custom-icon')
|
||
expect(span).toBeTruthy()
|
||
expect(span!.textContent).toBe('X')
|
||
handle.unmount()
|
||
})
|
||
|
||
test('click fires native click event', async () => {
|
||
let clicks = 0
|
||
const handle = mountComponent(IconButton, {
|
||
onClick: () => {
|
||
clicks++
|
||
},
|
||
})
|
||
await nextTick()
|
||
handle.host.querySelector('button')!.click()
|
||
expect(clicks).toBe(1)
|
||
handle.unmount()
|
||
})
|
||
})
|
||
|
||
suite('ui — Toast', () => {
|
||
test('hidden when visible=false, nothing in body', async () => {
|
||
const handle = mountComponent(Toast, { visible: false, message: 'x' })
|
||
await nextTick()
|
||
expect(document.querySelector('.toast')).toBeNull()
|
||
handle.unmount()
|
||
})
|
||
|
||
test('visible=true renders a teleported element with message', async () => {
|
||
const handle = mountComponent(Toast, {
|
||
visible: true,
|
||
variant: 'success',
|
||
message: 'Alles gut',
|
||
})
|
||
await nextTick()
|
||
const el = document.querySelector('.toast') as HTMLElement | null
|
||
expect(el).toBeTruthy()
|
||
expect(el!.className.includes('variant-success')).toBe(true)
|
||
expect(el!.textContent?.includes('Alles gut')).toBe(true)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('applies position class', async () => {
|
||
const handle = mountComponent(Toast, {
|
||
visible: true,
|
||
position: 'top',
|
||
message: 'x',
|
||
})
|
||
await nextTick()
|
||
const el = document.querySelector('.toast')!
|
||
expect(el.className.includes('position-top')).toBe(true)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('default slot overrides title/message', async () => {
|
||
const handle = mountComponent(
|
||
Toast,
|
||
{ visible: true, message: 'ignored' },
|
||
{ default: () => [h('span', { class: 'slot-body' }, 'slot wins')] },
|
||
)
|
||
await nextTick()
|
||
const slot = document.querySelector('.slot-body')
|
||
expect(slot).toBeTruthy()
|
||
expect(document.querySelector('.toast')!.textContent?.includes('slot wins')).toBe(true)
|
||
handle.unmount()
|
||
})
|
||
})
|
||
|
||
suite('project store — IndexedDB recents', () => {
|
||
test('save then getStoredProject returns identical scene', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'RS', width: 42, height: 42, fill: '#123456' })
|
||
s.addLayer(a.id)!
|
||
const rec = await saveProjectToStore(s.project, { name: 'test-save-get' })
|
||
try {
|
||
const back = await getStoredProject(rec.id)
|
||
expect(back).toBeTruthy()
|
||
expect(back!.name).toBe('test-save-get')
|
||
expect(back!.project!.assets).toHaveLength(1)
|
||
expect(back!.project!.scenes[0].layers).toHaveLength(1)
|
||
} finally {
|
||
await deleteStoredProject(rec.id)
|
||
}
|
||
})
|
||
|
||
test('listRecentProjects is sorted by savedAt desc', async () => {
|
||
const s = makeFreshStore()
|
||
const olderId = `test-older-${Date.now()}`
|
||
const newerId = `test-newer-${Date.now()}`
|
||
const older = await saveProjectToStore(s.project, { id: olderId, name: 'older' })
|
||
await tick(5)
|
||
const newer = await saveProjectToStore(s.project, { id: newerId, name: 'newer' })
|
||
try {
|
||
const list = await listRecentProjects()
|
||
const newerIdx = list.findIndex((p) => p.id === newer.id)
|
||
const olderIdx = list.findIndex((p) => p.id === older.id)
|
||
expect(newerIdx).toBeGreaterThan(-1)
|
||
expect(olderIdx).toBeGreaterThan(-1)
|
||
expect(newerIdx).toBeLessThan(olderIdx)
|
||
} finally {
|
||
await deleteStoredProject(olderId)
|
||
await deleteStoredProject(newerId)
|
||
}
|
||
})
|
||
|
||
test('save with same id updates existing record instead of adding', async () => {
|
||
const s = makeFreshStore()
|
||
const id = `test-update-${Date.now()}`
|
||
await saveProjectToStore(s.project, { id, name: 'v1' })
|
||
const before = (await listRecentProjects()).filter((p) => p.id === id).length
|
||
await saveProjectToStore(s.project, { id, name: 'v2' })
|
||
try {
|
||
const list = await listRecentProjects()
|
||
const matches = list.filter((p) => p.id === id)
|
||
expect(matches).toHaveLength(1)
|
||
expect(matches[0].name).toBe('v2')
|
||
expect(before).toBe(1)
|
||
} finally {
|
||
await deleteStoredProject(id)
|
||
}
|
||
})
|
||
|
||
test('deleteStoredProject removes entry', async () => {
|
||
const s = makeFreshStore()
|
||
const rec = await saveProjectToStore(s.project, { name: 'to-delete' })
|
||
await deleteStoredProject(rec.id)
|
||
const back = await getStoredProject(rec.id)
|
||
expect(back).toBeNull()
|
||
})
|
||
|
||
test('getStoredProject returns null for missing id', async () => {
|
||
const back = await getStoredProject('definitely-missing-id-42')
|
||
expect(back).toBeNull()
|
||
})
|
||
})
|
||
|
||
suite('ui — WelcomeDialog', () => {
|
||
// Modal teleports to body — snapshot/diff dialogs to isolate.
|
||
const snapshotDialogs = () =>
|
||
new Set(Array.from(document.querySelectorAll('[role="dialog"]')))
|
||
const dialogsAddedSince = (baseline: Set<Element>) =>
|
||
Array.from(document.querySelectorAll('[role="dialog"]')).filter(
|
||
(el) => !baseline.has(el),
|
||
) as HTMLElement[]
|
||
|
||
/** Poll until the predicate matches or the budget elapses. The
|
||
* dialog's IDB load + Vue render cycle is fully async, so fixed
|
||
* sleeps were flaky on slower machines. */
|
||
async function waitUntil(predicate: () => boolean, budgetMs = 1000, stepMs = 10) {
|
||
const start = performance.now()
|
||
while (!predicate()) {
|
||
if (performance.now() - start > budgetMs) return
|
||
await tick(stepMs)
|
||
}
|
||
}
|
||
|
||
test('ArrowDown / ArrowUp move focus through the recents list', async () => {
|
||
// Seed two recents via the real IDB path so the dialog's own
|
||
// `listRecentProjects` call finds them.
|
||
const s = makeFreshStore()
|
||
const idA = `test-arrow-a-${Date.now()}`
|
||
const idB = `test-arrow-b-${Date.now()}`
|
||
await saveProjectToStore(s.project, { id: idA, name: 'A' })
|
||
await tick(5)
|
||
await saveProjectToStore(s.project, { id: idB, name: 'B' })
|
||
const baseline = snapshotDialogs()
|
||
const handle = mountComponent(WelcomeDialog, { modelValue: true })
|
||
try {
|
||
await waitUntil(() => {
|
||
const d = dialogsAddedSince(baseline)[0]
|
||
return !!d && d.querySelectorAll('.recent').length >= 2
|
||
})
|
||
const dialog = dialogsAddedSince(baseline)[0]
|
||
expect(dialog).toBeTruthy()
|
||
const items = dialog.querySelectorAll('.recent') as NodeListOf<HTMLElement>
|
||
expect(items.length >= 2).toBe(true)
|
||
|
||
items[0].focus()
|
||
expect(document.activeElement).toBe(items[0])
|
||
items[0].dispatchEvent(
|
||
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }),
|
||
)
|
||
await nextTick()
|
||
expect(document.activeElement).toBe(items[1])
|
||
items[1].dispatchEvent(
|
||
new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }),
|
||
)
|
||
await nextTick()
|
||
expect(document.activeElement).toBe(items[0])
|
||
} finally {
|
||
handle.unmount()
|
||
await deleteStoredProject(idA).catch(() => {})
|
||
await deleteStoredProject(idB).catch(() => {})
|
||
}
|
||
})
|
||
|
||
test('Home and End jump to the first / last recent', async () => {
|
||
const s = makeFreshStore()
|
||
const ids = [
|
||
`test-home-end-a-${Date.now()}`,
|
||
`test-home-end-b-${Date.now()}`,
|
||
`test-home-end-c-${Date.now()}`,
|
||
]
|
||
for (const id of ids) {
|
||
await saveProjectToStore(s.project, { id, name: id })
|
||
await tick(5)
|
||
}
|
||
const baseline = snapshotDialogs()
|
||
const handle = mountComponent(WelcomeDialog, { modelValue: true })
|
||
try {
|
||
await waitUntil(() => {
|
||
const d = dialogsAddedSince(baseline)[0]
|
||
return !!d && d.querySelectorAll('.recent').length >= 3
|
||
})
|
||
const dialog = dialogsAddedSince(baseline)[0]
|
||
const items = dialog.querySelectorAll('.recent') as NodeListOf<HTMLElement>
|
||
expect(items.length >= 3).toBe(true)
|
||
|
||
items[1].focus()
|
||
items[1].dispatchEvent(
|
||
new KeyboardEvent('keydown', { key: 'End', bubbles: true }),
|
||
)
|
||
await nextTick()
|
||
expect(document.activeElement).toBe(items[items.length - 1])
|
||
items[items.length - 1].dispatchEvent(
|
||
new KeyboardEvent('keydown', { key: 'Home', bubbles: true }),
|
||
)
|
||
await nextTick()
|
||
expect(document.activeElement).toBe(items[0])
|
||
} finally {
|
||
handle.unmount()
|
||
for (const id of ids) await deleteStoredProject(id).catch(() => {})
|
||
}
|
||
})
|
||
|
||
test('Enter on a focused recent opens the project (when no confirm is up)', async () => {
|
||
// Regression guard: Enter must open the focused project while
|
||
// the welcome is the active modal. Delete-confirm (next test)
|
||
// verifies that Enter doesn't leak through to this handler while
|
||
// the confirm is on top.
|
||
const s = makeFreshStore()
|
||
const idA = `test-enter-a-${Date.now()}`
|
||
await saveProjectToStore(s.project, { id: idA, name: 'Open me' })
|
||
const baseline = snapshotDialogs()
|
||
let opened: { name: string } | null = null
|
||
const handle = mountComponent(WelcomeDialog, {
|
||
modelValue: true,
|
||
'onOpen': (p: { name: string }) => {
|
||
opened = p
|
||
},
|
||
})
|
||
try {
|
||
await waitUntil(() => {
|
||
const d = dialogsAddedSince(baseline)[0]
|
||
return !!d && d.querySelectorAll('.recent').length >= 1
|
||
})
|
||
const welcome = dialogsAddedSince(baseline)[0]
|
||
const top = welcome.querySelectorAll('.recent')[0] as HTMLElement
|
||
top.focus()
|
||
top.dispatchEvent(
|
||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||
)
|
||
await nextTick()
|
||
expect(opened).toBeTruthy()
|
||
} finally {
|
||
handle.unmount()
|
||
await deleteStoredProject(idA).catch(() => {})
|
||
}
|
||
})
|
||
|
||
test('Enter while the delete-confirm is up triggers confirm (not openProject)', async () => {
|
||
// After Backspace pops the confirm, focus must move to the
|
||
// confirm button so the next Enter fires its click instead of
|
||
// bubbling back to the welcome row and opening the project.
|
||
const s = makeFreshStore()
|
||
const idA = `test-enter-confirm-a-${Date.now()}`
|
||
const idB = `test-enter-confirm-b-${Date.now()}`
|
||
await saveProjectToStore(s.project, { id: idA, name: 'Keep' })
|
||
await tick(5)
|
||
await saveProjectToStore(s.project, { id: idB, name: 'DropMe' })
|
||
const baseline = snapshotDialogs()
|
||
let openCalls = 0
|
||
const handle = mountComponent(WelcomeDialog, {
|
||
modelValue: true,
|
||
'onOpen': () => {
|
||
openCalls += 1
|
||
},
|
||
})
|
||
try {
|
||
await waitUntil(() => {
|
||
const d = dialogsAddedSince(baseline)[0]
|
||
return !!d && d.querySelectorAll('.recent').length >= 2
|
||
})
|
||
const welcome = dialogsAddedSince(baseline)[0]
|
||
// The newest seed ('DropMe' / idB) is at the top of the list.
|
||
// We assert by name rather than by count because the browser's
|
||
// IDB may already hold MAX_RECENT real projects; in that case
|
||
// deleting the top entry just unmasks a hidden record and the
|
||
// count stays the same even though the seed is gone.
|
||
const top = welcome.querySelectorAll('.recent')[0] as HTMLElement
|
||
top.focus()
|
||
top.dispatchEvent(
|
||
new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true }),
|
||
)
|
||
await waitUntil(() => !!document.querySelector('.backdrop.topmost'))
|
||
// Confirm dialog is up; focus should now be on its primary
|
||
// button so Enter fires the confirm instead of bubbling back
|
||
// to the welcome row's Enter handler (which would openProject).
|
||
const topmost = document.querySelector('.backdrop.topmost') as HTMLElement
|
||
const primary = topmost.querySelector('footer .btn.primary') as HTMLButtonElement
|
||
await waitUntil(() => document.activeElement === primary)
|
||
expect(document.activeElement).toBe(primary)
|
||
primary.dispatchEvent(
|
||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||
)
|
||
// Native browser behaviour: Enter on a focused button synthesises
|
||
// a click. Simulate that click directly to keep jsdom/Chrome
|
||
// behaviour consistent inside the test.
|
||
primary.click()
|
||
await waitUntil(() => !welcome.textContent?.includes('DropMe'))
|
||
expect(welcome.textContent?.includes('DropMe')).toBe(false)
|
||
// The welcome's openProject handler must NOT have fired.
|
||
expect(openCalls).toBe(0)
|
||
} finally {
|
||
handle.unmount()
|
||
await deleteStoredProject(idA).catch(() => {})
|
||
await deleteStoredProject(idB).catch(() => {})
|
||
}
|
||
})
|
||
|
||
test('Backspace on a focused recent opens the confirm and removes it on OK', async () => {
|
||
// Seed two recents; mount welcome; focus the top one; press
|
||
// Backspace. A confirm dialog is expected to pop (topmost layer),
|
||
// and clicking OK should drop the entry from the welcome list.
|
||
const s = makeFreshStore()
|
||
const idA = `test-backspace-a-${Date.now()}`
|
||
const idB = `test-backspace-b-${Date.now()}`
|
||
await saveProjectToStore(s.project, { id: idA, name: 'Keep' })
|
||
await tick(5)
|
||
await saveProjectToStore(s.project, { id: idB, name: 'DropMe' })
|
||
const baseline = snapshotDialogs()
|
||
const handle = mountComponent(WelcomeDialog, { modelValue: true })
|
||
try {
|
||
await waitUntil(() => {
|
||
const d = dialogsAddedSince(baseline)[0]
|
||
return !!d && d.querySelectorAll('.recent').length >= 2
|
||
})
|
||
const welcome = dialogsAddedSince(baseline)[0]
|
||
// Assert by seed name rather than count — MAX_RECENT caps the
|
||
// visible list at 8, so if the browser's IDB already has enough
|
||
// real projects, deleting the top seed just unmasks a hidden
|
||
// record and the count does not drop.
|
||
const top = welcome.querySelectorAll('.recent')[0] as HTMLElement
|
||
top.focus()
|
||
expect(document.activeElement).toBe(top)
|
||
top.dispatchEvent(
|
||
new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true }),
|
||
)
|
||
await waitUntil(() => !!document.querySelector('.backdrop.topmost'))
|
||
const topmost = document.querySelector('.backdrop.topmost') as HTMLElement
|
||
const confirmBtn = topmost.querySelector('footer .btn.primary') as HTMLButtonElement
|
||
expect(confirmBtn).toBeTruthy()
|
||
confirmBtn.click()
|
||
// reload() briefly flips loading=true, which hides .recents
|
||
// behind the .empty "loading…" placeholder and also unmounts
|
||
// the <li>s (so 'DropMe' is transiently absent from textContent
|
||
// too). Wait for the list to fully settle before asserting.
|
||
await waitUntil(
|
||
() =>
|
||
!welcome.textContent?.includes('DropMe') &&
|
||
!!welcome.querySelector('.recents'),
|
||
)
|
||
expect(welcome.textContent?.includes('DropMe')).toBe(false)
|
||
// Focus is placed by deleteRecentWithConfirm AFTER reload
|
||
// returns, so poll until it lands inside welcome.
|
||
await waitUntil(() => welcome.contains(document.activeElement))
|
||
expect(welcome.contains(document.activeElement)).toBe(true)
|
||
} finally {
|
||
handle.unmount()
|
||
await deleteStoredProject(idA).catch(() => {})
|
||
await deleteStoredProject(idB).catch(() => {})
|
||
}
|
||
})
|
||
|
||
test('delete confirm floats above the welcome dialog and the list updates afterwards', async () => {
|
||
// Reproduces: clicking the trash icon used to pop the confirm
|
||
// behind welcome (both z-index 900); pressing ESC then dismissed
|
||
// welcome as well because both modals ate the key. The topmost
|
||
// flag fixes stacking and the stack-based ESC filter keeps the
|
||
// confirm from dragging welcome down with it.
|
||
const s = makeFreshStore()
|
||
const idA = `test-del-a-${Date.now()}`
|
||
const idB = `test-del-b-${Date.now()}`
|
||
await saveProjectToStore(s.project, { id: idA, name: 'Keep' })
|
||
await tick(5)
|
||
await saveProjectToStore(s.project, { id: idB, name: 'DropMe' })
|
||
const baseline = snapshotDialogs()
|
||
const handle = mountComponent(WelcomeDialog, { modelValue: true })
|
||
try {
|
||
await waitUntil(() => {
|
||
const d = dialogsAddedSince(baseline)[0]
|
||
return !!d && d.querySelectorAll('.recent').length >= 2
|
||
})
|
||
const welcome = dialogsAddedSince(baseline)[0]
|
||
expect(welcome).toBeTruthy()
|
||
expect(welcome.querySelectorAll('.recent').length >= 2).toBe(true)
|
||
|
||
const target = welcome.querySelectorAll('.recent')[0] as HTMLElement
|
||
const delBtn = target.querySelector('.del') as HTMLButtonElement
|
||
expect(delBtn).toBeTruthy()
|
||
delBtn.click()
|
||
await waitUntil(() => !!document.querySelector('.backdrop.topmost'))
|
||
const topmost = document.querySelector('.backdrop.topmost') as HTMLElement
|
||
expect(topmost).toBeTruthy()
|
||
const confirmBtn = topmost.querySelector(
|
||
'footer .btn.primary',
|
||
) as HTMLButtonElement
|
||
expect(confirmBtn).toBeTruthy()
|
||
confirmBtn.click()
|
||
// Assert by seed name rather than count — MAX_RECENT=8 caps the
|
||
// visible list, so deleting the top seed may just unmask a
|
||
// hidden record instead of dropping the total. Also wait for
|
||
// the list to rerender: reload() briefly flips loading=true,
|
||
// which hides .recents behind the "loading…" placeholder.
|
||
await waitUntil(
|
||
() =>
|
||
!welcome.textContent?.includes('DropMe') &&
|
||
!!welcome.querySelector('.recents'),
|
||
)
|
||
expect(welcome.textContent?.includes('DropMe')).toBe(false)
|
||
expect(welcome.isConnected).toBe(true)
|
||
expect(welcome.querySelector('.recents')).toBeTruthy()
|
||
} finally {
|
||
handle.unmount()
|
||
await deleteStoredProject(idA).catch(() => {})
|
||
await deleteStoredProject(idB).catch(() => {})
|
||
}
|
||
})
|
||
})
|
||
|
||
suite('clips — library & scope', () => {
|
||
test('addClip creates empty clip in library', () => {
|
||
const s = makeFreshStore()
|
||
const c = s.addClip('Intro')
|
||
expect(s.scene.clips?.length).toBe(1)
|
||
expect(c.name).toBe('Intro')
|
||
expect(c.layers).toHaveLength(0)
|
||
expect(c.duration).toBeGreaterThan(0)
|
||
})
|
||
|
||
test('enterClip switches activeLayers + activeDuration to clip scope', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'SceneL', width: 20, height: 20, fill: '#aaa' })
|
||
s.addLayer(a.id)!
|
||
const c = s.addClip('C1')
|
||
c.duration = 3.5
|
||
expect(s.activeLayers).toHaveLength(1)
|
||
expect(s.activeDuration).toBe(s.scene.duration)
|
||
s.enterClip(c.id)
|
||
await tick()
|
||
expect(s.activeLayers).toHaveLength(0)
|
||
expect(s.activeDuration).toBe(3.5)
|
||
expect(s.playhead).toBe(0)
|
||
s.exitClip()
|
||
expect(s.activeLayers).toHaveLength(1)
|
||
expect(s.activeDuration).toBe(s.scene.duration)
|
||
})
|
||
|
||
test('addLayer while in clip scope targets clip layers, not scene layers', async () => {
|
||
const s = makeFreshStore()
|
||
const c = s.addClip('C2')
|
||
const a = s.addAsset({ kind: 'rect', name: 'X', width: 20, height: 20, fill: '#888' })
|
||
s.enterClip(c.id)
|
||
await tick()
|
||
s.addLayer(a.id)!
|
||
expect(c.layers).toHaveLength(1)
|
||
expect(s.scene.layers).toHaveLength(0)
|
||
})
|
||
|
||
test('addClip auto-creates a first clip track if none exist', () => {
|
||
const s = makeFreshStore()
|
||
expect(s.scene.clipTracks ?? []).toHaveLength(0)
|
||
s.addClip('Auto')
|
||
expect((s.scene.clipTracks ?? []).length).toBeGreaterThan(0)
|
||
expect((s.scene.trackOrder ?? []).length).toBeGreaterThan(0)
|
||
})
|
||
|
||
test('addClipInstance anchors to a specific clipTrackId', () => {
|
||
const s = makeFreshStore()
|
||
const c = s.addClip('C3')
|
||
const t = s.scene.clipTracks![0]
|
||
const ci = s.addClipInstance(c.id, 2.5, t.id)
|
||
expect(ci).toBeTruthy()
|
||
expect(s.scene.clipInstances).toHaveLength(1)
|
||
expect(ci!.startTime).toBe(2.5)
|
||
expect(ci!.clipTrackId).toBe(t.id)
|
||
})
|
||
|
||
test('moveClipInstance updates position + can switch track', () => {
|
||
const s = makeFreshStore()
|
||
const c = s.addClip('C4')
|
||
const t1 = s.scene.clipTracks![0]
|
||
const t2 = s.addClipTrack()
|
||
const ci = s.addClipInstance(c.id, 0, t1.id)!
|
||
s.moveClipInstance(ci.id, 4, t2.id)
|
||
expect(ci.startTime).toBe(4)
|
||
expect(ci.clipTrackId).toBe(t2.id)
|
||
})
|
||
|
||
test('removeClip removes all instances and exits if active', () => {
|
||
const s = makeFreshStore()
|
||
const c = s.addClip('C5')
|
||
const t = s.scene.clipTracks![0]
|
||
s.addClipInstance(c.id, 0, t.id)
|
||
s.addClipInstance(c.id, 1, t.id)
|
||
s.enterClip(c.id)
|
||
s.removeClip(c.id)
|
||
expect(s.scene.clips).toHaveLength(0)
|
||
expect(s.scene.clipInstances).toHaveLength(0)
|
||
expect(s.activeClipId).toBeNull()
|
||
})
|
||
|
||
test('negative startTime is clamped to 0', () => {
|
||
const s = makeFreshStore()
|
||
const c = s.addClip('C6')
|
||
const t = s.scene.clipTracks![0]
|
||
const ci = s.addClipInstance(c.id, -5, t.id)!
|
||
expect(ci.startTime).toBe(0)
|
||
})
|
||
|
||
test('trimClipInstance clamps trimStart/trimEnd and preserves min visible duration', () => {
|
||
const s = makeFreshStore()
|
||
const c = s.addClip('Trim')
|
||
c.duration = 4
|
||
const t = s.scene.clipTracks![0]
|
||
const ci = s.addClipInstance(c.id, 0, t.id)!
|
||
s.trimClipInstance(ci.id, { trimStart: 1 })
|
||
expect(ci.trimStart).toBe(1)
|
||
s.trimClipInstance(ci.id, { trimEnd: 1 })
|
||
expect(ci.trimEnd).toBe(1)
|
||
// try to over-trim: start + end > duration - MIN_VISIBLE
|
||
s.trimClipInstance(ci.id, { trimStart: 10 })
|
||
const total = (ci.trimStart ?? 0) + (ci.trimEnd ?? 0)
|
||
expect(total).toBeLessThan(c.duration)
|
||
})
|
||
|
||
test('trimClipInstance accepts startTime shift (left-handle drag)', () => {
|
||
const s = makeFreshStore()
|
||
const c = s.addClip('TrimLeft')
|
||
c.duration = 4
|
||
const t = s.scene.clipTracks![0]
|
||
const ci = s.addClipInstance(c.id, 2, t.id)!
|
||
s.trimClipInstance(ci.id, { startTime: 2.5, trimStart: 0.5 })
|
||
expect(ci.startTime).toBe(2.5)
|
||
expect(ci.trimStart).toBe(0.5)
|
||
})
|
||
|
||
test('negative trim values are clamped to zero', () => {
|
||
const s = makeFreshStore()
|
||
const c = s.addClip('Neg')
|
||
c.duration = 3
|
||
const t = s.scene.clipTracks![0]
|
||
const ci = s.addClipInstance(c.id, 0, t.id)!
|
||
s.trimClipInstance(ci.id, { trimStart: -1, trimEnd: -2 })
|
||
expect(ci.trimStart).toBe(0)
|
||
expect(ci.trimEnd).toBe(0)
|
||
})
|
||
|
||
test('setClipInstanceTransform stores x / y / scale / rotation', () => {
|
||
const s = makeFreshStore()
|
||
const c = s.addClip('Xf')
|
||
const t = s.scene.clipTracks![0]
|
||
const ci = s.addClipInstance(c.id, 0, t.id)!
|
||
s.setClipInstanceTransform(ci.id, {
|
||
x: 80,
|
||
y: -30,
|
||
scaleX: 2,
|
||
scaleY: 0.5,
|
||
rotation: 45,
|
||
})
|
||
expect(ci.x).toBe(80)
|
||
expect(ci.y).toBe(-30)
|
||
expect(ci.scaleX).toBe(2)
|
||
expect(ci.scaleY).toBe(0.5)
|
||
expect(ci.rotation).toBe(45)
|
||
})
|
||
|
||
test('setClipInstanceTransform normalises rotation into (-180, 180]', () => {
|
||
// Values outside the canonical range are wrapped so the stored
|
||
// number stays human-readable across multiple transform
|
||
// sessions (Konva's transformer accumulates past 360).
|
||
const s = makeFreshStore()
|
||
const c = s.addClip('Xf')
|
||
const t = s.scene.clipTracks![0]
|
||
const ci = s.addClipInstance(c.id, 0, t.id)!
|
||
s.setClipInstanceTransform(ci.id, { rotation: 370 })
|
||
expect(ci.rotation).toBe(10)
|
||
s.setClipInstanceTransform(ci.id, { rotation: -190 })
|
||
expect(ci.rotation).toBe(170)
|
||
s.setClipInstanceTransform(ci.id, { rotation: 360 })
|
||
// 360 % 360 = 0 → pruned back out of the object.
|
||
expect('rotation' in ci).toBe(false)
|
||
})
|
||
|
||
test('setClipInstanceTransform prunes defaults so JSON stays clean', () => {
|
||
// Defaults (x=0, y=0, scale=1) are absent on a freshly-added
|
||
// instance. Writing them back explicitly mustn't leave the
|
||
// field around — serialized scenes stay minimal and diffable.
|
||
const s = makeFreshStore()
|
||
const c = s.addClip('Xf')
|
||
const t = s.scene.clipTracks![0]
|
||
const ci = s.addClipInstance(c.id, 0, t.id)!
|
||
s.setClipInstanceTransform(ci.id, { x: 50, scaleX: 2 })
|
||
expect(ci.x).toBe(50)
|
||
expect(ci.scaleX).toBe(2)
|
||
// Clear back to defaults — the keys should get deleted.
|
||
s.setClipInstanceTransform(ci.id, { x: 0, scaleX: 1 })
|
||
expect('x' in ci).toBe(false)
|
||
expect('scaleX' in ci).toBe(false)
|
||
})
|
||
|
||
test('setClipInstanceTransform leaves unspecified fields alone', () => {
|
||
const s = makeFreshStore()
|
||
const c = s.addClip('Xf')
|
||
const t = s.scene.clipTracks![0]
|
||
const ci = s.addClipInstance(c.id, 0, t.id)!
|
||
s.setClipInstanceTransform(ci.id, { x: 10, y: 20, scaleX: 3, scaleY: 3 })
|
||
// Partial patch: only x. y / scale must survive.
|
||
s.setClipInstanceTransform(ci.id, { x: 99 })
|
||
expect(ci.x).toBe(99)
|
||
expect(ci.y).toBe(20)
|
||
expect(ci.scaleX).toBe(3)
|
||
expect(ci.scaleY).toBe(3)
|
||
})
|
||
|
||
test('setClipInstanceTransform on an unknown id is a no-op', () => {
|
||
const s = makeFreshStore()
|
||
const c = s.addClip('Xf')
|
||
const t = s.scene.clipTracks![0]
|
||
s.addClipInstance(c.id, 0, t.id)!
|
||
s.setClipInstanceTransform('not-an-id', { x: 5 })
|
||
// No-op; no side effects or throw.
|
||
expect(s.scene.clipInstances!.every((ci) => ci.x === undefined)).toBe(true)
|
||
})
|
||
|
||
test('selectClipInstance clears the layer selection and vice versa', () => {
|
||
// Selection is mutually exclusive — stage transformer only
|
||
// targets one thing at a time.
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
const c = s.addClip('Xf')
|
||
const t = s.scene.clipTracks![0]
|
||
const ci = s.addClipInstance(c.id, 0, t.id)!
|
||
|
||
s.selectLayer(l.id)
|
||
expect(s.selectedLayerId).toBe(l.id)
|
||
expect(s.selectedClipInstanceId).toBe(null)
|
||
|
||
s.selectClipInstance(ci.id)
|
||
expect(s.selectedClipInstanceId).toBe(ci.id)
|
||
expect(s.selectedLayerId).toBe(null)
|
||
expect(s.selectedLayerIds).toEqual([])
|
||
|
||
s.selectLayer(l.id)
|
||
expect(s.selectedLayerId).toBe(l.id)
|
||
expect(s.selectedClipInstanceId).toBe(null)
|
||
|
||
s.selectClipInstance(null)
|
||
expect(s.selectedClipInstanceId).toBe(null)
|
||
// selecting null on the clip branch doesn't touch the layer
|
||
// selection — the user just cleared the clip-side pointer.
|
||
expect(s.selectedLayerId).toBe(l.id)
|
||
})
|
||
|
||
test('removeClipInstance clears selectedClipInstanceId when it matched', () => {
|
||
const s = makeFreshStore()
|
||
const c = s.addClip('Xf')
|
||
const t = s.scene.clipTracks![0]
|
||
const ci = s.addClipInstance(c.id, 0, t.id)!
|
||
s.selectClipInstance(ci.id)
|
||
s.removeClipInstance(ci.id)
|
||
expect(s.selectedClipInstanceId).toBe(null)
|
||
})
|
||
|
||
test('drawSceneFrame rotates the clip-instance group on the canvas', () => {
|
||
// Geometry that keeps the rect on-canvas through a 180° flip:
|
||
// - 20×10 rect anchored at layer origin (0,0) with no offset.
|
||
// - Clip instance at x=50, y=50 → unrotated, rect lives at
|
||
// world (50..70, 50..60).
|
||
// - Rotate 180° → rect flips to world (30..50, 40..50).
|
||
// Both pre and post positions sit comfortably on the 100×100
|
||
// canvas so we can assert concrete pixels on either side.
|
||
const s = makeFreshStore()
|
||
s.scene.canvas.width = 100
|
||
s.scene.canvas.height = 100
|
||
s.scene.canvas.background = 'transparent'
|
||
s.setDuration(5)
|
||
|
||
const rect = s.addAsset({
|
||
kind: 'rect',
|
||
name: 'R',
|
||
width: 20,
|
||
height: 10,
|
||
fill: '#ff0000',
|
||
})
|
||
const clip = s.addClip('C')
|
||
clip.duration = 5
|
||
clip.layers = [
|
||
{
|
||
id: 'clip-layer-r',
|
||
name: 'R',
|
||
assetId: rect.id,
|
||
visible: true,
|
||
base: { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
|
||
keyframes: [],
|
||
offsetX: 0,
|
||
offsetY: 0,
|
||
},
|
||
]
|
||
const track = s.scene.clipTracks![0]
|
||
const ci = s.addClipInstance(clip.id, 0, track.id)!
|
||
s.setClipInstanceTransform(ci.id, { x: 50, y: 50 })
|
||
|
||
// Baseline: unrotated rect covers world (50..70, 50..60). A
|
||
// pixel at (60, 55) must be red; (40, 45) must be transparent.
|
||
const unrotated = document.createElement('canvas')
|
||
unrotated.width = 100
|
||
unrotated.height = 100
|
||
const uCtx = unrotated.getContext('2d')!
|
||
drawSceneFrame(uCtx, s.scene, 0, {})
|
||
const beforeInside = uCtx.getImageData(60, 55, 1, 1).data
|
||
const beforeOutside = uCtx.getImageData(40, 45, 1, 1).data
|
||
expect(beforeInside[0] > 200).toBe(true)
|
||
expect(beforeInside[3] > 200).toBe(true)
|
||
expect(beforeOutside[3]).toBe(0)
|
||
|
||
// Rotate 180°: rect flips around (50, 50) to world (30..50,
|
||
// 40..50). The two probe pixels swap roles.
|
||
s.setClipInstanceTransform(ci.id, { rotation: 180 })
|
||
const rotated = document.createElement('canvas')
|
||
rotated.width = 100
|
||
rotated.height = 100
|
||
const rCtx = rotated.getContext('2d')!
|
||
drawSceneFrame(rCtx, s.scene, 0, {})
|
||
const afterInside = rCtx.getImageData(40, 45, 1, 1).data
|
||
const afterOutside = rCtx.getImageData(60, 55, 1, 1).data
|
||
expect(afterInside[0] > 200).toBe(true)
|
||
expect(afterInside[3] > 200).toBe(true)
|
||
expect(afterOutside[3]).toBe(0)
|
||
})
|
||
|
||
test('drawSceneFrame translates + scales the clip-instance group on the canvas', () => {
|
||
// Seeds a 100×100 scene with a tiny red rect clip; places one
|
||
// instance at the top-left (0,0) with 2× scale so the 10×10
|
||
// rect renders as a 20×20 block. Reads pixels to confirm the
|
||
// transform actually landed on the canvas-path renderer
|
||
// (mask-preview, thumbnails, etc. all go through drawSceneFrame).
|
||
const s = makeFreshStore()
|
||
s.scene.canvas.width = 100
|
||
s.scene.canvas.height = 100
|
||
s.scene.canvas.background = 'transparent'
|
||
s.setDuration(5)
|
||
|
||
const rect = s.addAsset({
|
||
kind: 'rect',
|
||
name: 'R',
|
||
width: 10,
|
||
height: 10,
|
||
fill: '#ff0000',
|
||
})
|
||
const clip = s.addClip('C')
|
||
clip.duration = 5
|
||
// Build the clip's sole layer directly — addLayer would target
|
||
// the scene, not the clip library.
|
||
clip.layers = [
|
||
{
|
||
id: 'clip-layer-1',
|
||
name: 'RL',
|
||
assetId: rect.id,
|
||
visible: true,
|
||
base: {
|
||
x: 5,
|
||
y: 5,
|
||
scaleX: 1,
|
||
scaleY: 1,
|
||
rotation: 0,
|
||
opacity: 1,
|
||
},
|
||
keyframes: [],
|
||
offsetX: 5,
|
||
offsetY: 5,
|
||
},
|
||
]
|
||
const track = s.scene.clipTracks![0]
|
||
const ci = s.addClipInstance(clip.id, 0, track.id)!
|
||
// Transform: nudge to (0,0) origin for the top-left and scale 2×.
|
||
s.setClipInstanceTransform(ci.id, { x: 0, y: 0, scaleX: 2, scaleY: 2 })
|
||
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = 100
|
||
canvas.height = 100
|
||
const ctx = canvas.getContext('2d')!
|
||
drawSceneFrame(ctx, s.scene, 0, {})
|
||
// Without transform the 10×10 rect at (5,5) covers x=0..10, y=0..10
|
||
// (layer's own offset centres it on its base pos). With scale 2×
|
||
// it covers 0..20 in both axes. A pixel at (15, 15) should be red.
|
||
const insideAfterScale = ctx.getImageData(15, 15, 1, 1).data
|
||
expect(insideAfterScale[0] > 200).toBe(true)
|
||
expect(insideAfterScale[3] > 200).toBe(true)
|
||
// A pixel at (25, 25) should still be blank (outside the
|
||
// scaled rect + stage-render still clips to canvas).
|
||
const outsideAfterScale = ctx.getImageData(25, 25, 1, 1).data
|
||
expect(outsideAfterScale[3]).toBe(0)
|
||
})
|
||
|
||
test('setTrackColor applies to layer and can be cleared with null', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'Col', width: 20, height: 20, fill: '#123' })
|
||
const l = s.addLayer(a.id)!
|
||
s.setTrackColor(l.id, '#ff6b6b')
|
||
expect(l.color).toBe('#ff6b6b')
|
||
s.setTrackColor(l.id, null)
|
||
expect(l.color).toBe(undefined)
|
||
})
|
||
|
||
test('setTrackColor applies to clip track', () => {
|
||
const s = makeFreshStore()
|
||
s.addClip('X')
|
||
const t = s.scene.clipTracks![0]
|
||
s.setTrackColor(t.id, '#51cf66')
|
||
expect(t.color).toBe('#51cf66')
|
||
s.setTrackColor(t.id, null)
|
||
expect(t.color).toBe(undefined)
|
||
})
|
||
|
||
test('setTrackLocked toggles layer lock flag', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'L', width: 10, height: 10, fill: '#111' })
|
||
const l = s.addLayer(a.id)!
|
||
s.setTrackLocked(l.id, true)
|
||
expect(l.locked).toBe(true)
|
||
s.setTrackLocked(l.id, false)
|
||
expect(l.locked).toBe(undefined)
|
||
})
|
||
|
||
test('setTrackLocked toggles clip-track lock flag', () => {
|
||
const s = makeFreshStore()
|
||
s.addClip('L')
|
||
const t = s.scene.clipTracks![0]
|
||
s.setTrackLocked(t.id, true)
|
||
expect(t.locked).toBe(true)
|
||
s.setTrackLocked(t.id, false)
|
||
expect(t.locked).toBe(undefined)
|
||
})
|
||
|
||
test('setTrackColor on unknown id is a no-op', () => {
|
||
const s = makeFreshStore()
|
||
s.setTrackColor('nope', '#ff0000')
|
||
// just verify no throw, no layer/clipTrack changed
|
||
expect(s.scene.layers).toHaveLength(0)
|
||
expect(s.scene.clipTracks ?? []).toHaveLength(0)
|
||
})
|
||
|
||
test('moveClipInstance without clipTrackId keeps the same track (x-only move)', () => {
|
||
const s = makeFreshStore()
|
||
const c = s.addClip('MoveX')
|
||
const t1 = s.scene.clipTracks![0]
|
||
const t2 = s.addClipTrack()
|
||
const ci = s.addClipInstance(c.id, 0, t1.id)!
|
||
// sanity: distinct tracks
|
||
expect(t1.id === t2.id).toBe(false)
|
||
s.moveClipInstance(ci.id, 5)
|
||
expect(ci.startTime).toBe(5)
|
||
expect(ci.clipTrackId).toBe(t1.id)
|
||
})
|
||
|
||
test('setClipInstancePosition pushes neighbours forward when the moved clip collides on the right', () => {
|
||
const s = makeFreshStore()
|
||
const c = s.addClip('P')
|
||
c.duration = 2
|
||
const t = s.scene.clipTracks![0]
|
||
const a = s.addClipInstance(c.id, 0, t.id)! // 0..2
|
||
const b = s.addClipInstance(c.id, 3, t.id)! // 3..5
|
||
const cc = s.addClipInstance(c.id, 6, t.id)! // 6..8
|
||
// Move `a` forward onto `b`. Expect `b` to shunt to 2, `cc` stays.
|
||
expect(s.setClipInstancePosition(a.id, 2)).toBe(true)
|
||
expect(a.startTime).toBe(2)
|
||
expect(b.startTime).toBe(4)
|
||
expect(cc.startTime).toBe(6)
|
||
})
|
||
|
||
test('setClipInstancePosition pushes neighbours backward when the moved clip collides on the left', () => {
|
||
const s = makeFreshStore()
|
||
const c = s.addClip('P')
|
||
c.duration = 2
|
||
const t = s.scene.clipTracks![0]
|
||
const a = s.addClipInstance(c.id, 0, t.id)! // 0..2
|
||
const b = s.addClipInstance(c.id, 3, t.id)! // 3..5
|
||
const cc = s.addClipInstance(c.id, 6, t.id)! // 6..8
|
||
// Move `cc` backward onto `b`. Expect `b` to shunt left to 2 (abut
|
||
// `a` end) and `a` shifts to 0 (already there).
|
||
expect(s.setClipInstancePosition(cc.id, 4)).toBe(true)
|
||
expect(cc.startTime).toBe(4)
|
||
expect(b.startTime).toBe(2)
|
||
expect(a.startTime).toBe(0)
|
||
})
|
||
|
||
test('setClipInstancePosition clamps moved clip forward when backward cascade hits 0', () => {
|
||
// Pack the lane: three 2s clips abutting from 0. Drag the right-
|
||
// most left to 1 — there's no room for the chain at 0, so the
|
||
// moved clip should land at the first fitting slot (4) instead.
|
||
const s = makeFreshStore()
|
||
const c = s.addClip('Pack')
|
||
c.duration = 2
|
||
const t = s.scene.clipTracks![0]
|
||
const a = s.addClipInstance(c.id, 0, t.id)! // 0..2
|
||
const b = s.addClipInstance(c.id, 2, t.id)! // 2..4
|
||
const cc = s.addClipInstance(c.id, 4, t.id)! // 4..6
|
||
expect(s.setClipInstancePosition(cc.id, 1)).toBe(true)
|
||
expect(cc.startTime).toBe(4)
|
||
expect(a.startTime).toBe(0)
|
||
expect(b.startTime).toBe(2)
|
||
})
|
||
|
||
test('setClipInstancePosition returns false for unknown instance or unknown track', () => {
|
||
const s = makeFreshStore()
|
||
expect(s.setClipInstancePosition('no-such-instance', 0)).toBe(false)
|
||
const c = s.addClip('X')
|
||
const t = s.scene.clipTracks![0]
|
||
const ci = s.addClipInstance(c.id, 0, t.id)!
|
||
expect(s.setClipInstancePosition(ci.id, 0, 'ghost-track')).toBe(false)
|
||
})
|
||
|
||
test('duplicateClipInstance clones onto the same track, offset by visible duration', () => {
|
||
const s = makeFreshStore()
|
||
const c = s.addClip('D')
|
||
c.duration = 3
|
||
const t = s.scene.clipTracks![0]
|
||
const ci = s.addClipInstance(c.id, 1, t.id)!
|
||
s.setClipInstanceTransform(ci.id, { x: 42, scaleX: 2 })
|
||
const newId = s.duplicateClipInstance(ci.id)!
|
||
expect(typeof newId).toBe('string')
|
||
expect(newId === ci.id).toBe(false)
|
||
const copy = s.activeScene.clipInstances.find((x) => x.id === newId)!
|
||
expect(copy.clipId).toBe(c.id)
|
||
expect(copy.clipTrackId).toBe(t.id)
|
||
expect(copy.startTime).toBe(1 + 3) // original.startTime + visible length
|
||
expect(copy.x).toBe(42)
|
||
expect(copy.scaleX).toBe(2)
|
||
})
|
||
|
||
test('duplicateClipInstance honours trim when computing the offset', () => {
|
||
const s = makeFreshStore()
|
||
const c = s.addClip('D')
|
||
c.duration = 4
|
||
const t = s.scene.clipTracks![0]
|
||
const ci = s.addClipInstance(c.id, 0, t.id)!
|
||
s.trimClipInstance(ci.id, { trimStart: 0.5, trimEnd: 0.5 })
|
||
const newId = s.duplicateClipInstance(ci.id)!
|
||
const copy = s.activeScene.clipInstances.find((x) => x.id === newId)!
|
||
// visible length = 4 - 0.5 - 0.5 = 3 → copy lands at 3
|
||
expect(copy.startTime).toBe(3)
|
||
expect(copy.trimStart).toBe(0.5)
|
||
expect(copy.trimEnd).toBe(0.5)
|
||
})
|
||
|
||
test('duplicateClipInstance returns null for unknown id', () => {
|
||
const s = makeFreshStore()
|
||
expect(s.duplicateClipInstance('nope')).toBe(null)
|
||
})
|
||
|
||
test('moveTrack reorders the unified stack (layer above clip track)', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'M', width: 10, height: 10, fill: '#000' })
|
||
const l = s.addLayer(a.id)!
|
||
s.addClip('X')
|
||
const track = s.scene.clipTracks![0]
|
||
// order is [layer, clipTrack]
|
||
expect(s.scene.trackOrder![0]).toBe(l.id)
|
||
expect(s.scene.trackOrder![1]).toBe(track.id)
|
||
s.moveTrack(0, 2)
|
||
expect(s.scene.trackOrder![0]).toBe(track.id)
|
||
expect(s.scene.trackOrder![1]).toBe(l.id)
|
||
})
|
||
})
|
||
|
||
suite('scene store — track groups', () => {
|
||
test('addTrackGroup creates an empty group at the top of trackOrder', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'G1', width: 10, height: 10, fill: '#111' })
|
||
s.addLayer(a.id)!
|
||
const g = s.addTrackGroup('My group')
|
||
expect(s.scene.groups).toHaveLength(1)
|
||
expect(g.childIds).toHaveLength(0)
|
||
expect(s.scene.trackOrder![0]).toBe(g.id)
|
||
})
|
||
|
||
test('addTrackGroup with an anchor inserts directly above that track', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#111' })
|
||
const l1 = s.addLayer(a.id)! // trackOrder ends up [l2, l1] via unshift
|
||
const l2 = s.addLayer(a.id)!
|
||
// Anchor on the second layer → group lands at its index, shoving it down.
|
||
const g = s.addTrackGroup('G', l1.id)
|
||
const order = s.scene.trackOrder ?? []
|
||
const gIdx = order.indexOf(g.id)
|
||
const l1Idx = order.indexOf(l1.id)
|
||
expect(gIdx).toBe(l1Idx - 1)
|
||
void l2
|
||
})
|
||
|
||
test('addTrackGroup with an anchor inside an existing group lands above the parent group', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#111' })
|
||
const l = s.addLayer(a.id)!
|
||
const parent = s.addTrackGroup('Parent')
|
||
s.moveTrackIntoGroup(l.id, parent.id)
|
||
// Anchor is nested inside `parent` — new group must sit above `parent`,
|
||
// not attempt to nest itself inside another group.
|
||
const fresh = s.addTrackGroup('Fresh', l.id)
|
||
const order = s.scene.trackOrder ?? []
|
||
expect(order.indexOf(fresh.id)).toBe(order.indexOf(parent.id) - 1)
|
||
})
|
||
|
||
test('addTrackGroup with an unknown anchor falls back to the top', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#111' })
|
||
s.addLayer(a.id)!
|
||
const g = s.addTrackGroup('G', 'ghost-id')
|
||
expect(s.scene.trackOrder![0]).toBe(g.id)
|
||
})
|
||
|
||
test('moveTrackIntoGroup moves track from top-level to group childIds', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'G2', width: 10, height: 10, fill: '#222' })
|
||
const l = s.addLayer(a.id)!
|
||
const g = s.addTrackGroup('G')
|
||
s.moveTrackIntoGroup(l.id, g.id)
|
||
expect(g.childIds).toContain(l.id)
|
||
expect((s.scene.trackOrder ?? []).includes(l.id)).toBe(false)
|
||
})
|
||
|
||
test('moveTrackOutOfGroup promotes track back to top-level', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'G3', width: 10, height: 10, fill: '#333' })
|
||
const l = s.addLayer(a.id)!
|
||
const g = s.addTrackGroup('G')
|
||
s.moveTrackIntoGroup(l.id, g.id)
|
||
s.moveTrackOutOfGroup(l.id)
|
||
expect(g.childIds.includes(l.id)).toBe(false)
|
||
expect(s.scene.trackOrder).toContain(l.id)
|
||
})
|
||
|
||
test('removeTrackGroup promotes children back to top-level', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'G4', width: 10, height: 10, fill: '#444' })
|
||
const l = s.addLayer(a.id)!
|
||
const g = s.addTrackGroup('G')
|
||
s.moveTrackIntoGroup(l.id, g.id)
|
||
s.removeTrackGroup(g.id)
|
||
expect(s.scene.groups).toHaveLength(0)
|
||
expect(s.scene.trackOrder).toContain(l.id)
|
||
})
|
||
|
||
test('toggleGroupCollapsed flips the collapsed flag', () => {
|
||
const s = makeFreshStore()
|
||
const g = s.addTrackGroup('G')
|
||
expect(!!g.collapsed).toBe(false)
|
||
s.toggleGroupCollapsed(g.id)
|
||
expect(g.collapsed).toBe(true)
|
||
s.toggleGroupCollapsed(g.id)
|
||
expect(g.collapsed).toBe(false)
|
||
})
|
||
|
||
test('renameGroup updates the name', () => {
|
||
const s = makeFreshStore()
|
||
const g = s.addTrackGroup('Old')
|
||
s.renameGroup(g.id, 'New')
|
||
expect(g.name).toBe('New')
|
||
})
|
||
|
||
test('a track can only be in one group at a time', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#555' })
|
||
const l = s.addLayer(a.id)!
|
||
const g1 = s.addTrackGroup('A')
|
||
const g2 = s.addTrackGroup('B')
|
||
s.moveTrackIntoGroup(l.id, g1.id)
|
||
s.moveTrackIntoGroup(l.id, g2.id)
|
||
expect(g1.childIds.includes(l.id)).toBe(false)
|
||
expect(g2.childIds).toContain(l.id)
|
||
})
|
||
|
||
test('expandedTrackOrder expands group IDs to their childIds inline', () => {
|
||
const s = makeFreshStore()
|
||
const a1 = s.addAsset({ kind: 'rect', name: 'A', width: 10, height: 10, fill: '#abc' })
|
||
const a2 = s.addAsset({ kind: 'rect', name: 'B', width: 10, height: 10, fill: '#def' })
|
||
const l1 = s.addLayer(a1.id)!
|
||
const l2 = s.addLayer(a2.id)!
|
||
const g = s.addTrackGroup('G')
|
||
s.moveTrackIntoGroup(l1.id, g.id)
|
||
// scene.trackOrder: [group, l2] (l1 is inside group)
|
||
// expanded: [l1, l2]
|
||
const expanded = expandedTrackOrder(s.scene)
|
||
expect(expanded).toEqual([l1.id, l2.id])
|
||
})
|
||
|
||
test('moveTrackIntoGroup respects atIndex for insertion', () => {
|
||
const s = makeFreshStore()
|
||
const a1 = s.addAsset({ kind: 'rect', name: 'A', width: 10, height: 10, fill: '#111' })
|
||
const a2 = s.addAsset({ kind: 'rect', name: 'B', width: 10, height: 10, fill: '#222' })
|
||
const a3 = s.addAsset({ kind: 'rect', name: 'C', width: 10, height: 10, fill: '#333' })
|
||
const l1 = s.addLayer(a1.id)!
|
||
const l2 = s.addLayer(a2.id)!
|
||
const l3 = s.addLayer(a3.id)!
|
||
const g = s.addTrackGroup('G')
|
||
s.moveTrackIntoGroup(l1.id, g.id) // [l1]
|
||
s.moveTrackIntoGroup(l2.id, g.id) // [l1, l2]
|
||
s.moveTrackIntoGroup(l3.id, g.id, 1) // [l1, l3, l2]
|
||
expect(g.childIds).toEqual([l1.id, l3.id, l2.id])
|
||
})
|
||
|
||
test('removeTrackGroup inserts children at the former group position', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#aaa' })
|
||
const top1 = s.addLayer(a.id)!
|
||
const top2 = s.addLayer(a.id)!
|
||
const childA = s.addLayer(a.id)!
|
||
const childB = s.addLayer(a.id)!
|
||
const g = s.addTrackGroup('G')
|
||
s.moveTrackIntoGroup(childA.id, g.id)
|
||
s.moveTrackIntoGroup(childB.id, g.id)
|
||
// trackOrder currently: [childB? no, children are removed from top-level]
|
||
// trackOrder contains: [g, top2, top1] (newest addLayer unshifts)
|
||
// Wait: addLayer unshifts; the creation order is top2(later), then top1(earlier)
|
||
// Then addLayer(childA), childB — all unshifted. Then moved into group.
|
||
// Let's just position group explicitly to be sure:
|
||
const order = s.scene.trackOrder ?? []
|
||
const gIdx = order.indexOf(g.id)
|
||
expect(gIdx).toBeGreaterThan(-1)
|
||
s.removeTrackGroup(g.id)
|
||
const newOrder = s.scene.trackOrder ?? []
|
||
// children should now sit at the former group position
|
||
expect(newOrder[gIdx]).toBe(childA.id)
|
||
expect(newOrder[gIdx + 1]).toBe(childB.id)
|
||
void top1
|
||
void top2
|
||
})
|
||
|
||
test('renameGroup ignores empty / whitespace names', () => {
|
||
const s = makeFreshStore()
|
||
const g = s.addTrackGroup('Original')
|
||
s.renameGroup(g.id, '')
|
||
expect(g.name).toBe('Original')
|
||
s.renameGroup(g.id, ' ')
|
||
expect(g.name).toBe('Original')
|
||
s.renameGroup(g.id, ' Renamed ')
|
||
expect(g.name).toBe('Renamed')
|
||
})
|
||
|
||
test('removeTrackGroup on unknown id is a no-op', () => {
|
||
const s = makeFreshStore()
|
||
const g = s.addTrackGroup('G')
|
||
const beforeCount = (s.scene.groups ?? []).length
|
||
s.removeTrackGroup('does-not-exist')
|
||
expect((s.scene.groups ?? []).length).toBe(beforeCount)
|
||
// The real group still exists
|
||
expect(s.scene.groups?.find((x) => x.id === g.id)).toBeTruthy()
|
||
})
|
||
|
||
test('setTrackColor applies to groups too', () => {
|
||
const s = makeFreshStore()
|
||
const g = s.addTrackGroup('Col')
|
||
s.setTrackColor(g.id, '#ff0000')
|
||
expect(g.color).toBe('#ff0000')
|
||
s.setTrackColor(g.id, null)
|
||
expect(g.color).toBe(undefined)
|
||
})
|
||
|
||
test('moveTrackIntoGroup with same group twice moves within group', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'M', width: 10, height: 10, fill: '#555' })
|
||
const l1 = s.addLayer(a.id)!
|
||
const l2 = s.addLayer(a.id)!
|
||
const g = s.addTrackGroup('G')
|
||
s.moveTrackIntoGroup(l1.id, g.id)
|
||
s.moveTrackIntoGroup(l2.id, g.id)
|
||
// l1 currently at index 0. Move l1 to index 1.
|
||
s.moveTrackIntoGroup(l1.id, g.id, 1)
|
||
expect(g.childIds).toEqual([l2.id, l1.id])
|
||
})
|
||
|
||
test('group color cascades to non-overridden children', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
|
||
const l1 = s.addLayer(a.id)!
|
||
const l2 = s.addLayer(a.id)!
|
||
const g = s.addTrackGroup('G')
|
||
s.moveTrackIntoGroup(l1.id, g.id)
|
||
s.moveTrackIntoGroup(l2.id, g.id)
|
||
s.setTrackColor(g.id, '#ff0000')
|
||
expect(l1.color).toBe('#ff0000')
|
||
expect(l2.color).toBe('#ff0000')
|
||
// The children inherited — they must NOT be marked as overridden.
|
||
expect(l1.colorOverridden).toBe(undefined)
|
||
expect(l2.colorOverridden).toBe(undefined)
|
||
})
|
||
|
||
test('explicit per-track color pins against later group-color changes', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
|
||
const l1 = s.addLayer(a.id)!
|
||
const l2 = s.addLayer(a.id)!
|
||
const g = s.addTrackGroup('G')
|
||
s.moveTrackIntoGroup(l1.id, g.id)
|
||
s.moveTrackIntoGroup(l2.id, g.id)
|
||
s.setTrackColor(g.id, '#ff0000')
|
||
// User recolours one child AFTER the group — it pins.
|
||
s.setTrackColor(l1.id, '#00ff00')
|
||
expect(l1.colorOverridden).toBe(true)
|
||
// Group colour changes again — pinned child ignores it; other follows.
|
||
s.setTrackColor(g.id, '#0000ff')
|
||
expect(l1.color).toBe('#00ff00')
|
||
expect(l2.color).toBe('#0000ff')
|
||
})
|
||
|
||
test('clearing a child-color (null) re-inherits the group color', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
const g = s.addTrackGroup('G')
|
||
s.moveTrackIntoGroup(l.id, g.id)
|
||
s.setTrackColor(g.id, '#ff0000')
|
||
s.setTrackColor(l.id, '#00ff00')
|
||
expect(l.color).toBe('#00ff00')
|
||
s.setTrackColor(l.id, null)
|
||
// Now inheriting again.
|
||
expect(l.color).toBe('#ff0000')
|
||
expect(l.colorOverridden).toBe(undefined)
|
||
})
|
||
|
||
test('clearing a group color clears cascade on non-overridden children', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
|
||
const l1 = s.addLayer(a.id)!
|
||
const l2 = s.addLayer(a.id)!
|
||
const g = s.addTrackGroup('G')
|
||
s.moveTrackIntoGroup(l1.id, g.id)
|
||
s.moveTrackIntoGroup(l2.id, g.id)
|
||
s.setTrackColor(g.id, '#ff0000')
|
||
s.setTrackColor(l2.id, '#00ff00')
|
||
s.setTrackColor(g.id, null)
|
||
expect(l1.color).toBe(undefined)
|
||
// l2 was pinned, keeps its explicit colour.
|
||
expect(l2.color).toBe('#00ff00')
|
||
})
|
||
|
||
test('moving a track into a coloured group inherits its color', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
const g = s.addTrackGroup('G')
|
||
s.setTrackColor(g.id, '#ff0000')
|
||
s.moveTrackIntoGroup(l.id, g.id)
|
||
expect(l.color).toBe('#ff0000')
|
||
expect(l.colorOverridden).toBe(undefined)
|
||
})
|
||
|
||
test('moving an overridden track into a coloured group keeps its color', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
s.setTrackColor(l.id, '#00ff00')
|
||
const g = s.addTrackGroup('G')
|
||
s.setTrackColor(g.id, '#ff0000')
|
||
s.moveTrackIntoGroup(l.id, g.id)
|
||
expect(l.color).toBe('#00ff00')
|
||
})
|
||
|
||
test('loading an older scene treats pre-existing track colors as overridden', () => {
|
||
// Migration: scenes saved before colorOverridden shipped must
|
||
// preserve their per-track colour choices against future group
|
||
// cascades. normalizeScene stamps colorOverridden=true on any
|
||
// track that arrives with a colour and no flag.
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
const snap = JSON.parse(JSON.stringify(s.scene))
|
||
snap.layers[0].color = '#123456'
|
||
// legacy: no colorOverridden field on the layer
|
||
s.loadScene(snap)
|
||
const loaded = s.scene.layers.find((x) => x.id === l.id)!
|
||
expect(loaded.colorOverridden).toBe(true)
|
||
})
|
||
|
||
test('normalizeScene (via loadScene) removes orphan childIds and deduplicates', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'N', width: 10, height: 10, fill: '#666' })
|
||
const l = s.addLayer(a.id)!
|
||
// Snapshot with a broken state
|
||
const snap = JSON.parse(JSON.stringify(s.scene))
|
||
snap.groups = [
|
||
{ id: 'g1', name: 'G1', childIds: [l.id, 'ghost-layer', l.id] },
|
||
{ id: 'g2', name: 'G2', childIds: [l.id] },
|
||
]
|
||
snap.trackOrder = [l.id, 'g1', 'g2']
|
||
s.loadScene(snap)
|
||
// Orphan 'ghost-layer' filtered out
|
||
const g1 = s.scene.groups?.find((g) => g.id === 'g1')!
|
||
const g2 = s.scene.groups?.find((g) => g.id === 'g2')!
|
||
expect(g1.childIds.includes('ghost-layer')).toBe(false)
|
||
// Dedupe: l.id appears only once across all groups
|
||
const inG1 = g1.childIds.filter((id) => id === l.id).length
|
||
const inG2 = g2.childIds.filter((id) => id === l.id).length
|
||
expect(inG1 + inG2).toBe(1)
|
||
})
|
||
|
||
test('moveTrackIntoGroup accepts script tracks as children', () => {
|
||
const s = makeFreshStore()
|
||
const st = s.addScriptTrack('Logic')
|
||
const g = s.addTrackGroup('G')
|
||
s.moveTrackIntoGroup(st.id, g.id)
|
||
expect(g.childIds).toContain(st.id)
|
||
expect((s.scene.trackOrder ?? []).includes(st.id)).toBe(false)
|
||
})
|
||
|
||
test('moveTrackIntoGroup accepts audio tracks as children', () => {
|
||
const s = makeFreshStore()
|
||
const at = s.addAudioTrack('FX')
|
||
const g = s.addTrackGroup('G')
|
||
s.moveTrackIntoGroup(at.id, g.id)
|
||
expect(g.childIds).toContain(at.id)
|
||
expect((s.scene.trackOrder ?? []).includes(at.id)).toBe(false)
|
||
})
|
||
|
||
test('group color cascades to script + audio children alongside layers', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'Mix', width: 10, height: 10, fill: '#000' })
|
||
const l = s.addLayer(a.id)!
|
||
const st = s.addScriptTrack('Script')
|
||
const at = s.addAudioTrack('Audio')
|
||
const g = s.addTrackGroup('G')
|
||
s.setTrackColor(g.id, '#ff8800')
|
||
s.moveTrackIntoGroup(l.id, g.id)
|
||
s.moveTrackIntoGroup(st.id, g.id)
|
||
s.moveTrackIntoGroup(at.id, g.id)
|
||
expect(l.color).toBe('#ff8800')
|
||
expect(st.color).toBe('#ff8800')
|
||
expect(at.color).toBe('#ff8800')
|
||
// None of them were explicitly recoloured — all inherit, none pin.
|
||
expect(l.colorOverridden).toBe(undefined)
|
||
expect(st.colorOverridden).toBe(undefined)
|
||
expect(at.colorOverridden).toBe(undefined)
|
||
})
|
||
|
||
test('normalizeScene preserves script + audio tracks as group children', () => {
|
||
// Legacy loads used to strip anything other than layers / clip
|
||
// tracks from childIds. All four non-group kinds must survive
|
||
// the roundtrip now.
|
||
const s = makeFreshStore()
|
||
const st = s.addScriptTrack('S')
|
||
const at = s.addAudioTrack('A')
|
||
const g = s.addTrackGroup('G')
|
||
const snap = JSON.parse(JSON.stringify(s.scene))
|
||
snap.groups = [{ id: g.id, name: 'G', childIds: [st.id, at.id] }]
|
||
snap.trackOrder = [g.id]
|
||
s.loadScene(snap)
|
||
const loaded = s.scene.groups?.find((x) => x.id === g.id)!
|
||
expect(loaded.childIds).toContain(st.id)
|
||
expect(loaded.childIds).toContain(at.id)
|
||
expect((s.scene.trackOrder ?? []).includes(st.id)).toBe(false)
|
||
expect((s.scene.trackOrder ?? []).includes(at.id)).toBe(false)
|
||
})
|
||
})
|
||
|
||
suite('scene store — duplicateTrack', () => {
|
||
test('duplicates a layer with fresh keyframe ids right below the original', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'D1', width: 10, height: 10, fill: '#abc' })
|
||
const l = s.addLayer(a.id, 'Orig')!
|
||
s.upsertKeyframe(l.id, 1, { x: 10 })
|
||
s.upsertKeyframe(l.id, 2, { x: 20 })
|
||
const origKfIds = new Set(l.keyframes.map((k) => k.id))
|
||
|
||
const newId = s.duplicateTrack(l.id)
|
||
expect(newId).toBeTruthy()
|
||
const dup = s.scene.layers.find((x) => x.id === newId)!
|
||
expect(dup).toBeTruthy()
|
||
expect(dup.id === l.id).toBe(false)
|
||
expect(dup.name).toBe('Orig')
|
||
expect(dup.assetId).toBe(a.id)
|
||
expect(dup.keyframes.length).toBe(l.keyframes.length)
|
||
// Fresh ids for every keyframe so history/undo never aliases.
|
||
for (const k of dup.keyframes) expect(origKfIds.has(k.id)).toBe(false)
|
||
// Clone sits immediately after the original in trackOrder.
|
||
const order = s.scene.trackOrder!
|
||
expect(order.indexOf(newId!)).toBe(order.indexOf(l.id) + 1)
|
||
})
|
||
|
||
test('layer duplicate is deeply independent of the original', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'D2', width: 10, height: 10, fill: '#bcd' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 1, { x: 10 })
|
||
|
||
const newId = s.duplicateTrack(l.id)!
|
||
const dup = s.scene.layers.find((x) => x.id === newId)!
|
||
// Mutate the clone — the original must not see the change.
|
||
dup.base.x = 999
|
||
dup.keyframes[0].props.x = 999
|
||
const origAfter = s.scene.layers.find((x) => x.id === l.id)!
|
||
expect(origAfter.base.x === 999).toBe(false)
|
||
expect(origAfter.keyframes[0].props.x === 999).toBe(false)
|
||
})
|
||
|
||
test('layer duplicate copies separated position/rotation kf arrays with fresh ids', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'D3', width: 10, height: 10, fill: '#cde' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 1, { x: 5, y: 5, rotation: 45 })
|
||
s.setLayerSeparated(l.id, true)
|
||
const before = s.scene.layers.find((x) => x.id === l.id)!
|
||
const origPosIds = new Set((before.positionKeyframes ?? []).map((k) => k.id))
|
||
const origRotIds = new Set((before.rotationKeyframes ?? []).map((k) => k.id))
|
||
|
||
const newId = s.duplicateTrack(l.id)!
|
||
const dup = s.scene.layers.find((x) => x.id === newId)!
|
||
expect(dup.separated).toBe(true)
|
||
expect((dup.positionKeyframes ?? []).length).toBe((before.positionKeyframes ?? []).length)
|
||
expect((dup.rotationKeyframes ?? []).length).toBe((before.rotationKeyframes ?? []).length)
|
||
for (const k of dup.positionKeyframes ?? []) expect(origPosIds.has(k.id)).toBe(false)
|
||
for (const k of dup.rotationKeyframes ?? []) expect(origRotIds.has(k.id)).toBe(false)
|
||
})
|
||
|
||
test('duplicating a grouped layer keeps the clone in the same group', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'D4', width: 10, height: 10, fill: '#def' })
|
||
const l = s.addLayer(a.id)!
|
||
const g = s.addTrackGroup('G')
|
||
s.moveTrackIntoGroup(l.id, g.id)
|
||
const newId = s.duplicateTrack(l.id)!
|
||
const group = s.scene.groups!.find((x) => x.id === g.id)!
|
||
// Duplicate lands in the group, directly after the original.
|
||
expect(group.childIds.indexOf(newId)).toBe(group.childIds.indexOf(l.id) + 1)
|
||
// Grouped children never leak into trackOrder.
|
||
expect((s.scene.trackOrder ?? []).includes(newId)).toBe(false)
|
||
})
|
||
|
||
test('duplicates a clip track along with its clip instances', () => {
|
||
const s = makeFreshStore()
|
||
const clip = s.addClip('C')
|
||
clip.duration = 5
|
||
const origTrack = s.addClipTrack('track-A')
|
||
const ci1 = s.addClipInstance(clip.id, 0, origTrack.id)!
|
||
const ci2 = s.addClipInstance(clip.id, 2, origTrack.id)!
|
||
|
||
const newId = s.duplicateTrack(origTrack.id)
|
||
expect(newId).toBeTruthy()
|
||
expect(newId === origTrack.id).toBe(false)
|
||
const newTrack = s.scene.clipTracks!.find((t) => t.id === newId)!
|
||
expect(newTrack.name).toBe('track-A')
|
||
// Original instances untouched.
|
||
expect(s.scene.clipInstances!.find((ci) => ci.id === ci1.id)!.clipTrackId).toBe(origTrack.id)
|
||
expect(s.scene.clipInstances!.find((ci) => ci.id === ci2.id)!.clipTrackId).toBe(origTrack.id)
|
||
// Clone track carries copies with fresh ids and same clipId/startTime.
|
||
const cloneInstances = s.scene.clipInstances!.filter((ci) => ci.clipTrackId === newId)
|
||
expect(cloneInstances).toHaveLength(2)
|
||
const startTimes = cloneInstances.map((ci) => ci.startTime).sort((a, b) => a - b)
|
||
expect(startTimes).toEqual([0, 2])
|
||
for (const ci of cloneInstances) {
|
||
expect(ci.clipId).toBe(clip.id)
|
||
expect(ci.id === ci1.id).toBe(false)
|
||
expect(ci.id === ci2.id).toBe(false)
|
||
}
|
||
// Slotted right after the original in trackOrder.
|
||
const order = s.scene.trackOrder!
|
||
expect(order.indexOf(newId!)).toBe(order.indexOf(origTrack.id) + 1)
|
||
})
|
||
|
||
test('duplicates a script track along with its frames', () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
const origTrack = s.addScriptTrack('triggers')
|
||
const f1 = s.addScriptFrame(origTrack.id, 1, [{ type: 'pause' }])!
|
||
const f2 = s.addScriptFrame(origTrack.id, 3, [{ type: 'play' }])!
|
||
|
||
const newId = s.duplicateTrack(origTrack.id)!
|
||
expect(newId === origTrack.id).toBe(false)
|
||
const newTrack = s.scene.scriptTracks!.find((t) => t.id === newId)!
|
||
expect(newTrack.name).toBe('triggers')
|
||
// Original frames untouched.
|
||
expect(s.scene.scriptFrames!.find((f) => f.id === f1.id)!.trackId).toBe(origTrack.id)
|
||
expect(s.scene.scriptFrames!.find((f) => f.id === f2.id)!.trackId).toBe(origTrack.id)
|
||
// Clone has its own frames at the same times with matching actions.
|
||
const cloneFrames = s.scene.scriptFrames!.filter((f) => f.trackId === newId)
|
||
expect(cloneFrames).toHaveLength(2)
|
||
const times = cloneFrames.map((f) => f.time).sort((a, b) => a - b)
|
||
expect(times).toEqual([1, 3])
|
||
for (const f of cloneFrames) {
|
||
expect(f.id === f1.id).toBe(false)
|
||
expect(f.id === f2.id).toBe(false)
|
||
}
|
||
})
|
||
|
||
test('returns null for unknown id and for group ids', () => {
|
||
const s = makeFreshStore()
|
||
expect(s.duplicateTrack('nope-no-such-track')).toBe(null)
|
||
const g = s.addTrackGroup('G')
|
||
expect(s.duplicateTrack(g.id)).toBe(null)
|
||
})
|
||
})
|
||
|
||
suite('app smoke — live DOM', () => {
|
||
test('app root, topbar, timeline, stage are mounted', () => {
|
||
expect(document.querySelector('.app')).toBeTruthy()
|
||
expect(document.querySelector('.topbar')).toBeTruthy()
|
||
expect(document.querySelector('.timeline')).toBeTruthy()
|
||
expect(document.querySelector('.stage')).toBeTruthy()
|
||
})
|
||
|
||
test('timeline snap toggle button is present', () => {
|
||
expect(document.querySelector('.snap-btn')).toBeTruthy()
|
||
})
|
||
|
||
test('topbar has save & recents buttons', () => {
|
||
// Tooltips live in `aria-label` now (custom v-tooltip directive
|
||
// replaced the native `title` attribute on every interactive
|
||
// element).
|
||
const saveBtn = document.querySelector('button[aria-label^="Save project"]')
|
||
const recentsBtn = document.querySelector('button[aria-label="Recent projects"]')
|
||
expect(saveBtn).toBeTruthy()
|
||
expect(recentsBtn).toBeTruthy()
|
||
})
|
||
|
||
test('canvas has crop toggle button', () => {
|
||
const cropBtn = document.querySelector('.crop-btn')
|
||
expect(cropBtn).toBeTruthy()
|
||
})
|
||
|
||
test('brand button opens about dialog', () => {
|
||
const brand = document.querySelector('button.brand')
|
||
expect(brand).toBeTruthy()
|
||
})
|
||
|
||
test('new-track dropzone is hidden while the clip library is empty', () => {
|
||
// Regression: the dropzone used to always render and show "Drop a
|
||
// clip here" even when the library had no clips. Timeline now gates
|
||
// `.clip-track-dropzone` behind `scene.clips.length > 0`. The live
|
||
// smoke scene starts blank, so the dropzone must be absent.
|
||
const dz = document.querySelector('.clip-track-dropzone')
|
||
expect(dz).toBeNull()
|
||
})
|
||
|
||
test('timeline has add-group button', () => {
|
||
const btn = document.querySelector('button[aria-label="Create a new track group"]')
|
||
expect(btn).toBeTruthy()
|
||
})
|
||
})
|
||
|
||
suite('timeline — backspace deletes selected layer', () => {
|
||
// Regression: Backspace / Delete used to only remove selected
|
||
// keyframes and markers. The selected layer track had no shortcut
|
||
// — the user had to use the context menu. Timeline's document-level
|
||
// keydown now also deletes the currently-selected layer, skipping
|
||
// locked layers (direct or via a locked parent group).
|
||
|
||
async function clearTimelineSelections() {
|
||
// The timeline's marker selection lives in a component-local ref
|
||
// that the store reset doesn't touch. Two Escape presses clear
|
||
// keyframes first, then markers — matches the handler's priority.
|
||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
||
await nextTick()
|
||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
||
await nextTick()
|
||
}
|
||
|
||
test('Backspace removes the selected layer and clears the selection', async () => {
|
||
const s = makeFreshStore()
|
||
await clearTimelineSelections()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
s.selectLayer(l.id)
|
||
expect(s.scene.layers.some((x) => x.id === l.id)).toBe(true)
|
||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace' }))
|
||
await nextTick()
|
||
expect(s.scene.layers.some((x) => x.id === l.id)).toBe(false)
|
||
expect(s.selectedLayerId).toBe(null)
|
||
})
|
||
|
||
test('Delete also removes the selected layer', async () => {
|
||
const s = makeFreshStore()
|
||
await clearTimelineSelections()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
s.selectLayer(l.id)
|
||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete' }))
|
||
await nextTick()
|
||
expect(s.scene.layers.some((x) => x.id === l.id)).toBe(false)
|
||
})
|
||
|
||
test('Backspace is a no-op while the layer itself is locked', async () => {
|
||
const s = makeFreshStore()
|
||
await clearTimelineSelections()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
s.selectLayer(l.id)
|
||
s.setTrackLocked(l.id, true)
|
||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace' }))
|
||
await nextTick()
|
||
expect(s.scene.layers.some((x) => x.id === l.id)).toBe(true)
|
||
})
|
||
|
||
test('Backspace is a no-op while the parent group is locked', async () => {
|
||
const s = makeFreshStore()
|
||
await clearTimelineSelections()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
const g = s.addTrackGroup('G')
|
||
s.moveTrackIntoGroup(l.id, g.id)
|
||
s.setTrackLocked(g.id, true)
|
||
s.selectLayer(l.id)
|
||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace' }))
|
||
await nextTick()
|
||
expect(s.scene.layers.some((x) => x.id === l.id)).toBe(true)
|
||
})
|
||
|
||
test('Backspace while typing in an input does not touch the selected layer', async () => {
|
||
const s = makeFreshStore()
|
||
await clearTimelineSelections()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
s.selectLayer(l.id)
|
||
// Mount an input, focus it, dispatch Backspace FROM the input.
|
||
const input = document.createElement('input')
|
||
input.setAttribute('data-test-input', '')
|
||
document.body.appendChild(input)
|
||
input.focus()
|
||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true }))
|
||
await nextTick()
|
||
expect(s.scene.layers.some((x) => x.id === l.id)).toBe(true)
|
||
input.remove()
|
||
})
|
||
|
||
test('Backspace with no layer selected is a no-op', async () => {
|
||
const s = makeFreshStore()
|
||
await clearTimelineSelections()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
// Explicitly deselect
|
||
s.selectLayer(null)
|
||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace' }))
|
||
await nextTick()
|
||
expect(s.scene.layers.some((x) => x.id === l.id)).toBe(true)
|
||
})
|
||
})
|
||
|
||
suite('version — app metadata', () => {
|
||
test('APP_VERSION is a semver-like string', () => {
|
||
expect(typeof APP_VERSION === 'string').toBe(true)
|
||
expect(/^\d+\.\d+\.\d+/.test(APP_VERSION)).toBe(true)
|
||
})
|
||
|
||
test('APP_NAME is non-empty', () => {
|
||
expect(APP_NAME.length).toBeGreaterThan(0)
|
||
})
|
||
})
|
||
|
||
suite('kf selection — shared composable', () => {
|
||
test('setting selection reflects across handles', () => {
|
||
const { selectedKfs } = useKfSelection()
|
||
const snapshot = selectedKfs.value
|
||
try {
|
||
selectedKfs.value = [
|
||
{ layerId: 'L1', kind: 'main', keyframeId: 'K1' },
|
||
{ layerId: 'L2', kind: 'position', keyframeId: 'K2' },
|
||
]
|
||
const second = useKfSelection()
|
||
expect(second.selectedKfs.value).toHaveLength(2)
|
||
expect(second.selectedKfSet.value.has('L1:main:K1')).toBe(true)
|
||
expect(second.selectedKfSet.value.has('L2:position:K2')).toBe(true)
|
||
} finally {
|
||
selectedKfs.value = snapshot
|
||
}
|
||
})
|
||
|
||
test('kfSelectionKey builds stable id', () => {
|
||
expect(kfSelectionKey({ layerId: 'a', kind: 'main', keyframeId: 'b' })).toBe('a:main:b')
|
||
expect(kfSelectionKey({ layerId: 'x', kind: 'position', keyframeId: 'y' })).toBe(
|
||
'x:position:y',
|
||
)
|
||
})
|
||
})
|
||
|
||
suite('assets — edge blur', () => {
|
||
test('rect asset stores blur value', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({
|
||
kind: 'rect',
|
||
name: 'R',
|
||
width: 40,
|
||
height: 40,
|
||
fill: '#ff0000',
|
||
blur: 12,
|
||
})
|
||
const stored = s.scene.assets.find((x) => x.id === a.id)!
|
||
expect(stored.kind).toBe('rect')
|
||
expect((stored as Extract<typeof stored, { kind: 'rect' }>).blur).toBe(12)
|
||
})
|
||
|
||
test('circle asset stores blur value', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({
|
||
kind: 'circle',
|
||
name: 'C',
|
||
radius: 30,
|
||
fill: '#0000ff',
|
||
blur: 6,
|
||
})
|
||
const stored = s.scene.assets.find((x) => x.id === a.id)!
|
||
expect(stored.kind).toBe('circle')
|
||
expect((stored as Extract<typeof stored, { kind: 'circle' }>).blur).toBe(6)
|
||
})
|
||
|
||
test('drawSceneFrame sets ctx.filter to blur() for blurred rect', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({
|
||
kind: 'rect',
|
||
name: 'Blurred',
|
||
width: 40,
|
||
height: 40,
|
||
fill: '#ff4488',
|
||
blur: 18,
|
||
})
|
||
s.addLayer(a.id)!
|
||
const { ctx, filterSets } = makeRecordingCtx()
|
||
drawSceneFrame(ctx, s.scene, 0, {})
|
||
expect(filterSets.some((v) => v === 'blur(18px)')).toBe(true)
|
||
})
|
||
|
||
test('drawSceneFrame does not apply blur when blur is unset or zero', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({
|
||
kind: 'rect',
|
||
name: 'Sharp',
|
||
width: 40,
|
||
height: 40,
|
||
fill: '#123456',
|
||
})
|
||
s.addLayer(a.id)!
|
||
const { ctx, filterSets } = makeRecordingCtx()
|
||
drawSceneFrame(ctx, s.scene, 0, {})
|
||
// The only filter writes should be the post-draw reset to 'none'
|
||
expect(filterSets.every((v) => v === 'none')).toBe(true)
|
||
})
|
||
|
||
test('drawSceneFrame applies blur for circle asset', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({
|
||
kind: 'circle',
|
||
name: 'CB',
|
||
radius: 20,
|
||
fill: '#22aa22',
|
||
blur: 9,
|
||
})
|
||
s.addLayer(a.id)!
|
||
const { ctx, filterSets } = makeRecordingCtx()
|
||
drawSceneFrame(ctx, s.scene, 0, {})
|
||
expect(filterSets.some((v) => v === 'blur(9px)')).toBe(true)
|
||
})
|
||
})
|
||
|
||
suite('mask layers — store', () => {
|
||
test('setLayerMask(true) enables negative mask by default', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'M', width: 40, height: 40, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
s.setLayerMask(l.id, true)
|
||
const layer = s.scene.layers.find((x) => x.id === l.id)!
|
||
expect(layer.isMask).toBe(true)
|
||
expect(layer.maskMode).toBe('negative')
|
||
})
|
||
|
||
test('setLayerMask(true, "negative") stores negative mode', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'circle', name: 'N', radius: 20, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
s.setLayerMask(l.id, true, 'negative')
|
||
const layer = s.scene.layers.find((x) => x.id === l.id)!
|
||
expect(layer.maskMode).toBe('negative')
|
||
})
|
||
|
||
test('setLayerMask(false) clears mask flags', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'M', width: 40, height: 40, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
s.setLayerMask(l.id, true, 'negative')
|
||
s.setLayerMask(l.id, false)
|
||
const layer = s.scene.layers.find((x) => x.id === l.id)!
|
||
expect(layer.isMask).toBe(undefined)
|
||
expect(layer.maskMode).toBe(undefined)
|
||
})
|
||
|
||
test('setLayerMaskMode flips mode without changing isMask', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'M', width: 40, height: 40, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
s.setLayerMask(l.id, true)
|
||
s.setLayerMaskMode(l.id, 'negative')
|
||
const layer = s.scene.layers.find((x) => x.id === l.id)!
|
||
expect(layer.isMask).toBe(true)
|
||
expect(layer.maskMode).toBe('negative')
|
||
})
|
||
})
|
||
|
||
suite('mask layers — rendering', () => {
|
||
function setupMaskScene(mode: 'positive' | 'negative') {
|
||
const s = makeFreshStore()
|
||
s.scene.canvas.width = 100
|
||
s.scene.canvas.height = 100
|
||
s.scene.canvas.background = 'transparent'
|
||
// Content: full-canvas red rect
|
||
const content = s.addAsset({
|
||
kind: 'rect',
|
||
name: 'BG',
|
||
width: 100,
|
||
height: 100,
|
||
fill: '#ff0000',
|
||
})
|
||
const contentLayer = s.addLayer(content.id)!
|
||
s.upsertKeyframe(contentLayer.id, 0, { x: 50, y: 50 })
|
||
// Mask: small circle centered at (50,50)
|
||
const mask = s.addAsset({
|
||
kind: 'circle',
|
||
name: 'M',
|
||
radius: 20,
|
||
fill: '#ffffff',
|
||
})
|
||
const maskLayer = s.addLayer(mask.id)!
|
||
s.upsertKeyframe(maskLayer.id, 0, { x: 50, y: 50 })
|
||
s.setLayerMask(maskLayer.id, true, mode)
|
||
const group = s.addTrackGroup('G')
|
||
s.moveTrackIntoGroup(maskLayer.id, group.id)
|
||
s.moveTrackIntoGroup(contentLayer.id, group.id)
|
||
return { store: s }
|
||
}
|
||
|
||
test('positive mask keeps content inside the mask shape and clears outside', () => {
|
||
const { store } = setupMaskScene('positive')
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = 100
|
||
canvas.height = 100
|
||
const ctx = canvas.getContext('2d')!
|
||
drawSceneFrame(ctx, store.scene, 0, {})
|
||
const inside = ctx.getImageData(50, 50, 1, 1).data
|
||
const outside = ctx.getImageData(5, 5, 1, 1).data
|
||
// Inside mask: red content visible
|
||
expect(inside[0] > 200).toBe(true)
|
||
expect(inside[3] > 200).toBe(true)
|
||
// Outside mask: cleared (alpha 0)
|
||
expect(outside[3]).toBe(0)
|
||
})
|
||
|
||
test('negative mask punches a hole in the content where the mask overlaps', () => {
|
||
const { store } = setupMaskScene('negative')
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = 100
|
||
canvas.height = 100
|
||
const ctx = canvas.getContext('2d')!
|
||
drawSceneFrame(ctx, store.scene, 0, {})
|
||
const inside = ctx.getImageData(50, 50, 1, 1).data
|
||
const outside = ctx.getImageData(5, 5, 1, 1).data
|
||
// Inside mask: hole (alpha 0)
|
||
expect(inside[3]).toBe(0)
|
||
// Outside mask: red content still there
|
||
expect(outside[0] > 200).toBe(true)
|
||
expect(outside[3] > 200).toBe(true)
|
||
})
|
||
|
||
test('layer outside a masked group is not affected by the mask', () => {
|
||
const { store } = setupMaskScene('positive')
|
||
// Add a green rect layer OUTSIDE the group
|
||
const outer = store.addAsset({
|
||
kind: 'rect',
|
||
name: 'Outer',
|
||
width: 20,
|
||
height: 20,
|
||
fill: '#00ff00',
|
||
})
|
||
const outerLayer = store.addLayer(outer.id)!
|
||
store.upsertKeyframe(outerLayer.id, 0, { x: 90, y: 90 })
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = 100
|
||
canvas.height = 100
|
||
const ctx = canvas.getContext('2d')!
|
||
drawSceneFrame(ctx, store.scene, 0, {})
|
||
// The outer layer renders fully regardless of mask
|
||
const outerPx = ctx.getImageData(90, 90, 1, 1).data
|
||
expect(outerPx[1] > 200).toBe(true)
|
||
expect(outerPx[3] > 200).toBe(true)
|
||
})
|
||
})
|
||
|
||
suite('scene store — shiftKeyframesPosition', () => {
|
||
test('shifts x/y of a single keyframe by delta', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'Shift', width: 40, height: 40, fill: '#a55' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 1, { x: 100, y: 50 })
|
||
const kf = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
|
||
s.shiftKeyframesPosition(
|
||
[{ layerId: l.id, kind: 'main', keyframeId: kf.id }],
|
||
25,
|
||
-10,
|
||
)
|
||
await tick()
|
||
expect(kf.props.x).toBe(125)
|
||
expect(kf.props.y).toBe(40)
|
||
})
|
||
|
||
test('shifts multiple keyframes across layers by the same delta', async () => {
|
||
const s = makeFreshStore()
|
||
const a1 = s.addAsset({ kind: 'rect', name: 'A', width: 10, height: 10, fill: '#111' })
|
||
const a2 = s.addAsset({ kind: 'rect', name: 'B', width: 10, height: 10, fill: '#222' })
|
||
const l1 = s.addLayer(a1.id)!
|
||
const l2 = s.addLayer(a2.id)!
|
||
s.upsertKeyframe(l1.id, 1, { x: 100, y: 100 })
|
||
s.upsertKeyframe(l2.id, 2, { x: 200, y: 300 })
|
||
const k1 = l1.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
|
||
const k2 = l2.keyframes.find((k) => Math.abs(k.time - 2) < 1e-3)!
|
||
s.shiftKeyframesPosition(
|
||
[
|
||
{ layerId: l1.id, kind: 'main', keyframeId: k1.id },
|
||
{ layerId: l2.id, kind: 'main', keyframeId: k2.id },
|
||
],
|
||
10,
|
||
5,
|
||
)
|
||
await tick()
|
||
expect(k1.props.x).toBe(110)
|
||
expect(k1.props.y).toBe(105)
|
||
expect(k2.props.x).toBe(210)
|
||
expect(k2.props.y).toBe(305)
|
||
})
|
||
|
||
test('skips rotation keyframes (no x/y)', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#333' })
|
||
const l = s.addLayer(a.id)!
|
||
s.setLayerSeparated(l.id, true)
|
||
s.upsertKeyframe(l.id, 1, { rotation: 45 })
|
||
const rotKf = (l.rotationKeyframes ?? []).find((k) => Math.abs(k.time - 1) < 1e-3)!
|
||
s.shiftKeyframesPosition(
|
||
[{ layerId: l.id, kind: 'rotation', keyframeId: rotKf.id }],
|
||
100,
|
||
100,
|
||
)
|
||
await tick()
|
||
expect((rotKf.props as Record<string, unknown>).x).toBe(undefined)
|
||
expect((rotKf.props as Record<string, unknown>).y).toBe(undefined)
|
||
expect(rotKf.props.rotation).toBe(45)
|
||
})
|
||
|
||
test('uses sampled fallback when keyframe has no explicit x/y', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'Fb', width: 10, height: 10, fill: '#444' })
|
||
const l = s.addLayer(a.id)!
|
||
// initial keyframe at t=0 has full base props (including x=640 from canvas center)
|
||
// Create a later keyframe that only sets scaleX
|
||
s.upsertKeyframe(l.id, 2, { scaleX: 2 })
|
||
const kf = l.keyframes.find((k) => Math.abs(k.time - 2) < 1e-3)!
|
||
// ensure x is not explicitly set
|
||
delete (kf.props as Record<string, unknown>).x
|
||
delete (kf.props as Record<string, unknown>).y
|
||
const sampledBefore = sampleLayer(l, 2)
|
||
s.shiftKeyframesPosition([{ layerId: l.id, kind: 'main', keyframeId: kf.id }], 7, 11)
|
||
await tick()
|
||
expect(kf.props.x).toBe(sampledBefore.x + 7)
|
||
expect(kf.props.y).toBe(sampledBefore.y + 11)
|
||
})
|
||
|
||
test('non-existing layer/keyframe refs are safely ignored', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#555' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 1, { x: 50 })
|
||
// Call with mix of valid and invalid refs — should not throw
|
||
s.shiftKeyframesPosition(
|
||
[
|
||
{ layerId: 'missing-layer', kind: 'main', keyframeId: 'missing-kf' },
|
||
{ layerId: l.id, kind: 'main', keyframeId: 'also-missing' },
|
||
],
|
||
10,
|
||
10,
|
||
)
|
||
// Valid keyframe at t=1 remained unshifted (we didn't target it)
|
||
const kf = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
|
||
expect(kf.props.x).toBe(50)
|
||
})
|
||
})
|
||
|
||
suite('scene store — shiftSceneWorldCoords (resize)', () => {
|
||
test('shifts base + keyframe x/y across every scene page', () => {
|
||
const s = makeFreshStore()
|
||
// Second page so we can check the shift touches pages other
|
||
// than the active one too.
|
||
s.addScene('Second')
|
||
s.switchScene(s.project.scenes[0].id)
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#abc' })
|
||
const l1 = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l1.id, 0, { x: 100, y: 50 })
|
||
s.switchScene(s.project.scenes[1].id)
|
||
const l2 = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l2.id, 0, { x: 200, y: 80 })
|
||
const beforeBase1 = { ...l1.base }
|
||
const beforeBase2 = { ...l2.base }
|
||
s.shiftSceneWorldCoords(30, -10)
|
||
expect(l1.base.x).toBe(beforeBase1.x + 30)
|
||
expect(l1.base.y).toBe(beforeBase1.y - 10)
|
||
expect(l2.base.x).toBe(beforeBase2.x + 30)
|
||
expect(l2.base.y).toBe(beforeBase2.y - 10)
|
||
const k1 = l1.keyframes.find((k) => k.time === 0)!
|
||
const k2 = l2.keyframes.find((k) => k.time === 0)!
|
||
expect(k1.props.x).toBe(130)
|
||
expect(k1.props.y).toBe(40)
|
||
expect(k2.props.x).toBe(230)
|
||
expect(k2.props.y).toBe(70)
|
||
})
|
||
|
||
test('shifts positionKeyframes on separated layers', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#111' })
|
||
const l = s.addLayer(a.id)!
|
||
s.setLayerSeparated(l.id, true)
|
||
s.upsertKeyframe(l.id, 1, { x: 10, y: 20 })
|
||
const pk = (l.positionKeyframes ?? []).find((k) => k.time === 1)!
|
||
s.shiftSceneWorldCoords(5, 7)
|
||
expect(pk.props.x).toBe(15)
|
||
expect(pk.props.y).toBe(27)
|
||
})
|
||
|
||
test('shifts keyframe bezier control points (ctrlX / ctrlY)', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#abc' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 1, { x: 100, y: 50 })
|
||
const kf = l.keyframes.find((k) => k.time === 1)!
|
||
kf.ctrlX = 80
|
||
kf.ctrlY = 30
|
||
s.shiftSceneWorldCoords(20, 15)
|
||
expect(kf.ctrlX).toBe(100)
|
||
expect(kf.ctrlY).toBe(45)
|
||
})
|
||
|
||
test('shifts clipInstance x/y but leaves clip-library layer keyframes alone', () => {
|
||
const s = makeFreshStore()
|
||
const c = s.addClip('Lib')
|
||
c.duration = 2
|
||
// Clip-library layer: should NOT be shifted (local to clip origin)
|
||
c.layers = [
|
||
{
|
||
id: 'lib-layer',
|
||
name: 'L',
|
||
assetId: 'none',
|
||
visible: true,
|
||
base: { x: 10, y: 10, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
|
||
keyframes: [
|
||
{
|
||
id: 'k1',
|
||
time: 0,
|
||
props: { x: 10, y: 10 },
|
||
easing: 'linear',
|
||
},
|
||
],
|
||
},
|
||
]
|
||
const t = s.scene.clipTracks![0]
|
||
const ci = s.addClipInstance(c.id, 0, t.id)!
|
||
ci.x = 40
|
||
ci.y = 60
|
||
s.shiftSceneWorldCoords(7, -3)
|
||
// Clip instance's static offset shifted with the world.
|
||
expect(ci.x).toBe(47)
|
||
expect(ci.y).toBe(57)
|
||
// Clip library's inner layer untouched.
|
||
expect(c.layers[0].base.x).toBe(10)
|
||
expect(c.layers[0].keyframes[0].props.x).toBe(10)
|
||
})
|
||
|
||
test('(0, 0) delta is a no-op', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#abc' })
|
||
const l = s.addLayer(a.id)!
|
||
const before = { ...l.base }
|
||
s.shiftSceneWorldCoords(0, 0)
|
||
expect(l.base.x).toBe(before.x)
|
||
expect(l.base.y).toBe(before.y)
|
||
})
|
||
})
|
||
|
||
suite('clip bounds — stable frame rect', () => {
|
||
// The stage wraps each ClipInstance in an invisible frame rect
|
||
// sized to the union of every clip layer's footprint across all
|
||
// keyframes. Keeps the canvas transformer's handles on the
|
||
// clip's own outer constraints instead of chasing a moving child
|
||
// shape, and gives empty-playhead frames a clickable hit area.
|
||
test('union bbox covers every layer at its extreme keyframes', () => {
|
||
const s = makeFreshStore()
|
||
const rectAsset = s.addAsset({
|
||
kind: 'rect', name: 'R', width: 40, height: 40, fill: '#f00',
|
||
})
|
||
const clip = s.addClip('CB')
|
||
s.enterClip(clip.id)
|
||
clip.duration = 5
|
||
const l = s.addLayer(rectAsset.id)!
|
||
// Move the layer from (0,0) to (200,120) between t=0 and t=clip.duration.
|
||
s.upsertKeyframe(l.id, 0, { x: 0, y: 0 })
|
||
s.upsertKeyframe(l.id, 4, { x: 200, y: 120 })
|
||
s.exitClip()
|
||
|
||
const assets = new Map(s.scene.assets.map((a) => [a.id, a]))
|
||
const bounds = computeClipBounds(clip, assets)
|
||
expect(bounds).toBeTruthy()
|
||
// The layer is 40×40 centered on its origin (default offset=20,20).
|
||
// At t=0 it sits around (0,0); at t=4 it sits around (200,120).
|
||
// Union must at least cover both extremes' centers.
|
||
expect(bounds!.x).toBeLessThan(-5)
|
||
expect(bounds!.y).toBeLessThan(-5)
|
||
expect(bounds!.x + bounds!.width).toBeGreaterThan(200 + 5)
|
||
expect(bounds!.y + bounds!.height).toBeGreaterThan(120 + 5)
|
||
})
|
||
|
||
test('clip without measurable layers returns null (caller uses fallback)', () => {
|
||
const s = makeFreshStore()
|
||
const clip = s.addClip('Empty')
|
||
const assets = new Map(s.scene.assets.map((a) => [a.id, a]))
|
||
expect(computeClipBounds(clip, assets)).toBeNull()
|
||
// Fallback is a small centred square so the transformer still
|
||
// has something to grab.
|
||
expect(CLIP_FALLBACK_BOUNDS.width).toBeGreaterThan(0)
|
||
expect(CLIP_FALLBACK_BOUNDS.height).toBeGreaterThan(0)
|
||
})
|
||
|
||
test('bounds scale with per-keyframe scaleX/scaleY', () => {
|
||
const s = makeFreshStore()
|
||
const rectAsset = s.addAsset({
|
||
kind: 'rect', name: 'R', width: 40, height: 40, fill: '#f00',
|
||
})
|
||
const clip = s.addClip('CB')
|
||
s.enterClip(clip.id)
|
||
clip.duration = 3
|
||
const l = s.addLayer(rectAsset.id)!
|
||
// Grow the layer 4× at t=2.
|
||
s.upsertKeyframe(l.id, 0, { x: 0, y: 0, scaleX: 1, scaleY: 1 })
|
||
s.upsertKeyframe(l.id, 2, { x: 0, y: 0, scaleX: 4, scaleY: 4 })
|
||
s.exitClip()
|
||
const assets = new Map(s.scene.assets.map((a) => [a.id, a]))
|
||
const bounds = computeClipBounds(clip, assets)
|
||
expect(bounds).toBeTruthy()
|
||
// 4× a 40-wide rect = 160 across → bbox is at least that wide
|
||
// (pre-rotation padding pushes it a bit bigger, which is fine).
|
||
expect(bounds!.width).toBeGreaterThan(159)
|
||
expect(bounds!.height).toBeGreaterThan(159)
|
||
})
|
||
})
|
||
|
||
suite('svg split', () => {
|
||
// A minimal SVG with two non-overlapping paths. Absolute
|
||
// coordinates so getBBox returns predictable values under jsdom/
|
||
// Chrome. The split helper should emit two standalone SVG parts.
|
||
const sampleSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="100" height="60" viewBox="0 0 100 60">
|
||
<rect id="left" x="0" y="0" width="40" height="40" fill="#f00" />
|
||
<rect id="right" x="60" y="20" width="40" height="40" fill="#0f0" />
|
||
</svg>`
|
||
|
||
function toDataUrl(svg: string): string {
|
||
return 'data:image/svg+xml,' + encodeURIComponent(svg)
|
||
}
|
||
|
||
test('splitSvg returns one part per top-level drawable', async () => {
|
||
const { splitSvg } = await import('@/lib/svgSplit')
|
||
const out = splitSvg(sampleSvg)
|
||
expect(out).toBeTruthy()
|
||
expect(out!.parts.length).toBe(2)
|
||
expect(out!.parts[0].svgText).toContain('<rect')
|
||
expect(out!.parts[0].svgText).toContain('id="left"')
|
||
// Each part keeps the ORIGINAL width/height/viewBox so the store
|
||
// can reuse the layer's position without any geometry math.
|
||
expect(out!.parts[0].svgText).toContain('width="100"')
|
||
expect(out!.parts[0].svgText).toContain('viewBox="0 0 100 60"')
|
||
expect(out!.parts[0].dataUrl.startsWith('data:image/svg+xml')).toBe(true)
|
||
})
|
||
|
||
test('splitSvg returns null for a single-element SVG (nothing to split)', async () => {
|
||
const { splitSvg } = await import('@/lib/svgSplit')
|
||
const one = `<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40"><rect width="40" height="40" fill="#333"/></svg>`
|
||
expect(splitSvg(one)).toBeNull()
|
||
})
|
||
|
||
test('splitSvg data URLs keep # encoded so hex colours survive (regression)', async () => {
|
||
// A raw `#` in the data URL is parsed as a fragment separator
|
||
// by every browser, which truncates the SVG at the first colour
|
||
// value. The encoder has to leave `#` percent-encoded even
|
||
// though it makes the URL a bit longer.
|
||
const { splitSvg } = await import('@/lib/svgSplit')
|
||
const out = splitSvg(sampleSvg)
|
||
expect(out).toBeTruthy()
|
||
for (const p of out!.parts) {
|
||
// The payload portion (after the comma) must NOT contain a
|
||
// literal `#` — otherwise browsers load a truncated SVG.
|
||
const payload = p.dataUrl.slice(p.dataUrl.indexOf(',') + 1)
|
||
expect(payload.includes('#')).toBe(false)
|
||
// It should still encode the hex colour:
|
||
expect(payload.includes('%23')).toBe(true)
|
||
}
|
||
})
|
||
|
||
test('splitLayerSvg replaces the original layer with one layer per part at the same stage position', () => {
|
||
const s = makeFreshStore()
|
||
const asset = s.addAsset({
|
||
kind: 'image',
|
||
name: 'SvgSrc',
|
||
src: toDataUrl(sampleSvg),
|
||
width: 100,
|
||
height: 60,
|
||
})
|
||
const l = s.addLayer(asset.id)!
|
||
const origX = l.base.x
|
||
const origY = l.base.y
|
||
const result = s.splitLayerSvg(l.id)
|
||
expect(result).toBeTruthy()
|
||
expect(result!.length).toBe(2)
|
||
// Original layer is gone.
|
||
expect(s.scene.layers.find((x) => x.id === l.id)).toBe(undefined)
|
||
// Both new layers exist and sit on top of the old layer's stage
|
||
// position — each part's SVG canvas is the same size as the
|
||
// original so no delta is needed.
|
||
const [a, b] = result!.map((id) => s.scene.layers.find((x) => x.id === id)!)
|
||
expect(a.base.x).toBe(origX)
|
||
expect(a.base.y).toBe(origY)
|
||
expect(b.base.x).toBe(origX)
|
||
expect(b.base.y).toBe(origY)
|
||
// New assets carry the full original dimensions.
|
||
const assetA = s.scene.assets.find(
|
||
(x) => x.id === a.assetId,
|
||
) as Extract<typeof s.scene.assets[number], { kind: 'image' }>
|
||
expect(assetA.width).toBe(100)
|
||
expect(assetA.height).toBe(60)
|
||
// New parts replace the original in trackOrder at its original slot.
|
||
const order = s.scene.trackOrder ?? []
|
||
expect(order.includes(l.id)).toBe(false)
|
||
for (const id of result!) expect(order.includes(id)).toBe(true)
|
||
})
|
||
|
||
test('splitLayerSvg clones keyframes verbatim (no x/y drift)', () => {
|
||
const s = makeFreshStore()
|
||
const asset = s.addAsset({
|
||
kind: 'image',
|
||
name: 'Anim',
|
||
src: toDataUrl(sampleSvg),
|
||
width: 100,
|
||
height: 60,
|
||
})
|
||
const l = s.addLayer(asset.id)!
|
||
s.upsertKeyframe(l.id, 1, { x: 200, y: 120 })
|
||
const result = s.splitLayerSvg(l.id)
|
||
expect(result).toBeTruthy()
|
||
const first = s.scene.layers.find((x) => x.id === result![0])!
|
||
const kf = first.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)
|
||
expect(kf).toBeTruthy()
|
||
// Same x/y as the source keyframe; no shift math to go wrong.
|
||
expect(kf!.props.x).toBe(200)
|
||
expect(kf!.props.y).toBe(120)
|
||
})
|
||
|
||
test('splitLayerSvg returns null for a non-SVG image layer', () => {
|
||
const s = makeFreshStore()
|
||
const asset = s.addAsset({
|
||
kind: 'image',
|
||
name: 'Raster',
|
||
src: 'data:image/png;base64,iVBORw0K==',
|
||
width: 40,
|
||
height: 40,
|
||
})
|
||
const l = s.addLayer(asset.id)!
|
||
expect(s.splitLayerSvg(l.id)).toBeNull()
|
||
})
|
||
})
|
||
|
||
suite('polygon — store actions', () => {
|
||
function makePolygonLayer() {
|
||
const s = makeFreshStore()
|
||
const asset = s.addAsset({
|
||
kind: 'polygon',
|
||
name: 'Poly',
|
||
points: [
|
||
{ x: 0, y: -40 },
|
||
{ x: 40, y: 20 },
|
||
{ x: -40, y: 20 },
|
||
],
|
||
fill: '#abc',
|
||
}) as PolygonAsset
|
||
const layer = s.addLayer(asset.id)!
|
||
return { s, asset, layer }
|
||
}
|
||
|
||
test('addLayer seeds initial keyframe with points for polygons', () => {
|
||
const { layer, asset } = makePolygonLayer()
|
||
const kf0 = layer.keyframes[0]
|
||
expect(kf0).toBeTruthy()
|
||
expect(Array.isArray(kf0.props.points)).toBe(true)
|
||
expect(kf0.props.points!.length).toBe(asset.points.length)
|
||
})
|
||
|
||
test('movePolygonVertex writes a points-array kf at playhead', () => {
|
||
const { s, layer, asset } = makePolygonLayer()
|
||
s.setPlayhead(0)
|
||
s.movePolygonVertex(layer.id, 0, 10, -50)
|
||
const kf = layer.keyframes.find((k) => Math.abs(k.time) < 1e-3)!
|
||
expect(kf.props.points![0].x).toBe(10)
|
||
expect(kf.props.points![0].y).toBe(-50)
|
||
// Other points remain unchanged
|
||
expect(kf.props.points![1].x).toBe(asset.points[1].x)
|
||
expect(kf.props.points![1].y).toBe(asset.points[1].y)
|
||
})
|
||
|
||
test('setPolygonPointCount grows by midpoint insertion and keeps KFs in sync', () => {
|
||
const { s, layer, asset } = makePolygonLayer()
|
||
const originalLen = asset.points.length
|
||
s.setPolygonPointCount(asset.id, originalLen + 2)
|
||
expect(asset.points.length).toBe(originalLen + 2)
|
||
for (const kf of layer.keyframes) {
|
||
if (!kf.props.points) continue
|
||
expect(kf.props.points.length).toBe(originalLen + 2)
|
||
}
|
||
})
|
||
|
||
test('setPolygonPointCount shrinks but never below 3', () => {
|
||
const { s, asset } = makePolygonLayer()
|
||
s.setPolygonPointCount(asset.id, 1)
|
||
expect(asset.points.length).toBe(3)
|
||
})
|
||
|
||
test('insertPolygonPointOnEdge inserts at expected index across all keyframes', () => {
|
||
const { s, layer, asset } = makePolygonLayer()
|
||
s.insertPolygonPointOnEdge(asset.id, 1, 0.5)
|
||
expect(asset.points.length).toBe(4)
|
||
const kf = layer.keyframes[0]
|
||
expect(kf.props.points!.length).toBe(4)
|
||
// midpoint between original [0] and [1]
|
||
const expectedX = (0 + 40) / 2
|
||
const expectedY = (-40 + 20) / 2
|
||
expect(asset.points[1].x).toBe(expectedX)
|
||
expect(asset.points[1].y).toBe(expectedY)
|
||
expect(kf.props.points![1].x).toBe(expectedX)
|
||
expect(kf.props.points![1].y).toBe(expectedY)
|
||
})
|
||
|
||
test('reversePolygonPoints reverses asset + keyframe points', () => {
|
||
const { s, layer, asset } = makePolygonLayer()
|
||
const firstBefore = { ...asset.points[0] }
|
||
s.reversePolygonPoints(asset.id)
|
||
expect(asset.points[asset.points.length - 1].x).toBe(firstBefore.x)
|
||
expect(asset.points[asset.points.length - 1].y).toBe(firstBefore.y)
|
||
const kf = layer.keyframes[0]
|
||
expect(kf.props.points![kf.props.points!.length - 1].x).toBe(firstBefore.x)
|
||
})
|
||
|
||
test('setPolygonClosed toggles the closed flag', () => {
|
||
const { s, asset } = makePolygonLayer()
|
||
s.setPolygonClosed(asset.id, false)
|
||
expect(asset.closed).toBe(false)
|
||
s.setPolygonClosed(asset.id, true)
|
||
expect(asset.closed).toBe(true)
|
||
})
|
||
})
|
||
|
||
suite('freeform drawing — lifecycle', () => {
|
||
test('startFreeformDrawing creates asset and layer', () => {
|
||
const s = makeFreshStore()
|
||
s.startFreeformDrawing()
|
||
expect(s.drawingMode).toBe('freeform')
|
||
expect(s.drawingAssetId).toBeTruthy()
|
||
expect(s.drawingLayerId).toBeTruthy()
|
||
const asset = s.scene.assets.find((a) => a.id === s.drawingAssetId)!
|
||
expect(asset.kind).toBe('polygon')
|
||
expect((asset as PolygonAsset).closed).toBe(false)
|
||
})
|
||
|
||
test('addDrawingPoint appends to asset and to KF points', () => {
|
||
const s = makeFreshStore()
|
||
s.startFreeformDrawing()
|
||
s.addDrawingPoint(10, 20)
|
||
s.addDrawingPoint(30, 40)
|
||
const asset = s.scene.assets.find((a) => a.id === s.drawingAssetId) as PolygonAsset
|
||
expect(asset.points.length).toBe(2)
|
||
expect(asset.points[1].x).toBe(30)
|
||
const layer = s.activeLayers.find((l) => l.id === s.drawingLayerId)!
|
||
expect(layer.keyframes[0].props.points!.length).toBe(2)
|
||
})
|
||
|
||
test('finishDrawing with <2 points drops the in-progress asset', () => {
|
||
const s = makeFreshStore()
|
||
s.startFreeformDrawing()
|
||
const assetId = s.drawingAssetId!
|
||
const layerId = s.drawingLayerId!
|
||
s.addDrawingPoint(5, 5) // only 1 point
|
||
s.finishDrawing()
|
||
expect(s.drawingMode).toBeNull()
|
||
expect(s.scene.assets.some((a) => a.id === assetId)).toBe(false)
|
||
expect(s.activeLayers.some((l) => l.id === layerId)).toBe(false)
|
||
})
|
||
|
||
test('finishDrawing with >=2 points keeps the layer and clears drawing state', () => {
|
||
const s = makeFreshStore()
|
||
s.startFreeformDrawing()
|
||
s.addDrawingPoint(0, 0)
|
||
s.addDrawingPoint(50, 50)
|
||
const assetId = s.drawingAssetId!
|
||
const layerId = s.drawingLayerId!
|
||
s.finishDrawing()
|
||
expect(s.drawingMode).toBeNull()
|
||
expect(s.drawingAssetId).toBeNull()
|
||
expect(s.drawingLayerId).toBeNull()
|
||
expect(s.scene.assets.some((a) => a.id === assetId)).toBe(true)
|
||
expect(s.activeLayers.some((l) => l.id === layerId)).toBe(true)
|
||
})
|
||
|
||
test('cancelDrawing removes the in-progress asset and layer', () => {
|
||
const s = makeFreshStore()
|
||
s.startFreeformDrawing()
|
||
s.addDrawingPoint(0, 0)
|
||
s.addDrawingPoint(10, 10)
|
||
const assetId = s.drawingAssetId!
|
||
const layerId = s.drawingLayerId!
|
||
s.cancelDrawing()
|
||
expect(s.drawingMode).toBeNull()
|
||
expect(s.scene.assets.some((a) => a.id === assetId)).toBe(false)
|
||
expect(s.activeLayers.some((l) => l.id === layerId)).toBe(false)
|
||
})
|
||
|
||
test('startFreeformDrawing resumes on selected open polygon instead of creating new', () => {
|
||
const s = makeFreshStore()
|
||
s.startFreeformDrawing()
|
||
s.addDrawingPoint(0, 0)
|
||
s.addDrawingPoint(20, 20)
|
||
const assetId = s.drawingAssetId!
|
||
const layerId = s.drawingLayerId!
|
||
s.finishDrawing()
|
||
// Layer stays selected; invoke start again
|
||
s.selectLayer(layerId)
|
||
s.startFreeformDrawing()
|
||
expect(s.drawingMode).toBe('freeform')
|
||
expect(s.drawingAssetId).toBe(assetId)
|
||
expect(s.drawingLayerId).toBe(layerId)
|
||
})
|
||
})
|
||
|
||
suite('polygon + stroke — rendering', () => {
|
||
test('drawSceneFrame renders a filled closed polygon in fill color', () => {
|
||
const s = makeFreshStore()
|
||
s.scene.canvas.width = 100
|
||
s.scene.canvas.height = 100
|
||
s.scene.canvas.background = 'transparent'
|
||
const a = s.addAsset({
|
||
kind: 'polygon',
|
||
name: 'Tri',
|
||
points: [
|
||
{ x: 0, y: -30 },
|
||
{ x: 30, y: 20 },
|
||
{ x: -30, y: 20 },
|
||
],
|
||
fill: '#ff0000',
|
||
})
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 0, { x: 50, y: 50 })
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = 100
|
||
canvas.height = 100
|
||
const ctx = canvas.getContext('2d')!
|
||
drawSceneFrame(ctx, s.scene, 0, {})
|
||
const inside = ctx.getImageData(50, 55, 1, 1).data
|
||
expect(inside[0] > 200).toBe(true) // red channel
|
||
expect(inside[3] > 200).toBe(true) // alpha
|
||
})
|
||
|
||
test('open polygon with stroke renders only the stroke path', () => {
|
||
const s = makeFreshStore()
|
||
s.scene.canvas.width = 100
|
||
s.scene.canvas.height = 100
|
||
s.scene.canvas.background = 'transparent'
|
||
const a = s.addAsset({
|
||
kind: 'polygon',
|
||
name: 'Line',
|
||
points: [
|
||
{ x: -40, y: 0 },
|
||
{ x: 40, y: 0 },
|
||
],
|
||
fill: 'transparent',
|
||
closed: false,
|
||
stroke: '#00ff00',
|
||
strokeWidth: 4,
|
||
})
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 0, { x: 50, y: 50 })
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = 100
|
||
canvas.height = 100
|
||
const ctx = canvas.getContext('2d')!
|
||
drawSceneFrame(ctx, s.scene, 0, {})
|
||
const onLine = ctx.getImageData(50, 50, 1, 1).data
|
||
const offLine = ctx.getImageData(50, 80, 1, 1).data
|
||
expect(onLine[1] > 200).toBe(true) // green on line
|
||
expect(onLine[3] > 200).toBe(true)
|
||
expect(offLine[3]).toBe(0) // transparent elsewhere
|
||
})
|
||
|
||
test('rect with stroke renders the outline', () => {
|
||
const s = makeFreshStore()
|
||
s.scene.canvas.width = 100
|
||
s.scene.canvas.height = 100
|
||
s.scene.canvas.background = 'transparent'
|
||
const a = s.addAsset({
|
||
kind: 'rect',
|
||
name: 'R',
|
||
width: 40,
|
||
height: 40,
|
||
fill: 'transparent',
|
||
stroke: '#0000ff',
|
||
strokeWidth: 4,
|
||
})
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 0, { x: 50, y: 50 })
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = 100
|
||
canvas.height = 100
|
||
const ctx = canvas.getContext('2d')!
|
||
drawSceneFrame(ctx, s.scene, 0, {})
|
||
// On the top edge of the rect
|
||
const onEdge = ctx.getImageData(50, 30, 1, 1).data
|
||
// Interior
|
||
const inside = ctx.getImageData(50, 50, 1, 1).data
|
||
expect(onEdge[2] > 200).toBe(true) // blue on edge
|
||
expect(inside[3]).toBe(0) // transparent interior
|
||
})
|
||
|
||
test('strokeWidth without explicit stroke falls back to black', () => {
|
||
const s = makeFreshStore()
|
||
s.scene.canvas.width = 100
|
||
s.scene.canvas.height = 100
|
||
s.scene.canvas.background = 'transparent'
|
||
const a = s.addAsset({
|
||
kind: 'rect',
|
||
name: 'R',
|
||
width: 40,
|
||
height: 40,
|
||
fill: 'transparent',
|
||
strokeWidth: 4,
|
||
})
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 0, { x: 50, y: 50 })
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = 100
|
||
canvas.height = 100
|
||
const ctx = canvas.getContext('2d')!
|
||
drawSceneFrame(ctx, s.scene, 0, {})
|
||
const onEdge = ctx.getImageData(50, 30, 1, 1).data
|
||
// Black stroke: RGB all low, alpha high
|
||
expect(onEdge[0] < 40).toBe(true)
|
||
expect(onEdge[1] < 40).toBe(true)
|
||
expect(onEdge[2] < 40).toBe(true)
|
||
expect(onEdge[3] > 200).toBe(true)
|
||
})
|
||
|
||
test('transparent fill on closed polygon skips fill', () => {
|
||
const s = makeFreshStore()
|
||
s.scene.canvas.width = 100
|
||
s.scene.canvas.height = 100
|
||
s.scene.canvas.background = 'transparent'
|
||
const a = s.addAsset({
|
||
kind: 'polygon',
|
||
name: 'Hollow',
|
||
points: [
|
||
{ x: -30, y: -30 },
|
||
{ x: 30, y: -30 },
|
||
{ x: 30, y: 30 },
|
||
{ x: -30, y: 30 },
|
||
],
|
||
fill: 'transparent',
|
||
closed: true,
|
||
stroke: '#ff00ff',
|
||
strokeWidth: 3,
|
||
})
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 0, { x: 50, y: 50 })
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = 100
|
||
canvas.height = 100
|
||
const ctx = canvas.getContext('2d')!
|
||
drawSceneFrame(ctx, s.scene, 0, {})
|
||
// Center should be fully transparent (no fill)
|
||
const center = ctx.getImageData(50, 50, 1, 1).data
|
||
expect(center[3]).toBe(0)
|
||
})
|
||
})
|
||
|
||
suite('bezier path — interpolation', () => {
|
||
test('sampleLayer returns quadratic bezier midpoint when ctrl is set', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'B', width: 10, height: 10, fill: '#111' })
|
||
const l = s.addLayer(a.id)!
|
||
// Two keyframes: (0,0) at t=0 and (100,0) at t=1 with a control at (50,100).
|
||
s.upsertKeyframe(l.id, 0, { x: 0, y: 0 })
|
||
s.upsertKeyframe(l.id, 1, { x: 100, y: 0 })
|
||
const cur = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
|
||
s.setKeyframeControl(l.id, cur.id, 50, 100)
|
||
const samp = sampleLayer(l, 0.5)
|
||
// At u=0.5: B = 0.25*0 + 0.5*50 + 0.25*100 = 25 + 25 = 50 x; y = 0.5*100 = 50
|
||
expect(Math.abs(samp.x - 50) < 1e-6).toBe(true)
|
||
expect(Math.abs(samp.y - 50) < 1e-6).toBe(true)
|
||
})
|
||
|
||
test('bezier affects only x/y; other props remain linear', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'B', width: 10, height: 10, fill: '#111' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 0, { x: 0, y: 0, opacity: 0 })
|
||
s.upsertKeyframe(l.id, 1, { x: 100, y: 0, opacity: 1 })
|
||
const cur = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
|
||
s.setKeyframeControl(l.id, cur.id, 50, 200)
|
||
const samp = sampleLayer(l, 0.5)
|
||
expect(Math.abs(samp.opacity - 0.5) < 1e-6).toBe(true)
|
||
})
|
||
|
||
test('endpoints are not affected by control point', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'B', width: 10, height: 10, fill: '#111' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 0, { x: 10, y: 20 })
|
||
s.upsertKeyframe(l.id, 2, { x: 110, y: 200 })
|
||
const cur = l.keyframes.find((k) => Math.abs(k.time - 2) < 1e-3)!
|
||
s.setKeyframeControl(l.id, cur.id, 500, 500)
|
||
const start = sampleLayer(l, 0)
|
||
const end = sampleLayer(l, 2)
|
||
expect(start.x).toBe(10)
|
||
expect(start.y).toBe(20)
|
||
expect(end.x).toBe(110)
|
||
expect(end.y).toBe(200)
|
||
})
|
||
})
|
||
|
||
suite('keyframe control — store actions', () => {
|
||
test('setKeyframeControl stores ctrlX/Y on the keyframe', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'K', width: 10, height: 10, fill: '#111' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 1, { x: 50, y: 50 })
|
||
const kf = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
|
||
s.setKeyframeControl(l.id, kf.id, 30, 40)
|
||
expect(kf.ctrlX).toBe(30)
|
||
expect(kf.ctrlY).toBe(40)
|
||
})
|
||
|
||
test('clearKeyframeControl removes ctrlX/Y', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'K', width: 10, height: 10, fill: '#111' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 1, { x: 50, y: 50 })
|
||
const kf = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
|
||
s.setKeyframeControl(l.id, kf.id, 30, 40)
|
||
s.clearKeyframeControl(l.id, kf.id)
|
||
expect(kf.ctrlX).toBe(undefined)
|
||
expect(kf.ctrlY).toBe(undefined)
|
||
})
|
||
|
||
test('setKeyframeControlFromPoint solves so the curve passes through the target', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'K', width: 10, height: 10, fill: '#111' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 0, { x: 0, y: 0 })
|
||
s.upsertKeyframe(l.id, 1, { x: 100, y: 0 })
|
||
const cur = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
|
||
// We want the curve at u=0.5 to pass through (50, 80).
|
||
s.setKeyframeControlFromPoint(l.id, cur.id, 0.5, 50, 80)
|
||
const samp = sampleLayer(l, 0.5)
|
||
expect(Math.abs(samp.x - 50) < 1e-6).toBe(true)
|
||
expect(Math.abs(samp.y - 80) < 1e-6).toBe(true)
|
||
})
|
||
|
||
test('setKeyframeControlFromPoint is a no-op for endpoints (u=0 or u=1)', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'K', width: 10, height: 10, fill: '#111' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 0, { x: 0, y: 0 })
|
||
s.upsertKeyframe(l.id, 1, { x: 100, y: 0 })
|
||
const cur = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
|
||
s.setKeyframeControlFromPoint(l.id, cur.id, 0, 10, 10)
|
||
s.setKeyframeControlFromPoint(l.id, cur.id, 1, 99, 99)
|
||
expect(cur.ctrlX).toBe(undefined)
|
||
expect(cur.ctrlY).toBe(undefined)
|
||
})
|
||
|
||
test('setKeyframeControlFromPoint needs a previous keyframe', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'K', width: 10, height: 10, fill: '#111' })
|
||
const l = s.addLayer(a.id)!
|
||
// Single keyframe at t=0 only
|
||
const kf = l.keyframes[0]
|
||
s.setKeyframeControlFromPoint(l.id, kf.id, 0.5, 20, 20)
|
||
expect(kf.ctrlX).toBe(undefined)
|
||
})
|
||
})
|
||
|
||
suite('clipart store — state', () => {
|
||
test('toggle flips open state', async () => {
|
||
const { useClipartStore } = await import('@/stores/clipart')
|
||
const s = useClipartStore()
|
||
s.$reset()
|
||
const start = s.open
|
||
s.toggle()
|
||
expect(s.open).toBe(!start)
|
||
s.toggle()
|
||
expect(s.open).toBe(start)
|
||
})
|
||
|
||
test('setOpen persists to localStorage', async () => {
|
||
const { useClipartStore } = await import('@/stores/clipart')
|
||
const s = useClipartStore()
|
||
s.setOpen(true)
|
||
expect(localStorage.getItem('animatr:clipartDrawerOpen')).toBe('1')
|
||
s.setOpen(false)
|
||
expect(localStorage.getItem('animatr:clipartDrawerOpen')).toBe('0')
|
||
})
|
||
|
||
test('clearFolder resets handles, active index and items', async () => {
|
||
const { useClipartStore } = await import('@/stores/clipart')
|
||
const s = useClipartStore()
|
||
// simulate a loaded state
|
||
s.handles = [{ name: 'mock' } as unknown as FileSystemDirectoryHandle]
|
||
s.activeHandleIndex = 0
|
||
s.items = []
|
||
await s.clearFolder()
|
||
expect(s.handles.length).toBe(0)
|
||
expect(s.activeHandleIndex).toBeNull()
|
||
expect(s.folderName).toBeNull()
|
||
expect(s.items.length).toBe(0)
|
||
})
|
||
|
||
test('supported flag matches FileSystemAccess helper', async () => {
|
||
const { useClipartStore } = await import('@/stores/clipart')
|
||
const { isFileSystemAccessSupported } = await import('@/lib/clipartLibrary')
|
||
const s = useClipartStore()
|
||
expect(s.supported).toBe(isFileSystemAccessSupported())
|
||
})
|
||
|
||
test('single-handle auto-drill: activeHandle points at the sole root', async () => {
|
||
const { useClipartStore } = await import('@/stores/clipart')
|
||
const s = useClipartStore()
|
||
await s.clearFolder()
|
||
const h = { name: 'only' } as unknown as FileSystemDirectoryHandle
|
||
s.handles = [h]
|
||
s.activeHandleIndex = null
|
||
// Pinia wraps the handle in a reactive proxy, so reference-equal
|
||
// fails — compare by the stable `name` field instead.
|
||
expect(s.activeHandle?.name).toBe('only')
|
||
expect(s.inOverview).toBe(false)
|
||
expect(s.canGoBack).toBe(false)
|
||
expect(s.folderName).toBe('only')
|
||
await s.clearFolder()
|
||
})
|
||
|
||
test('two handles with no active index sits in overview mode', async () => {
|
||
const { useClipartStore } = await import('@/stores/clipart')
|
||
const s = useClipartStore()
|
||
await s.clearFolder()
|
||
s.handles = [
|
||
{ name: 'a' } as unknown as FileSystemDirectoryHandle,
|
||
{ name: 'b' } as unknown as FileSystemDirectoryHandle,
|
||
]
|
||
s.activeHandleIndex = null
|
||
expect(s.inOverview).toBe(true)
|
||
expect(s.activeHandle).toBeNull()
|
||
expect(s.canGoBack).toBe(false)
|
||
expect(s.folderName).toBeNull()
|
||
await s.clearFolder()
|
||
})
|
||
|
||
test('enterFolder activates; exitFolder returns to overview when multi-root', async () => {
|
||
const { useClipartStore } = await import('@/stores/clipart')
|
||
const s = useClipartStore()
|
||
await s.clearFolder()
|
||
s.handles = [
|
||
{ name: 'a' } as unknown as FileSystemDirectoryHandle,
|
||
{ name: 'b' } as unknown as FileSystemDirectoryHandle,
|
||
]
|
||
s.enterFolder(1)
|
||
expect(s.activeHandleIndex).toBe(1)
|
||
expect(s.canGoBack).toBe(true)
|
||
s.exitFolder()
|
||
expect(s.activeHandleIndex).toBeNull()
|
||
expect(s.inOverview).toBe(true)
|
||
await s.clearFolder()
|
||
})
|
||
|
||
test('exitFolder is a no-op when only one root is configured', async () => {
|
||
const { useClipartStore } = await import('@/stores/clipart')
|
||
const s = useClipartStore()
|
||
await s.clearFolder()
|
||
s.handles = [{ name: 'only' } as unknown as FileSystemDirectoryHandle]
|
||
s.activeHandleIndex = 0
|
||
s.exitFolder()
|
||
// Single-folder installs always auto-drill — back has no meaning.
|
||
expect(s.activeHandleIndex).toBe(0)
|
||
expect(s.canGoBack).toBe(false)
|
||
await s.clearFolder()
|
||
})
|
||
|
||
test('removeFolder shifts the active index and compacts handles', async () => {
|
||
const { useClipartStore } = await import('@/stores/clipart')
|
||
const s = useClipartStore()
|
||
await s.clearFolder()
|
||
s.handles = [
|
||
{ name: 'a' } as unknown as FileSystemDirectoryHandle,
|
||
{ name: 'b' } as unknown as FileSystemDirectoryHandle,
|
||
{ name: 'c' } as unknown as FileSystemDirectoryHandle,
|
||
]
|
||
s.activeHandleIndex = 2
|
||
try {
|
||
await s.removeFolder(0) // a removed; active 2 → 1
|
||
expect(s.handles.map((h) => h.name)).toEqual(['b', 'c'])
|
||
expect(s.activeHandleIndex).toBe(1)
|
||
// Removing the active handle drops to overview.
|
||
await s.removeFolder(1)
|
||
expect(s.handles.map((h) => h.name)).toEqual(['b'])
|
||
expect(s.activeHandleIndex).toBeNull()
|
||
} finally {
|
||
// Ensure state is wiped even if assertions throw — otherwise
|
||
// the dangling `error`/`handles` leak into the next clipart test
|
||
// and the drawer renders the error branch instead of the grid.
|
||
await s.clearFolder()
|
||
}
|
||
})
|
||
|
||
test('saveClipartFolders + loadStoredClipartFolders round-trip (array format)', async () => {
|
||
// Smoke test for the new IDB schema. The picker returns a
|
||
// DirectoryHandle; we fake one with any object since we only
|
||
// care about serialisation behaviour, not the permission API.
|
||
const { saveClipartFolders, loadStoredClipartFolders } = await import(
|
||
'@/lib/clipartLibrary'
|
||
)
|
||
const fake = { name: 'A' } as unknown as FileSystemDirectoryHandle
|
||
await saveClipartFolders([fake])
|
||
const loaded = await loadStoredClipartFolders()
|
||
expect(loaded.length).toBe(1)
|
||
expect(loaded[0].name).toBe('A')
|
||
await saveClipartFolders([])
|
||
})
|
||
})
|
||
|
||
suite('interface prefs — shared composables', () => {
|
||
// Regression: the tooltip + asset-thumbs prefs are shared module-
|
||
// level refs so the Settings dialog and the sidebar/timeline
|
||
// toolbars stay in lock-step. The same refs also have to persist
|
||
// to localStorage. The thumbs pref was originally an inline ref
|
||
// inside AssetSidebar.vue — the settings tab couldn't change it
|
||
// without this extraction.
|
||
|
||
test('useTooltipPref persists to localStorage and shares the ref', async () => {
|
||
const { useTooltipPref } = await import('@/composables/useTooltipPref')
|
||
const a = useTooltipPref()
|
||
const b = useTooltipPref()
|
||
expect(a.enabled).toBe(b.enabled) // same ref instance
|
||
const initial = a.enabled.value
|
||
a.enabled.value = !initial
|
||
await nextTick()
|
||
expect(localStorage.getItem('ruwio:tooltipsEnabled')).toBe(initial ? '0' : '1')
|
||
// Both consumers see the flip because the ref is shared.
|
||
expect(b.enabled.value).toBe(!initial)
|
||
// Restore so other tests don't inherit a toggled state.
|
||
a.enabled.value = initial
|
||
await nextTick()
|
||
})
|
||
|
||
test('useAssetThumbsPref persists to localStorage and shares the ref', async () => {
|
||
const { useAssetThumbsPref } = await import('@/composables/useAssetThumbsPref')
|
||
const a = useAssetThumbsPref()
|
||
const b = useAssetThumbsPref()
|
||
expect(a.enabled).toBe(b.enabled)
|
||
const initial = a.enabled.value
|
||
a.enabled.value = !initial
|
||
await nextTick()
|
||
expect(localStorage.getItem('animatr.assetThumbs')).toBe(String(!initial))
|
||
expect(b.enabled.value).toBe(!initial)
|
||
a.enabled.value = initial
|
||
await nextTick()
|
||
})
|
||
})
|
||
|
||
suite('clipart drawer — grid pre-layout', () => {
|
||
// Regression: the drawer's `.drawer-panel` used to be `v-if`-gated on
|
||
// clipart.open and given `flex: 1; min-width: 0` — which meant the
|
||
// grid mounted at the same moment the drawer started its width
|
||
// transition, so the `repeat(auto-fill, minmax(88px, 1fr))` grid
|
||
// reflowed continuously from ~1 column at 5px → 4 columns at 424px
|
||
// and the thumbs "tumbled" into place. The panel is now always
|
||
// mounted at a rigid 400px, and `.clipart-drawer { overflow: hidden }`
|
||
// just clips the slide.
|
||
|
||
test('.drawer-panel is in the DOM when the drawer is closed', async () => {
|
||
const { useClipartStore } = await import('@/stores/clipart')
|
||
const s = useClipartStore()
|
||
s.setOpen(false)
|
||
const handle = mountComponent(ClipartDrawer, {})
|
||
await nextTick()
|
||
const panel = handle.host.querySelector('.drawer-panel')
|
||
expect(panel).toBeTruthy()
|
||
handle.unmount()
|
||
})
|
||
|
||
test('.drawer-panel is in the DOM when the drawer is open', async () => {
|
||
const { useClipartStore } = await import('@/stores/clipart')
|
||
const s = useClipartStore()
|
||
s.setOpen(true)
|
||
const handle = mountComponent(ClipartDrawer, {})
|
||
await nextTick()
|
||
const panel = handle.host.querySelector('.drawer-panel')
|
||
expect(panel).toBeTruthy()
|
||
handle.unmount()
|
||
})
|
||
|
||
test('.drawer-panel stays mounted across open → closed toggle', async () => {
|
||
const { useClipartStore } = await import('@/stores/clipart')
|
||
const s = useClipartStore()
|
||
s.setOpen(true)
|
||
const handle = mountComponent(ClipartDrawer, {})
|
||
await nextTick()
|
||
expect(handle.host.querySelector('.drawer-panel')).toBeTruthy()
|
||
s.setOpen(false)
|
||
await nextTick()
|
||
expect(handle.host.querySelector('.drawer-panel')).toBeTruthy()
|
||
handle.unmount()
|
||
})
|
||
|
||
test('.drawer-panel has a rigid 400px width regardless of the drawer state', async () => {
|
||
const { useClipartStore } = await import('@/stores/clipart')
|
||
const s = useClipartStore()
|
||
s.setOpen(false)
|
||
const handle = mountComponent(ClipartDrawer, {})
|
||
await nextTick()
|
||
const panel = handle.host.querySelector('.drawer-panel') as HTMLElement
|
||
expect(panel).toBeTruthy()
|
||
const widthClosed = getComputedStyle(panel).width
|
||
s.setOpen(true)
|
||
await nextTick()
|
||
const widthOpen = getComputedStyle(panel).width
|
||
expect(widthClosed).toBe('400px')
|
||
expect(widthOpen).toBe('400px')
|
||
handle.unmount()
|
||
})
|
||
|
||
test('.clipart-drawer clips its contents (overflow: hidden)', async () => {
|
||
const { useClipartStore } = await import('@/stores/clipart')
|
||
const s = useClipartStore()
|
||
s.setOpen(false)
|
||
const handle = mountComponent(ClipartDrawer, {})
|
||
await nextTick()
|
||
const drawer = handle.host.querySelector('.clipart-drawer') as HTMLElement
|
||
expect(drawer).toBeTruthy()
|
||
const overflowX = getComputedStyle(drawer).overflowX
|
||
const overflowY = getComputedStyle(drawer).overflowY
|
||
// `overflow: hidden` applies to both axes. We check each because
|
||
// browsers normalise the shorthand into the long-hand properties.
|
||
expect(overflowX).toBe('hidden')
|
||
expect(overflowY).toBe('hidden')
|
||
handle.unmount()
|
||
})
|
||
|
||
test('empty state shows a centred + button that calls addFolder', async () => {
|
||
const { useClipartStore } = await import('@/stores/clipart')
|
||
const s = useClipartStore()
|
||
s.setOpen(true)
|
||
s.supported = true
|
||
// No handles → empty-state with the big centred `+` action.
|
||
s.handles = []
|
||
s.activeHandleIndex = null
|
||
s.items = []
|
||
const calls: number[] = []
|
||
const orig = s.addFolder
|
||
s.addFolder = async () => {
|
||
calls.push(1)
|
||
}
|
||
try {
|
||
const handle = mountComponent(ClipartDrawer, {})
|
||
await nextTick()
|
||
const btn = handle.host.querySelector('.empty-add-btn') as HTMLButtonElement | null
|
||
expect(btn).toBeTruthy()
|
||
btn!.click()
|
||
expect(calls.length).toBe(1)
|
||
handle.unmount()
|
||
} finally {
|
||
s.addFolder = orig
|
||
s.setOpen(false)
|
||
}
|
||
})
|
||
|
||
test('audio clipart items render as .audio-tile with the music-note icon, not an img', async () => {
|
||
const { useClipartStore } = await import('@/stores/clipart')
|
||
const s = useClipartStore()
|
||
// Wipe first so prior tests' dangling error / loading / handles
|
||
// can't match the drawer's earlier `v-else-if` branches.
|
||
s.error = null
|
||
s.loading = false
|
||
s.setOpen(true)
|
||
// Tile grid is gated on an active folder — seed a stub handle so
|
||
// the template skips past the empty-state branch.
|
||
s.supported = true
|
||
s.handles = [{ name: 'stub' } as unknown as FileSystemDirectoryHandle]
|
||
s.activeHandleIndex = 0
|
||
const mockFile = new File(['x'], 'x.mp3', { type: 'audio/mpeg' })
|
||
s.items = [
|
||
{ kind: 'audio', name: 'ding.mp3', file: mockFile, url: 'blob:dummy-audio' },
|
||
{ kind: 'image', name: 'img.png', file: mockFile, url: 'blob:dummy-img' },
|
||
]
|
||
const handle = mountComponent(ClipartDrawer, {})
|
||
await nextTick()
|
||
const tiles = handle.host.querySelectorAll('.tile')
|
||
expect(tiles.length).toBe(2)
|
||
const audioTile = handle.host.querySelector('.tile.audio-tile')
|
||
expect(audioTile).toBeTruthy()
|
||
// The audio tile uses an icon wrapper instead of an <img>.
|
||
expect(audioTile!.querySelector('.audio-icon')).toBeTruthy()
|
||
expect(audioTile!.querySelector('img')).toBeNull()
|
||
// The label still carries the filename.
|
||
const label = audioTile!.querySelector('.tile-label')
|
||
expect(label?.textContent?.trim()).toBe('ding.mp3')
|
||
// Clean up the stub state so later tests get a fresh store —
|
||
// especially `open`: without closing the drawer, the main app's
|
||
// ClipartDrawer stays visually expanded underneath subsequent
|
||
// mountComponent calls, which can lead to confusing stale UI.
|
||
s.items = []
|
||
s.handles = []
|
||
s.activeHandleIndex = null
|
||
s.setOpen(false)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('overview mode renders folder tiles; clicking one enters that root', async () => {
|
||
const { useClipartStore } = await import('@/stores/clipart')
|
||
const s = useClipartStore()
|
||
s.error = null
|
||
s.loading = false
|
||
s.setOpen(true)
|
||
s.supported = true
|
||
const h1 = { name: 'folderA' } as unknown as FileSystemDirectoryHandle
|
||
const h2 = { name: 'folderB' } as unknown as FileSystemDirectoryHandle
|
||
s.handles = [h1, h2]
|
||
s.activeHandleIndex = null
|
||
// Overview items = one folder tile per root (the store would
|
||
// normally do this via refresh; we set it directly to avoid
|
||
// permission calls in the test environment).
|
||
s.items = [
|
||
{ kind: 'folder', name: 'folderA', handle: h1 },
|
||
{ kind: 'folder', name: 'folderB', handle: h2 },
|
||
]
|
||
const handle = mountComponent(ClipartDrawer, {})
|
||
await nextTick()
|
||
const tiles = handle.host.querySelectorAll('.tile.folder-tile')
|
||
expect(tiles.length).toBe(2)
|
||
// Clicking the second folder enters it.
|
||
;(tiles[1] as HTMLElement).click()
|
||
await nextTick()
|
||
expect(s.activeHandleIndex).toBe(1)
|
||
s.items = []
|
||
s.handles = []
|
||
s.activeHandleIndex = null
|
||
s.setOpen(false)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('back button is shown inside a folder when more than one root is configured', async () => {
|
||
const { useClipartStore } = await import('@/stores/clipart')
|
||
const s = useClipartStore()
|
||
s.setOpen(true)
|
||
s.supported = true
|
||
s.handles = [
|
||
{ name: 'a' } as unknown as FileSystemDirectoryHandle,
|
||
{ name: 'b' } as unknown as FileSystemDirectoryHandle,
|
||
]
|
||
s.activeHandleIndex = 0
|
||
s.items = []
|
||
const handle = mountComponent(ClipartDrawer, {})
|
||
await nextTick()
|
||
// The back button carries the exitFolder tooltip as aria-label
|
||
// via the v-tooltip directive.
|
||
const back = handle.host.querySelector(
|
||
`button[aria-label="${i18nT('clipart.backToIndex')}"]`,
|
||
) as HTMLButtonElement | null
|
||
expect(back).toBeTruthy()
|
||
back!.click()
|
||
await nextTick()
|
||
expect(s.activeHandleIndex).toBeNull()
|
||
s.items = []
|
||
s.handles = []
|
||
s.activeHandleIndex = null
|
||
s.setOpen(false)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('single-root install hides the back button (auto-drill)', async () => {
|
||
const { useClipartStore } = await import('@/stores/clipart')
|
||
const s = useClipartStore()
|
||
s.setOpen(true)
|
||
s.supported = true
|
||
s.handles = [{ name: 'only' } as unknown as FileSystemDirectoryHandle]
|
||
s.activeHandleIndex = null
|
||
s.items = []
|
||
const handle = mountComponent(ClipartDrawer, {})
|
||
await nextTick()
|
||
const back = handle.host.querySelector(
|
||
`button[aria-label="${i18nT('clipart.backToIndex')}"]`,
|
||
) as HTMLButtonElement | null
|
||
expect(back).toBeNull()
|
||
s.handles = []
|
||
s.setOpen(false)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('header + button calls addFolder', async () => {
|
||
const { useClipartStore } = await import('@/stores/clipart')
|
||
const s = useClipartStore()
|
||
s.setOpen(true)
|
||
s.supported = true
|
||
s.handles = [{ name: 'only' } as unknown as FileSystemDirectoryHandle]
|
||
s.activeHandleIndex = null
|
||
s.items = []
|
||
const calls: number[] = []
|
||
const orig = s.addFolder
|
||
s.addFolder = async () => {
|
||
calls.push(1)
|
||
}
|
||
try {
|
||
const handle = mountComponent(ClipartDrawer, {})
|
||
await nextTick()
|
||
const btn = handle.host.querySelector(
|
||
`button[aria-label="${i18nT('clipart.addFolderTooltip')}"]`,
|
||
) as HTMLButtonElement | null
|
||
expect(btn).toBeTruthy()
|
||
btn!.click()
|
||
expect(calls.length).toBe(1)
|
||
handle.unmount()
|
||
} finally {
|
||
s.addFolder = orig
|
||
s.handles = []
|
||
s.setOpen(false)
|
||
}
|
||
})
|
||
})
|
||
|
||
suite('ui — SceneDrawer', () => {
|
||
async function settlePromptCancel() {
|
||
// The drawer's `+` button dispatches addScene and then kicks off a
|
||
// promptDialog for renaming. Tests don't render DialogsHost, so
|
||
// we resolve the pending prompt explicitly to keep the dialog
|
||
// queue empty between tests.
|
||
const { cancelDialog } = await import('@/components/ui/dialogs')
|
||
cancelDialog()
|
||
}
|
||
|
||
test('renders one .scene-row per page, with .active on the current one', async () => {
|
||
const s = makeFreshStore()
|
||
s.addScene('Two')
|
||
s.addScene('Three')
|
||
const activeId = s.project.activeSceneId
|
||
const handle = mountComponent(SceneDrawer, {})
|
||
await nextTick()
|
||
const rows = handle.host.querySelectorAll('.scene-row')
|
||
expect(rows.length).toBe(3)
|
||
// Order matches `project.scenes`.
|
||
const names = Array.from(rows).map((r) => r.querySelector('.scene-name')?.textContent?.trim())
|
||
expect(names).toEqual(s.project.scenes.map((p) => p.name))
|
||
// Exactly one row is .active, and it's the active scene.
|
||
const active = Array.from(rows).filter((r) => r.classList.contains('active'))
|
||
expect(active.length).toBe(1)
|
||
const activeName = active[0].querySelector('.scene-name')?.textContent?.trim()
|
||
expect(activeName).toBe(s.project.scenes.find((p) => p.id === activeId)!.name)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('layer-count badge reflects each scene\'s layer count', async () => {
|
||
const s = makeFreshStore()
|
||
const asset = s.addAsset({ kind: 'rect', name: 'D', width: 10, height: 10, fill: '#abc' })
|
||
s.addLayer(asset.id)
|
||
s.addLayer(asset.id)
|
||
const two = s.addScene('Two')
|
||
s.addLayer(asset.id) // lands on Two (active after addScene)
|
||
// Render AFTER state is set up so the component's reactive read
|
||
// captures the final layer counts.
|
||
const handle = mountComponent(SceneDrawer, {})
|
||
await nextTick()
|
||
const rows = handle.host.querySelectorAll('.scene-row')
|
||
expect(rows.length).toBe(2)
|
||
const metas = Array.from(rows).map((r) =>
|
||
r.querySelector('.scene-meta')?.textContent?.trim(),
|
||
)
|
||
// First row = Scene 1 (2 layers), second row = Two (1 layer).
|
||
expect(metas).toEqual(['2', '1'])
|
||
void two
|
||
handle.unmount()
|
||
})
|
||
|
||
test('clicking a non-active row switches to that page without forcing play', async () => {
|
||
// In the editor, flipping scenes is pure navigation. Auto-play is
|
||
// reserved for the fullscreen preview — the drawer must not start
|
||
// playback on its own, otherwise the user's paused state dies
|
||
// behind their back.
|
||
const s = makeFreshStore()
|
||
const firstId = s.project.activeSceneId
|
||
const second = s.addScene('Two')
|
||
s.switchScene(firstId)
|
||
s.pause()
|
||
const handle = mountComponent(SceneDrawer, {})
|
||
await nextTick()
|
||
const rows = handle.host.querySelectorAll('.scene-row') as NodeListOf<HTMLButtonElement>
|
||
rows[1].click()
|
||
await nextTick()
|
||
expect(s.project.activeSceneId).toBe(second.id)
|
||
expect(s.playing).toBe(false)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('clicking the already-active row is a no-op', async () => {
|
||
const s = makeFreshStore()
|
||
const firstId = s.project.activeSceneId
|
||
s.pause()
|
||
const handle = mountComponent(SceneDrawer, {})
|
||
await nextTick()
|
||
const rows = handle.host.querySelectorAll('.scene-row') as NodeListOf<HTMLButtonElement>
|
||
// Row index 0 = the active one.
|
||
rows[0].click()
|
||
await nextTick()
|
||
// Still paused — no sneaky re-switch triggered auto-play.
|
||
expect(s.playing).toBe(false)
|
||
expect(s.project.activeSceneId).toBe(firstId)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('+ button appends a new scene and activates it', async () => {
|
||
const s = makeFreshStore()
|
||
expect(s.project.scenes).toHaveLength(1)
|
||
const handle = mountComponent(SceneDrawer, {})
|
||
await nextTick()
|
||
const addBtn = handle.host.querySelector('.add-btn') as HTMLButtonElement
|
||
expect(addBtn).toBeTruthy()
|
||
addBtn.click()
|
||
// Let addScene + the nextTick inside addScene resolve.
|
||
await nextTick()
|
||
await nextTick()
|
||
expect(s.project.scenes).toHaveLength(2)
|
||
expect(s.project.activeSceneId).toBe(s.project.scenes[1].id)
|
||
// Clean up the pending rename prompt.
|
||
await settlePromptCancel()
|
||
handle.unmount()
|
||
})
|
||
|
||
test('right-click on a row surfaces the context menu', async () => {
|
||
const s = makeFreshStore()
|
||
s.addScene('Two')
|
||
const handle = mountComponent(SceneDrawer, {})
|
||
await nextTick()
|
||
expect(handle.host.querySelector('.scene-menu')).toBeNull()
|
||
const row = handle.host.querySelector('.scene-row') as HTMLElement
|
||
row.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))
|
||
await nextTick()
|
||
expect(handle.host.querySelector('.scene-menu')).toBeTruthy()
|
||
// Delete option is disabled only when there's just one scene left.
|
||
const deleteBtn = handle.host.querySelector('.scene-menu .menu-item.danger') as HTMLButtonElement
|
||
expect(deleteBtn).toBeTruthy()
|
||
expect(deleteBtn.disabled).toBe(false)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('context menu disables Delete when only one scene remains', async () => {
|
||
const s = makeFreshStore()
|
||
expect(s.project.scenes.length).toBe(1)
|
||
const handle = mountComponent(SceneDrawer, {})
|
||
await nextTick()
|
||
const row = handle.host.querySelector('.scene-row') as HTMLElement
|
||
row.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))
|
||
await nextTick()
|
||
const deleteBtn = handle.host.querySelector('.scene-menu .menu-item.danger') as HTMLButtonElement
|
||
expect(deleteBtn).toBeTruthy()
|
||
expect(deleteBtn.disabled).toBe(true)
|
||
handle.unmount()
|
||
})
|
||
})
|
||
|
||
suite('ui — DocumentDrawer', () => {
|
||
// The document-settings drawer slides down from under the topbar
|
||
// when the project-name button is clicked — no local tab button,
|
||
// no hover-to-peek. Opening is driven by the shared
|
||
// useDocumentDrawer() composable; outside-click and ESC close it.
|
||
// Document / canvas / bg-video / timing settings live here; the
|
||
// project name is edited inline in the header.
|
||
|
||
beforeEach(async () => {
|
||
// The drawer's open state + project name are module-scoped
|
||
// (shared composables). Reset both so tests don't leak state.
|
||
const { useDocumentDrawer } = await import(
|
||
'@/composables/useDocumentDrawer'
|
||
)
|
||
const { useProjectName } = await import('@/composables/useProjectName')
|
||
useDocumentDrawer().closeDrawer()
|
||
useProjectName().projectName.value = null
|
||
useProjectName().projectId.value = null
|
||
})
|
||
|
||
test('renders the drawer panel starting closed, no tab button', async () => {
|
||
makeFreshStore()
|
||
const handle = mountComponent(DocumentDrawer, {})
|
||
await nextTick()
|
||
const drawer = handle.host.querySelector('.document-drawer') as HTMLElement
|
||
expect(drawer).toBeTruthy()
|
||
expect(drawer.classList.contains('open')).toBe(false)
|
||
// The panel is always mounted (hidden via transform when closed).
|
||
expect(handle.host.querySelector('.drawer-panel')).toBeTruthy()
|
||
// The old tab trigger is gone — opening is composable-driven.
|
||
expect(handle.host.querySelector('.drawer-tab')).toBe(null)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('toggle() via the composable flips .open on and off', async () => {
|
||
makeFreshStore()
|
||
const handle = mountComponent(DocumentDrawer, {})
|
||
await nextTick()
|
||
const drawer = handle.host.querySelector('.document-drawer') as HTMLElement
|
||
const { useDocumentDrawer } = await import(
|
||
'@/composables/useDocumentDrawer'
|
||
)
|
||
const dd = useDocumentDrawer()
|
||
expect(drawer.classList.contains('open')).toBe(false)
|
||
dd.toggle()
|
||
await nextTick()
|
||
expect(drawer.classList.contains('open')).toBe(true)
|
||
dd.toggle()
|
||
await nextTick()
|
||
expect(drawer.classList.contains('open')).toBe(false)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('opening externally via useDocumentDrawer().openDrawer() flips the class', async () => {
|
||
// Regression: the topbar project-name button opens the drawer
|
||
// through the shared composable, so all triggers (topbar click,
|
||
// programmatic openDrawer) must land on the same state.
|
||
makeFreshStore()
|
||
const handle = mountComponent(DocumentDrawer, {})
|
||
await nextTick()
|
||
const { useDocumentDrawer } = await import(
|
||
'@/composables/useDocumentDrawer'
|
||
)
|
||
useDocumentDrawer().openDrawer()
|
||
await nextTick()
|
||
const drawer = handle.host.querySelector('.document-drawer') as HTMLElement
|
||
expect(drawer.classList.contains('open')).toBe(true)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('panel exposes the canvas width / height fields bound to the store', async () => {
|
||
const s = makeFreshStore()
|
||
const handle = mountComponent(DocumentDrawer, {})
|
||
await nextTick()
|
||
const { useDocumentDrawer } = await import(
|
||
'@/composables/useDocumentDrawer'
|
||
)
|
||
useDocumentDrawer().openDrawer()
|
||
await nextTick()
|
||
const inputs = handle.host.querySelectorAll(
|
||
'.drawer-panel input[type="number"]',
|
||
) as NodeListOf<HTMLInputElement>
|
||
// Expected order: width, height, duration, fps.
|
||
expect(inputs.length).toBe(4)
|
||
expect(Number(inputs[0].value)).toBe(s.scene.canvas.width)
|
||
expect(Number(inputs[1].value)).toBe(s.scene.canvas.height)
|
||
// Edit width → store updates.
|
||
inputs[0].value = '640'
|
||
inputs[0].dispatchEvent(new Event('input', { bubbles: true }))
|
||
inputs[0].dispatchEvent(new Event('change', { bubbles: true }))
|
||
await nextTick()
|
||
expect(s.scene.canvas.width).toBe(640)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('preset buttons set canvas dimensions and reflect the active preset', async () => {
|
||
const s = makeFreshStore()
|
||
const handle = mountComponent(DocumentDrawer, {})
|
||
await nextTick()
|
||
const { useDocumentDrawer } = await import(
|
||
'@/composables/useDocumentDrawer'
|
||
)
|
||
useDocumentDrawer().openDrawer()
|
||
await nextTick()
|
||
const presets = Array.from(
|
||
handle.host.querySelectorAll('.preset'),
|
||
) as HTMLButtonElement[]
|
||
const fullHd = presets.find((b) => b.textContent?.includes('1920×1080'))
|
||
expect(fullHd).toBeTruthy()
|
||
fullHd!.click()
|
||
await nextTick()
|
||
expect(s.scene.canvas.width).toBe(1920)
|
||
expect(s.scene.canvas.height).toBe(1080)
|
||
expect(fullHd!.classList.contains('active')).toBe(true)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('mounting the drawer alone does not open it', async () => {
|
||
// Regression: there is no local trigger inside the drawer itself
|
||
// (no tab, no hover-peek). The only way in is via the composable,
|
||
// so a freshly-mounted drawer must stay closed.
|
||
makeFreshStore()
|
||
const handle = mountComponent(DocumentDrawer, {})
|
||
await nextTick()
|
||
const drawer = handle.host.querySelector('.document-drawer') as HTMLElement
|
||
expect(drawer.classList.contains('open')).toBe(false)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('duration + fps fields are bound to the store', async () => {
|
||
const s = makeFreshStore()
|
||
const handle = mountComponent(DocumentDrawer, {})
|
||
await nextTick()
|
||
const { useDocumentDrawer } = await import(
|
||
'@/composables/useDocumentDrawer'
|
||
)
|
||
useDocumentDrawer().openDrawer()
|
||
await nextTick()
|
||
const inputs = handle.host.querySelectorAll(
|
||
'.drawer-panel input[type="number"]',
|
||
) as NodeListOf<HTMLInputElement>
|
||
// Order is [width, height, duration, fps].
|
||
const duration = inputs[2]
|
||
const fps = inputs[3]
|
||
expect(Number(duration.value)).toBe(s.scene.duration)
|
||
expect(Number(fps.value)).toBe(s.scene.fps)
|
||
duration.value = '12.5'
|
||
duration.dispatchEvent(new Event('input', { bubbles: true }))
|
||
duration.dispatchEvent(new Event('change', { bubbles: true }))
|
||
await nextTick()
|
||
expect(s.scene.duration).toBeCloseTo(12.5, 5)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('project name input reflects the shared useProjectName ref', async () => {
|
||
// The drawer mirrors the topbar's name label — editing lives in
|
||
// the drawer and the topbar just displays. The input defaults to
|
||
// empty (with a placeholder) while the name is null; as soon as
|
||
// the source ref gets a value, the input follows via the watcher.
|
||
makeFreshStore()
|
||
const { useProjectName } = await import('@/composables/useProjectName')
|
||
const { projectName } = useProjectName()
|
||
const handle = mountComponent(DocumentDrawer, {})
|
||
await nextTick()
|
||
const input = handle.host.querySelector('.name-input') as HTMLInputElement
|
||
expect(input).toBeTruthy()
|
||
expect(input.value).toBe('')
|
||
projectName.value = 'My Project'
|
||
await nextTick()
|
||
expect(input.value).toBe('My Project')
|
||
handle.unmount()
|
||
})
|
||
|
||
test('committing a name via blur writes it to useProjectName.projectName', async () => {
|
||
makeFreshStore()
|
||
const { useProjectName } = await import('@/composables/useProjectName')
|
||
const { projectName } = useProjectName()
|
||
projectName.value = 'Old Name'
|
||
const handle = mountComponent(DocumentDrawer, {})
|
||
await nextTick()
|
||
const input = handle.host.querySelector('.name-input') as HTMLInputElement
|
||
input.value = ' Fresh Name '
|
||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||
input.dispatchEvent(new Event('blur', { bubbles: true }))
|
||
await nextTick()
|
||
// Trimmed. No surrounding whitespace leaks into the stored name.
|
||
expect(projectName.value).toBe('Fresh Name')
|
||
handle.unmount()
|
||
})
|
||
|
||
test('Enter commits the name; Escape reverts to the stored value', async () => {
|
||
makeFreshStore()
|
||
const { useProjectName } = await import('@/composables/useProjectName')
|
||
const { projectName } = useProjectName()
|
||
projectName.value = 'Stored'
|
||
const handle = mountComponent(DocumentDrawer, {})
|
||
await nextTick()
|
||
const input = handle.host.querySelector('.name-input') as HTMLInputElement
|
||
|
||
// Enter → commit.
|
||
input.value = 'Pressed Enter'
|
||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
|
||
// The handler blurs the input synchronously; blur fires commit.
|
||
input.dispatchEvent(new Event('blur', { bubbles: true }))
|
||
await nextTick()
|
||
expect(projectName.value).toBe('Pressed Enter')
|
||
|
||
// Escape → revert the draft, no store change.
|
||
input.value = 'Accidentally typed'
|
||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||
input.dispatchEvent(new Event('blur', { bubbles: true }))
|
||
await nextTick()
|
||
expect(projectName.value).toBe('Pressed Enter')
|
||
expect(input.value).toBe('Pressed Enter')
|
||
handle.unmount()
|
||
})
|
||
|
||
test('empty / whitespace-only name is rejected; stored value snaps back', async () => {
|
||
makeFreshStore()
|
||
const { useProjectName } = await import('@/composables/useProjectName')
|
||
const { projectName } = useProjectName()
|
||
projectName.value = 'Keep Me'
|
||
const handle = mountComponent(DocumentDrawer, {})
|
||
await nextTick()
|
||
const input = handle.host.querySelector('.name-input') as HTMLInputElement
|
||
input.value = ' '
|
||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||
input.dispatchEvent(new Event('blur', { bubbles: true }))
|
||
await nextTick()
|
||
expect(projectName.value).toBe('Keep Me')
|
||
expect(input.value).toBe('Keep Me')
|
||
handle.unmount()
|
||
})
|
||
})
|
||
|
||
suite('ui — ActionEditor (jumpToScene)', () => {
|
||
test('jumpToScene appears in the type dropdown', async () => {
|
||
const s = makeFreshStore()
|
||
s.addScene('Two')
|
||
const handle = mountComponent(
|
||
ActionEditor,
|
||
{ actions: [{ type: 'pause' } as const] },
|
||
)
|
||
await nextTick()
|
||
const select = handle.host.querySelector('.type-select') as HTMLSelectElement
|
||
expect(select).toBeTruthy()
|
||
const values = Array.from(select.options).map((o) => o.value)
|
||
expect(values.includes('jumpToScene')).toBe(true)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('switching a row to jumpToScene seeds it with the first non-active scene', async () => {
|
||
const s = makeFreshStore()
|
||
const activeId = s.project.activeSceneId
|
||
const other = s.addScene('Other')
|
||
s.switchScene(activeId) // pull the active marker back to Scene 1
|
||
let latest: unknown = null
|
||
const handle = mountComponent(ActionEditor, {
|
||
actions: [{ type: 'pause' } as const],
|
||
'onUpdate:actions': (v: unknown) => {
|
||
latest = v
|
||
},
|
||
})
|
||
await nextTick()
|
||
const select = handle.host.querySelector('.type-select') as HTMLSelectElement
|
||
select.value = 'jumpToScene'
|
||
select.dispatchEvent(new Event('change', { bubbles: true }))
|
||
await nextTick()
|
||
expect(Array.isArray(latest)).toBe(true)
|
||
const arr = latest as unknown[]
|
||
expect(arr.length).toBe(1)
|
||
const next = arr[0] as { type: string; sceneId?: string }
|
||
expect(next.type).toBe('jumpToScene')
|
||
// Defaults to the first non-active scene so the action is useful out of the box.
|
||
expect(next.sceneId).toBe(other.id)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('scene dropdown enumerates every scene by name, emits sceneId on change', async () => {
|
||
const s = makeFreshStore()
|
||
const firstId = s.project.scenes[0].id
|
||
const second = s.addScene('Two')
|
||
const third = s.addScene('Three')
|
||
// Active is now Three. The seed action points at Two; changing
|
||
// to Three via the dropdown should emit { sceneId: third.id }.
|
||
let latest: unknown = null
|
||
const handle = mountComponent(ActionEditor, {
|
||
actions: [{ type: 'jumpToScene', sceneId: second.id } as const],
|
||
'onUpdate:actions': (v: unknown) => {
|
||
latest = v
|
||
},
|
||
})
|
||
await nextTick()
|
||
// Find the scene dropdown (second select on the row — the first
|
||
// is the action-type select).
|
||
const selects = handle.host.querySelectorAll('select')
|
||
expect(selects.length).toBe(2)
|
||
const sceneSelect = selects[1] as HTMLSelectElement
|
||
const sceneValues = Array.from(sceneSelect.options).map((o) => o.value)
|
||
expect(sceneValues).toEqual([firstId, second.id, third.id])
|
||
const sceneLabels = Array.from(sceneSelect.options).map((o) => o.textContent?.trim())
|
||
expect(sceneLabels).toEqual(['Scene 1', 'Two', 'Three'])
|
||
sceneSelect.value = third.id
|
||
sceneSelect.dispatchEvent(new Event('change', { bubbles: true }))
|
||
await nextTick()
|
||
const next = (latest as unknown[])[0] as { type: string; sceneId: string }
|
||
expect(next.type).toBe('jumpToScene')
|
||
expect(next.sceneId).toBe(third.id)
|
||
handle.unmount()
|
||
})
|
||
})
|
||
|
||
suite('ui — ActionEditor (setButtonEnabled)', () => {
|
||
test('setButtonEnabled appears in the type dropdown', async () => {
|
||
const s = makeFreshStore()
|
||
void s
|
||
const handle = mountComponent(ActionEditor, {
|
||
actions: [{ type: 'pause' } as const],
|
||
})
|
||
await nextTick()
|
||
const select = handle.host.querySelector('.type-select') as HTMLSelectElement
|
||
const values = Array.from(select.options).map((o) => o.value)
|
||
expect(values.includes('setButtonEnabled')).toBe(true)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('switching a row to setButtonEnabled seeds it with the first button layer + enabled=false', async () => {
|
||
const s = makeFreshStore()
|
||
const btn = s.addButtonAsset('Play', 160, 60)
|
||
const layer = s.addLayer(btn.id)!
|
||
let latest: unknown = null
|
||
const handle = mountComponent(ActionEditor, {
|
||
actions: [{ type: 'pause' } as const],
|
||
'onUpdate:actions': (v: unknown) => {
|
||
latest = v
|
||
},
|
||
})
|
||
await nextTick()
|
||
const select = handle.host.querySelector('.type-select') as HTMLSelectElement
|
||
select.value = 'setButtonEnabled'
|
||
select.dispatchEvent(new Event('change', { bubbles: true }))
|
||
await nextTick()
|
||
const next = (latest as unknown[])[0] as {
|
||
type: string
|
||
layerId?: string
|
||
enabled?: boolean
|
||
}
|
||
expect(next.type).toBe('setButtonEnabled')
|
||
expect(next.layerId).toBe(layer.id)
|
||
// Defaults to "disable" — the common use case.
|
||
expect(next.enabled).toBe(false)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('button dropdown lists one entry per button layer across scenes', async () => {
|
||
const s = makeFreshStore()
|
||
const btn1 = s.addButtonAsset('Play', 160, 60)
|
||
const layer1 = s.addLayer(btn1.id)!
|
||
s.addScene('Other')
|
||
const btn2 = s.addButtonAsset('Stop', 160, 60)
|
||
const layer2 = s.addLayer(btn2.id)!
|
||
let latest: unknown = null
|
||
const handle = mountComponent(ActionEditor, {
|
||
actions: [{ type: 'setButtonEnabled', layerId: layer1.id, enabled: false } as const],
|
||
'onUpdate:actions': (v: unknown) => {
|
||
latest = v
|
||
},
|
||
})
|
||
await nextTick()
|
||
const selects = handle.host.querySelectorAll('select')
|
||
// action-type dropdown + button-layer dropdown
|
||
expect(selects.length).toBe(2)
|
||
const btnSelect = selects[1] as HTMLSelectElement
|
||
const ids = Array.from(btnSelect.options).map((o) => o.value)
|
||
expect(ids.sort()).toEqual([layer1.id, layer2.id].sort())
|
||
btnSelect.value = layer2.id
|
||
btnSelect.dispatchEvent(new Event('change', { bubbles: true }))
|
||
await nextTick()
|
||
const next = (latest as unknown[])[0] as { layerId: string; enabled: boolean }
|
||
expect(next.layerId).toBe(layer2.id)
|
||
expect(next.enabled).toBe(false)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('enabled checkbox round-trips true/false into the emitted action', async () => {
|
||
const s = makeFreshStore()
|
||
const btn = s.addButtonAsset('Play', 160, 60)
|
||
const layer = s.addLayer(btn.id)!
|
||
let latest: unknown = null
|
||
const handle = mountComponent(ActionEditor, {
|
||
actions: [{ type: 'setButtonEnabled', layerId: layer.id, enabled: false } as const],
|
||
'onUpdate:actions': (v: unknown) => {
|
||
latest = v
|
||
},
|
||
})
|
||
await nextTick()
|
||
const cb = handle.host.querySelector('input[type="checkbox"]') as HTMLInputElement
|
||
expect(cb).toBeTruthy()
|
||
expect(cb.checked).toBe(false)
|
||
cb.checked = true
|
||
cb.dispatchEvent(new Event('change', { bubbles: true }))
|
||
await nextTick()
|
||
const next = (latest as unknown[])[0] as { layerId: string; enabled: boolean }
|
||
expect(next.layerId).toBe(layer.id)
|
||
expect(next.enabled).toBe(true)
|
||
handle.unmount()
|
||
})
|
||
})
|
||
|
||
suite('ui — Timeline script-frame context menu', () => {
|
||
test('right-click on a script frame opens a menu with Duplicate + Delete', async () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
const track = s.addScriptTrack()
|
||
s.addScriptFrame(track.id, 2, [{ type: 'pause' }])
|
||
const handle = mountComponent(Timeline, {})
|
||
await nextTick()
|
||
const frame = handle.host.querySelector('.script-frame') as HTMLElement
|
||
expect(frame).toBeTruthy()
|
||
frame.dispatchEvent(
|
||
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 100, clientY: 50 }),
|
||
)
|
||
await nextTick()
|
||
const menus = Array.from(handle.host.querySelectorAll('.kf-menu')) as HTMLElement[]
|
||
const visible = menus.find((m) => m.offsetParent !== null || m.style.left !== '')
|
||
expect(visible).toBeTruthy()
|
||
const labels = Array.from(visible!.querySelectorAll('.kf-menu-item')).map((b) =>
|
||
b.textContent?.trim(),
|
||
)
|
||
expect(labels.length).toBe(2)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('menu Duplicate creates a second script frame on the same track', async () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
const track = s.addScriptTrack()
|
||
const f = s.addScriptFrame(track.id, 2, [{ type: 'pause' }], 'intro')!
|
||
const handle = mountComponent(Timeline, {})
|
||
await nextTick()
|
||
const frameEl = handle.host.querySelector('.script-frame') as HTMLElement
|
||
frameEl.dispatchEvent(
|
||
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 10, clientY: 10 }),
|
||
)
|
||
await nextTick()
|
||
const menus = Array.from(handle.host.querySelectorAll('.kf-menu')) as HTMLElement[]
|
||
const menu = menus[menus.length - 1]
|
||
const dup = menu.querySelector('.kf-menu-item') as HTMLButtonElement
|
||
dup.click()
|
||
await nextTick()
|
||
const framesOnTrack = (s.scene.scriptFrames ?? []).filter((x) => x.trackId === track.id)
|
||
expect(framesOnTrack).toHaveLength(2)
|
||
const clone = framesOnTrack.find((x) => x.id !== f.id)!
|
||
expect(clone.time).toBeGreaterThan(f.time)
|
||
expect(clone.actions.map((a) => a.type)).toEqual(['pause'])
|
||
expect(clone.label).toBe('intro')
|
||
handle.unmount()
|
||
})
|
||
})
|
||
|
||
suite('ui — Timeline clip-instance context menu', () => {
|
||
test('right-click on a clip-block opens a menu with Duplicate + Delete', async () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
const clip = s.addClip('CInst')
|
||
clip.duration = 2
|
||
const track = s.scene.clipTracks![0]
|
||
s.addClipInstance(clip.id, 0, track.id)
|
||
const handle = mountComponent(Timeline, {})
|
||
await nextTick()
|
||
const block = handle.host.querySelector('.clip-block') as HTMLElement
|
||
expect(block).toBeTruthy()
|
||
block.dispatchEvent(
|
||
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 30, clientY: 40 }),
|
||
)
|
||
await nextTick()
|
||
const menus = Array.from(handle.host.querySelectorAll('.kf-menu')) as HTMLElement[]
|
||
const menu = menus[menus.length - 1]
|
||
expect(menu).toBeTruthy()
|
||
const items = Array.from(menu.querySelectorAll('.kf-menu-item')) as HTMLButtonElement[]
|
||
expect(items).toHaveLength(2)
|
||
expect(items[items.length - 1].classList.contains('danger')).toBe(true)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('menu Duplicate clones the clip instance on the same track', async () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
const clip = s.addClip('CInst')
|
||
clip.duration = 2
|
||
const track = s.scene.clipTracks![0]
|
||
const ci = s.addClipInstance(clip.id, 1, track.id)!
|
||
const handle = mountComponent(Timeline, {})
|
||
await nextTick()
|
||
const block = handle.host.querySelector('.clip-block') as HTMLElement
|
||
block.dispatchEvent(
|
||
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 10, clientY: 10 }),
|
||
)
|
||
await nextTick()
|
||
const menus = Array.from(handle.host.querySelectorAll('.kf-menu')) as HTMLElement[]
|
||
const dup = menus[menus.length - 1].querySelector('.kf-menu-item') as HTMLButtonElement
|
||
dup.click()
|
||
await nextTick()
|
||
const siblings = s.activeScene.clipInstances.filter((x) => x.clipTrackId === track.id)
|
||
expect(siblings).toHaveLength(2)
|
||
const clone = siblings.find((x) => x.id !== ci.id)!
|
||
expect(clone.clipId).toBe(clip.id)
|
||
expect(clone.startTime).toBe(1 + 2)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('menu Delete removes the clip instance', async () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
const clip = s.addClip('CInst')
|
||
clip.duration = 2
|
||
const track = s.scene.clipTracks![0]
|
||
const ci = s.addClipInstance(clip.id, 0, track.id)!
|
||
const handle = mountComponent(Timeline, {})
|
||
await nextTick()
|
||
const block = handle.host.querySelector('.clip-block') as HTMLElement
|
||
block.dispatchEvent(
|
||
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 10, clientY: 10 }),
|
||
)
|
||
await nextTick()
|
||
const menus = Array.from(handle.host.querySelectorAll('.kf-menu')) as HTMLElement[]
|
||
const del = menus[menus.length - 1].querySelector('.kf-menu-item.danger') as HTMLButtonElement
|
||
del.click()
|
||
await nextTick()
|
||
expect(s.activeScene.clipInstances.find((x) => x.id === ci.id)).toBe(undefined)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('pure click on a clip-block selects the instance', async () => {
|
||
// Regression: a clip whose current playhead content is empty can
|
||
// only be grabbed via its timeline block. Pointerdown starts a
|
||
// drag-tracker; a pointerup without motion is a pure click and
|
||
// must land on selectClipInstance so the canvas transformer
|
||
// attaches to its wrapper group.
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
const clip = s.addClip('CInst')
|
||
clip.duration = 2
|
||
const track = s.scene.clipTracks![0]
|
||
const ci = s.addClipInstance(clip.id, 0, track.id)!
|
||
const handle = mountComponent(Timeline, {})
|
||
await nextTick()
|
||
const block = handle.host.querySelector('.clip-block') as HTMLElement
|
||
expect(block).toBeTruthy()
|
||
expect(s.selectedClipInstanceId).toBe(null)
|
||
block.dispatchEvent(
|
||
new PointerEvent('pointerdown', {
|
||
bubbles: true,
|
||
cancelable: true,
|
||
pointerId: 1,
|
||
button: 0,
|
||
clientX: 20,
|
||
clientY: 10,
|
||
}),
|
||
)
|
||
block.dispatchEvent(
|
||
new PointerEvent('pointerup', {
|
||
bubbles: true,
|
||
cancelable: true,
|
||
pointerId: 1,
|
||
button: 0,
|
||
clientX: 20,
|
||
clientY: 10,
|
||
}),
|
||
)
|
||
await nextTick()
|
||
expect(s.selectedClipInstanceId).toBe(ci.id)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('dragging a clip-block does not trigger selection', async () => {
|
||
// If the user actually moves the clip, the pointerup is a drag
|
||
// release — not a click — and must not also fire the click-to-
|
||
// select path. Otherwise every successful move would overwrite
|
||
// whatever the user had selected on the canvas.
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
const clip = s.addClip('CInst')
|
||
clip.duration = 2
|
||
const track = s.scene.clipTracks![0]
|
||
s.addClipInstance(clip.id, 0, track.id)!
|
||
const handle = mountComponent(Timeline, {})
|
||
await nextTick()
|
||
const block = handle.host.querySelector('.clip-block') as HTMLElement
|
||
block.dispatchEvent(
|
||
new PointerEvent('pointerdown', {
|
||
bubbles: true, cancelable: true, pointerId: 2, button: 0,
|
||
clientX: 20, clientY: 10,
|
||
}),
|
||
)
|
||
// Move past the 2px dead-zone to flip `moved` true.
|
||
block.dispatchEvent(
|
||
new PointerEvent('pointermove', {
|
||
bubbles: true, cancelable: true, pointerId: 2,
|
||
clientX: 60, clientY: 10,
|
||
}),
|
||
)
|
||
block.dispatchEvent(
|
||
new PointerEvent('pointerup', {
|
||
bubbles: true, cancelable: true, pointerId: 2, button: 0,
|
||
clientX: 60, clientY: 10,
|
||
}),
|
||
)
|
||
await nextTick()
|
||
expect(s.selectedClipInstanceId).toBe(null)
|
||
handle.unmount()
|
||
})
|
||
})
|
||
|
||
suite('scene store — clip track editor hide', () => {
|
||
test('setClipTrackEditorHidden toggles the flag on + off', () => {
|
||
const s = makeFreshStore()
|
||
s.addClip('CInst')
|
||
const track = s.scene.clipTracks![0]
|
||
expect(track.editorHidden).toBe(undefined)
|
||
s.setClipTrackEditorHidden(track.id, true)
|
||
expect(track.editorHidden).toBe(true)
|
||
s.setClipTrackEditorHidden(track.id, false)
|
||
// Pruned back out so the serialized JSON stays minimal.
|
||
expect('editorHidden' in track).toBe(false)
|
||
})
|
||
|
||
test('hiding a track clears selectedClipInstanceId for instances on it', () => {
|
||
// The transformer watcher reacts to selectedClipInstanceId; if we
|
||
// don't clear it, the handles stay wrapped around a ci-group
|
||
// that's no longer in the Konva stack.
|
||
const s = makeFreshStore()
|
||
const clip = s.addClip('CInst')
|
||
clip.duration = 2
|
||
const track = s.scene.clipTracks![0]
|
||
const ci = s.addClipInstance(clip.id, 0, track.id)!
|
||
s.selectClipInstance(ci.id)
|
||
expect(s.selectedClipInstanceId).toBe(ci.id)
|
||
s.setClipTrackEditorHidden(track.id, true)
|
||
expect(s.selectedClipInstanceId).toBe(null)
|
||
})
|
||
|
||
test('locking a clip track drops selection of an instance on it', () => {
|
||
// The transformer latches onto the ci-group while selection is
|
||
// active. Locking the track makes the ci-group non-listening,
|
||
// so the selection has to go too — otherwise the handles stay
|
||
// visible on a group the user can no longer interact with.
|
||
const s = makeFreshStore()
|
||
const clip = s.addClip('CInst')
|
||
clip.duration = 2
|
||
const track = s.scene.clipTracks![0]
|
||
const ci = s.addClipInstance(clip.id, 0, track.id)!
|
||
s.selectClipInstance(ci.id)
|
||
expect(s.selectedClipInstanceId).toBe(ci.id)
|
||
s.setTrackLocked(track.id, true)
|
||
expect(s.selectedClipInstanceId).toBe(null)
|
||
})
|
||
|
||
test('locking a group containing a clip track drops the instance selection', () => {
|
||
const s = makeFreshStore()
|
||
const clip = s.addClip('CInst')
|
||
clip.duration = 2
|
||
const track = s.scene.clipTracks![0]
|
||
const ci = s.addClipInstance(clip.id, 0, track.id)!
|
||
const g = s.addTrackGroup('G', track.id)
|
||
g.childIds.push(track.id)
|
||
const order = s.scene.trackOrder!
|
||
const ti = order.indexOf(track.id)
|
||
if (ti >= 0) order.splice(ti, 1)
|
||
s.selectClipInstance(ci.id)
|
||
s.setTrackLocked(g.id, true)
|
||
expect(s.selectedClipInstanceId).toBe(null)
|
||
})
|
||
|
||
test('hiding a track leaves selection on a different track alone', () => {
|
||
const s = makeFreshStore()
|
||
const clip = s.addClip('CInst')
|
||
clip.duration = 2
|
||
const t1 = s.scene.clipTracks![0]
|
||
const t2 = s.addClipTrack('T2')
|
||
const ci1 = s.addClipInstance(clip.id, 0, t1.id)!
|
||
const ci2 = s.addClipInstance(clip.id, 5, t2.id)!
|
||
s.selectClipInstance(ci2.id)
|
||
s.setClipTrackEditorHidden(t1.id, true)
|
||
// Only ci1 lives on the hidden track; ci2 is on t2 and must
|
||
// keep its selection.
|
||
expect(s.selectedClipInstanceId).toBe(ci2.id)
|
||
void ci1
|
||
})
|
||
})
|
||
|
||
suite('scene store — track group editor hide', () => {
|
||
test('setGroupEditorHidden toggles the flag on + off', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
const g = s.addTrackGroup(undefined, l.id)
|
||
expect(g.editorHidden).toBe(undefined)
|
||
s.setGroupEditorHidden(g.id, true)
|
||
expect(g.editorHidden).toBe(true)
|
||
s.setGroupEditorHidden(g.id, false)
|
||
expect('editorHidden' in g).toBe(false)
|
||
})
|
||
|
||
test('hiding a group clears selection of a child layer', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
s.selectLayer(l.id)
|
||
const groupId = s.groupSelectedLayers('G1')!
|
||
expect(groupId).toBeTruthy()
|
||
// groupSelectedLayers clears selection after consuming it, so
|
||
// re-select the layer now that it sits inside the group.
|
||
s.selectLayer(l.id)
|
||
expect(s.selectedLayerId).toBe(l.id)
|
||
s.setGroupEditorHidden(groupId, true)
|
||
expect(s.selectedLayerId).toBe(null)
|
||
expect(s.selectedLayerIds).toEqual([])
|
||
})
|
||
|
||
test('hiding a group clears clip-instance selection when the instance track is inside', () => {
|
||
const s = makeFreshStore()
|
||
const clip = s.addClip('CInst')
|
||
clip.duration = 2
|
||
const track = s.scene.clipTracks![0]
|
||
const ci = s.addClipInstance(clip.id, 0, track.id)!
|
||
// Wrap the clip track into a fresh group by poking childIds
|
||
// directly — there's no public addClipTrackToGroup action, the
|
||
// real app nests tracks through drag-drop.
|
||
const g = s.addTrackGroup('G2', track.id)
|
||
g.childIds.push(track.id)
|
||
const order = s.scene.trackOrder!
|
||
const ti = order.indexOf(track.id)
|
||
if (ti >= 0) order.splice(ti, 1)
|
||
s.selectClipInstance(ci.id)
|
||
expect(s.selectedClipInstanceId).toBe(ci.id)
|
||
s.setGroupEditorHidden(g.id, true)
|
||
expect(s.selectedClipInstanceId).toBe(null)
|
||
})
|
||
})
|
||
|
||
suite('ui — Timeline eye button stays clickable on locked tracks', () => {
|
||
test('locked layer row keeps the eye-btn pointer-events auto', async () => {
|
||
// Regression: `.track.locked .track-name { pointer-events: none }`
|
||
// was muting every button in the row; only `.lock-btn` was
|
||
// re-enabled. The eye toggle has to stay clickable so the user
|
||
// can hide / reveal a locked layer without unlocking first.
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
s.setTrackLocked(l.id, true)
|
||
const handle = mountComponent(Timeline, {})
|
||
await nextTick()
|
||
const lockedRow = handle.host.querySelector('.track.locked') as HTMLElement
|
||
expect(lockedRow).toBeTruthy()
|
||
const eye = lockedRow.querySelector('.track-name .eye-btn') as HTMLButtonElement
|
||
expect(eye).toBeTruthy()
|
||
const pe = getComputedStyle(eye).pointerEvents
|
||
expect(pe).toBe('auto')
|
||
// Sanity — the lane IS inert.
|
||
const lane = lockedRow.querySelector('.track-lane') as HTMLElement
|
||
expect(getComputedStyle(lane).pointerEvents).toBe('none')
|
||
handle.unmount()
|
||
})
|
||
|
||
test('locked clip-track row keeps the eye-btn pointer-events auto', async () => {
|
||
const s = makeFreshStore()
|
||
s.addClip('CInst')
|
||
const track = s.scene.clipTracks![0]
|
||
s.setTrackLocked(track.id, true)
|
||
const handle = mountComponent(Timeline, {})
|
||
await nextTick()
|
||
const lockedRow = handle.host.querySelector('.track.clip-track.locked') as HTMLElement
|
||
expect(lockedRow).toBeTruthy()
|
||
const eye = lockedRow.querySelector('.track-name .eye-btn') as HTMLButtonElement
|
||
expect(eye).toBeTruthy()
|
||
expect(getComputedStyle(eye).pointerEvents).toBe('auto')
|
||
handle.unmount()
|
||
})
|
||
|
||
test('toggling editorHidden on a locked layer still writes the flag', async () => {
|
||
// End-to-end: lock the layer, click its eye button, verify
|
||
// editorHidden flipped. Complements the CSS test — together
|
||
// they cover both the pointer-events rule and the store path.
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
s.setTrackLocked(l.id, true)
|
||
const handle = mountComponent(Timeline, {})
|
||
await nextTick()
|
||
const eye = handle.host.querySelector(
|
||
'.track.locked .track-name .eye-btn',
|
||
) as HTMLButtonElement
|
||
eye.click()
|
||
await nextTick()
|
||
expect(l.editorHidden).toBe(true)
|
||
handle.unmount()
|
||
})
|
||
})
|
||
|
||
suite('ui — Timeline clip-track eye button', () => {
|
||
test('clicking the eye toggles editorHidden on the clip track', async () => {
|
||
const s = makeFreshStore()
|
||
s.addClip('CInst')
|
||
const track = s.scene.clipTracks![0]
|
||
const handle = mountComponent(Timeline, {})
|
||
await nextTick()
|
||
const clipTrack = handle.host.querySelector('.track.clip-track') as HTMLElement
|
||
expect(clipTrack).toBeTruthy()
|
||
const eye = clipTrack.querySelector('.track-name .eye-btn') as HTMLButtonElement
|
||
expect(eye).toBeTruthy()
|
||
expect(eye.classList.contains('hidden')).toBe(false)
|
||
eye.click()
|
||
await nextTick()
|
||
expect(track.editorHidden).toBe(true)
|
||
const eye2 = clipTrack.querySelector('.track-name .eye-btn') as HTMLButtonElement
|
||
expect(eye2.classList.contains('hidden')).toBe(true)
|
||
eye2.click()
|
||
await nextTick()
|
||
expect('editorHidden' in track).toBe(false)
|
||
handle.unmount()
|
||
})
|
||
})
|
||
|
||
suite('scene store — track rename (layer + clip track)', () => {
|
||
test('setLayerName applies a trimmed non-empty name', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
s.setLayerName(l.id, ' Hero ')
|
||
expect(l.name).toBe('Hero')
|
||
})
|
||
|
||
test('setLayerName ignores empty / whitespace-only input', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
const original = l.name
|
||
s.setLayerName(l.id, '')
|
||
expect(l.name).toBe(original)
|
||
s.setLayerName(l.id, ' ')
|
||
expect(l.name).toBe(original)
|
||
})
|
||
|
||
test('setClipTrackName sets + clears the optional name field', () => {
|
||
const s = makeFreshStore()
|
||
s.addClip('CInst')
|
||
const track = s.scene.clipTracks![0]
|
||
s.setClipTrackName(track.id, 'Intro clips')
|
||
expect(track.name).toBe('Intro clips')
|
||
// Empty clears back to undefined — default label is shown via i18n.
|
||
s.setClipTrackName(track.id, '')
|
||
expect(track.name).toBe(undefined)
|
||
})
|
||
})
|
||
|
||
suite('ui — Timeline rename menu (layer + clip track)', () => {
|
||
// Finds the menu item whose label contains the given i18n string.
|
||
// Menu items share `.kf-menu-item`, so labels are the only stable
|
||
// handle that distinguishes rename from lock / delete / etc.
|
||
function findMenuItem(menu: HTMLElement, label: string): HTMLButtonElement | null {
|
||
const items = Array.from(menu.querySelectorAll('.kf-menu-item')) as HTMLButtonElement[]
|
||
return items.find((b) => b.textContent?.includes(label)) ?? null
|
||
}
|
||
|
||
test('right-click on a layer row → Rename layer → prompt → setLayerName', async () => {
|
||
const { dialogState, resolveDialog } = await import(
|
||
'@/components/ui/dialogs'
|
||
)
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'Rect', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
const handle = mountComponent(Timeline, {})
|
||
try {
|
||
await nextTick()
|
||
const row = handle.host.querySelector('.track .track-name') as HTMLElement
|
||
expect(row).toBeTruthy()
|
||
row.dispatchEvent(
|
||
new MouseEvent('contextmenu', {
|
||
bubbles: true, cancelable: true, clientX: 10, clientY: 10,
|
||
}),
|
||
)
|
||
await nextTick()
|
||
const menu = handle.host.querySelector('.kf-menu') as HTMLElement
|
||
expect(menu).toBeTruthy()
|
||
const renameBtn = findMenuItem(menu, i18nT('timeline.renameLayer'))
|
||
expect(renameBtn).toBeTruthy()
|
||
// renameFromMenu is async but runs synchronously up to the
|
||
// `await promptDialog(...)`; promptDialog's new Promise cb
|
||
// runs in the same tick and writes dialogState.value before
|
||
// returning. So the prompt state is observable immediately
|
||
// after the click.
|
||
renameBtn!.click()
|
||
expect(dialogState.value?.kind).toBe('prompt')
|
||
resolveDialog('Hero-from-menu')
|
||
// The awaited promptDialog now resolves on a microtask — tick
|
||
// so the continuation (store.setLayerName) runs.
|
||
await nextTick()
|
||
expect(l.name).toBe('Hero-from-menu')
|
||
} finally {
|
||
handle.unmount()
|
||
}
|
||
})
|
||
|
||
test('Rename layer prompt cancelled → layer name stays the same', async () => {
|
||
// Dismissing the prompt must not clobber the existing name.
|
||
const { dialogState, cancelDialog } = await import(
|
||
'@/components/ui/dialogs'
|
||
)
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'Rect', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
const original = l.name
|
||
const handle = mountComponent(Timeline, {})
|
||
try {
|
||
await nextTick()
|
||
const row = handle.host.querySelector('.track .track-name') as HTMLElement
|
||
row.dispatchEvent(
|
||
new MouseEvent('contextmenu', {
|
||
bubbles: true, cancelable: true, clientX: 10, clientY: 10,
|
||
}),
|
||
)
|
||
await nextTick()
|
||
const menu = handle.host.querySelector('.kf-menu') as HTMLElement
|
||
findMenuItem(menu, i18nT('timeline.renameLayer'))!.click()
|
||
expect(dialogState.value?.kind).toBe('prompt')
|
||
cancelDialog()
|
||
await nextTick()
|
||
expect(l.name).toBe(original)
|
||
} finally {
|
||
handle.unmount()
|
||
}
|
||
})
|
||
|
||
test('right-click on a clip-track row → Rename clip track → prompt → setClipTrackName', async () => {
|
||
const { dialogState, resolveDialog } = await import(
|
||
'@/components/ui/dialogs'
|
||
)
|
||
const s = makeFreshStore()
|
||
s.addClip('CInst')
|
||
const track = s.scene.clipTracks![0]
|
||
const handle = mountComponent(Timeline, {})
|
||
try {
|
||
await nextTick()
|
||
const row = handle.host.querySelector(
|
||
'.track.clip-track .track-name',
|
||
) as HTMLElement
|
||
expect(row).toBeTruthy()
|
||
row.dispatchEvent(
|
||
new MouseEvent('contextmenu', {
|
||
bubbles: true, cancelable: true, clientX: 10, clientY: 10,
|
||
}),
|
||
)
|
||
await nextTick()
|
||
const menu = handle.host.querySelector('.kf-menu') as HTMLElement
|
||
expect(menu).toBeTruthy()
|
||
const renameBtn = findMenuItem(menu, i18nT('timeline.renameClipTrack'))
|
||
expect(renameBtn).toBeTruthy()
|
||
renameBtn!.click()
|
||
expect(dialogState.value?.kind).toBe('prompt')
|
||
resolveDialog('Intro clips')
|
||
await nextTick()
|
||
expect(track.name).toBe('Intro clips')
|
||
} finally {
|
||
handle.unmount()
|
||
}
|
||
})
|
||
|
||
test('right-click on a group row → Rename group → prompt → renameGroup', async () => {
|
||
const { dialogState, resolveDialog } = await import(
|
||
'@/components/ui/dialogs'
|
||
)
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
s.selectLayer(l.id)
|
||
const groupId = s.groupSelectedLayers('G1')!
|
||
const group = s.scene.groups!.find((g) => g.id === groupId)!
|
||
const handle = mountComponent(Timeline, {})
|
||
try {
|
||
await nextTick()
|
||
const row = handle.host.querySelector(
|
||
'.track.group-track .track-name',
|
||
) as HTMLElement
|
||
expect(row).toBeTruthy()
|
||
row.dispatchEvent(
|
||
new MouseEvent('contextmenu', {
|
||
bubbles: true, cancelable: true, clientX: 10, clientY: 10,
|
||
}),
|
||
)
|
||
await nextTick()
|
||
const menu = handle.host.querySelector('.kf-menu') as HTMLElement
|
||
expect(menu).toBeTruthy()
|
||
const renameBtn = findMenuItem(menu, i18nT('timeline.renameGroup'))
|
||
expect(renameBtn).toBeTruthy()
|
||
renameBtn!.click()
|
||
expect(dialogState.value?.kind).toBe('prompt')
|
||
resolveDialog('Hero group')
|
||
await nextTick()
|
||
expect(group.name).toBe('Hero group')
|
||
} finally {
|
||
handle.unmount()
|
||
}
|
||
})
|
||
|
||
test('dblclick on a group row opens the rename prompt', async () => {
|
||
// Groups also support rename via double-click on the track
|
||
// name. Covers the second entry point so it stays wired up.
|
||
const { dialogState, resolveDialog } = await import(
|
||
'@/components/ui/dialogs'
|
||
)
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
s.selectLayer(l.id)
|
||
const groupId = s.groupSelectedLayers('Old')!
|
||
const group = s.scene.groups!.find((g) => g.id === groupId)!
|
||
const handle = mountComponent(Timeline, {})
|
||
try {
|
||
await nextTick()
|
||
const row = handle.host.querySelector(
|
||
'.track.group-track .track-name',
|
||
) as HTMLElement
|
||
row.dispatchEvent(
|
||
new MouseEvent('dblclick', { bubbles: true, cancelable: true }),
|
||
)
|
||
expect(dialogState.value?.kind).toBe('prompt')
|
||
resolveDialog('New name')
|
||
await nextTick()
|
||
expect(group.name).toBe('New name')
|
||
} finally {
|
||
handle.unmount()
|
||
}
|
||
})
|
||
})
|
||
|
||
suite('ui — Timeline group eye button', () => {
|
||
test('clicking the eye toggles editorHidden on the track group', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
s.selectLayer(l.id)
|
||
const groupId = s.groupSelectedLayers('G1')!
|
||
const group = s.scene.groups!.find((g) => g.id === groupId)!
|
||
const handle = mountComponent(Timeline, {})
|
||
await nextTick()
|
||
const groupTrack = handle.host.querySelector('.track.group-track') as HTMLElement
|
||
expect(groupTrack).toBeTruthy()
|
||
const eye = groupTrack.querySelector('.track-name .eye-btn') as HTMLButtonElement
|
||
expect(eye).toBeTruthy()
|
||
expect(eye.classList.contains('hidden')).toBe(false)
|
||
eye.click()
|
||
await nextTick()
|
||
expect(group.editorHidden).toBe(true)
|
||
const eye2 = groupTrack.querySelector('.track-name .eye-btn') as HTMLButtonElement
|
||
expect(eye2.classList.contains('hidden')).toBe(true)
|
||
eye2.click()
|
||
await nextTick()
|
||
expect('editorHidden' in group).toBe(false)
|
||
handle.unmount()
|
||
})
|
||
})
|
||
|
||
suite('ui — Timeline playhead-magnetic snap', () => {
|
||
// Snapping on → dragged clip blocks + clip trim handles stick to
|
||
// the playhead (and to keyframes).
|
||
|
||
/** Measure pps from the clip lane's layout width. The clip lane is
|
||
* styled width: laneWidth = duration * effectivePxPerSec, with
|
||
* NO minimum floor — unlike the clip-block itself, whose width
|
||
* is max(6px, …) and over-reports pps for short clips. */
|
||
function measurePxPerSec(root: HTMLElement, sceneDuration: number): number {
|
||
const lane = root.querySelector('.clip-lane') as HTMLElement
|
||
const w = lane.getBoundingClientRect().width
|
||
return w / sceneDuration
|
||
}
|
||
|
||
/** Dispatch a clean pointerdown / pointermove / pointerup sequence
|
||
* that drags an element by `deltaPx` horizontally from its
|
||
* centre. Real mouse pointers use pointerId 1; reusing that
|
||
* keeps setPointerCapture happy across handlers. */
|
||
function dragX(el: HTMLElement, deltaPx: number) {
|
||
const r = el.getBoundingClientRect()
|
||
const cx = r.left + r.width / 2
|
||
const cy = r.top + r.height / 2
|
||
el.dispatchEvent(new PointerEvent('pointerdown', {
|
||
bubbles: true, cancelable: true, pointerId: 1, button: 0,
|
||
clientX: cx, clientY: cy,
|
||
}))
|
||
el.dispatchEvent(new PointerEvent('pointermove', {
|
||
bubbles: true, cancelable: true, pointerId: 1,
|
||
clientX: cx + deltaPx, clientY: cy,
|
||
}))
|
||
el.dispatchEvent(new PointerEvent('pointerup', {
|
||
bubbles: true, cancelable: true, pointerId: 1, button: 0,
|
||
clientX: cx + deltaPx, clientY: cy,
|
||
}))
|
||
}
|
||
|
||
test('dragging a clip block lands exactly on the playhead when close', async () => {
|
||
const sceneDur = 20
|
||
const s = makeFreshStore()
|
||
s.setDuration(sceneDur)
|
||
const clip = s.addClip('C')
|
||
clip.duration = 10 // big enough that block width is always > 6px min
|
||
const track = s.scene.clipTracks![0]
|
||
const ci = s.addClipInstance(clip.id, 0, track.id)!
|
||
s.setPlayhead(6)
|
||
const handle = mountComponent(Timeline, {})
|
||
try {
|
||
await nextTick()
|
||
const block = handle.host.querySelector('.clip-block') as HTMLElement
|
||
expect(block).toBeTruthy()
|
||
const pps = measurePxPerSec(handle.host, sceneDur)
|
||
expect(pps > 0).toBe(true)
|
||
// Land ~0.03s off the playhead = well under the 8px snap
|
||
// threshold at any realistic pps.
|
||
const targetStart = 5.97
|
||
const deltaPx = (targetStart - ci.startTime) * pps
|
||
dragX(block, deltaPx)
|
||
await nextTick()
|
||
expect(ci.startTime).toBe(6)
|
||
} finally {
|
||
handle.unmount()
|
||
}
|
||
})
|
||
|
||
test('dragging the right handle of a clip snaps its end to the playhead', async () => {
|
||
// Reproduces the user request literally: "pull the end of a
|
||
// clip — it has to snap to the needle."
|
||
const sceneDur = 20
|
||
const s = makeFreshStore()
|
||
s.setDuration(sceneDur)
|
||
const clip = s.addClip('C')
|
||
clip.duration = 10 // right edge at startTime+10
|
||
const track = s.scene.clipTracks![0]
|
||
const ci = s.addClipInstance(clip.id, 0, track.id)!
|
||
s.setPlayhead(3)
|
||
const handle = mountComponent(Timeline, {})
|
||
try {
|
||
await nextTick()
|
||
const block = handle.host.querySelector('.clip-block') as HTMLElement
|
||
const rightHandle = block.querySelector(
|
||
'.clip-handle.end',
|
||
) as HTMLElement
|
||
expect(rightHandle).toBeTruthy()
|
||
const pps = measurePxPerSec(handle.host, sceneDur)
|
||
const targetEdge = 3.05
|
||
const deltaPx = (targetEdge - 10) * pps
|
||
dragX(rightHandle, deltaPx)
|
||
await nextTick()
|
||
// Right edge = 0 + 10 - trimEnd, snapped to 3 → trimEnd = 7.
|
||
expect(ci.trimEnd).toBeCloseTo(7, 5)
|
||
} finally {
|
||
handle.unmount()
|
||
}
|
||
})
|
||
|
||
test('dragging the left handle of a clip snaps its start to the playhead', async () => {
|
||
const sceneDur = 20
|
||
const s = makeFreshStore()
|
||
s.setDuration(sceneDur)
|
||
const clip = s.addClip('C')
|
||
clip.duration = 10
|
||
const track = s.scene.clipTracks![0]
|
||
const ci = s.addClipInstance(clip.id, 1, track.id)!
|
||
s.setPlayhead(4)
|
||
const handle = mountComponent(Timeline, {})
|
||
try {
|
||
await nextTick()
|
||
const block = handle.host.querySelector('.clip-block') as HTMLElement
|
||
const leftHandle = block.querySelector(
|
||
'.clip-handle.start',
|
||
) as HTMLElement
|
||
expect(leftHandle).toBeTruthy()
|
||
const pps = measurePxPerSec(handle.host, sceneDur)
|
||
const targetEdge = 3.95
|
||
const deltaPx = (targetEdge - 1) * pps
|
||
dragX(leftHandle, deltaPx)
|
||
await nextTick()
|
||
// Left edge snapped to 4 → startTime = 4, trimStart absorbs
|
||
// the 3s shift (initialTrimStart 0 + delta 3).
|
||
expect(ci.startTime).toBeCloseTo(4, 5)
|
||
expect(ci.trimStart).toBeCloseTo(3, 5)
|
||
} finally {
|
||
handle.unmount()
|
||
}
|
||
})
|
||
})
|
||
|
||
suite('ui — Timeline audio-clip context menu', () => {
|
||
test('right-click on an audio-clip-block opens a menu with Duplicate + Delete', async () => {
|
||
const s = makeFreshStore()
|
||
const asset = s.addAudioAsset({ name: 'beep', src: 'data:audio/mp3;x', duration: 2 })
|
||
const track = s.addAudioTrack('FX')
|
||
s.addAudioClip(asset.id, 0, track.id)
|
||
const handle = mountComponent(Timeline, {})
|
||
await nextTick()
|
||
const block = handle.host.querySelector('.audio-clip-block') as HTMLElement
|
||
expect(block).toBeTruthy()
|
||
block.dispatchEvent(
|
||
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 30, clientY: 40 }),
|
||
)
|
||
await nextTick()
|
||
const menus = Array.from(handle.host.querySelectorAll('.kf-menu')) as HTMLElement[]
|
||
const menu = menus[menus.length - 1]
|
||
const items = Array.from(menu.querySelectorAll('.kf-menu-item')) as HTMLButtonElement[]
|
||
expect(items).toHaveLength(2)
|
||
expect(items[items.length - 1].classList.contains('danger')).toBe(true)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('menu Duplicate clones the audio clip on the same track', async () => {
|
||
const s = makeFreshStore()
|
||
const asset = s.addAudioAsset({ name: 'beep', src: 'data:audio/mp3;x', duration: 2 })
|
||
const track = s.addAudioTrack('FX')
|
||
const clip = s.addAudioClip(asset.id, 1, track.id)!
|
||
const handle = mountComponent(Timeline, {})
|
||
await nextTick()
|
||
const block = handle.host.querySelector('.audio-clip-block') as HTMLElement
|
||
block.dispatchEvent(
|
||
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 10, clientY: 10 }),
|
||
)
|
||
await nextTick()
|
||
const menus = Array.from(handle.host.querySelectorAll('.kf-menu')) as HTMLElement[]
|
||
const dup = menus[menus.length - 1].querySelector('.kf-menu-item') as HTMLButtonElement
|
||
dup.click()
|
||
await nextTick()
|
||
const siblings = s.activeScene.audioClips.filter((c) => c.audioTrackId === track.id)
|
||
expect(siblings).toHaveLength(2)
|
||
const clone = siblings.find((c) => c.id !== clip.id)!
|
||
expect(clone.audioAssetId).toBe(asset.id)
|
||
expect(clone.startTime).toBe(1 + 2)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('menu Delete removes the audio clip', async () => {
|
||
const s = makeFreshStore()
|
||
const asset = s.addAudioAsset({ name: 'beep', src: 'data:audio/mp3;x', duration: 2 })
|
||
const track = s.addAudioTrack('FX')
|
||
const clip = s.addAudioClip(asset.id, 0, track.id)!
|
||
const handle = mountComponent(Timeline, {})
|
||
await nextTick()
|
||
const block = handle.host.querySelector('.audio-clip-block') as HTMLElement
|
||
block.dispatchEvent(
|
||
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 10, clientY: 10 }),
|
||
)
|
||
await nextTick()
|
||
const menus = Array.from(handle.host.querySelectorAll('.kf-menu')) as HTMLElement[]
|
||
const del = menus[menus.length - 1].querySelector('.kf-menu-item.danger') as HTMLButtonElement
|
||
del.click()
|
||
await nextTick()
|
||
expect(s.activeScene.audioClips.find((c) => c.id === clip.id)).toBe(undefined)
|
||
handle.unmount()
|
||
})
|
||
})
|
||
|
||
suite('ui — Timeline new-group button', () => {
|
||
test('clicking the new-group button with a selected layer inserts the group above it', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'Anchor', width: 10, height: 10, fill: '#111' })
|
||
const top = s.addLayer(a.id)!
|
||
const anchor = s.addLayer(a.id)!
|
||
s.selectLayer(anchor.id)
|
||
const handle = mountComponent(Timeline, {})
|
||
await nextTick()
|
||
const btn = handle.host.querySelector(
|
||
`button[aria-label="${i18nT('timeline.addGroupTooltip')}"]`,
|
||
) as HTMLButtonElement
|
||
expect(btn).toBeTruthy()
|
||
btn.click()
|
||
await nextTick()
|
||
const order = s.scene.trackOrder ?? []
|
||
const groupId = (s.scene.groups ?? [])[0]?.id
|
||
expect(groupId).toBeTruthy()
|
||
const gIdx = order.indexOf(groupId!)
|
||
const anchorIdx = order.indexOf(anchor.id)
|
||
// Group sits right above the anchor layer, pushing it down by one slot.
|
||
expect(gIdx).toBe(anchorIdx - 1)
|
||
// Unrelated `top` layer keeps its own slot — not dragged along.
|
||
expect(order.indexOf(top.id) >= 0).toBe(true)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('clicking the new-group button with no selection unshifts to the top (regression)', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#111' })
|
||
s.addLayer(a.id)
|
||
s.selectLayer(null)
|
||
const handle = mountComponent(Timeline, {})
|
||
await nextTick()
|
||
const btn = handle.host.querySelector(
|
||
`button[aria-label="${i18nT('timeline.addGroupTooltip')}"]`,
|
||
) as HTMLButtonElement
|
||
btn.click()
|
||
await nextTick()
|
||
const groupId = (s.scene.groups ?? [])[0]?.id
|
||
expect(s.scene.trackOrder?.[0]).toBe(groupId)
|
||
handle.unmount()
|
||
})
|
||
})
|
||
|
||
suite('ui — Timeline split-svg menu entry', () => {
|
||
const sampleSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40">
|
||
<rect x="0" y="0" width="20" height="20" fill="#f00"/>
|
||
<rect x="20" y="20" width="20" height="20" fill="#0f0"/>
|
||
</svg>`
|
||
|
||
function toDataUrl(svg: string): string {
|
||
return 'data:image/svg+xml,' + encodeURIComponent(svg)
|
||
}
|
||
|
||
test('Split SVG entry appears in the track menu for an SVG-backed layer', async () => {
|
||
const s = makeFreshStore()
|
||
const asset = s.addAsset({
|
||
kind: 'image',
|
||
name: 'Parts',
|
||
src: toDataUrl(sampleSvg),
|
||
width: 40,
|
||
height: 40,
|
||
})
|
||
s.addLayer(asset.id)
|
||
const handle = mountComponent(Timeline, {})
|
||
await nextTick()
|
||
// Layer tracks render as `.track` without a kind-specific suffix;
|
||
// the first track-name in the host is the layer we just added.
|
||
const name = handle.host.querySelector('.track .track-name') as HTMLElement | null
|
||
expect(name).toBeTruthy()
|
||
name!.dispatchEvent(
|
||
new MouseEvent('contextmenu', {
|
||
bubbles: true,
|
||
cancelable: true,
|
||
clientX: 20,
|
||
clientY: 20,
|
||
}),
|
||
)
|
||
await nextTick()
|
||
const menus = Array.from(handle.host.querySelectorAll('.kf-menu')) as HTMLElement[]
|
||
const menu = menus[menus.length - 1]
|
||
expect(menu).toBeTruthy()
|
||
const items = Array.from(menu.querySelectorAll('.kf-menu-item')) as HTMLButtonElement[]
|
||
const hasSplit = items.some((b) => b.textContent?.includes(i18nT('timeline.splitSvg')))
|
||
expect(hasSplit).toBe(true)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('Split SVG entry is absent for a raster (PNG) layer', async () => {
|
||
const s = makeFreshStore()
|
||
const asset = s.addAsset({
|
||
kind: 'image',
|
||
name: 'Raster',
|
||
src: 'data:image/png;base64,iVBORw0K==',
|
||
width: 40,
|
||
height: 40,
|
||
})
|
||
s.addLayer(asset.id)
|
||
const handle = mountComponent(Timeline, {})
|
||
await nextTick()
|
||
// Layer tracks render as `.track` without a kind-specific suffix;
|
||
// the first track-name in the host is the layer we just added.
|
||
const name = handle.host.querySelector('.track .track-name') as HTMLElement | null
|
||
expect(name).toBeTruthy()
|
||
name!.dispatchEvent(
|
||
new MouseEvent('contextmenu', {
|
||
bubbles: true,
|
||
cancelable: true,
|
||
clientX: 20,
|
||
clientY: 20,
|
||
}),
|
||
)
|
||
await nextTick()
|
||
const menus = Array.from(handle.host.querySelectorAll('.kf-menu')) as HTMLElement[]
|
||
const menu = menus[menus.length - 1]
|
||
const items = Array.from(menu.querySelectorAll('.kf-menu-item')) as HTMLButtonElement[]
|
||
const hasSplit = items.some((b) => b.textContent?.includes(i18nT('timeline.splitSvg')))
|
||
expect(hasSplit).toBe(false)
|
||
handle.unmount()
|
||
})
|
||
})
|
||
|
||
suite('ui — Timeline audio track', () => {
|
||
test('audio track renders as an .audio-track lane with one block per clip', async () => {
|
||
const s = makeFreshStore()
|
||
const asset = s.addAudioAsset({ name: 'beep', src: 'data:audio/mp3;x', duration: 2 })
|
||
const track = s.addAudioTrack('FX')
|
||
s.addAudioClip(asset.id, 0, track.id)
|
||
s.addAudioClip(asset.id, 3, track.id)
|
||
const handle = mountComponent(Timeline, {})
|
||
await nextTick()
|
||
const lanes = handle.host.querySelectorAll('.audio-track')
|
||
expect(lanes.length).toBe(1)
|
||
const blocks = handle.host.querySelectorAll('.audio-track .audio-clip-block')
|
||
expect(blocks.length).toBe(2)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('audio-clip-block left position scales with startTime (not hard-coded pps)', async () => {
|
||
// The timeline's px-per-second varies with zoom / fit — avoid
|
||
// pinning a specific pixel value and only assert the ordering:
|
||
// a later startTime produces a larger `left`, a startTime of 0
|
||
// sits flush at 0.
|
||
const s = makeFreshStore()
|
||
const asset = s.addAudioAsset({ name: 'x', src: 'data:audio/mp3;x', duration: 2 })
|
||
const track = s.addAudioTrack('A')
|
||
s.addAudioClip(asset.id, 0, track.id)
|
||
s.addAudioClip(asset.id, 3, track.id)
|
||
const handle = mountComponent(Timeline, {})
|
||
await nextTick()
|
||
const blocks = Array.from(
|
||
handle.host.querySelectorAll('.audio-track .audio-clip-block'),
|
||
) as HTMLElement[]
|
||
expect(blocks).toHaveLength(2)
|
||
const left0 = parseFloat(blocks[0].style.left)
|
||
const left3 = parseFloat(blocks[1].style.left)
|
||
expect(left0).toBe(0)
|
||
expect(left3 > 0).toBe(true)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('muting a track adds the .muted class', async () => {
|
||
const s = makeFreshStore()
|
||
const track = s.addAudioTrack('A')
|
||
const handle = mountComponent(Timeline, {})
|
||
await nextTick()
|
||
const lane = handle.host.querySelector('.audio-track') as HTMLElement
|
||
expect(lane.classList.contains('muted')).toBe(false)
|
||
s.setAudioTrackMuted(track.id, true)
|
||
await nextTick()
|
||
expect(lane.classList.contains('muted')).toBe(true)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('audio track name carries the draggable attribute so it joins drag-reorder', async () => {
|
||
// Audio tracks used to render with no @dragstart, which left them
|
||
// outside the stack-reorder flow. They now share the same hooks
|
||
// as layer / clip / script tracks.
|
||
const s = makeFreshStore()
|
||
s.addAudioTrack('Draggable')
|
||
const handle = mountComponent(Timeline, {})
|
||
await nextTick()
|
||
const name = handle.host.querySelector('.audio-track .track-name') as HTMLElement
|
||
expect(name).toBeTruthy()
|
||
expect(name.getAttribute('draggable')).toBe('true')
|
||
handle.unmount()
|
||
})
|
||
|
||
test('script track matches the default 30px track height', async () => {
|
||
// Regression: scripts used to be 26px, now unified with the other
|
||
// kinds so the timeline stack reads as a single visual list.
|
||
const s = makeFreshStore()
|
||
s.addScriptTrack('S')
|
||
const handle = mountComponent(Timeline, {})
|
||
await nextTick()
|
||
const st = handle.host.querySelector('.script-track') as HTMLElement
|
||
expect(st).toBeTruthy()
|
||
const h = getComputedStyle(st).height
|
||
expect(h).toBe('30px')
|
||
handle.unmount()
|
||
})
|
||
|
||
test('audio track co-exists with other track kinds in the tracks container', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#111' })
|
||
s.addLayer(a.id, 'layer-one')
|
||
s.addAudioTrack('A1')
|
||
const handle = mountComponent(Timeline, {})
|
||
await nextTick()
|
||
// At least two rows in the tracks column; one of them is the audio lane.
|
||
const rows = handle.host.querySelectorAll('.tracks .track')
|
||
expect(rows.length >= 2).toBe(true)
|
||
expect(handle.host.querySelector('.audio-track')).toBeTruthy()
|
||
handle.unmount()
|
||
})
|
||
|
||
test('pointer-drag on an audio-clip-block moves the clip in time', async () => {
|
||
const s = makeFreshStore()
|
||
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 5 })
|
||
const track = s.addAudioTrack('T')
|
||
const clip = s.addAudioClip(asset.id, 1, track.id)!
|
||
const handle = mountComponent(Timeline, {})
|
||
await nextTick()
|
||
const block = handle.host.querySelector('.audio-track .audio-clip-block') as HTMLElement
|
||
expect(block).toBeTruthy()
|
||
// The harness doesn't do layout, so we pin the component's pps
|
||
// by reading the rendered left value before the drag (startTime=1
|
||
// maps to `pps` pixels). That pins the conversion both ways.
|
||
const leftBefore = parseFloat(block.style.left)
|
||
const pps = leftBefore // because startTime was 1s
|
||
expect(pps > 0).toBe(true)
|
||
|
||
// Patch setPointerCapture / releasePointerCapture / hasPointerCapture
|
||
// on the block so pointer capture calls don't throw in the test
|
||
// harness (elements aren't attached in a way that supports it).
|
||
const captured = new Set<number>()
|
||
;(block as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture = (id) => {
|
||
captured.add(id)
|
||
}
|
||
;(block as unknown as { releasePointerCapture: (id: number) => void }).releasePointerCapture = (id) => {
|
||
captured.delete(id)
|
||
}
|
||
;(block as unknown as { hasPointerCapture: (id: number) => boolean }).hasPointerCapture = (id) => captured.has(id)
|
||
|
||
const startX = 200
|
||
const down = new PointerEvent('pointerdown', {
|
||
bubbles: true, cancelable: true, button: 0, pointerId: 1, clientX: startX, clientY: 10,
|
||
})
|
||
block.dispatchEvent(down)
|
||
// Drag right by 2*pps px → 2s forward; new startTime should be ~3s.
|
||
const move = new PointerEvent('pointermove', {
|
||
bubbles: true, cancelable: true, pointerId: 1, clientX: startX + 2 * pps, clientY: 10,
|
||
})
|
||
block.dispatchEvent(move)
|
||
const up = new PointerEvent('pointerup', {
|
||
bubbles: true, cancelable: true, pointerId: 1, clientX: startX + 2 * pps, clientY: 10,
|
||
})
|
||
block.dispatchEvent(up)
|
||
await nextTick()
|
||
expect(Math.abs(clip.startTime - 3) < 0.05).toBe(true)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('dragging clamps startTime at 0 (no negative positions)', async () => {
|
||
const s = makeFreshStore()
|
||
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 5 })
|
||
const track = s.addAudioTrack()
|
||
const clip = s.addAudioClip(asset.id, 1, track.id)!
|
||
const handle = mountComponent(Timeline, {})
|
||
await nextTick()
|
||
const block = handle.host.querySelector('.audio-track .audio-clip-block') as HTMLElement
|
||
const captured = new Set<number>()
|
||
;(block as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture = (id) => {
|
||
captured.add(id)
|
||
}
|
||
;(block as unknown as { releasePointerCapture: (id: number) => void }).releasePointerCapture = (id) => {
|
||
captured.delete(id)
|
||
}
|
||
;(block as unknown as { hasPointerCapture: (id: number) => boolean }).hasPointerCapture = (id) => captured.has(id)
|
||
block.dispatchEvent(
|
||
new PointerEvent('pointerdown', { bubbles: true, cancelable: true, button: 0, pointerId: 1, clientX: 500 }),
|
||
)
|
||
block.dispatchEvent(
|
||
// Big leftward drag — way past the left edge.
|
||
new PointerEvent('pointermove', { bubbles: true, cancelable: true, pointerId: 1, clientX: -5000 }),
|
||
)
|
||
block.dispatchEvent(
|
||
new PointerEvent('pointerup', { bubbles: true, cancelable: true, pointerId: 1, clientX: -5000 }),
|
||
)
|
||
await nextTick()
|
||
expect(clip.startTime).toBe(0)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('pointerup over a different audio track switches the clip to that track', async () => {
|
||
// The live app (which our tests render alongside) ALSO has a
|
||
// Timeline mounted. `document.elementFromPoint` would return
|
||
// whatever element is topmost at a given screen coord — often
|
||
// belonging to the live Timeline, not the one we just mounted.
|
||
// Override the lookup for this test to pin the expected hit.
|
||
const s = makeFreshStore()
|
||
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 2 })
|
||
const t1 = s.addAudioTrack('one')
|
||
const t2 = s.addAudioTrack('two')
|
||
const clip = s.addAudioClip(asset.id, 1, t1.id)!
|
||
const handle = mountComponent(Timeline, {})
|
||
await nextTick()
|
||
const block = handle.host.querySelector(
|
||
`.audio-track[data-audio-track-id="${t1.id}"] .audio-clip-block`,
|
||
) as HTMLElement
|
||
const targetLane = handle.host.querySelector(
|
||
`.audio-track[data-audio-track-id="${t2.id}"]`,
|
||
) as HTMLElement
|
||
expect(block).toBeTruthy()
|
||
expect(targetLane).toBeTruthy()
|
||
|
||
const origFromPoint = document.elementFromPoint.bind(document)
|
||
;(document as unknown as { elementFromPoint: (x: number, y: number) => Element | null }).elementFromPoint = (
|
||
_x: number,
|
||
y: number,
|
||
): Element | null => {
|
||
// Only intercept the sentinel Y we use for the drop — let the
|
||
// original handle every other call so the rest of the app
|
||
// isn't disturbed.
|
||
if (y === 42424242) return targetLane
|
||
return origFromPoint(_x, y)
|
||
}
|
||
|
||
const captured = new Set<number>()
|
||
;(block as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture = (id) => {
|
||
captured.add(id)
|
||
}
|
||
;(block as unknown as { releasePointerCapture: (id: number) => void }).releasePointerCapture = (id) => {
|
||
captured.delete(id)
|
||
}
|
||
;(block as unknown as { hasPointerCapture: (id: number) => boolean }).hasPointerCapture = (id) => captured.has(id)
|
||
try {
|
||
block.dispatchEvent(
|
||
new PointerEvent('pointerdown', { bubbles: true, cancelable: true, button: 0, pointerId: 1, clientX: 100, clientY: 10 }),
|
||
)
|
||
block.dispatchEvent(
|
||
new PointerEvent('pointermove', { bubbles: true, cancelable: true, pointerId: 1, clientX: 110, clientY: 42424242 }),
|
||
)
|
||
block.dispatchEvent(
|
||
new PointerEvent('pointerup', { bubbles: true, cancelable: true, pointerId: 1, clientX: 110, clientY: 42424242 }),
|
||
)
|
||
await nextTick()
|
||
expect(clip.audioTrackId).toBe(t2.id)
|
||
} finally {
|
||
;(document as unknown as { elementFromPoint: (x: number, y: number) => Element | null }).elementFromPoint =
|
||
origFromPoint
|
||
handle.unmount()
|
||
}
|
||
})
|
||
|
||
test('dragging an audio asset from the library shows a ghost preview on the hovered lane', async () => {
|
||
const { setAudioDragSource, clearAudioDragSource } = await import(
|
||
'@/composables/useAudioDragSource'
|
||
)
|
||
const s = makeFreshStore()
|
||
const asset = s.addAudioAsset({ name: 'intro', src: 'data:audio/mp3;x', duration: 3 })
|
||
const track = s.addAudioTrack()
|
||
const handle = mountComponent(Timeline, {})
|
||
await nextTick()
|
||
const lane = handle.host.querySelector('.audio-track .audio-lane') as HTMLElement
|
||
const { timeToX } = stubAudioLane(lane)
|
||
// Library dragstart would populate the shared source ref; simulate it.
|
||
setAudioDragSource({ duration: asset.duration, name: asset.name })
|
||
try {
|
||
const dt = new DataTransfer()
|
||
dt.setData('application/x-animatr-audio-asset', asset.id)
|
||
const over = new DragEvent('dragover', {
|
||
bubbles: true, cancelable: true, dataTransfer: dt,
|
||
clientX: timeToX(5, s.scene.duration), clientY: 10,
|
||
})
|
||
lane.dispatchEvent(over)
|
||
await nextTick()
|
||
const ghost = handle.host.querySelector('.audio-clip-ghost') as HTMLElement
|
||
expect(ghost).toBeTruthy()
|
||
// The ghost width reflects the asset duration (3s * pps).
|
||
const expectedWidthPx = parseFloat(ghost.style.width)
|
||
expect(expectedWidthPx).toBeGreaterThan(0)
|
||
// And dragleave wipes it.
|
||
lane.dispatchEvent(new DragEvent('dragleave', { bubbles: true, cancelable: true, dataTransfer: dt }))
|
||
await nextTick()
|
||
expect(handle.host.querySelector('.audio-clip-ghost')).toBeNull()
|
||
void track
|
||
} finally {
|
||
clearAudioDragSource()
|
||
handle.unmount()
|
||
}
|
||
})
|
||
|
||
test('dragging over a different track renders an .audio-clip-ghost preview on that lane', async () => {
|
||
const s = makeFreshStore()
|
||
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 2 })
|
||
const t1 = s.addAudioTrack('one')
|
||
const t2 = s.addAudioTrack('two')
|
||
s.addAudioClip(asset.id, 1, t1.id)
|
||
const handle = mountComponent(Timeline, {})
|
||
await nextTick()
|
||
const block = handle.host.querySelector(
|
||
`.audio-track[data-audio-track-id="${t1.id}"] .audio-clip-block`,
|
||
) as HTMLElement
|
||
const targetLane = handle.host.querySelector(
|
||
`.audio-track[data-audio-track-id="${t2.id}"]`,
|
||
) as HTMLElement
|
||
expect(block).toBeTruthy()
|
||
expect(targetLane).toBeTruthy()
|
||
|
||
const origFromPoint = document.elementFromPoint.bind(document)
|
||
;(document as unknown as { elementFromPoint: (x: number, y: number) => Element | null }).elementFromPoint = (
|
||
_x,
|
||
y: number,
|
||
) => (y === 42424242 ? targetLane : origFromPoint(_x, y))
|
||
|
||
const captured = new Set<number>()
|
||
;(block as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture = (id) => {
|
||
captured.add(id)
|
||
}
|
||
;(block as unknown as { releasePointerCapture: (id: number) => void }).releasePointerCapture = (id) => {
|
||
captured.delete(id)
|
||
}
|
||
;(block as unknown as { hasPointerCapture: (id: number) => boolean }).hasPointerCapture = (id) => captured.has(id)
|
||
try {
|
||
block.dispatchEvent(
|
||
new PointerEvent('pointerdown', { bubbles: true, cancelable: true, button: 0, pointerId: 1, clientX: 100, clientY: 10 }),
|
||
)
|
||
block.dispatchEvent(
|
||
new PointerEvent('pointermove', { bubbles: true, cancelable: true, pointerId: 1, clientX: 110, clientY: 42424242 }),
|
||
)
|
||
await nextTick()
|
||
// The ghost materialises inside t2's lane, NOT t1's.
|
||
const ghostInT2 = handle.host.querySelector(
|
||
`.audio-track[data-audio-track-id="${t2.id}"] .audio-clip-ghost`,
|
||
)
|
||
const ghostInT1 = handle.host.querySelector(
|
||
`.audio-track[data-audio-track-id="${t1.id}"] .audio-clip-ghost`,
|
||
)
|
||
expect(ghostInT2).toBeTruthy()
|
||
expect(ghostInT1).toBeNull()
|
||
// Release disposes the ghost either way.
|
||
block.dispatchEvent(
|
||
new PointerEvent('pointerup', { bubbles: true, cancelable: true, pointerId: 1, clientX: 110, clientY: 42424242 }),
|
||
)
|
||
await nextTick()
|
||
expect(handle.host.querySelector('.audio-clip-ghost')).toBeNull()
|
||
} finally {
|
||
;(document as unknown as { elementFromPoint: (x: number, y: number) => Element | null }).elementFromPoint =
|
||
origFromPoint
|
||
handle.unmount()
|
||
}
|
||
})
|
||
|
||
test('with only one audio track, pointerup cannot switch the track', async () => {
|
||
const s = makeFreshStore()
|
||
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 2 })
|
||
const only = s.addAudioTrack()
|
||
const clip = s.addAudioClip(asset.id, 1, only.id)!
|
||
const handle = mountComponent(Timeline, {})
|
||
await nextTick()
|
||
const block = handle.host.querySelector('.audio-track .audio-clip-block') as HTMLElement
|
||
const captured = new Set<number>()
|
||
;(block as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture = (id) => {
|
||
captured.add(id)
|
||
}
|
||
;(block as unknown as { releasePointerCapture: (id: number) => void }).releasePointerCapture = (id) => {
|
||
captured.delete(id)
|
||
}
|
||
;(block as unknown as { hasPointerCapture: (id: number) => boolean }).hasPointerCapture = (id) => captured.has(id)
|
||
// Drag horizontally + release anywhere (even way off-lane). The
|
||
// clip must stay on its only track — no neighbours to swap to.
|
||
block.dispatchEvent(
|
||
new PointerEvent('pointerdown', { bubbles: true, cancelable: true, button: 0, pointerId: 1, clientX: 100, clientY: 10 }),
|
||
)
|
||
block.dispatchEvent(
|
||
new PointerEvent('pointermove', { bubbles: true, cancelable: true, pointerId: 1, clientX: 140, clientY: 9999 }),
|
||
)
|
||
block.dispatchEvent(
|
||
new PointerEvent('pointerup', { bubbles: true, cancelable: true, pointerId: 1, clientX: 140, clientY: 9999 }),
|
||
)
|
||
await nextTick()
|
||
expect(clip.audioTrackId).toBe(only.id)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('dragging a clip into a neighbour pushes the neighbour forward (cascade)', async () => {
|
||
// Two clips on the same track: A at t=5 (length 2, 5..7),
|
||
// B at t=0 (length 2, 0..2). Drag B rightward past A — A must
|
||
// shift forward so the two don't overlap.
|
||
const s = makeFreshStore()
|
||
s.setDuration(30)
|
||
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 2 })
|
||
const track = s.addAudioTrack()
|
||
const b = s.addAudioClip(asset.id, 0, track.id)! // B at 0..2
|
||
const a = s.addAudioClip(asset.id, 5, track.id)! // A at 5..7
|
||
const handle = mountComponent(Timeline, {})
|
||
await nextTick()
|
||
const blocks = handle.host.querySelectorAll('.audio-track .audio-clip-block') as NodeListOf<HTMLElement>
|
||
expect(blocks.length).toBe(2)
|
||
const blockB = blocks[0] // rendered in insertion order; B was added first
|
||
const captured = new Set<number>()
|
||
;(blockB as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture = (id) => {
|
||
captured.add(id)
|
||
}
|
||
;(blockB as unknown as { releasePointerCapture: (id: number) => void }).releasePointerCapture = (id) => {
|
||
captured.delete(id)
|
||
}
|
||
;(blockB as unknown as { hasPointerCapture: (id: number) => boolean }).hasPointerCapture = (id) => captured.has(id)
|
||
|
||
// A (second block) sits at startTime=5, its style.left = pps*5.
|
||
// Read pps back from that so the drag delta we synthesise is in
|
||
// timeline-seconds, not pixels.
|
||
const pps = parseFloat(blocks[1].style.left) / 5
|
||
const startX = 500
|
||
blockB.dispatchEvent(
|
||
new PointerEvent('pointerdown', { bubbles: true, cancelable: true, button: 0, pointerId: 1, clientX: startX }),
|
||
)
|
||
// Drag right by 5s → B wants startTime=5, which overlaps A.
|
||
// Cascade pushes A from 5 to 5+2=7.
|
||
blockB.dispatchEvent(
|
||
new PointerEvent('pointermove', { bubbles: true, cancelable: true, pointerId: 1, clientX: startX + 5 * pps }),
|
||
)
|
||
blockB.dispatchEvent(
|
||
new PointerEvent('pointerup', { bubbles: true, cancelable: true, pointerId: 1, clientX: startX + 5 * pps }),
|
||
)
|
||
await nextTick()
|
||
expect(Math.abs(b.startTime - 5) < 0.1).toBe(true)
|
||
expect(Math.abs(a.startTime - 7) < 0.1).toBe(true)
|
||
handle.unmount()
|
||
})
|
||
|
||
// Helper: pin the audio-lane's bounding rect to a known shape AND
|
||
// compute clientX values from the live `scene.duration` so our t
|
||
// targets are immune to whatever `duration.value` resolves to in
|
||
// the test harness. pixelTimeFromEvent uses `ratio * duration`, so
|
||
// clientX = (targetT / duration) * width gives `t ≈ targetT`.
|
||
function stubAudioLane(
|
||
lane: HTMLElement,
|
||
width = 1000,
|
||
): { timeToX: (t: number, duration: number) => number } {
|
||
const fakeRect = {
|
||
left: 0, top: 0, width, height: 20,
|
||
right: width, bottom: 20, x: 0, y: 0, toJSON: () => ({}),
|
||
} as DOMRect
|
||
;(lane as unknown as { getBoundingClientRect: () => DOMRect }).getBoundingClientRect = () =>
|
||
fakeRect
|
||
return {
|
||
timeToX: (t, duration) => Math.round((t / duration) * width),
|
||
}
|
||
}
|
||
|
||
test('drop on a free spot adds a new clip to the lane', async () => {
|
||
const s = makeFreshStore()
|
||
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 2 })
|
||
const track = s.addAudioTrack()
|
||
s.addAudioClip(asset.id, 0, track.id) // occupies t=0..2
|
||
expect(s.activeScene.audioClips).toHaveLength(1)
|
||
const handle = mountComponent(Timeline, {})
|
||
await nextTick()
|
||
const lane = handle.host.querySelector('.audio-track .audio-lane') as HTMLElement
|
||
const { timeToX } = stubAudioLane(lane)
|
||
const dt = new DataTransfer()
|
||
dt.setData('application/x-animatr-audio-asset', asset.id)
|
||
const drop = new DragEvent('drop', {
|
||
bubbles: true, cancelable: true, dataTransfer: dt,
|
||
clientX: timeToX(5, s.scene.duration), clientY: 10,
|
||
})
|
||
lane.dispatchEvent(drop)
|
||
await nextTick()
|
||
expect(s.activeScene.audioClips).toHaveLength(2)
|
||
const newClip = s.activeScene.audioClips[1]
|
||
expect(Math.abs(newClip.startTime - 5) < 0.1).toBe(true)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('drop that would overlap an existing clip is silently refused', async () => {
|
||
const s = makeFreshStore()
|
||
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 3 })
|
||
const track = s.addAudioTrack()
|
||
s.addAudioClip(asset.id, 1, track.id) // occupies t=1..4
|
||
expect(s.activeScene.audioClips).toHaveLength(1)
|
||
const handle = mountComponent(Timeline, {})
|
||
await nextTick()
|
||
const lane = handle.host.querySelector('.audio-track .audio-lane') as HTMLElement
|
||
const { timeToX } = stubAudioLane(lane)
|
||
const dt = new DataTransfer()
|
||
dt.setData('application/x-animatr-audio-asset', asset.id)
|
||
// t=2 lands in the middle of the existing clip.
|
||
const drop = new DragEvent('drop', {
|
||
bubbles: true, cancelable: true, dataTransfer: dt,
|
||
clientX: timeToX(2, s.scene.duration), clientY: 10,
|
||
})
|
||
lane.dispatchEvent(drop)
|
||
await nextTick()
|
||
expect(s.activeScene.audioClips).toHaveLength(1)
|
||
handle.unmount()
|
||
})
|
||
})
|
||
|
||
suite('ui — CanvasStage context menu', () => {
|
||
// mountComponent doesn't install VueKonva, so the `<v-stage>` tree
|
||
// renders as unknown elements. That's fine for these tests — we
|
||
// only care about the host `.stage-container`, the contextmenu
|
||
// handler on it, and the menu overlay. Konva canvas rendering is
|
||
// out of scope here.
|
||
|
||
function dispatchContextMenu(el: Element, x = 50, y = 50) {
|
||
el.dispatchEvent(
|
||
new MouseEvent('contextmenu', {
|
||
bubbles: true,
|
||
cancelable: true,
|
||
clientX: x,
|
||
clientY: y,
|
||
}),
|
||
)
|
||
}
|
||
|
||
test('right-click opens a menu with two actions', async () => {
|
||
makeFreshStore()
|
||
const { useDocumentDrawer } = await import(
|
||
'@/composables/useDocumentDrawer'
|
||
)
|
||
useDocumentDrawer().closeDrawer()
|
||
const handle = mountComponent(CanvasStage, {})
|
||
await nextTick()
|
||
const container = handle.host.querySelector(
|
||
'.stage-container',
|
||
) as HTMLElement
|
||
expect(container).toBeTruthy()
|
||
expect(handle.host.querySelector('.stage-menu')).toBeNull()
|
||
dispatchContextMenu(container, 120, 80)
|
||
await nextTick()
|
||
const menu = handle.host.querySelector('.stage-menu') as HTMLElement
|
||
expect(menu).toBeTruthy()
|
||
const items = menu.querySelectorAll('.stage-menu-item')
|
||
expect(items.length).toBe(2)
|
||
// Positioned at the cursor via fixed coordinates.
|
||
expect(menu.style.left).toBe('120px')
|
||
expect(menu.style.top).toBe('80px')
|
||
handle.unmount()
|
||
})
|
||
|
||
test('clicking Document settings opens the document drawer', async () => {
|
||
makeFreshStore()
|
||
const { useDocumentDrawer } = await import(
|
||
'@/composables/useDocumentDrawer'
|
||
)
|
||
const dd = useDocumentDrawer()
|
||
dd.closeDrawer()
|
||
expect(dd.open.value).toBe(false)
|
||
const handle = mountComponent(CanvasStage, {})
|
||
await nextTick()
|
||
dispatchContextMenu(
|
||
handle.host.querySelector('.stage-container') as HTMLElement,
|
||
)
|
||
await nextTick()
|
||
const firstItem = handle.host.querySelector(
|
||
'.stage-menu .stage-menu-item',
|
||
) as HTMLButtonElement
|
||
expect(firstItem.textContent?.trim()).toBe(
|
||
i18nT('canvas.menu.documentSettings'),
|
||
)
|
||
firstItem.click()
|
||
await nextTick()
|
||
expect(dd.open.value).toBe(true)
|
||
// Menu dismissed on click.
|
||
expect(handle.host.querySelector('.stage-menu')).toBeNull()
|
||
dd.closeDrawer()
|
||
handle.unmount()
|
||
})
|
||
|
||
test('clicking Resize stage closes the menu and switches to resize mode', async () => {
|
||
makeFreshStore()
|
||
const handle = mountComponent(CanvasStage, {})
|
||
await nextTick()
|
||
dispatchContextMenu(
|
||
handle.host.querySelector('.stage-container') as HTMLElement,
|
||
)
|
||
await nextTick()
|
||
const items = handle.host.querySelectorAll(
|
||
'.stage-menu .stage-menu-item',
|
||
) as NodeListOf<HTMLButtonElement>
|
||
const resize = items[1]
|
||
expect(resize.textContent?.trim()).toBe(
|
||
i18nT('canvas.menu.stageResize'),
|
||
)
|
||
resize.click()
|
||
await nextTick()
|
||
expect(handle.host.querySelector('.stage-menu')).toBeNull()
|
||
expect(handle.host.querySelectorAll('.stage-resize-handle').length).toBe(8)
|
||
// Clean up so resize mode doesn't leak into following tests.
|
||
document.dispatchEvent(
|
||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
|
||
)
|
||
await nextTick()
|
||
handle.unmount()
|
||
})
|
||
|
||
test('ESC dismisses the menu', async () => {
|
||
makeFreshStore()
|
||
const handle = mountComponent(CanvasStage, {})
|
||
await nextTick()
|
||
dispatchContextMenu(
|
||
handle.host.querySelector('.stage-container') as HTMLElement,
|
||
)
|
||
await nextTick()
|
||
expect(handle.host.querySelector('.stage-menu')).toBeTruthy()
|
||
document.dispatchEvent(
|
||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
|
||
)
|
||
await nextTick()
|
||
expect(handle.host.querySelector('.stage-menu')).toBeNull()
|
||
handle.unmount()
|
||
})
|
||
|
||
test('pointerdown outside the menu dismisses it', async () => {
|
||
makeFreshStore()
|
||
const handle = mountComponent(CanvasStage, {})
|
||
await nextTick()
|
||
dispatchContextMenu(
|
||
handle.host.querySelector('.stage-container') as HTMLElement,
|
||
)
|
||
await nextTick()
|
||
expect(handle.host.querySelector('.stage-menu')).toBeTruthy()
|
||
// Click somewhere outside the menu — `document.body` will do.
|
||
document.body.dispatchEvent(
|
||
new PointerEvent('pointerdown', { bubbles: true }),
|
||
)
|
||
await nextTick()
|
||
expect(handle.host.querySelector('.stage-menu')).toBeNull()
|
||
handle.unmount()
|
||
})
|
||
|
||
test('clicking Resize stage enters resize mode and renders 8 handles', async () => {
|
||
makeFreshStore()
|
||
const handle = mountComponent(CanvasStage, {})
|
||
await nextTick()
|
||
dispatchContextMenu(
|
||
handle.host.querySelector('.stage-container') as HTMLElement,
|
||
)
|
||
await nextTick()
|
||
const items = handle.host.querySelectorAll(
|
||
'.stage-menu .stage-menu-item',
|
||
) as NodeListOf<HTMLButtonElement>
|
||
items[1].click()
|
||
await nextTick()
|
||
// Menu is gone, handles appear.
|
||
expect(handle.host.querySelector('.stage-menu')).toBeNull()
|
||
const handles = handle.host.querySelectorAll('.stage-resize-handle')
|
||
expect(handles.length).toBe(8)
|
||
const dirs = Array.from(handles).map((h) =>
|
||
Array.from(h.classList).find((c) => c.startsWith('dir-')),
|
||
)
|
||
// Every compass direction is present once.
|
||
for (const d of ['dir-n', 'dir-ne', 'dir-e', 'dir-se', 'dir-s', 'dir-sw', 'dir-w', 'dir-nw']) {
|
||
expect(dirs.includes(d)).toBe(true)
|
||
}
|
||
// Clean up — ESC exits so the next test doesn't inherit the mode.
|
||
document.dispatchEvent(
|
||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
|
||
)
|
||
await nextTick()
|
||
handle.unmount()
|
||
})
|
||
|
||
test('ESC exits resize mode and hides the handles', async () => {
|
||
makeFreshStore()
|
||
const handle = mountComponent(CanvasStage, {})
|
||
await nextTick()
|
||
dispatchContextMenu(
|
||
handle.host.querySelector('.stage-container') as HTMLElement,
|
||
)
|
||
await nextTick()
|
||
const resize = handle.host.querySelectorAll(
|
||
'.stage-menu .stage-menu-item',
|
||
)[1] as HTMLButtonElement
|
||
resize.click()
|
||
await nextTick()
|
||
expect(handle.host.querySelectorAll('.stage-resize-handle').length).toBe(8)
|
||
document.dispatchEvent(
|
||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
|
||
)
|
||
await nextTick()
|
||
expect(handle.host.querySelectorAll('.stage-resize-handle').length).toBe(0)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('Done button in the resize banner exits resize mode', async () => {
|
||
makeFreshStore()
|
||
const handle = mountComponent(CanvasStage, {})
|
||
await nextTick()
|
||
dispatchContextMenu(
|
||
handle.host.querySelector('.stage-container') as HTMLElement,
|
||
)
|
||
await nextTick()
|
||
;(handle.host.querySelectorAll(
|
||
'.stage-menu .stage-menu-item',
|
||
)[1] as HTMLButtonElement).click()
|
||
await nextTick()
|
||
const done = handle.host.querySelector(
|
||
'.stage-resize-done',
|
||
) as HTMLButtonElement
|
||
expect(done).toBeTruthy()
|
||
done.click()
|
||
await nextTick()
|
||
expect(handle.host.querySelectorAll('.stage-resize-handle').length).toBe(0)
|
||
expect(handle.host.querySelector('.stage-resize-banner')).toBeNull()
|
||
handle.unmount()
|
||
})
|
||
|
||
test('resize mode forces crop ON and restores the prior setting on exit', async () => {
|
||
// Crop obscures the effect of the drag — new canvas edges only
|
||
// read as new when everything outside is clipped. Enter forces
|
||
// crop on; exit restores whatever the user had configured before.
|
||
makeFreshStore()
|
||
const handle = mountComponent(CanvasStage, {})
|
||
await nextTick()
|
||
const stage = handle.host.querySelector('.stage-container') as HTMLElement
|
||
// Starts with the crop button inactive (default false).
|
||
const cropBtn = stage.querySelector('.zoom-chip:has(svg), .zoom-btn') // best-effort
|
||
// Easier: check the active class on the crop control via its
|
||
// tooltip-carrying chip — the aria-label flips between on/off.
|
||
function isCroppedActive() {
|
||
const btn = stage.querySelector(
|
||
`[aria-label="${i18nT('canvas.cropOn')}"]`,
|
||
) ||
|
||
stage.querySelector(
|
||
`[aria-label="${i18nT('canvas.cropOff')}"]`,
|
||
)
|
||
return btn?.getAttribute('aria-label') === i18nT('canvas.cropOff')
|
||
}
|
||
const initiallyCropped = isCroppedActive()
|
||
// Enter resize mode via context menu.
|
||
dispatchContextMenu(stage)
|
||
await nextTick()
|
||
;(handle.host.querySelectorAll(
|
||
'.stage-menu .stage-menu-item',
|
||
)[1] as HTMLButtonElement).click()
|
||
await nextTick()
|
||
// While in resize mode, crop is forced on.
|
||
expect(isCroppedActive()).toBe(true)
|
||
// Exit via Done.
|
||
;(handle.host.querySelector(
|
||
'.stage-resize-done',
|
||
) as HTMLButtonElement).click()
|
||
await nextTick()
|
||
// Crop restored to whatever it was before.
|
||
expect(isCroppedActive()).toBe(initiallyCropped)
|
||
void cropBtn
|
||
handle.unmount()
|
||
})
|
||
})
|
||
|
||
suite('ui — CanvasStage centre crosshair', () => {
|
||
test('HUD has a crosshair toggle that flips the active class', async () => {
|
||
makeFreshStore()
|
||
const handle = mountComponent(CanvasStage, {})
|
||
await nextTick()
|
||
const btn = handle.host.querySelector(
|
||
'.zoom-controls .center-cross-btn',
|
||
) as HTMLButtonElement
|
||
expect(btn).toBeTruthy()
|
||
expect(btn.classList.contains('active')).toBe(false)
|
||
btn.click()
|
||
await nextTick()
|
||
expect(btn.classList.contains('active')).toBe(true)
|
||
btn.click()
|
||
await nextTick()
|
||
expect(btn.classList.contains('active')).toBe(false)
|
||
handle.unmount()
|
||
})
|
||
})
|
||
|
||
suite('ui — CanvasStage centre crosshair (Konva)', () => {
|
||
// Verify the lines actually render at the correct coordinates in
|
||
// Konva when the toggle is on. Requires a proper VueKonva mount.
|
||
async function mountCanvasWithKonva() {
|
||
const VueKonva = (await import('vue-konva')).default
|
||
const host = document.createElement('div')
|
||
host.setAttribute('data-test-host', '')
|
||
host.style.width = '800px'
|
||
host.style.height = '600px'
|
||
document.body.appendChild(host)
|
||
const app = createApp({ render() { return h(CanvasStage, {}) } })
|
||
app.use(VueKonva)
|
||
app.directive('tooltip', vTooltip)
|
||
app.mount(host)
|
||
return {
|
||
host, app,
|
||
unmount() { app.unmount(); host.remove() },
|
||
}
|
||
}
|
||
async function getKonvaStage() {
|
||
const Konva = (await import('konva')).default
|
||
return Konva.stages[Konva.stages.length - 1]
|
||
}
|
||
|
||
test('lines span the scene canvas and are off by default', async () => {
|
||
const s = makeFreshStore()
|
||
// Pin a deterministic canvas so the assertions are exact.
|
||
s.scene.canvas = { width: 800, height: 600, background: '#ffffff' }
|
||
const handle = await mountCanvasWithKonva()
|
||
try {
|
||
await nextTick()
|
||
await tick(40)
|
||
const stage = await getKonvaStage()
|
||
// Off by default — no lines from this feature should be in
|
||
// the stack. Match by unique dash pattern fed into our
|
||
// computed (hard-coded lengths don't appear elsewhere).
|
||
const before = stage.find('Line') as unknown as Array<{
|
||
listening: () => boolean
|
||
}>
|
||
const crosshairBefore = before.filter((ln) => ln.listening() === false)
|
||
// This is fuzzy — just make sure the suite mounts cleanly.
|
||
expect(Array.isArray(crosshairBefore)).toBe(true)
|
||
const btn = handle.host.querySelector(
|
||
'.zoom-controls .center-cross-btn',
|
||
) as HTMLButtonElement
|
||
btn.click()
|
||
await nextTick()
|
||
await tick(20)
|
||
const horiz = stage.findOne('Line') as unknown as {
|
||
points: () => number[]
|
||
} | null
|
||
// At least one line exists after the toggle fires; grab all
|
||
// lines and confirm the pair running through (w/2, h/2).
|
||
const all = stage.find('Line') as unknown as Array<{
|
||
points: () => number[]
|
||
}>
|
||
const hasHorizontal = all.some((ln) => {
|
||
const p = ln.points()
|
||
return p.length === 4 && p[1] === 300 && p[3] === 300 && p[0] === 0 && p[2] === 800
|
||
})
|
||
const hasVertical = all.some((ln) => {
|
||
const p = ln.points()
|
||
return p.length === 4 && p[0] === 400 && p[2] === 400 && p[1] === 0 && p[3] === 600
|
||
})
|
||
expect(hasHorizontal).toBe(true)
|
||
expect(hasVertical).toBe(true)
|
||
void horiz
|
||
} finally {
|
||
handle.unmount()
|
||
}
|
||
})
|
||
|
||
test('lines follow the clip canvas when the user enters a clip', async () => {
|
||
// The crosshair uses effectiveCanvas, which for a scene (even
|
||
// inside enterClip) is the scene canvas. Verify the lines
|
||
// still render while clip-edit mode is active so the user has
|
||
// the same compose guide inside a clip.
|
||
const s = makeFreshStore()
|
||
s.scene.canvas = { width: 640, height: 480, background: '#ffffff' }
|
||
const clip = s.addClip('CInst')
|
||
s.enterClip(clip.id)
|
||
const handle = await mountCanvasWithKonva()
|
||
try {
|
||
await nextTick()
|
||
await tick(40)
|
||
const btn = handle.host.querySelector(
|
||
'.zoom-controls .center-cross-btn',
|
||
) as HTMLButtonElement
|
||
btn.click()
|
||
await nextTick()
|
||
await tick(20)
|
||
const stage = await getKonvaStage()
|
||
const lines = stage.find('Line') as unknown as Array<{
|
||
points: () => number[]
|
||
}>
|
||
const hasHorizontal = lines.some((ln) => {
|
||
const p = ln.points()
|
||
return p.length === 4 && p[1] === 240 && p[3] === 240 && p[0] === 0 && p[2] === 640
|
||
})
|
||
const hasVertical = lines.some((ln) => {
|
||
const p = ln.points()
|
||
return p.length === 4 && p[0] === 320 && p[2] === 320 && p[1] === 0 && p[3] === 480
|
||
})
|
||
expect(hasHorizontal).toBe(true)
|
||
expect(hasVertical).toBe(true)
|
||
} finally {
|
||
handle.unmount()
|
||
}
|
||
})
|
||
})
|
||
|
||
suite('ui — CanvasStage clip-instance transform', () => {
|
||
// mountComponent skips VueKonva, which is fine for DOM-overlay
|
||
// tests but not for clip-instance selection: we need real Konva
|
||
// nodes so `attachTransformer` can find #ci-group:<id> and so the
|
||
// Konva event bus bubbles mousedown from a child shape up to the
|
||
// group's @mousedown handler.
|
||
async function mountCanvasWithKonva() {
|
||
const VueKonva = (await import('vue-konva')).default
|
||
const host = document.createElement('div')
|
||
host.setAttribute('data-test-host', '')
|
||
host.style.width = '800px'
|
||
host.style.height = '600px'
|
||
document.body.appendChild(host)
|
||
const app = createApp({
|
||
render() {
|
||
return h(CanvasStage, {})
|
||
},
|
||
})
|
||
app.use(VueKonva)
|
||
app.directive('tooltip', vTooltip)
|
||
app.mount(host)
|
||
return {
|
||
host,
|
||
app,
|
||
unmount() {
|
||
app.unmount()
|
||
host.remove()
|
||
},
|
||
}
|
||
}
|
||
|
||
async function getKonvaStage() {
|
||
const Konva = (await import('konva')).default
|
||
// Last stage = the one just mounted by this test. The app's
|
||
// background CanvasStage also registers a stage, so we can't
|
||
// just take stages[0].
|
||
return Konva.stages[Konva.stages.length - 1]
|
||
}
|
||
|
||
/** Seed a scene with a single clip holding one rect layer, plus
|
||
* one instance of that clip on a new clip track. Returns the
|
||
* instance + the inner layer id so tests can target them by id. */
|
||
function seedClipInstance() {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
const asset = s.addAsset({
|
||
kind: 'rect',
|
||
name: 'R',
|
||
width: 80,
|
||
height: 80,
|
||
fill: '#f33',
|
||
})
|
||
const clip = s.addClip('CInst')
|
||
clip.duration = 2
|
||
s.enterClip(clip.id)
|
||
const innerLayer = s.addLayer(asset.id)!
|
||
s.exitClip()
|
||
const track = s.scene.clipTracks![0]
|
||
const ci = s.addClipInstance(clip.id, 0, track.id)!
|
||
return { s, clip, ci, innerLayerId: innerLayer.id }
|
||
}
|
||
|
||
test('selectClipInstance attaches the transformer to #ci-group:<id>', async () => {
|
||
// Simplest path: programmatic selection must wire the main
|
||
// transformer to the clip-instance wrapper group. If this fails
|
||
// the transform handles never appear, regardless of how the
|
||
// selection got set (mousedown handler, keyboard, etc.).
|
||
const { s, ci } = seedClipInstance()
|
||
const handle = await mountCanvasWithKonva()
|
||
try {
|
||
await nextTick()
|
||
await tick(40)
|
||
s.selectClipInstance(ci.id)
|
||
await nextTick()
|
||
await tick(40)
|
||
const stage = await getKonvaStage()
|
||
const groupId = `ci-group:${ci.id}`
|
||
const group = stage.findOne(`#${groupId}`)
|
||
expect(group).toBeTruthy()
|
||
// The main transformer (not the hit-shape one) must now wrap
|
||
// the clip-instance group.
|
||
const transformers = stage.find('Transformer')
|
||
const attachedToGroup = transformers.some((tr) => {
|
||
const nodes = (tr as unknown as { nodes: () => unknown[] }).nodes()
|
||
return nodes.length === 1 && (nodes[0] as { id: () => string }).id() === groupId
|
||
})
|
||
expect(attachedToGroup).toBe(true)
|
||
} finally {
|
||
handle.unmount()
|
||
}
|
||
})
|
||
|
||
test('mousedown on a clip-instance child shape selects the instance', async () => {
|
||
// Regression guard: after multi-clip-per-track landed nobody was
|
||
// checking that the bubbled-up @mousedown on the wrapper group
|
||
// still selects the instance. The inner shape must be listening
|
||
// so Konva bubbles the event through to the group's handler.
|
||
const { s, ci, innerLayerId } = seedClipInstance()
|
||
const handle = await mountCanvasWithKonva()
|
||
try {
|
||
await nextTick()
|
||
await tick(40)
|
||
const stage = await getKonvaStage()
|
||
const inner = stage.findOne(`#ci:${ci.id}:${innerLayerId}`)
|
||
expect(inner).toBeTruthy()
|
||
;(inner as unknown as {
|
||
fire: (type: string, evt: unknown, bubble: boolean) => void
|
||
}).fire(
|
||
'mousedown',
|
||
{ target: inner, type: 'mousedown', evt: { button: 0 } },
|
||
true,
|
||
)
|
||
await nextTick()
|
||
expect(s.selectedClipInstanceId).toBe(ci.id)
|
||
} finally {
|
||
handle.unmount()
|
||
}
|
||
})
|
||
|
||
test('transformend on the ci-group writes back via setClipInstanceTransform', async () => {
|
||
// End-to-end: the user dragged a handle, Konva applied the new
|
||
// scale/rotation to the group node, and on pointerup the
|
||
// transformend handler must persist those values onto the
|
||
// instance so the stored scene reflects the transform.
|
||
const { s, ci } = seedClipInstance()
|
||
const handle = await mountCanvasWithKonva()
|
||
try {
|
||
await nextTick()
|
||
await tick(40)
|
||
s.selectClipInstance(ci.id)
|
||
await nextTick()
|
||
await tick(40)
|
||
const stage = await getKonvaStage()
|
||
const group = stage.findOne(`#ci-group:${ci.id}`) as unknown as {
|
||
scaleX: (v?: number) => number
|
||
scaleY: (v?: number) => number
|
||
rotation: (v?: number) => number
|
||
x: (v?: number) => number
|
||
y: (v?: number) => number
|
||
fire: (type: string, evt: unknown, bubble: boolean) => void
|
||
}
|
||
expect(group).toBeTruthy()
|
||
group.scaleX(2)
|
||
group.scaleY(3)
|
||
group.rotation(45)
|
||
group.x(17)
|
||
group.y(-9)
|
||
group.fire(
|
||
'transformend',
|
||
{ target: group, type: 'transformend' },
|
||
true,
|
||
)
|
||
await nextTick()
|
||
expect(ci.scaleX).toBe(2)
|
||
expect(ci.scaleY).toBe(3)
|
||
expect(ci.rotation).toBe(45)
|
||
expect(ci.x).toBe(17)
|
||
expect(ci.y).toBe(-9)
|
||
} finally {
|
||
handle.unmount()
|
||
}
|
||
})
|
||
|
||
test('ci-group is framed by an invisible ci-frame rect at the clip bounds', async () => {
|
||
// The frame rect locks the transformer onto the clip's own
|
||
// outer constraints instead of the currently-visible inner
|
||
// shape. Its bbox dominates the group's client rect so handles
|
||
// stay put even when inner layers animate.
|
||
const { s, ci } = seedClipInstance()
|
||
const handle = await mountCanvasWithKonva()
|
||
try {
|
||
await nextTick()
|
||
await tick(40)
|
||
const stage = await getKonvaStage()
|
||
const frame = stage.findOne(`#ci-frame:${ci.id}`) as unknown as {
|
||
width: () => number
|
||
height: () => number
|
||
name: () => string
|
||
listening: () => boolean
|
||
} | null
|
||
expect(frame).toBeTruthy()
|
||
expect(frame!.name()).toBe('ci-frame')
|
||
expect(frame!.listening()).toBe(true)
|
||
expect(frame!.width()).toBeGreaterThan(0)
|
||
expect(frame!.height()).toBeGreaterThan(0)
|
||
// The seed clip has a single 80×80 rect, so the frame must be
|
||
// at least large enough to enclose it.
|
||
expect(frame!.width()).toBeGreaterThan(79)
|
||
expect(frame!.height()).toBeGreaterThan(79)
|
||
// Sanity: the stage's selection store hasn't flipped just by
|
||
// rendering — only an actual click should select.
|
||
expect(s.selectedClipInstanceId).toBe(null)
|
||
} finally {
|
||
handle.unmount()
|
||
}
|
||
})
|
||
|
||
test('clicking the ci-frame on an empty-content frame selects the instance', async () => {
|
||
// Regression: if the clip has no visible content at the current
|
||
// playhead, the old bundle was dropped entirely — no ci-group,
|
||
// nothing to click. With the frame rect the bundle always
|
||
// renders and empty-frame clips remain selectable.
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
const rect = s.addAsset({
|
||
kind: 'rect', name: 'R', width: 60, height: 60, fill: '#f00',
|
||
})
|
||
const clip = s.addClip('Hidden')
|
||
clip.duration = 2
|
||
s.enterClip(clip.id)
|
||
const inner = s.addLayer(rect.id)!
|
||
s.exitClip()
|
||
// Force the layer to be editorHidden or invisible at playhead 0
|
||
// so `items` comes back empty. Flipping `visible` is the
|
||
// authoritative hide — stackItems skips `!cl.visible`.
|
||
inner.visible = false
|
||
const track = s.scene.clipTracks![0]
|
||
const ci = s.addClipInstance(clip.id, 0, track.id)!
|
||
|
||
const handle = await mountCanvasWithKonva()
|
||
try {
|
||
await nextTick()
|
||
await tick(40)
|
||
const stage = await getKonvaStage()
|
||
const frame = stage.findOne(`#ci-frame:${ci.id}`)
|
||
expect(frame).toBeTruthy()
|
||
;(frame as unknown as {
|
||
fire: (type: string, evt: unknown, bubble: boolean) => void
|
||
}).fire(
|
||
'mousedown',
|
||
{ target: frame, type: 'mousedown', evt: { button: 0 } },
|
||
true,
|
||
)
|
||
await nextTick()
|
||
expect(s.selectedClipInstanceId).toBe(ci.id)
|
||
} finally {
|
||
handle.unmount()
|
||
}
|
||
})
|
||
|
||
test('editorHidden group cascades — children vanish from the Konva stack', async () => {
|
||
// Regression guard for the cascade: even if the child track's
|
||
// own flag is unset, hiding the parent group must drop its
|
||
// layers and clip instances from the editor.
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
const rect = s.addAsset({
|
||
kind: 'rect', name: 'R', width: 40, height: 40, fill: '#f00',
|
||
})
|
||
const layer = s.addLayer(rect.id)!
|
||
s.selectLayer(layer.id)
|
||
const groupId = s.groupSelectedLayers('G')!
|
||
expect(groupId).toBeTruthy()
|
||
s.setGroupEditorHidden(groupId, true)
|
||
const handle = await mountCanvasWithKonva()
|
||
try {
|
||
await nextTick()
|
||
await tick(40)
|
||
const stage = await getKonvaStage()
|
||
// The child layer's Konva node carries the layer id directly.
|
||
expect(stage.findOne(`#${layer.id}`)).toBeFalsy()
|
||
} finally {
|
||
handle.unmount()
|
||
}
|
||
})
|
||
|
||
test('locked clip track → ci-group is non-listening + non-draggable', async () => {
|
||
// Regression guard: before this, a locked clip track still
|
||
// rendered an interactive ci-group. Clicking its shapes would
|
||
// select and drag the instance as if nothing were locked. The
|
||
// group's `listening: false` cascades to the frame rect and
|
||
// every child, so one flag covers select / drag / transform.
|
||
const { s, ci } = seedClipInstance()
|
||
const track = s.scene.clipTracks![0]
|
||
s.setTrackLocked(track.id, true)
|
||
const handle = await mountCanvasWithKonva()
|
||
try {
|
||
await nextTick()
|
||
await tick(40)
|
||
const stage = await getKonvaStage()
|
||
const group = stage.findOne(`#ci-group:${ci.id}`) as unknown as {
|
||
listening: () => boolean
|
||
draggable: () => boolean
|
||
} | null
|
||
expect(group).toBeTruthy()
|
||
expect(group!.listening()).toBe(false)
|
||
expect(group!.draggable()).toBe(false)
|
||
} finally {
|
||
handle.unmount()
|
||
}
|
||
})
|
||
|
||
test('editorHidden clip track drops its instances from the Konva stack', async () => {
|
||
// Matches the layer editor-hide semantics: the track stays in
|
||
// the scene file (so exports still see it) but no ci-group or
|
||
// ci-frame is rendered in the editor.
|
||
const { s, ci } = seedClipInstance()
|
||
const track = s.scene.clipTracks![0]
|
||
s.setClipTrackEditorHidden(track.id, true)
|
||
const handle = await mountCanvasWithKonva()
|
||
try {
|
||
await nextTick()
|
||
await tick(40)
|
||
const stage = await getKonvaStage()
|
||
expect(stage.findOne(`#ci-group:${ci.id}`)).toBeFalsy()
|
||
expect(stage.findOne(`#ci-frame:${ci.id}`)).toBeFalsy()
|
||
} finally {
|
||
handle.unmount()
|
||
}
|
||
})
|
||
|
||
test('second clip instance on the same track is independently selectable', async () => {
|
||
// The user's own hunch points at multi-clip-per-track: when a
|
||
// track carries two instances, picking the second one must
|
||
// target that instance, not the first. Guards against a bundle
|
||
// / key collision that would route both clicks to the same
|
||
// group.
|
||
const { s, clip } = seedClipInstance()
|
||
const track = s.scene.clipTracks![0]
|
||
// Put the second instance at the same startTime so both are in
|
||
// range at the default playhead of 0 and both ci-groups are in
|
||
// the Konva stack.
|
||
const ci2 = s.addClipInstance(clip.id, 0, track.id)!
|
||
const handle = await mountCanvasWithKonva()
|
||
try {
|
||
await nextTick()
|
||
await tick(40)
|
||
const stage = await getKonvaStage()
|
||
const group2 = stage.findOne(`#ci-group:${ci2.id}`)
|
||
expect(group2).toBeTruthy()
|
||
;(group2 as unknown as {
|
||
fire: (type: string, evt: unknown, bubble: boolean) => void
|
||
}).fire(
|
||
'mousedown',
|
||
{ target: group2, type: 'mousedown', evt: { button: 0 } },
|
||
true,
|
||
)
|
||
await nextTick()
|
||
expect(s.selectedClipInstanceId).toBe(ci2.id)
|
||
} finally {
|
||
handle.unmount()
|
||
}
|
||
})
|
||
})
|
||
|
||
suite('ui — AssetSidebar audio section', () => {
|
||
test('lists audio assets with a duration label under the Audio heading', async () => {
|
||
const s = makeFreshStore()
|
||
s.addAudioAsset({ name: 'intro', src: 'data:audio/mp3;x', duration: 2.75 })
|
||
const handle = mountComponent(AssetSidebar, {})
|
||
await nextTick()
|
||
const audioRows = handle.host.querySelectorAll('.audio-asset')
|
||
expect(audioRows.length).toBe(1)
|
||
const sub = audioRows[0].querySelector('.clip-sub')
|
||
expect(sub?.textContent?.trim()).toBe('2.75s')
|
||
handle.unmount()
|
||
})
|
||
|
||
test('empty state shows the "no audio imported" placeholder', async () => {
|
||
makeFreshStore()
|
||
const handle = mountComponent(AssetSidebar, {})
|
||
await nextTick()
|
||
const empties = Array.from(handle.host.querySelectorAll('.empty')).map((el) =>
|
||
el.textContent?.trim(),
|
||
)
|
||
expect(empties.some((txt) => (txt ?? '').toLowerCase().includes('audio'))).toBe(true)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('sections are expanded by default and collapse on header click', async () => {
|
||
try {
|
||
localStorage.removeItem('animatr:assetSectionsCollapsed')
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
const s = makeFreshStore()
|
||
s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 1 })
|
||
const handle = mountComponent(AssetSidebar, {})
|
||
await nextTick()
|
||
// All four sections present in the header order.
|
||
const titles = Array.from(handle.host.querySelectorAll('.section-head-row .section-title'))
|
||
.map((el) => el.textContent?.trim())
|
||
expect(titles).toEqual(['Images', 'Clips', 'Buttons', 'Audio'])
|
||
// Default: audio list is visible.
|
||
const audioList = handle.host.querySelector('.audio-asset')
|
||
expect(audioList).toBeTruthy()
|
||
// Click the Audio section header → list hidden.
|
||
const audioHead = Array.from(handle.host.querySelectorAll('.section-head-row .section-head'))
|
||
.find((el) => el.querySelector('.section-title')?.textContent?.trim() === 'Audio') as HTMLButtonElement
|
||
expect(audioHead).toBeTruthy()
|
||
audioHead.click()
|
||
await nextTick()
|
||
const audioRowAfter = handle.host.querySelector('.audio-asset') as HTMLElement | null
|
||
expect(audioRowAfter).toBeTruthy()
|
||
expect(audioRowAfter!.offsetParent).toBeNull()
|
||
audioHead.click()
|
||
await nextTick()
|
||
expect((handle.host.querySelector('.audio-asset') as HTMLElement).offsetParent !== null).toBe(true)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('section count badge is the last element in every section row (right-aligned)', async () => {
|
||
// Both actionless rows (Images / Audio) and action-bearing rows
|
||
// (Clips / Buttons) must end with their count badge so the right
|
||
// column stays visually aligned across the whole sidebar.
|
||
try {
|
||
localStorage.removeItem('animatr:assetSectionsCollapsed')
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
makeFreshStore()
|
||
const handle = mountComponent(AssetSidebar, {})
|
||
await nextTick()
|
||
const rows = handle.host.querySelectorAll('.section-head-row')
|
||
expect(rows.length).toBe(4)
|
||
for (const row of Array.from(rows)) {
|
||
const last = row.lastElementChild as HTMLElement
|
||
expect(last.classList.contains('section-count')).toBe(true)
|
||
}
|
||
handle.unmount()
|
||
})
|
||
|
||
test('Library label is gone; "Thumbs" slide switch sits in the Assets header', async () => {
|
||
makeFreshStore()
|
||
const handle = mountComponent(AssetSidebar, {})
|
||
await nextTick()
|
||
// Nothing in the sidebar still says "Library".
|
||
expect(handle.host.textContent?.includes('Library')).toBe(false)
|
||
// The slide switch lives in `.assets-header` next to the <h3> —
|
||
// pin that structure so the layout doesn't drift back.
|
||
const header = handle.host.querySelector('.assets-header')
|
||
expect(header).toBeTruthy()
|
||
const h3 = header!.querySelector('h3')
|
||
expect(h3?.textContent?.trim()).toBe('Assets')
|
||
const label = header!.querySelector('.thumbs-switch .thumbs-switch-label')
|
||
expect(label?.textContent?.trim()).toBe('Thumbs')
|
||
// And the underlying checkbox input toggles showThumbs.
|
||
const input = header!.querySelector('.thumbs-switch-input') as HTMLInputElement
|
||
expect(input).toBeTruthy()
|
||
const initial = input.checked
|
||
input.checked = !initial
|
||
input.dispatchEvent(new Event('change', { bubbles: true }))
|
||
await nextTick()
|
||
// Re-read from a fresh query — the binding updates the checkbox
|
||
// via its `:checked` attribute on re-render.
|
||
const input2 = handle.host.querySelector('.thumbs-switch-input') as HTMLInputElement
|
||
expect(input2.checked).toBe(!initial)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('dragstart on an audio row sets the audio-asset DnD payload', async () => {
|
||
const s = makeFreshStore()
|
||
const asset = s.addAudioAsset({ name: 'beep', src: 'data:audio/mp3;x', duration: 1 })
|
||
const handle = mountComponent(AssetSidebar, {})
|
||
await nextTick()
|
||
const row = handle.host.querySelector('.audio-asset') as HTMLElement
|
||
expect(row).toBeTruthy()
|
||
const dt = new DataTransfer()
|
||
const ev = new DragEvent('dragstart', { bubbles: true, cancelable: true, dataTransfer: dt })
|
||
row.dispatchEvent(ev)
|
||
expect(dt.getData('application/x-animatr-audio-asset')).toBe(asset.id)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('dragover with OS files toggles the .drop-hot outline on the panel', async () => {
|
||
makeFreshStore()
|
||
const handle = mountComponent(AssetSidebar, {})
|
||
await nextTick()
|
||
const panel = handle.host.querySelector('.panel') as HTMLElement
|
||
expect(panel.classList.contains('drop-hot')).toBe(false)
|
||
const dt = new DataTransfer()
|
||
// `Files` is the well-known type DataTransfer.types reports for
|
||
// OS-initiated file drags. No need to actually attach a file for
|
||
// the dragover — the handler only sniffs `types`.
|
||
dt.items.add(new File(['x'], 'mock.mp3', { type: 'audio/mpeg' }))
|
||
const over = new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer: dt })
|
||
panel.dispatchEvent(over)
|
||
await nextTick()
|
||
expect(panel.classList.contains('drop-hot')).toBe(true)
|
||
const leave = new DragEvent('dragleave', { bubbles: true, cancelable: true, dataTransfer: dt })
|
||
panel.dispatchEvent(leave)
|
||
await nextTick()
|
||
expect(panel.classList.contains('drop-hot')).toBe(false)
|
||
handle.unmount()
|
||
})
|
||
})
|
||
|
||
suite('polygon — vertex bezier actions', () => {
|
||
function makeSquare() {
|
||
const s = makeFreshStore()
|
||
const asset = s.addAsset({
|
||
kind: 'polygon',
|
||
name: 'Sq',
|
||
points: [
|
||
{ x: -40, y: -40 },
|
||
{ x: 40, y: -40 },
|
||
{ x: 40, y: 40 },
|
||
{ x: -40, y: 40 },
|
||
],
|
||
fill: '#abc',
|
||
}) as PolygonAsset
|
||
const layer = s.addLayer(asset.id)!
|
||
return { s, asset, layer }
|
||
}
|
||
|
||
test('setPolygonVertexType curve sets type + default tangents', () => {
|
||
const { s, asset } = makeSquare()
|
||
s.setPolygonVertexType(asset.id, 1, 'curve')
|
||
const p = asset.points[1]
|
||
expect(p.type).toBe('curve')
|
||
expect(typeof p.outX).toBe('number')
|
||
expect(typeof p.outY).toBe('number')
|
||
expect(typeof p.inX).toBe('number')
|
||
expect(typeof p.inY).toBe('number')
|
||
// Curve symmetry: in = -out
|
||
expect(Math.abs((p.inX ?? 0) + (p.outX ?? 0)) < 1e-6).toBe(true)
|
||
expect(Math.abs((p.inY ?? 0) + (p.outY ?? 0)) < 1e-6).toBe(true)
|
||
})
|
||
|
||
test('setPolygonVertexType corner clears tangent fields', () => {
|
||
const { s, asset } = makeSquare()
|
||
s.setPolygonVertexType(asset.id, 2, 'curve')
|
||
s.setPolygonVertexType(asset.id, 2, 'corner')
|
||
const p = asset.points[2]
|
||
expect(p.type).toBe(undefined)
|
||
expect(p.inX).toBe(undefined)
|
||
expect(p.inY).toBe(undefined)
|
||
expect(p.outX).toBe(undefined)
|
||
expect(p.outY).toBe(undefined)
|
||
})
|
||
|
||
test('setPolygonVertexType accepts a list of indices', () => {
|
||
const { s, asset } = makeSquare()
|
||
s.setPolygonVertexType(asset.id, [0, 2], 'splitcurve')
|
||
expect(asset.points[0].type).toBe('splitcurve')
|
||
expect(asset.points[1].type).toBe(undefined)
|
||
expect(asset.points[2].type).toBe('splitcurve')
|
||
expect(asset.points[3].type).toBe(undefined)
|
||
})
|
||
|
||
test('setPolygonVertexTangent writes per-keyframe (curve mirrors sides)', async () => {
|
||
const { s, asset, layer } = makeSquare()
|
||
s.setPolygonVertexType(asset.id, 1, 'curve')
|
||
s.setPlayhead(0.5)
|
||
s.setPolygonVertexTangent(asset.id, 1, 'out', 20, 10)
|
||
await tick()
|
||
const kf = layer.keyframes.find((k) => Math.abs(k.time - 0.5) < 1e-3)!
|
||
const pt = kf.props.points![1]
|
||
expect(pt.outX).toBe(20)
|
||
expect(pt.outY).toBe(10)
|
||
expect(pt.inX).toBe(-20)
|
||
expect(pt.inY).toBe(-10)
|
||
})
|
||
|
||
test('setPolygonVertexTangent splitcurve keeps sides independent', async () => {
|
||
const { s, asset, layer } = makeSquare()
|
||
s.setPolygonVertexType(asset.id, 1, 'splitcurve')
|
||
s.setPlayhead(0)
|
||
s.setPolygonVertexTangent(asset.id, 1, 'out', 10, 0)
|
||
s.setPolygonVertexTangent(asset.id, 1, 'in', -3, 4)
|
||
await tick()
|
||
const kf = layer.keyframes.find((k) => Math.abs(k.time) < 1e-3)!
|
||
const pt = kf.props.points![1]
|
||
expect(pt.outX).toBe(10)
|
||
expect(pt.outY).toBe(0)
|
||
expect(pt.inX).toBe(-3)
|
||
expect(pt.inY).toBe(4)
|
||
})
|
||
|
||
test('tangent interpolation lerps between keyframes', async () => {
|
||
const { s, asset, layer } = makeSquare()
|
||
s.setPolygonVertexType(asset.id, 1, 'splitcurve')
|
||
s.setPlayhead(0)
|
||
s.setPolygonVertexTangent(asset.id, 1, 'out', 10, 0)
|
||
s.setPlayhead(1)
|
||
s.setPolygonVertexTangent(asset.id, 1, 'out', 30, 0)
|
||
await tick()
|
||
const mid = sampleLayer(layer, 0.5)
|
||
expect(Math.abs((mid.points?.[1].outX ?? 0) - 20) < 1e-6).toBe(true)
|
||
})
|
||
|
||
test('setPolygonVertexPositions updates multiple vertices in a single kf', async () => {
|
||
const { s, asset, layer } = makeSquare()
|
||
s.setPlayhead(0.2)
|
||
s.setPolygonVertexPositions(layer.id, [
|
||
{ index: 0, x: 100, y: 100 },
|
||
{ index: 2, x: -100, y: -100 },
|
||
])
|
||
await tick()
|
||
const kf = layer.keyframes.find((k) => Math.abs(k.time - 0.2) < 1e-3)!
|
||
expect(kf.props.points![0].x).toBe(100)
|
||
expect(kf.props.points![0].y).toBe(100)
|
||
expect(kf.props.points![2].x).toBe(-100)
|
||
expect(kf.props.points![2].y).toBe(-100)
|
||
// Others unchanged
|
||
expect(kf.props.points![1].x).toBe(asset.points[1].x)
|
||
expect(kf.props.points![1].y).toBe(asset.points[1].y)
|
||
})
|
||
|
||
test('setPolygonVertexPositions ignores out-of-range indices', async () => {
|
||
const { s, layer } = makeSquare()
|
||
s.setPlayhead(0)
|
||
s.setPolygonVertexPositions(layer.id, [
|
||
{ index: 99, x: 0, y: 0 },
|
||
{ index: -1, x: 0, y: 0 },
|
||
])
|
||
await tick()
|
||
// No keyframe was created or changed at playhead 0 beyond the initial one
|
||
expect(layer.keyframes.length).toBe(1)
|
||
})
|
||
})
|
||
|
||
suite('properties — fill/stroke transparent toggle', () => {
|
||
function findAlphaToggles(host: HTMLElement) {
|
||
return Array.from(
|
||
host.querySelectorAll<HTMLButtonElement>('button.alpha-toggle-btn'),
|
||
)
|
||
}
|
||
|
||
function resetSectionPrefs() {
|
||
// Ensure the Appearance section is open (default) regardless of any
|
||
// previous session's state persisted in localStorage.
|
||
try {
|
||
localStorage.removeItem('animatr:propSections')
|
||
} catch {
|
||
/* noop */
|
||
}
|
||
}
|
||
|
||
test('fill: toggle transparent on writes "transparent" to keyframe', async () => {
|
||
resetSectionPrefs()
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({
|
||
kind: 'rect',
|
||
name: 'R',
|
||
width: 50,
|
||
height: 50,
|
||
fill: '#ff0000',
|
||
})
|
||
const l = s.addLayer(a.id)!
|
||
s.selectLayer(l.id)
|
||
const handle = mountComponent(PropertiesPanel, {})
|
||
await nextTick()
|
||
await tick()
|
||
const toggles = findAlphaToggles(handle.host)
|
||
const fillToggle = toggles[0]
|
||
expect(!!fillToggle).toBe(true)
|
||
fillToggle.click()
|
||
await tick()
|
||
const kf = l.keyframes[0]
|
||
expect(kf.props.fill).toBe('transparent')
|
||
handle.unmount()
|
||
})
|
||
|
||
test('fill: toggle off restores the previous non-transparent color', async () => {
|
||
resetSectionPrefs()
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({
|
||
kind: 'rect',
|
||
name: 'R',
|
||
width: 50,
|
||
height: 50,
|
||
fill: '#ff0000',
|
||
})
|
||
const l = s.addLayer(a.id)!
|
||
s.selectLayer(l.id)
|
||
const handle = mountComponent(PropertiesPanel, {})
|
||
await nextTick()
|
||
await tick()
|
||
const toggles = findAlphaToggles(handle.host)
|
||
const fillToggle = toggles[0]
|
||
// On
|
||
fillToggle.click()
|
||
await tick()
|
||
expect(l.keyframes[0].props.fill).toBe('transparent')
|
||
// Off
|
||
fillToggle.click()
|
||
await tick()
|
||
const restored = l.keyframes[0].props.fill
|
||
expect(typeof restored).toBe('string')
|
||
expect(restored !== 'transparent').toBe(true)
|
||
// Should match the original fill (layer.base.fill was copied from asset)
|
||
expect(restored).toBe('#ff0000')
|
||
handle.unmount()
|
||
})
|
||
|
||
test('stroke: toggle on then off restores to a visible color (auto-black fallback)', async () => {
|
||
resetSectionPrefs()
|
||
const s = makeFreshStore()
|
||
// Rect without an explicit stroke — the restore should fall back to black
|
||
const a = s.addAsset({
|
||
kind: 'rect',
|
||
name: 'R',
|
||
width: 50,
|
||
height: 50,
|
||
fill: '#123456',
|
||
})
|
||
const l = s.addLayer(a.id)!
|
||
s.selectLayer(l.id)
|
||
const handle = mountComponent(PropertiesPanel, {})
|
||
await nextTick()
|
||
await tick()
|
||
const toggles = findAlphaToggles(handle.host)
|
||
const strokeToggle = toggles[1]
|
||
expect(!!strokeToggle).toBe(true)
|
||
strokeToggle.click()
|
||
await tick()
|
||
expect(l.keyframes[0].props.stroke).toBe('transparent')
|
||
strokeToggle.click()
|
||
await tick()
|
||
const restored = l.keyframes[0].props.stroke
|
||
expect(typeof restored).toBe('string')
|
||
expect(restored !== 'transparent').toBe(true)
|
||
handle.unmount()
|
||
})
|
||
|
||
test('fill: toggle off respects a user-picked color before transparency', async () => {
|
||
resetSectionPrefs()
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({
|
||
kind: 'rect',
|
||
name: 'R',
|
||
width: 50,
|
||
height: 50,
|
||
fill: '#6ea8ff',
|
||
})
|
||
const l = s.addLayer(a.id)!
|
||
s.selectLayer(l.id)
|
||
// User explicitly picked green before toggling transparent
|
||
s.upsertKeyframe(l.id, 0, { fill: '#00ff00' })
|
||
const handle = mountComponent(PropertiesPanel, {})
|
||
await nextTick()
|
||
await tick()
|
||
const toggles = findAlphaToggles(handle.host)
|
||
const fillToggle = toggles[0]
|
||
fillToggle.click()
|
||
await tick()
|
||
expect(l.keyframes[0].props.fill).toBe('transparent')
|
||
fillToggle.click()
|
||
await tick()
|
||
// Restore should be the user's green, not the asset default blue
|
||
expect(l.keyframes[0].props.fill).toBe('#00ff00')
|
||
handle.unmount()
|
||
})
|
||
})
|
||
|
||
suite('timeline — show locked tracks', () => {
|
||
test('toggling showLocked persists to localStorage', async () => {
|
||
const { showLocked } = await import('@/composables/useShowLocked')
|
||
const KEY = 'animatr:showLockedTracks'
|
||
showLocked.value = true
|
||
await tick()
|
||
expect(localStorage.getItem(KEY)).toBe('1')
|
||
showLocked.value = false
|
||
await tick()
|
||
expect(localStorage.getItem(KEY)).toBe('0')
|
||
showLocked.value = true
|
||
})
|
||
|
||
test('showLocked ref is shared across importers', async () => {
|
||
const a = await import('@/composables/useShowLocked')
|
||
const b = await import('@/composables/useShowLocked')
|
||
// Same module → same ref instance
|
||
expect(a.showLocked).toBe(b.showLocked)
|
||
a.showLocked.value = false
|
||
await tick()
|
||
expect(b.showLocked.value).toBe(false)
|
||
a.showLocked.value = true
|
||
})
|
||
})
|
||
|
||
suite('scene store — group lock', () => {
|
||
test('setTrackLocked on a group id sets group.locked', () => {
|
||
const s = makeFreshStore()
|
||
const g = s.addTrackGroup('G')
|
||
s.setTrackLocked(g.id, true)
|
||
const grp = s.scene.groups!.find((x) => x.id === g.id)!
|
||
expect(grp.locked).toBe(true)
|
||
})
|
||
|
||
test('setTrackLocked(false) clears group.locked', () => {
|
||
const s = makeFreshStore()
|
||
const g = s.addTrackGroup('G')
|
||
s.setTrackLocked(g.id, true)
|
||
s.setTrackLocked(g.id, false)
|
||
const grp = s.scene.groups!.find((x) => x.id === g.id)!
|
||
expect(grp.locked).toBe(undefined)
|
||
})
|
||
|
||
test('setTrackLocked dispatches to layer, clip or group based on id', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#111' })
|
||
const l = s.addLayer(a.id)!
|
||
const ct = s.addClipTrack()
|
||
const g = s.addTrackGroup('G')
|
||
s.setTrackLocked(l.id, true)
|
||
s.setTrackLocked(ct.id, true)
|
||
s.setTrackLocked(g.id, true)
|
||
expect(l.locked).toBe(true)
|
||
const stored = s.scene.clipTracks!.find((x) => x.id === ct.id)!
|
||
expect(stored.locked).toBe(true)
|
||
const grp = s.scene.groups!.find((x) => x.id === g.id)!
|
||
expect(grp.locked).toBe(true)
|
||
})
|
||
|
||
test('locking a group does not mutate child layer.locked', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#111' })
|
||
const l = s.addLayer(a.id)!
|
||
const g = s.addTrackGroup('G')
|
||
s.moveTrackIntoGroup(l.id, g.id)
|
||
s.setTrackLocked(g.id, true)
|
||
// The layer itself stays unlocked; effective lock is derived in UI code.
|
||
expect(l.locked).toBe(undefined)
|
||
})
|
||
|
||
test('locking a layer directly deselects it', () => {
|
||
// Regression: locking a layer while it was selected used to leave
|
||
// the props panel happily editing the now-inert track. The
|
||
// selection must drop on lock so edits can't leak through.
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
s.selectLayer(l.id)
|
||
expect(s.selectedLayerId).toBe(l.id)
|
||
s.setTrackLocked(l.id, true)
|
||
expect(s.selectedLayerId).toBe(null)
|
||
})
|
||
|
||
test('locking a group deselects a selected child layer', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
const g = s.addTrackGroup('G')
|
||
s.moveTrackIntoGroup(l.id, g.id)
|
||
s.selectLayer(l.id)
|
||
expect(s.selectedLayerId).toBe(l.id)
|
||
s.setTrackLocked(g.id, true)
|
||
expect(s.selectedLayerId).toBe(null)
|
||
})
|
||
|
||
test('locking a group with no selected child leaves selection alone', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'A', width: 10, height: 10, fill: '#f00' })
|
||
const b = s.addAsset({ kind: 'rect', name: 'B', width: 10, height: 10, fill: '#0f0' })
|
||
const la = s.addLayer(a.id)!
|
||
const lb = s.addLayer(b.id)!
|
||
const g = s.addTrackGroup('G')
|
||
// Only move la into the group; lb is selected and stays top-level.
|
||
s.moveTrackIntoGroup(la.id, g.id)
|
||
s.selectLayer(lb.id)
|
||
s.setTrackLocked(g.id, true)
|
||
// Locking the group can't touch the selection that lives outside it.
|
||
expect(s.selectedLayerId).toBe(lb.id)
|
||
})
|
||
|
||
test('unlocking a group does not auto-reselect the previously-selected layer', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
const g = s.addTrackGroup('G')
|
||
s.moveTrackIntoGroup(l.id, g.id)
|
||
s.selectLayer(l.id)
|
||
s.setTrackLocked(g.id, true)
|
||
// Now unlocking — selection stays null; user re-selects explicitly.
|
||
s.setTrackLocked(g.id, false)
|
||
expect(s.selectedLayerId).toBe(null)
|
||
})
|
||
})
|
||
|
||
suite('render — crop option', () => {
|
||
function setupStraddlingScene() {
|
||
const s = makeFreshStore()
|
||
s.scene.canvas.width = 100
|
||
s.scene.canvas.height = 100
|
||
s.scene.canvas.background = 'transparent'
|
||
const a = s.addAsset({
|
||
kind: 'rect',
|
||
name: 'Wide',
|
||
width: 60,
|
||
height: 20,
|
||
fill: '#00ff00',
|
||
})
|
||
const layer = s.addLayer(a.id)!
|
||
// Centered just off the right edge — 40px of the rect spills past x=100.
|
||
s.upsertKeyframe(layer.id, 0, { x: 110, y: 50 })
|
||
return s
|
||
}
|
||
|
||
test('default crop clips content outside stage bounds', () => {
|
||
const s = setupStraddlingScene()
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = 200
|
||
canvas.height = 100
|
||
const ctx = canvas.getContext('2d')!
|
||
drawSceneFrame(ctx, s.scene, 0, {})
|
||
// Sample a pixel ~20px past the right edge — still inside the rect's
|
||
// visual box but outside the stage. Should be cleared by the clip.
|
||
const outside = ctx.getImageData(120, 50, 1, 1).data
|
||
expect(outside[3]).toBe(0)
|
||
})
|
||
|
||
test('crop:false lets content render past the stage edge', () => {
|
||
const s = setupStraddlingScene()
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = 200
|
||
canvas.height = 100
|
||
const ctx = canvas.getContext('2d')!
|
||
drawSceneFrame(ctx, s.scene, 0, {}, { crop: false })
|
||
const outside = ctx.getImageData(120, 50, 1, 1).data
|
||
// Green channel visible in the part of the rect that extends past the stage
|
||
expect(outside[1] > 200).toBe(true)
|
||
expect(outside[3] > 200).toBe(true)
|
||
})
|
||
|
||
test('crop:false still paints stage background inside stage bounds', () => {
|
||
const s = makeFreshStore()
|
||
s.scene.canvas.width = 100
|
||
s.scene.canvas.height = 100
|
||
s.scene.canvas.background = '#ff0000'
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = 100
|
||
canvas.height = 100
|
||
const ctx = canvas.getContext('2d')!
|
||
drawSceneFrame(ctx, s.scene, 0, {}, { crop: false })
|
||
const inside = ctx.getImageData(50, 50, 1, 1).data
|
||
expect(inside[0] > 200).toBe(true)
|
||
expect(inside[3] > 200).toBe(true)
|
||
})
|
||
|
||
test('mask still applies when crop is disabled', () => {
|
||
// Same setup as the positive-mask render test but with crop:false —
|
||
// the mask should still clear content outside the mask shape, and the
|
||
// crop flag should only affect the stage clip, not the masked group.
|
||
const s = makeFreshStore()
|
||
s.scene.canvas.width = 100
|
||
s.scene.canvas.height = 100
|
||
s.scene.canvas.background = 'transparent'
|
||
const content = s.addAsset({
|
||
kind: 'rect',
|
||
name: 'BG',
|
||
width: 100,
|
||
height: 100,
|
||
fill: '#ff0000',
|
||
})
|
||
const contentLayer = s.addLayer(content.id)!
|
||
s.upsertKeyframe(contentLayer.id, 0, { x: 50, y: 50 })
|
||
const mask = s.addAsset({
|
||
kind: 'circle',
|
||
name: 'M',
|
||
radius: 20,
|
||
fill: '#ffffff',
|
||
})
|
||
const maskLayer = s.addLayer(mask.id)!
|
||
s.upsertKeyframe(maskLayer.id, 0, { x: 50, y: 50 })
|
||
s.setLayerMask(maskLayer.id, true, 'positive')
|
||
const group = s.addTrackGroup('G')
|
||
s.moveTrackIntoGroup(maskLayer.id, group.id)
|
||
s.moveTrackIntoGroup(contentLayer.id, group.id)
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = 100
|
||
canvas.height = 100
|
||
const ctx = canvas.getContext('2d')!
|
||
drawSceneFrame(ctx, s.scene, 0, {}, { crop: false })
|
||
const inside = ctx.getImageData(50, 50, 1, 1).data
|
||
const outside = ctx.getImageData(5, 5, 1, 1).data
|
||
// Positive mask keeps content inside
|
||
expect(inside[0] > 200).toBe(true)
|
||
expect(inside[3] > 200).toBe(true)
|
||
// Outside mask cleared by the mask (even without stage clipping)
|
||
expect(outside[3]).toBe(0)
|
||
})
|
||
|
||
test('content outside the stage renders past the edge when crop:false and mask-like groups exist elsewhere', () => {
|
||
// A straddling rect + a totally separate positive-mask group. The
|
||
// straddling rect must still bleed past the stage edge and the mask
|
||
// must still work inside its own group — they don't interfere.
|
||
const s = makeFreshStore()
|
||
s.scene.canvas.width = 100
|
||
s.scene.canvas.height = 100
|
||
s.scene.canvas.background = 'transparent'
|
||
// Straddling rect — spills past right edge
|
||
const strad = s.addAsset({
|
||
kind: 'rect',
|
||
name: 'Wide',
|
||
width: 60,
|
||
height: 20,
|
||
fill: '#00ff00',
|
||
})
|
||
const sLayer = s.addLayer(strad.id)!
|
||
s.upsertKeyframe(sLayer.id, 0, { x: 110, y: 50 })
|
||
// Masked group elsewhere
|
||
const content = s.addAsset({
|
||
kind: 'rect',
|
||
name: 'Blue',
|
||
width: 40,
|
||
height: 40,
|
||
fill: '#0000ff',
|
||
})
|
||
const cLayer = s.addLayer(content.id)!
|
||
s.upsertKeyframe(cLayer.id, 0, { x: 50, y: 50 })
|
||
const mask = s.addAsset({
|
||
kind: 'circle',
|
||
name: 'M',
|
||
radius: 10,
|
||
fill: '#fff',
|
||
})
|
||
const mLayer = s.addLayer(mask.id)!
|
||
s.upsertKeyframe(mLayer.id, 0, { x: 50, y: 50 })
|
||
s.setLayerMask(mLayer.id, true, 'positive')
|
||
const group = s.addTrackGroup('G')
|
||
s.moveTrackIntoGroup(mLayer.id, group.id)
|
||
s.moveTrackIntoGroup(cLayer.id, group.id)
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = 200
|
||
canvas.height = 100
|
||
const ctx = canvas.getContext('2d')!
|
||
drawSceneFrame(ctx, s.scene, 0, {}, { crop: false })
|
||
// Straddling rect's spill past x=100 is visible
|
||
const spill = ctx.getImageData(120, 50, 1, 1).data
|
||
expect(spill[1] > 200).toBe(true)
|
||
expect(spill[3] > 200).toBe(true)
|
||
// Mask still trims the blue rect outside its circle.
|
||
// (32, 50) is inside the blue rect (30–70) but outside the 10-radius
|
||
// mask circle (distance ~18), and well away from the green rect
|
||
// (80–140), so it must be cleared by the mask.
|
||
const maskOutside = ctx.getImageData(32, 50, 1, 1).data
|
||
expect(maskOutside[3]).toBe(0)
|
||
})
|
||
})
|
||
|
||
suite('scene store — playback range (in/out)', () => {
|
||
test('defaults: playbackIn=0, playbackOut=duration', () => {
|
||
const s = makeFreshStore()
|
||
expect(s.playbackIn).toBe(0)
|
||
expect(s.playbackOut).toBe(s.scene.duration)
|
||
expect(s.scene.inTime).toBe(undefined)
|
||
expect(s.scene.outTime).toBe(undefined)
|
||
})
|
||
|
||
test('setPlaybackRange clamps values into [0, duration]', () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
s.setPlaybackRange(-5, 20)
|
||
expect(s.playbackIn).toBe(0)
|
||
expect(s.playbackOut).toBe(10)
|
||
})
|
||
|
||
test('setPlaybackRange keeps out >= in', () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
s.setPlaybackRange(6, null)
|
||
s.setPlaybackRange(null, 3)
|
||
// out is clamped up to in (6), not down to 3
|
||
expect(s.playbackOut).toBe(6)
|
||
})
|
||
|
||
test('setPlaybackRange clamps playhead into new range', () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
s.setPlayhead(1)
|
||
s.setPlaybackRange(4, 8)
|
||
expect(s.playhead).toBe(4)
|
||
s.setPlayhead(9)
|
||
// tick isn't involved; setPlayhead itself doesn't clamp to range, so
|
||
// push it with setPlaybackRange again:
|
||
s.setPlaybackRange(4, 7)
|
||
expect(s.playhead).toBe(7)
|
||
})
|
||
|
||
test('setPlaybackRange null for both leaves scene alone', () => {
|
||
const s = makeFreshStore()
|
||
s.setPlaybackRange(2, 5)
|
||
s.setPlaybackRange(null, null)
|
||
expect(s.playbackIn).toBe(2)
|
||
expect(s.playbackOut).toBe(5)
|
||
})
|
||
|
||
test('togglePlay snaps playhead to playbackIn when outside range', async () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
s.setPlaybackRange(3, 7)
|
||
s.setPlayhead(1) // before in
|
||
await tick()
|
||
s.togglePlay()
|
||
expect(s.playhead).toBe(3)
|
||
expect(s.playing).toBe(true)
|
||
s.pause()
|
||
await tick()
|
||
})
|
||
|
||
test('tick stops exactly at playbackOut within range', async () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
s.setPlaybackRange(2, 5)
|
||
s.setPlayhead(2)
|
||
s.play()
|
||
await tick()
|
||
s.tick(4) // would put playhead at 6, past out=5
|
||
await tick()
|
||
expect(s.playhead).toBe(5)
|
||
expect(s.playing).toBe(false)
|
||
})
|
||
|
||
test('setPlaybackRange(0, null) deletes inTime when outTime is unset', () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
s.setPlaybackRange(3, null)
|
||
expect(s.scene.inTime).toBe(3)
|
||
s.setPlaybackRange(0, null)
|
||
expect(s.scene.inTime).toBe(undefined)
|
||
})
|
||
|
||
test('setPlaybackRange(null, duration) deletes outTime when inTime is unset', () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
s.setPlaybackRange(null, 7)
|
||
expect(s.scene.outTime).toBe(7)
|
||
s.setPlaybackRange(null, 10)
|
||
expect(s.scene.outTime).toBe(undefined)
|
||
})
|
||
})
|
||
|
||
suite('scene store — blank scene & normalization', () => {
|
||
test('createBlankScene has 1280x720 white bg and 10s duration', () => {
|
||
const s = createBlankScene()
|
||
expect(s.version).toBe(1)
|
||
expect(s.canvas.width).toBe(1280)
|
||
expect(s.canvas.height).toBe(720)
|
||
expect(s.canvas.background).toBe('#ffffff')
|
||
expect(s.duration).toBe(10)
|
||
expect(s.fps).toBe(60)
|
||
expect(s.assets).toHaveLength(0)
|
||
expect(s.layers).toHaveLength(0)
|
||
})
|
||
|
||
test('loadScene(createBlankScene()) resets selection, playhead, playing', async () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#f00' })
|
||
s.addLayer(a.id)!
|
||
s.setPlayhead(3)
|
||
s.play()
|
||
await tick()
|
||
s.loadScene(createBlankScene())
|
||
expect(s.scene.layers).toHaveLength(0)
|
||
expect(s.scene.assets).toHaveLength(0)
|
||
expect(s.scene.canvas.background).toBe('#ffffff')
|
||
expect(s.playhead).toBe(0)
|
||
expect(s.playing).toBe(false)
|
||
expect(s.selectedLayerId).toBe(null)
|
||
})
|
||
|
||
test('loadScene fills in canvas.background when missing', () => {
|
||
const s = makeFreshStore()
|
||
const broken = {
|
||
version: 1 as const,
|
||
canvas: { width: 640, height: 480, background: '' },
|
||
duration: 5,
|
||
fps: 30,
|
||
assets: [],
|
||
layers: [],
|
||
}
|
||
s.loadScene(broken)
|
||
expect(s.scene.canvas.background).toBe('#ffffff')
|
||
})
|
||
})
|
||
|
||
suite('scene store — mask defaults', () => {
|
||
test('setLayerMask(true) defaults to negative mask mode', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'M', width: 40, height: 40, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
s.setLayerMask(l.id, true)
|
||
const layer = s.scene.layers.find((x) => x.id === l.id)!
|
||
expect(layer.isMask).toBe(true)
|
||
expect(layer.maskMode).toBe('negative')
|
||
})
|
||
})
|
||
|
||
suite('render — top-level masks cascade', () => {
|
||
test('top-level positive mask clears content outside the mask shape', () => {
|
||
const s = makeFreshStore()
|
||
s.scene.canvas.width = 100
|
||
s.scene.canvas.height = 100
|
||
s.scene.canvas.background = 'transparent'
|
||
// Full-stage red rect
|
||
const content = s.addAsset({
|
||
kind: 'rect',
|
||
name: 'BG',
|
||
width: 100,
|
||
height: 100,
|
||
fill: '#ff0000',
|
||
})
|
||
const contentLayer = s.addLayer(content.id)!
|
||
s.upsertKeyframe(contentLayer.id, 0, { x: 50, y: 50 })
|
||
// Mask layer above it in the stack (top-level, NO group)
|
||
const mask = s.addAsset({
|
||
kind: 'circle',
|
||
name: 'M',
|
||
radius: 20,
|
||
fill: '#ffffff',
|
||
})
|
||
const maskLayer = s.addLayer(mask.id)!
|
||
s.upsertKeyframe(maskLayer.id, 0, { x: 50, y: 50 })
|
||
s.setLayerMask(maskLayer.id, true, 'positive')
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = 100
|
||
canvas.height = 100
|
||
const ctx = canvas.getContext('2d')!
|
||
drawSceneFrame(ctx, s.scene, 0, {})
|
||
// Inside mask circle → red kept
|
||
const inside = ctx.getImageData(50, 50, 1, 1).data
|
||
expect(inside[0] > 200).toBe(true)
|
||
expect(inside[3] > 200).toBe(true)
|
||
// Outside mask circle → cleared
|
||
const outside = ctx.getImageData(5, 5, 1, 1).data
|
||
expect(outside[3]).toBe(0)
|
||
})
|
||
|
||
test('top-level negative mask cuts a hole in the content below', () => {
|
||
const s = makeFreshStore()
|
||
s.scene.canvas.width = 100
|
||
s.scene.canvas.height = 100
|
||
s.scene.canvas.background = 'transparent'
|
||
const content = s.addAsset({
|
||
kind: 'rect',
|
||
name: 'BG',
|
||
width: 100,
|
||
height: 100,
|
||
fill: '#ff0000',
|
||
})
|
||
const contentLayer = s.addLayer(content.id)!
|
||
s.upsertKeyframe(contentLayer.id, 0, { x: 50, y: 50 })
|
||
const mask = s.addAsset({
|
||
kind: 'circle',
|
||
name: 'M',
|
||
radius: 15,
|
||
fill: '#ffffff',
|
||
})
|
||
const maskLayer = s.addLayer(mask.id)!
|
||
s.upsertKeyframe(maskLayer.id, 0, { x: 50, y: 50 })
|
||
s.setLayerMask(maskLayer.id, true, 'negative')
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = 100
|
||
canvas.height = 100
|
||
const ctx = canvas.getContext('2d')!
|
||
drawSceneFrame(ctx, s.scene, 0, {})
|
||
// Inside the circle → hole (cleared)
|
||
const inside = ctx.getImageData(50, 50, 1, 1).data
|
||
expect(inside[3]).toBe(0)
|
||
// Outside circle → red kept
|
||
const outside = ctx.getImageData(5, 5, 1, 1).data
|
||
expect(outside[0] > 200).toBe(true)
|
||
expect(outside[3] > 200).toBe(true)
|
||
})
|
||
})
|
||
|
||
suite('render — grouped clip tracks respect group masks', () => {
|
||
// Regression: the render.ts group branch used to extract only
|
||
// layer children from group.childIds, dropping clip tracks
|
||
// silently. A group containing only clips rendered as empty in
|
||
// mask-preview and frame-based exports — even when a SIBLING
|
||
// group had a mask, the clip group was invisible too because
|
||
// the renderer simply walked past its contents.
|
||
|
||
function setupWithClipTrackInGroup() {
|
||
const s = makeFreshStore()
|
||
s.scene.canvas.width = 100
|
||
s.scene.canvas.height = 100
|
||
s.scene.canvas.background = 'transparent'
|
||
const rectAsset = s.addAsset({
|
||
kind: 'rect', name: 'R', width: 100, height: 100, fill: '#00ff00',
|
||
})
|
||
// Clip with a single full-stage green rect, instantiated on a
|
||
// clip track tucked inside group A.
|
||
const clip = s.addClip('C')
|
||
clip.duration = 1
|
||
s.enterClip(clip.id)
|
||
const inner = s.addLayer(rectAsset.id)!
|
||
s.upsertKeyframe(inner.id, 0, { x: 50, y: 50 })
|
||
s.exitClip()
|
||
const track = s.scene.clipTracks![0]
|
||
s.addClipInstance(clip.id, 0, track.id)
|
||
// Wrap the track in group A (no mask inside — just content).
|
||
const groupA = s.addTrackGroup('A', track.id)
|
||
groupA.childIds.push(track.id)
|
||
const order = s.scene.trackOrder!
|
||
const ti = order.indexOf(track.id)
|
||
if (ti >= 0) order.splice(ti, 1)
|
||
return { s }
|
||
}
|
||
|
||
test('group containing only clip tracks renders its instance content', () => {
|
||
const { s } = setupWithClipTrackInGroup()
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = 100
|
||
canvas.height = 100
|
||
const ctx = canvas.getContext('2d')!
|
||
drawSceneFrame(ctx, s.scene, 0, {})
|
||
// The clip's green rect should cover the canvas — at least one
|
||
// interior pixel is green with full alpha.
|
||
const px = ctx.getImageData(50, 50, 1, 1).data
|
||
expect(px[1] > 200).toBe(true)
|
||
expect(px[3] > 200).toBe(true)
|
||
})
|
||
|
||
test('sibling group with a mask does not erase an unmasked group next to it', () => {
|
||
// User-reported scenario: group A (clip) + group G (element B
|
||
// + mask C). Before the fix, mask-preview showed nothing for
|
||
// A; afterwards, A's content is independent of G's mask.
|
||
const { s } = setupWithClipTrackInGroup()
|
||
// Group G holds a single red layer + a small positive mask,
|
||
// both at the centre. Without the fix A's green would stay
|
||
// off-screen because the render path dropped its clip track.
|
||
const red = s.addAsset({
|
||
kind: 'rect', name: 'B', width: 40, height: 40, fill: '#ff0000',
|
||
})
|
||
const bLayer = s.addLayer(red.id)!
|
||
s.upsertKeyframe(bLayer.id, 0, { x: 50, y: 50 })
|
||
const maskAsset = s.addAsset({
|
||
kind: 'circle', name: 'M', radius: 10, fill: '#ffffff',
|
||
})
|
||
const cLayer = s.addLayer(maskAsset.id)!
|
||
s.upsertKeyframe(cLayer.id, 0, { x: 50, y: 50 })
|
||
s.setLayerMask(cLayer.id, true, 'positive')
|
||
// Wrap B + C in a fresh group G. selectLayer + groupSelectedLayers
|
||
// groups them together, leaving group A untouched.
|
||
s.selectLayer(bLayer.id)
|
||
s.toggleLayerSelection(cLayer.id)
|
||
const gId = s.groupSelectedLayers('G')!
|
||
expect(gId).toBeTruthy()
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = 100
|
||
canvas.height = 100
|
||
const ctx = canvas.getContext('2d')!
|
||
drawSceneFrame(ctx, s.scene, 0, {})
|
||
// A pixel far from the mask-centre (e.g. corner 10,10) is
|
||
// outside G's mask — B would be clipped there. But group A's
|
||
// green clip covers the whole canvas and is unaffected by G's
|
||
// mask, so the pixel must be green (and opaque).
|
||
const corner = ctx.getImageData(10, 10, 1, 1).data
|
||
expect(corner[1] > 200).toBe(true)
|
||
expect(corner[3] > 200).toBe(true)
|
||
})
|
||
|
||
test('mask inside a group clips clip-track content in that same group', () => {
|
||
// The group-local mask still has to apply to clip instances
|
||
// that sit alongside it — otherwise grouping a clip with a
|
||
// mask would silently render the clip unmasked.
|
||
const s = makeFreshStore()
|
||
s.scene.canvas.width = 100
|
||
s.scene.canvas.height = 100
|
||
s.scene.canvas.background = 'transparent'
|
||
const rect = s.addAsset({
|
||
kind: 'rect', name: 'R', width: 100, height: 100, fill: '#00ff00',
|
||
})
|
||
const clip = s.addClip('C')
|
||
clip.duration = 1
|
||
s.enterClip(clip.id)
|
||
const inner = s.addLayer(rect.id)!
|
||
s.upsertKeyframe(inner.id, 0, { x: 50, y: 50 })
|
||
s.exitClip()
|
||
const track = s.scene.clipTracks![0]
|
||
s.addClipInstance(clip.id, 0, track.id)
|
||
// Build a mask circle, then group (clip track + mask) together.
|
||
const maskAsset = s.addAsset({
|
||
kind: 'circle', name: 'M', radius: 15, fill: '#ffffff',
|
||
})
|
||
const m = s.addLayer(maskAsset.id)!
|
||
s.upsertKeyframe(m.id, 0, { x: 50, y: 50 })
|
||
s.setLayerMask(m.id, true, 'positive')
|
||
const g = s.addTrackGroup('G', track.id)
|
||
g.childIds.push(m.id)
|
||
g.childIds.push(track.id)
|
||
const order = s.scene.trackOrder!
|
||
// Remove the clip track + mask layer from the top-level order
|
||
// so only the group sits there.
|
||
for (const id of [track.id, m.id]) {
|
||
const i = order.indexOf(id)
|
||
if (i >= 0) order.splice(i, 1)
|
||
}
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = 100
|
||
canvas.height = 100
|
||
const ctx = canvas.getContext('2d')!
|
||
drawSceneFrame(ctx, s.scene, 0, {})
|
||
// Inside the mask — green kept.
|
||
const inside = ctx.getImageData(50, 50, 1, 1).data
|
||
expect(inside[1] > 200).toBe(true)
|
||
expect(inside[3] > 200).toBe(true)
|
||
// Outside the mask — cleared.
|
||
const outside = ctx.getImageData(5, 5, 1, 1).data
|
||
expect(outside[3]).toBe(0)
|
||
})
|
||
})
|
||
|
||
suite('scene store — markers', () => {
|
||
test('addMarker defaults the name when none provided', () => {
|
||
const s = makeFreshStore()
|
||
const m = s.addMarker(2)
|
||
expect(m.name).toBe('Marker')
|
||
expect(m.time).toBe(2)
|
||
expect(s.scene.markers).toHaveLength(1)
|
||
})
|
||
|
||
test('addMarker de-dupes colliding names with suffix', () => {
|
||
const s = makeFreshStore()
|
||
s.addMarker(1, 'Intro')
|
||
s.addMarker(2, 'Intro')
|
||
s.addMarker(3, 'Intro')
|
||
const names = (s.scene.markers ?? []).map((m) => m.name)
|
||
expect(names).toEqual(['Intro', 'Intro 2', 'Intro 3'])
|
||
})
|
||
|
||
test('addMarker clamps time to [0, duration]', () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
const a = s.addMarker(-5)
|
||
const b = s.addMarker(99)
|
||
expect(a.time).toBe(0)
|
||
expect(b.time).toBe(10)
|
||
})
|
||
|
||
test('addMarker keeps the list sorted by time', () => {
|
||
const s = makeFreshStore()
|
||
s.addMarker(5, 'C')
|
||
s.addMarker(1, 'A')
|
||
s.addMarker(3, 'B')
|
||
const times = (s.scene.markers ?? []).map((m) => m.time)
|
||
expect(times).toEqual([1, 3, 5])
|
||
})
|
||
|
||
test('renameMarker succeeds on unique, rejects duplicates', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addMarker(1, 'A')
|
||
s.addMarker(2, 'B')
|
||
expect(s.renameMarker(a.id, 'A2')).toBe(true)
|
||
expect(s.renameMarker(a.id, 'B')).toBe(false)
|
||
const byId = (s.scene.markers ?? []).find((m) => m.id === a.id)
|
||
expect(byId?.name).toBe('A2')
|
||
})
|
||
|
||
test('removeMarker drops the entry', () => {
|
||
const s = makeFreshStore()
|
||
const m = s.addMarker(1)
|
||
s.removeMarker(m.id)
|
||
expect(s.scene.markers).toHaveLength(0)
|
||
})
|
||
|
||
test('setMarkerTime clamps and re-sorts', () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
const a = s.addMarker(1, 'A')
|
||
const b = s.addMarker(2, 'B')
|
||
s.setMarkerTime(a.id, 99)
|
||
const sortedNames = (s.scene.markers ?? []).map((m) => m.name)
|
||
expect(sortedNames).toEqual(['B', 'A'])
|
||
const updated = (s.scene.markers ?? []).find((m) => m.id === a.id)!
|
||
expect(updated.time).toBe(10)
|
||
void b
|
||
})
|
||
|
||
test('setMarkerColor sets and clears', () => {
|
||
const s = makeFreshStore()
|
||
const m = s.addMarker(1, 'A')
|
||
s.setMarkerColor(m.id, '#ff8800')
|
||
const get = () => s.scene.markers?.find((x) => x.id === m.id)
|
||
expect(get()?.color).toBe('#ff8800')
|
||
s.setMarkerColor(m.id, null)
|
||
expect(get()?.color).toBe(undefined)
|
||
})
|
||
})
|
||
|
||
suite('scene store — script tracks & frames', () => {
|
||
test('addScriptTrack appends and adds to trackOrder', () => {
|
||
const s = makeFreshStore()
|
||
const t = s.addScriptTrack('Triggers')
|
||
expect(s.scene.scriptTracks).toHaveLength(1)
|
||
expect(s.scene.trackOrder?.includes(t.id)).toBe(true)
|
||
})
|
||
|
||
test('addScriptFrame clamps time and returns frame', () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
const t = s.addScriptTrack()
|
||
const f = s.addScriptFrame(t.id, 99, [{ type: 'pause' }])
|
||
expect(f).toBeTruthy()
|
||
expect(f!.time).toBe(10)
|
||
expect(f!.actions).toHaveLength(1)
|
||
})
|
||
|
||
test('addScriptFrame returns null for unknown trackId', () => {
|
||
const s = makeFreshStore()
|
||
const f = s.addScriptFrame('no-such-id', 1)
|
||
expect(f).toBe(null)
|
||
})
|
||
|
||
test('script frames stay sorted by time', () => {
|
||
const s = makeFreshStore()
|
||
const t = s.addScriptTrack()
|
||
s.addScriptFrame(t.id, 5)
|
||
s.addScriptFrame(t.id, 1)
|
||
s.addScriptFrame(t.id, 3)
|
||
const times = (s.scene.scriptFrames ?? []).map((f) => f.time)
|
||
expect(times).toEqual([1, 3, 5])
|
||
})
|
||
|
||
test('removeScriptTrack cascades its frames and trackOrder entry', () => {
|
||
const s = makeFreshStore()
|
||
const t = s.addScriptTrack()
|
||
s.addScriptFrame(t.id, 1)
|
||
s.addScriptFrame(t.id, 2)
|
||
s.removeScriptTrack(t.id)
|
||
expect(s.scene.scriptTracks).toHaveLength(0)
|
||
expect(s.scene.scriptFrames).toHaveLength(0)
|
||
expect(s.scene.trackOrder?.includes(t.id)).toBe(false)
|
||
})
|
||
|
||
test('setScriptFrameActions replaces actions', () => {
|
||
const s = makeFreshStore()
|
||
const t = s.addScriptTrack()
|
||
const f = s.addScriptFrame(t.id, 1, [{ type: 'play' }])!
|
||
s.setScriptFrameActions(f.id, [{ type: 'pause' }, { type: 'rewindToLoop' }])
|
||
const updated = s.scene.scriptFrames?.find((x) => x.id === f.id)
|
||
expect(updated?.actions).toHaveLength(2)
|
||
expect(updated?.actions[0].type).toBe('pause')
|
||
})
|
||
|
||
test('setScriptFrameTime clamps and re-sorts', () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
const t = s.addScriptTrack()
|
||
const a = s.addScriptFrame(t.id, 1)!
|
||
const b = s.addScriptFrame(t.id, 2)!
|
||
s.setScriptFrameTime(a.id, 99)
|
||
const times = (s.scene.scriptFrames ?? []).map((f) => f.time)
|
||
expect(times).toEqual([2, 10])
|
||
void b
|
||
})
|
||
|
||
test('duplicateScriptFrame copies actions + label and steps forward by one offset', () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
const t = s.addScriptTrack()
|
||
const f = s.addScriptFrame(t.id, 1, [{ type: 'pause' }], 'intro-end')!
|
||
const newId = s.duplicateScriptFrame(f.id)
|
||
expect(newId).toBeTruthy()
|
||
const copy = s.scene.scriptFrames?.find((x) => x.id === newId)
|
||
expect(copy).toBeTruthy()
|
||
expect(copy!.trackId).toBe(t.id)
|
||
expect(copy!.time).toBeGreaterThan(f.time)
|
||
expect(copy!.actions).toHaveLength(1)
|
||
expect(copy!.actions[0].type).toBe('pause')
|
||
expect(copy!.label).toBe('intro-end')
|
||
})
|
||
|
||
test('duplicateScriptFrame hops over occupied slots on the same track', () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
const t = s.addScriptTrack()
|
||
const fps = s.scene.fps > 0 ? s.scene.fps : 60
|
||
const offset = Math.max(3 / fps, 0.1)
|
||
const src = s.addScriptFrame(t.id, 1, [{ type: 'pause' }])!
|
||
// Pre-populate the first duplicate slot so the store has to step again.
|
||
s.addScriptFrame(t.id, 1 + offset, [{ type: 'play' }])
|
||
const newId = s.duplicateScriptFrame(src.id)
|
||
const copy = s.scene.scriptFrames?.find((x) => x.id === newId)
|
||
expect(copy).toBeTruthy()
|
||
// Landed beyond the occupied slot.
|
||
expect(copy!.time).toBeGreaterThan(1 + offset + 1e-4)
|
||
})
|
||
|
||
test('duplicateScriptFrame returns null for an unknown id', () => {
|
||
const s = makeFreshStore()
|
||
expect(s.duplicateScriptFrame('nope')).toBeNull()
|
||
})
|
||
})
|
||
|
||
suite('scene store — audio model', () => {
|
||
function addAsset(s: ReturnType<typeof makeFreshStore>, name = 'beep', duration = 4) {
|
||
return s.addAudioAsset({ name, src: 'data:audio/mp3;base64,AAAA', duration })
|
||
}
|
||
|
||
test('addAudioAsset pushes an asset of kind "audio" into the shared library', () => {
|
||
const s = makeFreshStore()
|
||
const asset = addAsset(s, ' Intro ', 3.5)
|
||
expect(asset.kind).toBe('audio')
|
||
expect(asset.name).toBe('Intro') // name is trimmed
|
||
expect(asset.duration).toBe(3.5)
|
||
expect(s.project.assets.some((a) => a.id === asset.id)).toBe(true)
|
||
})
|
||
|
||
test('addAudioAsset clamps a negative duration to 0', () => {
|
||
const s = makeFreshStore()
|
||
const asset = s.addAudioAsset({ name: 'weird', src: 'x', duration: -5 })
|
||
expect(asset.duration).toBe(0)
|
||
})
|
||
|
||
test('addAudioTrack appends and lands in trackOrder', () => {
|
||
const s = makeFreshStore()
|
||
const t = s.addAudioTrack('Music')
|
||
expect(s.activeScene.audioTracks).toHaveLength(1)
|
||
expect(s.activeScene.audioTracks[0].name).toBe('Music')
|
||
expect(s.activeScene.trackOrder.includes(t.id)).toBe(true)
|
||
})
|
||
|
||
test('removeAudioTrack cascades its clips and trackOrder entry', () => {
|
||
const s = makeFreshStore()
|
||
const asset = addAsset(s)
|
||
const t = s.addAudioTrack()
|
||
s.addAudioClip(asset.id, 1, t.id)
|
||
s.addAudioClip(asset.id, 3, t.id)
|
||
expect(s.activeScene.audioClips).toHaveLength(2)
|
||
s.removeAudioTrack(t.id)
|
||
expect(s.activeScene.audioTracks).toHaveLength(0)
|
||
expect(s.activeScene.audioClips).toHaveLength(0)
|
||
expect(s.activeScene.trackOrder.includes(t.id)).toBe(false)
|
||
})
|
||
|
||
test('addAudioClip returns null for unknown asset or unknown track', () => {
|
||
const s = makeFreshStore()
|
||
const t = s.addAudioTrack()
|
||
expect(s.addAudioClip('no-such-asset', 0, t.id)).toBe(null)
|
||
const a = addAsset(s)
|
||
expect(s.addAudioClip(a.id, 0, 'no-such-track')).toBe(null)
|
||
expect(s.activeScene.audioClips).toHaveLength(0)
|
||
})
|
||
|
||
test('addAudioClip refuses to place an image asset as audio (guards kind)', () => {
|
||
const s = makeFreshStore()
|
||
const t = s.addAudioTrack()
|
||
const img = s.addAsset({
|
||
kind: 'rect',
|
||
name: 'R',
|
||
width: 10,
|
||
height: 10,
|
||
fill: '#000',
|
||
})
|
||
expect(s.addAudioClip(img.id, 0, t.id)).toBe(null)
|
||
})
|
||
|
||
test('moveAudioClip updates startTime and optional track; negative startTime clamps to 0', () => {
|
||
const s = makeFreshStore()
|
||
const asset = addAsset(s)
|
||
const t1 = s.addAudioTrack('one')
|
||
const t2 = s.addAudioTrack('two')
|
||
const c = s.addAudioClip(asset.id, 1, t1.id)!
|
||
s.moveAudioClip(c.id, -3, t2.id)
|
||
expect(c.startTime).toBe(0)
|
||
expect(c.audioTrackId).toBe(t2.id)
|
||
})
|
||
|
||
test('trimAudioClip clamps trims so the audible block keeps at least 0.05s', () => {
|
||
const s = makeFreshStore()
|
||
const asset = addAsset(s, 'x', 4) // asset length 4s
|
||
const t = s.addAudioTrack()
|
||
const c = s.addAudioClip(asset.id, 0, t.id)!
|
||
// Over-ambitious trim from BOTH ends: 3s off the head + 2s off
|
||
// the tail on a 4s asset (total 5s, way over the 3.95s budget).
|
||
// We don't pin which side absorbs the clamp — only the invariant
|
||
// that the audible window stays ≥ MIN_VISIBLE and neither trim
|
||
// turns negative.
|
||
s.trimAudioClip(c.id, { trimStart: 3, trimEnd: 2 })
|
||
const audible = asset.duration - (c.trimStart ?? 0) - (c.trimEnd ?? 0)
|
||
expect(audible >= 0.05 - 1e-9).toBe(true)
|
||
expect((c.trimStart ?? 0) >= 0).toBe(true)
|
||
expect((c.trimEnd ?? 0) >= 0).toBe(true)
|
||
})
|
||
|
||
test('removeAudioClip drops the clip without touching siblings', () => {
|
||
const s = makeFreshStore()
|
||
const asset = addAsset(s)
|
||
const t = s.addAudioTrack()
|
||
const a = s.addAudioClip(asset.id, 0, t.id)!
|
||
const b = s.addAudioClip(asset.id, 2, t.id)!
|
||
s.removeAudioClip(a.id)
|
||
expect(s.activeScene.audioClips).toHaveLength(1)
|
||
expect(s.activeScene.audioClips[0].id).toBe(b.id)
|
||
})
|
||
|
||
test('removeAsset of an audio asset cascades clips across every scene page', () => {
|
||
const s = makeFreshStore()
|
||
const asset = addAsset(s)
|
||
const firstTrack = s.addAudioTrack()
|
||
s.addAudioClip(asset.id, 0, firstTrack.id)
|
||
// Add a second scene, and place a clip there too.
|
||
const two = s.addScene('Two')
|
||
const secondTrack = s.addAudioTrack()
|
||
s.addAudioClip(asset.id, 0, secondTrack.id)
|
||
expect(s.activeScene.audioClips).toHaveLength(1)
|
||
s.removeAsset(asset.id)
|
||
// Both pages must have their clips purged, not just the active one.
|
||
for (const page of s.project.scenes) {
|
||
expect(page.audioClips.filter((c) => c.audioAssetId === asset.id).length).toBe(0)
|
||
}
|
||
void two
|
||
})
|
||
|
||
test('setAudioTrackMuted flips the muted flag', () => {
|
||
const s = makeFreshStore()
|
||
const t = s.addAudioTrack()
|
||
expect(!!t.muted).toBe(false)
|
||
s.setAudioTrackMuted(t.id, true)
|
||
expect(t.muted).toBe(true)
|
||
s.setAudioTrackMuted(t.id, false)
|
||
expect(t.muted).toBe(false)
|
||
})
|
||
|
||
test('duplicateTrack on an audio track clones clips with fresh ids and sits below the original', () => {
|
||
const s = makeFreshStore()
|
||
const asset = addAsset(s)
|
||
const orig = s.addAudioTrack('A')
|
||
s.addAudioClip(asset.id, 0, orig.id)
|
||
s.addAudioClip(asset.id, 2, orig.id)
|
||
const origClipIds = new Set(s.activeScene.audioClips.map((c) => c.id))
|
||
const newId = s.duplicateTrack(orig.id)
|
||
expect(newId).toBeTruthy()
|
||
expect(newId === orig.id).toBe(false)
|
||
const order = s.activeScene.trackOrder
|
||
expect(order.indexOf(newId!)).toBe(order.indexOf(orig.id) + 1)
|
||
const clones = s.activeScene.audioClips.filter((c) => c.audioTrackId === newId)
|
||
expect(clones).toHaveLength(2)
|
||
for (const c of clones) expect(origClipIds.has(c.id)).toBe(false)
|
||
})
|
||
|
||
test('duplicateScene also clones audio tracks + clips with fresh cross-refs', () => {
|
||
const s = makeFreshStore()
|
||
const asset = addAsset(s)
|
||
const track = s.addAudioTrack('A')
|
||
s.addAudioClip(asset.id, 1, track.id)
|
||
const origPageId = s.project.activeSceneId
|
||
const copy = s.duplicateScene(origPageId)!
|
||
// The clone has its own track id and its own clip id, but still
|
||
// references the (shared) asset.
|
||
expect(copy.audioTracks[0].id === track.id).toBe(false)
|
||
expect(copy.audioClips[0].audioTrackId).toBe(copy.audioTracks[0].id)
|
||
expect(copy.audioClips[0].audioAssetId).toBe(asset.id)
|
||
})
|
||
|
||
test('setAudioClipPosition cascades pushes on target track, leaves earlier clips alone', () => {
|
||
const s = makeFreshStore()
|
||
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 2 })
|
||
const track = s.addAudioTrack()
|
||
const early = s.addAudioClip(asset.id, 0, track.id)! // 0..2
|
||
const mid = s.addAudioClip(asset.id, 5, track.id)! // 5..7
|
||
const late = s.addAudioClip(asset.id, 8, track.id)! // 8..10
|
||
// Move `mid` to startTime=4 — it overlaps nothing before, but
|
||
// `late` (at 8) is still OK. Expect `mid` to land at 4 and no
|
||
// cascade for `late`.
|
||
expect(s.setAudioClipPosition(mid.id, 4)).toBe(true)
|
||
expect(mid.startTime).toBe(4)
|
||
expect(late.startTime).toBe(8)
|
||
expect(early.startTime).toBe(0)
|
||
// Now move `early` to startTime=4, which overlaps `mid` at 4..6.
|
||
// Cascade: push `mid` to 6, which then overlaps `late` at 8 —
|
||
// actually 6+2=8, so late stays at 8 (abutting).
|
||
s.setAudioClipPosition(early.id, 4)
|
||
expect(early.startTime).toBe(4)
|
||
expect(mid.startTime).toBe(6)
|
||
expect(late.startTime).toBe(8)
|
||
})
|
||
|
||
test('setAudioClipPosition with audioTrackId swaps the clip to another track', () => {
|
||
const s = makeFreshStore()
|
||
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 2 })
|
||
const t1 = s.addAudioTrack('one')
|
||
const t2 = s.addAudioTrack('two')
|
||
const clip = s.addAudioClip(asset.id, 1, t1.id)!
|
||
s.setAudioClipPosition(clip.id, 3, t2.id)
|
||
expect(clip.audioTrackId).toBe(t2.id)
|
||
expect(clip.startTime).toBe(3)
|
||
})
|
||
|
||
test('setAudioClipPosition returns false for unknown clip or unknown track', () => {
|
||
const s = makeFreshStore()
|
||
expect(s.setAudioClipPosition('no-such-clip', 0)).toBe(false)
|
||
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 1 })
|
||
const track = s.addAudioTrack()
|
||
const clip = s.addAudioClip(asset.id, 0, track.id)!
|
||
expect(s.setAudioClipPosition(clip.id, 0, 'ghost-track')).toBe(false)
|
||
})
|
||
|
||
test('setAudioClipPosition pushes neighbours backward when moved clip collides on the left', () => {
|
||
const s = makeFreshStore()
|
||
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 2 })
|
||
const track = s.addAudioTrack()
|
||
const early = s.addAudioClip(asset.id, 0, track.id)! // 0..2
|
||
const mid = s.addAudioClip(asset.id, 3, track.id)! // 3..5
|
||
const late = s.addAudioClip(asset.id, 6, track.id)! // 6..8
|
||
// Drag `late` back onto `mid`. `mid` must shunt left to 2 (abuts
|
||
// `early` end), `early` sits already at 0.
|
||
expect(s.setAudioClipPosition(late.id, 4)).toBe(true)
|
||
expect(late.startTime).toBe(4)
|
||
expect(mid.startTime).toBe(2)
|
||
expect(early.startTime).toBe(0)
|
||
})
|
||
|
||
test('setAudioClipPosition clamps moved clip forward when backward cascade hits 0', () => {
|
||
const s = makeFreshStore()
|
||
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 2 })
|
||
const track = s.addAudioTrack()
|
||
const a = s.addAudioClip(asset.id, 0, track.id)! // 0..2
|
||
const b = s.addAudioClip(asset.id, 2, track.id)! // 2..4
|
||
const cc = s.addAudioClip(asset.id, 4, track.id)! // 4..6
|
||
// Nothing fits to the left of 4 — drag to 1 clamps cc forward to 4.
|
||
expect(s.setAudioClipPosition(cc.id, 1)).toBe(true)
|
||
expect(cc.startTime).toBe(4)
|
||
expect(a.startTime).toBe(0)
|
||
expect(b.startTime).toBe(2)
|
||
})
|
||
|
||
test('duplicateAudioClip clones onto the same track, offset by visible duration', () => {
|
||
const s = makeFreshStore()
|
||
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 2 })
|
||
const track = s.addAudioTrack()
|
||
const clip = s.addAudioClip(asset.id, 1, track.id)!
|
||
const newId = s.duplicateAudioClip(clip.id)!
|
||
expect(typeof newId).toBe('string')
|
||
expect(newId === clip.id).toBe(false)
|
||
const copy = s.activeScene.audioClips.find((c) => c.id === newId)!
|
||
expect(copy.audioAssetId).toBe(asset.id)
|
||
expect(copy.audioTrackId).toBe(track.id)
|
||
expect(copy.startTime).toBe(1 + 2)
|
||
})
|
||
|
||
test('duplicateAudioClip honours trim when computing the offset', () => {
|
||
const s = makeFreshStore()
|
||
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 4 })
|
||
const track = s.addAudioTrack()
|
||
const clip = s.addAudioClip(asset.id, 0, track.id)!
|
||
s.trimAudioClip(clip.id, { trimStart: 0.5, trimEnd: 0.5 })
|
||
const newId = s.duplicateAudioClip(clip.id)!
|
||
const copy = s.activeScene.audioClips.find((c) => c.id === newId)!
|
||
// visible length = 4 - 0.5 - 0.5 = 3
|
||
expect(copy.startTime).toBe(3)
|
||
expect(copy.trimStart).toBe(0.5)
|
||
expect(copy.trimEnd).toBe(0.5)
|
||
})
|
||
|
||
test('duplicateAudioClip returns null for unknown id', () => {
|
||
const s = makeFreshStore()
|
||
expect(s.duplicateAudioClip('nope')).toBe(null)
|
||
})
|
||
|
||
test('normalizeScenePage drops orphan audio clips pointing at a missing track', () => {
|
||
const s = makeFreshStore()
|
||
const asset = addAsset(s)
|
||
const orphan = {
|
||
id: 'ghost-clip',
|
||
audioAssetId: asset.id,
|
||
audioTrackId: 'ghost-track',
|
||
startTime: 0,
|
||
}
|
||
s.activeScene.audioClips.push(orphan)
|
||
// Roundtrip through loadProject re-normalises.
|
||
s.loadProject(JSON.parse(JSON.stringify(s.project)))
|
||
expect(s.activeScene.audioClips.some((c) => c.id === 'ghost-clip')).toBe(false)
|
||
})
|
||
})
|
||
|
||
suite('composable — useAudioRuntime (createAudioRuntime)', () => {
|
||
interface StubAudio {
|
||
src: string
|
||
currentTime: number
|
||
paused: boolean
|
||
preload: string
|
||
played: number
|
||
paused_count: number
|
||
play(): Promise<void>
|
||
pause(): void
|
||
}
|
||
function makeAudioFactory() {
|
||
const created: StubAudio[] = []
|
||
const factory = () => {
|
||
const el: StubAudio = {
|
||
src: '',
|
||
currentTime: 0,
|
||
paused: true,
|
||
preload: '',
|
||
played: 0,
|
||
paused_count: 0,
|
||
play() {
|
||
this.paused = false
|
||
this.played++
|
||
return Promise.resolve()
|
||
},
|
||
pause() {
|
||
if (!this.paused) this.paused_count++
|
||
this.paused = true
|
||
},
|
||
}
|
||
created.push(el)
|
||
return el as unknown as HTMLAudioElement
|
||
}
|
||
return { factory, created }
|
||
}
|
||
|
||
test('play → the overlapping clip\'s element gets play() and a seek to audioTime', async () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;X', duration: 5 })
|
||
const track = s.addAudioTrack('T')
|
||
s.addAudioClip(asset.id, 2, track.id)
|
||
const { factory, created } = makeAudioFactory()
|
||
const rt = createAudioRuntime(s, { audioFactory: factory })
|
||
s.setPlayhead(3) // inside the 2..7 window
|
||
s.play()
|
||
await tick()
|
||
rt.reconcile()
|
||
expect(created.length).toBe(1)
|
||
const el = created[0]
|
||
expect(el.paused).toBe(false)
|
||
expect(el.played).toBe(1)
|
||
// audioTime = playhead - startTime + trimStart = 3 - 2 + 0 = 1
|
||
expect(Math.abs(el.currentTime - 1) < 0.01).toBe(true)
|
||
rt.dispose()
|
||
})
|
||
|
||
test('pause → all currently-playing elements get pause()', async () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;X', duration: 5 })
|
||
const track = s.addAudioTrack()
|
||
s.addAudioClip(asset.id, 0, track.id)
|
||
const { factory, created } = makeAudioFactory()
|
||
const rt = createAudioRuntime(s, { audioFactory: factory })
|
||
s.play()
|
||
await tick()
|
||
rt.reconcile()
|
||
expect(created[0].paused).toBe(false)
|
||
s.pause()
|
||
await tick()
|
||
expect(created[0].paused).toBe(true)
|
||
rt.dispose()
|
||
})
|
||
|
||
test('playhead outside a clip\'s window keeps its element paused', async () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;X', duration: 3 })
|
||
const track = s.addAudioTrack()
|
||
s.addAudioClip(asset.id, 4, track.id) // audible 4..7
|
||
const { factory, created } = makeAudioFactory()
|
||
const rt = createAudioRuntime(s, { audioFactory: factory })
|
||
s.setPlayhead(1) // before the clip
|
||
s.play()
|
||
await tick()
|
||
rt.reconcile()
|
||
// Element exists (pool seeded) but hasn't been play()'d.
|
||
expect(created.length).toBe(1)
|
||
expect(created[0].played).toBe(0)
|
||
expect(created[0].paused).toBe(true)
|
||
rt.dispose()
|
||
})
|
||
|
||
test('muting a track keeps its elements paused even while playing', async () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;X', duration: 5 })
|
||
const track = s.addAudioTrack()
|
||
s.addAudioClip(asset.id, 0, track.id)
|
||
s.setAudioTrackMuted(track.id, true)
|
||
const { factory, created } = makeAudioFactory()
|
||
const rt = createAudioRuntime(s, { audioFactory: factory })
|
||
s.setPlayhead(1)
|
||
s.play()
|
||
await tick()
|
||
rt.reconcile()
|
||
expect(created[0].played).toBe(0)
|
||
// Un-mute → element plays on next reconcile.
|
||
s.setAudioTrackMuted(track.id, false)
|
||
await tick()
|
||
rt.reconcile()
|
||
expect(created[0].played).toBe(1)
|
||
rt.dispose()
|
||
})
|
||
|
||
test('trimStart offsets audioTime by exactly trimStart seconds', async () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;X', duration: 10 })
|
||
const track = s.addAudioTrack()
|
||
const clip = s.addAudioClip(asset.id, 0, track.id)!
|
||
s.trimAudioClip(clip.id, { trimStart: 2 })
|
||
const { factory, created } = makeAudioFactory()
|
||
const rt = createAudioRuntime(s, { audioFactory: factory })
|
||
s.setPlayhead(1) // 1s into the clip from the timeline's POV
|
||
s.play()
|
||
await tick()
|
||
rt.reconcile()
|
||
// audioTime = playhead - startTime + trimStart = 1 - 0 + 2 = 3
|
||
expect(Math.abs(created[0].currentTime - 3) < 0.01).toBe(true)
|
||
rt.dispose()
|
||
})
|
||
|
||
test('removing a clip evicts its element from the pool', async () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;X', duration: 5 })
|
||
const track = s.addAudioTrack()
|
||
const clip = s.addAudioClip(asset.id, 0, track.id)!
|
||
const { factory } = makeAudioFactory()
|
||
const rt = createAudioRuntime(s, { audioFactory: factory })
|
||
s.play()
|
||
await tick()
|
||
rt.reconcile()
|
||
expect(rt.pool.has(clip.id)).toBe(true)
|
||
s.removeAudioClip(clip.id)
|
||
await tick()
|
||
rt.reconcile()
|
||
expect(rt.pool.has(clip.id)).toBe(false)
|
||
rt.dispose()
|
||
})
|
||
|
||
test('switching scenes drops elements whose clips left with the old page', async () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;X', duration: 5 })
|
||
const trackA = s.addAudioTrack('A')
|
||
const clipA = s.addAudioClip(asset.id, 0, trackA.id)!
|
||
const { factory } = makeAudioFactory()
|
||
const rt = createAudioRuntime(s, { audioFactory: factory })
|
||
s.play()
|
||
await tick()
|
||
rt.reconcile()
|
||
expect(rt.pool.has(clipA.id)).toBe(true)
|
||
s.addScene('Two') // auto-switches; new page has no audio clips yet
|
||
await tick()
|
||
rt.reconcile()
|
||
expect(rt.pool.has(clipA.id)).toBe(false)
|
||
expect(rt.pool.size).toBe(0)
|
||
rt.dispose()
|
||
})
|
||
|
||
test('dispose() pauses every element and empties the pool', async () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;X', duration: 5 })
|
||
const track = s.addAudioTrack()
|
||
s.addAudioClip(asset.id, 0, track.id)
|
||
const { factory, created } = makeAudioFactory()
|
||
const rt = createAudioRuntime(s, { audioFactory: factory })
|
||
s.play()
|
||
await tick()
|
||
rt.reconcile()
|
||
expect(rt.pool.size).toBe(1)
|
||
rt.dispose()
|
||
expect(rt.pool.size).toBe(0)
|
||
expect(created[0].paused).toBe(true)
|
||
})
|
||
|
||
test('getWaveformSlot returns pending the first time, caches subsequent calls', () => {
|
||
clearWaveformCache()
|
||
// Un-decodable src — the promise rejects, but the immediate
|
||
// synchronous return is what we're testing here.
|
||
const slot1 = getWaveformSlot('asset-x', 'not-a-real-url')
|
||
expect(slot1?.state).toBe('pending')
|
||
// Second call while still pending returns the same pending slot
|
||
// (no new decode kicked off).
|
||
const slot2 = getWaveformSlot('asset-x', 'not-a-real-url')
|
||
expect(slot2).toBe(slot1)
|
||
clearWaveformCache()
|
||
})
|
||
|
||
test('slicePeaksForClip returns only the audible range when trimmed', () => {
|
||
const full = new Float32Array([0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8])
|
||
// Asset is 8s long; trimStart=2 → drop first 25%; trimEnd=2 →
|
||
// drop last 25%. Expect roughly the middle half of the peaks.
|
||
const slice = slicePeaksForClip(full, 8, 2, 2)
|
||
expect(slice.length < full.length).toBe(true)
|
||
// Every value in the slice must be within the middle portion.
|
||
for (let i = 0; i < slice.length; i++) {
|
||
expect(slice[i] >= 0.3 - 1e-6 && slice[i] <= 0.6 + 1e-6).toBe(true)
|
||
}
|
||
})
|
||
|
||
test('slicePeaksForClip with no trim returns the whole array', () => {
|
||
const full = new Float32Array([0.1, 0.2, 0.3])
|
||
const slice = slicePeaksForClip(full, 3, 0, 0)
|
||
expect(slice.length).toBe(full.length)
|
||
})
|
||
|
||
test('drawWaveform paints something onto the canvas (no throw on empty)', () => {
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = 50
|
||
canvas.height = 20
|
||
// Non-empty peaks — expect SOME non-zero pixels after drawing.
|
||
drawWaveform(canvas, new Float32Array([0.5, 0.8, 0.3]), { pixelRatio: 1 })
|
||
const ctx = canvas.getContext('2d')!
|
||
const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data
|
||
let nonZero = 0
|
||
for (let i = 0; i < data.length; i += 4) {
|
||
if (data[i + 3] > 0) nonZero++
|
||
}
|
||
expect(nonZero > 0).toBe(true)
|
||
// And a zero-length peaks array must not throw.
|
||
drawWaveform(canvas, new Float32Array(0))
|
||
})
|
||
|
||
test('useAudioRuntime composable wires/unwires cleanly inside a component lifecycle', async () => {
|
||
// The Vue wrapper is a thin shell over createAudioRuntime — mount
|
||
// it via a harness component and expect no throw at mount /
|
||
// unmount. This is the integration-level check that the
|
||
// onBeforeUnmount → dispose hook is actually reached.
|
||
const { defineComponent } = await import('vue')
|
||
const { useAudioRuntime } = await import('@/composables/useAudioRuntime')
|
||
const factory = () => {
|
||
const el = {
|
||
src: '',
|
||
currentTime: 0,
|
||
paused: true,
|
||
preload: '',
|
||
play() {
|
||
this.paused = false
|
||
return Promise.resolve()
|
||
},
|
||
pause() {
|
||
this.paused = true
|
||
},
|
||
}
|
||
return el as unknown as HTMLAudioElement
|
||
}
|
||
const Harness = defineComponent({
|
||
setup() {
|
||
useAudioRuntime({ audioFactory: factory })
|
||
return () => h('div', { 'data-test-audio-host': '' })
|
||
},
|
||
})
|
||
const handle = mountComponent(Harness, {})
|
||
await nextTick()
|
||
expect(handle.host.querySelector('[data-test-audio-host]')).toBeTruthy()
|
||
// Unmount should not throw even if nothing in the pool.
|
||
handle.unmount()
|
||
})
|
||
})
|
||
|
||
suite('actions — evalAction with mock runtime', () => {
|
||
function makeMockRuntime(init?: Partial<ActionRuntime>): ActionRuntime & {
|
||
calls: string[]
|
||
playhead: number
|
||
playing: boolean
|
||
warnings: string[]
|
||
} {
|
||
const rt = {
|
||
fps: 60,
|
||
playbackIn: 0,
|
||
playbackOut: 10,
|
||
duration: 10,
|
||
markers: { intro: 2, outro: 8 },
|
||
playhead: 0,
|
||
playing: false,
|
||
warnings: [] as string[],
|
||
calls: [] as string[],
|
||
setPlayhead(t: number) {
|
||
this.calls.push(`setPlayhead:${t}`)
|
||
this.playhead = t
|
||
},
|
||
play() {
|
||
this.calls.push('play')
|
||
this.playing = true
|
||
},
|
||
pause() {
|
||
this.calls.push('pause')
|
||
this.playing = false
|
||
},
|
||
warn(msg: string) {
|
||
this.warnings.push(msg)
|
||
},
|
||
...init,
|
||
}
|
||
return rt as ReturnType<typeof makeMockRuntime>
|
||
}
|
||
|
||
test('play / pause / rewindToLoop', () => {
|
||
const rt = makeMockRuntime({ playbackIn: 2 })
|
||
evalAction({ type: 'play' }, rt)
|
||
evalAction({ type: 'pause' }, rt)
|
||
evalAction({ type: 'rewindToLoop' }, rt)
|
||
expect(rt.playing).toBe(false)
|
||
expect(rt.playhead).toBe(2)
|
||
expect(rt.calls).toEqual(['play', 'pause', 'setPlayhead:2'])
|
||
})
|
||
|
||
test('jumpTo clamps to [0, duration]', () => {
|
||
const rt = makeMockRuntime()
|
||
evalAction({ type: 'jumpTo', time: -3 }, rt)
|
||
expect(rt.playhead).toBe(0)
|
||
evalAction({ type: 'jumpTo', time: 99 }, rt)
|
||
expect(rt.playhead).toBe(10)
|
||
evalAction({ type: 'jumpTo', time: 5 }, rt)
|
||
expect(rt.playhead).toBe(5)
|
||
})
|
||
|
||
test('jumpToFrame divides by fps', () => {
|
||
const rt = makeMockRuntime({ fps: 30 })
|
||
evalAction({ type: 'jumpToFrame', frame: 90 }, rt)
|
||
expect(rt.playhead).toBe(3)
|
||
})
|
||
|
||
test('jumpToMarker resolves via markers map', () => {
|
||
const rt = makeMockRuntime()
|
||
evalAction({ type: 'jumpToMarker', name: 'outro' }, rt)
|
||
expect(rt.playhead).toBe(8)
|
||
})
|
||
|
||
test('jumpToMarker warns on unknown name and does not move playhead', () => {
|
||
const rt = makeMockRuntime()
|
||
const before = rt.playhead
|
||
evalAction({ type: 'jumpToMarker', name: 'missing' }, rt)
|
||
expect(rt.playhead).toBe(before)
|
||
expect(rt.warnings.length).toBe(1)
|
||
expect(rt.warnings[0]).toContain('missing')
|
||
})
|
||
|
||
test('jumpToScene dispatches switchScene on the runtime', () => {
|
||
let switched: string | null = null
|
||
const rt = makeMockRuntime({
|
||
sceneIds: new Set(['s-a', 's-b']),
|
||
switchScene(id: string) {
|
||
switched = id
|
||
},
|
||
})
|
||
evalAction({ type: 'jumpToScene', sceneId: 's-b' }, rt)
|
||
expect(switched).toBe('s-b')
|
||
expect(rt.warnings.length).toBe(0)
|
||
})
|
||
|
||
test('jumpToScene warns on unknown sceneId and does not switch', () => {
|
||
let switched: string | null = null
|
||
const rt = makeMockRuntime({
|
||
sceneIds: new Set(['s-a']),
|
||
switchScene(id: string) {
|
||
switched = id
|
||
},
|
||
})
|
||
evalAction({ type: 'jumpToScene', sceneId: 's-ghost' }, rt)
|
||
expect(switched).toBe(null)
|
||
expect(rt.warnings.length).toBe(1)
|
||
expect(rt.warnings[0]).toContain('s-ghost')
|
||
})
|
||
|
||
test('jumpToScene warns if runtime has no switchScene handler (e.g. exported bundle)', () => {
|
||
const rt = makeMockRuntime() // no switchScene, no sceneIds
|
||
evalAction({ type: 'jumpToScene', sceneId: 's-x' }, rt)
|
||
expect(rt.warnings.length).toBe(1)
|
||
expect(rt.warnings[0].toLowerCase()).toContain('does not support')
|
||
})
|
||
|
||
test('setButtonEnabled dispatches setButtonEnabled on the runtime', () => {
|
||
const flips: { layerId: string; enabled: boolean }[] = []
|
||
const rt = makeMockRuntime({
|
||
setButtonEnabled(layerId: string, enabled: boolean) {
|
||
flips.push({ layerId, enabled })
|
||
},
|
||
})
|
||
evalAction({ type: 'setButtonEnabled', layerId: 'L-1', enabled: false }, rt)
|
||
evalAction({ type: 'setButtonEnabled', layerId: 'L-1', enabled: true }, rt)
|
||
expect(flips).toEqual([
|
||
{ layerId: 'L-1', enabled: false },
|
||
{ layerId: 'L-1', enabled: true },
|
||
])
|
||
expect(rt.warnings.length).toBe(0)
|
||
})
|
||
|
||
test('setButtonEnabled warns when the runtime omits the handler', () => {
|
||
const rt = makeMockRuntime() // no setButtonEnabled
|
||
evalAction({ type: 'setButtonEnabled', layerId: 'L-1', enabled: false }, rt)
|
||
expect(rt.warnings.length).toBe(1)
|
||
expect(rt.warnings[0].toLowerCase()).toContain('does not support')
|
||
})
|
||
|
||
test('script action parses and dispatches sub-actions (no warn on valid DSL)', () => {
|
||
const rt = makeMockRuntime()
|
||
evalAction({ type: 'script', source: 'play\npause' }, rt)
|
||
expect(rt.warnings.length).toBe(0)
|
||
expect(rt.calls).toEqual(['play', 'pause'])
|
||
})
|
||
|
||
test('script action warns per invalid DSL line', () => {
|
||
const rt = makeMockRuntime()
|
||
evalAction({ type: 'script', source: 'play\nwat\npause' }, rt)
|
||
expect(rt.warnings.length).toBe(1)
|
||
expect(rt.warnings[0]).toContain('wat')
|
||
// Valid lines still fire.
|
||
expect(rt.calls).toEqual(['play', 'pause'])
|
||
})
|
||
|
||
test('buildMarkerMap maps name → time', () => {
|
||
const map = buildMarkerMap([
|
||
{ id: 'a', name: 'intro', time: 1 },
|
||
{ id: 'b', name: 'outro', time: 5 },
|
||
])
|
||
expect(map.intro).toBe(1)
|
||
expect(map.outro).toBe(5)
|
||
})
|
||
|
||
test('scriptFramesInRange returns only forward frames, sorted', () => {
|
||
const frames = [
|
||
{ id: 'a', trackId: 't', time: 3, actions: [] as Action[] },
|
||
{ id: 'b', trackId: 't', time: 1, actions: [] as Action[] },
|
||
{ id: 'c', trackId: 't', time: 6, actions: [] as Action[] },
|
||
]
|
||
// (0, 4] → only 1 and 3, sorted
|
||
const got = scriptFramesInRange(frames, 0, 4).map((f) => f.id)
|
||
expect(got).toEqual(['b', 'a'])
|
||
// backwards → empty
|
||
expect(scriptFramesInRange(frames, 5, 1)).toHaveLength(0)
|
||
})
|
||
|
||
test('scriptFramesInRange uses open-closed interval', () => {
|
||
const frames = [{ id: 'a', trackId: 't', time: 2, actions: [] as Action[] }]
|
||
// prev=2 (exclusive), next=2 → empty
|
||
expect(scriptFramesInRange(frames, 2, 2)).toHaveLength(0)
|
||
// prev=1, next=2 → includes 2
|
||
expect(scriptFramesInRange(frames, 1, 2)).toHaveLength(1)
|
||
})
|
||
|
||
test('scriptFramesInRange with includeStart catches frames exactly at prev', () => {
|
||
// Matches the first-tick-after-play path: a script at the starting
|
||
// playhead time (e.g. frame 0) would be skipped by the default
|
||
// open-left interval; includeStart closes it.
|
||
const frames = [
|
||
{ id: 'a', trackId: 't', time: 0, actions: [] as Action[] },
|
||
{ id: 'b', trackId: 't', time: 0.5, actions: [] as Action[] },
|
||
]
|
||
// Default: open-left — excludes frame at 0.
|
||
expect(scriptFramesInRange(frames, 0, 0.5).map((f) => f.id)).toEqual(['b'])
|
||
// includeStart: both fire.
|
||
expect(
|
||
scriptFramesInRange(frames, 0, 0.5, { includeStart: true }).map((f) => f.id),
|
||
).toEqual(['a', 'b'])
|
||
// Degenerate prev===next: only fires with includeStart.
|
||
expect(scriptFramesInRange(frames, 0, 0, { includeStart: true }).map((f) => f.id)).toEqual([
|
||
'a',
|
||
])
|
||
expect(scriptFramesInRange(frames, 0, 0)).toHaveLength(0)
|
||
})
|
||
})
|
||
|
||
suite('scene store — tick fires script frames forward-only', () => {
|
||
test('tick fires frames crossed during forward advance in time order', async () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
const t = s.addScriptTrack()
|
||
s.addScriptFrame(t.id, 2, [{ type: 'pause' }])
|
||
s.addScriptFrame(t.id, 1, [{ type: 'jumpTo', time: 9 }])
|
||
s.play()
|
||
// From 0 → 2: both frames get crossed; time-1 frame fires first and
|
||
// jumps the playhead to 9, time-2 frame fires second and pauses.
|
||
s.tick(2)
|
||
expect(s.playhead).toBe(9)
|
||
expect(s.playing).toBe(false)
|
||
await tick()
|
||
})
|
||
|
||
test('setPlayhead alone does not fire frames', async () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
const t = s.addScriptTrack()
|
||
s.addScriptFrame(t.id, 2, [{ type: 'pause' }])
|
||
s.play()
|
||
s.setPlayhead(5) // scrub over the frame without ticking
|
||
expect(s.playing).toBe(true)
|
||
s.pause()
|
||
await tick()
|
||
})
|
||
|
||
test('frames fire even after natural stop at playbackOut', async () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
s.setPlaybackRange(0, 5)
|
||
const t = s.addScriptTrack()
|
||
s.addScriptFrame(t.id, 5, [{ type: 'rewindToLoop' }])
|
||
s.play()
|
||
s.tick(6) // lands at playbackOut=5, playing=false, frame at 5 fires
|
||
expect(s.playing).toBe(false)
|
||
expect(s.playhead).toBe(0) // rewindToLoop → playbackIn=0
|
||
await tick()
|
||
})
|
||
|
||
test('a scripted jumpToScene switches the scene, keeps playing, and rewinds to the in-point', async () => {
|
||
// Chapter-transition semantics: the incoming scene starts at
|
||
// the project's in-point no matter where the outgoing scene was
|
||
// paused. Keeps playback running across the switch.
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
const aId = s.project.activeSceneId
|
||
const bId = s.addScene('B').id
|
||
s.switchScene(aId)
|
||
const track = s.addScriptTrack()
|
||
s.addScriptFrame(track.id, 3, [{ type: 'jumpToScene', sceneId: bId }])
|
||
s.play()
|
||
s.tick(3)
|
||
expect(s.project.activeSceneId).toBe(bId)
|
||
expect(s.playing).toBe(true)
|
||
expect(s.playhead).toBe(s.playbackIn)
|
||
await tick()
|
||
})
|
||
|
||
test('play-start fires a script sitting exactly at the playhead (frame-0 case)', async () => {
|
||
// Regression: a script placed at time=0 was silently skipped
|
||
// because the forward-firing window is (prev, next] — on the very
|
||
// first tick after play(), prev === 0 so the frame at 0 missed.
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
const t = s.addScriptTrack()
|
||
s.addScriptFrame(t.id, 0, [{ type: 'pause' }])
|
||
expect(s.playhead).toBe(0)
|
||
s.play()
|
||
expect(s.playing).toBe(true)
|
||
s.tick(0.5)
|
||
// Pause action fired → playing=false. Without the play-start fix
|
||
// this assertion flips (playing stays true because the frame-0
|
||
// script never ran).
|
||
expect(s.playing).toBe(false)
|
||
await tick()
|
||
})
|
||
|
||
test('play-start also fires a script at a non-zero starting playhead', async () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
const t = s.addScriptTrack()
|
||
s.addScriptFrame(t.id, 3, [{ type: 'pause' }])
|
||
s.setPlayhead(3)
|
||
s.play()
|
||
s.tick(0.1)
|
||
expect(s.playing).toBe(false)
|
||
await tick()
|
||
})
|
||
|
||
test('re-playing from the same playhead re-fires the start-frame script', async () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
const t = s.addScriptTrack()
|
||
let count = 0
|
||
// Stand-in for "a scripted action ran once this play session":
|
||
// a real pause action flips playing=false which we re-enable below.
|
||
s.addScriptFrame(t.id, 0, [{ type: 'pause' }])
|
||
for (let i = 0; i < 3; i++) {
|
||
s.setPlayhead(0)
|
||
s.play()
|
||
s.tick(0.1)
|
||
if (!s.playing) count++
|
||
}
|
||
expect(count).toBe(3)
|
||
await tick()
|
||
})
|
||
|
||
test('scripted jumpToScene with an unknown id warns and leaves state alone', async () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
const aId = s.project.activeSceneId
|
||
const track = s.addScriptTrack()
|
||
s.addScriptFrame(track.id, 2, [{ type: 'jumpToScene', sceneId: 'bogus-id' }])
|
||
s.play()
|
||
const warnings: string[] = []
|
||
const origWarn = console.warn
|
||
console.warn = (...args: unknown[]) => {
|
||
warnings.push(args.map(String).join(' '))
|
||
}
|
||
try {
|
||
s.tick(2)
|
||
} finally {
|
||
console.warn = origWarn
|
||
}
|
||
expect(s.project.activeSceneId).toBe(aId)
|
||
expect(s.playing).toBe(true)
|
||
expect(warnings.some((w) => w.includes('bogus-id'))).toBe(true)
|
||
await tick()
|
||
})
|
||
})
|
||
|
||
suite('scriptDsl — parser', () => {
|
||
test('parses bare commands', () => {
|
||
const { actions, errors } = parseScript('play\npause\nrewind')
|
||
expect(errors).toHaveLength(0)
|
||
expect(actions.map((a) => a.type)).toEqual(['play', 'pause', 'rewindToLoop'])
|
||
})
|
||
|
||
test('skips blank lines and comments', () => {
|
||
const src = [
|
||
'// leading comment',
|
||
'',
|
||
'play // trailing comment',
|
||
' ',
|
||
'pause',
|
||
].join('\n')
|
||
const { actions, errors } = parseScript(src)
|
||
expect(errors).toHaveLength(0)
|
||
expect(actions.map((a) => a.type)).toEqual(['play', 'pause'])
|
||
})
|
||
|
||
test('jump accepts seconds, #frame, and @marker', () => {
|
||
const { actions, errors } = parseScript('jump 2.5\njump #60\njump @intro')
|
||
expect(errors).toHaveLength(0)
|
||
expect(actions[0]).toEqual({ type: 'jumpTo', time: 2.5 })
|
||
expect(actions[1]).toEqual({ type: 'jumpToFrame', frame: 60 })
|
||
expect(actions[2]).toEqual({ type: 'jumpToMarker', name: 'intro' })
|
||
})
|
||
|
||
test('jump reports invalid arguments', () => {
|
||
const { actions, errors } = parseScript('jump\njump abc\njump #xx\njump @')
|
||
expect(actions).toHaveLength(0)
|
||
expect(errors).toHaveLength(4)
|
||
})
|
||
|
||
test('unknown commands report error, other lines still parse', () => {
|
||
const { actions, errors } = parseScript('play\nwat\npause')
|
||
expect(errors).toHaveLength(1)
|
||
expect(errors[0].line).toBe(2)
|
||
expect(actions.map((a) => a.type)).toEqual(['play', 'pause'])
|
||
})
|
||
})
|
||
|
||
suite('actions — script type expands via evalAction', () => {
|
||
test('script source is parsed and each action dispatched in order', () => {
|
||
const calls: string[] = []
|
||
const rt: ActionRuntime = {
|
||
fps: 60,
|
||
playbackIn: 0,
|
||
playbackOut: 10,
|
||
duration: 10,
|
||
markers: { intro: 2 },
|
||
setPlayhead: (t) => calls.push(`seek:${t}`),
|
||
play: () => calls.push('play'),
|
||
pause: () => calls.push('pause'),
|
||
warn: () => {},
|
||
}
|
||
evalAction(
|
||
{ type: 'script', source: 'pause\njump @intro\nplay' },
|
||
rt,
|
||
)
|
||
expect(calls).toEqual(['pause', 'seek:2', 'play'])
|
||
})
|
||
|
||
test('script errors surface via rt.warn, valid actions still fire', () => {
|
||
const warnings: string[] = []
|
||
const fired: string[] = []
|
||
const rt: ActionRuntime = {
|
||
fps: 60,
|
||
playbackIn: 0,
|
||
playbackOut: 10,
|
||
duration: 10,
|
||
markers: {},
|
||
setPlayhead: () => {},
|
||
play: () => fired.push('play'),
|
||
pause: () => fired.push('pause'),
|
||
warn: (m) => warnings.push(m),
|
||
}
|
||
evalAction({ type: 'script', source: 'play\nwat\npause' }, rt)
|
||
expect(fired).toEqual(['play', 'pause'])
|
||
expect(warnings).toHaveLength(1)
|
||
expect(warnings[0]).toContain('wat')
|
||
})
|
||
})
|
||
|
||
suite('scene store — macro model normalization', () => {
|
||
test('loadScene initializes new arrays when missing', () => {
|
||
const s = makeFreshStore()
|
||
const bare = {
|
||
version: 1 as const,
|
||
canvas: { width: 640, height: 480, background: '#ffffff' },
|
||
duration: 5,
|
||
fps: 30,
|
||
assets: [],
|
||
layers: [],
|
||
}
|
||
s.loadScene(bare)
|
||
expect(s.scene.scriptTracks).toEqual([])
|
||
expect(s.scene.scriptFrames).toEqual([])
|
||
expect(s.scene.markers).toEqual([])
|
||
})
|
||
|
||
test('setTrackLocked also works on script tracks', () => {
|
||
const s = makeFreshStore()
|
||
const t = s.addScriptTrack()
|
||
s.setTrackLocked(t.id, true)
|
||
expect(s.scene.scriptTracks?.find((x) => x.id === t.id)?.locked).toBe(true)
|
||
s.setTrackLocked(t.id, false)
|
||
expect(s.scene.scriptTracks?.find((x) => x.id === t.id)?.locked).toBe(undefined)
|
||
})
|
||
})
|
||
|
||
suite('macro-clips — store coverage gaps', () => {
|
||
test('removeMarker on unknown id is a no-op', () => {
|
||
const s = makeFreshStore()
|
||
s.addMarker(1, 'A')
|
||
s.removeMarker('no-such-id')
|
||
expect(s.scene.markers).toHaveLength(1)
|
||
})
|
||
|
||
test('setMarkerTime / setMarkerColor on unknown id are no-ops', () => {
|
||
const s = makeFreshStore()
|
||
s.setMarkerTime('ghost', 5)
|
||
s.setMarkerColor('ghost', '#ff0000')
|
||
expect(s.scene.markers).toHaveLength(0)
|
||
})
|
||
|
||
test('addMarker accepts an initial color', () => {
|
||
const s = makeFreshStore()
|
||
const m = s.addMarker(1, 'A', '#ff8800')
|
||
expect(m.color).toBe('#ff8800')
|
||
})
|
||
|
||
test('removeScriptFrame / setScriptFrameActions / setScriptFrameTime handle unknown ids', () => {
|
||
const s = makeFreshStore()
|
||
// None of these should throw or mutate anything.
|
||
s.removeScriptFrame('nope')
|
||
s.setScriptFrameActions('nope', [{ type: 'play' }])
|
||
s.setScriptFrameTime('nope', 1)
|
||
expect(s.scene.scriptFrames).toEqual([])
|
||
})
|
||
|
||
})
|
||
|
||
suite('macro-clips — DSL edge cases', () => {
|
||
test('empty source yields no actions and no errors', () => {
|
||
const { actions, errors } = parseScript('')
|
||
expect(actions).toHaveLength(0)
|
||
expect(errors).toHaveLength(0)
|
||
})
|
||
|
||
test('only comments or whitespace yields no actions', () => {
|
||
const { actions, errors } = parseScript(' \n\n// note\n // more\n')
|
||
expect(actions).toHaveLength(0)
|
||
expect(errors).toHaveLength(0)
|
||
})
|
||
|
||
test('trailing blank lines do not produce phantom errors', () => {
|
||
const { actions, errors } = parseScript('play\n\n\n')
|
||
expect(actions).toHaveLength(1)
|
||
expect(errors).toHaveLength(0)
|
||
})
|
||
|
||
test('jump with empty @marker reports error', () => {
|
||
const { errors } = parseScript('jump @')
|
||
expect(errors).toHaveLength(1)
|
||
expect(errors[0].message).toContain('marker')
|
||
})
|
||
})
|
||
|
||
suite('macro-clips — tick firing robustness', () => {
|
||
test('multiple frames on different tracks fire in time order', async () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
const t1 = s.addScriptTrack('A')
|
||
const t2 = s.addScriptTrack('B')
|
||
const log: number[] = []
|
||
// Three frames: 0.2 (t2), 0.5 (t1), 0.8 (t2)
|
||
// We can verify ordering via jumpTo actions that push into a list…
|
||
// but evalAction dispatches to store.setPlayhead etc. We instead
|
||
// observe the jumpTo time the playhead ends up at after all fire.
|
||
s.addScriptFrame(t2.id, 0.2, [{ type: 'jumpTo', time: 1 }])
|
||
s.addScriptFrame(t1.id, 0.5, [{ type: 'jumpTo', time: 2 }])
|
||
s.addScriptFrame(t2.id, 0.8, [{ type: 'jumpTo', time: 3 }])
|
||
s.play()
|
||
s.tick(1) // advances to 1, all three frames in (0, 1]
|
||
// The last fired wins — since frames fire sorted by time, the last
|
||
// action to run is jumpTo 3.
|
||
expect(s.playhead).toBe(3)
|
||
s.pause()
|
||
await tick()
|
||
void log
|
||
})
|
||
|
||
test('within a single frame, actions fire in list order', async () => {
|
||
const s = makeFreshStore()
|
||
s.setDuration(10)
|
||
const t = s.addScriptTrack()
|
||
s.addScriptFrame(t.id, 1, [
|
||
{ type: 'jumpTo', time: 5 },
|
||
{ type: 'jumpTo', time: 7 },
|
||
{ type: 'pause' },
|
||
])
|
||
s.play()
|
||
s.tick(2)
|
||
expect(s.playhead).toBe(7)
|
||
expect(s.playing).toBe(false)
|
||
await tick()
|
||
})
|
||
|
||
test('scriptFramesInRange handles exactly-at-next inclusively', () => {
|
||
const frames = [{ id: 'a', trackId: 't', time: 1, actions: [] as Action[] }]
|
||
// next strictly equals frame.time → included (open-closed)
|
||
expect(scriptFramesInRange(frames, 0.5, 1)).toHaveLength(1)
|
||
// next strictly below → excluded
|
||
expect(scriptFramesInRange(frames, 0.5, 0.999)).toHaveLength(0)
|
||
})
|
||
})
|
||
|
||
suite('button asset — still-frame visibility gate', () => {
|
||
// Button assets have 4 discrete state times (1,2,3,4). A still-frame
|
||
// (hold) keyframe must only be visible on its exact state frame —
|
||
// not held forward to the neighbours as on regular timelines.
|
||
test('isButtonLayerActiveAt: hold kf at t=2 only visible at t=2', async () => {
|
||
const { isButtonLayerActiveAt } = await import('@/lib/interpolate')
|
||
const layer: Layer = {
|
||
id: 'l1',
|
||
name: '',
|
||
assetId: 'a',
|
||
visible: true,
|
||
base: { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
|
||
keyframes: [
|
||
{
|
||
id: 'k1',
|
||
time: 2,
|
||
props: { x: 10 },
|
||
easing: 'linear',
|
||
hold: true,
|
||
},
|
||
],
|
||
}
|
||
expect(isButtonLayerActiveAt(layer, 1)).toBe(false)
|
||
expect(isButtonLayerActiveAt(layer, 2)).toBe(true)
|
||
expect(isButtonLayerActiveAt(layer, 3)).toBe(false)
|
||
expect(isButtonLayerActiveAt(layer, 4)).toBe(false)
|
||
})
|
||
|
||
test('isButtonLayerActiveAt: non-hold kfs at t=1 and t=4 still interpolate between states', async () => {
|
||
const { isButtonLayerActiveAt } = await import('@/lib/interpolate')
|
||
const layer: Layer = {
|
||
id: 'l',
|
||
name: '',
|
||
assetId: 'a',
|
||
visible: true,
|
||
base: { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
|
||
keyframes: [
|
||
{ id: 'k1', time: 1, props: { x: 0 }, easing: 'linear' },
|
||
{ id: 'k2', time: 4, props: { x: 100 }, easing: 'linear' },
|
||
],
|
||
}
|
||
expect(isButtonLayerActiveAt(layer, 1)).toBe(true)
|
||
expect(isButtonLayerActiveAt(layer, 2)).toBe(true)
|
||
expect(isButtonLayerActiveAt(layer, 3)).toBe(true)
|
||
expect(isButtonLayerActiveAt(layer, 4)).toBe(true)
|
||
})
|
||
|
||
test('isButtonLayerActiveAt: a hold kf followed by a regular kf shows only on both exact frames', async () => {
|
||
const { isButtonLayerActiveAt } = await import('@/lib/interpolate')
|
||
const layer: Layer = {
|
||
id: 'l',
|
||
name: '',
|
||
assetId: 'a',
|
||
visible: true,
|
||
base: { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
|
||
keyframes: [
|
||
{ id: 'k1', time: 2, props: { x: 0 }, easing: 'linear', hold: true },
|
||
{ id: 'k2', time: 4, props: { x: 100 }, easing: 'linear' },
|
||
],
|
||
}
|
||
expect(isButtonLayerActiveAt(layer, 1)).toBe(false)
|
||
expect(isButtonLayerActiveAt(layer, 2)).toBe(true)
|
||
// hold at t=2 → the region (2, 4) is "holding in silence", invisible.
|
||
expect(isButtonLayerActiveAt(layer, 3)).toBe(false)
|
||
expect(isButtonLayerActiveAt(layer, 4)).toBe(true)
|
||
})
|
||
|
||
test('isButtonLayerActiveAt: no keyframes → always visible (static layer)', async () => {
|
||
const { isButtonLayerActiveAt } = await import('@/lib/interpolate')
|
||
const layer: Layer = {
|
||
id: 'l',
|
||
name: '',
|
||
assetId: 'a',
|
||
visible: true,
|
||
base: { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
|
||
keyframes: [],
|
||
}
|
||
expect(isButtonLayerActiveAt(layer, 1)).toBe(true)
|
||
expect(isButtonLayerActiveAt(layer, 3)).toBe(true)
|
||
})
|
||
|
||
test('runtime engine embeds button-specific still-frame visibility', () => {
|
||
const r = generateRuntimeScript()
|
||
expect(r).toContain('buttonLayerActiveAt')
|
||
})
|
||
|
||
test('runtime engine evalAction handles setButtonEnabled via runtime.buttonDisabled', () => {
|
||
// Pin the engine source so the setButtonEnabled case both exists
|
||
// and writes into the runtime.buttonDisabled map — the same field
|
||
// the click-dispatcher reads to early-return on disabled buttons.
|
||
const r = generateRuntimeScript()
|
||
expect(r).toContain("case 'setButtonEnabled'")
|
||
expect(r).toContain('runtime.buttonDisabled[act.layerId]')
|
||
})
|
||
|
||
test('runtime engine fires frame-0 scripts via playStartPending', () => {
|
||
// Pin the engine source so the first-tick-after-play firing is
|
||
// still wired: variable exists, fireFrames takes an includeStart
|
||
// arg, and forcePlay re-arms the flag so every pause→play fires
|
||
// the start-frame scripts again.
|
||
const r = generateRuntimeScript()
|
||
expect(r).toContain('playStartPending')
|
||
expect(r).toContain('function fireFrames(prev, next, includeStart)')
|
||
expect(r).toContain('playStartPending=true')
|
||
})
|
||
|
||
test('timeline segments skip the connector line before a still-frame kf in button mode', async () => {
|
||
// In button-edit the state frames are discrete: the connector
|
||
// line that spans between kfs on a regular timeline must NOT
|
||
// be drawn from a preceding kf into a hold kf — that would
|
||
// suggest the layer is on-screen across the intervening states.
|
||
// Pin the button-mode branch of segmentsOf structurally: three
|
||
// kfs, middle one hold → no segment coming INTO the still.
|
||
const { default: T } = await import('@/components/Timeline.vue')
|
||
void T
|
||
// Reach into the private helper via a minimal contract: mount
|
||
// Timeline inside a button-asset-edit context and inspect the
|
||
// rendered .kf-seg elements for the track.
|
||
const s = makeFreshStore()
|
||
const btn = s.addButtonAsset('B', 100, 100)
|
||
s.enterButtonAssetEdit(btn.id)
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#abc' })
|
||
const layer = s.addLayer(a.id)!
|
||
// Lay out three kfs: normal (non-hold), hover (hold), disabled (non-hold).
|
||
layer.keyframes = [
|
||
{ id: 'k1', time: BUTTON_STATE_TIMES.normal, props: {}, easing: 'linear' },
|
||
{ id: 'k2', time: BUTTON_STATE_TIMES.hover, props: {}, easing: 'linear', hold: true },
|
||
{ id: 'k3', time: BUTTON_STATE_TIMES.disabled, props: {}, easing: 'linear' },
|
||
]
|
||
const handle = mountComponent(Timeline, {})
|
||
await nextTick()
|
||
const segs = handle.host.querySelectorAll('.kf-seg')
|
||
// Expected segments in button-mode:
|
||
// 1→2 (non-hold kf1 to hold kf2): drawn (interpolation into still)
|
||
// …actually no: hold k2 terminates *emerging* lines, not
|
||
// incoming. Only the OUT-segments from a hold are skipped.
|
||
// 2→3 (hold kf2 to non-hold kf3): SKIPPED (no line out of still).
|
||
expect(segs.length).toBe(1)
|
||
s.exitButtonAssetEdit()
|
||
handle.unmount()
|
||
})
|
||
|
||
test('addLayer in button-edit seeds the initial kf on the ACTIVE state, not always normal', () => {
|
||
const s = makeFreshStore()
|
||
const btn = s.addButtonAsset('B', 100, 100)
|
||
s.enterButtonAssetEdit(btn.id)
|
||
s.setActiveButtonState('disabled')
|
||
const asset = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#000' })
|
||
const layer = s.addLayer(asset.id)!
|
||
expect(layer.keyframes).toHaveLength(1)
|
||
expect(layer.keyframes[0].time).toBe(BUTTON_STATE_TIMES.disabled)
|
||
s.exitButtonAssetEdit()
|
||
void btn
|
||
})
|
||
|
||
test('drawButtonFrame renders a hold-kf layer ONLY on its own state frame', () => {
|
||
// End-to-end UI test: construct a button with a single rect
|
||
// whose layer has a hold-keyframe at the hover state. Then
|
||
// rasterise `drawButtonFrame` against each of the 4 state times
|
||
// and verify the rect's centre pixel is red only at hover and
|
||
// transparent on every other state.
|
||
const s = makeFreshStore()
|
||
const btn = s.addButtonAsset('B', 100, 100)
|
||
s.enterButtonAssetEdit(btn.id)
|
||
const red = s.addAsset({
|
||
kind: 'rect',
|
||
name: 'R',
|
||
width: 100,
|
||
height: 100,
|
||
fill: '#ff0000',
|
||
})
|
||
const layer = s.addLayer(red.id)!
|
||
// Replace the default normal-state kf with a single hold-kf at hover.
|
||
layer.keyframes = [
|
||
{
|
||
id: 'k',
|
||
time: BUTTON_STATE_TIMES.hover,
|
||
props: {
|
||
x: 50, y: 50, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1,
|
||
},
|
||
easing: 'linear',
|
||
hold: true,
|
||
},
|
||
]
|
||
s.exitButtonAssetEdit()
|
||
const button = s.scene.assets.find((x) => x.id === btn.id) as ButtonAsset
|
||
|
||
function probe(stateTime: number): boolean {
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = 100
|
||
canvas.height = 100
|
||
const ctx = canvas.getContext('2d')!
|
||
drawButtonFrame(ctx, button, s.scene, stateTime, {})
|
||
const data = ctx.getImageData(50, 50, 1, 1).data
|
||
// Red + opaque = visible. Any other reading (alpha 0, or colour
|
||
// washed out) → not rendered.
|
||
return data[0] > 200 && data[3] > 200
|
||
}
|
||
expect(probe(BUTTON_STATE_TIMES.normal)).toBe(false)
|
||
expect(probe(BUTTON_STATE_TIMES.hover)).toBe(true)
|
||
expect(probe(BUTTON_STATE_TIMES.pressed)).toBe(false)
|
||
expect(probe(BUTTON_STATE_TIMES.disabled)).toBe(false)
|
||
})
|
||
|
||
test('clicking + on an empty button slot inserts the kf AND moves the editor to that state', async () => {
|
||
const s = makeFreshStore()
|
||
const btn = s.addButtonAsset('B', 100, 100)
|
||
s.enterButtonAssetEdit(btn.id)
|
||
// addLayer in button-edit seeds a single kf on the current state (normal).
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#abc' })
|
||
const layer = s.addLayer(a.id)!
|
||
expect(layer.keyframes).toHaveLength(1)
|
||
expect(s.activeButtonState).toBe('normal')
|
||
|
||
const handle = mountComponent(Timeline, {})
|
||
await nextTick()
|
||
// With a kf at normal, three empty slots remain: hover, pressed, disabled (in that order).
|
||
const adders = handle.host.querySelectorAll<HTMLButtonElement>('.kf-add')
|
||
expect(adders.length).toBe(3)
|
||
// Click the last one — the "disabled" slot.
|
||
adders[adders.length - 1].click()
|
||
await nextTick()
|
||
expect(layer.keyframes).toHaveLength(2)
|
||
expect(layer.keyframes.some((k) => Math.abs(k.time - BUTTON_STATE_TIMES.disabled) < 1e-6)).toBe(true)
|
||
expect(s.activeButtonState).toBe('disabled')
|
||
expect(s.playhead).toBe(BUTTON_STATE_TIMES.disabled)
|
||
s.exitButtonAssetEdit()
|
||
handle.unmount()
|
||
})
|
||
|
||
test('clicking + falls back to the NEXT kf when no prev exists, and moves the editor', async () => {
|
||
// Pins the prev ?? next branch: with a lone kf at disabled, the + slot
|
||
// at hover must copy from the disabled kf and park the editor on hover.
|
||
const s = makeFreshStore()
|
||
const btn = s.addButtonAsset('B', 100, 100)
|
||
s.enterButtonAssetEdit(btn.id)
|
||
s.setActiveButtonState('disabled')
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#abc' })
|
||
const layer = s.addLayer(a.id)!
|
||
expect(layer.keyframes).toHaveLength(1)
|
||
expect(layer.keyframes[0].time).toBe(BUTTON_STATE_TIMES.disabled)
|
||
|
||
const handle = mountComponent(Timeline, {})
|
||
await nextTick()
|
||
// Three empty slots: normal, hover, pressed (in that order).
|
||
const adders = handle.host.querySelectorAll<HTMLButtonElement>('.kf-add')
|
||
expect(adders.length).toBe(3)
|
||
// Second = hover.
|
||
adders[1].click()
|
||
await nextTick()
|
||
expect(layer.keyframes).toHaveLength(2)
|
||
expect(layer.keyframes.some((k) => Math.abs(k.time - BUTTON_STATE_TIMES.hover) < 1e-6)).toBe(true)
|
||
expect(s.activeButtonState).toBe('hover')
|
||
expect(s.playhead).toBe(BUTTON_STATE_TIMES.hover)
|
||
s.exitButtonAssetEdit()
|
||
handle.unmount()
|
||
})
|
||
})
|
||
|
||
suite('button asset — data model + edit mode', () => {
|
||
test('addButtonAsset creates asset with empty layers and hitArea', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addButtonAsset('Play', 160, 60)
|
||
expect(a.kind).toBe('button')
|
||
expect(a.name).toBe('Play')
|
||
expect(a.canvas.width).toBe(160)
|
||
expect(a.canvas.height).toBe(60)
|
||
expect(a.layers).toHaveLength(0)
|
||
expect(a.hitArea).toHaveLength(0)
|
||
})
|
||
|
||
test('enter/exit button edit sets mode=states and resets on exit', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addButtonAsset('B')
|
||
s.enterButtonAssetEdit(a.id)
|
||
expect(s.activeButtonAssetId).toBe(a.id)
|
||
expect(s.activeButtonEditMode).toBe('states')
|
||
expect(s.activeButtonState).toBe('normal')
|
||
s.setActiveButtonState('hover')
|
||
expect(s.activeButtonState).toBe('hover')
|
||
s.exitButtonAssetEdit()
|
||
expect(s.activeButtonAssetId).toBe(null)
|
||
expect(s.activeButtonEditMode).toBe(null)
|
||
expect(s.activeButtonState).toBe(null)
|
||
})
|
||
|
||
test('state scrubber syncs playhead to kf-time for mutations', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addButtonAsset('B')
|
||
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 40, height: 20, fill: '#f00' })
|
||
s.enterButtonAssetEdit(a.id)
|
||
const layer = s.addLayer(rect.id)!
|
||
// BUTTON_STATE_TIMES is { normal: 1, hover: 2, pressed: 3, disabled: 4 }
|
||
// so a layer added inside the button edit lands on the "normal" slot.
|
||
expect(layer.keyframes[0].time).toBe(1)
|
||
s.setActiveButtonState('hover')
|
||
expect(s.playhead).toBe(2)
|
||
s.upsertKeyframe(layer.id, s.playhead, { scaleX: 2, scaleY: 2 })
|
||
expect(layer.keyframes.some((k) => Math.abs(k.time - 2) < 1e-6)).toBe(true)
|
||
s.setActiveButtonState('pressed')
|
||
expect(s.playhead).toBe(3)
|
||
s.setActiveButtonState('disabled')
|
||
expect(s.playhead).toBe(4)
|
||
})
|
||
|
||
test('mode toggle: hit parks playhead; returning to states restores it', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addButtonAsset('B')
|
||
s.enterButtonAssetEdit(a.id)
|
||
s.setActiveButtonState('pressed')
|
||
expect(s.playhead).toBe(3)
|
||
s.setButtonEditMode('hit')
|
||
expect(s.activeButtonEditMode).toBe('hit')
|
||
// Hit mode onion-skins the "normal" state (time=1), so the playhead
|
||
// is parked there — not at 0.
|
||
expect(s.playhead).toBe(1)
|
||
// Selected state is preserved across the hit trip.
|
||
expect(s.activeButtonState).toBe('pressed')
|
||
s.setButtonEditMode('states')
|
||
expect(s.playhead).toBe(3)
|
||
})
|
||
|
||
test('addLayer in hit mode returns null — no leak into states', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addButtonAsset('B')
|
||
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 40, height: 20, fill: '#f00' })
|
||
s.enterButtonAssetEdit(a.id)
|
||
s.setButtonEditMode('hit')
|
||
const result = s.addLayer(rect.id)
|
||
expect(result).toBe(null)
|
||
const asset = s.scene.assets.find((x) => x.id === a.id)!
|
||
expect(asset.kind === 'button' ? asset.layers.length : -1).toBe(0)
|
||
})
|
||
|
||
test('hit shape CRUD stays isolated from layers', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addButtonAsset('B')
|
||
s.addButtonHitShape(a.id, { kind: 'rect', x: 0, y: 0, width: 40, height: 20 })
|
||
s.addButtonHitShape(a.id, { kind: 'circle', x: 50, y: 50, radius: 10 })
|
||
const asset = s.scene.assets.find((x) => x.id === a.id)!
|
||
expect(asset.kind === 'button' ? asset.hitArea.length : -1).toBe(2)
|
||
expect(asset.kind === 'button' ? asset.layers.length : -1).toBe(0)
|
||
s.removeButtonHitShape(a.id, 0)
|
||
expect(asset.kind === 'button' ? asset.hitArea.length : -1).toBe(1)
|
||
})
|
||
|
||
test('polygon hit shapes persist and update via updateButtonHitShape', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addButtonAsset('B')
|
||
s.addButtonHitShape(a.id, {
|
||
kind: 'polygon',
|
||
points: [
|
||
{ x: 0, y: 0 },
|
||
{ x: 100, y: 0 },
|
||
{ x: 50, y: 80 },
|
||
],
|
||
})
|
||
const get = () => {
|
||
const asset = s.scene.assets.find((x) => x.id === a.id)
|
||
if (!asset || asset.kind !== 'button') return null
|
||
return asset.hitArea[0]
|
||
}
|
||
const sh0 = get()
|
||
expect(sh0 && sh0.kind === 'polygon' ? sh0.points.length : -1).toBe(3)
|
||
s.updateButtonHitShape(a.id, 0, {
|
||
points: [
|
||
{ x: 1, y: 1 },
|
||
{ x: 101, y: 1 },
|
||
{ x: 51, y: 81 },
|
||
{ x: 2, y: 82 },
|
||
],
|
||
} as Partial<HitShape>)
|
||
const sh1 = get()
|
||
expect(sh1 && sh1.kind === 'polygon' ? sh1.points.length : -1).toBe(4)
|
||
})
|
||
})
|
||
|
||
suite('button asset — state transition runtime', () => {
|
||
// Regression: button state changes used to snap instantly between
|
||
// the normal / hover / pressed / disabled slots. `setState` now
|
||
// records a transition whose sample time lerps between the
|
||
// corresponding `BUTTON_STATE_TIMES` slots over
|
||
// `BUTTON_STATE_TRANSITION_MS`. `getEffectiveTime` is what the
|
||
// renderer reads; it must return an intermediate time during the
|
||
// transition and snap to the target once finished.
|
||
|
||
async function importRt() {
|
||
const mod = await import('@/composables/useButtonRuntime')
|
||
return {
|
||
rt: mod.useButtonRuntime(),
|
||
DURATION: mod.BUTTON_STATE_TRANSITION_MS,
|
||
}
|
||
}
|
||
|
||
test('getEffectiveTime returns the target slot when no transition is running', async () => {
|
||
const { rt } = await importRt()
|
||
rt.clear('btn-a')
|
||
expect(rt.getEffectiveTime('btn-a', false)).toBe(BUTTON_STATE_TIMES.normal)
|
||
rt.clear('btn-a')
|
||
// Fast-path: disabled layers report disabled regardless of the
|
||
// runtime state or transitions.
|
||
expect(rt.getEffectiveTime('btn-a', true)).toBe(BUTTON_STATE_TIMES.disabled)
|
||
rt.clear('btn-a')
|
||
})
|
||
|
||
test('setState kicks off a transition; mid-flight time lies between slots', async () => {
|
||
const { rt, DURATION } = await importRt()
|
||
rt.clear('btn-b')
|
||
rt.setState('btn-b', 'hover')
|
||
// Immediately after setState, time is ~fromTime (normal=1) and
|
||
// approaching toTime (hover=2).
|
||
const t0 = rt.getEffectiveTime('btn-b', false)
|
||
expect(t0).toBeGreaterThan(BUTTON_STATE_TIMES.normal - 0.001)
|
||
expect(t0).toBeLessThan(BUTTON_STATE_TIMES.hover)
|
||
// After half the duration the time should be around halfway.
|
||
await new Promise((r) => setTimeout(r, DURATION / 2))
|
||
const tMid = rt.getEffectiveTime('btn-b', false)
|
||
expect(tMid).toBeGreaterThan(BUTTON_STATE_TIMES.normal)
|
||
expect(tMid).toBeLessThan(BUTTON_STATE_TIMES.hover)
|
||
// After the full duration (+ slack) the transition resolves to
|
||
// the target slot exactly.
|
||
await new Promise((r) => setTimeout(r, DURATION + 40))
|
||
expect(rt.getEffectiveTime('btn-b', false)).toBe(BUTTON_STATE_TIMES.hover)
|
||
rt.clear('btn-b')
|
||
})
|
||
|
||
test('mid-transition setState rebases from the live sample time', async () => {
|
||
// Regression: if hover → pressed flips in-flight from a previous
|
||
// normal → hover transition, the new transition should start at
|
||
// the *current* intermediate sample time instead of snapping
|
||
// back to the old `from` slot. Otherwise a quick hover+press
|
||
// produces a visible jump.
|
||
const { rt, DURATION } = await importRt()
|
||
rt.clear('btn-c')
|
||
rt.setState('btn-c', 'hover')
|
||
await new Promise((r) => setTimeout(r, DURATION / 2))
|
||
// At this point the effective time is roughly halfway between
|
||
// normal (1) and hover (2) — somewhere around 1.4–1.6.
|
||
const midway = rt.getEffectiveTime('btn-c', false)
|
||
rt.setState('btn-c', 'pressed')
|
||
// The freshly-started transition must begin at or just past the
|
||
// midway sample, not drop back toward normal (1).
|
||
const after = rt.getEffectiveTime('btn-c', false)
|
||
// Allow a small drift forward because `setTimeout` and the
|
||
// subsequent assignment both burn a few ms — the important
|
||
// thing is we didn't regress into the [1, midway) interval.
|
||
expect(after).toBeGreaterThan(midway - 0.01)
|
||
rt.clear('btn-c')
|
||
})
|
||
|
||
test('no-op setState (target === current) does not start a transition', async () => {
|
||
const { rt } = await importRt()
|
||
rt.clear('btn-d')
|
||
// Currently normal; asking for normal again is a no-op.
|
||
rt.setState('btn-d', 'normal')
|
||
expect(rt.getEffectiveTime('btn-d', false)).toBe(BUTTON_STATE_TIMES.normal)
|
||
rt.clear('btn-d')
|
||
})
|
||
|
||
test('store actionRuntime.setButtonEnabled toggles useButtonRuntime disabled state', async () => {
|
||
const mod = await import('@/composables/useButtonRuntime')
|
||
const btnRt = mod.useButtonRuntime()
|
||
const s = makeFreshStore()
|
||
const btn = s.addButtonAsset('B', 100, 40)
|
||
const layer = s.addLayer(btn.id)!
|
||
btnRt.clear(layer.id)
|
||
const actionRt = s.actionRuntime()
|
||
expect(typeof actionRt.setButtonEnabled).toBe('function')
|
||
actionRt.setButtonEnabled!(layer.id, false)
|
||
expect(btnRt.getEffective(layer.id, false)).toBe('disabled')
|
||
actionRt.setButtonEnabled!(layer.id, true)
|
||
expect(btnRt.getEffective(layer.id, false)).toBe('normal')
|
||
btnRt.clear(layer.id)
|
||
})
|
||
})
|
||
|
||
suite('button asset — thumbnail rendering', () => {
|
||
// Regression: renderButtonThumbnail used to pass images={} and any image
|
||
// layer inside a button produced a fully-transparent PNG, making
|
||
// sidebar thumbnails appear grey. Shape layers should always render
|
||
// visible pixels; image layers render when an images map is supplied.
|
||
|
||
function pngIsNonBlank(dataUrl: string): boolean {
|
||
// Data URL `data:image/png;base64,...` — non-trivial PNG means the
|
||
// renderer produced pixels. We accept anything over ~200 bytes of
|
||
// base64 payload, since an all-transparent 200×80-scaled canvas
|
||
// compresses to ~220 bytes (IHDR + tiny IDAT). A rect layer that
|
||
// actually paints fill colour balloons the IDAT noticeably.
|
||
const i = dataUrl.indexOf(',')
|
||
return i > 0 && dataUrl.length - i - 1 > 260
|
||
}
|
||
|
||
test('returns null when the button has no layers', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addButtonAsset('B', 200, 80)
|
||
const url = renderButtonThumbnail(a, s.scene)
|
||
expect(url).toBeNull()
|
||
})
|
||
|
||
test('renders shape layers without an images map', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addButtonAsset('B', 200, 80)
|
||
const rect = s.addAsset({
|
||
kind: 'rect',
|
||
name: 'R',
|
||
width: 200,
|
||
height: 80,
|
||
fill: '#6ea8ff',
|
||
})
|
||
s.enterButtonAssetEdit(a.id)
|
||
s.addLayer(rect.id)!
|
||
s.exitButtonAssetEdit()
|
||
const button = s.scene.assets.find((x) => x.id === a.id)!
|
||
const url = renderButtonThumbnail(
|
||
button as Extract<typeof button, { kind: 'button' }>,
|
||
s.scene,
|
||
)
|
||
expect(url).toBeTruthy()
|
||
// Data URL should be a well-formed PNG with actual content.
|
||
expect((url ?? '').startsWith('data:image/png;base64,')).toBe(true)
|
||
expect(pngIsNonBlank(url!)).toBe(true)
|
||
})
|
||
|
||
test('accepts a custom images map for image-kind layers', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addButtonAsset('B', 100, 40)
|
||
const img = s.addAsset({
|
||
kind: 'image',
|
||
name: 'I',
|
||
src: 'data:image/png;base64,iVBORw0KGgo=', // placeholder
|
||
width: 100,
|
||
height: 40,
|
||
})
|
||
s.enterButtonAssetEdit(a.id)
|
||
s.addLayer(img.id)!
|
||
s.exitButtonAssetEdit()
|
||
// Build a trivial HTMLImageElement stand-in. We don't wait for it to
|
||
// load — the point is just to confirm the function accepts the map
|
||
// without throwing, and that the button is not null.
|
||
const fake = new Image()
|
||
fake.width = 100
|
||
fake.height = 40
|
||
const button = s.scene.assets.find((x) => x.id === a.id)!
|
||
const url = renderButtonThumbnail(
|
||
button as Extract<typeof button, { kind: 'button' }>,
|
||
s.scene,
|
||
80,
|
||
{ [img.id]: fake },
|
||
)
|
||
expect(url).toBeTruthy()
|
||
expect((url ?? '').startsWith('data:image/png;base64,')).toBe(true)
|
||
})
|
||
})
|
||
|
||
suite('button asset — keyframes snap to state times', () => {
|
||
// Regression: keyframes inside a button-asset timeline must only
|
||
// exist at the 4 state times (BUTTON_STATE_TIMES = normal=1,
|
||
// hover=2, pressed=3, disabled=4). Any code path that places or
|
||
// moves a keyframe — upsertKeyframe, setKeyframeTime,
|
||
// duplicateKeyframe — snaps the time onto the nearest state. Free
|
||
// placement outside the 4 slots is disallowed.
|
||
|
||
function setupButton() {
|
||
const s = makeFreshStore()
|
||
const btn = s.addButtonAsset('B', 200, 80)
|
||
s.enterButtonAssetEdit(btn.id)
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 200, height: 80, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
// addLayer already put a keyframe at BUTTON_STATE_TIMES.normal (=1).
|
||
return { s, l }
|
||
}
|
||
|
||
test('upsertKeyframe snaps the requested time to the nearest state', () => {
|
||
const { s, l } = setupButton()
|
||
// 1.4 is closer to state 1 than to state 2 → should land at 1
|
||
// (merging with the existing one, rather than creating a new off-state kf).
|
||
s.upsertKeyframe(l.id, 1.4, { x: 10 })
|
||
expect(l.keyframes.every((k) => BUTTON_STATE_TIMES.normal === k.time || BUTTON_STATE_TIMES.hover === k.time || BUTTON_STATE_TIMES.pressed === k.time || BUTTON_STATE_TIMES.disabled === k.time)).toBe(true)
|
||
// 1.6 is closer to state 2 → new keyframe lands at 2
|
||
s.upsertKeyframe(l.id, 1.6, { x: 20 })
|
||
expect(l.keyframes.find((k) => Math.abs(k.time - 2) < 1e-6)).toBeTruthy()
|
||
})
|
||
|
||
test('setKeyframeTime snaps drag targets onto state times', () => {
|
||
const { s, l } = setupButton()
|
||
const kf = l.keyframes[0]
|
||
expect(kf.time).toBe(BUTTON_STATE_TIMES.normal)
|
||
// Drag "toward" hover but not all the way — 1.8 is closer to 2 → snaps to 2.
|
||
s.setKeyframeTime(l.id, kf.id, 1.8)
|
||
expect(kf.time).toBe(BUTTON_STATE_TIMES.hover)
|
||
// And 2.9 snaps to pressed.
|
||
s.setKeyframeTime(l.id, kf.id, 2.9)
|
||
expect(kf.time).toBe(BUTTON_STATE_TIMES.pressed)
|
||
})
|
||
|
||
test('setKeyframeTime refuses to land on an already-occupied state', () => {
|
||
const { s, l } = setupButton()
|
||
// Existing kf at state 1. Create a second at state 2.
|
||
s.upsertKeyframe(l.id, BUTTON_STATE_TIMES.hover, { x: 99 })
|
||
const kfAtHover = l.keyframes.find((k) => Math.abs(k.time - 2) < 1e-6)!
|
||
// Drag the hover kf toward normal. State 1 is already occupied — refuse.
|
||
s.setKeyframeTime(l.id, kfAtHover.id, 1.1)
|
||
expect(kfAtHover.time).toBe(BUTTON_STATE_TIMES.hover)
|
||
})
|
||
|
||
test('duplicateKeyframe picks the nearest free state slot', () => {
|
||
const { s, l } = setupButton()
|
||
// Only state 1 is occupied; duplicate should land on the next
|
||
// closest free slot — state 2 (hover).
|
||
const sourceId = l.keyframes[0].id
|
||
const newId = s.duplicateKeyframe(l.id, sourceId)!
|
||
expect(newId).toBeTruthy()
|
||
const dup = l.keyframes.find((k) => k.id === newId)!
|
||
expect(dup.time).toBe(BUTTON_STATE_TIMES.hover)
|
||
})
|
||
|
||
test('duplicateKeyframe returns null when every state slot is taken', () => {
|
||
const { s, l } = setupButton()
|
||
s.upsertKeyframe(l.id, BUTTON_STATE_TIMES.hover, { x: 2 })
|
||
s.upsertKeyframe(l.id, BUTTON_STATE_TIMES.pressed, { x: 3 })
|
||
s.upsertKeyframe(l.id, BUTTON_STATE_TIMES.disabled, { x: 4 })
|
||
const sourceId = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-6)!.id
|
||
const newId = s.duplicateKeyframe(l.id, sourceId)
|
||
expect(newId).toBe(null)
|
||
})
|
||
|
||
test('on the main timeline, snapping is NOT applied (free placement)', () => {
|
||
const s = makeFreshStore()
|
||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||
const l = s.addLayer(a.id)!
|
||
s.upsertKeyframe(l.id, 1.4, { x: 5 })
|
||
// No button-edit context → the kf lives at the exact requested time.
|
||
expect(l.keyframes.some((k) => Math.abs(k.time - 1.4) < 1e-6)).toBe(true)
|
||
})
|
||
})
|
||
|
||
suite('button asset — mask rendering', () => {
|
||
// Regression: masks inside buttons used to be ignored by every render
|
||
// path — the button branch in drawLayers called drawLayers recursively
|
||
// without the drawMaskedGroup split, so `isMask` layers drew as normal
|
||
// content. That made mask-preview / thumbnail / main-stage / exported
|
||
// runtime all show the mask shape instead of clipping. All paths now
|
||
// route button layers through drawMaskedGroup; these tests pin that.
|
||
function setupMaskedButton(mode: 'positive' | 'negative') {
|
||
const s = makeFreshStore()
|
||
const btn = s.addButtonAsset('B', 100, 100)
|
||
s.enterButtonAssetEdit(btn.id)
|
||
// Content: full-canvas red rect. addLayer already keyframes it at
|
||
// (canvas.width/2, canvas.height/2) at BUTTON_STATE_TIMES.normal,
|
||
// so we don't need to set anything else.
|
||
const red = s.addAsset({
|
||
kind: 'rect',
|
||
name: 'R',
|
||
width: 100,
|
||
height: 100,
|
||
fill: '#ff0000',
|
||
})
|
||
s.addLayer(red.id)!
|
||
// Mask: small circle at canvas center.
|
||
const mask = s.addAsset({
|
||
kind: 'circle',
|
||
name: 'M',
|
||
radius: 20,
|
||
fill: '#ffffff',
|
||
})
|
||
const maskLayer = s.addLayer(mask.id)!
|
||
s.setLayerMask(maskLayer.id, true, mode)
|
||
s.exitButtonAssetEdit()
|
||
const button = s.scene.assets.find((x) => x.id === btn.id) as ButtonAsset
|
||
return { store: s, button }
|
||
}
|
||
|
||
test('drawButtonFrame applies a positive mask inside the button', () => {
|
||
const { store, button } = setupMaskedButton('positive')
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = 100
|
||
canvas.height = 100
|
||
const ctx = canvas.getContext('2d')!
|
||
drawButtonFrame(ctx, button, store.scene, BUTTON_STATE_TIMES.normal, {})
|
||
const inside = ctx.getImageData(50, 50, 1, 1).data
|
||
const outside = ctx.getImageData(5, 5, 1, 1).data
|
||
expect(inside[0] > 200).toBe(true)
|
||
expect(inside[3] > 200).toBe(true)
|
||
expect(outside[3]).toBe(0)
|
||
})
|
||
|
||
test('drawButtonFrame applies a negative mask inside the button', () => {
|
||
const { store, button } = setupMaskedButton('negative')
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = 100
|
||
canvas.height = 100
|
||
const ctx = canvas.getContext('2d')!
|
||
drawButtonFrame(ctx, button, store.scene, BUTTON_STATE_TIMES.normal, {})
|
||
const inside = ctx.getImageData(50, 50, 1, 1).data
|
||
const outside = ctx.getImageData(5, 5, 1, 1).data
|
||
// Inside mask: punched out
|
||
expect(inside[3]).toBe(0)
|
||
// Outside mask: red content still there
|
||
expect(outside[0] > 200).toBe(true)
|
||
expect(outside[3] > 200).toBe(true)
|
||
})
|
||
|
||
test('drawSceneFrame applies button-internal masks when the button is placed on the main stage', () => {
|
||
const { store, button } = setupMaskedButton('positive')
|
||
// Size the main scene to match the button exactly so coordinates line up.
|
||
store.scene.canvas.width = 100
|
||
store.scene.canvas.height = 100
|
||
store.scene.canvas.background = 'transparent'
|
||
// Place the button as a layer on the main stage, centered.
|
||
store.addLayer(button.id)!
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = 100
|
||
canvas.height = 100
|
||
const ctx = canvas.getContext('2d')!
|
||
drawSceneFrame(ctx, store.scene, 0, {})
|
||
const inside = ctx.getImageData(50, 50, 1, 1).data
|
||
const outside = ctx.getImageData(5, 5, 1, 1).data
|
||
// Inside the circular mask: red content visible
|
||
expect(inside[0] > 200).toBe(true)
|
||
expect(inside[3] > 200).toBe(true)
|
||
// Outside the circle: masked away (alpha 0)
|
||
expect(outside[3]).toBe(0)
|
||
})
|
||
})
|
||
|
||
suite('i18n — track-menu rename/delete labels', () => {
|
||
// Regression: the script-track context menu used to surface the raw
|
||
// keys ("timeline.renameScriptTrack" / ".deleteScriptTrack") because
|
||
// the i18n messages were missing. Guard every new key so a future
|
||
// rename of the JSON/TS messages file doesn't silently break the menu.
|
||
test('renameScriptTrack is translated', () => {
|
||
const v = i18nT('timeline.renameScriptTrack')
|
||
expect(v).toBeTruthy()
|
||
expect(v === 'timeline.renameScriptTrack').toBe(false)
|
||
})
|
||
|
||
test('deleteScriptTrack is translated', () => {
|
||
const v = i18nT('timeline.deleteScriptTrack')
|
||
expect(v).toBeTruthy()
|
||
expect(v === 'timeline.deleteScriptTrack').toBe(false)
|
||
})
|
||
|
||
test('renameScriptTrackDialog.title + .confirm are translated', () => {
|
||
const title = i18nT('timeline.renameScriptTrackDialog.title')
|
||
const confirm = i18nT('timeline.renameScriptTrackDialog.confirm')
|
||
expect(title === 'timeline.renameScriptTrackDialog.title').toBe(false)
|
||
expect(confirm === 'timeline.renameScriptTrackDialog.confirm').toBe(false)
|
||
})
|
||
|
||
test('renameLayer + renameClipTrack are translated', () => {
|
||
expect(i18nT('timeline.renameLayer') === 'timeline.renameLayer').toBe(false)
|
||
expect(i18nT('timeline.renameClipTrack') === 'timeline.renameClipTrack').toBe(false)
|
||
})
|
||
|
||
test('renameLayerDialog / renameClipTrackDialog titles + confirms are translated', () => {
|
||
expect(i18nT('timeline.renameLayerDialog.title') === 'timeline.renameLayerDialog.title').toBe(false)
|
||
expect(i18nT('timeline.renameLayerDialog.confirm') === 'timeline.renameLayerDialog.confirm').toBe(false)
|
||
expect(i18nT('timeline.renameClipTrackDialog.title') === 'timeline.renameClipTrackDialog.title').toBe(false)
|
||
expect(i18nT('timeline.renameClipTrackDialog.confirm') === 'timeline.renameClipTrackDialog.confirm').toBe(false)
|
||
})
|
||
|
||
test('sidebar noClips / noButtons empty-states both suggest "yet"', () => {
|
||
// Regression: the clip library used to read "No clips" while the
|
||
// button library read "No buttons yet". Minor inconsistency that
|
||
// the user called out — both should suggest "more coming", not
|
||
// "there will never be any".
|
||
expect(i18nT('sidebar.noClips')).toContain('yet')
|
||
expect(i18nT('sidebar.noButtons')).toContain('yet')
|
||
})
|
||
|
||
test('holdDisabledHint key is gone (held kfs keep easing now)', () => {
|
||
// Regression: "Still frame — easing disabled" used to appear in
|
||
// the keyframe context menu and the easing buttons were grayed
|
||
// out. A held kf's easing still matters for the segment INTO it
|
||
// from the previous keyframe, so the restriction was wrong.
|
||
// Pin that the note is no longer emitted.
|
||
const v = i18nT('timeline.holdDisabledHint')
|
||
expect(v === 'timeline.holdDisabledHint').toBe(true)
|
||
})
|
||
|
||
test('app.saveProject tooltip mentions the Ctrl/Cmd+S shortcut', () => {
|
||
// Regression: Cmd/Ctrl+S was added as a keyboard save shortcut
|
||
// alongside the dirty-state dot. Surface it in the tooltip so
|
||
// the shortcut is discoverable; assert both the idle and the
|
||
// unsaved-changes variant stay in sync with the handler.
|
||
expect(i18nT('app.saveProject')).toContain('Ctrl/Cmd+S')
|
||
expect(i18nT('app.saveProjectDirty')).toContain('Ctrl/Cmd+S')
|
||
expect(i18nT('app.saveProjectDirty')).toContain('unsaved')
|
||
})
|
||
})
|
||
|
||
suite('timeline — scrub-handle obscured logic', () => {
|
||
// Regression: the handle used to be drawn over the toggle row when the
|
||
// playhead scrolled off the left edge, *or* be half-clipped at t=0
|
||
// depending on a static z-index. Timeline.vue now flips z-index based
|
||
// on whether the playhead is still in the visible ruler area. The
|
||
// rule is `scrollLeft > pxFromTime(playhead)`. Pin that rule so a
|
||
// future refactor of the computed doesn't accidentally break either
|
||
// direction.
|
||
const obscured = (scrollLeft: number, playheadPx: number) =>
|
||
scrollLeft > playheadPx
|
||
|
||
test('t=0 with no horizontal scroll → handle is visible (not obscured)', () => {
|
||
expect(obscured(0, 0)).toBe(false)
|
||
})
|
||
|
||
test('playhead right of the scroll offset → still visible', () => {
|
||
// User scrolled 120px right but the playhead is way out at 800px in
|
||
// ruler coordinates — the handle is comfortably in the visible ruler.
|
||
expect(obscured(120, 800)).toBe(false)
|
||
})
|
||
|
||
test('playhead scrolled left behind the name-spacer → obscured', () => {
|
||
// Playhead at t=0 (0px), scroll at 200px → playhead is off-screen
|
||
// to the left of the visible ruler → hide behind name-spacer.
|
||
expect(obscured(200, 0)).toBe(true)
|
||
})
|
||
})
|
||
}
|