macro-clips slice 5: action evaluator + forward-only script frame firing
- new src/lib/actions.ts:
- ActionRuntime interface (fps, range, markers, setPlayhead/play/pause,
setButtonState/setButtonDisabled, warn)
- evalAction dispatches each action kind, with clamping to [0, duration]
for time-based jumps and a warning path for unknown markers and for
script actions (DSL lands in slice 8)
- scriptFramesInRange helper returns frames in the open-closed interval
(prev, next], sorted
- store wires it up:
- tick captures prevPlayhead, advances, then fires script frames that
fall in (prev, landed]. Scrubs via setPlayhead never touch this path
so the forward-only guarantee falls out of the architecture.
- actionRuntime() builds an ActionRuntime bound to this store
- setButtonState is a no-op in the editor for now (waits for the
button-runtime slice); setButtonDisabled goes to the store action,
rewindToLoop/play/pause/jumps mutate the store directly
- tests: 10 evaluator tests against a mock ActionRuntime covering all
action kinds + marker map + scriptFramesInRange; 3 integration tests
for tick firing (ordering, no-fire-on-scrub, firing-at-playbackOut)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
b9d1ca16f7
commit
8817a73c3d
3 files changed
+320
-3
No files matched your search
@@ -1,5 +1,12 @@
|
||||
import { createApp, h, nextTick, type Component, type Slot } from 'vue'
|
||||
import { createBlankScene, useSceneStore } from '@/stores/scene'
|
||||
import {
|
||||
buildMarkerMap,
|
||||
evalAction,
|
||||
scriptFramesInRange,
|
||||
type ActionRuntime,
|
||||
} from '@/lib/actions'
|
||||
import type { Action } from '@/types/scene'
|
||||
import { sampleLayer, lerpColor } from '@/lib/interpolate'
|
||||
import { snapToCandidates } from '@/lib/timelineSnap'
|
||||
import {
|
||||
@@ -3198,6 +3205,183 @@ export function registerAllTests(): void {
|
||||
})
|
||||
})
|
||||
|
||||
suite('actions — evalAction with mock runtime', () => {
|
||||
function makeMockRuntime(init?: Partial<ActionRuntime>): ActionRuntime & {
|
||||
calls: string[]
|
||||
playhead: number
|
||||
playing: boolean
|
||||
buttonStates: Record<string, string>
|
||||
buttonDisabled: Record<string, boolean>
|
||||
warnings: string[]
|
||||
} {
|
||||
const rt = {
|
||||
fps: 60,
|
||||
playbackIn: 0,
|
||||
playbackOut: 10,
|
||||
duration: 10,
|
||||
markers: { intro: 2, outro: 8 },
|
||||
playhead: 0,
|
||||
playing: false,
|
||||
buttonStates: {} as Record<string, string>,
|
||||
buttonDisabled: {} as Record<string, boolean>,
|
||||
warnings: [] as string[],
|
||||
calls: [] as string[],
|
||||
setPlayhead(t: number) {
|
||||
this.calls.push(`setPlayhead:${t}`)
|
||||
this.playhead = t
|
||||
},
|
||||
play() {
|
||||
this.calls.push('play')
|
||||
this.playing = true
|
||||
},
|
||||
pause() {
|
||||
this.calls.push('pause')
|
||||
this.playing = false
|
||||
},
|
||||
setButtonState(id: string, state: string) {
|
||||
this.calls.push(`btnState:${id}=${state}`)
|
||||
this.buttonStates[id] = state
|
||||
},
|
||||
setButtonDisabled(id: string, d: boolean) {
|
||||
this.calls.push(`btnDisabled:${id}=${d}`)
|
||||
this.buttonDisabled[id] = d
|
||||
},
|
||||
warn(msg: string) {
|
||||
this.warnings.push(msg)
|
||||
},
|
||||
...init,
|
||||
}
|
||||
return rt as ReturnType<typeof makeMockRuntime>
|
||||
}
|
||||
|
||||
test('play / pause / rewindToLoop', () => {
|
||||
const rt = makeMockRuntime({ playbackIn: 2 })
|
||||
evalAction({ type: 'play' }, rt)
|
||||
evalAction({ type: 'pause' }, rt)
|
||||
evalAction({ type: 'rewindToLoop' }, rt)
|
||||
expect(rt.playing).toBe(false)
|
||||
expect(rt.playhead).toBe(2)
|
||||
expect(rt.calls).toEqual(['play', 'pause', 'setPlayhead:2'])
|
||||
})
|
||||
|
||||
test('jumpTo clamps to [0, duration]', () => {
|
||||
const rt = makeMockRuntime()
|
||||
evalAction({ type: 'jumpTo', time: -3 }, rt)
|
||||
expect(rt.playhead).toBe(0)
|
||||
evalAction({ type: 'jumpTo', time: 99 }, rt)
|
||||
expect(rt.playhead).toBe(10)
|
||||
evalAction({ type: 'jumpTo', time: 5 }, rt)
|
||||
expect(rt.playhead).toBe(5)
|
||||
})
|
||||
|
||||
test('jumpToFrame divides by fps', () => {
|
||||
const rt = makeMockRuntime({ fps: 30 })
|
||||
evalAction({ type: 'jumpToFrame', frame: 90 }, rt)
|
||||
expect(rt.playhead).toBe(3)
|
||||
})
|
||||
|
||||
test('jumpToMarker resolves via markers map', () => {
|
||||
const rt = makeMockRuntime()
|
||||
evalAction({ type: 'jumpToMarker', name: 'outro' }, rt)
|
||||
expect(rt.playhead).toBe(8)
|
||||
})
|
||||
|
||||
test('jumpToMarker warns on unknown name and does not move playhead', () => {
|
||||
const rt = makeMockRuntime()
|
||||
const before = rt.playhead
|
||||
evalAction({ type: 'jumpToMarker', name: 'missing' }, rt)
|
||||
expect(rt.playhead).toBe(before)
|
||||
expect(rt.warnings.length).toBe(1)
|
||||
expect(rt.warnings[0]).toContain('missing')
|
||||
})
|
||||
|
||||
test('setButtonState + setButtonDisabled dispatch to runtime', () => {
|
||||
const rt = makeMockRuntime()
|
||||
evalAction({ type: 'setButtonState', instanceId: 'b1', state: 'pressed' }, rt)
|
||||
evalAction({ type: 'setButtonDisabled', instanceId: 'b2', disabled: true }, rt)
|
||||
expect(rt.buttonStates.b1).toBe('pressed')
|
||||
expect(rt.buttonDisabled.b2).toBe(true)
|
||||
})
|
||||
|
||||
test("script action warns that DSL isn't implemented yet", () => {
|
||||
const rt = makeMockRuntime()
|
||||
evalAction({ type: 'script', source: 'play' }, rt)
|
||||
expect(rt.warnings.length).toBe(1)
|
||||
})
|
||||
|
||||
test('buildMarkerMap maps name → time', () => {
|
||||
const map = buildMarkerMap([
|
||||
{ id: 'a', name: 'intro', time: 1 },
|
||||
{ id: 'b', name: 'outro', time: 5 },
|
||||
])
|
||||
expect(map.intro).toBe(1)
|
||||
expect(map.outro).toBe(5)
|
||||
})
|
||||
|
||||
test('scriptFramesInRange returns only forward frames, sorted', () => {
|
||||
const frames = [
|
||||
{ id: 'a', trackId: 't', time: 3, actions: [] as Action[] },
|
||||
{ id: 'b', trackId: 't', time: 1, actions: [] as Action[] },
|
||||
{ id: 'c', trackId: 't', time: 6, actions: [] as Action[] },
|
||||
]
|
||||
// (0, 4] → only 1 and 3, sorted
|
||||
const got = scriptFramesInRange(frames, 0, 4).map((f) => f.id)
|
||||
expect(got).toEqual(['b', 'a'])
|
||||
// backwards → empty
|
||||
expect(scriptFramesInRange(frames, 5, 1)).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('scriptFramesInRange uses open-closed interval', () => {
|
||||
const frames = [{ id: 'a', trackId: 't', time: 2, actions: [] as Action[] }]
|
||||
// prev=2 (exclusive), next=2 → empty
|
||||
expect(scriptFramesInRange(frames, 2, 2)).toHaveLength(0)
|
||||
// prev=1, next=2 → includes 2
|
||||
expect(scriptFramesInRange(frames, 1, 2)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
suite('scene store — tick fires script frames forward-only', () => {
|
||||
test('tick fires frames crossed during forward advance in time order', async () => {
|
||||
const s = makeFreshStore()
|
||||
s.setDuration(10)
|
||||
const t = s.addScriptTrack()
|
||||
s.addScriptFrame(t.id, 2, [{ type: 'pause' }])
|
||||
s.addScriptFrame(t.id, 1, [{ type: 'jumpTo', time: 9 }])
|
||||
s.play()
|
||||
// From 0 → 2: both frames get crossed; time-1 frame fires first and
|
||||
// jumps the playhead to 9, time-2 frame fires second and pauses.
|
||||
s.tick(2)
|
||||
expect(s.playhead).toBe(9)
|
||||
expect(s.playing).toBe(false)
|
||||
await tick()
|
||||
})
|
||||
|
||||
test('setPlayhead alone does not fire frames', async () => {
|
||||
const s = makeFreshStore()
|
||||
s.setDuration(10)
|
||||
const t = s.addScriptTrack()
|
||||
s.addScriptFrame(t.id, 2, [{ type: 'pause' }])
|
||||
s.play()
|
||||
s.setPlayhead(5) // scrub over the frame without ticking
|
||||
expect(s.playing).toBe(true)
|
||||
s.pause()
|
||||
await tick()
|
||||
})
|
||||
|
||||
test('frames fire even after natural stop at playbackOut', async () => {
|
||||
const s = makeFreshStore()
|
||||
s.setDuration(10)
|
||||
s.setPlaybackRange(0, 5)
|
||||
const t = s.addScriptTrack()
|
||||
s.addScriptFrame(t.id, 5, [{ type: 'rewindToLoop' }])
|
||||
s.play()
|
||||
s.tick(6) // lands at playbackOut=5, playing=false, frame at 5 fires
|
||||
expect(s.playing).toBe(false)
|
||||
expect(s.playhead).toBe(0) // rewindToLoop → playbackIn=0
|
||||
await tick()
|
||||
})
|
||||
})
|
||||
|
||||
suite('scene store — macro model normalization', () => {
|
||||
test('loadScene initializes new arrays when missing', () => {
|
||||
const s = makeFreshStore()
|
||||
|
||||
Reference in new issue
Block a user