Files
ApiCore/ApiCore-Frontend/scripts/generate-icons.spec.ts
T
Nick.RosenloecherandClaude Opus 5.5 2395576c88 feat(t5): App-Icons, Manifest und Offline-Cache v5 (T5a A9)
- Honigzelle auf Tinte als Master-SVG; scripts/generate-icons.mjs erzeugt
  any/maskable 192/512, Monochrom 512, drei Shortcut-Icons, Apple-Icon und
  favicon.ico (16/32/48, ICO-Container von Hand) byte-gleich bei jedem Lauf.
- manifest.ts exakt nach Spec 12.3, Farben gleich dem hellen --background.
- Service Worker: SHELL_CACHE apicore-shell-v5, Icons im Shell-Cache; ein
  fehlendes Icon hält das Vorladen der Seiten nicht auf.
- Proxy-Matcher: nur echte Icon-Dateien (icons/*.png) ohne
  Sicherheits-Header, Punkte maskiert.
- BrandMark-Deckel mittig (linke Kante 18.8).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 17:51:41 +02:00

67 lines
2.8 KiB
TypeScript

// @vitest-environment node
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { BRAND_CELL_PATHS } from "@/components/brand-mark";
import { AREAS } from "@/lib/areas";
import { ICON_FILES, SHORTCUT_ICONS, buildIco, generateIcons, readMasterPaths } from "./generate-icons.mjs";
const ROOT = process.cwd();
describe("generate-icons (spec 12.2)", () => {
let outDir: string;
beforeEach(() => {
outDir = mkdtempSync(path.join(tmpdir(), "icons-"));
});
afterEach(() => rmSync(outDir, { recursive: true, force: true }));
it("the master SVG has the BrandMark geometry", () => {
expect(readMasterPaths(readFileSync(path.join(ROOT, "public/brand/apicore-mark.svg"), "utf8"))).toEqual(
BRAND_CELL_PATHS,
);
});
it("the lid sits centred over the cell, only lifted", () => {
const xs = (d: string) => [...d.matchAll(/L([\d.]+)\s/g)].map((match) => Number(match[1]));
const lidXs = xs(BRAND_CELL_PATHS.lid);
expect(Math.min(...lidXs) + Math.max(...lidXs)).toBeCloseTo(64, 5);
});
it("shortcut icons use the icon of their area", () => {
const pascal = (name: string) => name.replace(/(^|-)(\w)/g, (_, __, char: string) => char.toUpperCase());
for (const shortcut of SHORTCUT_ICONS) {
expect(AREAS[shortcut.area as keyof typeof AREAS].icon.displayName, shortcut.file).toBe(pascal(shortcut.lucide));
}
});
it("running the icon script twice leaves the files byte-identical", async () => {
const first = await generateIcons({ root: ROOT, outRoot: outDir });
const snapshot = new Map(first.map((file) => [file, readFileSync(path.join(outDir, file))]));
const second = await generateIcons({ root: ROOT, outRoot: outDir });
expect(second).toEqual(first);
expect([...first].sort()).toEqual([...ICON_FILES].sort());
for (const file of second) {
expect(readFileSync(path.join(outDir, file)).equals(snapshot.get(file) as Buffer), file).toBe(true);
}
});
it("buildIco writes a header, one entry per PNG and the PNGs behind it", () => {
const png = (size: number) => Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47]), Buffer.alloc(size)]);
const ico = buildIco([
{ size: 16, png: png(10) },
{ size: 256, png: png(20) },
]);
expect([ico.readUInt16LE(0), ico.readUInt16LE(2), ico.readUInt16LE(4)]).toEqual([0, 1, 2]);
// 256 px is written as 0 in the one-byte width and height fields.
expect([ico.readUInt8(6), ico.readUInt8(22)]).toEqual([16, 0]);
expect(ico.readUInt16LE(6 + 6)).toBe(32);
const secondOffset = ico.readUInt32LE(22 + 12);
expect(ico.readUInt32LE(6 + 12)).toBe(6 + 2 * 16);
expect(secondOffset).toBe(6 + 2 * 16 + 14);
expect(ico.length).toBe(secondOffset + 24);
});
});