import { beforeEach, describe, expect, it } from 'vitest'; import { SeededRNG } from '../src/core/SeededRNG'; import { idDisplayLines, idVerdict, type IdTell } from '../src/rules/idCheck'; import { generatePatron, _resetPatronSerial, type GeneratorContext } from '../src/patrons/generator'; import type { IdCard, Patron } from '../src/data/types'; const NIGHT = new Date('2026-07-18T00:00:00'); const card = (over: Partial = {}): IdCard => ({ name: 'Shazza Nguyen', dob: '2000-06-01', expiry: '2030-06-01', photoSeed: 1, ...over, }); const on = (iso: string): Date => new Date(`${iso}T00:00:00`); describe('idVerdict — birthday boundary', () => { it('18th birthday is tonight: 18, not underage, flagged', () => { const v = idVerdict(card({ dob: '2008-07-18' }), NIGHT); expect(v.claimedAge).toBe(18); expect(v.underage).toBe(false); expect(v.turnsEighteenTonight).toBe(true); }); it('18th birthday is tomorrow: still 17, underage', () => { const v = idVerdict(card({ dob: '2008-07-19' }), NIGHT); expect(v.claimedAge).toBe(17); expect(v.underage).toBe(true); expect(v.turnsEighteenTonight).toBe(false); }); it('18th birthday was yesterday: 18, no longer the birthday', () => { const v = idVerdict(card({ dob: '2008-07-17' }), NIGHT); expect(v.claimedAge).toBe(18); expect(v.turnsEighteenTonight).toBe(false); }); it('turnsEighteenTonight only ever fires at exactly 18', () => { expect(idVerdict(card({ dob: '2007-07-18' }), NIGHT).turnsEighteenTonight).toBe(false); expect(idVerdict(card({ dob: '2009-07-18' }), NIGHT).turnsEighteenTonight).toBe(false); }); }); describe('idVerdict — leap day DOB', () => { it('29 Feb DOB is still 17 on 28 Feb of a non-leap year', () => { const v = idVerdict(card({ dob: '2008-02-29' }), on('2026-02-28')); expect(v.claimedAge).toBe(17); expect(v.underage).toBe(true); }); it('29 Feb DOB turns 18 on 1 March of a non-leap year', () => { const v = idVerdict(card({ dob: '2008-02-29' }), on('2026-03-01')); expect(v.claimedAge).toBe(18); expect(v.underage).toBe(false); // Deliberate: leap babies get the flag on the day the age math grants it, // rather than never getting it in a non-leap year. expect(v.turnsEighteenTonight).toBe(true); }); // NB: a leap baby's 18th birthday can never fall on a real 29 Feb (leap years // are 4 apart, 18 is not a multiple of 4), so the interesting leap-night case // is a 28 Feb DOB the day AFTER its birthday. it('28 Feb DOB on a 29 Feb night: birthday was yesterday', () => { const v = idVerdict(card({ dob: '2010-02-28' }), on('2028-02-29')); expect(v.claimedAge).toBe(18); expect(v.turnsEighteenTonight).toBe(false); }); it('29 Feb DOB evaluated on a real 29 Feb is exactly on its birthday', () => { const v = idVerdict(card({ dob: '2008-02-29' }), on('2028-02-29')); expect(v.claimedAge).toBe(20); expect(v.underage).toBe(false); }); }); describe('idVerdict — expiry boundary', () => { it('expiry yesterday is expired', () => { const v = idVerdict(card({ expiry: '2026-07-17' }), NIGHT); expect(v.expired).toBe(true); expect(v.expiresTonight).toBe(false); expect(v.tells).toEqual(['expired']); }); it('expiry tonight is valid — the trap', () => { const v = idVerdict(card({ expiry: '2026-07-18' }), NIGHT); expect(v.expired).toBe(false); expect(v.expiresTonight).toBe(true); expect(v.tells).toEqual([]); expect(v.looksFake).toBe(false); }); it('expiry tomorrow is valid and unremarkable', () => { const v = idVerdict(card({ expiry: '2026-07-19' }), NIGHT); expect(v.expired).toBe(false); expect(v.expiresTonight).toBe(false); }); it('a nightDate carrying a time of day still compares day-to-day', () => { const v = idVerdict(card({ expiry: '2026-07-18' }), new Date('2026-07-18T02:30:00')); expect(v.expired).toBe(false); expect(v.expiresTonight).toBe(true); }); }); describe('idVerdict — tells', () => { it('a single visible tell comes back alone', () => { const v = idVerdict(card({ fake: { peelingLaminate: true } }), NIGHT); expect(v.tells).toEqual(['peelingLaminate']); expect(v.looksFake).toBe(true); }); it('a clean card with a future expiry has no tells', () => { const v = idVerdict(card(), NIGHT); expect(v.tells).toEqual([]); expect(v.looksFake).toBe(false); }); it('no fake object but a past expiry is still catchable', () => { const v = idVerdict(card({ expiry: '2024-01-01' }), NIGHT); expect(v.tells).toEqual(['expired']); expect(v.looksFake).toBe(true); }); it('all tells come back in fixed reading order', () => { const v = idVerdict( card({ expiry: '2020-05-05', fake: { photoMismatch: true, jokeName: true, wrongHologram: true, peelingLaminate: true }, }), NIGHT, ); expect(v.tells).toEqual([ 'peelingLaminate', 'wrongHologram', 'jokeName', 'photoMismatch', 'expired', ]); }); it('subsets keep that order regardless of how the flags were written', () => { const v = idVerdict(card({ fake: { photoMismatch: true, wrongHologram: true } }), NIGHT); expect(v.tells).toEqual(['wrongHologram', 'photoMismatch']); }); it('an empty fake object alone is not a tell', () => { const v = idVerdict(card({ fake: {} }), NIGHT); expect(v.tells).toEqual([]); expect(v.looksFake).toBe(false); }); // Guards against a later "if underage, skip the tell scan" shortcut: the two // are independent readings of the same card and must both survive. it('a card that both claims underage and has expired reports both', () => { const v = idVerdict(card({ dob: '2009-07-19', expiry: '2025-01-01' }), NIGHT); expect(v.claimedAge).toBe(16); expect(v.underage).toBe(true); expect(v.expired).toBe(true); expect(v.tells).toEqual(['expired']); expect(v.looksFake).toBe(true); }); it('TELL_ORDER covers every IdTell — no tell can be silently unreportable', () => { const everyTell: IdTell[] = [ 'peelingLaminate', 'wrongHologram', 'jokeName', 'photoMismatch', 'expired', ]; const v = idVerdict( card({ expiry: '2020-05-05', fake: { peelingLaminate: true, wrongHologram: true, jokeName: true, photoMismatch: true }, }), NIGHT, ); expect([...v.tells].sort()).toEqual([...everyTell].sort()); }); }); describe('idVerdict — calendar edges', () => { it('DOB 31 Dec against a 1 Jan night', () => { expect(idVerdict(card({ dob: '2008-12-31' }), on('2027-01-01')).claimedAge).toBe(18); expect(idVerdict(card({ dob: '2009-01-01' }), on('2027-01-01')).turnsEighteenTonight).toBe(true); }); it('DOB 31 Jan checked on 28 Feb', () => { const v = idVerdict(card({ dob: '2008-01-31' }), on('2026-02-28')); expect(v.claimedAge).toBe(18); expect(v.turnsEighteenTonight).toBe(false); }); }); describe('idDisplayLines', () => { it('formats DD/MM/YYYY with zero padding', () => { const lines = idDisplayLines(card({ dob: '2008-01-05', expiry: '2029-11-09' }), NIGHT); expect(lines.dob).toBe('05/01/2008'); expect(lines.expiry).toBe('09/11/2029'); expect(lines.name).toBe('Shazza Nguyen'); expect(lines.age).toBe('18'); }); it('age line agrees with the verdict at the underage boundary', () => { const id = card({ dob: '2008-07-19' }); expect(idDisplayLines(id, NIGHT).age).toBe('17'); expect(String(idVerdict(id, NIGHT).claimedAge)).toBe('17'); }); }); describe('integration sweep over generated patrons', () => { const SWEEP_NIGHT = new Date('2026-07-18T00:00:00'); let crowd: Patron[]; let kids: Patron[]; beforeEach(() => { _resetPatronSerial(); const ctx: GeneratorContext = { rng: new SeededRNG(11), nightDate: SWEEP_NIGHT }; crowd = Array.from({ length: 800 }, (_, i) => generatePatron(ctx, i % 360)); const kidCtx: GeneratorContext = { rng: new SeededRNG(11), nightDate: SWEEP_NIGHT }; kids = Array.from({ length: 60 }, () => generatePatron(kidCtx, 0, 'almost18')); }); it('every fake in the crowd is actually catchable', () => { const suspect = crowd.filter( (p) => p.idCard.fake || new Date(`${p.idCard.expiry}T00:00:00`) < SWEEP_NIGHT, ); expect(suspect.length).toBeGreaterThan(0); for (const p of suspect) { expect(idVerdict(p.idCard, SWEEP_NIGHT).looksFake).toBe(true); } }); it('clean cards in the crowd never read as fake', () => { const clean = crowd.filter( (p) => !p.idCard.fake && new Date(`${p.idCard.expiry}T00:00:00`) >= SWEEP_NIGHT, ); expect(clean.length).toBeGreaterThan(0); for (const p of clean) { expect(idVerdict(p.idCard, SWEEP_NIGHT).looksFake).toBe(false); } }); it("almost18 cards claim 18+ while the kid is 17 — tells are the only catch", () => { expect(kids.length).toBe(60); for (const kid of kids) { expect(kid.age).toBe(17); const v = idVerdict(kid.idCard, SWEEP_NIGHT); expect(v.claimedAge).toBeGreaterThanOrEqual(18); expect(v.underage).toBe(false); expect(v.looksFake).toBe(true); } }); // The override run above is a forced sample; this checks the almost18s that // actually turn up in a naturally-weighted crowd, which is what ships. it('almost18s occurring naturally in the crowd are also lying and catchable', () => { const inCrowd = crowd.filter((p) => p.archetype === 'almost18'); expect(inCrowd.length).toBeGreaterThan(0); for (const kid of inCrowd) { expect(kid.age).toBe(17); const v = idVerdict(kid.idCard, SWEEP_NIGHT); expect(v.claimedAge).toBeGreaterThanOrEqual(18); expect(v.underage).toBe(false); expect(v.looksFake).toBe(true); } }); it('idVerdict never throws across the whole crowd', () => { expect(() => { for (const p of [...crowd, ...kids]) { idVerdict(p.idCard, SWEEP_NIGHT); idDisplayLines(p.idCard, SWEEP_NIGHT); } }).not.toThrow(); }); }); describe('idVerdict — passback', () => { it('a passback-flagged card looks fake, with passback among the tells', () => { const v = idVerdict(card({ fake: { passback: true } }), NIGHT); expect(v.looksFake).toBe(true); expect(v.tells).toContain('passback'); }); it('the flag defaults off — an unflagged card carries no passback tell', () => { const v = idVerdict(card(), NIGHT); expect(v.tells).not.toContain('passback'); }); });