0.8.0: group colour cascade, still-frame past end, project rename, UX polish
Headline: - Group colour cascades to every non-overridden child (layer or clip track). New `colorOverridden` flag on Layer / ClipTrack / ScriptTrack / TrackGroup; setTrackColor on a non-group pins the track, clearing re-inherits from the parent group. moveTrackIntoGroup also inherits. normalizeScene migrates legacy scenes (existing colour ⇒ overridden). - Keyframes inside a button-asset timeline snap to the 4 state slots (normal/hover/pressed/disabled). upsertKeyframe / setKeyframeTime / duplicateKeyframe all enforce it; colliding drags refuse the move, duplicates walk to the nearest free slot. - Backspace / Delete on a selected layer removes it (respecting the layer's own lock and any enclosing group lock). - "Still frame" allowed on the last keyframe: the layer stays on stage past its final kf. Timeline draws a solid dim tail line from the last kf to the end, and single-keyframe tracks now show a full-span line so on-stage/off-stage reads at a glance. Height stays 3px. - Project name moved next to the logo, turns into a rename button on hover (pencil fades in). Rename syncs the stored record when the project is already persisted. - Shadow + blur keyframes animate in the preview and exported runtime: the runtime's lerpProps used to drop them, so every preview rendered shadow=0 between keyframes. RUNTIME_VERSION 11 → 13 (covers the button-mask + shadow fixes). Held-last-kf also taught to the runtime. - Button masks render across every path: edit-mode mask-preview, nested buttons on the main stage, sidebar thumbnails, fullscreen preview + exports. drawButtonFrame new in render.ts. Smaller fixes: - Dragging layer A on the stage with a stale selection on layer B used to move B and snap A back. Now the shift-selected branch only runs when the dragged layer is itself in the selection. - Clip-track row was missing its icon — PhFilmStrip was referenced but never imported. Added, plus dropped the bespoke `gap` on clip / script name rows so their icon+label spacing matches layer rows. - Clip-art drawer pre-lays-out its 400px grid behind `overflow: hidden` so slide-in reveals a finished grid instead of reflowing auto-fill columns over 220ms. - Timeline clip-track dropzone hides until the library has a clip; its "drop a clip here" hint stays centred in the visible ruler area. - Keyframe context menu: check-mark and icons share a single 12px column so the ✓ on an easing option lines up with PhPauseCircle / PhCopy / PhTrash on the rows below. "Still frame"'s active indicator moved to the trailing-check slot. - Button editor's mask-preview now shows the button's own stage instead of leaking through to the main scene. drawButtonFrame drives it. Regression tests cover all of the above: group-color cascade, pin, clear-re-inherit, migration, button-kf snap and collision rules, backspace-deletes-selected-layer (incl. lock guards + editable-target guard), still-frame-past-end visibility, clipart drawer rigid width, mask rendering in drawButtonFrame / drawSceneFrame, shadow + blur interpolation in both canvas and runtime paths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
ac89f6762d
commit
1e89ea86e0
15 files changed
+849
-74
No files matched your search
@@ -34,6 +34,7 @@ import Modal from '@/components/ui/Modal.vue'
|
||||
import IconButton from '@/components/ui/IconButton.vue'
|
||||
import Toast from '@/components/ui/Toast.vue'
|
||||
import PropertiesPanel from '@/components/PropertiesPanel.vue'
|
||||
import ClipartDrawer from '@/components/ClipartDrawer.vue'
|
||||
import { expect, suite, test } from './testRunner'
|
||||
|
||||
interface MountedHandle {
|
||||
@@ -462,6 +463,57 @@ export function registerAllTests(): void {
|
||||
})
|
||||
})
|
||||
|
||||
suite('interpolation — shadow properties', () => {
|
||||
// Regression: shadow keyframes used to render static (or nothing) in
|
||||
// the preview because the runtime's lerpProps dropped shadow fields.
|
||||
// The canvas-side sampleLayer must interpolate them correctly; this
|
||||
// pins the contract both paths share.
|
||||
|
||||
function rectWithShadow() {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'Sh', width: 10, height: 10, fill: '#fff' })
|
||||
const l = s.addLayer(a.id)!
|
||||
// Seed both endpoints so there's a clear interpolation target.
|
||||
s.upsertKeyframe(l.id, 0, {
|
||||
shadowOpacity: 0,
|
||||
shadowBlur: 0,
|
||||
shadowDistance: 0,
|
||||
shadowAngle: 0,
|
||||
shadowColor: '#000000',
|
||||
})
|
||||
s.upsertKeyframe(l.id, 2, {
|
||||
shadowOpacity: 1,
|
||||
shadowBlur: 20,
|
||||
shadowDistance: 10,
|
||||
shadowAngle: 180,
|
||||
shadowColor: '#ffffff',
|
||||
})
|
||||
return l
|
||||
}
|
||||
|
||||
test('shadowOpacity lerps halfway between keyframes', () => {
|
||||
const l = rectWithShadow()
|
||||
const mid = sampleLayer(l, 1)
|
||||
expect(mid.shadowOpacity).toBe(0.5)
|
||||
})
|
||||
|
||||
test('shadowBlur / distance / angle lerp halfway between keyframes', () => {
|
||||
const l = rectWithShadow()
|
||||
const mid = sampleLayer(l, 1)
|
||||
expect(mid.shadowBlur).toBe(10)
|
||||
expect(mid.shadowDistance).toBe(5)
|
||||
expect(mid.shadowAngle).toBe(90)
|
||||
})
|
||||
|
||||
test('shadowColor is interpolated per channel (not snapped)', () => {
|
||||
const l = rectWithShadow()
|
||||
const mid = sampleLayer(l, 1)
|
||||
// Black→white at t=0.5 lands on mid-gray (same lerpColor the
|
||||
// fill-color test above pins).
|
||||
expect(mid.shadowColor).toBe('#808080')
|
||||
})
|
||||
})
|
||||
|
||||
suite('interpolation — layer visibility (kf range)', () => {
|
||||
test('untouched baseline (single kf @0) is always visible', () => {
|
||||
const s = makeFreshStore()
|
||||
@@ -509,6 +561,38 @@ export function registerAllTests(): void {
|
||||
expect(isLayerActiveAt(l, 5)).toBe(true)
|
||||
expect(isLayerActiveAt(l, 6)).toBe(false)
|
||||
})
|
||||
|
||||
test('last kf held: layer stays visible past its last keyframe', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||||
const l = s.addLayer(a.id)!
|
||||
s.upsertKeyframe(l.id, 5, { scaleX: 2 })
|
||||
// Mark the t=5 kf as hold. It's the last keyframe — used to be a
|
||||
// no-op; now it means "stay on stage past the end of the anim".
|
||||
const last = l.keyframes.find((k) => k.time === 5)!
|
||||
s.setKeyframeHold(l.id, last.id, true)
|
||||
expect(isLayerActiveAt(l, 5)).toBe(true)
|
||||
expect(isLayerActiveAt(l, 6)).toBe(true)
|
||||
expect(isLayerActiveAt(l, 99)).toBe(true)
|
||||
// sampleLayer already pins to last.props for time >= last.time —
|
||||
// confirm the value is the last keyframe's, not interpolating out.
|
||||
expect(sampleLayer(l, 10).scaleX).toBe(2)
|
||||
})
|
||||
|
||||
test('non-last held kf does NOT extend visibility past end', () => {
|
||||
// Hold on a middle keyframe only changes how it interpolates TO
|
||||
// the following kf; it should not keep the layer alive past the
|
||||
// final keyframe when that final kf itself isn't held.
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||||
const l = s.addLayer(a.id)!
|
||||
s.upsertKeyframe(l.id, 2, { scaleX: 2 })
|
||||
s.upsertKeyframe(l.id, 5, { scaleX: 3 })
|
||||
const middle = l.keyframes.find((k) => k.time === 2)!
|
||||
s.setKeyframeHold(l.id, middle.id, true)
|
||||
expect(isLayerActiveAt(l, 5)).toBe(true)
|
||||
expect(isLayerActiveAt(l, 6)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
suite('timeline snap', () => {
|
||||
@@ -586,6 +670,21 @@ export function registerAllTests(): void {
|
||||
expect(src).toContain('AnimatrPlayer.mount')
|
||||
expect(src).toContain('BundlR')
|
||||
})
|
||||
|
||||
test('runtime lerpProps interpolates shadow + blur keyframes', () => {
|
||||
// Regression: shadow properties (shadowBlur/Opacity/Angle/Distance/
|
||||
// Color) and blur used to be dropped by the runtime's lerpProps,
|
||||
// so an animation that fades a shadow in the editor played flat
|
||||
// in the preview / exported HTML. The runtime must carry them
|
||||
// through the per-pair interpolation.
|
||||
const r = generateRuntimeScript()
|
||||
expect(r).toContain('shadowBlur')
|
||||
expect(r).toContain('shadowOpacity')
|
||||
expect(r).toContain('shadowAngle')
|
||||
expect(r).toContain('shadowDistance')
|
||||
expect(r).toContain('shadowColor')
|
||||
expect(r).toContain('out.blur')
|
||||
})
|
||||
})
|
||||
|
||||
suite('playback — transport', () => {
|
||||
@@ -1457,6 +1556,109 @@ export function registerAllTests(): void {
|
||||
expect(g.childIds).toEqual([l2.id, l1.id])
|
||||
})
|
||||
|
||||
test('group color cascades to non-overridden children', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
|
||||
const l1 = s.addLayer(a.id)!
|
||||
const l2 = s.addLayer(a.id)!
|
||||
const g = s.addTrackGroup('G')
|
||||
s.moveTrackIntoGroup(l1.id, g.id)
|
||||
s.moveTrackIntoGroup(l2.id, g.id)
|
||||
s.setTrackColor(g.id, '#ff0000')
|
||||
expect(l1.color).toBe('#ff0000')
|
||||
expect(l2.color).toBe('#ff0000')
|
||||
// The children inherited — they must NOT be marked as overridden.
|
||||
expect(l1.colorOverridden).toBe(undefined)
|
||||
expect(l2.colorOverridden).toBe(undefined)
|
||||
})
|
||||
|
||||
test('explicit per-track color pins against later group-color changes', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
|
||||
const l1 = s.addLayer(a.id)!
|
||||
const l2 = s.addLayer(a.id)!
|
||||
const g = s.addTrackGroup('G')
|
||||
s.moveTrackIntoGroup(l1.id, g.id)
|
||||
s.moveTrackIntoGroup(l2.id, g.id)
|
||||
s.setTrackColor(g.id, '#ff0000')
|
||||
// User recolours one child AFTER the group — it pins.
|
||||
s.setTrackColor(l1.id, '#00ff00')
|
||||
expect(l1.colorOverridden).toBe(true)
|
||||
// Group colour changes again — pinned child ignores it; other follows.
|
||||
s.setTrackColor(g.id, '#0000ff')
|
||||
expect(l1.color).toBe('#00ff00')
|
||||
expect(l2.color).toBe('#0000ff')
|
||||
})
|
||||
|
||||
test('clearing a child-color (null) re-inherits the group color', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
|
||||
const l = s.addLayer(a.id)!
|
||||
const g = s.addTrackGroup('G')
|
||||
s.moveTrackIntoGroup(l.id, g.id)
|
||||
s.setTrackColor(g.id, '#ff0000')
|
||||
s.setTrackColor(l.id, '#00ff00')
|
||||
expect(l.color).toBe('#00ff00')
|
||||
s.setTrackColor(l.id, null)
|
||||
// Now inheriting again.
|
||||
expect(l.color).toBe('#ff0000')
|
||||
expect(l.colorOverridden).toBe(undefined)
|
||||
})
|
||||
|
||||
test('clearing a group color clears cascade on non-overridden children', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
|
||||
const l1 = s.addLayer(a.id)!
|
||||
const l2 = s.addLayer(a.id)!
|
||||
const g = s.addTrackGroup('G')
|
||||
s.moveTrackIntoGroup(l1.id, g.id)
|
||||
s.moveTrackIntoGroup(l2.id, g.id)
|
||||
s.setTrackColor(g.id, '#ff0000')
|
||||
s.setTrackColor(l2.id, '#00ff00')
|
||||
s.setTrackColor(g.id, null)
|
||||
expect(l1.color).toBe(undefined)
|
||||
// l2 was pinned, keeps its explicit colour.
|
||||
expect(l2.color).toBe('#00ff00')
|
||||
})
|
||||
|
||||
test('moving a track into a coloured group inherits its color', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
|
||||
const l = s.addLayer(a.id)!
|
||||
const g = s.addTrackGroup('G')
|
||||
s.setTrackColor(g.id, '#ff0000')
|
||||
s.moveTrackIntoGroup(l.id, g.id)
|
||||
expect(l.color).toBe('#ff0000')
|
||||
expect(l.colorOverridden).toBe(undefined)
|
||||
})
|
||||
|
||||
test('moving an overridden track into a coloured group keeps its color', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
|
||||
const l = s.addLayer(a.id)!
|
||||
s.setTrackColor(l.id, '#00ff00')
|
||||
const g = s.addTrackGroup('G')
|
||||
s.setTrackColor(g.id, '#ff0000')
|
||||
s.moveTrackIntoGroup(l.id, g.id)
|
||||
expect(l.color).toBe('#00ff00')
|
||||
})
|
||||
|
||||
test('loading an older scene treats pre-existing track colors as overridden', () => {
|
||||
// Migration: scenes saved before colorOverridden shipped must
|
||||
// preserve their per-track colour choices against future group
|
||||
// cascades. normalizeScene stamps colorOverridden=true on any
|
||||
// track that arrives with a colour and no flag.
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
|
||||
const l = s.addLayer(a.id)!
|
||||
const snap = JSON.parse(JSON.stringify(s.scene))
|
||||
snap.layers[0].color = '#123456'
|
||||
// legacy: no colorOverridden field on the layer
|
||||
s.loadScene(snap)
|
||||
const loaded = s.scene.layers.find((x) => x.id === l.id)!
|
||||
expect(loaded.colorOverridden).toBe(true)
|
||||
})
|
||||
|
||||
test('normalizeScene (via loadScene) removes orphan childIds and deduplicates', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'N', width: 10, height: 10, fill: '#666' })
|
||||
@@ -1524,6 +1726,103 @@ export function registerAllTests(): void {
|
||||
})
|
||||
})
|
||||
|
||||
suite('timeline — backspace deletes selected layer', () => {
|
||||
// Regression: Backspace / Delete used to only remove selected
|
||||
// keyframes and markers. The selected layer track had no shortcut
|
||||
// — the user had to use the context menu. Timeline's document-level
|
||||
// keydown now also deletes the currently-selected layer, skipping
|
||||
// locked layers (direct or via a locked parent group).
|
||||
|
||||
async function clearTimelineSelections() {
|
||||
// The timeline's marker selection lives in a component-local ref
|
||||
// that the store reset doesn't touch. Two Escape presses clear
|
||||
// keyframes first, then markers — matches the handler's priority.
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
||||
await nextTick()
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
test('Backspace removes the selected layer and clears the selection', async () => {
|
||||
const s = makeFreshStore()
|
||||
await clearTimelineSelections()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||||
const l = s.addLayer(a.id)!
|
||||
s.selectLayer(l.id)
|
||||
expect(s.scene.layers.some((x) => x.id === l.id)).toBe(true)
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace' }))
|
||||
await nextTick()
|
||||
expect(s.scene.layers.some((x) => x.id === l.id)).toBe(false)
|
||||
expect(s.selectedLayerId).toBe(null)
|
||||
})
|
||||
|
||||
test('Delete also removes the selected layer', async () => {
|
||||
const s = makeFreshStore()
|
||||
await clearTimelineSelections()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||||
const l = s.addLayer(a.id)!
|
||||
s.selectLayer(l.id)
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete' }))
|
||||
await nextTick()
|
||||
expect(s.scene.layers.some((x) => x.id === l.id)).toBe(false)
|
||||
})
|
||||
|
||||
test('Backspace is a no-op while the layer itself is locked', async () => {
|
||||
const s = makeFreshStore()
|
||||
await clearTimelineSelections()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||||
const l = s.addLayer(a.id)!
|
||||
s.selectLayer(l.id)
|
||||
s.setTrackLocked(l.id, true)
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace' }))
|
||||
await nextTick()
|
||||
expect(s.scene.layers.some((x) => x.id === l.id)).toBe(true)
|
||||
})
|
||||
|
||||
test('Backspace is a no-op while the parent group is locked', async () => {
|
||||
const s = makeFreshStore()
|
||||
await clearTimelineSelections()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||||
const l = s.addLayer(a.id)!
|
||||
const g = s.addTrackGroup('G')
|
||||
s.moveTrackIntoGroup(l.id, g.id)
|
||||
s.setTrackLocked(g.id, true)
|
||||
s.selectLayer(l.id)
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace' }))
|
||||
await nextTick()
|
||||
expect(s.scene.layers.some((x) => x.id === l.id)).toBe(true)
|
||||
})
|
||||
|
||||
test('Backspace while typing in an input does not touch the selected layer', async () => {
|
||||
const s = makeFreshStore()
|
||||
await clearTimelineSelections()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||||
const l = s.addLayer(a.id)!
|
||||
s.selectLayer(l.id)
|
||||
// Mount an input, focus it, dispatch Backspace FROM the input.
|
||||
const input = document.createElement('input')
|
||||
input.setAttribute('data-test-input', '')
|
||||
document.body.appendChild(input)
|
||||
input.focus()
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true }))
|
||||
await nextTick()
|
||||
expect(s.scene.layers.some((x) => x.id === l.id)).toBe(true)
|
||||
input.remove()
|
||||
})
|
||||
|
||||
test('Backspace with no layer selected is a no-op', async () => {
|
||||
const s = makeFreshStore()
|
||||
await clearTimelineSelections()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||||
const l = s.addLayer(a.id)!
|
||||
// Explicitly deselect
|
||||
s.selectLayer(null)
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace' }))
|
||||
await nextTick()
|
||||
expect(s.scene.layers.some((x) => x.id === l.id)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
suite('version — app metadata', () => {
|
||||
test('APP_VERSION is a semver-like string', () => {
|
||||
expect(typeof APP_VERSION === 'string').toBe(true)
|
||||
@@ -2334,6 +2633,86 @@ export function registerAllTests(): void {
|
||||
})
|
||||
})
|
||||
|
||||
suite('clipart drawer — grid pre-layout', () => {
|
||||
// Regression: the drawer's `.drawer-panel` used to be `v-if`-gated on
|
||||
// clipart.open and given `flex: 1; min-width: 0` — which meant the
|
||||
// grid mounted at the same moment the drawer started its width
|
||||
// transition, so the `repeat(auto-fill, minmax(88px, 1fr))` grid
|
||||
// reflowed continuously from ~1 column at 5px → 4 columns at 424px
|
||||
// and the thumbs "tumbled" into place. The panel is now always
|
||||
// mounted at a rigid 400px, and `.clipart-drawer { overflow: hidden }`
|
||||
// just clips the slide.
|
||||
|
||||
test('.drawer-panel is in the DOM when the drawer is closed', async () => {
|
||||
const { useClipartStore } = await import('@/stores/clipart')
|
||||
const s = useClipartStore()
|
||||
s.setOpen(false)
|
||||
const handle = mountComponent(ClipartDrawer, {})
|
||||
await nextTick()
|
||||
const panel = handle.host.querySelector('.drawer-panel')
|
||||
expect(panel).toBeTruthy()
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('.drawer-panel is in the DOM when the drawer is open', async () => {
|
||||
const { useClipartStore } = await import('@/stores/clipart')
|
||||
const s = useClipartStore()
|
||||
s.setOpen(true)
|
||||
const handle = mountComponent(ClipartDrawer, {})
|
||||
await nextTick()
|
||||
const panel = handle.host.querySelector('.drawer-panel')
|
||||
expect(panel).toBeTruthy()
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('.drawer-panel stays mounted across open → closed toggle', async () => {
|
||||
const { useClipartStore } = await import('@/stores/clipart')
|
||||
const s = useClipartStore()
|
||||
s.setOpen(true)
|
||||
const handle = mountComponent(ClipartDrawer, {})
|
||||
await nextTick()
|
||||
expect(handle.host.querySelector('.drawer-panel')).toBeTruthy()
|
||||
s.setOpen(false)
|
||||
await nextTick()
|
||||
expect(handle.host.querySelector('.drawer-panel')).toBeTruthy()
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('.drawer-panel has a rigid 400px width regardless of the drawer state', async () => {
|
||||
const { useClipartStore } = await import('@/stores/clipart')
|
||||
const s = useClipartStore()
|
||||
s.setOpen(false)
|
||||
const handle = mountComponent(ClipartDrawer, {})
|
||||
await nextTick()
|
||||
const panel = handle.host.querySelector('.drawer-panel') as HTMLElement
|
||||
expect(panel).toBeTruthy()
|
||||
const widthClosed = getComputedStyle(panel).width
|
||||
s.setOpen(true)
|
||||
await nextTick()
|
||||
const widthOpen = getComputedStyle(panel).width
|
||||
expect(widthClosed).toBe('400px')
|
||||
expect(widthOpen).toBe('400px')
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('.clipart-drawer clips its contents (overflow: hidden)', async () => {
|
||||
const { useClipartStore } = await import('@/stores/clipart')
|
||||
const s = useClipartStore()
|
||||
s.setOpen(false)
|
||||
const handle = mountComponent(ClipartDrawer, {})
|
||||
await nextTick()
|
||||
const drawer = handle.host.querySelector('.clipart-drawer') as HTMLElement
|
||||
expect(drawer).toBeTruthy()
|
||||
const overflowX = getComputedStyle(drawer).overflowX
|
||||
const overflowY = getComputedStyle(drawer).overflowY
|
||||
// `overflow: hidden` applies to both axes. We check each because
|
||||
// browsers normalise the shorthand into the long-hand properties.
|
||||
expect(overflowX).toBe('hidden')
|
||||
expect(overflowY).toBe('hidden')
|
||||
handle.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
suite('polygon — vertex bezier actions', () => {
|
||||
function makeSquare() {
|
||||
const s = makeFreshStore()
|
||||
@@ -3777,6 +4156,88 @@ export function registerAllTests(): void {
|
||||
})
|
||||
})
|
||||
|
||||
suite('button asset — keyframes snap to state times', () => {
|
||||
// Regression: keyframes inside a button-asset timeline must only
|
||||
// exist at the 4 state times (BUTTON_STATE_TIMES = normal=1,
|
||||
// hover=2, pressed=3, disabled=4). Any code path that places or
|
||||
// moves a keyframe — upsertKeyframe, setKeyframeTime,
|
||||
// duplicateKeyframe — snaps the time onto the nearest state. Free
|
||||
// placement outside the 4 slots is disallowed.
|
||||
|
||||
function setupButton() {
|
||||
const s = makeFreshStore()
|
||||
const btn = s.addButtonAsset('B', 200, 80)
|
||||
s.enterButtonAssetEdit(btn.id)
|
||||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 200, height: 80, fill: '#fff' })
|
||||
const l = s.addLayer(a.id)!
|
||||
// addLayer already put a keyframe at BUTTON_STATE_TIMES.normal (=1).
|
||||
return { s, l }
|
||||
}
|
||||
|
||||
test('upsertKeyframe snaps the requested time to the nearest state', () => {
|
||||
const { s, l } = setupButton()
|
||||
// 1.4 is closer to state 1 than to state 2 → should land at 1
|
||||
// (merging with the existing one, rather than creating a new off-state kf).
|
||||
s.upsertKeyframe(l.id, 1.4, { x: 10 })
|
||||
expect(l.keyframes.every((k) => BUTTON_STATE_TIMES.normal === k.time || BUTTON_STATE_TIMES.hover === k.time || BUTTON_STATE_TIMES.pressed === k.time || BUTTON_STATE_TIMES.disabled === k.time)).toBe(true)
|
||||
// 1.6 is closer to state 2 → new keyframe lands at 2
|
||||
s.upsertKeyframe(l.id, 1.6, { x: 20 })
|
||||
expect(l.keyframes.find((k) => Math.abs(k.time - 2) < 1e-6)).toBeTruthy()
|
||||
})
|
||||
|
||||
test('setKeyframeTime snaps drag targets onto state times', () => {
|
||||
const { s, l } = setupButton()
|
||||
const kf = l.keyframes[0]
|
||||
expect(kf.time).toBe(BUTTON_STATE_TIMES.normal)
|
||||
// Drag "toward" hover but not all the way — 1.8 is closer to 2 → snaps to 2.
|
||||
s.setKeyframeTime(l.id, kf.id, 1.8)
|
||||
expect(kf.time).toBe(BUTTON_STATE_TIMES.hover)
|
||||
// And 2.9 snaps to pressed.
|
||||
s.setKeyframeTime(l.id, kf.id, 2.9)
|
||||
expect(kf.time).toBe(BUTTON_STATE_TIMES.pressed)
|
||||
})
|
||||
|
||||
test('setKeyframeTime refuses to land on an already-occupied state', () => {
|
||||
const { s, l } = setupButton()
|
||||
// Existing kf at state 1. Create a second at state 2.
|
||||
s.upsertKeyframe(l.id, BUTTON_STATE_TIMES.hover, { x: 99 })
|
||||
const kfAtHover = l.keyframes.find((k) => Math.abs(k.time - 2) < 1e-6)!
|
||||
// Drag the hover kf toward normal. State 1 is already occupied — refuse.
|
||||
s.setKeyframeTime(l.id, kfAtHover.id, 1.1)
|
||||
expect(kfAtHover.time).toBe(BUTTON_STATE_TIMES.hover)
|
||||
})
|
||||
|
||||
test('duplicateKeyframe picks the nearest free state slot', () => {
|
||||
const { s, l } = setupButton()
|
||||
// Only state 1 is occupied; duplicate should land on the next
|
||||
// closest free slot — state 2 (hover).
|
||||
const sourceId = l.keyframes[0].id
|
||||
const newId = s.duplicateKeyframe(l.id, sourceId)!
|
||||
expect(newId).toBeTruthy()
|
||||
const dup = l.keyframes.find((k) => k.id === newId)!
|
||||
expect(dup.time).toBe(BUTTON_STATE_TIMES.hover)
|
||||
})
|
||||
|
||||
test('duplicateKeyframe returns null when every state slot is taken', () => {
|
||||
const { s, l } = setupButton()
|
||||
s.upsertKeyframe(l.id, BUTTON_STATE_TIMES.hover, { x: 2 })
|
||||
s.upsertKeyframe(l.id, BUTTON_STATE_TIMES.pressed, { x: 3 })
|
||||
s.upsertKeyframe(l.id, BUTTON_STATE_TIMES.disabled, { x: 4 })
|
||||
const sourceId = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-6)!.id
|
||||
const newId = s.duplicateKeyframe(l.id, sourceId)
|
||||
expect(newId).toBe(null)
|
||||
})
|
||||
|
||||
test('on the main timeline, snapping is NOT applied (free placement)', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||||
const l = s.addLayer(a.id)!
|
||||
s.upsertKeyframe(l.id, 1.4, { x: 5 })
|
||||
// No button-edit context → the kf lives at the exact requested time.
|
||||
expect(l.keyframes.some((k) => Math.abs(k.time - 1.4) < 1e-6)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
suite('button asset — mask rendering', () => {
|
||||
// Regression: masks inside buttons used to be ignored by every render
|
||||
// path — the button branch in drawLayers called drawLayers recursively
|
||||
|
||||
Reference in new issue
Block a user