Files
Voiced/src/pdf/templates/neutral/neutral.test.tsx
T
xavierk ede8eeda90 Replace the sample data with synthetic fixtures and regenerate goldens and thumbnails
The template harness, tests and thumbnails now use a neutral vendor, bank account and
a generated sample wordmark (scripts/logo/make-sample-logo.mjs). Layout goldens and the
14 picker thumbnails are regenerated. Fixture strings keep their original lengths where
layout tests depend on them.
2026-10-05 10:18:53 +05:30

237 lines
12 KiB
TypeScript

import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { PAGE_SIZES } from "../../engine/geometry";
import { CLOSING_ID, TOTALS_ID } from "../../engine/ids";
import { walk, type LayoutNode } from "../../engine/layoutTree";
import { frameForPrefs } from "../../render/invoice";
import { marginsFor } from "../contract";
import { TEMPLATE_CATALOG, catalogEntry } from "../catalog";
import { buildFixture, ensureSetup, FIXTURES, prefsFor, renderFixture } from "../harness";
import { DEFAULT_TEMPLATE_ID, TEMPLATES, getTemplate } from "../registry";
import { classic } from "../classic";
import { elastic, geometry, initialsOf, planNeutral, rateCell } from "./plan";
import { linea, LINEA_VARIANT } from "./linea";
import { monogram, MONOGRAM_VARIANT } from "./monogram";
import { serenity, SERENITY_VARIANT } from "./serenity";
import { getMeasurer } from "../../engine/measure";
ensureSetup();
const NEUTRAL = [linea, monogram, serenity];
const VARIANTS = { linea: LINEA_VARIANT, monogram: MONOGRAM_VARIANT, serenity: SERENITY_VARIANT } as const;
const nodes = (layout: LayoutNode[]) => {
const out: LayoutNode[] = [];
walk(layout, (n) => out.push(n));
return out;
};
describe("registry and catalog", () => {
it("lists Classic first and Linea second, then the Neutral rest, the Ledger and the Band families", () => {
expect(Object.keys(TEMPLATES)).toEqual(["classic", "linea", "monogram", "serenity", "crimson-grid", "tangerine-ledger", "slate-band", "teal-swoosh", "purple-pop", "citrus-split", "monolith", "cobalt-stripe", "marble", "highlighter"]);
expect(TEMPLATE_CATALOG.map((e) => e.id)).toEqual(Object.keys(TEMPLATES));
});
it("falls back to Classic for an unknown, missing or prototype-key id", () => {
for (const id of ["no-such-template", undefined, "", "constructor", "__proto__", "toString"]) expect(getTemplate(id), String(id)).toBe(classic);
expect(getTemplate("serenity")).toBe(serenity);
expect(DEFAULT_TEMPLATE_ID).toBe("classic");
expect(catalogEntry("nope").id).toBe("classic");
});
it("the catalog and the definitions agree, and every thumbnail is on disk", () => {
for (const entry of TEMPLATE_CATALOG) {
const def = TEMPLATES[entry.id];
expect(def.name).toBe(entry.name);
expect(def.family).toBe(entry.family);
expect(def.thumbnail).toBe(entry.thumbnail);
expect(def.badge).toBe(entry.badge);
expect(existsSync(path.join(process.cwd(), "public", entry.thumbnail)), entry.thumbnail).toBe(true);
}
});
it("only Linea carries the badge and the opt-in logo", () => {
expect(TEMPLATE_CATALOG.filter((e) => e.badge).map((e) => e.id)).toEqual(["linea"]);
expect(linea.badge).toBe("Matches your original format");
expect(linea.logo.optIn).toBe(true);
expect(linea.logo.defaultOn).toBe(false);
expect(monogram.logo.defaultOn && serenity.logo.defaultOn).toBe(true);
expect(monogram.logo.slot?.mark).toBeTruthy();
expect(serenity.logo.slot?.mark).toBeTruthy();
});
it("every Neutral template declares its own role table in a bundled face", () => {
expect(linea.roles.body.face).toBe("Montserrat");
expect(monogram.roles.body.face).toBe("Open Sans");
expect(serenity.roles.body.face).toBe("Open Sans");
expect(serenity.roles).not.toBe(monogram.roles);
for (const t of NEUTRAL) for (const [name, token] of Object.entries(t.roles)) expect(token.size, `${t.id}.${name}`).toBeGreaterThan(0);
});
});
describe("page geometry", () => {
it("template margins and floors", () => {
expect(marginsFor(linea, "template")).toEqual({ top: 56, right: 59.55, bottom: 60, left: 59.55 });
expect(marginsFor(monogram, "template")).toEqual({ top: 58, right: 69.64, bottom: 60, left: 69.64 });
expect(marginsFor(serenity, "template")).toEqual({ top: 56, right: 64.1, bottom: 60, left: 162.91 });
// Serenity's left margin never goes under the decorative band, whatever the preset.
expect(marginsFor(serenity, "narrow").left).toBe(162.91);
expect(marginsFor(serenity, "normal").left).toBe(162.91);
// Narrow and Normal are absolute presets, only lifted to the floors (36 pt here).
expect(marginsFor(linea, "narrow")).toEqual({ top: 36, right: 36, bottom: 36, left: 36 });
expect(marginsFor(monogram, "normal")).toEqual({ top: 54, right: 54, bottom: 54, left: 54 });
});
it("the rules sit where the Canva ones do on A4 (x 69.64-525.86 for Linea and Monogram, 162.91-531.39 for Serenity)", () => {
for (const [t, left, right] of [
[linea, 69.64, 525.86],
[monogram, 69.64, 525.86],
[serenity, 162.91, 531.39],
] as const) {
const frame = frameForPrefs(prefsFor("A4", { templateId: t.id }));
const v = VARIANTS[t.id as keyof typeof VARIANTS];
const g = geometry(v, frame);
const x0 = frame.pad.left + v.ruleInset;
// Canva's page is 0.22 pt wider than A4, so the right end is allowed that much.
expect(Math.abs(x0 - left), t.id).toBeLessThanOrEqual(0.3);
expect(Math.abs(x0 + g.ruleW - right), t.id).toBeLessThanOrEqual(0.3);
expect(frame.pad.bottom - frame.footerReserve).toBe(60);
}
});
it("Letter keeps the physical margins and gives the extra width to the content", () => {
for (const t of NEUTRAL) {
const a4 = frameForPrefs(prefsFor("A4", { templateId: t.id }));
const letter = frameForPrefs(prefsFor("LETTER", { templateId: t.id }));
expect(letter.pad).toEqual(a4.pad);
expect(letter.contentWidth - a4.contentWidth).toBeCloseTo(PAGE_SIZES.LETTER.width - PAGE_SIZES.A4.width, 2);
expect(letter.heightDeficit).toBeCloseTo(49.89, 2);
}
});
it("elastic gaps give back exactly the deficit and never pass their minimum", () => {
const ranges = { a: { nominal: 70, min: 34 }, b: { nominal: 40, min: 20 }, c: { nominal: 50, min: 24 } };
expect(elastic(ranges, 0)).toEqual({ a: 70, b: 40, c: 50 });
const e = elastic(ranges, 49.89);
const given = 160 - (e.a + e.b + e.c);
expect(given).toBeCloseTo(49.89, 1);
expect(e.a).toBeGreaterThanOrEqual(34);
expect(elastic(ranges, 1000)).toEqual({ a: 34, b: 20, c: 24 });
});
});
describe("helpers", () => {
it("initialsOf takes the first and last word", () => {
expect(initialsOf("Test Vendor")).toBe("TV");
expect(initialsOf("Venkatachalam Longsurname Ramasubramanian Voice Studios Pvt")).toBe("VP");
expect(initialsOf("Bongbetic")).toBe("B");
expect(initialsOf(" ")).toBe("");
expect(initialsOf("& Co")).toBe("C");
});
it("rateCell keeps '₹1,200.00/hr' on one line when it fits and splits the unit otherwise", () => {
const body = linea.roles.tableBody;
expect(rateCell("₹1,200.00 per hour", 90, body)).toBe("₹1,200.00/hr");
expect(rateCell("₹1,200.00 per session", 60, body)).toEqual(["₹1,200.00", "per session"]);
expect(rateCell(null, 90, body)).toBe("—");
expect(getMeasurer()).toBeTruthy();
});
});
describe("what the layout does with a logo", () => {
const withLogo = { name: "logo", items: 2, logo: "real" as const };
const imageCount = (r: Awaited<ReturnType<typeof renderFixture>>) => nodes(r.result.core.layout[0].nodes).filter((n) => n.type === "image" && !n.decor).length;
it("Monogram and Serenity draw the logo inside the slot; with no logo nothing, or the initials", async () => {
for (const t of [monogram, serenity]) {
const r = await renderFixture(withLogo, prefsFor("A4", { templateId: t.id }));
expect(imageCount(r), t.id).toBe(1);
const img = nodes(r.result.core.layout[0].nodes).find((n) => n.type === "image")!;
expect(img.box.x + img.box.w, t.id).toBeLessThanOrEqual(frameForPrefs(r.prefs).page.width - frameForPrefs(r.prefs).pad.right + 0.5);
const none = await renderFixture({ ...withLogo, logo: "none" }, prefsFor("A4", { templateId: t.id }));
expect(imageCount(none), t.id).toBe(0);
const text = none.pdf.pages[0].items.map((i) => i.str).join(" ");
if (t.id === "monogram") expect(text).toContain("TV");
// No Canva ring or stamp is ever drawn.
expect(nodes(none.result.core.layout[0].nodes).filter((n) => n.type === "svg" && !n.decor), t.id).toHaveLength(0);
}
});
it("Linea is logo-free until the user turns the logo on", async () => {
const off = await renderFixture(withLogo, prefsFor("A4", { templateId: "linea" }));
expect(imageCount(off)).toBe(0);
const on = await renderFixture(withLogo, prefsFor("A4", { templateId: "linea", logo: { show: true } }));
expect(imageCount(on)).toBe(1);
const img = nodes(on.result.core.layout[0].nodes).find((n) => n.type === "image")!;
expect(img.box.h).toBeLessThanOrEqual(48 + 0.01);
expect(img.box.w).toBeLessThanOrEqual(150 + 0.01);
expect(img.box.x).toBeCloseTo(59.55, 1);
});
it("a logo wide as a wordmark stays inside its box in Monogram, a tall one too", async () => {
for (const logo of ["wide", "square"] as const) {
const r = await renderFixture({ ...withLogo, logo }, prefsFor("A4", { templateId: "monogram" }));
const img = nodes(r.result.core.layout[0].nodes).find((n) => n.type === "image")!;
expect(img.box.w).toBeLessThanOrEqual(142.75 + 0.01);
expect(img.box.h).toBeLessThanOrEqual(116.5 + 0.01);
}
});
});
describe("the fonts the templates ask for are the ones embedded", () => {
it("Linea embeds Montserrat, Monogram and Serenity embed Open Sans, and none falls back to Helvetica", async () => {
for (const [t, family] of [
[linea, "Montserrat"],
[monogram, "OpenSans"],
[serenity, "OpenSans"],
] as const) {
const r = await renderFixture({ name: "fonts", items: 2, logo: "none" }, prefsFor("A4", { templateId: t.id }));
expect(r.pdf.raw, t.id).toContain(family);
expect(r.pdf.raw, t.id).not.toContain("/BaseFont /Helvetica");
}
});
});
describe("plan matches the laid-out blocks", () => {
// The totals tail and the closing block carry minPresenceAhead values predicted from the measurer;
// too low and a block can start a page without its spacer.
for (const t of NEUTRAL) {
for (const size of ["A4", "LETTER"] as const) {
for (const spec of FIXTURES) {
it(`${t.id}/${spec.name} on ${size}`, async () => {
const prefs = prefsFor(size, { templateId: t.id });
const { model } = buildFixture(spec);
const frame = frameForPrefs(prefs);
const r = await renderFixture(spec, prefs);
const logo = null;
void logo;
const find = (id: string) => {
const hits: number[] = [];
for (const page of r.result.core.layout) walk(page.nodes, (n) => n.id === id && hits.push(n.box.h));
expect(hits).toHaveLength(1);
return hits[0];
};
// The same plan the layout used: logo-less here, so it only differs in the header (not asserted).
const plan = planNeutral(model, frame, VARIANTS[t.id as keyof typeof VARIANTS], null);
const tailGap = plan.tailH - find(TOTALS_ID);
expect(tailGap, "tail prediction vs layout").toBeGreaterThanOrEqual(-0.01);
// Wrap is predicted greedily and can only err by whole lines, on the safe side.
expect(tailGap).toBeLessThanOrEqual(15);
const closingGap = plan.closingH - find(CLOSING_ID);
expect(closingGap, "closing prediction vs layout").toBeGreaterThanOrEqual(-0.01);
expect(closingGap).toBeLessThanOrEqual(15);
});
}
}
}
});
describe("design docs", () => {
it("each Neutral doc records its implementation notes", () => {
for (const f of ["04-linea.md", "05-monogram.md", "06-serenity.md"]) {
const text = readFileSync(path.join(process.cwd(), "docs/templates", f), "utf8");
expect(text, f).toContain("## Implementation notes");
}
});
});