|
@@ -0,0 +1,202 @@
|
|
|
|
|
+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<string, string> = {
|
|
|
|
|
+ "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<string> {
|
|
|
|
|
+ return mkdtemp(join(tmpdir(), "editor-assets-"));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function fixtureProject(): Promise<string> {
|
|
|
|
|
+ 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<string> {
|
|
|
|
|
+ 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 });
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+});
|