// Tests for the SVG assets' faces (svg-faces.mjs) and what truncates by them: // fit() and wrapPx() in render-cards.mjs. // // Fira Sans -- every manifest without a brand -- keeps its 0.50 em average, // pinned here to the character counts it always produced. IBM Plex Sans -- the // Archilyzer Media preset -- is measured per character from the vendored font, // and a fitted run must come out INSIDE its budget whatever the text is. // // Run with: pnpm test:scripts import assert from "node:assert/strict"; import test from "node:test"; import { createHash } from "node:crypto"; import { spawnSync } from "node:child_process"; import { readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { fit, wrapPx } from "./render-cards.mjs"; import { FALLBACK_EM, FIRA_SANS, IBM_PLEX_SANS, textWidth } from "./svg-faces.mjs"; import { IBM_PLEX_SANS_METRICS } from "./face-metrics.mjs"; const HERE = path.dirname(fileURLToPath(import.meta.url)); const PLEX = IBM_PLEX_SANS; // What these assets actually draw: the manifests' labels, a quote, the // capitals of a column head, and the worst cases an average cannot see. const TEXTS = [ "two meme makers and the thumbnail guy — he gives no total", "“I have 10 employees, my coffee company employees… my editors, my designer”", "2 editors · 1 designer · 1 office · 5 contractors", "The Quartering · media", "THE QUARTERING · MEDIA", "COFFEE BRAND COFFEE CO", "WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW", "MMMM@@@@%%%%mmmmwwww————", "illicit little lilies fill it, still ill", "≈10 → 3, full-time; “salaried… pensions”", "mixed 中文 and 🙂 fall back", " leading and trailing spaces ", ]; // [size, budget, run] of the fit() call sites (render-cards.mjs), plus tight ones. const SITES = [ [14, 262, {}], // rail: track label [13, 436, {}], // rail: log label [13, 394, {}], // rail: roster [13, 230, {}], // ledger: arithmetic label [13, 140, { weight: "bold", ls: 0.6 }], // scroll: column head [18, 796, {}], // scroll: what he said [25, 60, {}], [10.5, 33, { weight: "bold", ls: 1.3 }], ]; test("Fira Sans: the 0.50 em average, exactly the character counts it always cut at", () => { // Default face is Fira, and passing it is the same call. const t = "x".repeat(100); assert.equal(fit(t, 13, 436), fit(t, 13, 436, FIRA_SANS)); // floor(436 / 6.5) = 67 characters: 66 of the text and the ellipsis. assert.equal(fit(t, 13, 436), `${"x".repeat(66)}…`); assert.equal(fit("x".repeat(67), 13, 436), "x".repeat(67)); // Never fewer than 4, and the cut is trimmed before the ellipsis. assert.equal(fit("abcdefghij", 20, 10), "abc…"); assert.equal(fit("ab cdefgh", 10, 30), "ab…"); // A run's weight and letter-spacing do not move an average face. assert.equal(fit("THE QUARTERING · MEDIA", 13, 140, FIRA_SANS, { weight: "bold", ls: 0.6 }), "THE QUARTERING · MED…"); // It counts: the same length for wide and narrow letters alike. assert.equal(fit("W".repeat(40), 13, 140).length, fit("i".repeat(40), 13, 140).length); assert.equal(fit(null, 13, 140), ""); }); test("IBM Plex Sans: a fitted run is inside its budget, whatever the text", () => { for (const [size, budget, run] of SITES) { for (const t of TEXTS) { const out = fit(t, size, budget, PLEX, run); const w = textWidth(out, size, PLEX, run); assert.ok(w <= budget + 1e-9, `${JSON.stringify(out)} is ${w.toFixed(2)} px in a ${budget} px column (${size} px ${JSON.stringify(run)})`); if (out === t) continue; // Otherwise: a prefix of the text and the ellipsis... assert.ok(out.endsWith("…")); const kept = out.slice(0, -1); assert.ok(t.startsWith(kept), `${JSON.stringify(out)} is not a prefix of ${JSON.stringify(t)}`); // ...and the LONGEST that fits: one more character would not. const next = [...t][[...kept].length]; if (next && next.trim()) { assert.ok(textWidth(`${kept}${next}…`, size, PLEX, run) > budget, `${JSON.stringify(out)} could keep ${JSON.stringify(next)}`); } } } }); test("IBM Plex Sans: measured, not counted -- the head Fira's count would overrun, and the letters it would cut", () => { const head = { weight: "bold", ls: 0.6 }; // The scroll's column head, bold and letter-spaced in 140 px. Fira's count // keeps 21 characters, which set in Plex Sans Bold are 175 px. const byCount = fit("THE QUARTERING · MEDIA", 13, 140); assert.ok(textWidth(byCount, 13, PLEX, head) > 170); assert.equal(fit("THE QUARTERING · MEDIA", 13, 140, PLEX, head), "THE QUARTERING…"); assert.equal(fit("W".repeat(25), 13, 140, PLEX, head), `${"W".repeat(9)}…`); // Narrow letters: the count cuts at 21, the measure keeps what fits. const narrow = "illicit little lilies fill it, still ill"; assert.equal(fit(narrow, 13, 140), "illicit little lilie…"); assert.equal(fit(narrow, 13, 140, PLEX), "illicit little lilies fill it, s…"); // Bold is its own table, and letter-spacing is paid per character. assert.ok(textWidth("employees", 13, PLEX, { weight: "bold" }) > textWidth("employees", 13, PLEX)); assert.equal(textWidth("ab", 10, PLEX, { ls: 2 }), textWidth("ab", 10, PLEX) + 4); }); test("wrapPx: Fira wraps by count as it did; Plex by width, and no line overruns", () => { const q = "“I built an entire fulfillment center, we have like six employees now”"; assert.deepEqual(wrapPx(q, 25, 838, 2), [ "“I built an entire fulfillment center, we have like six employees", "now”", ]); // 765 px of Plex Sans in an 838 px column: one line, no stranded word. assert.deepEqual(wrapPx(q, 25, 838, 2, PLEX), [q]); for (const t of TEXTS) { for (const [size, budget] of [[25, 838], [25, 300], [13, 120]]) { const lines = wrapPx(t, size, budget, 2, PLEX); assert.ok(lines.length <= 2); for (const l of lines) assert.ok(textWidth(l, size, PLEX) <= budget + 1e-9, `${JSON.stringify(l)} overruns ${budget}`); } } // Elided on the last line when the text runs on. const long = wrapPx(TEXTS[1].repeat(3), 25, 400, 2, PLEX); assert.equal(long.length, 2); assert.ok(long[1].endsWith("…")); }); test("textWidth: the table's advances, the fallback for what the face does not map", () => { assert.equal(textWidth("a", 1000, PLEX), 534); assert.equal(textWidth("a", 1000, PLEX, { weight: "bold" }), 569); assert.equal(textWidth("a", 1000, PLEX, { weight: 700 }), 569); assert.equal(textWidth("中", 10, PLEX), 10 * FALLBACK_EM); // An astral character is one character, not two UTF-16 halves. assert.equal(textWidth("🙂", 10, PLEX), 10 * FALLBACK_EM); // An average face counts. assert.equal(textWidth("abcd", 10, FIRA_SANS), 20); assert.equal(textWidth("abcd", 10, FIRA_SANS, { ls: 1 }), 24); }); test("the table is the vendored font's: same file, every mapped character, both weights", () => { const font = readFileSync(path.join(HERE, "fonts", IBM_PLEX_SANS_METRICS.font)); assert.equal(createHash("sha256").update(font).digest("hex"), IBM_PLEX_SANS_METRICS.sha256); const n = IBM_PLEX_SANS_METRICS.ranges.reduce((a, [f, l]) => a + l - f + 1, 0); assert.equal(n, 891); assert.equal(IBM_PLEX_SANS_METRICS.advances[400].length, n); assert.equal(IBM_PLEX_SANS_METRICS.advances[700].length, n); assert.equal(PLEX.advances.regular.size, n); assert.equal(PLEX.family, "IBM Plex Sans"); assert.equal(FIRA_SANS.family, "Fira Sans"); }); // The generator needs python3 + fontTools, and the instancer's rounding is // fontTools's own: under another version the parity check says so and skips. const py = spawnSync("python3", ["-c", "import fontTools; print(fontTools.version)"], { encoding: "utf8" }); const pyFontTools = py.status === 0 ? py.stdout.trim() : null; const paritySkip = !pyFontTools ? "python3 with fontTools is not available" : pyFontTools !== IBM_PLEX_SANS_METRICS.fontTools ? `fontTools ${pyFontTools} is installed; face-metrics.mjs was generated with ${IBM_PLEX_SANS_METRICS.fontTools}` : false; test("face-metrics.mjs is what gen-face-metrics.py writes today", { skip: paritySkip }, () => { const r = spawnSync("python3", [path.join(HERE, "fonts", "gen-face-metrics.py"), "--stdout"], { encoding: "utf8", maxBuffer: 1 << 24, }); assert.equal(r.status, 0, r.stderr); assert.equal(r.stdout, readFileSync(path.join(HERE, "face-metrics.mjs"), "utf8")); });