macro-clips slice 8: script DSL parser + runtime label resolution
- src/lib/scriptDsl.ts: small line-based parser producing Action[] plus
a list of ParseError{line,message}. Handles: play/pause/rewind,
jump <sec> | #<frame> | @<marker>, state btn:<label> <state>,
enable/disable btn:<label>, // comments, skips blanks
- evalAction for type:'script' parses the source and recursively
dispatches sub-actions; errors surface via rt.warn
- ActionRuntime.setButtonState/setButtonDisabled are documented as
taking an instance id OR a label; the store's actionRuntime builds
a label→id map and resolves refs before applying
- exportPlayer template gets a matching parseScriptSource plus
resolveButtonRef; evalAction-in-engine mirrors the TS version
- RUNTIME_VERSION bumped to '4'
- tests: parser covers commands, comments, jump variants, state/enable
errors, unknown commands; two integration tests verify that script
actions dispatch via evalAction and that parse errors don't block
valid actions
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
94ebbac238
commit
fb58cd18f7
5 files changed
+368
-10
No files matched your search
@@ -6,6 +6,7 @@ import {
|
||||
scriptFramesInRange,
|
||||
type ActionRuntime,
|
||||
} from '@/lib/actions'
|
||||
import { parseScript } from '@/lib/scriptDsl'
|
||||
import type { Action } from '@/types/scene'
|
||||
import { sampleLayer, lerpColor } from '@/lib/interpolate'
|
||||
import { snapToCandidates } from '@/lib/timelineSnap'
|
||||
@@ -3382,6 +3383,125 @@ export function registerAllTests(): void {
|
||||
})
|
||||
})
|
||||
|
||||
suite('scriptDsl — parser', () => {
|
||||
test('parses bare commands', () => {
|
||||
const { actions, errors } = parseScript('play\npause\nrewind')
|
||||
expect(errors).toHaveLength(0)
|
||||
expect(actions.map((a) => a.type)).toEqual(['play', 'pause', 'rewindToLoop'])
|
||||
})
|
||||
|
||||
test('skips blank lines and comments', () => {
|
||||
const src = [
|
||||
'// leading comment',
|
||||
'',
|
||||
'play // trailing comment',
|
||||
' ',
|
||||
'pause',
|
||||
].join('\n')
|
||||
const { actions, errors } = parseScript(src)
|
||||
expect(errors).toHaveLength(0)
|
||||
expect(actions.map((a) => a.type)).toEqual(['play', 'pause'])
|
||||
})
|
||||
|
||||
test('jump accepts seconds, #frame, and @marker', () => {
|
||||
const { actions, errors } = parseScript('jump 2.5\njump #60\njump @intro')
|
||||
expect(errors).toHaveLength(0)
|
||||
expect(actions[0]).toEqual({ type: 'jumpTo', time: 2.5 })
|
||||
expect(actions[1]).toEqual({ type: 'jumpToFrame', frame: 60 })
|
||||
expect(actions[2]).toEqual({ type: 'jumpToMarker', name: 'intro' })
|
||||
})
|
||||
|
||||
test('jump reports invalid arguments', () => {
|
||||
const { actions, errors } = parseScript('jump\njump abc\njump #xx\njump @')
|
||||
expect(actions).toHaveLength(0)
|
||||
expect(errors).toHaveLength(4)
|
||||
})
|
||||
|
||||
test('state requires btn:<label> and a valid state', () => {
|
||||
const good = parseScript('state btn:play pressed')
|
||||
expect(good.errors).toHaveLength(0)
|
||||
expect(good.actions[0]).toEqual({
|
||||
type: 'setButtonState',
|
||||
instanceId: 'play',
|
||||
state: 'pressed',
|
||||
})
|
||||
const bad1 = parseScript('state play pressed')
|
||||
expect(bad1.errors).toHaveLength(1)
|
||||
const bad2 = parseScript('state btn:play weird')
|
||||
expect(bad2.errors).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('enable / disable map to setButtonDisabled', () => {
|
||||
const { actions } = parseScript('enable btn:play\ndisable btn:play')
|
||||
expect(actions[0]).toEqual({
|
||||
type: 'setButtonDisabled',
|
||||
instanceId: 'play',
|
||||
disabled: false,
|
||||
})
|
||||
expect(actions[1]).toEqual({
|
||||
type: 'setButtonDisabled',
|
||||
instanceId: 'play',
|
||||
disabled: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('unknown commands report error, other lines still parse', () => {
|
||||
const { actions, errors } = parseScript('play\nwat\npause')
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(errors[0].line).toBe(2)
|
||||
expect(actions.map((a) => a.type)).toEqual(['play', 'pause'])
|
||||
})
|
||||
})
|
||||
|
||||
suite('actions — script type expands via evalAction', () => {
|
||||
test('script source is parsed and each action dispatched in order', () => {
|
||||
const calls: string[] = []
|
||||
const rt: ActionRuntime = {
|
||||
fps: 60,
|
||||
playbackIn: 0,
|
||||
playbackOut: 10,
|
||||
duration: 10,
|
||||
markers: { intro: 2 },
|
||||
setPlayhead: (t) => calls.push(`seek:${t}`),
|
||||
play: () => calls.push('play'),
|
||||
pause: () => calls.push('pause'),
|
||||
setButtonState: (id, s) => calls.push(`state:${id}=${s}`),
|
||||
setButtonDisabled: (id, d) => calls.push(`disabled:${id}=${d}`),
|
||||
warn: () => {},
|
||||
}
|
||||
evalAction(
|
||||
{
|
||||
type: 'script',
|
||||
source: 'pause\njump @intro\nstate btn:play pressed',
|
||||
},
|
||||
rt,
|
||||
)
|
||||
expect(calls).toEqual(['pause', 'seek:2', 'state:play=pressed'])
|
||||
})
|
||||
|
||||
test('script errors surface via rt.warn, valid actions still fire', () => {
|
||||
const warnings: string[] = []
|
||||
const fired: string[] = []
|
||||
const rt: ActionRuntime = {
|
||||
fps: 60,
|
||||
playbackIn: 0,
|
||||
playbackOut: 10,
|
||||
duration: 10,
|
||||
markers: {},
|
||||
setPlayhead: () => {},
|
||||
play: () => fired.push('play'),
|
||||
pause: () => fired.push('pause'),
|
||||
setButtonState: () => {},
|
||||
setButtonDisabled: () => {},
|
||||
warn: (m) => warnings.push(m),
|
||||
}
|
||||
evalAction({ type: 'script', source: 'play\nwat\npause' }, rt)
|
||||
expect(fired).toEqual(['play', 'pause'])
|
||||
expect(warnings).toHaveLength(1)
|
||||
expect(warnings[0]).toContain('wat')
|
||||
})
|
||||
})
|
||||
|
||||
suite('scene store — macro model normalization', () => {
|
||||
test('loadScene initializes new arrays when missing', () => {
|
||||
const s = makeFreshStore()
|
||||
|
||||
Reference in new issue
Block a user