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:
1 parent
6471c446df
commit
22b553e931
3 files changed
+394
-40
No files matched your search
@@ -23,7 +23,7 @@ import {
|
||||
useButtonRuntime,
|
||||
} from '@/composables/useButtonRuntime'
|
||||
import { evalAction } from '@/lib/actions'
|
||||
import { lerpProps } from '@/lib/interpolate'
|
||||
import { blendButtonStates } from '@/lib/buttonBlend'
|
||||
import { t } from '@/i18n'
|
||||
import { useKfSelection } from '@/composables/useKfSelection'
|
||||
import { showLocked } from '@/composables/useShowLocked'
|
||||
@@ -226,50 +226,15 @@ function sampleStateLayers(
|
||||
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(
|
||||
clip: ButtonClip,
|
||||
from: ButtonState,
|
||||
to: ButtonState,
|
||||
u: number,
|
||||
): ButtonRenderItem['layers'] {
|
||||
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 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
|
||||
return blendButtonStates(clip, from, to, u, (id) =>
|
||||
store.scene.assets.find((a) => a.id === id),
|
||||
)
|
||||
}
|
||||
|
||||
const activeButtonInstances = computed<ButtonRenderItem[]>(() => {
|
||||
|
||||
+335
-1
@@ -4,6 +4,7 @@ import {
|
||||
applyButtonStateChange,
|
||||
useButtonRuntime,
|
||||
} from '@/composables/useButtonRuntime'
|
||||
import { blendButtonStates } from '@/lib/buttonBlend'
|
||||
import {
|
||||
buildMarkerMap,
|
||||
evalAction,
|
||||
@@ -11,7 +12,7 @@ import {
|
||||
type ActionRuntime,
|
||||
} from '@/lib/actions'
|
||||
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 { snapToCandidates } from '@/lib/timelineSnap'
|
||||
import {
|
||||
@@ -3627,4 +3628,337 @@ export function registerAllTests(): void {
|
||||
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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user