not-tonight/tests/sceneLifecycle.test.ts
type-two be76c5722c Two critical lifecycle bugs the fresh-eyes review caught, both live
- IncidentReportScene never stopped itself: the paperwork rendered on top of the
  roster after the FIRST night of every run. Looked like a freeze.
- HaulDeskScene never reset per-night state: from night two the back office was
  a blank $0 desk that ate every input. Mine, from SOLO-26 — adding scene.stop()
  is exactly what makes Phaser hand back a dirty instance next time.
- Dev routes (n/p/f/j/m) shipped unguarded on window; a stray F binned the shift.
  Now Ctrl+Shift in built games, and R routes home to the roster.
- tests/sceneLifecycle.test.ts guards the class, verified to fail on the real bug.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 17:49:04 +10:00

85 lines
3.9 KiB
TypeScript

import { describe, expect, it } from 'vitest';
// The bug class this file exists for, hit FOUR times now:
// 1. DoorScene.busy stuck true after a late ruling (dead door for the run)
// 2. seenCards leaking across nights (every regular stamped SEEN TONIGHT)
// 3. IncidentReportScene never stopping (paperwork frozen over the roster)
// 4. HaulDeskScene never resetting (a blank $0 desk that eats every input)
//
// Phaser instantiates each configured scene ONCE and reuses that instance for
// the life of the game. So two rules hold for every scene that is entered more
// than once per session:
// - anything scoped to one night must be reset in create()/init(), never
// left to a class-field initialiser, and
// - a scene that hands control to a scene it does not own must stop itself,
// because ScenePlugin.start only queues a stop for its OWN key.
//
// Driving real Phaser here would need a canvas and a DOM; these are source
// assertions instead. Cheaper, and they fail for the right reason with a
// message that names the fix.
// Vite's glob import instead of node:fs — this suite runs under the app's
// tsconfig, which has no node types, and `?raw` gives us the source as a string
// without adding a dependency or loosening the config for one test.
const SOURCES = import.meta.glob('../src/**/*.ts', { query: '?raw', import: 'default', eager: true }) as Record<string, string>;
const src = (p: string): string => {
const hit = Object.entries(SOURCES).find(([k]) => k.endsWith(`/src/${p}`));
if (!hit) throw new Error(`sceneLifecycle: could not read src/${p}`);
return hit[1];
};
describe('scene lifecycle: state that must not survive the night', () => {
it('HaulDeskScene clears every per-night field in create()', () => {
const s = src('scenes/shared/HaulDeskScene.ts');
const body = s.slice(s.indexOf('create(): void {'));
for (const field of ['finished', 'idx', 'counted', 'phase', 'phaseMs', 'phaseFor', 'falling']) {
expect(body, `HaulDeskScene.create() must reset this.${field}`).toContain(`this.${field} =`);
}
});
it('IncidentReportScene resets its answers and index per night', () => {
const s = src('scenes/door/IncidentReportScene.ts');
const head = s.slice(0, s.indexOf('showQuestion'));
expect(head).toMatch(/this\.(answers|idx|index)\s*=/);
});
it('DoorScene clears the passback net and the busy latch every night', () => {
// Both of these have already shipped as bugs once.
const body = src('scenes/door/DoorScene.ts');
expect(body).toContain('this.seenCards.clear()');
expect(body).toContain('this.busy = false');
});
});
describe('scene lifecycle: a scene that hands over must stop itself', () => {
// Each of these hands control to a scene it does not own, via a `next`
// closure or a direct start of a sibling. Phaser will not stop them for us.
const HANDOVERS: Array<[string, string]> = [
['scenes/shared/HaulDeskScene.ts', 'the back office'],
['scenes/door/IncidentReportScene.ts', 'the paperwork'],
];
for (const [path, what] of HANDOVERS) {
it(`${what} stops itself on the way out`, () => {
const s = src(path);
expect(
s.includes('this.scene.stop()'),
`${path}: calls into another scene but never stops itself — it will keep ` +
`rendering underneath whatever comes next (ScenePlugin.start only stops its own key)`,
).toBe(true);
});
}
it('and the handover happens AFTER the stop, not before', () => {
// Stopping after the handover would tear down the scene that was just
// started in some orderings; stop first, then hand on.
const s = src('scenes/shared/HaulDeskScene.ts');
const stop = s.lastIndexOf('this.scene.stop()');
const next = s.indexOf('this.args.next()', stop);
expect(stop).toBeGreaterThan(-1);
expect(next, 'next() should follow scene.stop() in the exit handler').toBeGreaterThan(stop);
});
});