add testing suite
This commit is contained in:
@@ -1,91 +0,0 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import { generateDungeon } from "../dungeonGenerator.js";
|
||||
import { generatePDF } from "../generatePDF.js";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
const OLLAMA_API_URL = process.env.OLLAMA_API_URL;
|
||||
|
||||
test("Integration tests", { skip: !OLLAMA_API_URL }, async (t) => {
|
||||
let dungeonData;
|
||||
|
||||
await t.test("Generate dungeon", async () => {
|
||||
dungeonData = await generateDungeon();
|
||||
assert(dungeonData, "Dungeon data should be generated");
|
||||
});
|
||||
|
||||
await t.test("Title is 2-4 words, no colons", () => {
|
||||
assert(dungeonData.title, "Title should exist");
|
||||
const words = dungeonData.title.split(/\s+/);
|
||||
assert(words.length >= 2 && words.length <= 4, `Title should be 2-4 words, got ${words.length}: "${dungeonData.title}"`);
|
||||
assert(!dungeonData.title.includes(":"), `Title should not contain colons: "${dungeonData.title}"`);
|
||||
});
|
||||
|
||||
await t.test("Flavor text is ≤60 words", () => {
|
||||
assert(dungeonData.flavor, "Flavor text should exist");
|
||||
const words = dungeonData.flavor.split(/\s+/);
|
||||
assert(words.length <= 60, `Flavor text should be ≤60 words, got ${words.length}`);
|
||||
});
|
||||
|
||||
await t.test("Hooks have no title prefixes", () => {
|
||||
assert(dungeonData.hooksRumors, "Hooks should exist");
|
||||
dungeonData.hooksRumors.forEach((hook, i) => {
|
||||
assert(!hook.match(/^[^:]+:\s/), `Hook ${i + 1} should not have title prefix: "${hook}"`);
|
||||
});
|
||||
});
|
||||
|
||||
await t.test("Exactly 6 random events", () => {
|
||||
assert(dungeonData.randomEvents, "Random events should exist");
|
||||
assert.strictEqual(dungeonData.randomEvents.length, 6, `Should have exactly 6 random events, got ${dungeonData.randomEvents.length}`);
|
||||
});
|
||||
|
||||
await t.test("Encounter details don't include encounter name", () => {
|
||||
assert(dungeonData.encounters, "Encounters should exist");
|
||||
dungeonData.encounters.forEach((encounter) => {
|
||||
if (encounter.details) {
|
||||
const detailsLower = encounter.details.toLowerCase();
|
||||
const nameLower = encounter.name.toLowerCase();
|
||||
assert(!detailsLower.startsWith(nameLower), `Encounter "${encounter.name}" details should not start with encounter name: "${encounter.details}"`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await t.test("Treasure uses em-dash format, no 'description' text", () => {
|
||||
assert(dungeonData.treasure, "Treasure should exist");
|
||||
dungeonData.treasure.forEach((item, i) => {
|
||||
if (typeof item === "object" && item.description) {
|
||||
assert(!item.description.toLowerCase().startsWith("description"), `Treasure ${i + 1} description should not start with 'description': "${item.description}"`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await t.test("NPCs have no 'description' text", () => {
|
||||
assert(dungeonData.npcs, "NPCs should exist");
|
||||
dungeonData.npcs.forEach((npc, i) => {
|
||||
if (npc.trait) {
|
||||
assert(!npc.trait.toLowerCase().startsWith("description"), `NPC ${i + 1} trait should not start with 'description': "${npc.trait}"`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await t.test("PDF fits on one page", async () => {
|
||||
const testPdfPath = path.join(process.cwd(), "test-output.pdf");
|
||||
try {
|
||||
await generatePDF(dungeonData, testPdfPath);
|
||||
const pdfBuffer = await fs.readFile(testPdfPath);
|
||||
// Check PDF page count by counting "%%EOF" markers (rough estimate)
|
||||
const pdfText = pdfBuffer.toString("binary");
|
||||
const pageCount = (pdfText.match(/\/Type\s*\/Page[^s]/g) || []).length;
|
||||
// Should be 1 page for content, or 2 if map exists
|
||||
const expectedPages = dungeonData.map ? 2 : 1;
|
||||
assert(pageCount <= expectedPages, `PDF should have ≤${expectedPages} page(s), got ${pageCount}`);
|
||||
} finally {
|
||||
try {
|
||||
await fs.unlink(testPdfPath);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
92
test/integration/dungeonGeneration.test.js
Normal file
92
test/integration/dungeonGeneration.test.js
Normal file
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect, beforeAll } from "vitest";
|
||||
import { generateDungeon } from "../../src/dungeonGenerator.js";
|
||||
import { generatePDF } from "../../src/generatePDF.js";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
const hasOllama = !!process.env.OLLAMA_API_URL;
|
||||
|
||||
describe.skipIf(!hasOllama)("Dungeon generation (Ollama)", () => {
|
||||
let dungeonData;
|
||||
|
||||
beforeAll(async () => {
|
||||
dungeonData = await generateDungeon();
|
||||
}, 120000);
|
||||
|
||||
it("generates dungeon data", () => {
|
||||
expect(dungeonData).toBeDefined();
|
||||
});
|
||||
|
||||
it("title is 2-4 words, no colons", () => {
|
||||
expect(dungeonData.title).toBeTruthy();
|
||||
const words = dungeonData.title.split(/\s+/);
|
||||
expect(words.length).toBeGreaterThanOrEqual(2);
|
||||
expect(words.length).toBeLessThanOrEqual(4);
|
||||
expect(dungeonData.title).not.toContain(":");
|
||||
});
|
||||
|
||||
it("flavor text is ≤60 words", () => {
|
||||
expect(dungeonData.flavor).toBeTruthy();
|
||||
const words = dungeonData.flavor.split(/\s+/);
|
||||
expect(words.length).toBeLessThanOrEqual(60);
|
||||
});
|
||||
|
||||
it("hooks have no title prefixes", () => {
|
||||
expect(dungeonData.hooksRumors).toBeDefined();
|
||||
dungeonData.hooksRumors.forEach((hook) => {
|
||||
expect(hook).not.toMatch(/^[^:]+:\s/);
|
||||
});
|
||||
});
|
||||
|
||||
it("has exactly 6 random events", () => {
|
||||
expect(dungeonData.randomEvents).toBeDefined();
|
||||
expect(dungeonData.randomEvents.length).toBe(6);
|
||||
});
|
||||
|
||||
it("encounter details do not start with encounter name", () => {
|
||||
expect(dungeonData.encounters).toBeDefined();
|
||||
dungeonData.encounters.forEach((encounter) => {
|
||||
if (encounter.details) {
|
||||
const detailsLower = encounter.details.toLowerCase();
|
||||
const nameLower = encounter.name.toLowerCase();
|
||||
expect(detailsLower.startsWith(nameLower)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("treasure descriptions do not start with 'description'", () => {
|
||||
expect(dungeonData.treasure).toBeDefined();
|
||||
dungeonData.treasure.forEach((item) => {
|
||||
if (typeof item === "object" && item.description) {
|
||||
expect(item.description.toLowerCase().startsWith("description")).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("NPC traits do not start with 'description'", () => {
|
||||
expect(dungeonData.npcs).toBeDefined();
|
||||
dungeonData.npcs.forEach((npc) => {
|
||||
if (npc.trait) {
|
||||
expect(npc.trait.toLowerCase().startsWith("description")).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("PDF fits on one page", async () => {
|
||||
const testPdfPath = path.join(process.cwd(), "test-output.pdf");
|
||||
try {
|
||||
await generatePDF(dungeonData, testPdfPath);
|
||||
const pdfBuffer = await fs.readFile(testPdfPath);
|
||||
const pdfText = pdfBuffer.toString("binary");
|
||||
const pageCount = (pdfText.match(/\/Type\s*\/Page[^s]/g) || []).length;
|
||||
const expectedPages = dungeonData.map ? 2 : 1;
|
||||
expect(pageCount).toBeLessThanOrEqual(expectedPages);
|
||||
} finally {
|
||||
try {
|
||||
await fs.unlink(testPdfPath);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
}, 60000);
|
||||
});
|
||||
178
test/unit/dungeonGenerator.generateDungeon.test.js
Normal file
178
test/unit/dungeonGenerator.generateDungeon.test.js
Normal file
@@ -0,0 +1,178 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("../../src/ollamaClient.js", () => ({ callOllama: vi.fn() }));
|
||||
|
||||
const { callOllama } = await import("../../src/ollamaClient.js");
|
||||
const { generateDungeon } = await import("../../src/dungeonGenerator.js");
|
||||
|
||||
describe("generateDungeon (mocked Ollama)", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(callOllama)
|
||||
.mockResolvedValueOnce(
|
||||
"1. Dark Hall\n2. Lost Mines\n3. Shadow Keep"
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
"Central Conflict: The power source fails. Primary Faction: The Guard. Dynamic Element: Temporal rifts."
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
"Description:\nA dark place under the earth.\nHooks & Rumors:\n1. A merchant vanished near the entrance.\n2. Strange lights in the depths.\n3. The Guard seeks the artifact.\n4. Rifts cause brief time skips."
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
"1. Entrance Hall: A dark entrance with torches and damp walls. Pillars offer cover. The air smells of earth.\n2. Climax Chamber: The final room where the power source pulses. The Guard holds the artifact. Multiple approaches possible."
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
`Locations:
|
||||
1. Corridor: A long corridor with flickering lights.
|
||||
2. Chamber: A side chamber with debris.
|
||||
3. Shrine: A small shrine to the old gods.
|
||||
|
||||
Encounters:
|
||||
1. Patrol: Hall: Guard patrol passes through.
|
||||
2. Rift: Corridor: A temporal rift causes disorientation.
|
||||
3. Ambush: Chamber: Bandits lie in wait.
|
||||
4. Guardian: Shrine: A warden challenges intruders.
|
||||
5. Boss: Climax Chamber: The leader defends the artifact.
|
||||
6. Trap: Corridor: A pressure plate triggers darts.
|
||||
|
||||
NPCs:
|
||||
1. Captain: Leader of the Guard, stern and duty-bound.
|
||||
2. Scout: Young scout, curious about the rifts.
|
||||
3. Priest: Keeper of the shrine, knows old lore.
|
||||
4. Merchant: Survivor who lost his cargo.
|
||||
|
||||
Treasures:
|
||||
1. Artifact: The power source core.
|
||||
2. Journal: Captain's log with tactical notes.
|
||||
3. Key: Opens the climax chamber.
|
||||
4. Gem: A glowing temporal crystal.
|
||||
|
||||
Random Events:
|
||||
1. Rift Shift: Time skips forward one hour.
|
||||
2. Guard Patrol: A patrol approaches.
|
||||
3. Echo: Voices from the past echo.
|
||||
4. Light Flicker: Lights go out for a moment.
|
||||
5. Distant Cry: Someone calls for help.
|
||||
6. Dust Fall: Ceiling dust falls, revealing a hidden symbol.`
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
"1. The adventurers could ally with the Guard and secure the artifact.\n2. They might destroy the source and end the rifts.\n3. They could bargain with the faction for passage.\n4. They might flee and seal the entrance."
|
||||
);
|
||||
});
|
||||
|
||||
it("returns dungeon data with all required fields", async () => {
|
||||
const result = await generateDungeon();
|
||||
expect(result).toBeDefined();
|
||||
expect(result.title).toBeTruthy();
|
||||
expect(result.flavor).toBeTruthy();
|
||||
expect(result.hooksRumors).toBeDefined();
|
||||
expect(Array.isArray(result.rooms)).toBe(true);
|
||||
expect(Array.isArray(result.encounters)).toBe(true);
|
||||
expect(Array.isArray(result.npcs)).toBe(true);
|
||||
expect(Array.isArray(result.treasure)).toBe(true);
|
||||
expect(Array.isArray(result.randomEvents)).toBe(true);
|
||||
expect(Array.isArray(result.plotResolutions)).toBe(true);
|
||||
}, 10000);
|
||||
|
||||
});
|
||||
|
||||
describe("generateDungeon with fewer items (mocked Ollama)", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(callOllama)
|
||||
.mockResolvedValueOnce("1. Dark Hall\n2. Lost Mines")
|
||||
.mockResolvedValueOnce("Central Conflict: War. Primary Faction: Guard. Dynamic Element: Magic.")
|
||||
.mockResolvedValueOnce("Description: A place.\nHooks & Rumors:\n1. One.\n2. Two.\n3. Three.\n4. Four.")
|
||||
.mockResolvedValueOnce("1. Entrance: First room.\n2. Climax: Final room.")
|
||||
.mockResolvedValueOnce(
|
||||
`Locations:
|
||||
1. Corridor: A corridor.
|
||||
2. Chamber: A chamber.
|
||||
|
||||
Encounters:
|
||||
1. Patrol: Corridor: A patrol.
|
||||
2. Ambush: Chamber: Bandits.
|
||||
|
||||
NPCs:
|
||||
1. Captain: Leader.
|
||||
2. Scout: Scout.
|
||||
|
||||
Treasures:
|
||||
1. Gold: Coins.
|
||||
2. Gem: A gem.
|
||||
|
||||
Random Events:
|
||||
1. Rift Shift: Time skips.
|
||||
2. Guard Patrol: Patrol approaches.`
|
||||
)
|
||||
.mockResolvedValueOnce("1. The adventurers could win.\n2. They might flee.");
|
||||
});
|
||||
|
||||
it("pads random events and encounters when step 5 returns fewer than 6", async () => {
|
||||
const result = await generateDungeon();
|
||||
expect(result.randomEvents.length).toBe(6);
|
||||
expect(result.encounters.length).toBe(6);
|
||||
expect(result.npcs.length).toBeGreaterThanOrEqual(4);
|
||||
}, 10000);
|
||||
|
||||
it("builds six encounters from scratch when step 5 returns none", async () => {
|
||||
vi.mocked(callOllama)
|
||||
.mockResolvedValueOnce("1. Dark Hall\n2. Lost Mines")
|
||||
.mockResolvedValueOnce("Central Conflict: War. Primary Faction: Guard. Dynamic Element: Magic.")
|
||||
.mockResolvedValueOnce("Description: A place.\nHooks & Rumors:\n1. One.\n2. Two.\n3. Three.\n4. Four.")
|
||||
.mockResolvedValueOnce("1. Entrance: First room.\n2. Climax: Final room.")
|
||||
.mockResolvedValueOnce(
|
||||
`Locations:
|
||||
1. Corridor: A corridor.
|
||||
2. Chamber: A chamber.
|
||||
|
||||
Encounters:
|
||||
|
||||
NPCs:
|
||||
1. Captain: Leader.
|
||||
|
||||
Treasures:
|
||||
1. Gold: Coins.
|
||||
|
||||
Random Events:
|
||||
1. Rift: Time skips.`
|
||||
)
|
||||
.mockResolvedValueOnce("1. The adventurers could win.");
|
||||
const result = await generateDungeon();
|
||||
expect(result.encounters.length).toBe(6);
|
||||
expect(result.encounters.every((e) => e.name && e.details)).toBe(true);
|
||||
}, 10000);
|
||||
|
||||
it("handles random events with no colon and short text (fallback name)", async () => {
|
||||
vi.mocked(callOllama)
|
||||
.mockResolvedValueOnce("1. Dark Hall\n2. Lost Mines")
|
||||
.mockResolvedValueOnce("Central Conflict: War. Primary Faction: Guard. Dynamic Element: Magic.")
|
||||
.mockResolvedValueOnce("Description: A place.\nHooks & Rumors:\n1. One.\n2. Two.\n3. Three.\n4. Four.")
|
||||
.mockResolvedValueOnce("1. Entrance: First room.\n2. Climax: Final room.")
|
||||
.mockResolvedValueOnce(
|
||||
`Locations:
|
||||
1. Corridor: A corridor.
|
||||
2. Chamber: A chamber.
|
||||
|
||||
Encounters:
|
||||
1. Patrol: Corridor: A patrol.
|
||||
2. Ambush: Chamber: Bandits.
|
||||
|
||||
NPCs:
|
||||
1. Captain: Leader.
|
||||
2. Scout: Scout.
|
||||
|
||||
Treasures:
|
||||
1. Gold: Coins.
|
||||
2. Gem: A gem.
|
||||
|
||||
Random Events:
|
||||
1. One two three
|
||||
2. Event Name: Placeholder event
|
||||
3. Rift Shift Time Skips Forward One Hour
|
||||
4. Rift Shift: Time skips forward one hour.`
|
||||
)
|
||||
.mockResolvedValueOnce("1. The adventurers could win.\n2. They might flee.");
|
||||
const result = await generateDungeon();
|
||||
expect(result.randomEvents.length).toBe(6);
|
||||
expect(result.randomEvents.some((e) => e.name && e.description)).toBe(true);
|
||||
}, 10000);
|
||||
});
|
||||
1088
test/unit/dungeonGenerator.test.js
Normal file
1088
test/unit/dungeonGenerator.test.js
Normal file
File diff suppressed because it is too large
Load Diff
227
test/unit/dungeonTemplate.test.js
Normal file
227
test/unit/dungeonTemplate.test.js
Normal file
@@ -0,0 +1,227 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
escapeHtml,
|
||||
truncateText,
|
||||
parseEventForDisplay,
|
||||
dungeonTemplate,
|
||||
} from "../../src/dungeonTemplate.js";
|
||||
|
||||
describe("escapeHtml", () => {
|
||||
it("returns empty string for empty input", () => {
|
||||
expect(escapeHtml("")).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string for null/undefined-like", () => {
|
||||
expect(escapeHtml(null)).toBe("");
|
||||
expect(escapeHtml(undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("escapes & < > \" '", () => {
|
||||
expect(escapeHtml("&")).toBe("&");
|
||||
expect(escapeHtml("<")).toBe("<");
|
||||
expect(escapeHtml(">")).toBe(">");
|
||||
expect(escapeHtml('"')).toBe(""");
|
||||
expect(escapeHtml("'")).toBe("'");
|
||||
expect(escapeHtml('<script>&"\'</script>')).toBe(
|
||||
"<script>&"'</script>"
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves normal text unchanged", () => {
|
||||
expect(escapeHtml("Hello World")).toBe("Hello World");
|
||||
});
|
||||
});
|
||||
|
||||
describe("truncateText", () => {
|
||||
it("returns empty for empty input", () => {
|
||||
expect(truncateText("", 1, 100)).toBe("");
|
||||
});
|
||||
|
||||
it("returns text when within sentence and char limits", () => {
|
||||
const one = "One sentence.";
|
||||
expect(truncateText(one, 1, 100)).toBe(one);
|
||||
});
|
||||
|
||||
it("truncates to maxSentences", () => {
|
||||
const three = "First. Second. Third.";
|
||||
expect(truncateText(three, 1, 500)).toBe("First.");
|
||||
expect(truncateText(three, 2, 500)).toContain("First.");
|
||||
expect(truncateText(three, 2, 500)).toContain("Second.");
|
||||
});
|
||||
|
||||
it("truncates by maxChars and ends at sentence boundary when possible", () => {
|
||||
const long = "A short bit. Then a much longer sentence that goes past the limit we set.";
|
||||
const out = truncateText(long, 99, 30);
|
||||
expect(out.length).toBeLessThanOrEqual(33);
|
||||
expect(out === "A short bit." || out.endsWith("...")).toBe(true);
|
||||
});
|
||||
|
||||
it("appends ... when no sentence boundary near end", () => {
|
||||
const noPeriod = "No period here and more text";
|
||||
expect(truncateText(noPeriod, 1, 15)).toMatch(/\.\.\.$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseEventForDisplay", () => {
|
||||
it("returns object name and description when given object", () => {
|
||||
const event = { name: "Event A", description: "Something happened." };
|
||||
const got = parseEventForDisplay(event, 0);
|
||||
expect(got.name).toBe("Event A");
|
||||
expect(got.description).toContain("Something");
|
||||
});
|
||||
|
||||
it('parses "Name: Description" string', () => {
|
||||
const got = parseEventForDisplay("Fire: The room catches fire.", 0);
|
||||
expect(got.name).toBe("Fire");
|
||||
expect(got.description).toContain("catches fire");
|
||||
});
|
||||
|
||||
it("splits string without colon into first two words as name, rest as description", () => {
|
||||
const got = parseEventForDisplay("One Two Three Four", 0);
|
||||
expect(got.name).toBe("One Two");
|
||||
expect(got.description).toBe("Three Four");
|
||||
});
|
||||
|
||||
it("uses fallback Event N and full string for short string", () => {
|
||||
const got = parseEventForDisplay("Hi", 2);
|
||||
expect(got.name).toBe("Event 3");
|
||||
expect(got.description).toBe("Hi");
|
||||
});
|
||||
|
||||
it("handles non-string non-object with index", () => {
|
||||
const got = parseEventForDisplay(null, 1);
|
||||
expect(got.name).toBe("Event 2");
|
||||
expect(got.description).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("dungeonTemplate", () => {
|
||||
it("produces HTML with title and main sections for minimal data", () => {
|
||||
const data = {
|
||||
title: "Test Dungeon",
|
||||
flavor: "A dark place.",
|
||||
hooksRumors: ["Hook one.", "Hook two."],
|
||||
rooms: [{ name: "Room 1", description: "A room." }],
|
||||
encounters: [{ name: "Encounter 1", details: "Hall: Something happens." }],
|
||||
npcs: [{ name: "NPC 1", trait: "A guard." }],
|
||||
treasure: [{ name: "Gold", description: "Shiny." }],
|
||||
randomEvents: [{ name: "Event 1", description: "Something." }],
|
||||
plotResolutions: ["Resolution one."],
|
||||
};
|
||||
const html = dungeonTemplate(data);
|
||||
expect(html).toContain("Test Dungeon");
|
||||
expect(html).toContain("A dark place.");
|
||||
expect(html).toContain("Room 1");
|
||||
expect(html).toContain("Encounter 1");
|
||||
expect(html).toContain("NPC 1");
|
||||
expect(html).toContain("Gold");
|
||||
expect(html).toContain("Event 1");
|
||||
expect(html).toContain("Resolution one.");
|
||||
expect(html).toContain("<!DOCTYPE html>");
|
||||
});
|
||||
|
||||
it("includes map page when data.map is data URL", () => {
|
||||
const data = {
|
||||
title: "With Map",
|
||||
flavor: "Flavor.",
|
||||
map: "data:image/png;base64,abc123",
|
||||
hooksRumors: ["H1"],
|
||||
rooms: [],
|
||||
encounters: [],
|
||||
npcs: [],
|
||||
treasure: [],
|
||||
randomEvents: [],
|
||||
plotResolutions: [],
|
||||
};
|
||||
const html = dungeonTemplate(data);
|
||||
expect(html).toContain("map-page");
|
||||
expect(html).toContain("data:image/png;base64,abc123");
|
||||
});
|
||||
|
||||
it("omits flavor paragraph when flavor is empty", () => {
|
||||
const data = {
|
||||
title: "No Flavor",
|
||||
flavor: "",
|
||||
hooksRumors: ["H1"],
|
||||
rooms: [],
|
||||
encounters: [],
|
||||
npcs: [],
|
||||
treasure: [],
|
||||
randomEvents: [],
|
||||
plotResolutions: [],
|
||||
};
|
||||
const html = dungeonTemplate(data);
|
||||
expect(html).toContain("No Flavor");
|
||||
expect(html).not.toMatch(/<p class="flavor">/);
|
||||
});
|
||||
|
||||
it("renders treasure as string Name — Desc", () => {
|
||||
const data = {
|
||||
title: "T",
|
||||
flavor: "F",
|
||||
hooksRumors: [],
|
||||
rooms: [],
|
||||
encounters: [],
|
||||
npcs: [],
|
||||
treasure: ["Gold — Shiny coins."],
|
||||
randomEvents: [],
|
||||
plotResolutions: [],
|
||||
};
|
||||
const html = dungeonTemplate(data);
|
||||
expect(html).toContain("Gold");
|
||||
expect(html).toContain("Shiny coins");
|
||||
});
|
||||
|
||||
it("renders NPC as string Name: Trait", () => {
|
||||
const data = {
|
||||
title: "T",
|
||||
flavor: "F",
|
||||
hooksRumors: [],
|
||||
rooms: [],
|
||||
encounters: [],
|
||||
npcs: ["Guard: A stern guard."],
|
||||
treasure: [],
|
||||
randomEvents: [],
|
||||
plotResolutions: [],
|
||||
};
|
||||
const html = dungeonTemplate(data);
|
||||
expect(html).toContain("Guard");
|
||||
expect(html).toContain("stern guard");
|
||||
});
|
||||
|
||||
it("strips location prefix from encounter details when it looks like a location name", () => {
|
||||
const data = {
|
||||
title: "T",
|
||||
flavor: "F",
|
||||
hooksRumors: [],
|
||||
rooms: [{ name: "Grand Hall", description: "Big." }],
|
||||
encounters: [
|
||||
{ name: "E1", details: "Grand Hall: The fight happens here in the hall." },
|
||||
],
|
||||
npcs: [],
|
||||
treasure: [],
|
||||
randomEvents: [],
|
||||
plotResolutions: [],
|
||||
};
|
||||
const html = dungeonTemplate(data);
|
||||
expect(html).toContain("The fight happens here");
|
||||
});
|
||||
|
||||
it("renders encounter details without name when details start with encounter name", () => {
|
||||
const data = {
|
||||
title: "T",
|
||||
flavor: "F",
|
||||
hooksRumors: [],
|
||||
rooms: [],
|
||||
encounters: [
|
||||
{ name: "Goblin Attack", details: "Goblin Attack: They strike." },
|
||||
],
|
||||
npcs: [],
|
||||
treasure: [],
|
||||
randomEvents: [],
|
||||
plotResolutions: [],
|
||||
};
|
||||
const html = dungeonTemplate(data);
|
||||
expect(html).toContain("They strike");
|
||||
});
|
||||
});
|
||||
263
test/unit/ollamaClient.test.js
Normal file
263
test/unit/ollamaClient.test.js
Normal file
@@ -0,0 +1,263 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
cleanText,
|
||||
inferApiType,
|
||||
callOllama,
|
||||
callOllamaExplicit,
|
||||
initializeModel,
|
||||
OLLAMA_MODEL,
|
||||
} from "../../src/ollamaClient.js";
|
||||
|
||||
describe("cleanText", () => {
|
||||
it("strips markdown headers", () => {
|
||||
expect(cleanText("# Title")).toBe("Title");
|
||||
expect(cleanText("## Sub")).toBe("Sub");
|
||||
});
|
||||
|
||||
it("replaces bold with plain text", () => {
|
||||
expect(cleanText("**bold**")).toBe("bold");
|
||||
});
|
||||
|
||||
it("removes asterisks and underscores", () => {
|
||||
expect(cleanText("*a* _b_")).toBe("a b");
|
||||
});
|
||||
|
||||
it("collapses whitespace to single spaces and trims", () => {
|
||||
expect(cleanText(" a b \n c ")).toBe("a b c");
|
||||
});
|
||||
});
|
||||
|
||||
describe("inferApiType", () => {
|
||||
it("returns ollama-generate for null/undefined/empty string", () => {
|
||||
expect(inferApiType(null)).toBe("ollama-generate");
|
||||
expect(inferApiType(undefined)).toBe("ollama-generate");
|
||||
expect(inferApiType("")).toBe("ollama-generate");
|
||||
});
|
||||
|
||||
it("returns open-webui for URL with /api/chat/completions", () => {
|
||||
expect(inferApiType("http://host/api/chat/completions")).toBe("open-webui");
|
||||
});
|
||||
|
||||
it("returns ollama-chat for URL with /api/chat", () => {
|
||||
expect(inferApiType("http://host/api/chat")).toBe("ollama-chat");
|
||||
});
|
||||
|
||||
it("returns ollama-generate for plain base URL", () => {
|
||||
expect(inferApiType("http://localhost:11434")).toBe("ollama-generate");
|
||||
});
|
||||
});
|
||||
|
||||
describe("callOllama (mocked fetch)", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalEnv = process.env.OLLAMA_API_URL;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.OLLAMA_API_URL = "http://localhost:11434";
|
||||
globalThis.fetch = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.OLLAMA_API_URL = originalEnv;
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("returns cleaned text from ollama-generate response", async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ response: "**Hello** world" }),
|
||||
});
|
||||
const result = await callOllama("Hi", undefined, 1, "test");
|
||||
expect(result).toBe("Hello world");
|
||||
});
|
||||
|
||||
it("throws on non-ok response", async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: "Error",
|
||||
text: () => Promise.resolve("server error"),
|
||||
});
|
||||
await expect(callOllama("Hi", undefined, 1, "test")).rejects.toThrow("Ollama request failed");
|
||||
});
|
||||
|
||||
it("throws on non-ok response when response.text() rejects", async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 502,
|
||||
statusText: "Bad Gateway",
|
||||
text: () => Promise.reject(new Error("body read error")),
|
||||
});
|
||||
await expect(callOllama("Hi", undefined, 1, "test")).rejects.toThrow("Ollama request failed");
|
||||
});
|
||||
|
||||
it("retries on failure then succeeds", async () => {
|
||||
vi.mocked(globalThis.fetch)
|
||||
.mockRejectedValueOnce(new Error("network error"))
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ response: "Retry ok" }),
|
||||
});
|
||||
const result = await callOllama("Hi", undefined, 2, "test");
|
||||
expect(result).toBe("Retry ok");
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("callOllamaExplicit (mocked fetch)", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalUrl = process.env.OLLAMA_API_URL;
|
||||
const originalKey = process.env.OLLAMA_API_KEY;
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.fetch = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.OLLAMA_API_URL = originalUrl;
|
||||
process.env.OLLAMA_API_KEY = originalKey;
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("returns content from open-webui response shape", async () => {
|
||||
process.env.OLLAMA_API_URL = "http://host/api/chat/completions";
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
choices: [{ message: { content: "**Open** answer" } }],
|
||||
}),
|
||||
});
|
||||
const result = await callOllamaExplicit(
|
||||
"Hi",
|
||||
"model",
|
||||
1,
|
||||
"test",
|
||||
"open-webui"
|
||||
);
|
||||
expect(result).toBe("Open answer");
|
||||
});
|
||||
|
||||
it("sends Authorization header when open-webui and OLLAMA_API_KEY set", async () => {
|
||||
process.env.OLLAMA_API_URL = "http://host/api/chat/completions";
|
||||
process.env.OLLAMA_API_KEY = "secret-key";
|
||||
process.env.OLLAMA_MODEL = "";
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
choices: [{ message: { content: "ok" } }],
|
||||
}),
|
||||
});
|
||||
await callOllamaExplicit("Hi", "model", 1, "test", "open-webui");
|
||||
const [, opts] = vi.mocked(globalThis.fetch).mock.calls[0];
|
||||
expect(opts?.headers?.Authorization).toBe("Bearer secret-key");
|
||||
});
|
||||
|
||||
it("returns content from ollama-chat response shape", async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({ message: { content: "Chat **reply**" } }),
|
||||
});
|
||||
const result = await callOllamaExplicit(
|
||||
"Hi",
|
||||
"model",
|
||||
1,
|
||||
"test",
|
||||
"ollama-chat"
|
||||
);
|
||||
expect(result).toBe("Chat reply");
|
||||
});
|
||||
|
||||
it("throws when response has no content", async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({}),
|
||||
});
|
||||
await expect(
|
||||
callOllamaExplicit("Hi", "model", 1, "test", "ollama-generate")
|
||||
).rejects.toThrow("No response from Ollama");
|
||||
});
|
||||
});
|
||||
|
||||
describe("initializeModel (mocked fetch)", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalEnv = process.env.OLLAMA_API_URL;
|
||||
const originalOllamaModel = process.env.OLLAMA_MODEL;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.OLLAMA_API_URL = "http://localhost:11434";
|
||||
process.env.OLLAMA_MODEL = "";
|
||||
globalThis.fetch = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.OLLAMA_API_URL = originalEnv;
|
||||
process.env.OLLAMA_MODEL = originalOllamaModel;
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("does not fetch when OLLAMA_MODEL is set", async () => {
|
||||
process.env.OLLAMA_MODEL = "existing-model";
|
||||
await initializeModel();
|
||||
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves OLLAMA_MODEL unchanged when fetch returns not ok", async () => {
|
||||
process.env.OLLAMA_MODEL = "";
|
||||
const before = OLLAMA_MODEL;
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: () => Promise.resolve({}),
|
||||
});
|
||||
await initializeModel();
|
||||
expect(OLLAMA_MODEL).toBe(before);
|
||||
});
|
||||
|
||||
it("fetches /api/tags when OLLAMA_MODEL not set", async () => {
|
||||
process.env.OLLAMA_MODEL = "";
|
||||
process.env.OLLAMA_API_URL = "http://localhost:11434";
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ models: [{ name: "test-model" }] }),
|
||||
});
|
||||
await initializeModel();
|
||||
expect(globalThis.fetch).toHaveBeenCalled();
|
||||
const [url, opts] = vi.mocked(globalThis.fetch).mock.calls[0];
|
||||
expect(String(url)).toMatch(/\/api\/tags$/);
|
||||
expect(opts?.method || "GET").toBe("GET");
|
||||
});
|
||||
|
||||
it("fetches /api/v1/models when URL has open-webui path and sets model from data.data id", async () => {
|
||||
process.env.OLLAMA_MODEL = "";
|
||||
process.env.OLLAMA_API_URL = "http://host/api/chat/completions";
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: [{ id: "webui-model" }] }),
|
||||
});
|
||||
await initializeModel();
|
||||
const [url] = vi.mocked(globalThis.fetch).mock.calls[0];
|
||||
expect(String(url)).toMatch(/\/api\/v1\/models$/);
|
||||
expect(OLLAMA_MODEL).toBe("webui-model");
|
||||
});
|
||||
|
||||
it("sets model from data.data[0].name when id missing", async () => {
|
||||
process.env.OLLAMA_MODEL = "";
|
||||
process.env.OLLAMA_API_URL = "http://host/api/chat/completions";
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: [{ name: "webui-model-name" }] }),
|
||||
});
|
||||
await initializeModel();
|
||||
expect(OLLAMA_MODEL).toBe("webui-model-name");
|
||||
});
|
||||
|
||||
it("catches fetch failure and warns", async () => {
|
||||
vi.mocked(globalThis.fetch).mockRejectedValueOnce(new Error("network"));
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
await initializeModel();
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining("Could not fetch default model"));
|
||||
warn.mockRestore();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user