0.12.0: document drawer redesign, PWA install, clip/audio context menus + push-cascade

- Document drawer slides out from under the topbar (no tab, no hover
  peek); opens via project-name button, closes on outside-click or ESC.
  Layout: inputs row + divider + preset row, presets user-managed via
  popup and persisted in localStorage.
- PWA manifest + minimal service worker so Chrome installs RUWIO as a
  standalone app — suppresses the native fullscreen exit overlay.
- Clips and audio clips get right-click context menus (Duplicate /
  Delete) via new `duplicateClipInstance` / `duplicateAudioClip` store
  actions.
- Clip moves cascade non-overlap in both directions; `setAudioClipPosition`
  now pushes backward as well as forward. New `setClipInstancePosition`
  gives clip instances the same behaviour.
- Library drags carry timeline-shaped ghost previews (audio pill, clip
  pill, button thumb) instead of the library row; clip lane renders a
  dashed drop-target ghost and rejects window-overlap drops.
- Scene / Clipart drawer tabs disappear in fullscreen; the 24 px they
  occupied is reclaimed for the stage. Play button sits on the exact
  horizontal center via a 3-column grid in the transport cluster.
- Tests: context menu + push-cascade + duplicate action coverage for
  both clip kinds; DocumentDrawer suite rewritten for the slide-out
  model.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-24 07:41:26 +02:00
1 parent fa66ba69bb
commit 354dd8a4f0
25 files changed
+2294 -405

No files matched your search

+537 -1
View File
@@ -42,6 +42,7 @@ 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'
@@ -55,7 +56,7 @@ import {
slicePeaksForClip,
} from '@/composables/useAudioWaveform'
import { vTooltip } from '@/directives/tooltip'
import { expect, suite, test } from './testRunner'
import { beforeEach, expect, suite, test } from './testRunner'
interface MountedHandle {
host: HTMLDivElement
@@ -2544,6 +2545,101 @@ export function registerAllTests(): void {
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' })
@@ -4271,6 +4367,243 @@ export function registerAllTests(): void {
})
})
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()
@@ -4496,6 +4829,147 @@ export function registerAllTests(): void {
})
})
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()
})
})
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 audio track', () => {
test('audio track renders as an .audio-track lane with one block per clip', async () => {
const s = makeFreshStore()
@@ -6244,6 +6718,68 @@ export function registerAllTests(): void {
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)