import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { describe, expect, it } from "vitest"; import { deleteAsset, importAssets, listAssets, readAssetEntries, refreshEditEngineAssets } from "../src/assets.js"; import { createEditEngine } from "../src/edit-engine.js"; const PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; const OGG_BASE64 = "T2dnUwAAAAAAAAAAAAAAAAAAAAAAAE9nZ1M="; const FIXTURE_FILES: Record = { "content/time.yaml": [ "id: default", "dayLengthTicks: 240", "seasons:", " - { name: spring, dayCount: 14 }", "start: { day: 1, season: spring }", "", ].join("\n"), "content/maps/farm.yaml": [ "id: farm", "width: 2", "height: 2", "tilesetRef: farm-tiles", "spawn: { x: 0, y: 0 }", "layers:", " - name: ground", " data:", " - [grass, grass]", " - [grass, grass]", "", ].join("\n"), }; async function tmpDir(): Promise { return mkdtemp(join(tmpdir(), "editor-assets-")); } async function fixtureProject(): Promise { const dir = await tmpDir(); for (const [rel, content] of Object.entries(FIXTURE_FILES)) { const target = join(dir, rel); await mkdir(dirname(target), { recursive: true }); await writeFile(target, content, "utf8"); } await mkdir(join(dir, "content", "assets"), { recursive: true }); await mkdir(join(dir, "sources"), { recursive: true }); return dir; } async function writeSource(dir: string, name: string, base64: string): Promise { const full = join(dir, "sources", name); await mkdir(dirname(full), { recursive: true }); await writeFile(full, Buffer.from(base64, "base64")); return full; } describe("listAssets (F2)", () => { it("scans png + ogg with dataUrl on images only, sorted by id", async () => { const dir = await tmpDir(); try { await mkdir(join(dir, "content", "assets"), { recursive: true }); await writeFile(join(dir, "content", "assets", "b-1.ogg"), Buffer.from(OGG_BASE64, "base64")); await writeFile(join(dir, "content", "assets", "a-1.png"), Buffer.from(PNG_BASE64, "base64")); const assets = await listAssets(dir); expect(assets.map((a) => a.id)).toEqual(["a-1", "b-1"]); const image = assets[0]; expect(image?.kind).toBe("image"); expect(image?.mime).toBe("image/png"); expect(image?.dataUrl).toBe(`data:image/png;base64,${PNG_BASE64}`); expect(image?.size).toBe(Buffer.from(PNG_BASE64, "base64").length); const audio = assets[1]; expect(audio?.kind).toBe("audio"); expect(audio?.mime).toBe("audio/ogg"); expect(audio?.dataUrl).toBeUndefined(); } finally { await rm(dir, { recursive: true, force: true }); } }); it("ignores non-asset files and a missing assets dir", async () => { const dir = await tmpDir(); try { await mkdir(join(dir, "content", "assets"), { recursive: true }); await writeFile(join(dir, "content", "assets", "notes.txt"), "not an asset"); expect(await listAssets(dir)).toEqual([]); } finally { await rm(dir, { recursive: true, force: true }); } }); }); describe("importAssets (F2)", () => { it("copies source files into content/assets with a sanitized id", async () => { const dir = await tmpDir(); try { const src = await writeSource(dir, "My Icon (1).png", PNG_BASE64); const src2 = await writeSource(dir, "click-1.ogg", OGG_BASE64); const result = await importAssets(dir, [src, src2]); expect(result.errors).toBeUndefined(); expect(result.imported.map((a) => a.id)).toEqual(["my-icon-1", "click-1"]); expect(await readdir(join(dir, "content", "assets"))).toEqual(["click-1.ogg", "my-icon-1.png"]); const original = await readFile(src); expect(await readFile(join(dir, "content", "assets", "my-icon-1.png"))).toEqual(original); expect(result.imported.find((a) => a.id === "my-icon-1")?.dataUrl).toBe(`data:image/png;base64,${PNG_BASE64}`); } finally { await rm(dir, { recursive: true, force: true }); } }); it("dedupes a colliding basename with a -2 suffix and never overwrites", async () => { const dir = await tmpDir(); try { await mkdir(join(dir, "content", "assets"), { recursive: true }); await writeFile(join(dir, "content", "assets", "tile-1.png"), Buffer.from(OGG_BASE64, "base64")); const src = await writeSource(dir, "tile-1.png", PNG_BASE64); const srcAgain = await writeSource(dir, "tile-1.png", PNG_BASE64); const first = await importAssets(dir, [src]); expect(first.imported.map((a) => a.id)).toEqual(["tile-1-2"]); expect((await stat(join(dir, "content", "assets", "tile-1.png"))).size).toBe( Buffer.from(OGG_BASE64, "base64").length, ); const second = await importAssets(dir, [srcAgain]); expect(second.imported.map((a) => a.id)).toEqual(["tile-1-3"]); expect(await readdir(join(dir, "content", "assets"))).toEqual(["tile-1-2.png", "tile-1-3.png", "tile-1.png"]); } finally { await rm(dir, { recursive: true, force: true }); } }); it("rejects unsupported extensions per-file while still importing the valid ones", async () => { const dir = await tmpDir(); try { const ok = await writeSource(dir, "grass-1.png", PNG_BASE64); const bad = await writeSource(dir, "readme.txt", "hello"); const result = await importAssets(dir, [bad, ok]); expect(result.imported.map((a) => a.id)).toEqual(["grass-1"]); expect(result.errors).toHaveLength(1); expect(result.errors?.[0]?.path).toBe(bad); expect(result.errors?.[0]?.message).toMatch(/unsupported asset type/); } finally { await rm(dir, { recursive: true, force: true }); } }); }); describe("deleteAsset (F2)", () => { it("removes the file for a known id and errors for an unknown one", async () => { const dir = await tmpDir(); try { await mkdir(join(dir, "content", "assets"), { recursive: true }); await writeFile(join(dir, "content", "assets", "a-1.png"), Buffer.from(PNG_BASE64, "base64")); await writeFile(join(dir, "content", "assets", "b-1.ogg"), Buffer.from(OGG_BASE64, "base64")); expect(await deleteAsset(dir, "a-1")).toEqual({ ok: true }); expect(await readdir(join(dir, "content", "assets"))).toEqual(["b-1.ogg"]); const missing = await deleteAsset(dir, "a-1"); expect(missing.ok).toBe(false); expect(missing.error).toBe("asset not found: a-1"); } finally { await rm(dir, { recursive: true, force: true }); } }); }); describe("readAssetEntries + refreshEditEngineAssets (F2)", () => { it("reads the same table shape the ContentLoader produces (D31 parity)", async () => { const dir = await tmpDir(); try { await mkdir(join(dir, "content", "assets"), { recursive: true }); await writeFile(join(dir, "content", "assets", "grass-1.png"), Buffer.from(PNG_BASE64, "base64")); await writeFile(join(dir, "content", "assets", "click-1.ogg"), Buffer.from(OGG_BASE64, "base64")); const entries = await readAssetEntries(dir); expect(entries.get("grass-1")).toEqual({ kind: "image", mime: "image/png", data: PNG_BASE64 }); expect(entries.get("click-1")).toEqual({ kind: "audio", mime: "audio/ogg", data: OGG_BASE64 }); } finally { await rm(dir, { recursive: true, force: true }); } }); it("rebuilds the edit engine's asset table after an import + delete", async () => { const dir = await fixtureProject(); try { const engine = createEditEngine(dir); await engine.start(); try { expect(engine.assets.size).toBe(0); const src = await writeSource(dir, "grass-1.png", PNG_BASE64); const imported = await importAssets(dir, [src]); expect(imported.imported.map((a) => a.id)).toEqual(["grass-1"]); await refreshEditEngineAssets(engine, dir); expect(engine.assets.get("grass-1")?.kind).toBe("image"); await expect(deleteAsset(dir, "grass-1")).resolves.toEqual({ ok: true }); await refreshEditEngineAssets(engine, dir); expect(engine.assets.size).toBe(0); } finally { await engine.stop(); } } finally { await rm(dir, { recursive: true, force: true }); } }); });