import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { mapSchema } from "@nengine/core"; import type { MapDef } from "@nengine/core"; import { parse, stringify } from "yaml"; import { describe, expect, it } from "vitest"; import { DEFAULT_ERASE_TILE, TILE_SIZE, UNKNOWN_TILE_COLOR, addTileId, canRedo, canUndo, clearHistory, collectTileIds, createMapModel, erase, isWalkableTile, markSaved, paint, pointToTile, redo, resizeMap, serialize, setSpawn, tileAssetId, tileColor, toggleWalkable, undo, } from "../src/renderer/map-model.js"; import type { MapModel } from "../src/renderer/map-model.js"; const fixturesDir = fileURLToPath(new URL("./fixtures", import.meta.url)); function grid(width: number, height: number, fill = "grass"): string[][] { return Array.from({ length: height }, () => Array.from({ length: width }, () => fill)); } function makeDef(overrides: Partial = {}): MapDef { const def: MapDef = { id: "t", width: 4, height: 4, tilesetRef: "ts", spawn: { x: 1, y: 1 }, layers: [{ name: "ground", data: grid(4, 4) }], ...overrides, }; return def; } function model(def: MapDef): MapModel { const m = createMapModel(def); if (m === null) throw new Error("expected a valid model"); return m; } describe("createMapModel", () => { it("builds a model from a valid def without mutating the input", () => { const def = makeDef(); const snapshot = JSON.stringify(def); const m = model(def); paint(m, 0, 0, "water"); setSpawn(m, 2, 2); toggleWalkable(m, "grass"); erase(m, 1, 1); expect(JSON.stringify(def)).toBe(snapshot); expect(m.dirty).toBe(true); }); it("rejects corrupt defs", () => { expect(createMapModel({ id: 42 })).toBeNull(); const badRows = makeDef({ layers: [{ name: "ground", data: grid(4, 2) }] }); expect(createMapModel(badRows)).toBeNull(); }); it("seeds the palette from ids on the map", () => { const def = makeDef({ layers: [{ name: "ground", data: grid(4, 4, "tilled") }] }); def.layers[0].data[0] = ["water", "grass", "tilled", "grass"]; const m = model(def); expect(m.palette).toEqual(["water", "grass", "tilled"]); }); }); describe("paint", () => { it("writes ground-layer data and marks dirty", () => { const m = model(makeDef()); expect(m.dirty).toBe(false); paint(m, 2, 3, "tilled"); expect(m.def.layers[0].data[3][2]).toBe("tilled"); expect(m.dirty).toBe(true); }); it("uses the layer named ground", () => { const def = makeDef(); def.layers = [ { name: "base", data: grid(4, 4, "rock") }, { name: "ground", data: grid(4, 4) }, ]; const m = model(def); paint(m, 1, 1, "water"); expect(m.def.layers[1].data[1][1]).toBe("water"); expect(m.def.layers[0].data[1][1]).toBe("rock"); }); it("falls back to the first layer when ground is absent", () => { const def = makeDef({ layers: [{ name: "base", data: grid(4, 4) }] }); const m = model(def); paint(m, 2, 2, "water"); expect(m.def.layers[0].data[2][2]).toBe("water"); }); it("ignores out-of-bounds cells", () => { const m = model(makeDef()); paint(m, -1, 0, "water"); paint(m, 0, -1, "water"); paint(m, 99, 0, "water"); paint(m, 0, 99, "water"); expect(m.def.layers[0].data[0][0]).toBe("grass"); expect(m.dirty).toBe(false); }); it("adds a new tile id to the palette", () => { const m = model(makeDef()); paint(m, 0, 0, "rock"); expect(m.palette).toContain("rock"); expect(collectTileIds(m)).toContain("rock"); }); }); describe("erase", () => { it("restores the default grass tile when the map uses it", () => { const m = model(makeDef()); paint(m, 0, 0, "water"); erase(m, 0, 0); expect(m.def.layers[0].data[0][0]).toBe(DEFAULT_ERASE_TILE); }); it("falls back to the first palette id when grass is absent", () => { const def = makeDef({ layers: [{ name: "ground", data: grid(4, 4, "water") }] }); const m = model(def); paint(m, 1, 1, "tilled"); erase(m, 1, 1); expect(m.def.layers[0].data[1][1]).toBe("water"); }); it("writes an empty string when the palette is empty", () => { const def = makeDef({ layers: [{ name: "ground", data: grid(4, 4, "") }] }); const m = model(def); erase(m, 2, 2); expect(m.def.layers[0].data[2][2]).toBe(""); }); }); describe("setSpawn", () => { it("moves the spawn point", () => { const m = model(makeDef()); setSpawn(m, 3, 2); expect(m.def.spawn).toEqual({ x: 3, y: 2 }); expect(m.dirty).toBe(true); }); it("clamps out-of-bounds coordinates", () => { const m = model(makeDef()); setSpawn(m, -5, 2); expect(m.def.spawn.x).toBe(0); setSpawn(m, 100, 100); expect(m.def.spawn).toEqual({ x: 3, y: 3 }); }); it("rejects non-integer coordinates", () => { const m = model(makeDef()); setSpawn(m, Number.NaN, 1); setSpawn(m, 1.5, 1); expect(m.def.spawn).toEqual({ x: 1, y: 1 }); expect(m.dirty).toBe(false); }); }); describe("toggleWalkable", () => { it("sets absent ids to false", () => { const m = model(makeDef()); expect(m.def.walkable).toBeUndefined(); toggleWalkable(m, "grass"); expect(m.def.walkable?.grass).toBe(false); expect(m.dirty).toBe(true); }); it("deletes the key when already false (restores fallback)", () => { const m = model(makeDef()); toggleWalkable(m, "grass"); toggleWalkable(m, "grass"); expect(m.def.walkable).toBeUndefined(); }); it("sets true entries to false", () => { const def = makeDef(); def.walkable = { grass: true }; const m = model(def); toggleWalkable(m, "grass"); expect(m.def.walkable?.grass).toBe(false); }); it("keeps unrelated entries when deleting one", () => { const def = makeDef(); def.walkable = { water: false, tilled: false }; const m = model(def); toggleWalkable(m, "water"); expect(m.def.walkable).toEqual({ tilled: false }); }); it("ignores the empty tile id", () => { const m = model(makeDef()); toggleWalkable(m, ""); expect(m.def.walkable).toBeUndefined(); expect(m.dirty).toBe(false); }); }); describe("addTileId", () => { it("registers a palette id without touching the def", () => { const m = model(makeDef()); const defBefore = JSON.stringify(m.def); addTileId(m, "sand"); expect(m.palette).toContain("sand"); expect(JSON.stringify(m.def)).toBe(defBefore); expect(m.dirty).toBe(false); addTileId(m, "sand"); expect(m.palette.filter((id) => id === "sand")).toHaveLength(1); addTileId(m, ""); expect(m.palette).not.toContain(""); }); }); describe("serialize", () => { it("roundtrips model → def → model identity for the same def", () => { const def = makeDef({ walkable: { water: false }, tiles: { grass: "grass-1" } }); const m = model(def); const out = serialize(m); expect(out).not.toBeNull(); expect(out).toEqual(def); expect(out).not.toBe(m.def); expect(mapSchema.safeParse(out as MapDef).success).toBe(true); }); it("returns a fresh object reflecting edits", () => { const m = model(makeDef()); paint(m, 0, 0, "tilled"); setSpawn(m, 0, 0); const out = serialize(m); expect(out?.layers[0].data[0][0]).toBe("tilled"); expect(out?.spawn).toEqual({ x: 0, y: 0 }); }); it("rejects an invalid working copy", () => { const m = model(makeDef()); m.def.layers[0].data[0].length = 99; expect(serialize(m)).toBeNull(); }); }); describe("collectTileIds", () => { it("collects unique ids from all layers in stable order", () => { const def: MapDef = { id: "t", width: 2, height: 2, tilesetRef: "ts", spawn: { x: 0, y: 0 }, layers: [ { name: "ground", data: [["water", "grass"], ["grass", "tilled"]] }, { name: "deco", data: [["rock", "grass"], ["", "rock"]] }, ], }; const m = model(def); expect(collectTileIds(m)).toEqual(["water", "grass", "tilled", "rock"]); }); }); describe("tileColor", () => { it("keeps known tiles stable and special-cased", () => { expect(tileColor("grass")).toBe("#7cb342"); expect(tileColor("water")).toBe("#4fc3f7"); expect(tileColor("tilled")).toBe("#8d6e63"); expect(tileColor("")).toBe(UNKNOWN_TILE_COLOR); }); it("is stable for unknown ids and format is a hue", () => { expect(tileColor("rock")).toBe(tileColor("rock")); expect(tileColor("rock")).toMatch(/^hsl\(\d+ 55% 55%\)$/); expect(tileColor("rock")).not.toBe(tileColor("sand")); }); }); describe("isWalkableTile", () => { it("honours declared walkable entries", () => { const def = makeDef(); def.walkable = { water: false, tilled: true }; const m = model(def); expect(isWalkableTile(m, "water")).toBe(false); expect(isWalkableTile(m, "tilled")).toBe(true); }); it("falls back to the engine's UNWALKABLE_TILES", () => { const m = model(makeDef()); expect(isWalkableTile(m, "water")).toBe(false); expect(isWalkableTile(m, "grass")).toBe(true); expect(isWalkableTile(m, "rock")).toBe(true); }); }); describe("pointToTile", () => { it("maps pixel offsets to grid tiles", () => { expect(pointToTile(0, 0)).toEqual({ x: 0, y: 0 }); expect(pointToTile(31, 31)).toEqual({ x: 0, y: 0 }); expect(pointToTile(32, 64)).toEqual({ x: 1, y: 2 }); expect(pointToTile(-5, 3)).toEqual({ x: -1, y: 0 }); expect(TILE_SIZE).toBe(32); }); }); describe("dirty lifecycle", () => { it("tracks edits and clears on markSaved", () => { const m = model(makeDef()); expect(m.dirty).toBe(false); paint(m, 0, 0, "water"); expect(m.dirty).toBe(true); markSaved(m); expect(m.dirty).toBe(false); erase(m, 0, 0); expect(m.dirty).toBe(true); markSaved(m); setSpawn(m, 2, 2); expect(m.dirty).toBe(true); markSaved(m); toggleWalkable(m, "grass"); expect(m.dirty).toBe(true); markSaved(m); addTileId(m, "sand"); expect(m.dirty).toBe(false); }); }); describe("real map fixture end-to-end", () => { it("edits a real farm.yaml through parse → model → serialize → stringify → parse", async () => { const text = await readFile(join(fixturesDir, "farm.yaml"), "utf8"); const m = createMapModel(parse(text)); expect(m).not.toBeNull(); if (m === null) throw new Error("fixture must be a valid map"); expect(mapSchema.safeParse(m.def).success).toBe(true); expect(m.def.width).toBe(16); expect(m.def.spawn).toEqual({ x: 2, y: 4 }); paint(m, 0, 0, "grass"); setSpawn(m, 4, 4); toggleWalkable(m, "tilled"); const out = serialize(m); expect(out).not.toBeNull(); if (out === null) throw new Error("edited fixture must serialize"); const yaml = stringify(out); const reparsed = parse(yaml); expect(reparsed).toEqual(out); expect(mapSchema.safeParse(reparsed).success).toBe(true); const m2 = createMapModel(reparsed); expect(m2).not.toBeNull(); expect(m2?.def.layers[0].data[0][0]).toBe("grass"); expect(m2?.def.spawn).toEqual({ x: 4, y: 4 }); expect(m2?.def.walkable).toEqual({ water: false, tilled: false }); expect(m2?.def.tiles?.water).toBe("water-1"); expect(await readFile(join(fixturesDir, "farm.yaml"), "utf8")).toBe(text); }); }); describe("undo/redo", () => { it("paint then undo restores the cell, clears dirty, and redo re-applies", () => { const m = model(makeDef()); paint(m, 0, 0, "water"); expect(m.dirty).toBe(true); expect(canUndo(m)).toBe(true); expect(undo(m)).toBe(true); expect(m.def.layers[0].data[0][0]).toBe("grass"); expect(m.dirty).toBe(false); expect(canUndo(m)).toBe(false); expect(redo(m)).toBe(true); expect(m.def.layers[0].data[0][0]).toBe("water"); expect(m.dirty).toBe(true); }); it("erase records its actual write (before/after), not the erase intent", () => { const m = model(makeDef()); paint(m, 2, 2, "water"); markSaved(m); erase(m, 2, 2); expect(m.def.layers[0].data[2][2]).toBe("grass"); expect(undo(m)).toBe(true); expect(m.def.layers[0].data[2][2]).toBe("water"); }); it("setSpawn undo restores the previous (clamped) coordinates", () => { const m = model(makeDef()); setSpawn(m, 3, 3); expect(m.def.spawn).toEqual({ x: 3, y: 3 }); expect(undo(m)).toBe(true); expect(m.def.spawn).toEqual({ x: 1, y: 1 }); }); it("toggleWalkable undo restores the record entry (and deletion restores the false entry)", () => { const m = model(makeDef()); toggleWalkable(m, "grass"); // absent → false expect(m.def.walkable).toEqual({ grass: false }); expect(undo(m)).toBe(true); expect(m.def.walkable).toBeUndefined(); toggleWalkable(m, "grass"); toggleWalkable(m, "grass"); // false → deleted expect(m.def.walkable).toBeUndefined(); expect(undo(m)).toBe(true); expect(m.def.walkable).toEqual({ grass: false }); }); it("walkable undo restores an earlier unrelated entry of a multi-entry record", () => { const m = model(makeDef({ walkable: { water: false } })); toggleWalkable(m, "grass"); undo(m); expect(m.def.walkable).toEqual({ water: false }); }); it("no-ops never enter the stack: same-value paint, same spawn, out-of-bounds paint", () => { const m = model(makeDef()); paint(m, 0, 0, "grass"); // same value paint(m, 99, 99, "water"); // out of bounds setSpawn(m, 1, 1); // unchanged (clamped to same) expect(m.history.length).toBe(0); expect(m.dirty).toBe(false); }); it("ops unwind LIFO and a new op clears the redo stack", () => { const m = model(makeDef()); paint(m, 0, 0, "water"); paint(m, 1, 1, "water"); undo(m); undo(m); expect(redo(m)).toBe(true); paint(m, 3, 3, "water"); // new op expect(canRedo(m)).toBe(false); expect(redo(m)).toBe(false); }); it("markSaved is a checkpoint: it clears both stacks and dirty", () => { const m = model(makeDef()); paint(m, 0, 0, "water"); undo(m); markSaved(m); expect(m.history.length).toBe(0); expect(m.future.length).toBe(0); expect(m.dirty).toBe(false); expect(canUndo(m)).toBe(false); expect(canRedo(m)).toBe(false); }); it("undoing everything returns to the saved state (Save button clears)", () => { const m = model(makeDef()); paint(m, 0, 0, "water"); paint(m, 1, 1, "water"); setSpawn(m, 2, 2); expect(m.dirty).toBe(true); while (undo(m)) { // drain } expect(m.dirty).toBe(false); expect(m.def.spawn).toEqual({ x: 1, y: 1 }); expect(m.def.layers[0].data[0][0]).toBe("grass"); expect(m.def.layers[0].data[1][1]).toBe("grass"); }); it("palette is additive and does not roll back on undo", () => { const m = model(makeDef()); paint(m, 0, 0, "rock"); expect(m.palette).toContain("rock"); undo(m); expect(m.palette).toContain("rock"); }); it("undo/redo return false on empty stacks", () => { const m = model(makeDef()); expect(undo(m)).toBe(false); expect(redo(m)).toBe(false); }); }); describe("resizeMap", () => { it("grows width/height padding rows and columns with the erase tile", () => { const m = model(makeDef()); expect(resizeMap(m, 6, 5)).toBe(true); expect(m.def.width).toBe(6); expect(m.def.height).toBe(5); expect(m.def.layers[0].data.length).toBe(5); for (const row of m.def.layers[0].data) expect(row.length).toBe(6); expect(m.def.layers[0].data[4].every((tile) => tile === DEFAULT_ERASE_TILE)).toBe(true); expect(serialize(m)).not.toBeNull(); }); it("shrinks by truncating and keeps non-square shapes", () => { const m = model(makeDef({ width: 8, height: 10, layers: [{ name: "ground", data: grid(8, 10) }] })); expect(resizeMap(m, 20, 3)).toBe(true); expect(m.def.width).toBe(20); expect(m.def.height).toBe(3); expect(m.def.layers[0].data.length).toBe(3); for (const row of m.def.layers[0].data) expect(row.length).toBe(20); expect(serialize(m)).not.toBeNull(); }); it("resizes every layer, not just the ground layer", () => { const m = model( makeDef({ layers: [{ name: "ground", data: grid(4, 4) }, { name: "overlay", data: grid(4, 4, "") }] }), ); expect(resizeMap(m, 6, 6)).toBe(true); for (const layer of m.def.layers) { expect(layer.data.length).toBe(6); for (const row of layer.data) expect(row.length).toBe(6); } }); it("clamps an out-of-range spawn into the new bounds", () => { const m = model(makeDef({ width: 12, height: 12, spawn: { x: 10, y: 10 }, layers: [{ name: "ground", data: grid(12, 12) }] })); expect(resizeMap(m, 4, 4)).toBe(true); expect(m.def.spawn).toEqual({ x: 3, y: 3 }); }); it("rejects non-positive/non-integer dimensions and unchanged sizes, leaving the def untouched", () => { const m = model(makeDef()); const before = JSON.stringify(m.def); expect(resizeMap(m, 0, 4)).toBe(false); expect(resizeMap(m, 4, -1)).toBe(false); expect(resizeMap(m, 4.5, 4)).toBe(false); expect(resizeMap(m, Number.NaN, 4)).toBe(false); expect(resizeMap(m, 4, 4)).toBe(false); expect(JSON.stringify(m.def)).toBe(before); expect(m.history.length).toBe(0); }); it("resize is undoable/redoable via the full-grid snapshot", () => { const m = model(makeDef({ width: 8, height: 8, spawn: { x: 6, y: 6 }, layers: [{ name: "ground", data: grid(8, 8) }] })); expect(resizeMap(m, 4, 4)).toBe(true); expect(undo(m)).toBe(true); expect(m.def.width).toBe(8); expect(m.def.height).toBe(8); expect(m.def.spawn).toEqual({ x: 6, y: 6 }); expect(m.def.layers[0].data.length).toBe(8); expect(m.def.layers[0].data[0].length).toBe(8); expect(redo(m)).toBe(true); expect(m.def.width).toBe(4); expect(serialize(m)).not.toBeNull(); }); }); describe("tileAssetId", () => { it("returns the asset id for a mapped tile and undefined otherwise", () => { const m = model(makeDef({ tiles: { grass: "grass-1", water: "water-1" } })); expect(tileAssetId(m, "grass")).toBe("grass-1"); expect(tileAssetId(m, "sand")).toBeUndefined(); }); it("returns undefined without a tiles record", () => { expect(tileAssetId(model(makeDef()), "grass")).toBeUndefined(); }); }); describe("clearHistory (bug-fix round)", () => { it("drops both stacks but keeps the dirty flag — an edit is an unsaved edit", () => { const m = model(makeDef()); paint(m, 0, 0, "water"); clearHistory(m); expect(m.history.length).toBe(0); expect(m.future.length).toBe(0); expect(m.dirty).toBe(true); // the painted tile is still unsaved expect(canUndo(m)).toBe(false); expect(serialize(m)?.layers[0].data[0][0]).toBe("water"); // markSaved is the only way back to clean. markSaved(m); expect(m.dirty).toBe(false); }); });