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

104 lines
3.9 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 } 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(
// 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);
}
});
});