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; 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); }); });