import { describe, expect, it } from 'vitest'; import { IDLE_FRAMES, dollPlan } from '../src/patrons/dollPlan'; import { generatePatron, _resetPatronSerial } from '../src/patrons/generator'; import { SeededRNG } from '../src/core/SeededRNG'; import { violations, activeRules } from '../src/rules/dressCode'; const NIGHT = new Date('2026-07-18T00:00:00'); describe('idle frames (design §4.1 must survive animation)', () => { it('every frame draws the SAME set of outfit pixels, only displaced', () => { // The dress code convicts on things the doll draws. If a frame dropped or // recoloured a rect, the player could be judged on a tell that blinked. _resetPatronSerial(); const rng = new SeededRNG(31); for (let i = 0; i < 40; i++) { const p = generatePatron({ rng, nightDate: NIGHT }, 120); const base = dollPlan(p, 'queue', 0); for (let f = 1; f < IDLE_FRAMES; f++) { const frame = dollPlan(p, 'queue', f); expect(frame.rects.length, `p${i} f${f} rect count`).toBe(base.rects.length); // Same colours, same sizes, in the same order — only x/y may move. for (let r = 0; r < base.rects.length; r++) { expect(frame.rects[r]!.colour).toBe(base.rects[r]!.colour); expect(frame.rects[r]!.w).toBe(base.rects[r]!.w); expect(frame.rects[r]!.h).toBe(base.rects[r]!.h); } } } }); it('a frame never moves anything more than a pixel or two', () => { _resetPatronSerial(); const rng = new SeededRNG(7); for (let i = 0; i < 30; i++) { const p = generatePatron({ rng, nightDate: NIGHT }, 200); const base = dollPlan(p, 'queue', 0); for (let f = 1; f < IDLE_FRAMES; f++) { const frame = dollPlan(p, 'queue', f); for (let r = 0; r < base.rects.length; r++) { expect(Math.abs(frame.rects[r]!.x - base.rects[r]!.x)).toBeLessThanOrEqual(2); expect(Math.abs(frame.rects[r]!.y - base.rects[r]!.y)).toBeLessThanOrEqual(1); } } } }); it('the feet stay on the floor — nobody hovers', () => { _resetPatronSerial(); const p = generatePatron({ rng: new SeededRNG(5), nightDate: NIGHT }, 60); const base = dollPlan(p, 'queue', 0); const feet = (plan: typeof base): number => Math.max(...plan.rects.map((r) => r.y + r.h)); for (let f = 1; f < IDLE_FRAMES; f++) { expect(feet(dollPlan(p, 'queue', f))).toBe(feet(base)); } }); it('what the rules judge is unchanged by which frame is showing', () => { _resetPatronSerial(); const rng = new SeededRNG(99); for (let i = 0; i < 40; i++) { const p = generatePatron({ rng, nightDate: NIGHT }, 240); // violations read outfit DATA, which frames never touch — this pins that // the animation stayed on the render side of the contract. const before = violations(p, 240).map((r) => r.id); dollPlan(p, 'queue', 2); expect(violations(p, 240).map((r) => r.id)).toEqual(before); } expect(activeRules(240).length).toBeGreaterThan(0); }); it('frame 0 is exactly what every existing caller already got', () => { _resetPatronSerial(); const p = generatePatron({ rng: new SeededRNG(12), nightDate: NIGHT }, 100); expect(dollPlan(p, 'queue', 0)).toEqual(dollPlan(p, 'queue')); }); it('the other poses ignore frames — the ID photo does not breathe', () => { _resetPatronSerial(); const p = generatePatron({ rng: new SeededRNG(3), nightDate: NIGHT }, 100); for (const pose of ['idPhoto', 'floorTopDown'] as const) { expect(dollPlan(p, pose, 2)).toEqual(dollPlan(p, pose, 0)); } }); });