bak
This commit is contained in:
1 parent
6b6157d786
commit
3ef5fb5a2d
19 files changed
+1244
-94
No files matched your search
@@ -9,6 +9,9 @@ import {
|
||||
} from '@/lib/exportPlayer'
|
||||
import { ProjectFileError, parseProject, serializeProject } from '@/lib/projectFile'
|
||||
import { createHistory } from '@/composables/useHistory'
|
||||
import { useKfSelection, kfSelectionKey } from '@/composables/useKfSelection'
|
||||
import { APP_NAME, APP_VERSION } from '@/version'
|
||||
import { drawSceneFrame } from '@/lib/render'
|
||||
import {
|
||||
deleteStoredProject,
|
||||
getStoredProject,
|
||||
@@ -26,6 +29,34 @@ interface MountedHandle {
|
||||
unmount: () => void
|
||||
}
|
||||
|
||||
interface RecordingCtx {
|
||||
ctx: CanvasRenderingContext2D
|
||||
filterSets: string[]
|
||||
}
|
||||
|
||||
function makeRecordingCtx(): RecordingCtx {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 200
|
||||
canvas.height = 200
|
||||
const real = canvas.getContext('2d')!
|
||||
const filterSets: string[] = []
|
||||
const proxy = new Proxy(real, {
|
||||
set(target, prop, value) {
|
||||
if (prop === 'filter') filterSets.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 }
|
||||
}
|
||||
|
||||
function mountComponent(
|
||||
component: Component,
|
||||
props: Record<string, unknown>,
|
||||
@@ -1213,5 +1244,228 @@ export function registerAllTests(): void {
|
||||
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('timeline has new-track dropzone', () => {
|
||||
const dz = document.querySelector('.clip-track-dropzone')
|
||||
expect(dz).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
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('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)
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user