| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638 |
- import { readFile } from "node:fs/promises";
- import { join } from "node:path";
- import { fileURLToPath } from "node:url";
- import { buildingSchema, sceneSchema } from "@nengine/core-schema";
- import type { SceneDef, SceneEntity } from "@nengine/core-schema";
- import { parse, stringify } from "yaml";
- import { describe, expect, it } from "vitest";
- import {
- ANIMAL_COLOR,
- DEFAULT_SCENE_ID,
- NPC_COLOR,
- addAnimal,
- addBuilding,
- addNpc,
- canRedo,
- canUndo,
- clearHistory,
- createSceneModel,
- entityAt,
- entityColor,
- entityFootprint,
- entityIndexAt,
- markSaved,
- moveEntity,
- redo,
- removeEntity,
- sceneFitsAfterResize,
- sceneFitsIn,
- serialize,
- syncMapSize,
- undo,
- } from "../src/renderer/scene-model.js";
- import type { SceneFootprint, SceneModel } from "../src/renderer/scene-model.js";
- const fixturesDir = fileURLToPath(new URL("./fixtures", import.meta.url));
- const FOOTPRINTS: Record<string, SceneFootprint> = {
- house: { w: 2, h: 2 },
- shed: { w: 2, h: 2 },
- well: { w: 1, h: 1 },
- };
- function model(mapWidth = 10, mapHeight = 10, footprints: Record<string, SceneFootprint> = FOOTPRINTS): SceneModel {
- const m = createSceneModel(
- { id: "farm", entities: [] },
- { mapWidth, mapHeight, footprints },
- );
- if (m === null) throw new Error("expected a valid model");
- return m;
- }
- function entities(m: SceneModel): SceneEntity[] {
- return m.def.entities;
- }
- describe("createSceneModel", () => {
- it("builds a model from a valid def without mutating the input", () => {
- const def: SceneDef = {
- id: "farm",
- entities: [{ type: "building", buildingId: "well", x: 1, y: 1 }],
- };
- const snapshot = JSON.stringify(def);
- const m = createSceneModel(def, { mapWidth: 10, mapHeight: 10, footprints: FOOTPRINTS });
- expect(m).not.toBeNull();
- if (m === null) throw new Error("expected a valid model");
- expect(m.dirty).toBe(false);
- addNpc(m, "mayor", 3, 3);
- expect(JSON.stringify(def)).toBe(snapshot);
- expect(m.dirty).toBe(true);
- });
- it("rejects corrupt defs", () => {
- expect(createSceneModel({ id: 42 }, { mapWidth: 10, mapHeight: 10 })).toBeNull();
- expect(createSceneModel({ id: "farm", entities: [{ type: "npc", x: 1 }] }, { mapWidth: 10, mapHeight: 10 })).toBeNull();
- expect(
- createSceneModel({ id: "farm", entities: [{ type: "building", buildingId: "well", x: -1, y: 0 }] }, { mapWidth: 10, mapHeight: 10 }),
- ).toBeNull();
- expect(
- createSceneModel({ id: "farm", entities: [{ type: "silo", x: 1, y: 1 }] }, { mapWidth: 10, mapHeight: 10 }),
- ).toBeNull();
- });
- it("accepts an empty entities array", () => {
- const m = createSceneModel({ id: "farm", entities: [] }, { mapWidth: 10, mapHeight: 10 });
- expect(m?.def.entities).toEqual([]);
- });
- it("creates a new empty scene from undefined input with the default id", () => {
- const m = createSceneModel(undefined, { mapWidth: 10, mapHeight: 10 });
- expect(m).not.toBeNull();
- expect(m?.def).toEqual({ id: DEFAULT_SCENE_ID, entities: [] });
- expect(m?.dirty).toBe(false);
- });
- it("honours a custom default id for new scenes", () => {
- const m = createSceneModel(undefined, { mapWidth: 10, mapHeight: 10, defaultId: "farm" });
- expect(m?.def.id).toBe("farm");
- });
- it("deep-clones the footprints table", () => {
- const footprints: Record<string, SceneFootprint> = { house: { w: 2, h: 2 } };
- const m = model(10, 10, footprints);
- m.footprints.house = { w: 5, h: 5 };
- expect(footprints.house).toEqual({ w: 2, h: 2 });
- });
- });
- describe("addBuilding", () => {
- it("places a building and marks dirty", () => {
- const m = model();
- expect(m.dirty).toBe(false);
- const result = addBuilding(m, "well", 3, 4);
- expect(result).toEqual({ ok: true });
- expect(entities(m)).toEqual([{ type: "building", buildingId: "well", x: 3, y: 4 }]);
- expect(m.dirty).toBe(true);
- });
- it("clamps out-of-bounds coordinates footprint-aware", () => {
- const m = model(10, 10);
- addBuilding(m, "house", 9, 9);
- expect(entities(m)[0]).toEqual({ type: "building", buildingId: "house", x: 8, y: 8 });
- addBuilding(m, "well", -3, -3);
- expect(entities(m)[1]).toEqual({ type: "building", buildingId: "well", x: 0, y: 0 });
- });
- it("rejects non-integer coordinates", () => {
- const m = model();
- expect(addBuilding(m, "well", 1.5, 1)).toEqual({ ok: false, reason: expect.stringContaining("integer") });
- expect(addBuilding(m, "well", Number.NaN, 1)).toEqual({ ok: false, reason: expect.stringContaining("integer") });
- expect(entities(m)).toHaveLength(0);
- expect(m.dirty).toBe(false);
- });
- it("rejects an unknown building id", () => {
- const m = model();
- const result = addBuilding(m, "silo", 1, 1);
- expect(result).toEqual({ ok: false, reason: expect.stringContaining('unknown building "silo"') });
- expect(entities(m)).toHaveLength(0);
- });
- it("rejects an empty building id", () => {
- const m = model();
- expect(addBuilding(m, "", 1, 1)).toEqual({ ok: false, reason: expect.stringContaining("must not be empty") });
- });
- it("rejects overlap with another building's footprint", () => {
- const m = model();
- addBuilding(m, "house", 0, 0);
- const result = addBuilding(m, "well", 1, 1);
- expect(result.ok).toBe(false);
- if (!result.ok) expect(result.reason).toContain("overlaps");
- expect(entities(m)).toHaveLength(1);
- expect(m.dirty).toBe(true);
- });
- it("rejects a duplicate building id at the same coordinates", () => {
- const m = model();
- addBuilding(m, "well", 2, 2);
- const result = addBuilding(m, "well", 2, 2);
- expect(result).toEqual({ ok: false, reason: expect.stringContaining('duplicate building "well" at (2,2)') });
- expect(entities(m)).toHaveLength(1);
- });
- it("rejects overlap with a placed npc or animal", () => {
- const m = model();
- addNpc(m, "mayor", 2, 2);
- expect(addBuilding(m, "house", 1, 1).ok).toBe(false);
- addAnimal(m, "chicken", 5, 5);
- expect(addBuilding(m, "house", 4, 4).ok).toBe(false);
- expect(entities(m).filter((e) => e.type === "building")).toHaveLength(0);
- });
- it("allows adjacent placement that only touches edges", () => {
- const m = model();
- addBuilding(m, "house", 0, 0);
- const result = addBuilding(m, "well", 2, 0);
- expect(result).toEqual({ ok: true });
- expect(entities(m)).toHaveLength(2);
- });
- it("rejects a footprint larger than the map", () => {
- const m = model(1, 1);
- const result = addBuilding(m, "house", 0, 0);
- expect(result.ok).toBe(false);
- if (!result.ok) expect(result.reason).toContain("does not fit");
- expect(entities(m)).toHaveLength(0);
- });
- });
- describe("addNpc", () => {
- it("places an npc with optional dialogue and label", () => {
- const m = model();
- const result = addNpc(m, "mayor", 4, 4, { dialogue: "intro", label: "Mayor" });
- expect(result).toEqual({ ok: true });
- expect(entities(m)).toEqual([
- { type: "npc", npcId: "mayor", x: 4, y: 4, dialogue: "intro", label: "Mayor" },
- ]);
- expect(m.dirty).toBe(true);
- });
- it("omits empty dialogue and label", () => {
- const m = model();
- addNpc(m, "mayor", 4, 4, { dialogue: "", label: "" });
- expect(entities(m)).toEqual([{ type: "npc", npcId: "mayor", x: 4, y: 4 }]);
- });
- it("rejects a duplicate npc id", () => {
- const m = model();
- addNpc(m, "mayor", 1, 1);
- const result = addNpc(m, "mayor", 5, 5);
- expect(result).toEqual({ ok: false, reason: expect.stringContaining('duplicate npc id "mayor"') });
- expect(entities(m)).toHaveLength(1);
- });
- it("clamps out-of-bounds coordinates", () => {
- const m = model(10, 10);
- addNpc(m, "mayor", -5, 99);
- expect(entities(m)[0]).toEqual({ type: "npc", npcId: "mayor", x: 0, y: 9 });
- });
- it("rejects placement inside a building footprint", () => {
- const m = model();
- addBuilding(m, "house", 0, 0);
- const result = addNpc(m, "mayor", 1, 1);
- expect(result.ok).toBe(false);
- if (!result.ok) expect(result.reason).toContain("overlaps");
- expect(entities(m)).toHaveLength(1);
- });
- });
- describe("addAnimal", () => {
- it("places an animal and marks dirty", () => {
- const m = model();
- const result = addAnimal(m, "chicken", 3, 3);
- expect(result).toEqual({ ok: true });
- expect(entities(m)).toEqual([{ type: "animal", animalId: "chicken", x: 3, y: 3 }]);
- expect(m.dirty).toBe(true);
- });
- it("clamps out-of-bounds coordinates", () => {
- const m = model(10, 10);
- addAnimal(m, "chicken", 10, -2);
- expect(entities(m)[0]).toEqual({ type: "animal", animalId: "chicken", x: 9, y: 0 });
- });
- it("allows multiple animals with the same animal id", () => {
- const m = model();
- addAnimal(m, "chicken", 1, 1);
- addAnimal(m, "chicken", 3, 3);
- expect(entities(m)).toHaveLength(2);
- });
- it("rejects overlap with a building", () => {
- const m = model();
- addBuilding(m, "house", 0, 0);
- expect(addAnimal(m, "chicken", 0, 0).ok).toBe(false);
- expect(entities(m)).toHaveLength(1);
- });
- });
- describe("removeEntity", () => {
- it("removes the entity at the index and marks dirty", () => {
- const m = model();
- addBuilding(m, "well", 1, 1);
- addNpc(m, "mayor", 3, 3);
- const result = removeEntity(m, 0);
- expect(result).toEqual({ ok: true });
- expect(entities(m)).toHaveLength(1);
- expect(entities(m)[0]?.type).toBe("npc");
- expect(m.dirty).toBe(true);
- });
- it("rejects an out-of-range index", () => {
- const m = model();
- expect(removeEntity(m, 0)).toEqual({ ok: false, reason: expect.stringContaining("no entity at index") });
- addAnimal(m, "chicken", 1, 1);
- expect(removeEntity(m, 5)).toEqual({ ok: false, reason: expect.stringContaining("no entity at index") });
- expect(entities(m)).toHaveLength(1);
- });
- });
- describe("moveEntity", () => {
- it("moves an entity and marks dirty", () => {
- const m = model();
- addNpc(m, "mayor", 1, 1);
- const result = moveEntity(m, 0, 4, 5);
- expect(result).toEqual({ ok: true });
- expect(entities(m)[0]).toEqual({ type: "npc", npcId: "mayor", x: 4, y: 5 });
- expect(m.dirty).toBe(true);
- });
- it("re-clamps out-of-bounds targets footprint-aware", () => {
- const m = model(10, 10);
- addBuilding(m, "house", 1, 1);
- moveEntity(m, 0, -5, 99);
- expect(entities(m)[0]).toEqual({ type: "building", buildingId: "house", x: 0, y: 8 });
- });
- it("rejects a move into another entity's footprint without mutating", () => {
- const m = model();
- addBuilding(m, "house", 0, 0);
- addNpc(m, "mayor", 6, 6);
- const before = JSON.stringify(m.def);
- const result = moveEntity(m, 0, 6, 6);
- expect(result.ok).toBe(false);
- if (!result.ok) expect(result.reason).toContain("overlaps");
- expect(JSON.stringify(m.def)).toBe(before);
- expect(m.dirty).toBe(true);
- });
- it("rejects a move into its own footprint (same entity excluded from overlap)", () => {
- const m = model();
- addBuilding(m, "house", 0, 0);
- addNpc(m, "mayor", 6, 6);
- const result = moveEntity(m, 0, 1, 1);
- expect(result).toEqual({ ok: true });
- expect(entities(m)[0]).toEqual({ type: "building", buildingId: "house", x: 1, y: 1 });
- });
- it("rejects an invalid index and non-integer coordinates", () => {
- const m = model();
- expect(moveEntity(m, 0, 1, 1)).toEqual({ ok: false, reason: expect.stringContaining("no entity at index") });
- addAnimal(m, "chicken", 1, 1);
- expect(moveEntity(m, 0, 2.5, 1)).toEqual({ ok: false, reason: expect.stringContaining("integer") });
- expect(entities(m)[0]).toEqual({ type: "animal", animalId: "chicken", x: 1, y: 1 });
- });
- });
- describe("serialize", () => {
- it("roundtrips a valid scene as a fresh object", () => {
- const m = model();
- addBuilding(m, "house", 0, 0);
- addNpc(m, "mayor", 3, 3, { dialogue: "intro" });
- const out = serialize(m);
- expect(out).not.toBeNull();
- expect(out).toEqual({
- id: "farm",
- entities: [
- { type: "building", buildingId: "house", x: 0, y: 0 },
- { type: "npc", npcId: "mayor", x: 3, y: 3, dialogue: "intro" },
- ],
- });
- expect(out).not.toBe(m.def);
- expect(out?.entities[0]).not.toBe(m.def.entities[0]);
- expect(sceneSchema.safeParse(out as SceneDef).success).toBe(true);
- });
- it("rejects an invalid working copy", () => {
- const m = model();
- addAnimal(m, "chicken", 1, 1);
- const invalid = entities(m)[0] as { animalId: string };
- delete invalid.animalId;
- expect(serialize(m)).toBeNull();
- });
- it("still serializes after a rejected op (invalid ops never corrupt)", () => {
- const m = model();
- addBuilding(m, "house", 0, 0);
- addBuilding(m, "well", 1, 1);
- addNpc(m, "mayor", 1, 1);
- const out = serialize(m);
- expect(out).not.toBeNull();
- expect(out?.entities).toHaveLength(1);
- });
- it("serializes an empty scene", () => {
- const m = model();
- const out = serialize(m);
- expect(out).toEqual({ id: "farm", entities: [] });
- });
- });
- describe("entityAt / entityIndexAt", () => {
- it("finds entities by any footprint tile", () => {
- const m = model();
- addBuilding(m, "house", 0, 0);
- addNpc(m, "mayor", 5, 5);
- expect(entityIndexAt(m, 0, 0)).toBe(0);
- expect(entityIndexAt(m, 1, 1)).toBe(0);
- expect(entityIndexAt(m, 0, 1)).toBe(0);
- expect(entityIndexAt(m, 5, 5)).toBe(1);
- expect(entityIndexAt(m, 3, 3)).toBe(-1);
- expect(entityAt(m, 1, 1)?.type).toBe("building");
- expect(entityAt(m, 3, 3)).toBeUndefined();
- });
- it("returns the topmost entity for overlapping loaded defs", () => {
- const m = model();
- addBuilding(m, "house", 0, 0);
- const raw = m.def as SceneDef;
- raw.entities.push({ type: "npc", npcId: "mayor", x: 1, y: 1 });
- expect(entityIndexAt(m, 1, 1)).toBe(1);
- expect(entityAt(m, 1, 1)?.type).toBe("npc");
- });
- });
- describe("entityFootprint / entityColor", () => {
- it("uses the footprint table for buildings and 1x1 for others", () => {
- const m = model();
- addBuilding(m, "house", 0, 0);
- addNpc(m, "mayor", 5, 5);
- addAnimal(m, "chicken", 8, 8);
- expect(entityFootprint(m, entities(m)[0] as SceneEntity)).toEqual({ w: 2, h: 2 });
- expect(entityFootprint(m, entities(m)[1] as SceneEntity)).toEqual({ w: 1, h: 1 });
- expect(entityFootprint(m, entities(m)[2] as SceneEntity)).toEqual({ w: 1, h: 1 });
- });
- it("falls back to 1x1 for a building with no footprint entry", () => {
- const m = model(10, 10, {});
- const raw = m.def as SceneDef;
- raw.entities.push({ type: "building", buildingId: "silo", x: 0, y: 0 });
- expect(entityFootprint(m, entities(m)[0] as SceneEntity)).toEqual({ w: 1, h: 1 });
- });
- it("colors npc amber, animal red, buildings a stable hash hue", () => {
- expect(entityColor({ type: "npc", npcId: "mayor", x: 0, y: 0 })).toBe(NPC_COLOR);
- expect(entityColor({ type: "animal", animalId: "chicken", x: 0, y: 0 })).toBe(ANIMAL_COLOR);
- const shed = entityColor({ type: "building", buildingId: "shed", x: 0, y: 0 });
- const shedAgain = entityColor({ type: "building", buildingId: "shed", x: 0, y: 0 });
- expect(shed).toBe(shedAgain);
- expect(shed).toMatch(/^hsl\(\d+ 45% 45%\)$/);
- });
- });
- describe("dirty lifecycle", () => {
- it("tracks edits and clears on markSaved", () => {
- const m = model();
- expect(m.dirty).toBe(false);
- addBuilding(m, "well", 1, 1);
- expect(m.dirty).toBe(true);
- markSaved(m);
- expect(m.dirty).toBe(false);
- moveEntity(m, 0, 2, 2);
- expect(m.dirty).toBe(true);
- markSaved(m);
- removeEntity(m, 0);
- expect(m.dirty).toBe(true);
- });
- });
- describe("real scene fixture end-to-end", () => {
- it("edits a real scene through read → parse → model → place → serialize → stringify → parse", async () => {
- const sceneText = await readFile(join(fixturesDir, "scene-farm.yaml"), "utf8");
- const shedText = await readFile(join(fixturesDir, "buildings", "shed.yaml"), "utf8");
- const wellText = await readFile(join(fixturesDir, "buildings", "well.yaml"), "utf8");
- const footprints: Record<string, SceneFootprint> = {};
- for (const text of [shedText, wellText]) {
- const def = buildingSchema.safeParse(parse(text));
- expect(def.success).toBe(true);
- if (def.success) footprints[def.data.id] = def.data.footprint;
- }
- const m = createSceneModel(parse(sceneText), { mapWidth: 16, mapHeight: 16, footprints });
- expect(m).not.toBeNull();
- if (m === null) throw new Error("fixture must be a valid scene");
- expect(sceneSchema.safeParse(m.def).success).toBe(true);
- expect(m.def.id).toBe("farm-yard");
- expect(entities(m)).toHaveLength(3);
- const placed = addBuilding(m, "well", 8, 8);
- expect(placed).toEqual({ ok: true });
- const npc = addNpc(m, "farmer", 10, 10, { label: "Farmer" });
- expect(npc).toEqual({ ok: true });
- expect(addBuilding(m, "shed", 0, 0).ok).toBe(false);
- 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(sceneSchema.safeParse(reparsed).success).toBe(true);
- const m2 = createSceneModel(reparsed, { mapWidth: 16, mapHeight: 16, footprints });
- expect(m2).not.toBeNull();
- expect(m2?.def.entities).toHaveLength(5);
- expect(m2?.def.entities[3]).toEqual({ type: "building", buildingId: "well", x: 8, y: 8 });
- expect(m2?.def.entities[4]).toEqual({ type: "npc", npcId: "farmer", x: 10, y: 10, label: "Farmer" });
- expect(await readFile(join(fixturesDir, "scene-farm.yaml"), "utf8")).toBe(sceneText);
- });
- });
- describe("undo/redo", () => {
- it("addBuilding/addNpc/addAnimal undo removes the entity and redo restores it", () => {
- const m = model();
- addBuilding(m, "house", 4, 4);
- addNpc(m, "mayor", 0, 0, { dialogue: "intro" });
- addAnimal(m, "chicken", 7, 7);
- expect(entities(m).length).toBe(3);
- expect(undo(m)).toBe(true);
- expect(entities(m).length).toBe(2);
- expect(undo(m)).toBe(true);
- expect(entities(m).length).toBe(1);
- expect(undo(m)).toBe(true);
- expect(entities(m).length).toBe(0);
- expect(m.dirty).toBe(false);
- expect(redo(m)).toBe(true);
- expect(entities(m)[0]).toEqual({ type: "building", buildingId: "house", x: 4, y: 4 });
- expect(redo(m)).toBe(true);
- expect(entities(m)[1]).toEqual({ type: "npc", npcId: "mayor", x: 0, y: 0, dialogue: "intro" });
- expect(m.dirty).toBe(true);
- });
- it("removeEntity undo re-inserts the snapshot at the original index", () => {
- const m = model();
- addBuilding(m, "house", 4, 4);
- addNpc(m, "mayor", 0, 0);
- expect(removeEntity(m, 0)).toEqual({ ok: true });
- expect(entities(m).map((e) => e.type)).toEqual(["npc"]);
- expect(undo(m)).toBe(true);
- expect(entities(m).map((e) => e.type)).toEqual(["building", "npc"]);
- expect(entities(m)[0]).toEqual({ type: "building", buildingId: "house", x: 4, y: 4 });
- });
- it("history entries snapshot entities: a later move never corrupts redo", () => {
- const m = model();
- addBuilding(m, "house", 4, 4); // add entry snapshots house at (4,4)
- expect(moveEntity(m, 0, 7, 7)).toEqual({ ok: true }); // live house → (7,7)
- expect(undo(m)).toBe(true); // undo the move
- expect(undo(m)).toBe(true); // undo the add
- expect(entities(m)).toHaveLength(0);
- expect(redo(m)).toBe(true); // redo the add — restores the SNAPSHOT coords, not (7,7)
- expect(entities(m)[0]).toEqual({ type: "building", buildingId: "house", x: 4, y: 4 });
- });
- it("moveEntity undo restores the previous coordinates", () => {
- const m = model();
- addNpc(m, "mayor", 2, 2);
- expect(moveEntity(m, 0, 8, 8)).toEqual({ ok: true });
- expect(entities(m)[0].x).toBe(8);
- expect(undo(m)).toBe(true);
- expect(entities(m)[0]).toEqual({ x: 2, y: 2, type: "npc", npcId: "mayor" });
- });
- it("rejected ops never enter the stack", () => {
- const m = model();
- addBuilding(m, "house", 4, 4);
- expect(addBuilding(m, "house", 4, 4)).toEqual({ ok: false, reason: expect.stringContaining("duplicate") });
- expect(addNpc(m, "mayor", 0, 0));
- expect(addNpc(m, "mayor", 1, 1)).toEqual({ ok: false, reason: expect.stringContaining("duplicate npc") });
- expect(moveEntity(m, 99, 0, 0)).toEqual({ ok: false, reason: expect.stringContaining("index") });
- expect(m.history.length).toBe(2); // only the two successful adds
- });
- it("markSaved is a checkpoint and undo-all returns clean", () => {
- const m = model();
- addNpc(m, "mayor", 0, 0);
- undo(m);
- markSaved(m);
- expect(m.history.length).toBe(0);
- expect(m.future.length).toBe(0);
- expect(m.dirty).toBe(false);
- addNpc(m, "mia", 1, 1);
- addNpc(m, "cow", 2, 2);
- while (undo(m)) {
- // drain
- }
- expect(m.dirty).toBe(false);
- expect(entities(m).length).toBe(0);
- });
- });
- describe("syncMapSize", () => {
- it("updates bounds and clamps entities into the new size", () => {
- const m = model(10, 10);
- addBuilding(m, "house", 8, 8); // 2×2 at 8,8 → fits in 10×10
- addNpc(m, "mayor", 9, 0);
- syncMapSize(m, 5, 5);
- expect(m.mapWidth).toBe(5);
- expect(m.mapHeight).toBe(5);
- expect(entities(m)[0]).toEqual({ type: "building", buildingId: "house", x: 3, y: 3 });
- expect(entities(m)[1]).toEqual({ type: "npc", npcId: "mayor", x: 4, y: 0 });
- });
- it("leaves a building whose footprint cannot fit in place (defensive — sceneFitsIn guards first)", () => {
- const m = model(10, 10);
- addBuilding(m, "house", 4, 4);
- syncMapSize(m, 1, 1);
- expect(entities(m)[0]).toEqual({ type: "building", buildingId: "house", x: 4, y: 4 });
- });
- });
- describe("sceneFitsIn", () => {
- it("rejects a size that cannot contain a placed building footprint", () => {
- const m = model(10, 10);
- addBuilding(m, "house", 4, 4); // 2×2
- expect(sceneFitsIn(m, 10, 10)).toEqual({ ok: true });
- expect(sceneFitsIn(m, 2, 2)).toEqual({ ok: true });
- const result = sceneFitsIn(m, 1, 1);
- expect(result).toEqual({ ok: false, reason: expect.stringContaining('building "house"') });
- expect(result).toEqual({ ok: false, reason: expect.stringContaining("1×1") });
- });
- it("always fits npc/animal (1×1)", () => {
- const m = model(10, 10);
- addNpc(m, "mayor", 9, 9);
- expect(addAnimal(m, "chicken", 8, 8)).toEqual({ ok: true });
- expect(sceneFitsIn(m, 1, 1)).toEqual({ ok: true });
- });
- });
- describe("clearHistory + sceneFitsAfterResize (bug-fix round)", () => {
- it("clearHistory keeps the dirty flag — the resize is an unsaved edit", () => {
- const m = model();
- addNpc(m, "mayor", 0, 0);
- clearHistory(m);
- expect(m.history.length).toBe(0);
- expect(m.dirty).toBe(true);
- expect(canUndo(m)).toBe(false);
- });
- it("sceneFitsAfterResize rejects a shrink whose clamping would overlap buildings", () => {
- const m = model(10, 10);
- addBuilding(m, "house", 0, 0); // 2×2
- addBuilding(m, "shed", 3, 0); // 2×2 — clamp target (1,0) would overlap the house
- const result = sceneFitsAfterResize(m, 3, 3);
- expect(result).toEqual({ ok: false, reason: expect.stringContaining("would overlap") });
- // per-building fit still passes at 3×3 (each 2×2 fits) — the overlap
- // simulation is what rejects it
- expect(sceneFitsIn(m, 3, 3)).toEqual({ ok: true });
- });
- it("sceneFitsAfterResize passes when clamped entities stay apart", () => {
- const m = model(10, 10);
- addBuilding(m, "house", 0, 0);
- addBuilding(m, "shed", 5, 0);
- addNpc(m, "mayor", 9, 9);
- expect(sceneFitsAfterResize(m, 4, 4)).toEqual({ ok: true });
- });
- it("sceneFitsAfterResize still rejects uncontainable buildings", () => {
- const m = model(10, 10);
- addBuilding(m, "house", 0, 0); // 2×2
- expect(sceneFitsAfterResize(m, 1, 1)).toEqual({ ok: false, reason: expect.stringContaining("does not fit") });
- });
- });
|