import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { IdCard, Patron, Verdict } from '../src/data/types'; import type { JudgeContext } from '../src/rules/doorTypes'; // dressCode and idCheck are mocked: judge's job is scoring, and pinning their // outputs is the only way to construct the cases that matter (a flawless fake on // a 17-year-old, four simultaneous rule breaches). Their own tests cover them. const mocks = vi.hoisted(() => ({ violations: vi.fn(), idVerdict: vi.fn() })); vi.mock('../src/rules/dressCode', () => ({ violations: mocks.violations })); vi.mock('../src/rules/idCheck', () => ({ idVerdict: mocks.idVerdict })); const { LOCKOUT_MIN, judge, JUDGE_TUNING: T } = await import('../src/rules/judge'); // The REAL rule labels, pulled past the mock. Hand-written shorts in a test are // how a broken player-facing string ships: every shipped `short` is a caps // prohibition ("NO VISIBLE LOGOS"), so any Dazza line built from one has to read // correctly against that phrasing, not against a tidy lowercase invention. const { DRESS_CODE_RULES } = await vi.importActual('../src/rules/dressCode'); const REAL_SHORTS: string[] = DRESS_CODE_RULES.map((r) => r.short); const NIGHT = new Date('2026-07-18T00:00:00'); const VERDICTS: Verdict[] = ['admit', 'deny', 'sobrietyTest', 'patDown', 'wait']; const rule = (short: string) => ({ id: short, text: `mate ${short}`, short, activeFrom: 30, violates: () => true, }); const idFor = (claimedAge: number, over: Partial> = {}) => ({ ...cleanId(claimedAge), ...over, }); function cleanId(claimedAge: number) { return { claimedAge, underage: claimedAge < 18, expired: false, expiresTonight: false, turnsEighteenTonight: false, tells: [] as string[], looksFake: false, }; } const card = (over: Partial = {}): IdCard => ({ name: 'Shazza Nguyen', dob: '2001-01-01', expiry: '2029-01-01', photoSeed: 1234, ...over, }); const makePatron = (over: Partial = {}): Patron => ({ id: 'p7', dollSeed: 1234, archetype: 'punter', age: 25, idCard: card(), outfit: [ { slot: 'shoes', type: 'boot', colour: 'black' }, { slot: 'legs', type: 'jeans', colour: 'denim' }, { slot: 'top', type: 'shirt', colour: 'blue' }, { slot: 'outer', type: 'none', colour: 'black' }, { slot: 'hair', type: 'short', colour: 'brown' }, { slot: 'accessory', type: 'none', colour: 'black' }, ], intoxication: 0.1, tolerance: 0.6, flags: {}, ...over, }); const ctx = (over: Partial = {}): JudgeContext => ({ clockMin: 120, nightDate: NIGHT, lapRoll: 0.5, insideCount: 40, licensed: 200, ...over, }); /** Pin what the (mocked) inspection surface reports for the next judge() call. */ const scene = (broken: string[], id = cleanId(25)): void => { mocks.violations.mockReturnValue(broken.map(rule)); mocks.idVerdict.mockReturnValue(id); }; beforeEach(() => { vi.clearAllMocks(); scene([]); }); describe('judge — denials', () => { it('denying a genuine violator pays meaningful vibe for a small aggro bump', () => { scene(['no thongs']); const o = judge(makePatron(), 'deny', ctx()); expect(o.vibeDelta).toBe(T.denyViolatorVibe); expect(o.vibeDelta).toBeGreaterThan(0); expect(o.aggroDelta).toBe(T.denyViolatorAggro); expect(o.aggroDelta).toBeGreaterThan(0); }); it('denying a clean patron pays less vibe and costs more aggro than denying a violator', () => { const violator = (scene(['no logos']), judge(makePatron(), 'deny', ctx())); const clean = (scene([]), judge(makePatron(), 'deny', ctx())); expect(clean.vibeDelta).toBeGreaterThan(0); expect(clean.vibeDelta).toBeLessThan(violator.vibeDelta); expect(clean.aggroDelta).toBeGreaterThan(violator.aggroDelta); }); it('a card that reads underage justifies the denial on its own', () => { scene([], idFor(17)); expect(judge(makePatron(), 'deny', ctx()).vibeDelta).toBe(T.denyViolatorVibe); }); it('a fake-looking card justifies the denial on its own', () => { scene([], idFor(19, { looksFake: true, tells: ['peelingLaminate'] })); expect(judge(makePatron(), 'deny', ctx()).vibeDelta).toBe(T.denyViolatorVibe); }); it('a real minor holding a flawless card is still a justified denial', () => { scene([], cleanId(19)); expect(judge(makePatron({ age: 17 }), 'deny', ctx()).vibeDelta).toBe(T.denyViolatorVibe); }); it("denying the owner's mate hits vibe hard, fires immediately, and gets a text", () => { const o = judge(makePatron({ flags: { knowsOwner: true } }), 'deny', ctx()); expect(o.vibeDelta).toBe(T.denyOwnersMateVibe); expect(o.vibeDelta).toBeLessThan(0); expect(o.aggroDelta).toBeGreaterThan(0); expect(o.dazzaText).toBeTruthy(); expect(o.deferred).toBeUndefined(); }); it("the owner's mate outranks anything else wrong with him", () => { scene(['no thongs', 'no logos'], idFor(19, { looksFake: true, tells: ['jokeName'] })); const o = judge(makePatron({ flags: { knowsOwner: true } }), 'deny', ctx()); expect(o.vibeDelta).toBe(T.denyOwnersMateVibe); }); it("denying the owner's mate is the single worst vibe outcome in the table", () => { const cases: Array<[string[], Patron, Partial]> = [ [[], makePatron(), {}], [['no logos'], makePatron(), {}], [['a', 'b', 'c', 'd', 'e'], makePatron(), {}], [[], makePatron({ archetype: 'influencer' }), {}], [[], makePatron({ archetype: 'regular', flags: { timesDeniedBefore: 4 } }), {}], [['a'], makePatron({ archetype: 'regular', flags: { timesDeniedBefore: 9 } }), {}], [[], makePatron({ age: 17 }), { insideCount: 300, licensed: 200 }], ]; const worstElsewhere = Math.min( ...cases.flatMap(([broken, p, c]) => (['admit', 'deny', 'sobrietyTest', 'patDown', 'wait'] as Verdict[]).flatMap((v) => { scene(broken, idFor(19, { looksFake: broken.length > 0 })); const o = judge(p, v, ctx(c)); return [o.vibeDelta, ...(o.deferred ?? []).map((d) => d.vibeDelta ?? 0)]; }), ), ); scene([]); const mate = judge(makePatron({ flags: { knowsOwner: true } }), 'deny', ctx()).vibeDelta; expect(mate).toBeLessThan(worstElsewhere); }); it('denying an influencer gains extra vibe AND extra aggro over the same call on a punter', () => { const punter = judge(makePatron(), 'deny', ctx()); const influencer = judge(makePatron({ archetype: 'influencer' }), 'deny', ctx()); expect(influencer.vibeDelta).toBe(punter.vibeDelta + T.denyInfluencerVibe); expect(influencer.aggroDelta).toBe(punter.aggroDelta + T.denyInfluencerAggro); expect(T.denyInfluencerVibe).toBeGreaterThan(0); expect(T.denyInfluencerAggro).toBeGreaterThan(0); }); it('a regular with a grudge costs extra vibe and gets a note', () => { const fresh = judge(makePatron({ archetype: 'regular' }), 'deny', ctx()); const grudged = judge( makePatron({ archetype: 'regular', flags: { timesDeniedBefore: 2 } }), 'deny', ctx(), ); expect(T.denyGrudgedRegularVibe).toBeLessThan(0); expect(grudged.vibeDelta).toBe(fresh.vibeDelta + T.denyGrudgedRegularVibe); expect(grudged.note).toBeTruthy(); }); it('a regular denied for the first time carries no grudge penalty', () => { const o = judge(makePatron({ archetype: 'regular', flags: { timesDeniedBefore: 0 } }), 'deny', ctx()); expect(o.vibeDelta).toBe(T.denyCleanVibe); expect(o.note).toBeUndefined(); }); it('denying a clean fresh18 costs nothing extra but leaves a note', () => { const o = judge(makePatron({ archetype: 'fresh18', age: 18 }), 'deny', ctx()); expect(o.vibeDelta).toBe(T.denyCleanVibe); expect(o.aggroDelta).toBe(T.denyCleanAggro); expect(o.note).toBeTruthy(); }); it('a fresh18 who actually broke a rule gets the violator score and no conscience note', () => { scene(['no white sneakers']); const o = judge(makePatron({ archetype: 'fresh18', age: 18 }), 'deny', ctx()); expect(o.vibeDelta).toBe(T.denyViolatorVibe); expect(o.note).toBeUndefined(); }); it('the inspector scores identically to a punter — detectable meters would kill the mechanic', () => { for (const v of VERDICTS) { scene([]); const punter = judge(makePatron({ archetype: 'punter' }), v, ctx()); scene([]); const inspector = judge(makePatron({ archetype: 'inspector' }), v, ctx()); expect(inspector).toEqual(punter); } }); }); describe('judge — admissions', () => { it('admitting a clean patron nudges vibe up and lets the queue breathe', () => { const o = judge(makePatron(), 'admit', ctx()); expect(o.vibeDelta).toBe(T.admitCleanVibe); expect(o.vibeDelta).toBeGreaterThan(0); expect(o.aggroDelta).toBe(T.admitCleanAggro); expect(o.aggroDelta).toBeLessThan(0); }); it('admitting a clean 25-year-old yields no heat strike and no deferred tail', () => { const o = judge(makePatron({ age: 25 }), 'admit', ctx()); expect(o.deferred).toBeUndefined(); // Checked in both places: `outcome.heatStrike` alone is vacuous, because // judge never populates the top-level channel at all (see below). expect(o.heatStrike).toBeUndefined(); expect((o.deferred ?? []).some((d) => d.heatStrike)).toBe(false); }); it('heat strikes ride ONLY in deferred[], never top-level — one wiring, one application', () => { // Load-bearing for the integrator: whoever wires door:verdict -> heat:strike // must read the deferred array. If a strike ever appeared in both channels a // naive wiring would burn two of the three licences for one mistake. scene([], idFor(17)); const o = judge(makePatron({ age: 16 }), 'admit', ctx({ insideCount: 300, licensed: 200 })); expect(o.heatStrike).toBeUndefined(); expect((o.deferred ?? []).filter((d) => d.heatStrike)).toHaveLength(2); }); it('admitting a dress-code violator buys immediate queue relief and defers the pain', () => { scene(['no thongs']); const o = judge(makePatron(), 'admit', ctx()); expect(o.vibeDelta).toBe(0); expect(o.aggroDelta).toBe(T.admitViolatorAggro); expect(o.aggroDelta).toBeLessThan(0); expect(o.deferred).toHaveLength(1); expect(o.deferred?.[0]?.vibeDelta).toBeLessThan(0); }); it('lapRoll 0 lands the lap at +2 minutes, lapRoll 1 at +5', () => { scene(['no logos']); const early = judge(makePatron(), 'admit', ctx({ clockMin: 100, lapRoll: 0 })); scene(['no logos']); const late = judge(makePatron(), 'admit', ctx({ clockMin: 100, lapRoll: 1 })); expect(early.deferred?.[0]?.atClockMin).toBe(102); expect(late.deferred?.[0]?.atClockMin).toBe(105); }); it('the lap hit scales with rule count and stops at the cap', () => { scene(['a']); const one = judge(makePatron(), 'admit', ctx()).deferred?.[0]?.vibeDelta; scene(['a', 'b']); const two = judge(makePatron(), 'admit', ctx()).deferred?.[0]?.vibeDelta; scene(['a', 'b', 'c', 'd', 'e', 'f']); const many = judge(makePatron(), 'admit', ctx()).deferred?.[0]?.vibeDelta; expect(one).toBe(T.lapVibePerRule); expect(two).toBe(T.lapVibePerRule * 2); expect(many).toBe(T.lapVibeCap); }); it('the lap text names the rule that was broken', () => { scene(['bucket hats']); const hit = judge(makePatron(), 'admit', ctx()).deferred?.[0]; expect(hit?.dazzaText).toContain('bucket hats'); expect(hit?.reason).toContain('bucket hats'); }); it('the lap text QUOTES the sign rather than claiming to see it, for every real rule', () => { // Regression: the line was "i can see ${short} from HERE", which against the // real labels renders "i can see NO VISIBLE LOGOS from HERE" — an // exoneration, not an accusation, for all eight rules. Quoting the sign is // the invariant; narrating sight of a prohibition inverts the meaning. expect(REAL_SHORTS.length).toBeGreaterThan(0); for (const short of REAL_SHORTS) { scene([short]); const hit = judge(makePatron(), 'admit', ctx()).deferred?.[0]; expect(hit?.dazzaText).toContain(`"${short}"`); expect(hit?.dazzaText).not.toMatch(/(?:can |could |i )see\s+NO\b/i); } }); it('a lap rolled near last drinks still lands inside the night', () => { // At 2:58 AM an unclamped +2..5 lands at 360..363. Anything past 360 never // fires, which would make the last minutes of every night consequence-free. for (const lapRoll of [0, 0.5, 1]) { scene(['no thongs']); const hit = judge(makePatron(), 'admit', ctx({ clockMin: 358, lapRoll })).deferred?.[0]; expect(hit?.atClockMin).toBeLessThanOrEqual(T.auditClockMin); expect(hit?.atClockMin).toBeGreaterThan(358); } }); it('a junk lapRoll still schedules a finite lap', () => { for (const lapRoll of [NaN, Infinity, -Infinity, -3, 7]) { scene(['no logos']); const hit = judge(makePatron(), 'admit', ctx({ clockMin: 100, lapRoll })).deferred?.[0]; expect(Number.isFinite(hit?.atClockMin)).toBe(true); expect(hit?.atClockMin).toBeGreaterThanOrEqual(100 + T.lapDelayMin); expect(hit?.atClockMin).toBeLessThanOrEqual(100 + T.lapDelayMin + T.lapDelaySpread); } }); it('admitting an underage card feels good now and strikes at the audit', () => { scene([], idFor(17)); const o = judge(makePatron({ age: 25 }), 'admit', ctx()); expect(o.vibeDelta).toBe(T.admitBadIdVibe); expect(o.vibeDelta).toBeGreaterThan(0); expect(o.deferred).toHaveLength(1); expect(o.deferred?.[0]?.atClockMin).toBe(T.auditClockMin); expect(o.deferred?.[0]?.heatStrike?.deferred).toBe(true); }); it('admitting a card that looks fake strikes at the audit too', () => { scene([], idFor(19, { looksFake: true, tells: ['wrongHologram'] })); const o = judge(makePatron({ age: 22 }), 'admit', ctx()); expect(o.vibeDelta).toBe(T.admitBadIdVibe); expect(o.deferred?.[0]?.heatStrike).toBeDefined(); }); it('admitting a minor on a FLAWLESS fake still strikes — and feels like a clean admit', () => { // Card says 19, no tells, expiry years away. The player could not have seen // it. The licence goes anyway. scene([], cleanId(19)); const kid = makePatron({ age: 17, idCard: card({ dob: '2007-01-01', expiry: '2030-01-01' }) }); const o = judge(kid, 'admit', ctx()); expect(o.vibeDelta).toBe(T.admitCleanVibe); expect(o.aggroDelta).toBe(T.admitCleanAggro); expect(o.deferred).toHaveLength(1); expect(o.deferred?.[0]?.heatStrike?.deferred).toBe(true); expect(o.deferred?.[0]?.reason).toContain('17'); }); it('a bad card AND a real minor produce exactly one strike, not two', () => { scene([], idFor(17, { looksFake: true, tells: ['jokeName'] })); const o = judge(makePatron({ age: 16 }), 'admit', ctx()); const strikes = (o.deferred ?? []).filter((d) => d.heatStrike); expect(strikes).toHaveLength(1); expect(strikes[0]?.reason).toContain('16'); expect(strikes[0]?.reason).toContain('jokeName'); }); it('an expired but genuine licence is logged as expired, not as a forgery', () => { // idCheck folds 'expired' into tells, so looksFake is true for a real card // that merely lapsed. The strike is fair; calling it fake in the log is not. scene([], idFor(40, { looksFake: true, expired: true, tells: ['expired'] })); const o = judge(makePatron({ age: 40 }), 'admit', ctx()); const reason = o.deferred?.[0]?.reason ?? ''; expect(reason).toContain('expired'); expect(reason).not.toContain('dodgy'); expect(reason).not.toMatch(/fake|minor/i); }); it('admitting at or over licensed capacity adds its own deferred strike', () => { const under = judge(makePatron(), 'admit', ctx({ insideCount: 199, licensed: 200 })); const at = judge(makePatron(), 'admit', ctx({ insideCount: 200, licensed: 200 })); expect(under.deferred).toBeUndefined(); expect(at.deferred).toHaveLength(1); expect(at.deferred?.[0]?.atClockMin).toBe(T.auditClockMin); expect(at.deferred?.[0]?.heatStrike?.reason).toContain('capacity'); }); it('a bad ID admitted over capacity is two separate strikes', () => { scene([], idFor(17)); const o = judge(makePatron({ age: 17 }), 'admit', ctx({ insideCount: 250, licensed: 200 })); expect((o.deferred ?? []).filter((d) => d.heatStrike)).toHaveLength(2); }); }); describe('judge — the 1:30 lockout', () => { it('denying a clean patron after lockout is justified — the law is the reason', () => { const after = judge(makePatron(), 'deny', ctx({ clockMin: LOCKOUT_MIN })); const before = judge(makePatron(), 'deny', ctx({ clockMin: LOCKOUT_MIN - 1 })); // Justified denial pays violator-rate vibe; an unjustified clean denial doesn't. expect(after.vibeDelta).toBe(T.denyViolatorVibe); expect(before.vibeDelta).not.toBe(after.vibeDelta); }); it('admitting after lockout defers exactly one strike, with a stable reason', () => { const o = judge(makePatron(), 'admit', ctx({ clockMin: LOCKOUT_MIN + 10 })); const strikes = (o.deferred ?? []).filter((d) => d.heatStrike); expect(strikes).toHaveLength(1); // STABLE — no patron id baked in — so dedupe holds it to one write-up a night // (the over-capacity strike learned this the hard way). expect(strikes[0]!.heatStrike!.reason).toBe('admission after the 1:30 lockout'); }); it('admitting one minute before lockout carries no lockout strike', () => { const o = judge(makePatron(), 'admit', ctx({ clockMin: LOCKOUT_MIN - 1 })); expect((o.deferred ?? []).some((d) => d.heatStrike)).toBe(false); }); }); describe('judge — stalls and structure', () => { it('sobriety tests and pat-downs only cost queue patience', () => { for (const v of ['sobrietyTest', 'patDown'] as Verdict[]) { const o = judge(makePatron(), v, ctx()); expect(o.vibeDelta).toBe(0); expect(o.aggroDelta).toBe(T.stallAggro); expect(o.aggroDelta).toBeGreaterThan(0); expect(o.deferred).toBeUndefined(); } }); it('stalling never scores the underlying patron — that waits for the real verdict', () => { scene(['no thongs'], idFor(17, { looksFake: true })); const o = judge(makePatron({ age: 17 }), 'sobrietyTest', ctx()); expect(o.vibeDelta).toBe(0); expect(o.heatStrike).toBeUndefined(); expect(o.deferred).toBeUndefined(); }); it('making them wait is theatre: aggro up, hype up, vibe untouched', () => { const o = judge(makePatron(), 'wait', ctx()); expect(o.vibeDelta).toBe(0); expect(o.aggroDelta).toBe(T.waitAggro); expect(o.hypeDelta).toBe(T.waitHype); expect(T.waitHype).toBeGreaterThan(0); }); it('every verdict returns finite numbers, never NaN or undefined', () => { for (const v of VERDICTS) { for (const broken of [[], ['a'], ['a', 'b', 'c']]) { scene(broken, idFor(17, { looksFake: true, tells: ['expired'] })); const o = judge(makePatron({ age: 17 }), v, ctx({ insideCount: 400, licensed: 200 })); expect(Number.isFinite(o.vibeDelta)).toBe(true); expect(Number.isFinite(o.aggroDelta)).toBe(true); } } }); it('every deferred hit is attributable and lands in the future (or at the audit)', () => { const c = ctx({ clockMin: 200, insideCount: 400, licensed: 200 }); scene(['no blazers', 'no sunnies'], idFor(17, { looksFake: true, tells: ['expired'] })); const o = judge(makePatron({ id: 'p42', age: 17 }), 'admit', c); expect(o.deferred?.length).toBeGreaterThan(0); for (const d of o.deferred ?? []) { expect(d.patronId).toBe('p42'); expect(d.reason.length).toBeGreaterThan(0); expect(d.atClockMin > c.clockMin || d.atClockMin === T.auditClockMin).toBe(true); } }); it('is deterministic: identical inputs give a deeply equal outcome', () => { const p = makePatron({ archetype: 'influencer', age: 17 }); const c = ctx({ lapRoll: 0.37, insideCount: 210, licensed: 200 }); scene(['no logos'], idFor(17)); const a = judge(p, 'admit', c); scene(['no logos'], idFor(17)); const b = judge(p, 'admit', c); expect(a).toEqual(b); }); }); // ---- intoxication & contraband (LANE-CONTENT) -------------------------------- // // Before this block existed, judge() read neither. Denying someone who could not // stand up scored exactly like denying a clean punter (+7 aggro), so the game's // most legible signal — sway, red eyes, an entire sobriety minigame — was worth // nothing, and the optimal line was to wave the drunk through. These tests pin // the fix in both directions: refusing a maggot is defensible, and admitting one // costs you later. const drunk = (intoxication: number): Patron => makePatron({ intoxication }); describe('judge — intoxication', () => { it('denying a messy or maggot patron is scored as justified, not arbitrary', () => { for (const x of [0.7, 0.9]) { const o = judge(drunk(x), 'deny', ctx()); expect(o.vibeDelta).toBe(T.denyViolatorVibe); expect(o.aggroDelta).toBe(T.denyViolatorAggro); } }); it('denying a merely LOOSE patron stays a judgement call, not a free win', () => { // 'loose' is deliberately outside the refusable band: a bit merry is the // player's call and the game does not get to tell them it was right. const o = judge(drunk(0.5), 'deny', ctx()); expect(o.vibeDelta).toBe(T.denyCleanVibe); expect(o.aggroDelta).toBe(T.denyCleanAggro); }); it('a sober patron is unaffected by the drunk branch', () => { const o = judge(drunk(0.05), 'deny', ctx()); expect(o.vibeDelta).toBe(T.denyCleanVibe); }); it('admitting anyone already showing tells schedules a ripening hit', () => { for (const [x, mult] of [[0.5, 1], [0.7, 2], [0.9, 3]] as const) { const o = judge(drunk(x), 'admit', ctx({ clockMin: 100 })); const ripen = o.deferred?.find((d) => d.reason.includes('already')); expect(ripen, `intoxication ${x}`).toBeDefined(); expect(ripen!.vibeDelta).toBe(T.ripenVibePerStage * mult); expect(ripen!.atClockMin).toBe(100 + T.ripenDelayMin * mult); expect(ripen!.dazzaText).toBeTruthy(); expect(ripen!.patronId).toBe('p7'); } }); it('admitting a sober patron schedules no ripening hit', () => { const o = judge(drunk(0.05), 'admit', ctx()); expect(o.deferred?.some((d) => d.reason.includes('already'))).toBeFalsy(); }); it('the ripening hit never lands after the audit', () => { const o = judge(drunk(0.9), 'admit', ctx({ clockMin: 355 })); const ripen = o.deferred?.find((d) => d.reason.includes('already')); expect(ripen!.atClockMin).toBeLessThanOrEqual(T.auditClockMin); }); it('admitting a visibly drunk patron is NOT strictly better than denying them', () => { // The encounters review found `quietBeer` had admit dominating deny on BOTH // meters with no heat exposure, which made the humane call secretly optimal — // the exact thing design §4.3 forbids. Deferred cost is what buys it back. const p = drunk(0.5); const admit = judge(p, 'admit', ctx()); const deny = judge(p, 'deny', ctx()); const admitDeferredVibe = (admit.deferred ?? []).reduce((s, d) => s + (d.vibeDelta ?? 0), 0); const admitNet = admit.vibeDelta + admitDeferredVibe; expect(admitNet).toBeLessThan(deny.vibeDelta); }); }); describe('judge — contraband', () => { it('denying someone carrying a severity-3 item is justified', () => { const o = judge(makePatron({ flags: { contraband: ['baggie'] } }), 'deny', ctx()); expect(o.vibeDelta).toBe(T.denyViolatorVibe); expect(o.aggroDelta).toBe(T.denyViolatorAggro); }); it('a kebab or a warm tinnie is not grounds for anything', () => { for (const item of ['kebab', 'tinnies', 'flask']) { const o = judge(makePatron({ flags: { contraband: [item] } }), 'deny', ctx()); expect(o.vibeDelta, item).toBe(T.denyCleanVibe); } }); it('an unknown contraband id never throws or counts', () => { const o = judge(makePatron({ flags: { contraband: ['budgieOfTheseus'] } }), 'deny', ctx()); expect(o.vibeDelta).toBe(T.denyCleanVibe); }); });