0.10.4: button still-frames render only on their own state
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
fcb8edbb88
commit
8895fc76ef
8 files changed
+311
-23
No files matched your search
+186
-1
@@ -12,7 +12,7 @@ import {
|
||||
type ActionRuntime,
|
||||
} from '@/lib/actions'
|
||||
import { parseScript } from '@/lib/scriptDsl'
|
||||
import type { Action, ButtonAsset, HitShape } from '@/types/scene'
|
||||
import type { Action, ButtonAsset, HitShape, Layer } from '@/types/scene'
|
||||
import { BUTTON_STATE_TIMES } from '@/types/scene'
|
||||
import { isLayerActiveAt, sampleLayer, lerpColor } from '@/lib/interpolate'
|
||||
import { snapToCandidates } from '@/lib/timelineSnap'
|
||||
@@ -6697,6 +6697,191 @@ export function registerAllTests(): void {
|
||||
})
|
||||
})
|
||||
|
||||
suite('button asset — still-frame visibility gate', () => {
|
||||
// Button assets have 4 discrete state times (1,2,3,4). A still-frame
|
||||
// (hold) keyframe must only be visible on its exact state frame —
|
||||
// not held forward to the neighbours as on regular timelines.
|
||||
test('isButtonLayerActiveAt: hold kf at t=2 only visible at t=2', async () => {
|
||||
const { isButtonLayerActiveAt } = await import('@/lib/interpolate')
|
||||
const layer: Layer = {
|
||||
id: 'l1',
|
||||
name: '',
|
||||
assetId: 'a',
|
||||
visible: true,
|
||||
base: { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
|
||||
keyframes: [
|
||||
{
|
||||
id: 'k1',
|
||||
time: 2,
|
||||
props: { x: 10 },
|
||||
easing: 'linear',
|
||||
hold: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
expect(isButtonLayerActiveAt(layer, 1)).toBe(false)
|
||||
expect(isButtonLayerActiveAt(layer, 2)).toBe(true)
|
||||
expect(isButtonLayerActiveAt(layer, 3)).toBe(false)
|
||||
expect(isButtonLayerActiveAt(layer, 4)).toBe(false)
|
||||
})
|
||||
|
||||
test('isButtonLayerActiveAt: non-hold kfs at t=1 and t=4 still interpolate between states', async () => {
|
||||
const { isButtonLayerActiveAt } = await import('@/lib/interpolate')
|
||||
const layer: Layer = {
|
||||
id: 'l',
|
||||
name: '',
|
||||
assetId: 'a',
|
||||
visible: true,
|
||||
base: { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
|
||||
keyframes: [
|
||||
{ id: 'k1', time: 1, props: { x: 0 }, easing: 'linear' },
|
||||
{ id: 'k2', time: 4, props: { x: 100 }, easing: 'linear' },
|
||||
],
|
||||
}
|
||||
expect(isButtonLayerActiveAt(layer, 1)).toBe(true)
|
||||
expect(isButtonLayerActiveAt(layer, 2)).toBe(true)
|
||||
expect(isButtonLayerActiveAt(layer, 3)).toBe(true)
|
||||
expect(isButtonLayerActiveAt(layer, 4)).toBe(true)
|
||||
})
|
||||
|
||||
test('isButtonLayerActiveAt: a hold kf followed by a regular kf shows only on both exact frames', async () => {
|
||||
const { isButtonLayerActiveAt } = await import('@/lib/interpolate')
|
||||
const layer: Layer = {
|
||||
id: 'l',
|
||||
name: '',
|
||||
assetId: 'a',
|
||||
visible: true,
|
||||
base: { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
|
||||
keyframes: [
|
||||
{ id: 'k1', time: 2, props: { x: 0 }, easing: 'linear', hold: true },
|
||||
{ id: 'k2', time: 4, props: { x: 100 }, easing: 'linear' },
|
||||
],
|
||||
}
|
||||
expect(isButtonLayerActiveAt(layer, 1)).toBe(false)
|
||||
expect(isButtonLayerActiveAt(layer, 2)).toBe(true)
|
||||
// hold at t=2 → the region (2, 4) is "holding in silence", invisible.
|
||||
expect(isButtonLayerActiveAt(layer, 3)).toBe(false)
|
||||
expect(isButtonLayerActiveAt(layer, 4)).toBe(true)
|
||||
})
|
||||
|
||||
test('isButtonLayerActiveAt: no keyframes → always visible (static layer)', async () => {
|
||||
const { isButtonLayerActiveAt } = await import('@/lib/interpolate')
|
||||
const layer: Layer = {
|
||||
id: 'l',
|
||||
name: '',
|
||||
assetId: 'a',
|
||||
visible: true,
|
||||
base: { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
|
||||
keyframes: [],
|
||||
}
|
||||
expect(isButtonLayerActiveAt(layer, 1)).toBe(true)
|
||||
expect(isButtonLayerActiveAt(layer, 3)).toBe(true)
|
||||
})
|
||||
|
||||
test('runtime engine embeds button-specific still-frame visibility', () => {
|
||||
const r = generateRuntimeScript()
|
||||
expect(r).toContain('buttonLayerActiveAt')
|
||||
})
|
||||
|
||||
test('timeline segments skip the connector line before a still-frame kf in button mode', async () => {
|
||||
// In button-edit the state frames are discrete: the connector
|
||||
// line that spans between kfs on a regular timeline must NOT
|
||||
// be drawn from a preceding kf into a hold kf — that would
|
||||
// suggest the layer is on-screen across the intervening states.
|
||||
// Pin the button-mode branch of segmentsOf structurally: three
|
||||
// kfs, middle one hold → no segment coming INTO the still.
|
||||
const { default: T } = await import('@/components/Timeline.vue')
|
||||
void T
|
||||
// Reach into the private helper via a minimal contract: mount
|
||||
// Timeline inside a button-asset-edit context and inspect the
|
||||
// rendered .kf-seg elements for the track.
|
||||
const s = makeFreshStore()
|
||||
const btn = s.addButtonAsset('B', 100, 100)
|
||||
s.enterButtonAssetEdit(btn.id)
|
||||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#abc' })
|
||||
const layer = s.addLayer(a.id)!
|
||||
// Lay out three kfs: normal (non-hold), hover (hold), disabled (non-hold).
|
||||
layer.keyframes = [
|
||||
{ id: 'k1', time: BUTTON_STATE_TIMES.normal, props: {}, easing: 'linear' },
|
||||
{ id: 'k2', time: BUTTON_STATE_TIMES.hover, props: {}, easing: 'linear', hold: true },
|
||||
{ id: 'k3', time: BUTTON_STATE_TIMES.disabled, props: {}, easing: 'linear' },
|
||||
]
|
||||
const handle = mountComponent(Timeline, {})
|
||||
await nextTick()
|
||||
const segs = handle.host.querySelectorAll('.kf-seg')
|
||||
// Expected segments in button-mode:
|
||||
// 1→2 (non-hold kf1 to hold kf2): drawn (interpolation into still)
|
||||
// …actually no: hold k2 terminates *emerging* lines, not
|
||||
// incoming. Only the OUT-segments from a hold are skipped.
|
||||
// 2→3 (hold kf2 to non-hold kf3): SKIPPED (no line out of still).
|
||||
expect(segs.length).toBe(1)
|
||||
s.exitButtonAssetEdit()
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('addLayer in button-edit seeds the initial kf on the ACTIVE state, not always normal', () => {
|
||||
const s = makeFreshStore()
|
||||
const btn = s.addButtonAsset('B', 100, 100)
|
||||
s.enterButtonAssetEdit(btn.id)
|
||||
s.setActiveButtonState('disabled')
|
||||
const asset = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#000' })
|
||||
const layer = s.addLayer(asset.id)!
|
||||
expect(layer.keyframes).toHaveLength(1)
|
||||
expect(layer.keyframes[0].time).toBe(BUTTON_STATE_TIMES.disabled)
|
||||
s.exitButtonAssetEdit()
|
||||
void btn
|
||||
})
|
||||
|
||||
test('drawButtonFrame renders a hold-kf layer ONLY on its own state frame', () => {
|
||||
// End-to-end UI test: construct a button with a single rect
|
||||
// whose layer has a hold-keyframe at the hover state. Then
|
||||
// rasterise `drawButtonFrame` against each of the 4 state times
|
||||
// and verify the rect's centre pixel is red only at hover and
|
||||
// transparent on every other state.
|
||||
const s = makeFreshStore()
|
||||
const btn = s.addButtonAsset('B', 100, 100)
|
||||
s.enterButtonAssetEdit(btn.id)
|
||||
const red = s.addAsset({
|
||||
kind: 'rect',
|
||||
name: 'R',
|
||||
width: 100,
|
||||
height: 100,
|
||||
fill: '#ff0000',
|
||||
})
|
||||
const layer = s.addLayer(red.id)!
|
||||
// Replace the default normal-state kf with a single hold-kf at hover.
|
||||
layer.keyframes = [
|
||||
{
|
||||
id: 'k',
|
||||
time: BUTTON_STATE_TIMES.hover,
|
||||
props: {
|
||||
x: 50, y: 50, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1,
|
||||
},
|
||||
easing: 'linear',
|
||||
hold: true,
|
||||
},
|
||||
]
|
||||
s.exitButtonAssetEdit()
|
||||
const button = s.scene.assets.find((x) => x.id === btn.id) as ButtonAsset
|
||||
|
||||
function probe(stateTime: number): boolean {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 100
|
||||
canvas.height = 100
|
||||
const ctx = canvas.getContext('2d')!
|
||||
drawButtonFrame(ctx, button, s.scene, stateTime, {})
|
||||
const data = ctx.getImageData(50, 50, 1, 1).data
|
||||
// Red + opaque = visible. Any other reading (alpha 0, or colour
|
||||
// washed out) → not rendered.
|
||||
return data[0] > 200 && data[3] > 200
|
||||
}
|
||||
expect(probe(BUTTON_STATE_TIMES.normal)).toBe(false)
|
||||
expect(probe(BUTTON_STATE_TIMES.hover)).toBe(true)
|
||||
expect(probe(BUTTON_STATE_TIMES.pressed)).toBe(false)
|
||||
expect(probe(BUTTON_STATE_TIMES.disabled)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
suite('button asset — data model + edit mode', () => {
|
||||
test('addButtonAsset creates asset with empty layers and hitArea', () => {
|
||||
const s = makeFreshStore()
|
||||
|
||||
Reference in new issue
Block a user