Add the Monolith and Cobalt Stripe templates

Standalone layouts for reference templates 3 and 8, with the user's logo sized to the
placeholder box, a CapRule decor primitive, optional TableRows rule/heading options,
and a separate 1..45-row sweep test file.
This commit is contained in:
2026-10-04 11:25:19 +05:30
parent 4cea3fdd8e
commit dd85c3b3c5
26 changed files with 2795 additions and 37 deletions
+414
View File
@@ -0,0 +1,414 @@
import { existsSync, readdirSync, readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { CLOSING_ID, TABLE_HEAD_ID, TABLE_ROW_PREFIX, TOTALS_ID } from "../../engine/ids";
import { walk, type LayoutNode } from "../../engine/layoutTree";
import { SLOT_ID } from "../../blocks/slots";
import { frameForPrefs, renderInvoicePdf } from "../../render/invoice";
import { loadCanvas, pagePng } from "../../testing/rasterize";
import { catalogEntry, TEMPLATE_CATALOG } from "../catalog";
import { marginsFor } from "../contract";
import { META_ID, SUPPLIER_ID, TITLE_ID } from "../classic/ids";
import { buildFixture, ensureSetup, FIXTURES, prefsFor, renderFixture, type FixtureSpec } from "../harness";
import { getTemplate, TEMPLATES } from "../registry";
import { SLOTS } from "../slots.generated";
import { cobaltStripe } from "./cobalt";
import { planCobalt } from "./plan";
import { COBALT, COBALT_ROLES, COBALT_VARIANT, SKY, ZEBRA } from "./tokens";
ensureSetup();
const V = COBALT_VARIANT;
const slot = SLOTS["cobalt-stripe"]!;
const nodes = (layout: { nodes: LayoutNode[] }[]) => {
const out: LayoutNode[] = [];
for (const p of layout) walk(p.nodes, (n) => out.push(n));
return out;
};
type R = Awaited<ReturnType<typeof renderFixture>>;
const onPage = (r: R, i: number) => nodes([r.result.core.layout[i]]);
const imageBoxes = (r: R) => nodes(r.result.core.layout).filter((n) => n.type === "image" && !n.decor);
const byId = (r: R, id: string, page = 0) => onPage(r, page).find((n) => n.id === id)!;
const textLines = (root: LayoutNode) => {
const out: Array<{ text: string; x: number; right: number; y: number }> = [];
walk([root], (n) => {
if (n.type === "text") for (const l of n.lines ?? []) if (l.text.trim()) out.push({ text: l.text.trim(), x: l.x, right: l.x + l.w - (n.letterSpacing ?? 0), y: l.y });
});
return out;
};
const contentRight = (r: R) => {
const f = frameForPrefs(r.prefs);
return f.page.width - f.pad.right;
};
/** The reference sample's shape: no due date, no logo, nothing the design has no slot for. */
const NAMES = ["Logo Design", "Website Design", "Brand Design", "Banner Design", "Flyer Design", "Social Media Template", "Name Card", "Web Developer"];
const PLAIN: FixtureSpec = {
name: "plain",
items: NAMES.map((description, i) => ({ id: i + 1, description, mode: "rate" as const, rate: 100, unit: "hour" as const, quantity: 1, amount: 0, sortOrder: i })),
registered: false,
logo: "none",
invoice: { dueDate: "", notes: "" },
};
/** RGB of pixels of page 1 at 4x. */
async function colourAt(bytes: Uint8Array, points: Array<[number, number]>): Promise<number[][]> {
const lib = await loadCanvas();
if (!lib) return [];
const png = await pagePng(lib, bytes, 1, 4);
const img = await lib.loadImage(png);
const c = lib.createCanvas(img.width, img.height);
const ctx = c.getContext("2d");
ctx.drawImage(img, 0, 0);
return points.map(([x, y]) => Array.from(ctx.getImageData(Math.round(x * 4), Math.round(y * 4), 1, 1).data).slice(0, 3));
}
const near = (c: number[], hex: string, tol = 8) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)).every((w, i) => Math.abs(c[i] - w) <= tol);
describe("registry, catalog and fonts", () => {
it("is registered last, as a standalone template with its thumbnail on disk", () => {
expect(getTemplate("cobalt-stripe")).toBe(cobaltStripe);
expect(cobaltStripe.family).toBe("standalone");
expect(catalogEntry("cobalt-stripe").name).toBe("Cobalt Stripe");
expect(existsSync(path.join(process.cwd(), "public", cobaltStripe.thumbnail)), cobaltStripe.thumbnail).toBe(true);
expect(Object.keys(TEMPLATES).at(-1)).toBe("cobalt-stripe");
expect(TEMPLATE_CATALOG.map((e) => e.id)).toEqual(Object.keys(TEMPLATES));
});
it("uses Poppins 400/700 and Open Sans 400/700 only, and the design's palette only", () => {
for (const [name, token] of Object.entries(COBALT_ROLES)) {
expect(["Poppins", "Open Sans"], name).toContain(token.face);
expect([400, 700], name).toContain(token.weight);
}
const palette = new Set([COBALT, ZEBRA, SKY, "#1B1B1B", "#595A5A", "#A6A6A6", "#FFFFFF", "#000000"].map((c) => c.toLowerCase()));
const dir = path.join(process.cwd(), "src/pdf/templates/cobalt");
for (const f of readdirSync(dir).filter((x) => /\.(ts|tsx)$/.test(x) && !/\.test\./.test(x))) {
const colours = readFileSync(path.join(dir, f), "utf8").match(/#[0-9a-fA-F]{6}\b/g) ?? [];
expect(colours.filter((c) => !palette.has(c.toLowerCase())), f).toEqual([]);
}
});
});
describe("page geometry", () => {
it("template margins: the logo's top is the page margin, the footer's bottom sits 59.5 pt above the page foot", () => {
expect(marginsFor(cobaltStripe, "template")).toEqual({ top: 54.2, right: 59.55, bottom: 35.5, left: 59.55 });
const frame = frameForPrefs(prefsFor("A4", { templateId: "cobalt-stripe" }));
expect(frame.pad.bottom).toBeCloseTo(59.5, 1);
expect(frame.contentWidth).toBeCloseTo(476.18, 1);
});
it("Letter keeps the physical margins and gives the extra width to the description", async () => {
const a4 = frameForPrefs(prefsFor("A4", { templateId: "cobalt-stripe" }));
const letter = frameForPrefs(prefsFor("LETTER", { templateId: "cobalt-stripe" }));
expect(letter.pad).toEqual(a4.pad);
expect(letter.heightDeficit).toBeCloseTo(49.89, 2);
const model = buildFixture(PLAIN).model;
const pa = planCobalt(model, a4, V, null, false);
const pl = planCobalt(model, letter, V, null, false);
expect(pl.cw).toEqual(pa.cw);
expect(pl.table.widths[1] - pa.table.widths[1]).toBeCloseTo(letter.contentWidth - a4.contentWidth, 1);
});
});
describe("header", () => {
it("matches the design: name, INVOICE and the rule text at the reference baselines, INVOICE and the rule text ending on the right edge", async () => {
const r = await renderFixture(PLAIN, prefsFor("A4", { templateId: "cobalt-stripe" }));
const items = r.pdf.pages[0].items;
const right = contentRight(r);
const name = items.find((i) => i.str.trim() === "Test Vendor" && i.bottom < 130)!;
const title = items.find((i) => i.str.trim() === "INVOICE")!;
const pan = items.find((i) => i.str.startsWith("PAN"))!;
expect(name.bottom).toBeCloseTo(74.6, 0);
expect(name.x0).toBeCloseTo(59.5, 0);
expect(title.bottom).toBeCloseTo(87.3, 0);
expect(title.x0).toBeCloseTo(395.6, 0);
expect(pan.bottom).toBeCloseTo(117.1, 0);
expect(pan.x1).toBeCloseTo(right, 0);
// INVOICE is tracked: its ink ends on the edge (the trailing letter-spacing is cancelled by Tracked).
expect(title.x1).toBeLessThanOrEqual(right + 1.5);
expect(title.x1).toBeGreaterThan(right - 3);
});
it("the GSTIN is the tagline under the name and the PAN is the rule text; the reference script signature is not reproduced", async () => {
const r = await renderFixture({ name: "t", items: 3, logo: "none", signature: true }, prefsFor("A4", { templateId: "cobalt-stripe" }));
const supplier = textLines(byId(r, SUPPLIER_ID)).map((l) => l.text);
expect(supplier).toEqual(expect.arrayContaining(["Test Vendor", "GSTIN: 29AAAAA0000A1Z5"]));
expect(r.pdf.pages[0].items.some((i) => i.str.startsWith("PAN: ABCDE1234F"))).toBe(true);
expect(r.pdf.raw).not.toMatch(/BDScript|BD Script/i);
});
it("TAX INVOICE takes the room it needs and the left block shrinks; a long invoice number never meets the title", async () => {
for (const size of ["A4", "LETTER"] as const) {
const r = await renderFixture({ name: "t", items: 3, logo: "none", invoice: { number: "AB/2026-27/0012345" } }, prefsFor(size, { templateId: "cobalt-stripe" }));
expect(r.result.issues.filter((i) => i.severity === "error")).toEqual([]);
const meta = textLines(byId(r, META_ID));
expect(Math.max(...meta.map((l) => l.right))).toBeLessThanOrEqual(contentRight(r) + 0.5);
const supplier = byId(r, SUPPLIER_ID);
const title = byId(r, TITLE_ID);
expect(supplier.box.x + supplier.box.w).toBeLessThanOrEqual(title.box.x + 0.5);
}
});
it("the grey rule has its sky accent at the left and sits under the header at the design's y (113.8)", async () => {
const r = await renderFixture(PLAIN, prefsFor("A4", { templateId: "cobalt-stripe" }));
const frame = frameForPrefs(r.prefs);
const svgs = onPage(r, 0).filter((n) => n.type === "svg" && !n.decor);
const rule = svgs.find((n) => n.box.h === V.head.rule.accent && n.box.w > 300)!;
expect(rule.box.y + rule.box.h / 2).toBeCloseTo(113.8, 0);
const [accent, grey] = await colourAt(r.result.bytes, [
[rule.box.x + 20, rule.box.y + rule.box.h / 2],
[rule.box.x + 120, rule.box.y + rule.box.h / 2],
]);
expect(near(accent, SKY), `accent ${accent}`).toBe(true);
expect(near(grey, "#A6A6A6"), `grey ${grey}`).toBe(true);
expect(rule.box.x).toBeCloseTo(frame.pad.left, 1);
});
});
describe("logo", () => {
it("a wordmark fills the measured lockup box from its left edge and replaces the name; the tagline stays under it", async () => {
const lockup = slot.lockup!;
for (const size of ["A4", "LETTER"] as const) {
const r = await renderFixture({ name: "o", items: 2, logo: "real" }, prefsFor(size, { templateId: "cobalt-stripe" }));
const [img] = imageBoxes(r);
expect(imageBoxes(r)).toHaveLength(1);
expect(img.box.h).toBeLessThanOrEqual(lockup.h + 0.6);
expect(img.box.w).toBeLessThanOrEqual(lockup.w + 0.6);
expect(img.box.x).toBeCloseTo(frameForPrefs(r.prefs).pad.left, 1);
expect(r.pdf.pages[0].items.filter((i) => i.str.trim() === "Test Vendor" && i.bottom < 130)).toHaveLength(0);
const tag = r.pdf.pages[0].items.find((i) => i.str.startsWith("GSTIN: 29AAAAA"))!;
expect(tag.top).toBeGreaterThanOrEqual(img.box.y + img.box.h - 0.5);
}
});
it("a mark keeps to the measured mark box and keeps the name beside it; with no logo the name is Poppins Bold at the design's baseline", async () => {
const mark = slot.mark!;
const square = await renderFixture({ name: "o", items: 2, logo: "square" }, prefsFor("A4", { templateId: "cobalt-stripe" }));
const [img] = imageBoxes(square);
expect(img.box.h).toBeLessThanOrEqual(mark.h + 0.6);
expect(img.box.w).toBeLessThanOrEqual(mark.w + 0.6);
const name = square.pdf.pages[0].items.find((i) => i.str.trim() === "Test Vendor" && i.bottom < 130)!;
expect(name.x0).toBeGreaterThanOrEqual(img.box.x + img.box.w + V.head.markGap - 0.6);
const none = await renderFixture({ name: "o", items: 2, logo: "none" }, prefsFor("A4", { templateId: "cobalt-stripe" }));
expect(imageBoxes(none)).toHaveLength(0);
expect(none.pdf.pages[0].items.find((i) => i.str.trim() === "Test Vendor" && i.bottom < 130)!.bottom).toBeCloseTo(74.6, 0);
});
it("a tall logo is held to the lockup height", async () => {
const { model } = buildFixture({ name: "tall", items: 2, logo: "square" });
model.logo = { ...model.logo!, aspect: 0.5, width: 60, height: 120, kind: "tall", includesName: false };
const result = await renderInvoicePdf({ model, prefs: prefsFor("A4", { templateId: "cobalt-stripe" }) });
expect(result.issues.filter((i) => i.severity === "error")).toEqual([]);
const img = nodes(result.core.layout).find((n) => n.type === "image" && !n.decor)!;
expect(img.box.h).toBeLessThanOrEqual(slot.mark!.h + 0.6);
});
});
describe("table", () => {
it("is a cobalt bar over zebra rows: 20.76 pt header, 17.82 pt rows, white and light-blue alternating, 1.5 pt light-blue separators", async () => {
const r = await renderFixture(PLAIN, prefsFor("A4", { templateId: "cobalt-stripe" }));
const frame = frameForPrefs(r.prefs);
const head = byId(r, TABLE_HEAD_ID);
expect(head.box.h).toBeCloseTo(V.table.headH, 0);
const rows = [0, 1, 2, 3].map((i) => byId(r, `${TABLE_ROW_PREFIX}${i}`));
for (const row of rows) expect(row.box.h).toBeCloseTo(V.table.rowH, 0);
const plan = planCobalt(r.fixture.model, frame, V, null, false);
const left1 = frame.pad.left + plan.cw.no;
const colours = await colourAt(r.result.bytes, [
[head.box.x + 1.5, head.box.y + 10],
[rows[0].box.x + 1.5, rows[0].box.y + 8],
[rows[1].box.x + 1.5, rows[1].box.y + 8],
[left1 - 0.75, rows[0].box.y + 8],
[left1 - 0.75, head.box.y + 10],
[left1 - 0.75, rows[1].box.y + 8],
]);
if (colours.length) {
expect(near(colours[0], COBALT), `header ${colours[0]}`).toBe(true);
expect(near(colours[1], "#FFFFFF"), `row 1 ${colours[1]}`).toBe(true);
expect(near(colours[2], ZEBRA), `row 2 ${colours[2]}`).toBe(true);
expect(near(colours[3], ZEBRA), `separator on a white row ${colours[3]}`).toBe(true);
expect(near(colours[4], COBALT), `no separator in the header ${colours[4]}`).toBe(true);
}
});
it("columns follow the design: NO 24.9, QTY 84.9, PRICE 105.4, TOTAL 89.3 pt; amounts end 3.1 pt inside the edge, prices 31.7 pt inside their column", async () => {
const r = await renderFixture(PLAIN, prefsFor("A4", { templateId: "cobalt-stripe" }));
const frame = frameForPrefs(r.prefs);
const plan = planCobalt(r.fixture.model, frame, V, null, false);
expect(plan.cw).toMatchObject({ no: 24.9, qty: 84.9, price: 105.4, amount: 89.3 });
const right = contentRight(r);
const priceRight = right - plan.cw.amount - V.table.priceInset;
// The headings follow the design: DESCRIPTION is centred over its left-aligned column, PRICE ends where the prices do.
const heads = r.pdf.pages[0].items;
const desc = heads.find((i) => i.str.trim() === "DESCRIPTION")!;
const descLeft = frame.pad.left + plan.cw.no;
expect((desc.x0 + desc.x1) / 2).toBeCloseTo(descLeft + plan.table.widths[1] / 2, 0);
expect(heads.find((i) => i.str.trim() === "PRICE")!.x1).toBeCloseTo(priceRight, 0);
for (let i = 0; i < 3; i++) {
const lines = textLines(byId(r, `${TABLE_ROW_PREFIX}${i}`));
const amount = lines.find((l) => /^₹[\d,]+\.\d\d$/.test(l.text) && l.right > right - 20)!;
expect(amount.right, `row ${i}`).toBeCloseTo(right - V.table.amountInset, 0);
const price = lines.find((l) => l.text.endsWith("/hr"))!;
expect(price.right, `row ${i} price`).toBeCloseTo(priceRight, 0);
}
});
});
describe("totals", () => {
it("Sub Total rows end 3.1 pt inside the edge, labels end 95.9 pt into the 214.5 pt column; the GRAND TOTAL and PAYMENT METHOD bars share a top", async () => {
const r = await renderFixture({ name: "t", items: 3, logo: "none" }, prefsFor("A4", { templateId: "cobalt-stripe" }));
const frame = frameForPrefs(r.prefs);
const totals = byId(r, TOTALS_ID);
const lines = textLines(totals);
const right = contentRight(r);
const colLeft = right - V.totals.rightW;
const sub = lines.find((l) => l.text === "Sub Total :")!;
expect(sub.right).toBeCloseTo(colLeft + V.totals.labelW, 0);
expect(lines.find((l) => l.text === r.fixture.model.totals.subtotalText)!.right).toBeCloseTo(right - V.totals.valuePad, 0);
const bars = onPage(r, 0).filter((n) => n.type === "view" && Math.abs(n.box.h - V.totals.barH) < 0.5 && n.box.y >= totals.box.y);
// The Sub Total rows are as tall and wide as the bar, so the bar is the lowest of them.
const grand = bars.filter((n) => Math.abs(n.box.w - V.totals.rightW) < 0.5).sort((a, b) => b.box.y - a.box.y)[0];
const pay = bars.find((n) => Math.abs(n.box.w - V.totals.leftBarW) < 0.5)!;
expect(grand.box.x).toBeCloseTo(colLeft, 1);
expect(pay.box.x).toBeCloseTo(frame.pad.left, 1);
expect(pay.box.y).toBeCloseTo(grand.box.y, 0);
const [g, p] = await colourAt(r.result.bytes, [
[grand.box.x + 3, grand.box.y + 2],
[pay.box.x + 3, pay.box.y + 2],
]);
if (g) {
expect(near(g, COBALT)).toBe(true);
expect(near(p, COBALT)).toBe(true);
}
});
it("amount in words sits right-aligned under the GRAND TOTAL bar", async () => {
const r = await renderFixture({ name: "t", items: 3, logo: "none" }, prefsFor("A4", { templateId: "cobalt-stripe" }));
const words = byId(r, SLOT_ID.amountInWords);
const totals = byId(r, TOTALS_ID);
const grand = onPage(r, 0).filter((n) => n.type === "view" && Math.abs(n.box.h - V.totals.barH) < 0.5 && Math.abs(n.box.w - V.totals.rightW) < 0.5 && n.box.y >= totals.box.y).sort((a, b) => b.box.y - a.box.y)[0];
expect(words.box.y).toBeGreaterThanOrEqual(grand.box.y + grand.box.h);
for (const l of textLines(words)) expect(l.right).toBeCloseTo(contentRight(r), 0);
});
});
describe("closing and footer", () => {
it("ends with the icon footer: a rule with accents at both ends and three contacts, all inside the content box", async () => {
const r = await renderFixture({ name: "c", items: 3, logo: "none", signature: true }, prefsFor("A4", { templateId: "cobalt-stripe" }));
const last = r.result.pages - 1;
const closing = byId(r, CLOSING_ID, last);
const frame = frameForPrefs(r.prefs);
const svgs = onPage(r, last).filter((n) => n.type === "svg" && !n.decor && n.box.y >= closing.box.y - 0.01);
const icons = svgs.filter((n) => n.box.w === V.closing.icon.size);
expect(icons).toHaveLength(3);
const rule = svgs.find((n) => n.box.w > 400 && n.box.h === V.head.rule.accent)!;
expect(rule.box.w).toBeCloseTo(frame.contentWidth, 1);
for (const i of icons) {
expect(i.box.y).toBeGreaterThanOrEqual(rule.box.y + rule.box.h);
expect(i.box.x + i.box.w).toBeLessThanOrEqual(contentRight(r) + 0.5);
}
expect(r.result.pages).toBe(1);
const sample = await colourAt(r.result.bytes, [[rule.box.x + 20, rule.box.y + 1.5], [rule.box.x + 200, rule.box.y + 1.5], [rule.box.x + rule.box.w - 20, rule.box.y + 1.5]]);
if (sample.length) {
expect(near(sample[0], SKY), `left accent ${sample[0]}`).toBe(true);
expect(near(sample[1], "#A6A6A6"), `grey ${sample[1]}`).toBe(true);
expect(near(sample[2], SKY), `right accent ${sample[2]}`).toBe(true);
}
// The closing block ends the page: its bottom is the wrap area's bottom.
expect(closing.box.y + closing.box.h).toBeCloseTo(frame.page.height - frame.pad.bottom, 0);
const text = r.pdf.pages[last].items.map((i) => i.str).join(" ");
expect(text).toContain("9000000000");
expect(text).toContain("vendor@example.com");
});
it("a pasted signature image replaces the script: right-aligned, at most 36 pt tall, with the signatory name and designation under it", async () => {
const r = await renderFixture({ name: "s", items: 2, logo: "none", signature: true }, prefsFor("A4", { templateId: "cobalt-stripe" }));
const sig = byId(r, SLOT_ID.signatory, r.result.pages - 1);
const img = onPage(r, r.result.pages - 1).find((n) => n.type === "image" && n.box.y >= sig.box.y - 0.01 && n.box.y + n.box.h <= sig.box.y + sig.box.h + 0.01)!;
expect(img.box.h).toBeLessThanOrEqual(V.closing.signatureImageH + 0.01);
const text = textLines(sig).map((l) => l.text);
expect(text).toEqual(expect.arrayContaining(["Test Vendor", "Proprietor"]));
// Without an image: the computer-generated note stands in.
const plain = await renderFixture({ name: "s", items: 2, logo: "none", signature: false }, prefsFor("A4", { templateId: "cobalt-stripe" }));
expect(plain.pdf.pages[plain.result.pages - 1].items.map((i) => i.str).join(" ")).toContain("Computer-generated");
});
it("long terms flow as a block of their own and keep the closing block bounded", async () => {
const r = await renderFixture(FIXTURES.find((f) => f.name === "long-notes")!, prefsFor("A4", { templateId: "cobalt-stripe" }));
const plan = planCobalt(r.fixture.model, frameForPrefs(r.prefs), V, null, false);
expect(plan.closing.termsBeside).toBe(false);
expect(byId(r, CLOSING_ID, r.result.pages - 1).box.h).toBeLessThan(300);
expect(r.pdf.pages.map((p) => p.items.map((i) => i.str).join(" ")).join(" ")).toContain("Note 18");
});
it("an address, e-mail and phone that are too long for one row shrink and wrap inside the footer", async () => {
const r = await renderFixture(FIXTURES.find((f) => f.name === "id-tokens")!, prefsFor("A4", { templateId: "cobalt-stripe" }));
expect(r.result.issues.filter((i) => i.severity === "error")).toEqual([]);
const closing = byId(r, CLOSING_ID, r.result.pages - 1);
for (const l of textLines(closing)) expect(l.right).toBeLessThanOrEqual(contentRight(r) + 0.5);
});
});
describe("the GST slots", () => {
it("every GST slot has a home on a tax invoice", async () => {
const r = await renderFixture({ name: "t", items: 2, logo: "none", signature: true }, prefsFor("A4", { templateId: "cobalt-stripe" }));
const ids = new Set(nodes(r.result.core.layout).flatMap((n) => (n.id ? [n.id] : [])));
for (const id of [`${SLOT_ID.gstin}-supplier`, `${SLOT_ID.gstin}-client`, SLOT_ID.pos, SLOT_ID.taxRows, SLOT_ID.amountInWords, SLOT_ID.signatory, SLOT_ID.reverseCharge]) expect(ids.has(id), `#${id}`).toBe(true);
const flat = r.pdf.pages.flatMap((p) => p.items.map((i) => i.str)).join("").replace(/\s+/g, "").toUpperCase();
for (const word of ["SUBTOTAL:", "TAXABLEVALUE:", "CGST9%:", "SGST9%:", "HSN/SAC:998397", "PAN:ABCDE1234F"]) expect(flat, word).toContain(word);
});
it("an unregistered invoice carries none of the GST slots", async () => {
const r = await renderFixture(FIXTURES.find((f) => f.name === "unregistered")!, prefsFor("A4", { templateId: "cobalt-stripe" }));
const ids = new Set(nodes(r.result.core.layout).flatMap((n) => (n.id ? [n.id] : [])));
for (const id of [`${SLOT_ID.gstin}-supplier`, SLOT_ID.pos, SLOT_ID.taxRows, SLOT_ID.reverseCharge]) expect(ids.has(id), `#${id}`).toBe(false);
});
});
describe("long text", () => {
it("long names, addresses and unbroken tokens raise no audit error on either size", async () => {
for (const size of ["A4", "LETTER"] as const) {
for (const name of ["id-tokens", "long-address", "long-client", "long-notes"]) {
const r = await renderFixture(FIXTURES.find((f) => f.name === name)!, prefsFor(size, { templateId: "cobalt-stripe" }));
expect(r.result.issues.filter((i) => i.severity === "error"), `${size}/${name}`).toEqual([]);
}
}
});
it("wrapped description rows grow and keep their zebra colour and separators (no row is taller than its text)", async () => {
const r = await renderFixture(FIXTURES.find((f) => f.name === "multiline")!, prefsFor("A4", { templateId: "cobalt-stripe" }));
const rows = [0, 1, 2].map((i) => byId(r, `${TABLE_ROW_PREFIX}${i}`));
expect(rows[0].box.h).toBeGreaterThan(3 * 13.6);
expect(rows[2].box.h).toBeGreaterThan(rows[1].box.h);
expect(r.result.issues.filter((i) => i.severity === "error")).toEqual([]);
});
});
describe("plan matches the laid-out blocks", () => {
for (const size of ["A4", "LETTER"] as const) {
for (const spec of FIXTURES) {
it(`${spec.name} on ${size}`, async () => {
const prefs = prefsFor(size, { templateId: "cobalt-stripe" });
const frame = frameForPrefs(prefs);
// Logo-less, so the plan made here is the one the layout made.
const r = await renderFixture({ ...spec, logo: "none" }, prefs);
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];
};
const plan = planCobalt(r.fixture.model, frame, V, null, false);
expect(plan.tailH - find(TOTALS_ID), "tail prediction vs layout").toBeGreaterThanOrEqual(-0.01);
expect(plan.tailH - find(TOTALS_ID)).toBeLessThanOrEqual(24);
expect(plan.closingH - find(CLOSING_ID), "closing prediction vs layout").toBeGreaterThanOrEqual(-0.01);
expect(plan.closingH - find(CLOSING_ID)).toBeLessThanOrEqual(32);
});
}
}
});
describe("design docs", () => {
it("records its implementation notes", () => {
const text = readFileSync(path.join(process.cwd(), "docs/templates/08-cobalt-stripe.md"), "utf8");
expect(text).toContain("## Implementation notes");
});
});