bak
This commit is contained in:
1 parent
e4aa0dce86
commit
cc6b11e61d
23 files changed
+2047
-524
No files matched your search
@@ -1,3 +1,4 @@
|
||||
import { createApp, h, nextTick, type Component, type Slot } from 'vue'
|
||||
import { useSceneStore } from '@/stores/scene'
|
||||
import { sampleLayer, lerpColor } from '@/lib/interpolate'
|
||||
import { snapToCandidates } from '@/lib/timelineSnap'
|
||||
@@ -7,8 +8,48 @@ import {
|
||||
generateSceneScript,
|
||||
} from '@/lib/exportPlayer'
|
||||
import { ProjectFileError, parseProject, serializeProject } from '@/lib/projectFile'
|
||||
import { createHistory } from '@/composables/useHistory'
|
||||
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 { expect, suite, test } from './testRunner'
|
||||
|
||||
interface MountedHandle {
|
||||
host: HTMLDivElement
|
||||
app: ReturnType<typeof createApp>
|
||||
unmount: () => void
|
||||
}
|
||||
|
||||
function mountComponent(
|
||||
component: Component,
|
||||
props: Record<string, unknown>,
|
||||
slots?: Record<string, Slot>,
|
||||
): MountedHandle {
|
||||
const host = document.createElement('div')
|
||||
host.setAttribute('data-test-host', '')
|
||||
document.body.appendChild(host)
|
||||
const app = createApp({
|
||||
render() {
|
||||
return h(component, props, slots)
|
||||
},
|
||||
})
|
||||
app.mount(host)
|
||||
return {
|
||||
host,
|
||||
app,
|
||||
unmount() {
|
||||
app.unmount()
|
||||
host.remove()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function makeFreshStore() {
|
||||
const s = useSceneStore()
|
||||
s.$reset()
|
||||
@@ -96,6 +137,43 @@ export function registerAllTests(): void {
|
||||
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' })
|
||||
@@ -192,6 +270,21 @@ export function registerAllTests(): void {
|
||||
expect(dup.time).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
test('setKeyframeHold toggles hold flag', async () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'Hold', width: 50, height: 50, fill: '#ddaa44' })
|
||||
const l = s.addLayer(a.id)
|
||||
s.upsertKeyframe(l.id, 1, { x: 400 })
|
||||
await tick()
|
||||
const k = l.keyframes[l.keyframes.length - 1]
|
||||
s.setKeyframeHold(l.id, k.id, true)
|
||||
await tick()
|
||||
expect(k.hold).toBe(true)
|
||||
s.setKeyframeHold(l.id, k.id, false)
|
||||
await tick()
|
||||
expect(k.hold).toBe(undefined)
|
||||
})
|
||||
|
||||
test('setKeyframeEasing updates easing field', async () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'Ease', width: 50, height: 50, fill: '#88cc44' })
|
||||
@@ -280,6 +373,21 @@ export function registerAllTests(): void {
|
||||
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' })
|
||||
@@ -503,6 +611,381 @@ export function registerAllTests(): void {
|
||||
})
|
||||
})
|
||||
|
||||
suite('history — undo/redo', () => {
|
||||
test('canUndo starts false; becomes true after a committed mutation', async () => {
|
||||
const s = makeFreshStore()
|
||||
const h = createHistory(s, { debounceMs: 10 })
|
||||
h.start()
|
||||
expect(h.canUndo.value).toBe(false)
|
||||
const a = s.addAsset({ kind: 'rect', name: 'H1', width: 10, height: 10, fill: '#000' })
|
||||
s.addLayer(a.id)
|
||||
await tick(60)
|
||||
expect(h.canUndo.value).toBe(true)
|
||||
h.stop()
|
||||
})
|
||||
|
||||
test('undo reverts a mutation, redo reapplies', async () => {
|
||||
const s = makeFreshStore()
|
||||
const h = createHistory(s, { debounceMs: 10 })
|
||||
h.start()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'H2', width: 10, height: 10, fill: '#000' })
|
||||
s.addLayer(a.id)
|
||||
await tick(60)
|
||||
expect(s.scene.layers).toHaveLength(1)
|
||||
h.undo()
|
||||
await tick(20)
|
||||
expect(s.scene.layers).toHaveLength(0)
|
||||
expect(h.canRedo.value).toBe(true)
|
||||
h.redo()
|
||||
await tick(20)
|
||||
expect(s.scene.layers).toHaveLength(1)
|
||||
h.stop()
|
||||
})
|
||||
|
||||
test('rapid mutations within debounce window collapse into one step', async () => {
|
||||
const s = makeFreshStore()
|
||||
const h = createHistory(s, { debounceMs: 50 })
|
||||
h.start()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'H3', width: 10, height: 10, fill: '#000' })
|
||||
const l = s.addLayer(a.id)
|
||||
s.upsertKeyframe(l.id, 1, { x: 100 })
|
||||
s.upsertKeyframe(l.id, 1, { x: 200 })
|
||||
s.upsertKeyframe(l.id, 1, { x: 300 })
|
||||
await tick(120)
|
||||
h.undo()
|
||||
await tick(20)
|
||||
expect(s.scene.layers).toHaveLength(0)
|
||||
h.stop()
|
||||
})
|
||||
|
||||
test('mutation after undo drops the redo branch', async () => {
|
||||
const s = makeFreshStore()
|
||||
const h = createHistory(s, { debounceMs: 10 })
|
||||
h.start()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'H4', width: 10, height: 10, fill: '#000' })
|
||||
s.addLayer(a.id)
|
||||
await tick(40)
|
||||
h.undo()
|
||||
await tick(20)
|
||||
expect(h.canRedo.value).toBe(true)
|
||||
const b = s.addAsset({ kind: 'circle', name: 'H4c', radius: 10, fill: '#fff' })
|
||||
s.addLayer(b.id)
|
||||
await tick(40)
|
||||
expect(h.canRedo.value).toBe(false)
|
||||
h.stop()
|
||||
})
|
||||
|
||||
test('resetTo clears history and seeds initial snapshot', async () => {
|
||||
const s = makeFreshStore()
|
||||
const h = createHistory(s, { debounceMs: 10 })
|
||||
h.start()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'H5', width: 10, height: 10, fill: '#000' })
|
||||
s.addLayer(a.id)
|
||||
await tick(40)
|
||||
expect(h.canUndo.value).toBe(true)
|
||||
h.resetTo(s.scene)
|
||||
expect(h.canUndo.value).toBe(false)
|
||||
expect(h.canRedo.value).toBe(false)
|
||||
h.stop()
|
||||
})
|
||||
|
||||
test('undo clamps playhead when target scene has shorter duration', async () => {
|
||||
const s = makeFreshStore()
|
||||
const h = createHistory(s, { debounceMs: 10 })
|
||||
h.start()
|
||||
s.setDuration(3)
|
||||
await tick(40)
|
||||
s.setDuration(10)
|
||||
s.setPlayhead(8)
|
||||
await tick(40)
|
||||
h.undo()
|
||||
await tick(20)
|
||||
expect(s.scene.duration).toBe(3)
|
||||
expect(s.playhead).toBe(3)
|
||||
h.stop()
|
||||
})
|
||||
|
||||
test('max history cap drops oldest entries', async () => {
|
||||
const s = makeFreshStore()
|
||||
const h = createHistory(s, { debounceMs: 10, max: 3 })
|
||||
h.start()
|
||||
s.setDuration(2)
|
||||
await tick(30)
|
||||
s.setDuration(3)
|
||||
await tick(30)
|
||||
s.setDuration(4)
|
||||
await tick(30)
|
||||
s.setDuration(5)
|
||||
await tick(30)
|
||||
h.undo()
|
||||
h.undo()
|
||||
h.undo()
|
||||
await tick(20)
|
||||
expect(s.scene.duration).toBeGreaterThan(2)
|
||||
h.stop()
|
||||
})
|
||||
})
|
||||
|
||||
suite('ui — Modal', () => {
|
||||
test('renders nothing when modelValue is false', async () => {
|
||||
const handle = mountComponent(Modal, { modelValue: false, title: 'X' })
|
||||
await nextTick()
|
||||
expect(document.querySelector('[role="dialog"]')).toBeNull()
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('teleports backdrop + dialog to body when open, renders title', async () => {
|
||||
const handle = mountComponent(Modal, { modelValue: true, title: 'Hallo Modal' })
|
||||
await nextTick()
|
||||
const dialog = document.querySelector('[role="dialog"]') as HTMLElement | null
|
||||
expect(dialog).toBeTruthy()
|
||||
expect(dialog!.textContent?.includes('Hallo Modal')).toBe(true)
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('Esc triggers update:modelValue false', async () => {
|
||||
let latest: boolean | null = null
|
||||
const handle = mountComponent(Modal, {
|
||||
modelValue: true,
|
||||
title: 'X',
|
||||
'onUpdate:modelValue': (v: boolean) => {
|
||||
latest = v
|
||||
},
|
||||
})
|
||||
await nextTick()
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
||||
await nextTick()
|
||||
expect(latest).toBe(false)
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('Esc does nothing when closeOnEsc is false', async () => {
|
||||
let latest: boolean | null = null
|
||||
const handle = mountComponent(Modal, {
|
||||
modelValue: true,
|
||||
title: 'X',
|
||||
closeOnEsc: false,
|
||||
'onUpdate:modelValue': (v: boolean) => {
|
||||
latest = v
|
||||
},
|
||||
})
|
||||
await nextTick()
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
||||
await nextTick()
|
||||
expect(latest).toBeNull()
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('dismissable=false hides close button and swallows Esc', async () => {
|
||||
let latest: boolean | null = null
|
||||
const handle = mountComponent(Modal, {
|
||||
modelValue: true,
|
||||
title: 'Busy',
|
||||
dismissable: false,
|
||||
'onUpdate:modelValue': (v: boolean) => {
|
||||
latest = v
|
||||
},
|
||||
})
|
||||
await nextTick()
|
||||
const btn = document.querySelector('[role="dialog"] header button')
|
||||
expect(btn).toBeNull()
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
||||
await nextTick()
|
||||
expect(latest).toBeNull()
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('renders footer slot content', async () => {
|
||||
const handle = mountComponent(
|
||||
Modal,
|
||||
{ modelValue: true, title: 'X' },
|
||||
{
|
||||
footer: () => [h('button', { class: 'my-footer-btn' }, 'Fertig')],
|
||||
},
|
||||
)
|
||||
await nextTick()
|
||||
const btn = document.querySelector('.my-footer-btn')
|
||||
expect(btn).toBeTruthy()
|
||||
expect(btn!.textContent).toBe('Fertig')
|
||||
handle.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
suite('ui — IconButton', () => {
|
||||
test('renders a button element', async () => {
|
||||
const handle = mountComponent(IconButton, { title: 'hi' })
|
||||
await nextTick()
|
||||
const btn = handle.host.querySelector('button')
|
||||
expect(btn).toBeTruthy()
|
||||
expect(btn!.getAttribute('title')).toBe('hi')
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('applies variant + size + active classes', async () => {
|
||||
const handle = mountComponent(IconButton, {
|
||||
variant: 'primary',
|
||||
size: 'md',
|
||||
active: true,
|
||||
})
|
||||
await nextTick()
|
||||
const btn = handle.host.querySelector('button')!
|
||||
expect(btn.className.includes('variant-primary')).toBe(true)
|
||||
expect(btn.className.includes('size-md')).toBe(true)
|
||||
expect(btn.className.includes('active')).toBe(true)
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('disabled prop disables the native button', async () => {
|
||||
const handle = mountComponent(IconButton, { disabled: true })
|
||||
await nextTick()
|
||||
const btn = handle.host.querySelector('button')!
|
||||
expect(btn.disabled).toBe(true)
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('renders default slot in place of icon prop', async () => {
|
||||
const handle = mountComponent(
|
||||
IconButton,
|
||||
{},
|
||||
{ default: () => [h('span', { class: 'custom-icon' }, 'X')] },
|
||||
)
|
||||
await nextTick()
|
||||
const span = handle.host.querySelector('.custom-icon')
|
||||
expect(span).toBeTruthy()
|
||||
expect(span!.textContent).toBe('X')
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('click fires native click event', async () => {
|
||||
let clicks = 0
|
||||
const handle = mountComponent(IconButton, {
|
||||
onClick: () => {
|
||||
clicks++
|
||||
},
|
||||
})
|
||||
await nextTick()
|
||||
handle.host.querySelector('button')!.click()
|
||||
expect(clicks).toBe(1)
|
||||
handle.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
suite('ui — Toast', () => {
|
||||
test('hidden when visible=false, nothing in body', async () => {
|
||||
const handle = mountComponent(Toast, { visible: false, message: 'x' })
|
||||
await nextTick()
|
||||
expect(document.querySelector('.toast')).toBeNull()
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('visible=true renders a teleported element with message', async () => {
|
||||
const handle = mountComponent(Toast, {
|
||||
visible: true,
|
||||
variant: 'success',
|
||||
message: 'Alles gut',
|
||||
})
|
||||
await nextTick()
|
||||
const el = document.querySelector('.toast') as HTMLElement | null
|
||||
expect(el).toBeTruthy()
|
||||
expect(el!.className.includes('variant-success')).toBe(true)
|
||||
expect(el!.textContent?.includes('Alles gut')).toBe(true)
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('applies position class', async () => {
|
||||
const handle = mountComponent(Toast, {
|
||||
visible: true,
|
||||
position: 'top',
|
||||
message: 'x',
|
||||
})
|
||||
await nextTick()
|
||||
const el = document.querySelector('.toast')!
|
||||
expect(el.className.includes('position-top')).toBe(true)
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('default slot overrides title/message', async () => {
|
||||
const handle = mountComponent(
|
||||
Toast,
|
||||
{ visible: true, message: 'ignored' },
|
||||
{ default: () => [h('span', { class: 'slot-body' }, 'slot wins')] },
|
||||
)
|
||||
await nextTick()
|
||||
const slot = document.querySelector('.slot-body')
|
||||
expect(slot).toBeTruthy()
|
||||
expect(document.querySelector('.toast')!.textContent?.includes('slot wins')).toBe(true)
|
||||
handle.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
suite('project store — IndexedDB recents', () => {
|
||||
test('save then getStoredProject returns identical scene', async () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'RS', width: 42, height: 42, fill: '#123456' })
|
||||
s.addLayer(a.id)
|
||||
const rec = await saveProjectToStore(s.scene, { name: 'test-save-get' })
|
||||
try {
|
||||
const back = await getStoredProject(rec.id)
|
||||
expect(back).toBeTruthy()
|
||||
expect(back!.name).toBe('test-save-get')
|
||||
expect(back!.scene.assets).toHaveLength(1)
|
||||
expect(back!.scene.layers).toHaveLength(1)
|
||||
} finally {
|
||||
await deleteStoredProject(rec.id)
|
||||
}
|
||||
})
|
||||
|
||||
test('listRecentProjects is sorted by savedAt desc', async () => {
|
||||
const s = makeFreshStore()
|
||||
const olderId = `test-older-${Date.now()}`
|
||||
const newerId = `test-newer-${Date.now()}`
|
||||
const older = await saveProjectToStore(s.scene, { id: olderId, name: 'older' })
|
||||
await tick(5)
|
||||
const newer = await saveProjectToStore(s.scene, { id: newerId, name: 'newer' })
|
||||
try {
|
||||
const list = await listRecentProjects()
|
||||
const newerIdx = list.findIndex((p) => p.id === newer.id)
|
||||
const olderIdx = list.findIndex((p) => p.id === older.id)
|
||||
expect(newerIdx).toBeGreaterThan(-1)
|
||||
expect(olderIdx).toBeGreaterThan(-1)
|
||||
expect(newerIdx).toBeLessThan(olderIdx)
|
||||
} finally {
|
||||
await deleteStoredProject(olderId)
|
||||
await deleteStoredProject(newerId)
|
||||
}
|
||||
})
|
||||
|
||||
test('save with same id updates existing record instead of adding', async () => {
|
||||
const s = makeFreshStore()
|
||||
const id = `test-update-${Date.now()}`
|
||||
await saveProjectToStore(s.scene, { id, name: 'v1' })
|
||||
const before = (await listRecentProjects()).filter((p) => p.id === id).length
|
||||
await saveProjectToStore(s.scene, { id, name: 'v2' })
|
||||
try {
|
||||
const list = await listRecentProjects()
|
||||
const matches = list.filter((p) => p.id === id)
|
||||
expect(matches).toHaveLength(1)
|
||||
expect(matches[0].name).toBe('v2')
|
||||
expect(before).toBe(1)
|
||||
} finally {
|
||||
await deleteStoredProject(id)
|
||||
}
|
||||
})
|
||||
|
||||
test('deleteStoredProject removes entry', async () => {
|
||||
const s = makeFreshStore()
|
||||
const rec = await saveProjectToStore(s.scene, { name: 'to-delete' })
|
||||
await deleteStoredProject(rec.id)
|
||||
const back = await getStoredProject(rec.id)
|
||||
expect(back).toBeNull()
|
||||
})
|
||||
|
||||
test('getStoredProject returns null for missing id', async () => {
|
||||
const back = await getStoredProject('definitely-missing-id-42')
|
||||
expect(back).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
suite('app smoke — live DOM', () => {
|
||||
test('app root, topbar, timeline, stage are mounted', () => {
|
||||
expect(document.querySelector('.app')).toBeTruthy()
|
||||
@@ -521,5 +1004,10 @@ export function registerAllTests(): void {
|
||||
expect(saveBtn).toBeTruthy()
|
||||
expect(recentsBtn).toBeTruthy()
|
||||
})
|
||||
|
||||
test('canvas has crop toggle button', () => {
|
||||
const cropBtn = document.querySelector('.crop-btn')
|
||||
expect(cropBtn).toBeTruthy()
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user