Files
Voiced/src/pdf/templates/neutral/neutral.test.tsx
T
xavierk 9de9a6ffee Add Ledger and Band template families with decor primitives
Crimson Grid, Tangerine Ledger, Slate Band and Teal Swoosh are rebuilt from
the reference PDFs with grid and band tables that survive page breaks,
full-bleed fixed bands, pennant tabs, gradient corner swooshes with
keep-outs, Carbon icon circles, and the user's logo in the measured slot
(knockout or white chip on the dark Slate band). Adds static Poppins
500-700 and Inter fonts. Thumbnails regenerated for all eight templates;
the item-count sweeps run in their own test file.
2026-10-04 08:34:18 +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"]);
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 reference 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;
// the reference design'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("AP");
expect(initialsOf("Venkatachalam Longsurname Ramasubramanian Voice Studios Pvt")).toBe("AP");
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("AP");
// No reference 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");
}
});
});