From 22b553e931418fd3e2fab303ceda0cecbddaddd0 Mon Sep 17 00:00:00 2001
From: th
Date: Mon, 20 Apr 2026 22:00:58 +0200
Subject: [PATCH] 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)
---
src/components/CanvasStage.vue | 43 +----
src/devtools/tests.ts | 336 ++++++++++++++++++++++++++++++++-
src/lib/buttonBlend.ts | 55 ++++++
3 files changed, 394 insertions(+), 40 deletions(-)
create mode 100644 src/lib/buttonBlend.ts
diff --git a/src/components/CanvasStage.vue b/src/components/CanvasStage.vue
index 16dcd8c..0604c83 100644
--- a/src/components/CanvasStage.vue
+++ b/src/components/CanvasStage.vue
@@ -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()
- const toByName = new Map()
- for (const l of fromLayers) fromByName.set(l.name, l)
- for (const l of toLayers) toByName.set(l.name, l)
- const seen = new Set()
- 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(() => {
diff --git a/src/devtools/tests.ts b/src/devtools/tests.ts
index 07c0c6b..9dc6139 100644
--- a/src/devtools/tests.ts
+++ b/src/devtools/tests.ts
@@ -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)
+ })
+ })
}
diff --git a/src/lib/buttonBlend.ts b/src/lib/buttonBlend.ts
new file mode 100644
index 0000000..62fbcfc
--- /dev/null
+++ b/src/lib/buttonBlend.ts
@@ -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()
+ const toByName = new Map()
+ 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
+}
|