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>, i: number) => nodes([r.result.core.layout[i]]); const imageBoxes = (r: Awaited>) => nodes(r.result.core.layout).filter((n) => n.type === "image" && !n.decor); const byId = (r: Awaited>, id: string, page = 0) => onPage(r, page).find((n) => n.id === id)!; const textsOf = (r: Awaited>, 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>) => { 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(-4, -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>) => 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 hr")!; 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("when only the closing block moves to page 2 the page-1 gaps are squeezed enough to keep the items and totals on page 1", async () => { // Row counts where the overflow before the closing block is within the gaps that help page 1 (A4 / Letter). const cases: Array<["A4" | "LETTER", number]> = [["A4", 4], ["A4", 5], ["LETTER", 4]]; for (const [size, n] of cases) { const r = await renderFixture({ name: "s", items: n, logo: "none" }, prefsFor(size, { templateId: "monolith" })); const where = (id: string) => { const pages: number[] = []; r.result.core.layout.forEach((pg, i) => walk(pg.nodes, (x) => x.id === id && pages.push(i))); return pages; }; const rowPages = new Set(); r.result.core.layout.forEach((pg, i) => walk(pg.nodes, (x) => x.id?.startsWith(TABLE_ROW_PREFIX) && rowPages.add(i))); expect([...rowPages], `${size} n=${n} rows`).toEqual([0]); expect(where(TOTALS_ID), `${size} n=${n} totals`).toEqual([0]); expect(where(CLOSING_ID), `${size} n=${n} closing`).toEqual([1]); expect(r.result.pages).toBe(2); } }); it("flow notes never start with less than a line of room: the block starts on the next page instead of collapsing into zero-height lines", async () => { const base = FIXTURES.find((f) => f.name === "long-notes")!; // Row counts where the notes' label used to land in the last few points of a page (A4 / Letter). const cases: Array<["A4" | "LETTER", number]> = [["A4", 3], ["A4", 14], ["LETTER", 13]]; for (const [size, n] of cases) { const r = await renderFixture({ ...base, items: n, logo: "none" }, prefsFor(size, { templateId: "monolith" })); expect(r.result.issues.filter((i) => i.severity === "error"), `${size} n=${n}`).toEqual([]); const frame = frameForPrefs(r.prefs); const label = nodes(r.result.core.layout).find((x) => x.type === "text" && x.lines?.some((l) => /^notes/i.test(l.text)))!; let page = -1; r.result.core.layout.forEach((pg, i) => walk(pg.nodes, (x) => x === label && (page = i))); expect(label.box.y + 70, `${size} n=${n} label room`).toBeLessThanOrEqual(frame.page.height - frame.pad.bottom); expect(page).toBeGreaterThan(0); } }); 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"); }); });