deadhead-space-freight-tycoon/apps/web/test/map-art.test.ts

188 lines
6.7 KiB
TypeScript

// @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, pieceIds } from "../src/map/art/manifest";
// @ts-expect-error: a plain .mjs build script, tested directly.
import { MIN_ALPHA, sliceSheet } from "../scripts/slice.mjs";
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],
rocks: [64, 128],
} 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;
}
if (asset.kind === "strip") {
const meta = await image(join(first, `${id}.png`)).metadata();
expect(meta.width!).toBeLessThanOrEqual(4096);
expect(meta.height!).toBeLessThanOrEqual(256);
continue;
}
// A sliced sheet writes one sprite per piece (CR-016).
const bases = asset.kind === "rocks" ? pieceIds(id, asset) : [id];
const [one, two] = SIZES[asset.kind];
for (const base of bases) {
for (const [suffix, longSide] of [
["1x", one],
["2x", two],
] as const) {
const meta = await image(join(first, `${base}@${suffix}.png`)).metadata();
expect(Math.max(meta.width!, meta.height!), `${base}@${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(
// Alpha up to 2/255 is invisible (ship-lighter.png has a stray alpha-1 corner pixel).
corners.every((a) => a! <= 2),
`${asset.file} (${id}): background isn't transparent`,
).toBe(true);
}
});
});
/** A transparent sheet with an opaque square at each `[x, y, size]`, plus faint glow and specks. */
async function sheet(squares: [number, number, number][]): Promise<Buffer> {
const width = 200;
const height = 120;
const data = Buffer.alloc(width * height * 4);
const paint = (x: number, y: number, a: number) => {
const i = (y * width + x) * 4;
data[i] = 180;
data[i + 1] = 160;
data[i + 2] = 140;
data[i + 3] = Math.max(data[i + 3]!, a);
};
for (const [left, top, size] of squares) {
// A faint halo bridging toward the neighbours, below the slicing threshold.
for (let y = top - 6; y < top + size + 6; y++) {
for (let x = left - 6; x < left + size + 6; x++) {
if (x >= 0 && y >= 0 && x < width && y < height) paint(x, y, MIN_ALPHA - 6);
}
}
for (let y = top; y < top + size; y++)
for (let x = left; x < left + size; x++) paint(x, y, 255);
}
// A stray speck: far too small to be a sprite.
paint(195, 115, 255);
return sharp(data, { raw: { width, height, channels: 4 } })
.png()
.toBuffer();
}
describe("slicing a sprite sheet (Step 16.5)", () => {
it("cuts three separate rocks into exactly three trimmed sprites", async () => {
const pieces: Buffer[] = await sliceSheet(
sharp,
await sheet([
[10, 10, 30],
[80, 20, 20],
[140, 60, 40],
]),
);
expect(pieces).toHaveLength(3);
const sizes = await Promise.all(pieces.map((piece) => sharp(piece).metadata()));
// Reading order (top row left to right), each trimmed to its own square.
expect(sizes.map((m) => [m.width, m.height])).toEqual([
[30, 30],
[20, 20],
[40, 40],
]);
});
it("keeps a single rock as one sprite", async () => {
expect(await sliceSheet(sharp, await sheet([[60, 30, 50]]))).toHaveLength(1);
});
it("drops a neighbour's glow from each sprite", async () => {
const [piece] = await sliceSheet(
sharp,
await sheet([
[10, 10, 30],
[44, 10, 30],
]),
);
const { data } = await sharp(piece).raw().toBuffer({ resolveWithObject: true });
// Every pixel is either the rock (opaque) or cleared, never the faint halo.
for (let i = 3; i < data.length; i += 4) expect([0, 255]).toContain(data[i]);
});
});