import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; import { AGE_NOTES, AUDIENCE_OUTCOMES, BAND_ADJECTIVES, BAND_DESCRIPTORS, BAND_NOUNS, BAND_PERSON_NAMES, BAND_SUFFIXES, BAND_VERBS, BUILDING_STATES, CONNECTORS, CROSSOVERS, createRand, DEFAULT_BAND_SEED, DEFAULT_DESCRIPTION_SEED, DEFAULT_VENUE_SEED, FESTIVAL_TYPES, GENRES, generateBandNames, generateDescriptions, generateVenueNames, INTENSITIES, LISTING_TYPES, NOISE_VERBS, NUMBER_WORDS, ODD_INSTRUMENTS, PRIZES, PROHIBITIONS, pickFrom, REQUIREMENTS, SHOW_ITEMS, TIMES, TOUR_ADJECTIVES, VENUE_TYPES, WEIRD_VENUES, } from "#shared/band-name-generator.ts"; describe("createRand", () => { test("produces the expected Mulberry32 sequence for a fixed seed", () => { const rand = createRand(0x12345678); expect([rand(), rand(), rand(), rand()]).toEqual([ 0.10615200875326991, 0.941276284167543, 0.9398706152569503, 0.2338848018553108, ]); }); test("pickFrom maps seeded random values to stable array indexes", () => { const rand = createRand(0x12345678); const pool = ["alpha", "beta", "gamma", "delta"]; expect([ pickFrom(rand, pool), pickFrom(rand, pool), pickFrom(rand, pool), pickFrom(rand, pool), ]).toEqual(["alpha", "delta", "delta", "alpha"]); }); }); describe("generateBandNames", () => { test("returns exactly `count` names", () => { expect(generateBandNames(5, 1).length).toBe(5); expect(generateBandNames(60, DEFAULT_BAND_SEED).length).toBe(60); }); test("the seeded default list contains no duplicates", () => { const names = generateBandNames(60, DEFAULT_BAND_SEED); expect(new Set(names).size).toBe(names.length); }); test("is deterministic for the same seed", () => { expect(generateBandNames(20, 7)).toEqual(generateBandNames(20, 7)); }); test("differs between seeds", () => { expect(generateBandNames(10, 1)).not.toEqual(generateBandNames(10, 2)); }); test("each name is built from the band word pools", () => { const haystack = [ ...BAND_ADJECTIVES, ...BAND_NOUNS, ...BAND_PERSON_NAMES, ...BAND_SUFFIXES, ]; for (const name of generateBandNames(40, 11)) { expect(name.length).toBeGreaterThan(0); expect(haystack.some((w) => name.includes(w))).toBe(true); } }); test("produces a wide variety (no single template dominates)", () => { const names = generateBandNames(60, DEFAULT_BAND_SEED); const startsWithThe = names.filter((n) => n.startsWith("The ")).length; expect(startsWithThe).toBeLessThan(names.length); }); }); describe("generateVenueNames", () => { test("returns exactly `count` unique names", () => { const names = generateVenueNames(40, DEFAULT_VENUE_SEED); expect(names.length).toBe(40); expect(new Set(names).size).toBe(40); }); test("is deterministic for the same seed", () => { expect(generateVenueNames(15, 5)).toEqual(generateVenueNames(15, 5)); }); test("each name incorporates a venue or festival type word", () => { const venueWords = [...VENUE_TYPES, ...FESTIVAL_TYPES]; for (const name of generateVenueNames(40, DEFAULT_VENUE_SEED)) { expect(venueWords.some((w) => name.includes(w))).toBe(true); } }); }); describe("generateDescriptions", () => { test("returns exactly `count` descriptions", () => { expect(generateDescriptions(40, DEFAULT_DESCRIPTION_SEED).length).toBe(40); }); test("is deterministic for the same seed", () => { expect(generateDescriptions(20, 42)).toEqual(generateDescriptions(20, 42)); }); test("differs between seeds", () => { expect(generateDescriptions(10, 1)).not.toEqual( generateDescriptions(10, 2), ); }); test("every description starts with a capital letter", () => { for (const d of generateDescriptions(40, DEFAULT_DESCRIPTION_SEED)) { expect(d.length).toBeGreaterThan(0); expect(d[0]).toBe(d[0]!.toUpperCase()); } }); test("articles agree with the following word", () => { for (const d of generateDescriptions(80, DEFAULT_DESCRIPTION_SEED)) { // No "An " or "A " left after fixArticles runs. expect(d).not.toMatch(/\bAn [^aeiouAEIOU]/); expect(d).not.toMatch(/\bA [aeiouAEIOU]/); } }); }); // Pinned outputs for the default seeds. These double as a regression check on // the seed constants themselves (a changed seed renders different text) and // on renderTemplate's capitalisation (a botched charAt/slice index would // produce different text at the same position). describe("golden outputs for the default seeds", () => { test("first band name", () => { expect(generateBandNames(1, DEFAULT_BAND_SEED)[0]).toBe( "Insatiable Rabid Hydra", ); }); test("first venue name", () => { expect(generateVenueNames(1, DEFAULT_VENUE_SEED)[0]).toBe( "The Boiler Room of Caboose", ); }); test("first description (capitalises a lowercase pool word)", () => { expect(generateDescriptions(1, DEFAULT_DESCRIPTION_SEED)[0]).toBe( "Approximately-original-lineup reckoning supported by blackgaze eternal underdogs", ); }); test("fixes 'A' to 'An' before a vowel-starting building state", () => { expect(generateDescriptions(120, DEFAULT_DESCRIPTION_SEED)[119]).toBe( "An informally-occupied former HMV hosts emo-violence warriors", ); }); test("leaves 'A' as-is before a consonant-starting building state", () => { expect(generateDescriptions(24, DEFAULT_DESCRIPTION_SEED)[23]).toBe( "A derelict padlocked snooker hall hosts crust-punk diehards", ); }); }); // Every pattern template, copied from band-name-generator.ts (the arrays // themselves aren't exported — only reachable through the generate* // functions). Mutating any template's literal text must make it stop // matching its regex here, for every sample that used it. const BAND_PATTERNS = [ "The {adj} {noun}", "The {adj} {noun} {suffix}", "{adj} {noun}", "{adj} {noun} {suffix}", "{noun}'s {adj} {noun}", "{adj} {person} {suffix}", "{person} {suffix}", "{adj} {adj} {noun}", "The {adj} {adj} {noun}", "A {adj} {noun}", "{noun} of {noun}", "The {noun} of {noun}", "{noun} {suffix}", "{person} and the {adj} {noun}", ]; const VENUE_PATTERNS = [ "The {noun} {venue}", "The {adj} {noun} {venue}", "{noun} {venue}", "{adj} {noun} {festival}", "The {noun} {festival}", "{noun}'s {venue}", "The {venue} of {noun}", "The {adj} {venue}", "{noun} {festival}", "{person}'s {venue}", ]; const DESCRIPTION_PATTERNS = [ "{intensity} {listingType} of {showItem}, {showItem}, and {showItem}", "{intensity} {genre} {listingType} — {ageNote}, no {prohibition}", "{tourAdj} {listingType} {connector} {genre} {bandDescriptor}", "{genre} {bandDescriptor} {noiseVerb} the {venue}", "{genre} {listingType} {connector} {number} {oddInstrument}", "{number} bands, {number} {oddInstrument}s, one {buildingState} {weirdVenue}", "{bandDescriptor} of {genre} {noiseVerb} the {venue} on a {listingType} of {showItem}", "{intensity} {listingType} of {genre}, {genre}, and {showItem}", "{genre} {listingType}, {ageNote}, no {prohibition}", "{crossover} {genre} {listingType} {connector} {oddInstrument}", "A {buildingState} {weirdVenue} hosts {genre} {bandDescriptor}", "{genre} {listingType} — {requirement} compulsory, {requirement} optional", "{genre} karaoke {listingType}, prizes for {prize}", "{tourAdj} tour {connector} {outcome} and {outcome}", "Doors at {time}, {genre} on at {time}, {outcome} by midnight", "{genre} collective inside a {buildingState} {weirdVenue}", "{bandDescriptor} {bandVerb} for one {tourAdj} {listingType}", "{intensity} {genre} {listingType} {connector} {oddInstrument} solos", "{genre} {bandDescriptor} {bandVerb} from the {weirdVenue}", "{intensity} {showItem}, {intensity} {showItem}, one {oddInstrument}", "{crossover} crossover {connector} {oddInstrument} and {showItem}", "{tourAdj} {listingType} ending in {outcome}", "{intensity} {genre} {connector} {requirement} and {requirement}", "{number} {genre} acts {connector} {number} {oddInstrument}", "{bandDescriptor} of {genre} return for one {tourAdj} {listingType}", ]; // Mirrors the private SLOT_POOLS map in band-name-generator.ts, so each // slot's regex only accepts its actual pool words — not any string. A loose // `.+` per slot would let a mutated template's corrupted tail get absorbed // by some other (also loose) pattern's slot, hiding the mutation. const SLOT_POOLS: Record = { adj: BAND_ADJECTIVES, ageNote: AGE_NOTES, bandDescriptor: BAND_DESCRIPTORS, bandVerb: BAND_VERBS, buildingState: BUILDING_STATES, connector: CONNECTORS, crossover: CROSSOVERS, festival: FESTIVAL_TYPES, genre: GENRES, intensity: INTENSITIES, listingType: LISTING_TYPES, noiseVerb: NOISE_VERBS, noun: BAND_NOUNS, number: NUMBER_WORDS, oddInstrument: ODD_INSTRUMENTS, outcome: AUDIENCE_OUTCOMES, person: BAND_PERSON_NAMES, prize: PRIZES, prohibition: PROHIBITIONS, requirement: REQUIREMENTS, showItem: SHOW_ITEMS, suffix: BAND_SUFFIXES, time: TIMES, tourAdj: TOUR_ADJECTIVES, venue: VENUE_TYPES, weirdVenue: WEIRD_VENUES, }; const escapeRegex = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); /** Alternation of a slot's pool words. The first slot in a template also * accepts each word with its first letter capitalised, since renderTemplate * capitalises the whole string's first character. */ const slotAlternation = (key: string, isFirstSlot: boolean): string => { const words = SLOT_POOLS[key]!; const variants = isFirstSlot ? words.flatMap((w) => [w, w.charAt(0).toUpperCase() + w.slice(1)]) : words; return `(?:${[...new Set(variants)].map(escapeRegex).join("|")})`; }; /** Build a regex matching a rendered template: literal text stays literal, * `{slot}` becomes an alternation of that slot's real pool words, and a * leading "A " tolerates the "An" correction (the only place any template * needs it — see fixArticles). */ const templateRegex = (template: string): RegExp => { const segments = template.split(/(\{\w+\})/g); const firstSlotIndex = segments.findIndex((part) => /^\{\w+\}$/.test(part)); const body = segments .map((part, index) => { const slotMatch = /^\{(\w+)\}$/.exec(part); return slotMatch ? slotAlternation(slotMatch[1]!, index === firstSlotIndex) : escapeRegex(part); }) .join("") .replace(/^A /, "(?:A|An) "); return new RegExp(`^${body}$`); }; /** Every sample matches one known pattern, and every pattern matches at * least one sample — so mutating any pattern's literal text is caught * whether the mutant renders as blank output or a corrupted string. */ const expectFullPatternCoverage = ( patterns: readonly string[], samples: readonly string[], ): void => { const regexes = patterns.map(templateRegex); for (const sample of samples) { expect(regexes.some((re) => re.test(sample))).toBe(true); } for (const re of regexes) { expect(samples.some((s) => re.test(s))).toBe(true); } }; describe("pattern template coverage", () => { test("every band pattern is used, and every band name matches a known pattern", () => { expectFullPatternCoverage( BAND_PATTERNS, generateBandNames(120, DEFAULT_BAND_SEED), ); }); test("every venue pattern is used, and every venue name matches a known pattern", () => { expectFullPatternCoverage( VENUE_PATTERNS, generateVenueNames(150, DEFAULT_VENUE_SEED), ); }); test("every description pattern is used, and every description matches a known pattern", () => { expectFullPatternCoverage( DESCRIPTION_PATTERNS, generateDescriptions(250, DEFAULT_DESCRIPTION_SEED), ); }); });