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:
thandClaude Opus 4.7 committed 2026-04-20 21:00:59 +02:00
1 parent b9d1ca16f7
commit 8817a73c3d
3 files changed
+320 -3

No files matched your search

+184
View File
@@ -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()