import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
// Mock Registry before any transitive imports resolve it, so that effect
// registrations at module-load time call the stub instead of the real singleton.
vi.mock("../core/registry.js", () => ({
Registry: {
get: vi.fn(),
register: vi.fn(),
serialize: vi.fn(),
deserialize: vi.fn(),
getSerializersByTag: vi.fn(() => new Map()),
},
}));
import { loadBoard, buildBoard } from "./loader.js";
import { Board, ROWS, COLUMNS } from "../core/board.js";
import { Terrain } from "../core/terrain.js";
import { Agent } from "../core/agent.js";
import { Item } from "../core/item.js";
import { Registry } from "../core/registry.js";
// Minimal test pieces: Object.create satisfies instanceof checks without
// requiring a full constructor call (no name/flags/symbol needed).
const testTerrain = Object.create(Terrain.prototype);
const testAgent = Object.create(Agent.prototype);
const testItem = Object.create(Item.prototype);
/** Return a minimal valid scenario data object with optional overrides. */
function makeData(overrides = {}) {
return {
outside: false,
startX: 0,
startY: 0,
scenarioName: null,
creator: null,
description: null,
startInv: null,
folder: null,
diagram: [],
terrain: {},
pieces: [],
...overrides,
};
}
// ── buildBoard ────────────────────────────────────────────────────────────────
describe("buildBoard", () => {
beforeEach(() => {
vi.clearAllMocks();
});
// ── board properties ───────────────────────────────────────────────────────
it("sets board properties from data", () => {
const data = makeData({
outside: true,
startX: 3,
startY: 7,
scenarioName: "My Board",
creator: "Alice",
description: "A cool board",
folder: "myfolder",
});
const { board } = buildBoard(data, "myfolder/myboard");
expect(board.outside).toBe(true);
expect(board.startX).toBe(3);
expect(board.startY).toBe(7);
expect(board.scenarioName).toBe("My Board");
expect(board.creator).toBe("Alice");
expect(board.description).toBe("A cool board");
expect(board.folder).toBe("myfolder");
expect(board.boardID).toBe("myfolder/myboard");
});
it("uses data.name as fallback for scenarioName when scenarioName is absent", () => {
const data = makeData({ scenarioName: undefined, name: "Fallback" });
const { board } = buildBoard(data, "path");
expect(board.scenarioName).toBe("Fallback");
});
it("sets scenarioName to null when neither scenarioName nor name is present", () => {
const data = makeData({ scenarioName: undefined });
const { board } = buildBoard(data, "path");
expect(board.scenarioName).toBeNull();
});
it("stores startInv string directly on board", () => {
const data = makeData({ startInv: "Sword,Shield" });
const { board } = buildBoard(data, "path");
expect(board.startInv).toBe("Sword,Shield");
});
it("returns a Board instance", () => {
const { board } = buildBoard(makeData(), "path");
expect(board).toBeInstanceOf(Board);
});
// ── return value ───────────────────────────────────────────────────────────
it("returns startX and startY from data", () => {
const result = buildBoard(makeData({ startX: 4, startY: 9 }), "path");
expect(result.startX).toBe(4);
expect(result.startY).toBe(9);
});
it("returns startX/startY as 0 when absent", () => {
const result = buildBoard(
makeData({ startX: undefined, startY: undefined }),
"path",
);
expect(result.startX).toBe(0);
expect(result.startY).toBe(0);
});
it("returns startInv as empty array when startInv is null", () => {
const result = buildBoard(makeData({ startInv: null }), "path");
expect(result.startInv).toEqual([]);
});
it("parses comma-separated startInv into trimmed array", () => {
const result = buildBoard(
makeData({ startInv: "Bow, Arrow, Bomb" }),
"path",
);
expect(result.startInv).toEqual(["Bow", "Arrow", "Bomb"]);
});
it("returns startInv array with single entry when no commas", () => {
const result = buildBoard(makeData({ startInv: "Sword" }), "path");
expect(result.startInv).toEqual(["Sword"]);
});
// ── terrain placement from diagram ─────────────────────────────────────────
it("places terrain at (0,0) when char maps to a Terrain piece", () => {
Registry.get.mockReturnValue(testTerrain);
const diagram = ["#" + " ".repeat(COLUMNS - 1)];
const { board } = buildBoard(
makeData({ diagram, terrain: { "#": "Wall" } }),
"path",
);
expect(Registry.get).toHaveBeenCalledWith("Wall");
expect(board.cells[0][0].terrain).toBe(testTerrain);
});
it("places terrain at arbitrary (x, y) based on diagram position", () => {
Registry.get.mockReturnValue(testTerrain);
// Put '#' at column 3 of row 2 (y=2, x=3)
const blankRow = " ".repeat(COLUMNS);
const diagram = [
blankRow,
blankRow,
" ".repeat(3) + "#" + " ".repeat(COLUMNS - 4),
];
const { board } = buildBoard(
makeData({ diagram, terrain: { "#": "Wall" } }),
"path",
);
expect(board.cells[3][2].terrain).toBe(testTerrain);
});
it("skips diagram char not present in terrainMap", () => {
const diagram = ["X" + " ".repeat(COLUMNS - 1)];
const { board } = buildBoard(makeData({ diagram, terrain: {} }), "path");
expect(Registry.get).not.toHaveBeenCalled();
expect(board.cells[0][0].terrain).toBeNull();
});
it("skips terrain key when Registry returns a non-Terrain piece", () => {
Registry.get.mockReturnValue(testAgent);
const diagram = ["#" + " ".repeat(COLUMNS - 1)];
const { board } = buildBoard(
makeData({ diagram, terrain: { "#": "SomeAgent" } }),
"path",
);
expect(board.cells[0][0].terrain).toBeNull();
});
it("handles missing diagram gracefully (no diagram key)", () => {
const data = makeData({ diagram: undefined });
expect(() => buildBoard(data, "path")).not.toThrow();
});
// ── piece placement from pieces array ──────────────────────────────────────
it("places an agent from the pieces array", () => {
Registry.get.mockReturnValue(testAgent);
const { board } = buildBoard(
makeData({ pieces: [{ key: "Goblin", x: 5, y: 3 }] }),
"path",
);
expect(board.cells[5][3].agent).toBe(testAgent);
});
it("places an item from the pieces array", () => {
Registry.get.mockReturnValue(testItem);
const { board } = buildBoard(
makeData({ pieces: [{ key: "Sword", x: 2, y: 8 }] }),
"path",
);
expect(board.cells[2][8].items).toContain(testItem);
});
it("places terrain from the pieces array", () => {
Registry.get.mockReturnValue(testTerrain);
const { board } = buildBoard(
makeData({ pieces: [{ key: "Lava", x: 10, y: 15 }] }),
"path",
);
expect(board.cells[10][15].terrain).toBe(testTerrain);
});
it("skips piece with null key", () => {
buildBoard(makeData({ pieces: [{ key: null, x: 0, y: 0 }] }), "path");
expect(Registry.get).not.toHaveBeenCalled();
});
it("skips piece when Registry returns null", () => {
Registry.get.mockReturnValue(null);
expect(() =>
buildBoard(
makeData({ pieces: [{ key: "Unknown", x: 0, y: 0 }] }),
"path",
),
).not.toThrow();
});
it("skips piece with out-of-bounds coordinates (cell is null)", () => {
Registry.get.mockReturnValue(testAgent);
expect(() =>
buildBoard(
makeData({ pieces: [{ key: "Goblin", x: -1, y: 0 }] }),
"path",
),
).not.toThrow();
});
it("does not overwrite an existing agent in a cell", () => {
const secondAgent = Object.create(Agent.prototype);
Registry.get
.mockReturnValueOnce(testAgent)
.mockReturnValueOnce(secondAgent);
const { board } = buildBoard(
makeData({
pieces: [
{ key: "First", x: 1, y: 1 },
{ key: "Second", x: 1, y: 1 },
],
}),
"path",
);
expect(board.cells[1][1].agent).toBe(testAgent);
});
it("handles empty pieces array without error", () => {
expect(() => buildBoard(makeData({ pieces: [] }), "path")).not.toThrow();
expect(Registry.get).not.toHaveBeenCalled();
});
// ── adjacent boards ────────────────────────────────────────────────────────
it("registers adjacent boards with the board directory as prefix", () => {
const data = makeData({ north: "level2", east: "level3" });
const { board } = buildBoard(data, "tutorial/start");
expect(board._adjacentBoards.get("north")).toBe("tutorial/level2");
expect(board._adjacentBoards.get("east")).toBe("tutorial/level3");
});
it("registers adjacent board paths without prefix for top-level boardPath", () => {
const data = makeData({ south: "next" });
const { board } = buildBoard(data, "start");
expect(board._adjacentBoards.get("south")).toBe("next");
});
it("supports all six direction keys", () => {
const data = makeData({
north: "n",
south: "s",
east: "e",
west: "w",
up: "u",
down: "d",
});
const { board } = buildBoard(data, "map");
expect(board._adjacentBoards.size).toBe(6);
});
it("does not register directions absent from data", () => {
const { board } = buildBoard(makeData({}), "path");
expect(board._adjacentBoards.size).toBe(0);
});
});
// ── loadBoard ─────────────────────────────────────────────────────────────────
describe("loadBoard", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
function stubFetch(overrides = {}) {
const defaults = {
ok: true,
status: 200,
headers: { get: () => "application/json" },
json: async () => makeData({}),
};
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({ ...defaults, ...overrides }),
);
}
it("fetches from the correct URL", async () => {
stubFetch();
await loadBoard("tutorial/start", "http://localhost:5173");
expect(globalThis.fetch).toHaveBeenCalledWith(
"http://localhost:5173/public/scenarios/tutorial/start.json",
);
});
it("throws on a non-ok response", async () => {
stubFetch({ ok: false, status: 404 });
await expect(
loadBoard("missing/board", "http://localhost"),
).rejects.toThrow("Failed to load board");
});
it("includes the status code in the error message for non-ok responses", async () => {
stubFetch({ ok: false, status: 500 });
await expect(loadBoard("board", "http://localhost")).rejects.toThrow("500");
});
it("throws when content-type is neither application/json nor text/json", async () => {
stubFetch({ headers: { get: () => "text/html" } });
await expect(loadBoard("board", "http://localhost")).rejects.toThrow(
"response was not JSON",
);
});
it("accepts application/json content-type", async () => {
stubFetch({ headers: { get: () => "application/json; charset=utf-8" } });
await expect(loadBoard("board", "http://localhost")).resolves.toBeDefined();
});
it("accepts text/json content-type", async () => {
stubFetch({ headers: { get: () => "text/json" } });
await expect(loadBoard("board", "http://localhost")).resolves.toBeDefined();
});
it("returns a result with board, startX, startY, and startInv", async () => {
stubFetch({ json: async () => makeData({ startX: 5, startY: 7 }) });
const result = await loadBoard("test/board", "http://localhost");
expect(result).toHaveProperty("board");
expect(result).toHaveProperty("startX", 5);
expect(result).toHaveProperty("startY", 7);
expect(result).toHaveProperty("startInv");
});
it("treats null content-type header as non-JSON and throws", async () => {
stubFetch({ headers: { get: () => null } });
await expect(loadBoard("board", "http://localhost")).rejects.toThrow(
"response was not JSON",
);
});
});