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
@@ -0,0 +1,327 @@
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 { 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 { BRAND_ID, IDS_ID } from "./Layout";
import { monolith } from "./monolith";
import { planMonolith } from "./plan";
import { MONOLITH_ROLES, MONOLITH_VARIANT } from "./tokens";
ensureSetup();
const V = MONOLITH_VARIANT;
const allowance = SLOTS.monolith!.allowance;
const nodes = (layout: { nodes: LayoutNode[] }[]) => {
const out: LayoutNode[] = [];
for (const p of layout) walk(p.nodes, (n) => out.push(n));
return out;
};
const onPage = (r: Awaited<ReturnType<typeof renderFixture>>, i: number) => nodes([r.result.core.layout[i]]);
const imageBoxes = (r: Awaited<ReturnType<typeof renderFixture>>) => nodes(r.result.core.layout).filter((n) => n.type === "image" && !n.decor);
const byId = (r: Awaited<ReturnType<typeof renderFixture>>, id: string, page = 0) => onPage(r, page).find((n) => n.id === id)!;
const textsOf = (r: Awaited<ReturnType<typeof renderFixture>>, page = 0) => r.pdf.pages[page].items.map((i) => i.str).join(" ");
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;
};
/** The reference sample's shape: no due date, so the right-hand stack is the design's three lines. */
const PLAIN: FixtureSpec = { name: "plain", items: 3, registered: false, logo: "none", invoice: { dueDate: "" } };
const contentRight = (r: Awaited<ReturnType<typeof renderFixture>>) => {
const f = frameForPrefs(r.prefs);
return f.page.width - f.pad.right;
};
describe("registry, catalog and fonts", () => {
it("is registered after the Northline family, as a standalone template with its thumbnail on disk", () => {
expect(getTemplate("monolith")).toBe(monolith);
expect(monolith.family).toBe("standalone");
expect(catalogEntry("monolith").name).toBe("Monolith");
expect(existsSync(path.join(process.cwd(), "public", monolith.thumbnail)), monolith.thumbnail).toBe(true);
expect(Object.keys(TEMPLATES).slice(-2)).toEqual(["monolith", "cobalt-stripe"]);
expect(TEMPLATE_CATALOG.map((e) => e.id)).toEqual(Object.keys(TEMPLATES));
});
it("is black Poppins only: every role is Poppins 400/500/700 and the sources carry no colour but #000000", () => {
for (const [name, token] of Object.entries(MONOLITH_ROLES)) {
expect(token.face, name).toBe("Poppins");
expect([400, 500, 700], name).toContain(token.weight);
}
const dir = path.join(process.cwd(), "src/pdf/templates/monolith");
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]{3,8}\b/g) ?? [];
expect(colours.filter((c) => c.toLowerCase() !== "#000000"), f).toEqual([]);
}
expect(monolith.decor.ink).toBe("#000000");
expect(monolith.chrome.color).toBe("#000000");
});
});
describe("page geometry", () => {
it("template margins", () => {
expect(marginsFor(monolith, "template")).toEqual({ top: 33, right: 35, bottom: 22, left: 35 });
expect(marginsFor(monolith, "narrow").left).toBe(36);
});
it("Letter keeps the physical margins and takes the missing height from the gaps", () => {
const a4 = frameForPrefs(prefsFor("A4", { templateId: "monolith" }));
const letter = frameForPrefs(prefsFor("LETTER", { templateId: "monolith" }));
expect(letter.pad).toEqual(a4.pad);
expect(letter.heightDeficit).toBeCloseTo(49.89, 2);
});
});
describe("header", () => {
it("matches the design: title origin 16 pt in, baseline 96 pt; the vendor, date and number lines end on the right edge at the design's pitch", async () => {
const r = await renderFixture(PLAIN, prefsFor("A4", { templateId: "monolith" }));
const items = r.pdf.pages[0].items;
const title = items.find((i) => i.str.trim() === "INVOICE")!;
expect(title.x0).toBeCloseTo(35 + V.titleIndent, 0);
expect(title.bottom).toBeCloseTo(96.2, 0);
const right = contentRight(r);
const brand = items.find((i) => i.str.trim() === "Test Vendor" && i.bottom < 130)!;
const date = items.find((i) => i.str.includes("2026") && i.bottom < 130)!;
const number = items.find((i) => i.str.includes("INVOICE NO."))!;
for (const [what, it, baseline] of [["name", brand, 63.7], ["date", date, 84.7], ["number", number, 105.7]] as const) {
expect(it.bottom, what).toBeCloseTo(baseline, 0);
expect(it.x1, what).toBeCloseTo(right, 0);
}
});
it("TAX INVOICE shrinks to the room the right-hand stack leaves instead of meeting it", 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: "monolith" }));
const plan = planMonolith(r.fixture.model, frameForPrefs(r.prefs), V, null, false);
expect(plan.head.titleSize).toBeLessThan(MONOLITH_ROLES.title.size);
expect(plan.head.titleSize).toBeGreaterThanOrEqual(MONOLITH_ROLES.title.minSize as number);
const title = textLines(byId(r, TITLE_ID));
const stack = textLines(byId(r, META_ID));
const stackLeft = Math.min(...stack.map((l) => l.x));
expect(Math.max(...title.map((l) => l.right)), size).toBeLessThan(stackLeft - 10);
expect(r.result.issues.filter((i) => i.severity === "error")).toEqual([]);
}
});
it("a cancelled invoice is marked in the stack and closes with its sentence", async () => {
const r = await renderFixture(FIXTURES.find((f) => f.name === "cancelled")!, prefsFor("A4", { templateId: "monolith" }));
expect(textLines(byId(r, META_ID)).some((l) => l.text === "CANCELLED")).toBe(true);
expect(textsOf(r, r.result.pages - 1)).toContain("This invoice has been cancelled.");
});
});
describe("logo", () => {
it("is the text-only CAVEIN slot: the allowance is 160 x 28, right-anchored, and the template offers exactly it", () => {
expect(SLOTS.monolith!.textOnly).toBe(true);
expect(allowance).toMatchObject({ w: 160, h: 28 });
expect(monolith.logo.slot).toBe(SLOTS.monolith);
expect(monolith.logo.box).toEqual({ w: allowance.w, h: allowance.h });
expect(monolith.logo.available?.(frameForPrefs(prefsFor("A4", { templateId: "monolith" })))).toEqual({ w: allowance.w, h: allowance.h });
expect(monolith.logo.defaultOn).toBe(true);
});
it("every logo fits the allowance, ends on the right edge and the date and number lines flow BELOW its box", async () => {
for (const size of ["A4", "LETTER"] as const) {
for (const logo of ["real", "wide", "square"] as const) {
const r = await renderFixture({ name: "o", items: 2, logo }, prefsFor(size, { templateId: "monolith" }));
const imgs = imageBoxes(r);
expect(imgs, `${size}/${logo}`).toHaveLength(1);
const [img] = imgs;
expect(img.box.h).toBeLessThanOrEqual(allowance.h + 0.01);
expect(img.box.w).toBeLessThanOrEqual(allowance.w + 0.01);
expect(img.box.x + img.box.w, `${size}/${logo} right edge`).toBeCloseTo(contentRight(r), 0);
// The brand zone reserves the logo box, so the meta lines start under it.
const brand = byId(r, BRAND_ID);
const supplier = byId(r, SUPPLIER_ID);
const meta = byId(r, META_ID);
expect(img.box.y + img.box.h, `${size}/${logo} logo bottom against meta top`).toBeLessThanOrEqual(meta.box.y + 0.01);
expect(supplier.box.y + supplier.box.h, `${size}/${logo} supplier box against meta`).toBeLessThanOrEqual(meta.box.y + 0.01);
expect(brand.box.y + brand.box.h).toBeLessThanOrEqual(meta.box.y + 0.01);
const lines = textLines(meta);
for (const l of lines) expect(l.y, `${size}/${logo} meta line`).toBeGreaterThanOrEqual(img.box.y + img.box.h - 0.01);
}
}
});
it("a tall logo is held to the 28 pt allowance and still leaves the stack under it", 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 prefs = prefsFor("A4", { templateId: "monolith" });
const result = await renderInvoicePdf({ model, prefs });
expect(result.issues.filter((i) => i.severity === "error")).toEqual([]);
const all = nodes(result.core.layout);
const img = all.find((n) => n.type === "image" && !n.decor)!;
const meta = all.find((n) => n.id === META_ID)!;
expect(img.box.h).toBeLessThanOrEqual(allowance.h + 0.01);
expect(img.box.y + img.box.h).toBeLessThanOrEqual(meta.box.y + 0.01);
});
it("a wordmark replaces the vendor name text; a mark keeps it beside the mark; no logo prints the name in the template's type", async () => {
const header = (r: Awaited<ReturnType<typeof renderFixture>>) => r.pdf.pages[0].items.filter((i) => i.str.trim() === "Test Vendor" && i.bottom < 130);
const wide = await renderFixture({ name: "o", items: 2, logo: "real" }, prefsFor("A4", { templateId: "monolith" }));
expect(header(wide)).toHaveLength(0);
const square = await renderFixture({ name: "o", items: 2, logo: "square" }, prefsFor("A4", { templateId: "monolith" }));
const [name] = header(square);
expect(name).toBeTruthy();
expect(name.x1).toBeLessThanOrEqual(imageBoxes(square)[0].box.x + 0.5);
const none = await renderFixture({ name: "o", items: 2, logo: "none" }, prefsFor("A4", { templateId: "monolith" }));
expect(imageBoxes(none)).toHaveLength(0);
expect(header(none)).toHaveLength(1);
});
});
describe("table, totals and footer", () => {
it("rules only: a 1.5 pt rule above and below a 39.3 pt header, amounts right-aligned 10 pt inside the edge, qty centred", async () => {
const r = await renderFixture(PLAIN, prefsFor("A4", { templateId: "monolith" }));
const head = byId(r, TABLE_HEAD_ID);
expect(head.box.h).toBeCloseTo(V.table.headH, 0);
const right = contentRight(r);
for (let i = 0; i < 3; i++) {
const row = byId(r, `${TABLE_ROW_PREFIX}${i}`);
const lines = textLines(row);
const amount = lines[lines.length - 1];
expect(amount.text, `row ${i}`).toMatch(/^₹[\d,]+\.\d\d$/);
expect(amount.right, `row ${i} amount edge`).toBeCloseTo(right - V.table.numPad, 0);
}
const qty = textLines(byId(r, `${TABLE_ROW_PREFIX}0`)).find((l) => l.text === "1")!;
const plan = planMonolith(r.fixture.model, frameForPrefs(r.prefs), V, null, false);
const left = frameForPrefs(r.prefs).pad.left + plan.W - plan.cw.amount - plan.cw.qty;
expect(qty.x + (qty.right - qty.x) / 2).toBeCloseTo(left + plan.cw.qty / 2, 0);
});
it("the totals box ends on the right edge, its labels are centred and a rule sits above Total", async () => {
const r = await renderFixture({ name: "t", items: 3, logo: "none" }, prefsFor("A4", { templateId: "monolith" }));
const totals = byId(r, TOTALS_ID);
const plan = planMonolith(r.fixture.model, frameForPrefs(r.prefs), V, null, false);
const frame = frameForPrefs(r.prefs);
const lines = textLines(totals);
const labelLeft = frame.pad.left + plan.W - plan.tail.boxW;
const subtotal = lines.find((l) => l.text === "Subtotal")!;
expect(subtotal.x + (subtotal.right - subtotal.x) / 2 - labelLeft).toBeCloseTo(plan.tail.labelW / 2, 0);
expect(lines.find((l) => l.text === r.fixture.model.totals.totalText)!.right).toBeCloseTo(frame.page.width - frame.pad.right - V.totals.valuePad, 0);
const rules = nodes([r.result.core.layout[0]]).filter((n) => n.type === "view" && n.box.h <= 0.76 && n.box.h >= 0.74 && Math.abs(n.box.w - plan.tail.boxW) < 0.5);
// The rule sits in a spacing wrapper of the same size, so count distinct positions.
expect(new Set(rules.map((n) => Math.round(n.box.y * 10)))).toHaveProperty("size", 1);
const total = textLines(totals).find((l) => l.text === "Total")!;
expect(rules[0].box.y).toBeLessThan(totals.box.y + total.y);
});
it("the closing block is the design's footer: payment information as a label : value grid at 114 pt, the vendor block at the right, between two rules", async () => {
const r = await renderFixture({ name: "c", items: 3, logo: "none", signature: true }, prefsFor("A4", { templateId: "monolith" }));
const closing = byId(r, CLOSING_ID, r.result.pages - 1);
const lines = textLines(closing);
const frame = frameForPrefs(r.prefs);
const bank = lines.find((l) => l.text === "State Bank of India")!;
const label = lines.find((l) => l.text === "Bank")!;
expect(bank.x - label.x).toBeCloseTo(V.closing.labelW + V.closing.colonW, 0);
expect(lines.some((l) => l.text === "PAYMENT INFORMATION :")).toBe(true);
const plan = planMonolith(r.fixture.model, frame, V, null, false);
const vendor = lines.find((l) => l.text === "9000000000")!;
expect(vendor.x).toBeCloseTo(frame.pad.left + plan.W - V.sideW, 0);
const rules = onPage(r, r.result.pages - 1).filter((n) => n.type === "view" && Math.abs(n.box.h - V.rule) < 0.01 && Math.abs(n.box.w - plan.W) < 0.5 && n.box.y >= closing.box.y - 0.01);
expect(rules.length).toBeGreaterThanOrEqual(2);
// The signature (an image and the signatory) sits in the band above the footer's top rule.
expect(byId(r, SLOT_ID.signatory, r.result.pages - 1).box.y + byId(r, SLOT_ID.signatory, r.result.pages - 1).box.h).toBeLessThanOrEqual(rules[0].box.y + 0.5);
});
it("a short invoice with the sample's data fits one A4 page by giving back the empty band, row padding and spacing", async () => {
const spec: FixtureSpec = {
name: "thumb",
items: [
{ id: 1, description: "Voice-over narration", mode: "rate", rate: 1500, unit: "hour", quantity: 4, amount: 0, sortOrder: 0, hsnSac: "998397" },
{ id: 2, description: "Audio mastering", mode: "rate", rate: 800, unit: "hour", quantity: 2, amount: 0, sortOrder: 1, hsnSac: "998397" },
],
logo: "real",
vendor: { vendorEmail: "", vendorPhone: "", vendorPan: "" },
};
const r = await renderFixture(spec, prefsFor("A4", { templateId: "monolith" }));
expect(r.result.pages).toBe(1);
expect(r.result.issues.filter((i) => i.severity === "error")).toEqual([]);
});
});
describe("the GST slots and the supplier ids", () => {
it("every GST slot has a home on a tax invoice, and the supplier's GSTIN and PAN are on page 1 whatever the length", async () => {
const r = await renderFixture({ name: "t", items: 60, signature: true }, prefsFor("A4", { templateId: "monolith" }));
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);
expect(byId(r, IDS_ID)).toBeTruthy();
expect(textLines(byId(r, IDS_ID)).map((l) => l.text)).toEqual(expect.arrayContaining(["SUPPLIER:", "GSTIN: 29AAAAA0000A1Z5", "PAN: ABCDE1234F"]));
const flat = r.pdf.pages.flatMap((p) => p.items.map((i) => i.str)).join("").replace(/\s+/g, "").toUpperCase();
for (const word of ["SUBTOTAL", "TAXABLEVALUE", "CGST@9%", "SGST@9%", "HSN/SAC:998397", "PLACEOFSUPPLY"]) 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: "monolith" }));
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: "monolith" }));
expect(r.result.issues.filter((i) => i.severity === "error"), `${size}/${name}`).toEqual([]);
}
}
});
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: "monolith" }));
const plan = planMonolith(r.fixture.model, frameForPrefs(r.prefs), V, null, false);
expect(plan.closing.notesBeside).toBe(false);
expect(byId(r, CLOSING_ID, r.result.pages - 1).box.h).toBeLessThan(300);
expect(r.pdf.pages.map((p, i) => textsOf(r, i)).join(" ")).toContain("Note 18");
});
it("an over-long vendor name wraps right-aligned in its column and never reaches the title", async () => {
const r = await renderFixture({ ...FIXTURES.find((f) => f.name === "id-tokens")!, logo: "none" }, prefsFor("A4", { templateId: "monolith" }));
const brand = textLines(byId(r, BRAND_ID));
expect(brand.length).toBeGreaterThan(1);
const title = textLines(byId(r, TITLE_ID));
expect(Math.min(...brand.map((l) => l.x))).toBeGreaterThan(Math.max(...title.map((l) => l.right)));
});
});
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: "monolith" });
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 = planMonolith(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(24);
});
}
}
});
describe("design docs", () => {
it("records its implementation notes", () => {
const text = readFileSync(path.join(process.cwd(), "docs/templates/03-monolith.md"), "utf8");
expect(text).toContain("## Implementation notes");
});
});