not-tonight/tests/nightSummary.test.ts
type-two 7af3732f39 Fix build break in the shoe tests: freshNightState takes (venueId, nightIndex, licensed)
vitest does not typecheck, so these passed under `npm test` while `npm run build`
failed. The gate is lint AND build AND test for exactly this reason — run it in
that order after touching tests, not before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 03:18:49 +10:00

155 lines
5.7 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
// The functions under test are pure, but they live in modules that subclass
// Phaser.Scene, and Phaser touches `window` at import time under the node
// environment. Stubbing the module is the only way to reach them from a test.
vi.mock('phaser', () => ({ default: { Scene: class {} } }));
import { freshNightState } from '../src/core/meters';
import { DAZZA_SIGNOFF } from '../src/data/strings/door';
import { MAX_HEAT_STRIKES, shouldRecordStrike, type NightLog } from '../src/scenes/door/NightScene';
import { nightCash, signoffFor } from '../src/scenes/door/NightSummaryScene';
import type { NightState } from '../src/data/types';
const log = (over: Partial<NightLog> = {}): NightLog => ({
vibeHistory: [50],
verdicts: [],
admitted: 0,
denied: 0,
tested: 0,
pattedDown: 0,
hypePeak: 1,
heatStrikes: [],
endReason: 'clock',
seed: 1,
role: 'DOOR',
wage: 180,
floorCash: 0,
pocketCash: 0,
...over,
});
const stateWith = (...reasons: string[]): NightState => {
const s = freshNightState('voltage', 0, 80);
for (const reason of reasons) s.heatStrikes.push({ reason });
return s;
};
describe('nightCash', () => {
it('pays a flat wage for an uneventful night', () => {
expect(nightCash(log(), stateWith())).toBe(180);
});
it('pays $2 per admit on top of the wage', () => {
const clean = stateWith();
expect(nightCash(log({ admitted: 10 }), clean)).toBe(nightCash(log(), clean) + 20);
expect(nightCash(log({ admitted: 40 }), clean)).toBeGreaterThan(
nightCash(log({ admitted: 10 }), clean),
);
});
it('pays a hype bonus that scales with peak hype, not with final hype', () => {
const clean = stateWith();
const base = nightCash(log(), clean);
expect(nightCash(log({ hypePeak: 2 }), clean)).toBe(base + 90);
expect(nightCash(log({ hypePeak: 3 }), clean)).toBe(base + 180);
});
it('deducts $60 for every heat strike', () => {
const l = log({ admitted: 20, hypePeak: 2 });
const none = nightCash(l, stateWith());
expect(nightCash(l, stateWith('over capacity'))).toBe(none - 60);
expect(nightCash(l, stateWith('over capacity', 'minor admitted'))).toBe(none - 120);
});
it('floors at zero — a catastrophic night pays nothing, it does not invoice you', () => {
const strikes = Array.from({ length: 20 }, (_, i) => `offence ${i}`);
expect(nightCash(log(), stateWith(...strikes))).toBe(0);
});
it('returns a whole number even when hype is fractional', () => {
for (const hypePeak of [1, 1.3, 1.75, 2.4, 3]) {
const cash = nightCash(log({ hypePeak, admitted: 7 }), stateWith());
expect(Number.isInteger(cash)).toBe(true);
}
});
});
describe('signoffFor', () => {
it('gives the harshest line at two strikes no matter how good the room felt', () => {
expect(signoffFor(100, 2)).toBe(DAZZA_SIGNOFF[0]);
expect(signoffFor(100, 3)).toBe(DAZZA_SIGNOFF[0]);
// one strike is survivable — a great room still earns praise
expect(signoffFor(100, 1)).not.toBe(DAZZA_SIGNOFF[0]);
});
it('never moves to an earlier line as vibe rises (no strikes)', () => {
for (const strikes of [0, 1]) {
let lowest = 0;
for (let vibe = 0; vibe <= 100; vibe++) {
const idx = DAZZA_SIGNOFF.indexOf(signoffFor(vibe, strikes));
expect(idx).toBeGreaterThanOrEqual(lowest);
lowest = idx;
}
}
});
it('actually varies with vibe rather than always saying the same thing', () => {
expect(signoffFor(0, 0)).not.toBe(signoffFor(100, 0));
});
it('returns a real signoff line across the whole 0..100 band, endpoints included', () => {
for (let vibe = 0; vibe <= 100; vibe++) {
const line = signoffFor(vibe, 0);
expect(line.length).toBeGreaterThan(0);
expect(DAZZA_SIGNOFF).toContain(line);
}
});
});
describe('shouldRecordStrike', () => {
it('matches CONTRACTS.md §3: max 3 strikes', () => {
expect(MAX_HEAT_STRIKES).toBe(3);
});
it('records a strike the night has not seen before', () => {
expect(shouldRecordStrike(stateWith(), { reason: 'minor admitted' })).toBe(true);
expect(shouldRecordStrike(stateWith('over capacity'), { reason: 'minor admitted' })).toBe(true);
});
it('stops recording once the licence is already gone', () => {
const full = stateWith('a', 'b', 'c');
expect(full.heatStrikes).toHaveLength(MAX_HEAT_STRIKES);
expect(shouldRecordStrike(full, { reason: 'd' })).toBe(false);
});
it('treats a persistent breach as one offence, not one per patron', () => {
const s = stateWith('over capacity');
expect(shouldRecordStrike(s, { reason: 'over capacity' })).toBe(false);
expect(shouldRecordStrike(s, { reason: 'over capacity', deferred: true })).toBe(false);
});
});
describe('the booth shoe', () => {
it('is real money — cash taken at the decks reaches the pay line', () => {
const without = nightCash(log({ pocketCash: 0 }), freshNightState('theRoyal', 0, 80));
const withTips = nightCash(log({ pocketCash: 50 }), freshNightState('theRoyal', 0, 80));
expect(withTips - without).toBe(50);
});
it('cannot be eaten by strikes — a deduction takes the wage, not your pocket', () => {
// Three strikes wipes the wage to the Math.max(0, ...) floor. Whatever a
// punter physically handed you is still in your pocket at the end of it.
const state = freshNightState('theRoyal', 0, 80);
state.heatStrikes = [
{ reason: 'a', deferred: true },
{ reason: 'b', deferred: true },
{ reason: 'c', deferred: true },
];
const broke = nightCash(log({ wage: 120, pocketCash: 0 }), state);
const tipped = nightCash(log({ wage: 120, pocketCash: 40 }), state);
expect(broke).toBe(0);
expect(tipped).toBe(40);
});
});