assets.test.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202
  1. import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
  2. import { tmpdir } from "node:os";
  3. import { dirname, join } from "node:path";
  4. import { describe, expect, it } from "vitest";
  5. import { deleteAsset, importAssets, listAssets, readAssetEntries, refreshEditEngineAssets } from "../src/assets.js";
  6. import { createEditEngine } from "../src/edit-engine.js";
  7. const PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
  8. const OGG_BASE64 = "T2dnUwAAAAAAAAAAAAAAAAAAAAAAAE9nZ1M=";
  9. const FIXTURE_FILES: Record<string, string> = {
  10. "content/time.yaml": [
  11. "id: default",
  12. "dayLengthTicks: 240",
  13. "seasons:",
  14. " - { name: spring, dayCount: 14 }",
  15. "start: { day: 1, season: spring }",
  16. "",
  17. ].join("\n"),
  18. "content/maps/farm.yaml": [
  19. "id: farm",
  20. "width: 2",
  21. "height: 2",
  22. "tilesetRef: farm-tiles",
  23. "spawn: { x: 0, y: 0 }",
  24. "layers:",
  25. " - name: ground",
  26. " data:",
  27. " - [grass, grass]",
  28. " - [grass, grass]",
  29. "",
  30. ].join("\n"),
  31. };
  32. async function tmpDir(): Promise<string> {
  33. return mkdtemp(join(tmpdir(), "editor-assets-"));
  34. }
  35. async function fixtureProject(): Promise<string> {
  36. const dir = await tmpDir();
  37. for (const [rel, content] of Object.entries(FIXTURE_FILES)) {
  38. const target = join(dir, rel);
  39. await mkdir(dirname(target), { recursive: true });
  40. await writeFile(target, content, "utf8");
  41. }
  42. await mkdir(join(dir, "content", "assets"), { recursive: true });
  43. await mkdir(join(dir, "sources"), { recursive: true });
  44. return dir;
  45. }
  46. async function writeSource(dir: string, name: string, base64: string): Promise<string> {
  47. const full = join(dir, "sources", name);
  48. await mkdir(dirname(full), { recursive: true });
  49. await writeFile(full, Buffer.from(base64, "base64"));
  50. return full;
  51. }
  52. describe("listAssets (F2)", () => {
  53. it("scans png + ogg with dataUrl on images only, sorted by id", async () => {
  54. const dir = await tmpDir();
  55. try {
  56. await mkdir(join(dir, "content", "assets"), { recursive: true });
  57. await writeFile(join(dir, "content", "assets", "b-1.ogg"), Buffer.from(OGG_BASE64, "base64"));
  58. await writeFile(join(dir, "content", "assets", "a-1.png"), Buffer.from(PNG_BASE64, "base64"));
  59. const assets = await listAssets(dir);
  60. expect(assets.map((a) => a.id)).toEqual(["a-1", "b-1"]);
  61. const image = assets[0];
  62. expect(image?.kind).toBe("image");
  63. expect(image?.mime).toBe("image/png");
  64. expect(image?.dataUrl).toBe(`data:image/png;base64,${PNG_BASE64}`);
  65. expect(image?.size).toBe(Buffer.from(PNG_BASE64, "base64").length);
  66. const audio = assets[1];
  67. expect(audio?.kind).toBe("audio");
  68. expect(audio?.mime).toBe("audio/ogg");
  69. expect(audio?.dataUrl).toBeUndefined();
  70. } finally {
  71. await rm(dir, { recursive: true, force: true });
  72. }
  73. });
  74. it("ignores non-asset files and a missing assets dir", async () => {
  75. const dir = await tmpDir();
  76. try {
  77. await mkdir(join(dir, "content", "assets"), { recursive: true });
  78. await writeFile(join(dir, "content", "assets", "notes.txt"), "not an asset");
  79. expect(await listAssets(dir)).toEqual([]);
  80. } finally {
  81. await rm(dir, { recursive: true, force: true });
  82. }
  83. });
  84. });
  85. describe("importAssets (F2)", () => {
  86. it("copies source files into content/assets with a sanitized id", async () => {
  87. const dir = await tmpDir();
  88. try {
  89. const src = await writeSource(dir, "My Icon (1).png", PNG_BASE64);
  90. const src2 = await writeSource(dir, "click-1.ogg", OGG_BASE64);
  91. const result = await importAssets(dir, [src, src2]);
  92. expect(result.errors).toBeUndefined();
  93. expect(result.imported.map((a) => a.id)).toEqual(["my-icon-1", "click-1"]);
  94. expect(await readdir(join(dir, "content", "assets"))).toEqual(["click-1.ogg", "my-icon-1.png"]);
  95. const original = await readFile(src);
  96. expect(await readFile(join(dir, "content", "assets", "my-icon-1.png"))).toEqual(original);
  97. expect(result.imported.find((a) => a.id === "my-icon-1")?.dataUrl).toBe(`data:image/png;base64,${PNG_BASE64}`);
  98. } finally {
  99. await rm(dir, { recursive: true, force: true });
  100. }
  101. });
  102. it("dedupes a colliding basename with a -2 suffix and never overwrites", async () => {
  103. const dir = await tmpDir();
  104. try {
  105. await mkdir(join(dir, "content", "assets"), { recursive: true });
  106. await writeFile(join(dir, "content", "assets", "tile-1.png"), Buffer.from(OGG_BASE64, "base64"));
  107. const src = await writeSource(dir, "tile-1.png", PNG_BASE64);
  108. const srcAgain = await writeSource(dir, "tile-1.png", PNG_BASE64);
  109. const first = await importAssets(dir, [src]);
  110. expect(first.imported.map((a) => a.id)).toEqual(["tile-1-2"]);
  111. expect((await stat(join(dir, "content", "assets", "tile-1.png"))).size).toBe(
  112. Buffer.from(OGG_BASE64, "base64").length,
  113. );
  114. const second = await importAssets(dir, [srcAgain]);
  115. expect(second.imported.map((a) => a.id)).toEqual(["tile-1-3"]);
  116. expect(await readdir(join(dir, "content", "assets"))).toEqual(["tile-1-2.png", "tile-1-3.png", "tile-1.png"]);
  117. } finally {
  118. await rm(dir, { recursive: true, force: true });
  119. }
  120. });
  121. it("rejects unsupported extensions per-file while still importing the valid ones", async () => {
  122. const dir = await tmpDir();
  123. try {
  124. const ok = await writeSource(dir, "grass-1.png", PNG_BASE64);
  125. const bad = await writeSource(dir, "readme.txt", "hello");
  126. const result = await importAssets(dir, [bad, ok]);
  127. expect(result.imported.map((a) => a.id)).toEqual(["grass-1"]);
  128. expect(result.errors).toHaveLength(1);
  129. expect(result.errors?.[0]?.path).toBe(bad);
  130. expect(result.errors?.[0]?.message).toMatch(/unsupported asset type/);
  131. } finally {
  132. await rm(dir, { recursive: true, force: true });
  133. }
  134. });
  135. });
  136. describe("deleteAsset (F2)", () => {
  137. it("removes the file for a known id and errors for an unknown one", async () => {
  138. const dir = await tmpDir();
  139. try {
  140. await mkdir(join(dir, "content", "assets"), { recursive: true });
  141. await writeFile(join(dir, "content", "assets", "a-1.png"), Buffer.from(PNG_BASE64, "base64"));
  142. await writeFile(join(dir, "content", "assets", "b-1.ogg"), Buffer.from(OGG_BASE64, "base64"));
  143. expect(await deleteAsset(dir, "a-1")).toEqual({ ok: true });
  144. expect(await readdir(join(dir, "content", "assets"))).toEqual(["b-1.ogg"]);
  145. const missing = await deleteAsset(dir, "a-1");
  146. expect(missing.ok).toBe(false);
  147. expect(missing.error).toBe("asset not found: a-1");
  148. } finally {
  149. await rm(dir, { recursive: true, force: true });
  150. }
  151. });
  152. });
  153. describe("readAssetEntries + refreshEditEngineAssets (F2)", () => {
  154. it("reads the same table shape the ContentLoader produces (D31 parity)", async () => {
  155. const dir = await tmpDir();
  156. try {
  157. await mkdir(join(dir, "content", "assets"), { recursive: true });
  158. await writeFile(join(dir, "content", "assets", "grass-1.png"), Buffer.from(PNG_BASE64, "base64"));
  159. await writeFile(join(dir, "content", "assets", "click-1.ogg"), Buffer.from(OGG_BASE64, "base64"));
  160. const entries = await readAssetEntries(dir);
  161. expect(entries.get("grass-1")).toEqual({ kind: "image", mime: "image/png", data: PNG_BASE64 });
  162. expect(entries.get("click-1")).toEqual({ kind: "audio", mime: "audio/ogg", data: OGG_BASE64 });
  163. } finally {
  164. await rm(dir, { recursive: true, force: true });
  165. }
  166. });
  167. it("rebuilds the edit engine's asset table after an import + delete", async () => {
  168. const dir = await fixtureProject();
  169. try {
  170. const engine = createEditEngine(dir);
  171. await engine.start();
  172. try {
  173. expect(engine.assets.size).toBe(0);
  174. const src = await writeSource(dir, "grass-1.png", PNG_BASE64);
  175. const imported = await importAssets(dir, [src]);
  176. expect(imported.imported.map((a) => a.id)).toEqual(["grass-1"]);
  177. await refreshEditEngineAssets(engine, dir);
  178. expect(engine.assets.get("grass-1")?.kind).toBe("image");
  179. await expect(deleteAsset(dir, "grass-1")).resolves.toEqual({ ok: true });
  180. await refreshEditEngineAssets(engine, dir);
  181. expect(engine.assets.size).toBe(0);
  182. } finally {
  183. await engine.stop();
  184. }
  185. } finally {
  186. await rm(dir, { recursive: true, force: true });
  187. }
  188. });
  189. });