// @vitest-environment node import { execFileSync } from "node:child_process"; import { mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import sharp from "sharp"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { artManifest } from "../src/map/art/manifest"; const webRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); const script = join(webRoot, "scripts/map-art.mjs"); sharp.cache(false); /** Reads through a buffer so sharp never holds the temp files open (Windows). */ const image = (path: string) => sharp(readFileSync(path)); const SIZES = { body: [256, 512], badge: [256, 512], ship: [128, 256] } as const; const run = (outDir: string) => execFileSync(process.execPath, [script, outDir], { cwd: webRoot, stdio: "pipe" }); describe("map art optimization script (CR-003)", () => { let first: string; let second: string; beforeAll(() => { first = mkdtempSync(join(tmpdir(), "map-art-a-")); second = mkdtempSync(join(tmpdir(), "map-art-b-")); run(first); run(second); }, 120_000); afterAll(() => { rmSync(first, { recursive: true, force: true }); rmSync(second, { recursive: true, force: true }); }); const present = Object.entries(artManifest.assets).filter(([, a]) => a.file !== null); it("writes every expected file at the right size", async () => { for (const [id, asset] of present) { if (asset.kind === "background") { const meta = await image(join(first, `${id}.webp`)).metadata(); expect(meta.format).toBe("webp"); expect(Math.max(meta.width!, meta.height!)).toBeLessThanOrEqual(2048); continue; } const [one, two] = SIZES[asset.kind]; for (const [suffix, longSide] of [ ["1x", one], ["2x", two], ] as const) { const meta = await image(join(first, `${id}@${suffix}.png`)).metadata(); expect(Math.max(meta.width!, meta.height!), `${id}@${suffix}`).toBe(longSide); } } }); it("produces identical files when run twice", () => { const files = readdirSync(first).sort(); expect(readdirSync(second).sort()).toEqual(files); for (const file of files) { expect(readFileSync(join(second, file)).equals(readFileSync(join(first, file))), file).toBe( true, ); } }); it("matches the committed output pixel for pixel", async () => { // Pixels, not bytes: PNG compression may differ between platforms (CI runs Linux). const committed = join(webRoot, "public/assets/map-gen"); for (const file of readdirSync(first).filter((f) => f.endsWith(".png"))) { const [fresh, stored] = await Promise.all([ image(join(first, file)).raw().toBuffer(), image(join(committed, file)).raw().toBuffer(), ]); expect(stored.equals(fresh), file).toBe(true); } }); it("keeps the background under 1 MB", () => { expect(statSync(join(first, "bg.webp")).size).toBeLessThan(1024 * 1024); }); it("has source sprites with transparent backgrounds", async () => { for (const [id, asset] of present) { if (asset.kind === "background") continue; const { data, info } = await image(join(webRoot, artManifest.sourceDir, asset.file!)) .ensureAlpha() .raw() .toBuffer({ resolveWithObject: true }); const alpha = (x: number, y: number) => data[(y * info.width + x) * info.channels + 3]; const corners = [ alpha(0, 0), alpha(info.width - 1, 0), alpha(0, info.height - 1), alpha(info.width - 1, info.height - 1), ]; expect( corners.every((a) => a === 0), `${asset.file} (${id}): background isn't transparent`, ).toBe(true); } }); });