This commit is contained in:
th committed 2026-04-19 20:31:53 +02:00
1 parent e4aa0dce86
commit cc6b11e61d
23 files changed
+2047 -524

No files matched your search

+488
View File
@@ -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()
})
})
}