map-model.test.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587
  1. import { readFile } from "node:fs/promises";
  2. import { join } from "node:path";
  3. import { fileURLToPath } from "node:url";
  4. import { mapSchema } from "@nengine/core";
  5. import type { MapDef } from "@nengine/core";
  6. import { parse, stringify } from "yaml";
  7. import { describe, expect, it } from "vitest";
  8. import {
  9. DEFAULT_ERASE_TILE,
  10. TILE_SIZE,
  11. UNKNOWN_TILE_COLOR,
  12. addTileId,
  13. canRedo,
  14. canUndo,
  15. clearHistory,
  16. collectTileIds,
  17. createMapModel,
  18. erase,
  19. isWalkableTile,
  20. markSaved,
  21. paint,
  22. pointToTile,
  23. redo,
  24. resizeMap,
  25. serialize,
  26. setSpawn,
  27. tileAssetId,
  28. tileColor,
  29. toggleWalkable,
  30. undo,
  31. } from "../src/renderer/map-model.js";
  32. import type { MapModel } from "../src/renderer/map-model.js";
  33. const fixturesDir = fileURLToPath(new URL("./fixtures", import.meta.url));
  34. function grid(width: number, height: number, fill = "grass"): string[][] {
  35. return Array.from({ length: height }, () => Array.from({ length: width }, () => fill));
  36. }
  37. function makeDef(overrides: Partial<MapDef> = {}): MapDef {
  38. const def: MapDef = {
  39. id: "t",
  40. width: 4,
  41. height: 4,
  42. tilesetRef: "ts",
  43. spawn: { x: 1, y: 1 },
  44. layers: [{ name: "ground", data: grid(4, 4) }],
  45. ...overrides,
  46. };
  47. return def;
  48. }
  49. function model(def: MapDef): MapModel {
  50. const m = createMapModel(def);
  51. if (m === null) throw new Error("expected a valid model");
  52. return m;
  53. }
  54. describe("createMapModel", () => {
  55. it("builds a model from a valid def without mutating the input", () => {
  56. const def = makeDef();
  57. const snapshot = JSON.stringify(def);
  58. const m = model(def);
  59. paint(m, 0, 0, "water");
  60. setSpawn(m, 2, 2);
  61. toggleWalkable(m, "grass");
  62. erase(m, 1, 1);
  63. expect(JSON.stringify(def)).toBe(snapshot);
  64. expect(m.dirty).toBe(true);
  65. });
  66. it("rejects corrupt defs", () => {
  67. expect(createMapModel({ id: 42 })).toBeNull();
  68. const badRows = makeDef({ layers: [{ name: "ground", data: grid(4, 2) }] });
  69. expect(createMapModel(badRows)).toBeNull();
  70. });
  71. it("seeds the palette from ids on the map", () => {
  72. const def = makeDef({ layers: [{ name: "ground", data: grid(4, 4, "tilled") }] });
  73. def.layers[0].data[0] = ["water", "grass", "tilled", "grass"];
  74. const m = model(def);
  75. expect(m.palette).toEqual(["water", "grass", "tilled"]);
  76. });
  77. });
  78. describe("paint", () => {
  79. it("writes ground-layer data and marks dirty", () => {
  80. const m = model(makeDef());
  81. expect(m.dirty).toBe(false);
  82. paint(m, 2, 3, "tilled");
  83. expect(m.def.layers[0].data[3][2]).toBe("tilled");
  84. expect(m.dirty).toBe(true);
  85. });
  86. it("uses the layer named ground", () => {
  87. const def = makeDef();
  88. def.layers = [
  89. { name: "base", data: grid(4, 4, "rock") },
  90. { name: "ground", data: grid(4, 4) },
  91. ];
  92. const m = model(def);
  93. paint(m, 1, 1, "water");
  94. expect(m.def.layers[1].data[1][1]).toBe("water");
  95. expect(m.def.layers[0].data[1][1]).toBe("rock");
  96. });
  97. it("falls back to the first layer when ground is absent", () => {
  98. const def = makeDef({ layers: [{ name: "base", data: grid(4, 4) }] });
  99. const m = model(def);
  100. paint(m, 2, 2, "water");
  101. expect(m.def.layers[0].data[2][2]).toBe("water");
  102. });
  103. it("ignores out-of-bounds cells", () => {
  104. const m = model(makeDef());
  105. paint(m, -1, 0, "water");
  106. paint(m, 0, -1, "water");
  107. paint(m, 99, 0, "water");
  108. paint(m, 0, 99, "water");
  109. expect(m.def.layers[0].data[0][0]).toBe("grass");
  110. expect(m.dirty).toBe(false);
  111. });
  112. it("adds a new tile id to the palette", () => {
  113. const m = model(makeDef());
  114. paint(m, 0, 0, "rock");
  115. expect(m.palette).toContain("rock");
  116. expect(collectTileIds(m)).toContain("rock");
  117. });
  118. });
  119. describe("erase", () => {
  120. it("restores the default grass tile when the map uses it", () => {
  121. const m = model(makeDef());
  122. paint(m, 0, 0, "water");
  123. erase(m, 0, 0);
  124. expect(m.def.layers[0].data[0][0]).toBe(DEFAULT_ERASE_TILE);
  125. });
  126. it("falls back to the first palette id when grass is absent", () => {
  127. const def = makeDef({ layers: [{ name: "ground", data: grid(4, 4, "water") }] });
  128. const m = model(def);
  129. paint(m, 1, 1, "tilled");
  130. erase(m, 1, 1);
  131. expect(m.def.layers[0].data[1][1]).toBe("water");
  132. });
  133. it("writes an empty string when the palette is empty", () => {
  134. const def = makeDef({ layers: [{ name: "ground", data: grid(4, 4, "") }] });
  135. const m = model(def);
  136. erase(m, 2, 2);
  137. expect(m.def.layers[0].data[2][2]).toBe("");
  138. });
  139. });
  140. describe("setSpawn", () => {
  141. it("moves the spawn point", () => {
  142. const m = model(makeDef());
  143. setSpawn(m, 3, 2);
  144. expect(m.def.spawn).toEqual({ x: 3, y: 2 });
  145. expect(m.dirty).toBe(true);
  146. });
  147. it("clamps out-of-bounds coordinates", () => {
  148. const m = model(makeDef());
  149. setSpawn(m, -5, 2);
  150. expect(m.def.spawn.x).toBe(0);
  151. setSpawn(m, 100, 100);
  152. expect(m.def.spawn).toEqual({ x: 3, y: 3 });
  153. });
  154. it("rejects non-integer coordinates", () => {
  155. const m = model(makeDef());
  156. setSpawn(m, Number.NaN, 1);
  157. setSpawn(m, 1.5, 1);
  158. expect(m.def.spawn).toEqual({ x: 1, y: 1 });
  159. expect(m.dirty).toBe(false);
  160. });
  161. });
  162. describe("toggleWalkable", () => {
  163. it("sets absent ids to false", () => {
  164. const m = model(makeDef());
  165. expect(m.def.walkable).toBeUndefined();
  166. toggleWalkable(m, "grass");
  167. expect(m.def.walkable?.grass).toBe(false);
  168. expect(m.dirty).toBe(true);
  169. });
  170. it("deletes the key when already false (restores fallback)", () => {
  171. const m = model(makeDef());
  172. toggleWalkable(m, "grass");
  173. toggleWalkable(m, "grass");
  174. expect(m.def.walkable).toBeUndefined();
  175. });
  176. it("sets true entries to false", () => {
  177. const def = makeDef();
  178. def.walkable = { grass: true };
  179. const m = model(def);
  180. toggleWalkable(m, "grass");
  181. expect(m.def.walkable?.grass).toBe(false);
  182. });
  183. it("keeps unrelated entries when deleting one", () => {
  184. const def = makeDef();
  185. def.walkable = { water: false, tilled: false };
  186. const m = model(def);
  187. toggleWalkable(m, "water");
  188. expect(m.def.walkable).toEqual({ tilled: false });
  189. });
  190. it("ignores the empty tile id", () => {
  191. const m = model(makeDef());
  192. toggleWalkable(m, "");
  193. expect(m.def.walkable).toBeUndefined();
  194. expect(m.dirty).toBe(false);
  195. });
  196. });
  197. describe("addTileId", () => {
  198. it("registers a palette id without touching the def", () => {
  199. const m = model(makeDef());
  200. const defBefore = JSON.stringify(m.def);
  201. addTileId(m, "sand");
  202. expect(m.palette).toContain("sand");
  203. expect(JSON.stringify(m.def)).toBe(defBefore);
  204. expect(m.dirty).toBe(false);
  205. addTileId(m, "sand");
  206. expect(m.palette.filter((id) => id === "sand")).toHaveLength(1);
  207. addTileId(m, "");
  208. expect(m.palette).not.toContain("");
  209. });
  210. });
  211. describe("serialize", () => {
  212. it("roundtrips model → def → model identity for the same def", () => {
  213. const def = makeDef({ walkable: { water: false }, tiles: { grass: "grass-1" } });
  214. const m = model(def);
  215. const out = serialize(m);
  216. expect(out).not.toBeNull();
  217. expect(out).toEqual(def);
  218. expect(out).not.toBe(m.def);
  219. expect(mapSchema.safeParse(out as MapDef).success).toBe(true);
  220. });
  221. it("returns a fresh object reflecting edits", () => {
  222. const m = model(makeDef());
  223. paint(m, 0, 0, "tilled");
  224. setSpawn(m, 0, 0);
  225. const out = serialize(m);
  226. expect(out?.layers[0].data[0][0]).toBe("tilled");
  227. expect(out?.spawn).toEqual({ x: 0, y: 0 });
  228. });
  229. it("rejects an invalid working copy", () => {
  230. const m = model(makeDef());
  231. m.def.layers[0].data[0].length = 99;
  232. expect(serialize(m)).toBeNull();
  233. });
  234. });
  235. describe("collectTileIds", () => {
  236. it("collects unique ids from all layers in stable order", () => {
  237. const def: MapDef = {
  238. id: "t",
  239. width: 2,
  240. height: 2,
  241. tilesetRef: "ts",
  242. spawn: { x: 0, y: 0 },
  243. layers: [
  244. { name: "ground", data: [["water", "grass"], ["grass", "tilled"]] },
  245. { name: "deco", data: [["rock", "grass"], ["", "rock"]] },
  246. ],
  247. };
  248. const m = model(def);
  249. expect(collectTileIds(m)).toEqual(["water", "grass", "tilled", "rock"]);
  250. });
  251. });
  252. describe("tileColor", () => {
  253. it("keeps known tiles stable and special-cased", () => {
  254. expect(tileColor("grass")).toBe("#7cb342");
  255. expect(tileColor("water")).toBe("#4fc3f7");
  256. expect(tileColor("tilled")).toBe("#8d6e63");
  257. expect(tileColor("")).toBe(UNKNOWN_TILE_COLOR);
  258. });
  259. it("is stable for unknown ids and format is a hue", () => {
  260. expect(tileColor("rock")).toBe(tileColor("rock"));
  261. expect(tileColor("rock")).toMatch(/^hsl\(\d+ 55% 55%\)$/);
  262. expect(tileColor("rock")).not.toBe(tileColor("sand"));
  263. });
  264. });
  265. describe("isWalkableTile", () => {
  266. it("honours declared walkable entries", () => {
  267. const def = makeDef();
  268. def.walkable = { water: false, tilled: true };
  269. const m = model(def);
  270. expect(isWalkableTile(m, "water")).toBe(false);
  271. expect(isWalkableTile(m, "tilled")).toBe(true);
  272. });
  273. it("falls back to the engine's UNWALKABLE_TILES", () => {
  274. const m = model(makeDef());
  275. expect(isWalkableTile(m, "water")).toBe(false);
  276. expect(isWalkableTile(m, "grass")).toBe(true);
  277. expect(isWalkableTile(m, "rock")).toBe(true);
  278. });
  279. });
  280. describe("pointToTile", () => {
  281. it("maps pixel offsets to grid tiles", () => {
  282. expect(pointToTile(0, 0)).toEqual({ x: 0, y: 0 });
  283. expect(pointToTile(31, 31)).toEqual({ x: 0, y: 0 });
  284. expect(pointToTile(32, 64)).toEqual({ x: 1, y: 2 });
  285. expect(pointToTile(-5, 3)).toEqual({ x: -1, y: 0 });
  286. expect(TILE_SIZE).toBe(32);
  287. });
  288. });
  289. describe("dirty lifecycle", () => {
  290. it("tracks edits and clears on markSaved", () => {
  291. const m = model(makeDef());
  292. expect(m.dirty).toBe(false);
  293. paint(m, 0, 0, "water");
  294. expect(m.dirty).toBe(true);
  295. markSaved(m);
  296. expect(m.dirty).toBe(false);
  297. erase(m, 0, 0);
  298. expect(m.dirty).toBe(true);
  299. markSaved(m);
  300. setSpawn(m, 2, 2);
  301. expect(m.dirty).toBe(true);
  302. markSaved(m);
  303. toggleWalkable(m, "grass");
  304. expect(m.dirty).toBe(true);
  305. markSaved(m);
  306. addTileId(m, "sand");
  307. expect(m.dirty).toBe(false);
  308. });
  309. });
  310. describe("real map fixture end-to-end", () => {
  311. it("edits a real farm.yaml through parse → model → serialize → stringify → parse", async () => {
  312. const text = await readFile(join(fixturesDir, "farm.yaml"), "utf8");
  313. const m = createMapModel(parse(text));
  314. expect(m).not.toBeNull();
  315. if (m === null) throw new Error("fixture must be a valid map");
  316. expect(mapSchema.safeParse(m.def).success).toBe(true);
  317. expect(m.def.width).toBe(16);
  318. expect(m.def.spawn).toEqual({ x: 2, y: 4 });
  319. paint(m, 0, 0, "grass");
  320. setSpawn(m, 4, 4);
  321. toggleWalkable(m, "tilled");
  322. const out = serialize(m);
  323. expect(out).not.toBeNull();
  324. if (out === null) throw new Error("edited fixture must serialize");
  325. const yaml = stringify(out);
  326. const reparsed = parse(yaml);
  327. expect(reparsed).toEqual(out);
  328. expect(mapSchema.safeParse(reparsed).success).toBe(true);
  329. const m2 = createMapModel(reparsed);
  330. expect(m2).not.toBeNull();
  331. expect(m2?.def.layers[0].data[0][0]).toBe("grass");
  332. expect(m2?.def.spawn).toEqual({ x: 4, y: 4 });
  333. expect(m2?.def.walkable).toEqual({ water: false, tilled: false });
  334. expect(m2?.def.tiles?.water).toBe("water-1");
  335. expect(await readFile(join(fixturesDir, "farm.yaml"), "utf8")).toBe(text);
  336. });
  337. });
  338. describe("undo/redo", () => {
  339. it("paint then undo restores the cell, clears dirty, and redo re-applies", () => {
  340. const m = model(makeDef());
  341. paint(m, 0, 0, "water");
  342. expect(m.dirty).toBe(true);
  343. expect(canUndo(m)).toBe(true);
  344. expect(undo(m)).toBe(true);
  345. expect(m.def.layers[0].data[0][0]).toBe("grass");
  346. expect(m.dirty).toBe(false);
  347. expect(canUndo(m)).toBe(false);
  348. expect(redo(m)).toBe(true);
  349. expect(m.def.layers[0].data[0][0]).toBe("water");
  350. expect(m.dirty).toBe(true);
  351. });
  352. it("erase records its actual write (before/after), not the erase intent", () => {
  353. const m = model(makeDef());
  354. paint(m, 2, 2, "water");
  355. markSaved(m);
  356. erase(m, 2, 2);
  357. expect(m.def.layers[0].data[2][2]).toBe("grass");
  358. expect(undo(m)).toBe(true);
  359. expect(m.def.layers[0].data[2][2]).toBe("water");
  360. });
  361. it("setSpawn undo restores the previous (clamped) coordinates", () => {
  362. const m = model(makeDef());
  363. setSpawn(m, 3, 3);
  364. expect(m.def.spawn).toEqual({ x: 3, y: 3 });
  365. expect(undo(m)).toBe(true);
  366. expect(m.def.spawn).toEqual({ x: 1, y: 1 });
  367. });
  368. it("toggleWalkable undo restores the record entry (and deletion restores the false entry)", () => {
  369. const m = model(makeDef());
  370. toggleWalkable(m, "grass"); // absent → false
  371. expect(m.def.walkable).toEqual({ grass: false });
  372. expect(undo(m)).toBe(true);
  373. expect(m.def.walkable).toBeUndefined();
  374. toggleWalkable(m, "grass");
  375. toggleWalkable(m, "grass"); // false → deleted
  376. expect(m.def.walkable).toBeUndefined();
  377. expect(undo(m)).toBe(true);
  378. expect(m.def.walkable).toEqual({ grass: false });
  379. });
  380. it("walkable undo restores an earlier unrelated entry of a multi-entry record", () => {
  381. const m = model(makeDef({ walkable: { water: false } }));
  382. toggleWalkable(m, "grass");
  383. undo(m);
  384. expect(m.def.walkable).toEqual({ water: false });
  385. });
  386. it("no-ops never enter the stack: same-value paint, same spawn, out-of-bounds paint", () => {
  387. const m = model(makeDef());
  388. paint(m, 0, 0, "grass"); // same value
  389. paint(m, 99, 99, "water"); // out of bounds
  390. setSpawn(m, 1, 1); // unchanged (clamped to same)
  391. expect(m.history.length).toBe(0);
  392. expect(m.dirty).toBe(false);
  393. });
  394. it("ops unwind LIFO and a new op clears the redo stack", () => {
  395. const m = model(makeDef());
  396. paint(m, 0, 0, "water");
  397. paint(m, 1, 1, "water");
  398. undo(m);
  399. undo(m);
  400. expect(redo(m)).toBe(true);
  401. paint(m, 3, 3, "water"); // new op
  402. expect(canRedo(m)).toBe(false);
  403. expect(redo(m)).toBe(false);
  404. });
  405. it("markSaved is a checkpoint: it clears both stacks and dirty", () => {
  406. const m = model(makeDef());
  407. paint(m, 0, 0, "water");
  408. undo(m);
  409. markSaved(m);
  410. expect(m.history.length).toBe(0);
  411. expect(m.future.length).toBe(0);
  412. expect(m.dirty).toBe(false);
  413. expect(canUndo(m)).toBe(false);
  414. expect(canRedo(m)).toBe(false);
  415. });
  416. it("undoing everything returns to the saved state (Save button clears)", () => {
  417. const m = model(makeDef());
  418. paint(m, 0, 0, "water");
  419. paint(m, 1, 1, "water");
  420. setSpawn(m, 2, 2);
  421. expect(m.dirty).toBe(true);
  422. while (undo(m)) {
  423. // drain
  424. }
  425. expect(m.dirty).toBe(false);
  426. expect(m.def.spawn).toEqual({ x: 1, y: 1 });
  427. expect(m.def.layers[0].data[0][0]).toBe("grass");
  428. expect(m.def.layers[0].data[1][1]).toBe("grass");
  429. });
  430. it("palette is additive and does not roll back on undo", () => {
  431. const m = model(makeDef());
  432. paint(m, 0, 0, "rock");
  433. expect(m.palette).toContain("rock");
  434. undo(m);
  435. expect(m.palette).toContain("rock");
  436. });
  437. it("undo/redo return false on empty stacks", () => {
  438. const m = model(makeDef());
  439. expect(undo(m)).toBe(false);
  440. expect(redo(m)).toBe(false);
  441. });
  442. });
  443. describe("resizeMap", () => {
  444. it("grows width/height padding rows and columns with the erase tile", () => {
  445. const m = model(makeDef());
  446. expect(resizeMap(m, 6, 5)).toBe(true);
  447. expect(m.def.width).toBe(6);
  448. expect(m.def.height).toBe(5);
  449. expect(m.def.layers[0].data.length).toBe(5);
  450. for (const row of m.def.layers[0].data) expect(row.length).toBe(6);
  451. expect(m.def.layers[0].data[4].every((tile) => tile === DEFAULT_ERASE_TILE)).toBe(true);
  452. expect(serialize(m)).not.toBeNull();
  453. });
  454. it("shrinks by truncating and keeps non-square shapes", () => {
  455. const m = model(makeDef({ width: 8, height: 10, layers: [{ name: "ground", data: grid(8, 10) }] }));
  456. expect(resizeMap(m, 20, 3)).toBe(true);
  457. expect(m.def.width).toBe(20);
  458. expect(m.def.height).toBe(3);
  459. expect(m.def.layers[0].data.length).toBe(3);
  460. for (const row of m.def.layers[0].data) expect(row.length).toBe(20);
  461. expect(serialize(m)).not.toBeNull();
  462. });
  463. it("resizes every layer, not just the ground layer", () => {
  464. const m = model(
  465. makeDef({ layers: [{ name: "ground", data: grid(4, 4) }, { name: "overlay", data: grid(4, 4, "") }] }),
  466. );
  467. expect(resizeMap(m, 6, 6)).toBe(true);
  468. for (const layer of m.def.layers) {
  469. expect(layer.data.length).toBe(6);
  470. for (const row of layer.data) expect(row.length).toBe(6);
  471. }
  472. });
  473. it("clamps an out-of-range spawn into the new bounds", () => {
  474. const m = model(makeDef({ width: 12, height: 12, spawn: { x: 10, y: 10 }, layers: [{ name: "ground", data: grid(12, 12) }] }));
  475. expect(resizeMap(m, 4, 4)).toBe(true);
  476. expect(m.def.spawn).toEqual({ x: 3, y: 3 });
  477. });
  478. it("rejects non-positive/non-integer dimensions and unchanged sizes, leaving the def untouched", () => {
  479. const m = model(makeDef());
  480. const before = JSON.stringify(m.def);
  481. expect(resizeMap(m, 0, 4)).toBe(false);
  482. expect(resizeMap(m, 4, -1)).toBe(false);
  483. expect(resizeMap(m, 4.5, 4)).toBe(false);
  484. expect(resizeMap(m, Number.NaN, 4)).toBe(false);
  485. expect(resizeMap(m, 4, 4)).toBe(false);
  486. expect(JSON.stringify(m.def)).toBe(before);
  487. expect(m.history.length).toBe(0);
  488. });
  489. it("resize is undoable/redoable via the full-grid snapshot", () => {
  490. const m = model(makeDef({ width: 8, height: 8, spawn: { x: 6, y: 6 }, layers: [{ name: "ground", data: grid(8, 8) }] }));
  491. expect(resizeMap(m, 4, 4)).toBe(true);
  492. expect(undo(m)).toBe(true);
  493. expect(m.def.width).toBe(8);
  494. expect(m.def.height).toBe(8);
  495. expect(m.def.spawn).toEqual({ x: 6, y: 6 });
  496. expect(m.def.layers[0].data.length).toBe(8);
  497. expect(m.def.layers[0].data[0].length).toBe(8);
  498. expect(redo(m)).toBe(true);
  499. expect(m.def.width).toBe(4);
  500. expect(serialize(m)).not.toBeNull();
  501. });
  502. });
  503. describe("tileAssetId", () => {
  504. it("returns the asset id for a mapped tile and undefined otherwise", () => {
  505. const m = model(makeDef({ tiles: { grass: "grass-1", water: "water-1" } }));
  506. expect(tileAssetId(m, "grass")).toBe("grass-1");
  507. expect(tileAssetId(m, "sand")).toBeUndefined();
  508. });
  509. it("returns undefined without a tiles record", () => {
  510. expect(tileAssetId(model(makeDef()), "grass")).toBeUndefined();
  511. });
  512. });
  513. describe("clearHistory (bug-fix round)", () => {
  514. it("drops both stacks but keeps the dirty flag — an edit is an unsaved edit", () => {
  515. const m = model(makeDef());
  516. paint(m, 0, 0, "water");
  517. clearHistory(m);
  518. expect(m.history.length).toBe(0);
  519. expect(m.future.length).toBe(0);
  520. expect(m.dirty).toBe(true); // the painted tile is still unsaved
  521. expect(canUndo(m)).toBe(false);
  522. expect(serialize(m)?.layers[0].data[0][0]).toBe("water");
  523. // markSaved is the only way back to clean.
  524. markSaved(m);
  525. expect(m.dirty).toBe(false);
  526. });
  527. });