macro-clips slice 1: types + store for buttons, scripts, markers

- ButtonClip/ButtonInstance, ScriptTrack/ScriptFrame, Marker, Action union
- new optional scene collections (buttonClips, buttonInstances, scriptTracks,
  scriptFrames, markers); normalizeScene initializes them on load
- store actions for all new entities, marker name + button label uniqueness,
  time clamping, cascading deletes (script frames with their track, button
  instances with their clip), script tracks join trackOrder + setTrackLocked
- design doc docs/macro-clips-and-scripts.md
- tests cover add/remove/rename/retime paths, uniqueness, sort order,
  label collisions, and normalization

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 20:34:44 +02:00
1 parent d4c4830fe2
commit a75e9151ed
4 files changed
+952

No files matched your search

+239
View File
@@ -2987,4 +2987,243 @@ export function registerAllTests(): void {
expect(outside[3] > 200).toBe(true)
})
})
suite('scene store — markers', () => {
test('addMarker defaults the name when none provided', () => {
const s = makeFreshStore()
const m = s.addMarker(2)
expect(m.name).toBe('Marker')
expect(m.time).toBe(2)
expect(s.scene.markers).toHaveLength(1)
})
test('addMarker de-dupes colliding names with suffix', () => {
const s = makeFreshStore()
s.addMarker(1, 'Intro')
s.addMarker(2, 'Intro')
s.addMarker(3, 'Intro')
const names = (s.scene.markers ?? []).map((m) => m.name)
expect(names).toEqual(['Intro', 'Intro 2', 'Intro 3'])
})
test('addMarker clamps time to [0, duration]', () => {
const s = makeFreshStore()
s.setDuration(10)
const a = s.addMarker(-5)
const b = s.addMarker(99)
expect(a.time).toBe(0)
expect(b.time).toBe(10)
})
test('addMarker keeps the list sorted by time', () => {
const s = makeFreshStore()
s.addMarker(5, 'C')
s.addMarker(1, 'A')
s.addMarker(3, 'B')
const times = (s.scene.markers ?? []).map((m) => m.time)
expect(times).toEqual([1, 3, 5])
})
test('renameMarker succeeds on unique, rejects duplicates', () => {
const s = makeFreshStore()
const a = s.addMarker(1, 'A')
s.addMarker(2, 'B')
expect(s.renameMarker(a.id, 'A2')).toBe(true)
expect(s.renameMarker(a.id, 'B')).toBe(false)
const byId = (s.scene.markers ?? []).find((m) => m.id === a.id)
expect(byId?.name).toBe('A2')
})
test('removeMarker drops the entry', () => {
const s = makeFreshStore()
const m = s.addMarker(1)
s.removeMarker(m.id)
expect(s.scene.markers).toHaveLength(0)
})
test('setMarkerTime clamps and re-sorts', () => {
const s = makeFreshStore()
s.setDuration(10)
const a = s.addMarker(1, 'A')
const b = s.addMarker(2, 'B')
s.setMarkerTime(a.id, 99)
const sortedNames = (s.scene.markers ?? []).map((m) => m.name)
expect(sortedNames).toEqual(['B', 'A'])
const updated = (s.scene.markers ?? []).find((m) => m.id === a.id)!
expect(updated.time).toBe(10)
void b
})
test('setMarkerColor sets and clears', () => {
const s = makeFreshStore()
const m = s.addMarker(1, 'A')
s.setMarkerColor(m.id, '#ff8800')
const get = () => s.scene.markers?.find((x) => x.id === m.id)
expect(get()?.color).toBe('#ff8800')
s.setMarkerColor(m.id, null)
expect(get()?.color).toBe(undefined)
})
})
suite('scene store — script tracks & frames', () => {
test('addScriptTrack appends and adds to trackOrder', () => {
const s = makeFreshStore()
const t = s.addScriptTrack('Triggers')
expect(s.scene.scriptTracks).toHaveLength(1)
expect(s.scene.trackOrder?.includes(t.id)).toBe(true)
})
test('addScriptFrame clamps time and returns frame', () => {
const s = makeFreshStore()
s.setDuration(10)
const t = s.addScriptTrack()
const f = s.addScriptFrame(t.id, 99, [{ type: 'pause' }])
expect(f).toBeTruthy()
expect(f!.time).toBe(10)
expect(f!.actions).toHaveLength(1)
})
test('addScriptFrame returns null for unknown trackId', () => {
const s = makeFreshStore()
const f = s.addScriptFrame('no-such-id', 1)
expect(f).toBe(null)
})
test('script frames stay sorted by time', () => {
const s = makeFreshStore()
const t = s.addScriptTrack()
s.addScriptFrame(t.id, 5)
s.addScriptFrame(t.id, 1)
s.addScriptFrame(t.id, 3)
const times = (s.scene.scriptFrames ?? []).map((f) => f.time)
expect(times).toEqual([1, 3, 5])
})
test('removeScriptTrack cascades its frames and trackOrder entry', () => {
const s = makeFreshStore()
const t = s.addScriptTrack()
s.addScriptFrame(t.id, 1)
s.addScriptFrame(t.id, 2)
s.removeScriptTrack(t.id)
expect(s.scene.scriptTracks).toHaveLength(0)
expect(s.scene.scriptFrames).toHaveLength(0)
expect(s.scene.trackOrder?.includes(t.id)).toBe(false)
})
test('setScriptFrameActions replaces actions', () => {
const s = makeFreshStore()
const t = s.addScriptTrack()
const f = s.addScriptFrame(t.id, 1, [{ type: 'play' }])!
s.setScriptFrameActions(f.id, [{ type: 'pause' }, { type: 'rewindToLoop' }])
const updated = s.scene.scriptFrames?.find((x) => x.id === f.id)
expect(updated?.actions).toHaveLength(2)
expect(updated?.actions[0].type).toBe('pause')
})
test('setScriptFrameTime clamps and re-sorts', () => {
const s = makeFreshStore()
s.setDuration(10)
const t = s.addScriptTrack()
const a = s.addScriptFrame(t.id, 1)!
const b = s.addScriptFrame(t.id, 2)!
s.setScriptFrameTime(a.id, 99)
const times = (s.scene.scriptFrames ?? []).map((f) => f.time)
expect(times).toEqual([2, 10])
void b
})
})
suite('scene store — button clips & instances', () => {
test('addButtonClip creates 4 empty state stacks', () => {
const s = makeFreshStore()
const bc = s.addButtonClip('Play')
expect(bc.name).toBe('Play')
expect(bc.states.normal).toHaveLength(0)
expect(bc.states.hover).toHaveLength(0)
expect(bc.states.pressed).toHaveLength(0)
expect(bc.states.disabled).toHaveLength(0)
})
test('addButtonInstance validates clip and track ids', () => {
const s = makeFreshStore()
const bc = s.addButtonClip('B')
const ct = s.addClipTrack()
expect(s.addButtonInstance('no-clip', ct.id, 0)).toBe(null)
expect(s.addButtonInstance(bc.id, 'no-track', 0)).toBe(null)
const inst = s.addButtonInstance(bc.id, ct.id, 0)
expect(inst).toBeTruthy()
expect(s.scene.buttonInstances).toHaveLength(1)
})
test('removeButtonClip cascades its instances', () => {
const s = makeFreshStore()
const bc = s.addButtonClip('B')
const ct = s.addClipTrack()
s.addButtonInstance(bc.id, ct.id, 0)
s.addButtonInstance(bc.id, ct.id, 1)
s.removeButtonClip(bc.id)
expect(s.scene.buttonClips).toHaveLength(0)
expect(s.scene.buttonInstances).toHaveLength(0)
})
test('setButtonInstanceLabel enforces scene-wide uniqueness', () => {
const s = makeFreshStore()
const bc = s.addButtonClip('B')
const ct = s.addClipTrack()
const a = s.addButtonInstance(bc.id, ct.id, 0)!
const b = s.addButtonInstance(bc.id, ct.id, 1)!
expect(s.setButtonInstanceLabel(a.id, 'play')).toBe(true)
expect(s.setButtonInstanceLabel(b.id, 'play')).toBe(false)
expect(s.setButtonInstanceLabel(b.id, 'mute')).toBe(true)
const getA = s.scene.buttonInstances?.find((x) => x.id === a.id)
const getB = s.scene.buttonInstances?.find((x) => x.id === b.id)
expect(getA?.label).toBe('play')
expect(getB?.label).toBe('mute')
})
test('setButtonInstanceOnClick + setButtonInstanceDisabled', () => {
const s = makeFreshStore()
const bc = s.addButtonClip('B')
const ct = s.addClipTrack()
const inst = s.addButtonInstance(bc.id, ct.id, 0)!
s.setButtonInstanceOnClick(inst.id, [{ type: 'pause' }])
s.setButtonInstanceDisabled(inst.id, true)
const get = () => s.scene.buttonInstances?.find((x) => x.id === inst.id)
expect(get()?.onClick).toHaveLength(1)
expect(get()?.disabled).toBe(true)
s.setButtonInstanceOnClick(inst.id, [])
s.setButtonInstanceDisabled(inst.id, false)
expect(get()?.onClick).toBe(undefined)
expect(get()?.disabled).toBe(undefined)
})
})
suite('scene store — macro model normalization', () => {
test('loadScene initializes new arrays when missing', () => {
const s = makeFreshStore()
const bare = {
version: 1 as const,
canvas: { width: 640, height: 480, background: '#ffffff' },
duration: 5,
fps: 30,
assets: [],
layers: [],
}
s.loadScene(bare)
expect(s.scene.buttonClips).toEqual([])
expect(s.scene.buttonInstances).toEqual([])
expect(s.scene.scriptTracks).toEqual([])
expect(s.scene.scriptFrames).toEqual([])
expect(s.scene.markers).toEqual([])
})
test('setTrackLocked also works on script tracks', () => {
const s = makeFreshStore()
const t = s.addScriptTrack()
s.setTrackLocked(t.id, true)
expect(s.scene.scriptTracks?.find((x) => x.id === t.id)?.locked).toBe(true)
s.setTrackLocked(t.id, false)
expect(s.scene.scriptTracks?.find((x) => x.id === t.id)?.locked).toBe(undefined)
})
})
}