Files
orca/src/shared/codex-pet-sprite-defaults.test.ts
T
Jinho Choiandnasagong 13c690b05a fix(pet): match Codex's per-frame pacing and pointer interactions for imported pets (#8730)
Imported .codex-pet bundles played every animation at a flat 8 fps (~9x too
fast). Mirror Codex's exact per-frame duration tables, render uneven holds as
step-end keyframes, upgrade legacy-persisted pets at render, and add the Codex
mascot's pointer interactions (hover, grab-and-hold on frame 0, horizontal-drag
running). Hardened over several adversarial review rounds: honor explicit fps,
start each row/pet from frame 0, scope drag to its pointer, and guard untrusted
persisted data.

Fixes #8729

Co-authored-by: nasagong <zinho2000@gachon.ac.kr>
2026-07-16 21:59:44 -07:00

112 lines
3.9 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
CODEX_PET_ANIMATIONS,
CODEX_PET_DEFAULT_COLUMNS,
CODEX_PET_DEFAULT_FPS,
applyCodexSpriteTimingDefaults,
type CustomPetSprite
} from './codex-pet-sprite-defaults'
function legacyCodexSprite(): CustomPetSprite {
return {
frameWidth: 192,
frameHeight: 208,
columns: 8,
rows: 9,
sheetWidth: 1536,
sheetHeight: 1872,
fps: CODEX_PET_DEFAULT_FPS,
defaultAnimation: 'idle',
// Pre-durations builds baked exactly this shape from the old defaults.
animations: Object.fromEntries(
Object.entries(CODEX_PET_ANIMATIONS).map(([name, anim]) => [
name,
{ row: anim.row, frames: anim.frames }
])
)
}
}
describe('CODEX_PET_ANIMATIONS', () => {
it('ships per-frame durations matching each frame count', () => {
for (const [name, anim] of Object.entries(CODEX_PET_ANIMATIONS)) {
expect(anim.frameDurationsMs, name).toBeDefined()
expect(anim.frameDurationsMs, name).toHaveLength(anim.frames)
for (const ms of anim.frameDurationsMs ?? []) {
expect(ms, name).toBeGreaterThan(0)
}
}
})
it('keeps the Codex ambient idle cycle at 6.6 seconds', () => {
const total = (CODEX_PET_ANIMATIONS.idle.frameDurationsMs ?? []).reduce(
(sum, ms) => sum + ms,
0
)
expect(total).toBe(6600)
})
})
describe('applyCodexSpriteTimingDefaults', () => {
it('upgrades the legacy baked-default fingerprint with durations', () => {
const upgraded = applyCodexSpriteTimingDefaults(legacyCodexSprite())
expect(upgraded.animations).toEqual(CODEX_PET_ANIMATIONS)
expect(upgraded.animations?.idle.frameDurationsMs).toEqual([1680, 660, 660, 840, 840, 1920])
})
it('leaves hand-authored animation layouts untouched', () => {
const sprite = legacyCodexSprite()
sprite.animations = { blink: { row: 0, frames: 2 } }
expect(applyCodexSpriteTimingDefaults(sprite)).toBe(sprite)
})
it('leaves sprites with a non-default fps untouched', () => {
const sprite = { ...legacyCodexSprite(), fps: 4 }
expect(applyCodexSpriteTimingDefaults(sprite)).toBe(sprite)
})
it('leaves sprites that already declare durations untouched', () => {
const sprite = legacyCodexSprite()
sprite.animations = {
...sprite.animations,
idle: { row: 0, frames: 6, frameDurationsMs: [100, 100, 100, 100, 100, 100] }
}
expect(applyCodexSpriteTimingDefaults(sprite)).toBe(sprite)
})
it('leaves sprites without animations untouched', () => {
const sprite = { ...legacyCodexSprite(), animations: undefined }
expect(applyCodexSpriteTimingDefaults(sprite)).toBe(sprite)
})
it('leaves a hand-authored 8 fps sheet that only reuses the Codex row map untouched', () => {
// Same fps + nine Codex rows, but non-Codex frame geometry — must not retime.
const sprite: CustomPetSprite = {
...legacyCodexSprite(),
frameWidth: 32,
frameHeight: 32,
sheetWidth: CODEX_PET_DEFAULT_COLUMNS * 32,
sheetHeight: 9 * 32
}
expect(applyCodexSpriteTimingDefaults(sprite)).toBe(sprite)
})
it('leaves a sheet with a non-idle default animation untouched', () => {
const sprite = { ...legacyCodexSprite(), defaultAnimation: 'running' }
expect(applyCodexSpriteTimingDefaults(sprite)).toBe(sprite)
})
it('leaves a sheet with a non-Codex column count untouched', () => {
const sprite = { ...legacyCodexSprite(), columns: CODEX_PET_DEFAULT_COLUMNS + 2 }
expect(applyCodexSpriteTimingDefaults(sprite)).toBe(sprite)
})
it('does not throw on a corrupted animation entry that matches the outer fingerprint', () => {
const sprite = legacyCodexSprite()
// Persisted data is untrusted: a null value under a matching key must be a
// non-match, not a crash.
sprite.animations = { ...sprite.animations, idle: null as never }
expect(applyCodexSpriteTimingDefaults(sprite)).toBe(sprite)
})
})