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:
1 parent
b57994c754
commit
b9f2ba9e47
9 files changed
+436
-15
No files matched your search
+312
-3
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user