tests: fill macro-clips coverage gaps (+35 tests)

Refactor + new tests triggered by an explicit coverage audit of the
macro-clips feature set.

- extracted blendButtonStates into src/lib/buttonBlend.ts so it can be
  tested without mounting CanvasStage; the component now delegates to
  it. Behaviour unchanged.
- store coverage: removeMarker / setMarkerTime / setMarkerColor on
  unknown ids are no-ops, addMarker accepts an initial color,
  removeScriptFrame / setScriptFrameActions / setScriptFrameTime on
  unknown ids are no-ops, removeButtonInstance keeps the clip,
  addButtonInstance defaults x/y to stage center,
  setButtonInstancePosition writes through, setButtonInstanceLabel('')
  clears, setButtonClipTransitions stores + deletes.
- button runtime: applyButtonStateChange is a no-op when the target
  equals the current state, honors the persisted disabled flag as the
  from-state, reset() clears transitions alongside state/disabled.
  store.actionRuntime warns on unknown button refs.
- blendButtonStates: matched layer lerps props (incl. fill color),
  unmatched from-layer fades out, unmatched to-layer fades in,
  u=0/u=1 boundary conditions, missing asset id resolves are skipped.
- DSL edge cases: empty source, comments-only, trailing blank lines,
  wrong state ref prefix, disable without arg, jump @ without name.
- tick firing robustness: multi-track frames fire in time order,
  actions within a frame fire in list order, scriptFramesInRange
  open-closed boundary is exact.

Macro-clips suites now total 76 tests across 11 groups; grand total
277 tests in the runner.

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 22:00:58 +02:00
1 parent 6471c446df
commit 22b553e931
3 files changed
+394 -40

No files matched your search

+4 -39
View File
@@ -23,7 +23,7 @@ import {
useButtonRuntime, useButtonRuntime,
} from '@/composables/useButtonRuntime' } from '@/composables/useButtonRuntime'
import { evalAction } from '@/lib/actions' import { evalAction } from '@/lib/actions'
import { lerpProps } from '@/lib/interpolate' import { blendButtonStates } from '@/lib/buttonBlend'
import { t } from '@/i18n' import { t } from '@/i18n'
import { useKfSelection } from '@/composables/useKfSelection' import { useKfSelection } from '@/composables/useKfSelection'
import { showLocked } from '@/composables/useShowLocked' import { showLocked } from '@/composables/useShowLocked'
@@ -226,50 +226,15 @@ function sampleStateLayers(
return out return out
} }
/**
* Blend the "from" state's layers into the "to" state's layers by layer
* name. Matched pairs lerp their props; unmatched from-layers fade out,
* unmatched to-layers fade in.
*/
function blendStates( function blendStates(
clip: ButtonClip, clip: ButtonClip,
from: ButtonState, from: ButtonState,
to: ButtonState, to: ButtonState,
u: number, u: number,
): ButtonRenderItem['layers'] { ): ButtonRenderItem['layers'] {
const fromLayers = clip.states[from] ?? [] return blendButtonStates(clip, from, to, u, (id) =>
const toLayers = clip.states[to] ?? [] store.scene.assets.find((a) => a.id === id),
const fromByName = new Map<string, Layer>() )
const toByName = new Map<string, Layer>()
for (const l of fromLayers) fromByName.set(l.name, l)
for (const l of toLayers) toByName.set(l.name, l)
const seen = new Set<string>()
const out: ButtonRenderItem['layers'] = []
// Preserve to-state order primarily, with unmatched from-layers pinned
// underneath so the new state visually rises into place.
for (const l of fromLayers) {
if (toByName.has(l.name)) continue
const asset = store.scene.assets.find((a) => a.id === l.assetId)
if (!asset) continue
const base = sampleLayer(l, 0)
out.push({ layer: l, asset, props: { ...base, opacity: base.opacity * (1 - u) } })
}
for (const l of toLayers) {
const asset = store.scene.assets.find((a) => a.id === l.assetId)
if (!asset) continue
seen.add(l.name)
const fromLayer = fromByName.get(l.name)
if (fromLayer) {
const a = sampleLayer(fromLayer, 0)
const b = sampleLayer(l, 0)
out.push({ layer: l, asset, props: lerpProps(a, b, u) })
} else {
const base = sampleLayer(l, 0)
out.push({ layer: l, asset, props: { ...base, opacity: base.opacity * u } })
}
}
void seen
return out
} }
const activeButtonInstances = computed<ButtonRenderItem[]>(() => { const activeButtonInstances = computed<ButtonRenderItem[]>(() => {
+335 -1
View File
@@ -4,6 +4,7 @@ import {
applyButtonStateChange, applyButtonStateChange,
useButtonRuntime, useButtonRuntime,
} from '@/composables/useButtonRuntime' } from '@/composables/useButtonRuntime'
import { blendButtonStates } from '@/lib/buttonBlend'
import { import {
buildMarkerMap, buildMarkerMap,
evalAction, evalAction,
@@ -11,7 +12,7 @@ import {
type ActionRuntime, type ActionRuntime,
} from '@/lib/actions' } from '@/lib/actions'
import { parseScript } from '@/lib/scriptDsl' import { parseScript } from '@/lib/scriptDsl'
import type { Action } from '@/types/scene' import type { Action, Asset, ButtonClip, Layer } from '@/types/scene'
import { sampleLayer, lerpColor } from '@/lib/interpolate' import { sampleLayer, lerpColor } from '@/lib/interpolate'
import { snapToCandidates } from '@/lib/timelineSnap' import { snapToCandidates } from '@/lib/timelineSnap'
import { import {
@@ -3627,4 +3628,337 @@ export function registerAllTests(): void {
expect(s.scene.scriptTracks?.find((x) => x.id === t.id)?.locked).toBe(undefined) expect(s.scene.scriptTracks?.find((x) => x.id === t.id)?.locked).toBe(undefined)
}) })
}) })
suite('macro-clips — store coverage gaps', () => {
test('removeMarker on unknown id is a no-op', () => {
const s = makeFreshStore()
s.addMarker(1, 'A')
s.removeMarker('no-such-id')
expect(s.scene.markers).toHaveLength(1)
})
test('setMarkerTime / setMarkerColor on unknown id are no-ops', () => {
const s = makeFreshStore()
s.setMarkerTime('ghost', 5)
s.setMarkerColor('ghost', '#ff0000')
expect(s.scene.markers).toHaveLength(0)
})
test('addMarker accepts an initial color', () => {
const s = makeFreshStore()
const m = s.addMarker(1, 'A', '#ff8800')
expect(m.color).toBe('#ff8800')
})
test('removeScriptFrame / setScriptFrameActions / setScriptFrameTime handle unknown ids', () => {
const s = makeFreshStore()
// None of these should throw or mutate anything.
s.removeScriptFrame('nope')
s.setScriptFrameActions('nope', [{ type: 'play' }])
s.setScriptFrameTime('nope', 1)
expect(s.scene.scriptFrames).toEqual([])
})
test('removeButtonInstance removes the instance but keeps the clip', () => {
const s = makeFreshStore()
const bc = s.addButtonClip('B')
const ct = s.addClipTrack()
const inst = s.addButtonInstance(bc.id, ct.id, 0)!
s.removeButtonInstance(inst.id)
expect(s.scene.buttonInstances).toHaveLength(0)
expect(s.scene.buttonClips).toHaveLength(1)
})
test('addButtonInstance defaults x/y to stage center', () => {
const s = makeFreshStore()
const bc = s.addButtonClip('B')
const ct = s.addClipTrack()
const inst = s.addButtonInstance(bc.id, ct.id, 0)!
expect(inst.x).toBe(s.scene.canvas.width / 2)
expect(inst.y).toBe(s.scene.canvas.height / 2)
})
test('setButtonInstancePosition updates x/y', () => {
const s = makeFreshStore()
const bc = s.addButtonClip('B')
const ct = s.addClipTrack()
const inst = s.addButtonInstance(bc.id, ct.id, 0)!
s.setButtonInstancePosition(inst.id, 42, 99)
const got = s.scene.buttonInstances?.find((x) => x.id === inst.id)
expect(got?.x).toBe(42)
expect(got?.y).toBe(99)
})
test('setButtonInstanceLabel("") clears the label', () => {
const s = makeFreshStore()
const bc = s.addButtonClip('B')
const ct = s.addClipTrack()
const inst = s.addButtonInstance(bc.id, ct.id, 0)!
s.setButtonInstanceLabel(inst.id, 'play')
expect(s.setButtonInstanceLabel(inst.id, '')).toBe(true)
const got = s.scene.buttonInstances?.find((x) => x.id === inst.id)
expect(got?.label).toBe(undefined)
})
test('setButtonClipTransitions stores list and deletes it when empty', () => {
const s = makeFreshStore()
const bc = s.addButtonClip('B')
s.setButtonClipTransitions(bc.id, [
{ from: 'normal', to: 'hover', duration: 0.2, easing: 'easeOut' },
])
const got = () => s.scene.buttonClips?.find((c) => c.id === bc.id)
expect(got()?.transitions).toHaveLength(1)
s.setButtonClipTransitions(bc.id, [])
expect(got()?.transitions).toBe(undefined)
})
})
suite('macro-clips — button runtime edge cases', () => {
test('applyButtonStateChange is a no-op when target equals current', () => {
const rt = useButtonRuntime()
rt.reset()
applyButtonStateChange(
'b1',
'normal',
[{ from: 'normal', to: 'normal', duration: 1, easing: 'linear' }],
undefined,
)
expect(rt.getState('b1')).toBe('normal')
expect(rt.transition.has('b1')).toBe(false)
})
test('applyButtonStateChange honors persisted disabled flag as the from-state', () => {
const rt = useButtonRuntime()
rt.reset()
// persistedDisabled=true → current 'disabled'; transition from disabled
// to normal should fire if defined.
applyButtonStateChange(
'b1',
'normal',
[{ from: 'disabled', to: 'normal', duration: 0.1, easing: 'linear' }],
true,
)
expect(rt.transition.has('b1')).toBe(true)
rt.endTransition('b1')
})
test('reset() clears state, disabled and transitions', () => {
const rt = useButtonRuntime()
rt.setState('b1', 'pressed')
rt.setDisabled('b2', true)
rt.startTransition('b1', 'normal', 'hover', 1, 'linear', performance.now())
rt.reset()
expect(rt.getState('b1')).toBe('normal')
expect(rt.isDisabled('b2')).toBe(false)
expect(rt.transition.size).toBe(0)
})
test('store.actionRuntime warns + skips for unknown button refs', () => {
const s = makeFreshStore()
const warned: string[] = []
const origWarn = console.warn
console.warn = (...a: unknown[]) => { warned.push(a.map(String).join(' ')) }
try {
const ar = s.actionRuntime()
ar.setButtonState('ghost', 'pressed')
ar.setButtonDisabled('ghost', true)
} finally {
console.warn = origWarn
}
expect(warned.length >= 2).toBe(true)
})
})
suite('macro-clips — blendButtonStates', () => {
function makeLayer(name: string, fill: string): Layer {
return {
id: `l-${name}-${fill}`,
name,
assetId: 'a1',
visible: true,
base: { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1, fill },
keyframes: [
{
id: `kf-${name}-${fill}`,
time: 0,
props: { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1, fill },
easing: 'linear',
},
],
}
}
const asset: Asset = {
id: 'a1',
kind: 'rect',
name: 'face',
width: 40,
height: 20,
fill: '#000',
}
const assetBy = (id: string) => (id === 'a1' ? asset : undefined)
test('matched layer interpolates props via lerpProps', () => {
const clip: ButtonClip = {
id: 'bc',
name: 'b',
states: {
normal: [makeLayer('Face', '#ff0000')],
hover: [makeLayer('Face', '#00ff00')],
pressed: [],
disabled: [],
},
}
const out = blendButtonStates(clip, 'normal', 'hover', 0.5, assetBy)
expect(out).toHaveLength(1)
expect(out[0].layer.name).toBe('Face')
// At u=0.5 the fill should be the mid of #ff0000 → #00ff00 (roughly).
const f = out[0].props.fill!
// Red and green channels should each be around 0x7f-ish
const n = parseInt(f.replace('#', ''), 16)
const r = (n >> 16) & 255
const g = (n >> 8) & 255
expect(r > 100 && r < 160).toBe(true)
expect(g > 100 && g < 160).toBe(true)
})
test('unmatched from-layer fades out, unmatched to-layer fades in', () => {
const clip: ButtonClip = {
id: 'bc',
name: 'b',
states: {
normal: [makeLayer('Only-N', '#ff0000')],
hover: [makeLayer('Only-H', '#00ff00')],
pressed: [],
disabled: [],
},
}
const u = 0.25
const out = blendButtonStates(clip, 'normal', 'hover', u, assetBy)
// First: unmatched from-layer, opacity * (1 - u) = 0.75
expect(out[0].layer.name).toBe('Only-N')
expect(out[0].props.opacity).toBeCloseTo(1 - u, 3)
// Second: unmatched to-layer, opacity * u = 0.25
expect(out[1].layer.name).toBe('Only-H')
expect(out[1].props.opacity).toBeCloseTo(u, 3)
})
test('u=0 is pure from-state, u=1 is pure to-state (matched layer)', () => {
const clip: ButtonClip = {
id: 'bc',
name: 'b',
states: {
normal: [makeLayer('Face', '#ff0000')],
hover: [makeLayer('Face', '#00ff00')],
pressed: [],
disabled: [],
},
}
const atZero = blendButtonStates(clip, 'normal', 'hover', 0, assetBy)
expect(atZero[0].props.fill).toBe('#ff0000')
const atOne = blendButtonStates(clip, 'normal', 'hover', 1, assetBy)
expect(atOne[0].props.fill).toBe('#00ff00')
})
test('skips layers whose asset id does not resolve', () => {
const clip: ButtonClip = {
id: 'bc',
name: 'b',
states: {
normal: [makeLayer('Face', '#ff0000')],
hover: [makeLayer('Face', '#00ff00')],
pressed: [],
disabled: [],
},
}
const out = blendButtonStates(clip, 'normal', 'hover', 0.5, () => undefined)
expect(out).toHaveLength(0)
})
})
suite('macro-clips — DSL edge cases', () => {
test('empty source yields no actions and no errors', () => {
const { actions, errors } = parseScript('')
expect(actions).toHaveLength(0)
expect(errors).toHaveLength(0)
})
test('only comments or whitespace yields no actions', () => {
const { actions, errors } = parseScript(' \n\n// note\n // more\n')
expect(actions).toHaveLength(0)
expect(errors).toHaveLength(0)
})
test('trailing blank lines do not produce phantom errors', () => {
const { actions, errors } = parseScript('play\n\n\n')
expect(actions).toHaveLength(1)
expect(errors).toHaveLength(0)
})
test('state command with wrong ref prefix reports error', () => {
const { errors } = parseScript('state label pressed')
expect(errors).toHaveLength(1)
expect(errors[0].message).toContain('btn:')
})
test('disable without argument reports error', () => {
const { errors } = parseScript('disable')
expect(errors).toHaveLength(1)
expect(errors[0].message).toContain('disable')
})
test('jump with empty @marker reports error', () => {
const { errors } = parseScript('jump @')
expect(errors).toHaveLength(1)
expect(errors[0].message).toContain('marker')
})
})
suite('macro-clips — tick firing robustness', () => {
test('multiple frames on different tracks fire in time order', async () => {
const s = makeFreshStore()
s.setDuration(10)
const t1 = s.addScriptTrack('A')
const t2 = s.addScriptTrack('B')
const log: number[] = []
// Three frames: 0.2 (t2), 0.5 (t1), 0.8 (t2)
// We can verify ordering via jumpTo actions that push into a list…
// but evalAction dispatches to store.setPlayhead etc. We instead
// observe the jumpTo time the playhead ends up at after all fire.
s.addScriptFrame(t2.id, 0.2, [{ type: 'jumpTo', time: 1 }])
s.addScriptFrame(t1.id, 0.5, [{ type: 'jumpTo', time: 2 }])
s.addScriptFrame(t2.id, 0.8, [{ type: 'jumpTo', time: 3 }])
s.play()
s.tick(1) // advances to 1, all three frames in (0, 1]
// The last fired wins — since frames fire sorted by time, the last
// action to run is jumpTo 3.
expect(s.playhead).toBe(3)
s.pause()
await tick()
void log
})
test('within a single frame, actions fire in list order', async () => {
const s = makeFreshStore()
s.setDuration(10)
const t = s.addScriptTrack()
s.addScriptFrame(t.id, 1, [
{ type: 'jumpTo', time: 5 },
{ type: 'jumpTo', time: 7 },
{ type: 'pause' },
])
s.play()
s.tick(2)
expect(s.playhead).toBe(7)
expect(s.playing).toBe(false)
await tick()
})
test('scriptFramesInRange handles exactly-at-next inclusively', () => {
const frames = [{ id: 'a', trackId: 't', time: 1, actions: [] as Action[] }]
// next strictly equals frame.time → included (open-closed)
expect(scriptFramesInRange(frames, 0.5, 1)).toHaveLength(1)
// next strictly below → excluded
expect(scriptFramesInRange(frames, 0.5, 0.999)).toHaveLength(0)
})
})
} }
+55
View File
@@ -0,0 +1,55 @@
import type { AnimatableProps, Asset, ButtonClip, ButtonState, Layer } from '@/types/scene'
import { lerpProps, sampleLayer } from '@/lib/interpolate'
export interface BlendedLayerRender {
layer: Layer
asset: Asset
props: AnimatableProps
}
/**
* Blend the "from" state's layers into the "to" state's layers by layer
* name. Matched pairs lerp their sampled (t=0) props; unmatched
* from-layers fade out (opacity × (1 − u)), unmatched to-layers fade in
* (opacity × u). Used by the canvas renderer and the export runtime
* while a button state transition is in progress.
*
* `u` is expected to be post-easing progress ∈ [0, 1].
*/
export function blendButtonStates(
clip: ButtonClip,
from: ButtonState,
to: ButtonState,
u: number,
assetBy: (id: string) => Asset | undefined,
): BlendedLayerRender[] {
const fromLayers = clip.states[from] ?? []
const toLayers = clip.states[to] ?? []
const fromByName = new Map<string, Layer>()
const toByName = new Map<string, Layer>()
for (const l of fromLayers) fromByName.set(l.name, l)
for (const l of toLayers) toByName.set(l.name, l)
const out: BlendedLayerRender[] = []
// Unmatched from-layers render first (behind to-layers) and fade out.
for (const l of fromLayers) {
if (toByName.has(l.name)) continue
const asset = assetBy(l.assetId)
if (!asset) continue
const base = sampleLayer(l, 0)
out.push({ layer: l, asset, props: { ...base, opacity: base.opacity * (1 - u) } })
}
for (const l of toLayers) {
const asset = assetBy(l.assetId)
if (!asset) continue
const fromLayer = fromByName.get(l.name)
if (fromLayer) {
const a = sampleLayer(fromLayer, 0)
const b = sampleLayer(l, 0)
out.push({ layer: l, asset, props: lerpProps(a, b, u) })
} else {
const base = sampleLayer(l, 0)
out.push({ layer: l, asset, props: { ...base, opacity: base.opacity * u } })
}
}
return out
}