import { describe, expect, it } from 'vitest'; import { SeededRNG } from '../src/core/SeededRNG'; import { drunkify, typedLength, wobbleAt, type RngLike } from '../src/ui/typo'; /** Fresh stream every call — drunkify consumes draws, so tests must not share state. */ function stream(seed: number, name = 'typo'): RngLike { return new SeededRNG(seed).stream(name); } const LINE = "Yeah nah mate the line's round the corner, hop in and grab a schooner before last drinks at half two."; // Long enough that two different seeds colliding on an identical mangle is not plausible. const CORPUS = Array.from({ length: 6 }, () => LINE).join(' '); function levenshtein(a: string, b: string): number { let prev = Array.from({ length: b.length + 1 }, (_, i) => i); for (let i = 1; i <= a.length; i++) { const row = [i]; for (let j = 1; j <= b.length; j++) { const cost = a[i - 1] === b[j - 1] ? 0 : 1; row.push(Math.min(row[j - 1]! + 1, prev[j]! + 1, prev[j - 1]! + cost)); } prev = row; } return prev[b.length]!; } const nonLetters = (s: string): string => s.replace(/[a-zA-Z]/g, ''); /** * Matches exactly those strings that keep `input`'s non-letter skeleton verbatim * while every letter run has become zero or more *letters*. Comparing against this * — rather than stripping non-letters first, which makes the assertion vacuous — * is what catches a letter position emitting something that is not a letter. */ function letterShape(input: string): RegExp { const MARK = '\u0000'; // cannot occur in dialogue text, so it is a safe placeholder const src = input .replace(/[a-zA-Z]+/g, MARK) .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') .split(MARK) .join('[a-zA-Z]*'); return new RegExp(`^${src}$`); } describe('drunkify', () => { it('is byte-identical for the same text, drunkenness and seed', () => { const a = drunkify(CORPUS, { drunkenness: 0.8, rng: stream(1234) }); const b = drunkify(CORPUS, { drunkenness: 0.8, rng: stream(1234) }); expect(a).toBe(b); expect(a).not.toBe(CORPUS); // sanity: 0.8 actually mangles something }); it('diverges between seeds', () => { const a = drunkify(CORPUS, { drunkenness: 0.8, rng: stream(1) }); const b = drunkify(CORPUS, { drunkenness: 0.8, rng: stream(2) }); expect(a).not.toBe(b); }); it('is stream-scoped, not seed-scoped', () => { const a = drunkify(CORPUS, { drunkenness: 0.8, rng: stream(7, 'patron:dialogue') }); const b = drunkify(CORPUS, { drunkenness: 0.8, rng: stream(7, 'patron:heckle') }); expect(a).not.toBe(b); }); it('leaves text untouched at drunkenness 0, without consuming draws', () => { const rng = stream(99); expect(drunkify(LINE, { drunkenness: 0, rng })).toBe(LINE); expect(rng.next()).toBe(stream(99).next()); }); it('leaves text untouched at or below the 0.25 sober floor', () => { for (const d of [0.05, 0.1, 0.2, 0.249, 0.25]) { expect(drunkify(CORPUS, { drunkenness: d, rng: stream(5) })).toBe(CORPUS); } // Just above the floor the odds per letter are tiny, so check across seeds // that *something* slips — the threshold is a ramp, not a hard cut. const seeds = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; const mangled = seeds.filter((s) => drunkify(CORPUS, { drunkenness: 0.3, rng: stream(s) }) !== CORPUS); expect(mangled.length).toBeGreaterThan(0); }); it('clamps drunkenness into 0..1', () => { expect(drunkify(CORPUS, { drunkenness: 4, rng: stream(42) })).toBe( drunkify(CORPUS, { drunkenness: 1, rng: stream(42) }), ); expect(drunkify(CORPUS, { drunkenness: -3, rng: stream(42) })).toBe(CORPUS); }); it('treats a non-finite drunkenness as sober, without consuming draws', () => { for (const d of [NaN, Infinity, -Infinity]) { const rng = stream(77); expect(drunkify(CORPUS, { drunkenness: d, rng })).toBe(CORPUS); expect(rng.next()).toBe(stream(77).next()); } }); it('never substitutes or drops non-letters, even at maximum', () => { const punctuated = 'Oi! 3 IDs, 2 mates — $15 cover (cash only)?? "no wuckas"...\tline 4\n'; for (const seed of [11, 12, 13, 14, 15]) { const out = drunkify(punctuated, { drunkenness: 1, rng: stream(seed) }); expect(nonLetters(out)).toBe(nonLetters(punctuated)); } }); it('only ever emits letters where letters were', () => { const punctuated = 'Oi! 3 IDs, 2 mates — $15 cover (cash only)?? "no wuckas"...\tline 4\n'; for (const source of [CORPUS, punctuated]) { const shape = letterShape(source); for (const seed of [808, 809, 810]) { expect(drunkify(source, { drunkenness: 1, rng: stream(seed) })).toMatch(shape); } } }); it('mangles substantially more the drunker the speaker is', () => { // Statistical, so average over several seeds and assert a loose ratio. let tipsy = 0; let maggot = 0; for (const seed of [1, 2, 3, 4, 5, 6, 7, 8]) { tipsy += levenshtein(CORPUS, drunkify(CORPUS, { drunkenness: 0.4, rng: stream(seed) })); maggot += levenshtein(CORPUS, drunkify(CORPUS, { drunkenness: 1, rng: stream(seed) })); } expect(tipsy).toBeGreaterThan(0); expect(maggot).toBeGreaterThan(tipsy * 2); }); }); describe('typedLength', () => { it('reveals nothing at zero elapsed', () => { expect(typedLength(LINE, 0, 28)).toBe(0); expect(typedLength(LINE, 0, 28, 1)).toBe(0); }); it('reaches but never exceeds the full length', () => { expect(typedLength(LINE, LINE.length * 28, 28)).toBe(LINE.length); expect(typedLength(LINE, 10_000_000, 28, 1)).toBe(LINE.length); expect(typedLength('', 10_000, 28)).toBe(0); }); it('is monotonic in elapsed time', () => { let prev = -1; for (let ms = 0; ms <= 4000; ms += 17) { const n = typedLength(LINE, ms, 28, 0.7); expect(n).toBeGreaterThanOrEqual(prev); prev = n; } }); it('makes drunk speakers type slower', () => { const sober = typedLength(LINE, 900, 28, 0); const drunk = typedLength(LINE, 900, 28, 1); expect(drunk).toBeLessThan(sober); expect(typedLength(LINE, 900, 28, 0.5)).toBeLessThan(sober); }); it('clamps drunkenness like drunkify does', () => { expect(typedLength(LINE, 900, 28, 5)).toBe(typedLength(LINE, 900, 28, 1)); expect(typedLength(LINE, 900, 28, -5)).toBe(typedLength(LINE, 900, 28, 0)); }); it('short-circuits to full length when charMs is non-positive', () => { expect(typedLength(LINE, 0, 0)).toBe(LINE.length); expect(typedLength(LINE, 0, -5, 1)).toBe(LINE.length); }); // DialogueBox accumulates `elapsed += delta`, so a NaN that escaped here would // stall the reveal for the rest of the night rather than for one frame. it('treats a non-finite elapsed or drunkenness as zero', () => { expect(typedLength(LINE, NaN, 28)).toBe(0); expect(typedLength(LINE, NaN, 28, 1)).toBe(0); expect(typedLength(LINE, 900, 28, NaN)).toBe(typedLength(LINE, 900, 28, 0)); }); }); describe('wobbleAt', () => { it('is flat when sober', () => { for (let i = 0; i < 20; i++) expect(wobbleAt(i, 0, i * 0.3)).toBe(0); expect(wobbleAt(3, -1, 1.2)).toBe(0); }); it('stays within the 1.6px amplitude at maximum', () => { for (let i = 0; i < 200; i++) { for (const phase of [0, 0.7, 2.5, 6.1]) { expect(Math.abs(wobbleAt(i, 1, phase))).toBeLessThanOrEqual(1.6); expect(Math.abs(wobbleAt(i, 4, phase))).toBeLessThanOrEqual(1.6); // clamped } } }); it('scales with drunkenness', () => { // Index/phase chosen so sin() is well away from a zero crossing. const at = (d: number): number => Math.abs(wobbleAt(0, d, Math.PI / 2)); expect(at(0.5)).toBeGreaterThan(at(0.25)); expect(at(1)).toBeGreaterThan(at(0.5)); }); // Same reasoning as typedLength: phase is accumulated, and a NaN reaching // text.y makes Phaser drop the glyph silently instead of erroring. it('treats a non-finite phase or drunkenness as zero', () => { expect(wobbleAt(0, 1, NaN)).toBe(0); expect(wobbleAt(5, 1, Infinity)).toBe(0); expect(wobbleAt(5, NaN, 1.2)).toBe(0); }); it('is deterministic for the same index, drunkenness and phase', () => { expect(wobbleAt(7, 0.6, 1.25)).toBe(wobbleAt(7, 0.6, 1.25)); expect(wobbleAt(8, 0.6, 1.25)).not.toBe(wobbleAt(7, 0.6, 1.25)); }); });