0.10.1: audio clip push-cascade + track-swap ghost, Media drawer rename

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-22 12:52:47 +02:00
1 parent 04f867547d
commit c5b1dd566a
8 files changed
+538 -43

No files matched your search

+285 -19
View File
@@ -3889,6 +3889,35 @@ export function registerAllTests(): void {
handle.unmount()
})
test('empty state shows a Choose-Folder button that calls pickFolder', async () => {
const { useClipartStore } = await import('@/stores/clipart')
const s = useClipartStore()
s.setOpen(true)
s.supported = true
// No handle → empty-state with action.
s.handle = null
s.folderName = null
s.items = []
// Spy on pickFolder so we don't actually open a folder picker.
const calls: number[] = []
const orig = s.pickFolder
s.pickFolder = async () => {
calls.push(1)
}
try {
const handle = mountComponent(ClipartDrawer, {})
await nextTick()
const btn = handle.host.querySelector('.choose-folder-btn') as HTMLButtonElement | null
expect(btn).toBeTruthy()
btn!.click()
expect(calls.length).toBe(1)
handle.unmount()
} finally {
s.pickFolder = 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()
@@ -4287,20 +4316,213 @@ export function registerAllTests(): void {
handle.unmount()
})
test('drag stops at the neighbour\'s edge when the move would overlap', async () => {
// Two clips on the same track: A at t=0 (length 2), B at t=5.
// Drag B leftward; it must not be pushed into A's 0..2 window.
// The last valid position is startTime=2 (exactly abutting A).
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()
s.addAudioClip(asset.id, 0, track.id) // A
const b = s.addAudioClip(asset.id, 5, track.id)! // B
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[1]
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)
@@ -4309,25 +4531,26 @@ export function registerAllTests(): void {
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: 500 }),
new PointerEvent('pointerdown', { bubbles: true, cancelable: true, button: 0, pointerId: 1, clientX: startX }),
)
// Big leftward drag — would end up at some negative position if
// unconstrained, but overlap with A blocks further movement.
// 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: -5000 }),
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: -5000 }),
new PointerEvent('pointerup', { bubbles: true, cancelable: true, pointerId: 1, clientX: startX + 5 * pps }),
)
await nextTick()
// B never entered A's window; it stayed at its last valid pos
// (≥ 2). Depending on the number of move events (just one here),
// it'll actually have stopped at 5 since the first evaluated
// newStart would already collide. Either way: no overlap.
expect(b.startTime >= 2 - 1e-9).toBe(true)
// And most importantly: A stayed put.
expect(s.activeScene.audioClips.find((c) => c.id !== b.id)!.startTime).toBe(0)
expect(Math.abs(b.startTime - 5) < 0.1).toBe(true)
expect(Math.abs(a.startTime - 7) < 0.1).toBe(true)
handle.unmount()
})
@@ -5536,6 +5759,49 @@ export function registerAllTests(): void {
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('normalizeScenePage drops orphan audio clips pointing at a missing track', () => {
const s = makeFreshStore()
const asset = addAsset(s)