import { describe, it, expect, vi } from "vitest"; import { cleanText } from "../../src/textUtils.js"; import { parseList, parseObjects, parseEncounterText, splitCombinedEncounters, parseRandomEventsRaw, parseMainContentSections, } from "../../src/parsing.js"; import { validateContentCompleteness, validateContentQuality, validateContentStructure, validateNarrativeCoherence, extractCanonicalNames, } from "../../src/validation.js"; import { validateAndFixContent, fixStructureIssues, fixMissingContent, fixNarrativeCoherence, validateNameConsistency, standardizeEncounterLocations, } from "../../src/contentFixes.js"; import { deduplicateRoomsByName, padNpcsToMinimum, buildEncountersList, mergeRandomEventsWithFallbacks, fixRoomPlaceholderName, limitIntermediateRooms, } from "../../src/dungeonBuild.js"; describe("cleanText", () => { it("returns empty string for empty input", () => { expect(cleanText("")).toBe(""); expect(cleanText(null)).toBe(""); }); it("strips markdown and normalizes spaces", () => { expect(cleanText("# Head")).toBe("Head"); expect(cleanText("**bold**")).toBe("bold"); expect(cleanText(" a b ")).toBe("a b"); }); }); describe("parseRandomEventsRaw", () => { it("parses Event Name: Description format", () => { const raw = "1. Rift Shift: Time skips forward."; const got = parseRandomEventsRaw(raw); expect(got).toHaveLength(1); expect(got[0].name).toBe("Rift Shift"); expect(got[0].description).toContain("Time skips"); }); it("returns null for placeholder name in colon format", () => { const raw = "1. Event Name: Placeholder event"; const got = parseRandomEventsRaw(raw); expect(got).toHaveLength(0); }); it("returns null when name contains 'placeholder' (colon format)", () => { const raw = "1. My placeholder idea: Something happens."; const got = parseRandomEventsRaw(raw); expect(got).toHaveLength(0); }); it("uses first two words as name when no colon and more than 3 words", () => { const raw = "1. Rift Shift Time Skips Forward"; const got = parseRandomEventsRaw(raw); expect(got).toHaveLength(1); expect(got[0].name).toBe("Rift Shift"); expect(got[0].description).toContain("Time Skips"); }); it("uses Event N and full text when no colon and 3 or fewer words", () => { const raw = "1. One two three"; const got = parseRandomEventsRaw(raw); expect(got).toHaveLength(1); expect(got[0].name).toBe("Event 1"); expect(got[0].description).toBe("One two three"); }); it("filters out short and placeholder-like entries", () => { const raw = "1. Short\n2. A random event occurs.\n3. Real Event: A real description here."; const got = parseRandomEventsRaw(raw); expect(got).toHaveLength(1); expect(got[0].name).toBe("Real Event"); }); }); describe("parseMainContentSections", () => { it("returns five sections when content has all labels", () => { const raw = "Locations:\n1. Hall: A hall.\n\nEncounters:\n1. Goblin: Hall: Attacks.\n\nNPCs:\n1. Guard: Stern.\n\nTreasures:\n1. Gold — coins.\n\nRandom Events:\n1. Event: Desc."; const got = parseMainContentSections(raw); expect(got.intermediateRoomsSection).toContain("Locations"); expect(got.encountersSection).toContain("Goblin"); expect(got.npcsSection).toContain("Guard"); expect(got.treasureSection).toContain("Gold"); expect(got.randomEventsSection).toContain("Event"); }); it("fills random section via regex when initial split has no fourth segment", () => { const raw = "Encounters:\n\nNPCs:\n\nTreasures:\n\nRandom Events:\n1. Rift: Time skips."; const got = parseMainContentSections(raw); expect(got.randomEventsSection).toContain("Rift"); }); it("returns initialSplit when random is in content but Random Events regex does not match", () => { const raw = "Encounters:\n\nNPCs:\n\nTreasures:\n\nrandom stuff"; const got = parseMainContentSections(raw); expect(got.randomEventsSection).toBeUndefined(); }); it("extracts encounters from first block when encounters section empty but block contains Encounter N", () => { const raw = "Locations:\n1. Corridor: A corridor.\nEncounter 1 Goblin Room Name Hall Details In the hall.\n\nEncounters:\n\nNPCs:\n1. Captain: Leader.\n\nTreasures:\n\nRandom Events:\n"; const got = parseMainContentSections(raw); expect(got.encountersSection).toMatch(/Goblin.*Hall/); expect(got.intermediateRoomsSection).not.toMatch(/Encounter 1/); }); it("returns encountersSection as-is when first block has no Encounter text", () => { const raw = "Locations:\n1. Hall: A hall.\n\nEncounters:\n1. Goblin: Hall: Attacks.\n\nNPCs:\n1. Guard: Stern."; const got = parseMainContentSections(raw); expect(got.encountersSection).toContain("Goblin"); expect(got.intermediateRoomsSection).toContain("Hall"); }); it("returns early when inter includes Encounter but regex match is null", () => { const raw = "Locations:\n1. Hall: A hall.\nEncounter foo\n\nEncounters:\n\nNPCs:\n1. Guard: Stern.\n\nTreasures:\n\nRandom Events:\n"; const got = parseMainContentSections(raw); expect(got.encountersSection).toBe(""); expect(got.intermediateRoomsSection).toContain("Encounter foo"); }); it("uses npcMatch when third segment is empty but content has npc", () => { const raw = "Encounters:\n\nNPCs:\n\nTreasures:\n1. Gold.\n\nRandom Events:\n"; const got = parseMainContentSections(raw); expect(got.npcsSection).toBeDefined(); expect(got.treasureSection).toContain("Gold"); }); it("uses npcMatch when NPCs: immediately followed by Treasures: (empty segment)", () => { const raw = "Encounters:\n\nNPCs:Treasures:\n1. Gold.\n\nRandom Events:\n"; const got = parseMainContentSections(raw); expect(got.npcsSection).toBe(""); expect(got.treasureSection).toContain("Gold"); }); it("returns withRandom when npc in content but npcMatch regex does not match", () => { const raw = "Encounters:\n\nenpcTreasures:\n\n"; const got = parseMainContentSections(raw); expect(got.encountersSection).toBe("enpc"); expect(got.npcsSection).toBe("\n\n"); }); it("extracts encounter using simpleMatch when no Room Name/Location in block", () => { const raw = "Locations:\n1. Corridor: A corridor.\nEncounter 1 Goblin Grand Hall Details In the hall.\n\nEncounters:\n\nNPCs:\n1. Captain: Leader.\n\nTreasures:\n\nRandom Events:\n"; const got = parseMainContentSections(raw); expect(got.encountersSection).toMatch(/Goblin|Grand Hall/); }); it("uses fallback format when encounter block matches neither match nor simpleMatch", () => { const raw = "Locations:\n1. Corridor: A corridor.\nEncounter 1 No details format here.\n\nEncounters:\n\nNPCs:\n1. Captain: Leader.\n\nTreasures:\n\nRandom Events:\n"; const got = parseMainContentSections(raw); expect(got.encountersSection).toMatch(/^1\./); }); }); describe("deduplicateRoomsByName", () => { it("returns empty for empty or null input", () => { expect(deduplicateRoomsByName([])).toEqual([]); expect(deduplicateRoomsByName(null)).toEqual([]); }); it("keeps first occurrence when names duplicate (case-insensitive)", () => { const rooms = [ { name: "Hall", description: "First" }, { name: "hall", description: "Second" }, { name: "HALL", description: "Third" }, ]; const got = deduplicateRoomsByName(rooms); expect(got).toHaveLength(1); expect(got[0].description).toBe("First"); }); it("filters out rooms with no name", () => { const rooms = [ { name: "Hall", description: "x" }, { name: "", description: "y" }, { name: null, description: "z" }, ]; const got = deduplicateRoomsByName(rooms); expect(got).toHaveLength(1); }); }); describe("padNpcsToMinimum", () => { it("returns input when length >= minCount", () => { const npcs = [{ name: "A", trait: "x" }, { name: "B", trait: "y" }, { name: "C", trait: "z" }, { name: "D", trait: "w" }]; expect(padNpcsToMinimum(npcs, "Primary Faction: Guard.", 4)).toEqual(npcs); }); it("pads to minCount using faction from coreConcepts", () => { const npcs = [{ name: "Captain", trait: "Leader." }]; const got = padNpcsToMinimum(npcs, "Primary Faction: The Guard.", 4); expect(got).toHaveLength(4); expect(got[1].name).toBe("NPC 2"); expect(got[1].trait).toContain("guard"); }); it("returns empty array when parsedNpcs null and minCount 4", () => { const got = padNpcsToMinimum(null, "Primary Faction: Guard.", 4); expect(got).toEqual([]); }); it("uses default faction text when coreConcepts has no Primary Faction", () => { const npcs = [{ name: "A", trait: "x" }]; const got = padNpcsToMinimum(npcs, "Central Conflict: War.", 3); expect(got).toHaveLength(3); expect(got[1].trait).toContain("primary faction"); }); }); describe("buildEncountersList", () => { const rooms = [{ name: "Hall", description: "x" }, { name: "Corridor", description: "y" }]; const coreConcepts = "Dynamic Element: Magic. Central Conflict: War."; it("returns 6 new encounters when parsedEncounters is empty", () => { const got = buildEncountersList([], rooms, coreConcepts); expect(got).toHaveLength(6); expect(got[0].name).toMatch(/Encounter$/); expect(got[0].details).toContain("magic"); }); it("pads when 0 < length < 6", () => { const parsed = [ { name: "Patrol", details: "Hall: A patrol." }, { name: "Ambush", details: "Corridor: Bandits." }, ]; const got = buildEncountersList(parsed, rooms, coreConcepts); expect(got).toHaveLength(6); expect(got[0].name).toBe("Patrol"); expect(got[2].details).toContain("magic"); }); it("returns input when length >= 6", () => { const parsed = Array.from({ length: 6 }, (_, i) => ({ name: `E${i + 1}`, details: `Hall: Details ${i}.` })); const got = buildEncountersList(parsed, rooms, coreConcepts); expect(got).toHaveLength(6); expect(got[0].name).toBe("E1"); }); it("uses Unknown Location when rooms array is empty", () => { const got = buildEncountersList([], [], coreConcepts); expect(got).toHaveLength(6); expect(got[0].name).toContain("Unknown Location"); }); it("uses default dynamicElement and conflict when coreConcepts is null", () => { const got = buildEncountersList([], [{ name: "Hall" }], null); expect(got).toHaveLength(6); expect(got[0].details).toContain("strange"); }); }); describe("mergeRandomEventsWithFallbacks", () => { const coreConcepts = "Central Conflict: War. Dynamic Element: Magic."; it("returns empty when parsedEvents empty (no fill)", () => { const got = mergeRandomEventsWithFallbacks([], coreConcepts, 6); expect(got).toHaveLength(0); }); it("merges when 0 < length < maxCount", () => { const parsed = [ { name: "Rift", description: "Time skips." }, { name: "Patrol", description: "Guard approaches." }, ]; const got = mergeRandomEventsWithFallbacks(parsed, coreConcepts, 6); expect(got).toHaveLength(6); expect(got[0].name).toBe("Rift"); expect(got[2].name).toBe("Dungeon Shift"); }); it("returns truncated list when length >= maxCount", () => { const parsed = Array.from({ length: 6 }, (_, i) => ({ name: `E${i + 1}`, description: "x" })); const got = mergeRandomEventsWithFallbacks(parsed, coreConcepts, 6); expect(got).toHaveLength(6); expect(got[0].name).toBe("E1"); }); it("uses default conflict when coreConcepts is null", () => { const parsed = [{ name: "E1", description: "x" }]; const got = mergeRandomEventsWithFallbacks(parsed, null, 6); expect(got).toHaveLength(6); expect(got[1].name).toBe("Conflict Manifestation"); }); }); describe("limitIntermediateRooms", () => { it("returns slice when length > maxCount and warns", () => { const rooms = [ { name: "A", description: "x" }, { name: "B", description: "y" }, { name: "C", description: "z" }, { name: "D", description: "w" }, ]; const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const got = limitIntermediateRooms(rooms, 3); expect(got).toHaveLength(3); expect(got[0].name).toBe("A"); expect(warn).toHaveBeenCalledWith(expect.stringContaining("Expected exactly 3 intermediate")); warn.mockRestore(); }); it("returns all when length <= maxCount", () => { const rooms = [{ name: "A", description: "x" }, { name: "B", description: "y" }]; const got = limitIntermediateRooms(rooms, 3); expect(got).toHaveLength(2); }); }); describe("fixRoomPlaceholderName", () => { it("returns room unchanged when name is not placeholder", () => { const room = { name: "Grand Hall", description: "A big hall." }; fixRoomPlaceholderName(room); expect(room.name).toBe("Grand Hall"); }); it("extracts name from description when name is Room Name and description has Name: prefix", () => { const room = { name: "Room Name", description: "Whispering Gallery: Dim light." }; fixRoomPlaceholderName(room); expect(room.name).toBe("Whispering Gallery"); expect(room.description).toContain("Dim"); }); it("extracts name and updates description when first regex matches (colon)", () => { const room = { name: "Room Name", description: "Grand Hall: Big space." }; fixRoomPlaceholderName(room); expect(room.name).toBe("Grand Hall"); expect(room.description).toBe("Big space."); }); it("extracts name when second regex matches (is)", () => { const room = { name: "Room Name", description: "The Chamber is dark." }; fixRoomPlaceholderName(room); expect(room.name).toBe("Chamber"); }); it("uses fallback first words when no nameMatch", () => { const room = { name: "room name", description: "No match here at all" }; fixRoomPlaceholderName(room); expect(room.name).toBe("No match here at"); }); it("returns null for null input", () => { expect(fixRoomPlaceholderName(null)).toBeNull(); }); }); describe("parseList", () => { it("returns empty array for empty input", () => { expect(parseList("")).toEqual([]); expect(parseList(null)).toEqual([]); }); it("parses single numbered item", () => { expect(parseList("1. First item")).toEqual(["First item"]); }); it("uses fallback split when regex does not match", () => { const raw = "1) Only paren style"; const got = parseList(raw); expect(got.length).toBeGreaterThanOrEqual(1); }); it("parses multiple numbered items", () => { const raw = "1. One\n2. Two\n3. Three"; expect(parseList(raw)).toEqual(["One", "Two", "Three"]); }); it("handles fallback format", () => { const raw = "1) Item A"; expect(parseList(raw).length).toBeGreaterThanOrEqual(1); }); }); describe("parseObjects", () => { it("parses rooms with name and description", () => { const raw = "1. Room Name - A dark room."; const got = parseObjects(raw, "rooms"); expect(got).toHaveLength(1); expect(got[0].name).toBe("Room Name"); expect(got[0].description).toContain("dark"); }); it("filters placeholder names for rooms", () => { const raw = "1. Location Name - desc"; expect(parseObjects(raw, "rooms")).toHaveLength(0); }); it("parses encounters with name and details", () => { const raw = "1. Goblin: Hall: Attacks."; const got = parseObjects(raw, "encounters"); expect(got).toHaveLength(1); expect(got[0].name).toBe("Goblin"); expect(got[0].details).toContain("Hall"); }); it("parses treasure with em-dash", () => { const raw = "1. Gold — Shiny coins."; const got = parseObjects(raw, "treasure"); expect(got).toHaveLength(1); expect(got[0].name).toBe("Gold"); expect(got[0].description).toContain("Shiny"); }); it("parses treasure with hyphen (generic path)", () => { const raw = "1. Silver - A pile of silver."; const got = parseObjects(raw, "treasure"); expect(got).toHaveLength(1); expect(got[0].name).toBe("Silver"); expect(got[0].description).toContain("pile"); }); it("parses npcs with trait", () => { const raw = "1. Guard: A stern guard."; const got = parseObjects(raw, "npcs"); expect(got).toHaveLength(1); expect(got[0].name).toBe("Guard"); expect(got[0].trait).toContain("stern"); }); it("filters encounter placeholder names (Location Name / Encounter Name)", () => { const rawWithPlaceholder = "1. Location Name: Hall: Some details here for the encounter."; const got = parseObjects(rawWithPlaceholder, "encounters"); expect(got).toHaveLength(0); const rawEncName = "1. Encounter Name: Room: Details."; expect(parseObjects(rawEncName, "encounters")).toHaveLength(0); }); it("parses encounter with two-part format (Name: details)", () => { const raw = "1. Goblin: Attacks in the hall."; const got = parseObjects(raw, "encounters"); expect(got).toHaveLength(1); expect(got[0].name).toBe("Goblin"); expect(got[0].details).toContain("Attacks"); }); it("filters out encounter with only one part (no colon)", () => { const raw = "1. OnlyOnePart"; const got = parseObjects(raw, "encounters"); expect(got).toHaveLength(0); }); it("filters treasure with placeholder name", () => { const raw = "1. Treasure Name — A shiny thing."; expect(parseObjects(raw, "treasure")).toHaveLength(0); }); it("filters room with NPC Name placeholder", () => { const raw = "1. NPC Name - A person."; expect(parseObjects(raw, "rooms")).toHaveLength(0); }); }); describe("parseEncounterText", () => { it("parses Encounter N Name Room Name Details format", () => { const text = "Encounter 1 Goblin Room Name Hall Details In the hall."; const got = parseEncounterText(text, 0); expect(got).not.toBeNull(); expect(got.name).toBe("Goblin"); expect(got.details).toContain("Hall"); }); it("parses Encounter N Name: Location: Details format", () => { const got = parseEncounterText("Encounter 1 Boss: Grand Hall: The boss waits here.", 0); expect(got).not.toBeNull(); expect(got.name).toBe("Boss"); expect(got.details).toMatch(/Grand Hall.*boss/); }); it("parses simple N Name: details format", () => { const got = parseEncounterText("1. Boss: The boss appears.", 0); expect(got).not.toBeNull(); expect(got.name).toContain("Boss"); expect(got.details).toContain("appears"); }); it("parses via colonSplit when text has colon but no leading number format", () => { const got = parseEncounterText("Goblin: Hall: Attacks here.", 0); expect(got).not.toBeNull(); expect(got.name).toBe("Goblin"); expect(got.details).toContain("Hall"); }); it("returns fallback name and trimmed details when no other format matches", () => { const got = parseEncounterText("1. xyz short", 2); expect(got.name).toBe("Encounter 3"); expect(got.details).toBeTruthy(); }); }); describe("splitCombinedEncounters", () => { it("returns empty for empty array", () => { expect(splitCombinedEncounters([])).toEqual([]); }); it("returns same array when not combined", () => { const list = [{ name: "E1", details: "Short." }]; expect(splitCombinedEncounters(list)).toEqual(list); }); it("returns encounter unchanged when details are missing", () => { const encounters = [{ name: "E1" }]; const rooms = [{ name: "Hall" }]; const { encounters: out } = standardizeEncounterLocations(encounters, rooms); expect(out[0]).toEqual(encounters[0]); }); it("splits when single encounter has combined text", () => { const combined = "Encounter 1 A Hall Details First. Encounter 2 B Room Details Second."; const list = [{ name: "1", details: combined }]; const got = splitCombinedEncounters(list); expect(got.length).toBeGreaterThanOrEqual(1); }); it("splits when single encounter details contain number and capital (combined)", () => { const list = [{ name: "E1", details: "Hall: First part. 2 Second Encounter in room." }]; const got = splitCombinedEncounters(list); expect(got.length).toBeGreaterThanOrEqual(1); }); }); describe("standardizeEncounterLocations", () => { it("returns input when encounters or rooms missing", () => { const enc = [{ name: "E1", details: "x" }]; expect(standardizeEncounterLocations(null, [])).toEqual({ encounters: null, fixes: [] }); expect(standardizeEncounterLocations(enc, null)).toEqual({ encounters: enc, fixes: [] }); }); it("adds colon after room name when missing", () => { const encounters = [{ name: "E1", details: "Hall something" }]; const rooms = [{ name: "Hall" }]; const { encounters: out, fixes } = standardizeEncounterLocations(encounters, rooms); expect(out[0].details).toMatch(/Hall:\s/); expect(fixes.length).toBeGreaterThanOrEqual(0); }); it("leaves details unchanged when location already has colon", () => { const encounters = [{ name: "E1", details: "Hall: already has colon." }]; const rooms = [{ name: "Hall" }]; const { encounters: out, fixes } = standardizeEncounterLocations(encounters, rooms); expect(out[0].details).toBe("Hall: already has colon."); expect(fixes).toHaveLength(0); }); it("leaves details unchanged when no room match", () => { const encounters = [{ name: "E1", details: "Unknown: text" }]; const rooms = [{ name: "Hall" }]; const { encounters: out } = standardizeEncounterLocations(encounters, rooms); expect(out[0].details).toBe("Unknown: text"); }); it("applies standardization when second room name matches details prefix", () => { const encounters = [{ name: "E1", details: "Corridor something" }]; const rooms = [{ name: "Hall" }, { name: "Corridor" }]; const { encounters: out } = standardizeEncounterLocations(encounters, rooms); expect(out[0].details).toMatch(/Corridor:\s/); }); }); describe("validateContentCompleteness", () => { it("reports missing title", () => { const issues = validateContentCompleteness({ flavor: "x".repeat(30) }); expect(issues.some((i) => i.toLowerCase().includes("title"))).toBe(true); }); it("reports short flavor", () => { const issues = validateContentCompleteness({ title: "X", flavor: "short" }); expect(issues.some((i) => i.toLowerCase().includes("flavor"))).toBe(true); }); it("reports few hooks", () => { const issues = validateContentCompleteness({ title: "X", flavor: "a ".repeat(15), hooksRumors: ["one"], }); expect(issues.some((i) => i.toLowerCase().includes("hook"))).toBe(true); }); it("reports when plotResolutions array is too short", () => { const issues = validateContentCompleteness({ title: "T", flavor: "a ".repeat(15), hooksRumors: ["a", "b", "c", "d"], rooms: [], encounters: [], npcs: [], plotResolutions: ["one"], }); expect(issues.some((i) => i.toLowerCase().includes("plot") || i.includes("resolution"))).toBe(true); }); it("returns empty for valid full data", () => { const roomDesc = "A room with enough description length here."; const data = { title: "Test", flavor: "A ".repeat(15), hooksRumors: ["a", "b", "c", "d"], rooms: Array.from({ length: 5 }, (_, i) => ({ name: `R${i + 1}`, description: roomDesc })), encounters: Array.from({ length: 6 }, (_, i) => ({ name: `E${i + 1}`, details: `R1: Encounter details long enough.` })), npcs: Array.from({ length: 4 }, (_, i) => ({ name: `N${i + 1}`, trait: "Trait with enough length for validation." })), treasure: Array.from({ length: 4 }, (_, i) => ({ name: `T${i + 1}`, description: "x" })), randomEvents: Array.from({ length: 6 }, (_, i) => ({ name: `Ev${i + 1}`, description: "y" })), plotResolutions: ["p1", "p2", "p3", "p4"], }; const issues = validateContentCompleteness(data); expect(issues.length).toBe(0); }); it("does not push room description issues when rooms is undefined", () => { const issues = validateContentCompleteness({ title: "T", flavor: "A ".repeat(15), hooksRumors: ["a", "b", "c", "d"], }); expect(issues.some((i) => i.includes("Room") && i.includes("description"))).toBe(false); }); it("reports room description too short", () => { const data = { title: "T", flavor: "A ".repeat(15), hooksRumors: ["a", "b", "c", "d"], rooms: [{ name: "R1", description: "Short." }], encounters: [], npcs: [], }; const issues = validateContentCompleteness(data); expect(issues.some((i) => i.includes("description too short"))).toBe(true); }); it("reports encounter details too short", () => { const data = { title: "T", flavor: "A ".repeat(15), hooksRumors: ["a", "b", "c", "d"], rooms: [], encounters: [{ name: "E1", details: "Short." }], npcs: [], }; const issues = validateContentCompleteness(data); expect(issues.some((i) => i.includes("details too short"))).toBe(true); }); it("reports NPC trait too short", () => { const data = { title: "T", flavor: "A ".repeat(15), hooksRumors: ["a", "b", "c", "d"], rooms: [], encounters: [], npcs: [{ name: "N1", trait: "Short." }], }; const issues = validateContentCompleteness(data); expect(issues.some((i) => i.includes("description too short"))).toBe(true); }); }); describe("validateContentQuality", () => { it("reports vague language when too many vague words", () => { const data = { flavor: "Some various several things stuff items here.", }; const issues = validateContentQuality(data); expect(issues.some((i) => i.includes("vague language"))).toBe(true); }); it("reports short room description", () => { const data = { rooms: [{ name: "R1", description: "Short." }], }; const issues = validateContentQuality(data); expect(issues.some((i) => i.includes("too short"))).toBe(true); }); it("skips vague check when text is missing", () => { const data = { rooms: [{ name: "R1" }] }; const issues = validateContentQuality(data); expect(issues.filter((i) => i.includes("vague"))).toHaveLength(0); }); }); describe("validateContentStructure", () => { it("reports missing room name", () => { const data = { rooms: [{ name: "", description: "x" }] }; const issues = validateContentStructure(data); expect(issues.some((i) => i.includes("Room") && i.includes("name"))).toBe(true); }); it("reports room name too long", () => { const data = { rooms: [{ name: "One Two Three Four Five Six Seven", description: "A room." }], }; const issues = validateContentStructure(data); expect(issues.some((i) => i.includes("name too long"))).toBe(true); }); it("reports encounter missing location prefix", () => { const data = { encounters: [{ name: "E1", details: "no colon prefix" }], }; const issues = validateContentStructure(data); expect(issues.some((i) => i.includes("location"))).toBe(true); }); it("reports encounter name too long", () => { const data = { encounters: [{ name: "A B C D E F G", details: "Hall: details" }], }; const issues = validateContentStructure(data); expect(issues.some((i) => i.includes("name too long"))).toBe(true); }); it("reports no location-prefix issue when details have Location: prefix", () => { const data = { encounters: [{ name: "E1", details: "Hall: proper prefix." }], }; const issues = validateContentStructure(data); expect(issues.some((i) => i.includes("location"))).toBe(false); }); it("reports NPC name too long", () => { const data = { npcs: [{ name: "Alice Bob Carol Dave Eve", trait: "Long trait here." }], }; const issues = validateContentStructure(data); expect(issues.some((i) => i.includes("name too long"))).toBe(true); }); }); describe("validateNarrativeCoherence", () => { it("reports unknown location in encounter details", () => { const data = { rooms: [{ name: "Hall" }], encounters: [{ name: "E1", details: "UnknownPlace: text" }], }; const issues = validateNarrativeCoherence(data); expect(issues.some((i) => i.includes("unknown location"))).toBe(true); }); it("reports poorly integrated faction when few refs", () => { const data = { coreConcepts: "Primary Faction: The Guard.", npcs: [{ name: "N1", trait: "unrelated" }], encounters: [{ name: "E1", details: "Hall: no faction" }], }; const issues = validateNarrativeCoherence(data); expect(issues.some((i) => i.includes("Faction"))).toBe(true); }); it("reports no issue when faction has enough refs", () => { const data = { coreConcepts: "Primary Faction: The Guard.", rooms: [{ name: "Hall" }], npcs: [{ name: "N1", trait: "Member of the Guard." }], encounters: [ { name: "E1", details: "Hall: The Guard patrols here." }, { name: "E2", details: "Hall: Guard reinforcements." }, ], }; const issues = validateNarrativeCoherence(data); expect(issues.filter((i) => i.includes("Faction"))).toHaveLength(0); }); it("reports no issue when coreConcepts has no Primary Faction", () => { const data = { coreConcepts: "Central Conflict: War.", rooms: [{ name: "Hall" }], encounters: [{ name: "E1", details: "Hall: text" }], }; const issues = validateNarrativeCoherence(data); expect(issues).toHaveLength(0); }); }); describe("extractCanonicalNames", () => { it("extracts npc and room names", () => { const data = { npcs: [{ name: "Alice" }], rooms: [{ name: "Hall" }], coreConcepts: "Primary Faction: The Guard.", }; const names = extractCanonicalNames(data); expect(names.npcs).toContain("Alice"); expect(names.rooms).toContain("Hall"); expect(names.factions).toContain("The Guard"); }); it("returns empty factions when coreConcepts has no Primary Faction", () => { const data = { coreConcepts: "Central Conflict: War. Dynamic Element: Magic." }; const names = extractCanonicalNames(data); expect(names.factions).toHaveLength(0); }); }); describe("validateNameConsistency", () => { it("fixes NPC name in flavor and hooks when inconsistent", () => { const data = { flavor: "The alice is here.", hooksRumors: ["alice said something."], encounters: [{ name: "E1", details: "Hall: alice appears." }], plotResolutions: ["alice wins."], npcs: [{ name: "Alice" }], rooms: [{ name: "Hall" }], }; const fixes = validateNameConsistency(data); expect(data.flavor).toContain("Alice"); expect(fixes.some((f) => f.includes("Flavor") || f.includes("flavor"))).toBe(true); }); it("fixes room name in encounter details when inconsistent", () => { const data = { encounters: [{ name: "E1", details: "main hall: something." }], rooms: [{ name: "Main Hall", description: "x" }], }; const fixes = validateNameConsistency(data); expect(data.encounters[0].details).toContain("Main Hall"); expect(fixes.some((f) => f.includes("room") || f.includes("Room"))).toBe(true); }); }); describe("fixStructureIssues", () => { it("adds default room name when description has no extractable name", () => { const data = { rooms: [{ name: "", description: "x" }], }; const fixes = fixStructureIssues(data); expect(data.rooms[0].name).toBe("Room 1"); expect(fixes.some((f) => f.includes("Added default name for room"))).toBe(true); }); it("adds default encounter name when details have no colon", () => { const data = { encounters: [{ name: "", details: "no colon here" }], }; const fixes = fixStructureIssues(data); expect(data.encounters[0].name).toBe("Encounter 1"); expect(fixes.some((f) => f.includes("Added default name for encounter"))).toBe(true); }); it("extracts encounter name from details when details have Name: Rest", () => { const data = { encounters: [{ name: "", details: "Goblin King: Attacks from the shadows." }], }; const fixes = fixStructureIssues(data); expect(data.encounters[0].name).toBe("Goblin King"); expect(data.encounters[0].details).toContain("Attacks"); expect(fixes.some((f) => f.includes("Extracted encounter name"))).toBe(true); }); it("adds default NPC name when trait has no extractable name", () => { const data = { npcs: [{ name: "", trait: "no capital word at start" }], }; const fixes = fixStructureIssues(data); expect(data.npcs[0].name).toBe("NPC 1"); expect(fixes.some((f) => f.includes("Added default name for NPC"))).toBe(true); }); it("extracts NPC name from trait when trait starts with Capital Name", () => { const data = { npcs: [{ name: "", trait: "Captain Smith: leads the guard." }], }; const fixes = fixStructureIssues(data); expect(data.npcs[0].name).toBe("Captain Smith"); expect(fixes.some((f) => f.includes("Extracted NPC name"))).toBe(true); }); it("truncates room name over 6 words", () => { const data = { rooms: [{ name: "A B C D E F G H", description: "Room." }], }; const fixes = fixStructureIssues(data); expect(data.rooms[0].name).toBe("A B C D E F"); expect(fixes.some((f) => f.includes("Truncated room name"))).toBe(true); }); it("truncates encounter name over 6 words", () => { const data = { encounters: [{ name: "A B C D E F G", details: "Hall: x" }], }; const fixes = fixStructureIssues(data); expect(data.encounters[0].name).toBe("A B C D E F"); expect(fixes.some((f) => f.includes("Truncated encounter name"))).toBe(true); }); it("truncates NPC name over 4 words", () => { const data = { npcs: [{ name: "A B C D E", trait: "Trait." }], }; const fixes = fixStructureIssues(data); expect(data.npcs[0].name).toBe("A B C D"); expect(fixes.some((f) => f.includes("Truncated NPC name"))).toBe(true); }); }); describe("fixMissingContent", () => { it("pads encounters using rooms when encounters exist but under 6", () => { const data = { rooms: [{ name: "Hall", description: "A hall." }], encounters: [{ name: "E1", details: "Hall: first." }], coreConcepts: "Dynamic Element: Magic. Central Conflict: War.", }; const fixes = fixMissingContent(data); expect(data.encounters.length).toBe(6); expect(fixes.some((f) => f.includes("fallback") || f.includes("Added"))).toBe(true); }); it("pads NPCs to 4 when missing", () => { const data = { npcs: [], coreConcepts: "Primary Faction: Guard." }; const fixes = fixMissingContent(data); expect(data.npcs.length).toBe(4); expect(fixes.length).toBeGreaterThan(0); }); it("pads treasure to 4 when missing", () => { const data = { treasure: [] }; const fixes = fixMissingContent(data); expect(data.treasure.length).toBe(4); expect(fixes.some((f) => f.includes("treasure"))).toBe(true); }); it("pads random events to 6 when some exist and coreConcepts set", () => { const data = { randomEvents: [{ name: "E1", description: "One." }], coreConcepts: "Dynamic Element: Magic. Central Conflict: War.", }; const fixes = fixMissingContent(data); expect(data.randomEvents.length).toBe(6); expect(fixes.some((f) => f.includes("random event"))).toBe(true); }); it("pads plot resolutions to 4 when missing", () => { const data = { plotResolutions: [] }; const fixes = fixMissingContent(data); expect(data.plotResolutions.length).toBe(4); expect(fixes.some((f) => f.includes("plot resolution"))).toBe(true); }); it("does not add random event fallbacks when randomEvents is empty", () => { const data = { randomEvents: [], coreConcepts: "Dynamic Element: Magic." }; fixMissingContent(data); expect(data.randomEvents.length).toBe(0); }); }); describe("fixNarrativeCoherence", () => { it("fixes encounter with unknown location by assigning a room", () => { const data = { rooms: [{ name: "Hall", description: "A hall." }], encounters: [{ name: "E1", details: "UnknownPlace: something happens." }], }; const fixes = fixNarrativeCoherence(data); expect(data.encounters[0].details).toMatch(/^Hall:\s/); expect(fixes.some((f) => f.includes("Fixed unknown location"))).toBe(true); }); it("leaves encounter unchanged when location matches a room name", () => { const data = { rooms: [{ name: "Hall", description: "A hall." }], encounters: [{ name: "E1", details: "Hall: something." }], }; const fixes = fixNarrativeCoherence(data); expect(data.encounters[0].details).toBe("Hall: something."); expect(fixes).toHaveLength(0); }); it("does not assign room when rooms array is empty", () => { const data = { rooms: [], encounters: [{ name: "E1", details: "Unknown: text." }], }; const fixes = fixNarrativeCoherence(data); expect(data.encounters[0].details).toBe("Unknown: text."); expect(fixes).toHaveLength(0); }); }); describe("validateAndFixContent", () => { it("applies fixes and returns dungeonData", () => { const dungeonData = { title: "Test", flavor: "A ".repeat(20), hooksRumors: ["a", "b", "c", "d"], rooms: [ { name: "", description: "Grand Hall. A big room." }, { name: "Room2", description: "Second." }, ], encounters: [ { name: "", details: "Something happens." }, ], npcs: [], treasure: [], randomEvents: [], plotResolutions: ["One."], coreConcepts: "Primary Faction: Guard. Dynamic Element: Magic.", }; const result = validateAndFixContent(dungeonData); expect(result).toBeDefined(); expect(result.title).toBe("Test"); expect(dungeonData.rooms[0].name).toBeTruthy(); expect(dungeonData.encounters[0].name).toBeTruthy(); }); it("adds location to encounter when details lack location prefix", () => { const dungeonData = { title: "Test", flavor: "A ".repeat(20), hooksRumors: ["a", "b", "c", "d"], rooms: [{ name: "Hall", description: "A hall." }], encounters: [{ name: "E1", details: "no colon prefix" }], npcs: [], coreConcepts: "Primary Faction: Guard.", }; const result = validateAndFixContent(dungeonData); expect(dungeonData.encounters[0].details).toMatch(/^Hall:\s/); expect(result).toBeDefined(); }); it("skips encounter when details are missing", () => { const dungeonData = { title: "Test", flavor: "A ".repeat(20), hooksRumors: ["a", "b", "c", "d"], rooms: [{ name: "Hall", description: "A hall." }], encounters: [{ name: "E1" }], npcs: [], coreConcepts: "Primary Faction: Guard.", }; const result = validateAndFixContent(dungeonData); expect(result.encounters[0].details).toBeUndefined(); }); it("logs Content quality checks passed when no issues remain", () => { const longDesc = "A room with enough descriptive text to pass length checks here."; const longDetails = "Hall: Encounter details that are long enough for validation."; const longTrait = "An NPC with a trait long enough to pass validation here."; const dungeonData = { title: "Valid Dungeon", flavor: "A dungeon with enough flavor text to pass the minimum length.", hooksRumors: ["a", "b", "c", "d"], rooms: [ { name: "Hall", description: longDesc }, { name: "Room2", description: longDesc }, { name: "Room3", description: longDesc }, { name: "Room4", description: longDesc }, { name: "Room5", description: longDesc }, ], encounters: [ { name: "E1", details: longDetails }, { name: "E2", details: longDetails }, { name: "E3", details: longDetails }, { name: "E4", details: longDetails }, { name: "E5", details: longDetails }, { name: "E6", details: longDetails }, ], npcs: [ { name: "Guard", trait: longTrait + " Guard faction." }, { name: "Captain", trait: longTrait + " Guard faction." }, { name: "N3", trait: longTrait }, { name: "N4", trait: longTrait }, ], treasure: [{ name: "T1", description: "d" }, { name: "T2", description: "d" }, { name: "T3", description: "d" }, { name: "T4", description: "d" }], randomEvents: Array(6).fill({ name: "Event", description: "Desc" }), plotResolutions: ["P1", "P2", "P3", "P4"], coreConcepts: "Primary Faction: Guard. Dynamic Element: Magic.", }; const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); validateAndFixContent(dungeonData); expect(logSpy).toHaveBeenCalledWith("\n[Validation] Content quality checks passed"); logSpy.mockRestore(); }); });