scenarios/loader.test.js

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",
    );
  });
});