0.9.1: jumpToScene action + auto-play on scene switch

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 07:44:43 +02:00
1 parent b57994c754
commit b9f2ba9e47
9 files changed
+436 -15

No files matched your search

+312 -3
View File
@@ -41,6 +41,8 @@ import IconButton from '@/components/ui/IconButton.vue'
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 ActionEditor from '@/components/ActionEditor.vue'
import { vTooltip } from '@/directives/tooltip'
import { expect, suite, test } from './testRunner'
@@ -1280,22 +1282,40 @@ export function registerAllTests(): void {
expect(s.scene.layers[0].id).toBe(l2.id)
})
test('switchScene clears editor context (selection, clip-edit, playback, drawing)', () => {
test('switchScene clears editor context (selection, clip-edit, drawing) and auto-plays', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'SW', width: 10, height: 10, fill: '#111' })
const l = s.addLayer(a.id)!
s.selectLayer(l.id)
s.play()
s.pause()
const clip = s.addClip('C')
s.activeClipId = clip.id
const two = s.addScene()
// addScene already routed through switchScene — cross-check.
expect(s.selectedLayerId).toBeNull()
expect(s.selectedLayerIds).toHaveLength(0)
expect(s.playing).toBe(false)
expect(s.activeClipId).toBeNull()
expect(s.activeButtonAssetId).toBeNull()
expect(s.project.activeSceneId).toBe(two.id)
// Switching always auto-plays on the new page — even if the
// editor was paused beforehand.
expect(s.playing).toBe(true)
})
test('switchScene auto-plays even when the editor was paused', () => {
const s = makeFreshStore()
s.pause()
const second = s.addScene('Two')
const firstId = s.project.scenes[0].id
s.pause()
expect(s.playing).toBe(false)
s.switchScene(firstId)
expect(s.playing).toBe(true)
expect(s.project.activeSceneId).toBe(firstId)
// And the inverse direction — Two plays too.
s.pause()
s.switchScene(second.id)
expect(s.playing).toBe(true)
})
test('switchScene returns false for an unknown id and leaves state alone', () => {
@@ -3818,6 +3838,219 @@ export function registerAllTests(): void {
})
})
suite('ui — SceneDrawer', () => {
async function settlePromptCancel() {
// The drawer's `+` button dispatches addScene and then kicks off a
// promptDialog for renaming. Tests don't render DialogsHost, so
// we resolve the pending prompt explicitly to keep the dialog
// queue empty between tests.
const { cancelDialog } = await import('@/components/ui/dialogs')
cancelDialog()
}
test('renders one .scene-row per page, with .active on the current one', async () => {
const s = makeFreshStore()
s.addScene('Two')
s.addScene('Three')
const activeId = s.project.activeSceneId
const handle = mountComponent(SceneDrawer, {})
await nextTick()
const rows = handle.host.querySelectorAll('.scene-row')
expect(rows.length).toBe(3)
// Order matches `project.scenes`.
const names = Array.from(rows).map((r) => r.querySelector('.scene-name')?.textContent?.trim())
expect(names).toEqual(s.project.scenes.map((p) => p.name))
// Exactly one row is .active, and it's the active scene.
const active = Array.from(rows).filter((r) => r.classList.contains('active'))
expect(active.length).toBe(1)
const activeName = active[0].querySelector('.scene-name')?.textContent?.trim()
expect(activeName).toBe(s.project.scenes.find((p) => p.id === activeId)!.name)
handle.unmount()
})
test('layer-count badge reflects each scene\'s layer count', async () => {
const s = makeFreshStore()
const asset = s.addAsset({ kind: 'rect', name: 'D', width: 10, height: 10, fill: '#abc' })
s.addLayer(asset.id)
s.addLayer(asset.id)
const two = s.addScene('Two')
s.addLayer(asset.id) // lands on Two (active after addScene)
// Render AFTER state is set up so the component's reactive read
// captures the final layer counts.
const handle = mountComponent(SceneDrawer, {})
await nextTick()
const rows = handle.host.querySelectorAll('.scene-row')
expect(rows.length).toBe(2)
const metas = Array.from(rows).map((r) =>
r.querySelector('.scene-meta')?.textContent?.trim(),
)
// First row = Scene 1 (2 layers), second row = Two (1 layer).
expect(metas).toEqual(['2', '1'])
void two
handle.unmount()
})
test('clicking a non-active row switches to that page and auto-plays', async () => {
const s = makeFreshStore()
const firstId = s.project.activeSceneId
const second = s.addScene('Two')
// Go back to Scene 1 and pause so we can observe the auto-play flip.
s.switchScene(firstId)
s.pause()
const handle = mountComponent(SceneDrawer, {})
await nextTick()
const rows = handle.host.querySelectorAll('.scene-row') as NodeListOf<HTMLButtonElement>
// Row index 1 = Two (non-active).
rows[1].click()
await nextTick()
expect(s.project.activeSceneId).toBe(second.id)
expect(s.playing).toBe(true)
handle.unmount()
})
test('clicking the already-active row is a no-op', async () => {
const s = makeFreshStore()
const firstId = s.project.activeSceneId
s.pause()
const handle = mountComponent(SceneDrawer, {})
await nextTick()
const rows = handle.host.querySelectorAll('.scene-row') as NodeListOf<HTMLButtonElement>
// Row index 0 = the active one.
rows[0].click()
await nextTick()
// Still paused — no sneaky re-switch triggered auto-play.
expect(s.playing).toBe(false)
expect(s.project.activeSceneId).toBe(firstId)
handle.unmount()
})
test('+ button appends a new scene and activates it', async () => {
const s = makeFreshStore()
expect(s.project.scenes).toHaveLength(1)
const handle = mountComponent(SceneDrawer, {})
await nextTick()
const addBtn = handle.host.querySelector('.add-btn') as HTMLButtonElement
expect(addBtn).toBeTruthy()
addBtn.click()
// Let addScene + the nextTick inside addScene resolve.
await nextTick()
await nextTick()
expect(s.project.scenes).toHaveLength(2)
expect(s.project.activeSceneId).toBe(s.project.scenes[1].id)
// Clean up the pending rename prompt.
await settlePromptCancel()
handle.unmount()
})
test('right-click on a row surfaces the context menu', async () => {
const s = makeFreshStore()
s.addScene('Two')
const handle = mountComponent(SceneDrawer, {})
await nextTick()
expect(handle.host.querySelector('.scene-menu')).toBeNull()
const row = handle.host.querySelector('.scene-row') as HTMLElement
row.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))
await nextTick()
expect(handle.host.querySelector('.scene-menu')).toBeTruthy()
// Delete option is disabled only when there's just one scene left.
const deleteBtn = handle.host.querySelector('.scene-menu .menu-item.danger') as HTMLButtonElement
expect(deleteBtn).toBeTruthy()
expect(deleteBtn.disabled).toBe(false)
handle.unmount()
})
test('context menu disables Delete when only one scene remains', async () => {
const s = makeFreshStore()
expect(s.project.scenes.length).toBe(1)
const handle = mountComponent(SceneDrawer, {})
await nextTick()
const row = handle.host.querySelector('.scene-row') as HTMLElement
row.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))
await nextTick()
const deleteBtn = handle.host.querySelector('.scene-menu .menu-item.danger') as HTMLButtonElement
expect(deleteBtn).toBeTruthy()
expect(deleteBtn.disabled).toBe(true)
handle.unmount()
})
})
suite('ui — ActionEditor (jumpToScene)', () => {
test('jumpToScene appears in the type dropdown', async () => {
const s = makeFreshStore()
s.addScene('Two')
const handle = mountComponent(
ActionEditor,
{ actions: [{ type: 'pause' } as const] },
)
await nextTick()
const select = handle.host.querySelector('.type-select') as HTMLSelectElement
expect(select).toBeTruthy()
const values = Array.from(select.options).map((o) => o.value)
expect(values.includes('jumpToScene')).toBe(true)
handle.unmount()
})
test('switching a row to jumpToScene seeds it with the first non-active scene', async () => {
const s = makeFreshStore()
const activeId = s.project.activeSceneId
const other = s.addScene('Other')
s.switchScene(activeId) // pull the active marker back to Scene 1
let latest: unknown = null
const handle = mountComponent(ActionEditor, {
actions: [{ type: 'pause' } as const],
'onUpdate:actions': (v: unknown) => {
latest = v
},
})
await nextTick()
const select = handle.host.querySelector('.type-select') as HTMLSelectElement
select.value = 'jumpToScene'
select.dispatchEvent(new Event('change', { bubbles: true }))
await nextTick()
expect(Array.isArray(latest)).toBe(true)
const arr = latest as unknown[]
expect(arr.length).toBe(1)
const next = arr[0] as { type: string; sceneId?: string }
expect(next.type).toBe('jumpToScene')
// Defaults to the first non-active scene so the action is useful out of the box.
expect(next.sceneId).toBe(other.id)
handle.unmount()
})
test('scene dropdown enumerates every scene by name, emits sceneId on change', async () => {
const s = makeFreshStore()
const firstId = s.project.scenes[0].id
const second = s.addScene('Two')
const third = s.addScene('Three')
// Active is now Three. The seed action points at Two; changing
// to Three via the dropdown should emit { sceneId: third.id }.
let latest: unknown = null
const handle = mountComponent(ActionEditor, {
actions: [{ type: 'jumpToScene', sceneId: second.id } as const],
'onUpdate:actions': (v: unknown) => {
latest = v
},
})
await nextTick()
// Find the scene dropdown (second select on the row — the first
// is the action-type select).
const selects = handle.host.querySelectorAll('select')
expect(selects.length).toBe(2)
const sceneSelect = selects[1] as HTMLSelectElement
const sceneValues = Array.from(sceneSelect.options).map((o) => o.value)
expect(sceneValues).toEqual([firstId, second.id, third.id])
const sceneLabels = Array.from(sceneSelect.options).map((o) => o.textContent?.trim())
expect(sceneLabels).toEqual(['Scene 1', 'Two', 'Three'])
sceneSelect.value = third.id
sceneSelect.dispatchEvent(new Event('change', { bubbles: true }))
await nextTick()
const next = (latest as unknown[])[0] as { type: string; sceneId: string }
expect(next.type).toBe('jumpToScene')
expect(next.sceneId).toBe(third.id)
handle.unmount()
})
})
suite('polygon — vertex bezier actions', () => {
function makeSquare() {
const s = makeFreshStore()
@@ -4811,6 +5044,40 @@ export function registerAllTests(): void {
expect(rt.warnings[0]).toContain('missing')
})
test('jumpToScene dispatches switchScene on the runtime', () => {
let switched: string | null = null
const rt = makeMockRuntime({
sceneIds: new Set(['s-a', 's-b']),
switchScene(id: string) {
switched = id
},
})
evalAction({ type: 'jumpToScene', sceneId: 's-b' }, rt)
expect(switched).toBe('s-b')
expect(rt.warnings.length).toBe(0)
})
test('jumpToScene warns on unknown sceneId and does not switch', () => {
let switched: string | null = null
const rt = makeMockRuntime({
sceneIds: new Set(['s-a']),
switchScene(id: string) {
switched = id
},
})
evalAction({ type: 'jumpToScene', sceneId: 's-ghost' }, rt)
expect(switched).toBe(null)
expect(rt.warnings.length).toBe(1)
expect(rt.warnings[0]).toContain('s-ghost')
})
test('jumpToScene warns if runtime has no switchScene handler (e.g. exported bundle)', () => {
const rt = makeMockRuntime() // no switchScene, no sceneIds
evalAction({ type: 'jumpToScene', sceneId: 's-x' }, rt)
expect(rt.warnings.length).toBe(1)
expect(rt.warnings[0].toLowerCase()).toContain('does not support')
})
test('script action parses and dispatches sub-actions (no warn on valid DSL)', () => {
const rt = makeMockRuntime()
evalAction({ type: 'script', source: 'play\npause' }, rt)
@@ -4898,6 +5165,48 @@ export function registerAllTests(): void {
expect(s.playhead).toBe(0) // rewindToLoop → playbackIn=0
await tick()
})
test('a scripted jumpToScene switches the active scene and keeps playing', async () => {
// User-facing contract: a script track fires a scene-switch in the
// middle of a running timeline — the active scene flips and the
// timeline keeps running on the new page. All pages share
// duration/fps, so the playhead stays put and continues onward.
const s = makeFreshStore()
s.setDuration(10)
const aId = s.project.activeSceneId
const bId = s.addScene('B').id
s.switchScene(aId)
const track = s.addScriptTrack()
s.addScriptFrame(track.id, 3, [{ type: 'jumpToScene', sceneId: bId }])
s.play()
s.tick(3)
expect(s.project.activeSceneId).toBe(bId)
expect(s.playing).toBe(true)
await tick()
})
test('scripted jumpToScene with an unknown id warns and leaves state alone', async () => {
const s = makeFreshStore()
s.setDuration(10)
const aId = s.project.activeSceneId
const track = s.addScriptTrack()
s.addScriptFrame(track.id, 2, [{ type: 'jumpToScene', sceneId: 'bogus-id' }])
s.play()
const warnings: string[] = []
const origWarn = console.warn
console.warn = (...args: unknown[]) => {
warnings.push(args.map(String).join(' '))
}
try {
s.tick(2)
} finally {
console.warn = origWarn
}
expect(s.project.activeSceneId).toBe(aId)
expect(s.playing).toBe(true)
expect(warnings.some((w) => w.includes('bogus-id'))).toBe(true)
await tick()
})
})
suite('scriptDsl — parser', () => {