293 lines
10 KiB
TypeScript
293 lines
10 KiB
TypeScript
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> = {}): 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');
|
|
});
|
|
});
|