import { existsSync, readdirSync, readFileSync } from "node:fs"; import path from "node:path"; import { beforeAll, 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 { parsePdfText } from "../../testing/pdfText"; 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 { OPTIN_SLOTS } from "../slots.optin"; import { SLOTS } from "../slots.generated"; import { PAD_PREFIX, PAY_ID } from "./Layout"; import { highlighter } from "./highlighter"; import { planHighlighter } from "./plan"; import { BLACK, HIGHLIGHTER_ROLES, HIGHLIGHTER_VARIANT, INDIGO, INK, YELLOW } from "./tokens"; ensureSetup(); // fontkit caches glyph objects per font for the process, and pdfkit creates a composite's components (the period and comma inside // Poppins' colon and semicolon) at embed time with no code points (src/pdf/quirks.test.tsx, quirk 13). A first render without any // period or comma, such as the 3-row sample at 100.00 an hour, then makes a later wrapped client name overflow its box by 3.6 pt // (textkit's glyph-to-string mapping slips). The tests run in a fixed order, so warm Poppins with the harness's default invoice, // whose amounts have both. beforeAll(async () => { await renderFixture({ name: "warm-up", items: 3 }, prefsFor("A4", { templateId: "highlighter" })); }); const V = HIGHLIGHTER_VARIANT; const slot = OPTIN_SLOTS.highlighter!; type R = Awaited>; 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: 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; h: 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, h: l.h }); }); return out; }; const contentRight = (r: R) => { const f = frameForPrefs(r.prefs); return f.page.width - f.pad.right; }; const optIn = (size: "A4" | "LETTER" = "A4") => prefsFor(size, { templateId: "highlighter", logo: { show: true } }); const hits = (a: { x: number; y: number; w: number; h: number }, b: { x: number; y: number; w: number; h: number }) => Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x) > 0.5 && Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y) > 0.5; /** The Canva sample's shape: no due date, no address, the payment block's two lines, nothing the design has no slot for. */ const NAMES = ["Brand consultation", "Logo design", "Website design"]; const 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 })); async function canva(size: "A4" | "LETTER" = "A4"): Promise<{ r: R; items: Array<{ str: string; x0: number; x1: number; bottom: number }> }> { const fixture = buildFixture({ name: "canva", registered: false, logo: "none", items: PLAIN_ITEMS, invoice: { dueDate: "", notes: "", clientName: "Olivia Wilson", clientAddress: "", clientGstin: "" } }); fixture.model.bank = { accountName: "Wealth", lines: [{ label: "Bank", value: "Wealth Banking" }, { label: "Account No.", value: "123 4567 8901" }] }; fixture.model.client.posLabel = null; const prefs = prefsFor(size, { templateId: "highlighter" }); const result = await renderInvoicePdf({ model: fixture.model, prefs }); const pdf = await parsePdfText(result.bytes); return { r: { fixture, prefs, result, pdf } as R, items: pdf.pages[0].items }; } const find = (items: Array<{ str: string; x0: number; x1: number; bottom: number }>, text: string) => items.find((i) => i.str.trim().startsWith(text))!; /** RGB of pixels of page `page` at 4x. */ async function colourAt(bytes: Uint8Array, points: Array<[number, number]>, page = 1): Promise { const lib = await loadCanvas(); if (!lib) return []; const png = await pagePng(lib, bytes, page, 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 and the logo opt-in", () => { expect(getTemplate("highlighter")).toBe(highlighter); expect(highlighter.family).toBe("standalone"); expect(catalogEntry("highlighter").name).toBe("Highlighter"); expect(catalogEntry("highlighter").logoOptIn).toBe(true); expect(existsSync(path.join(process.cwd(), "public", highlighter.thumbnail)), highlighter.thumbnail).toBe(true); expect(Object.keys(TEMPLATES).at(-1)).toBe("highlighter"); expect(TEMPLATE_CATALOG.map((e) => e.id)).toEqual(Object.keys(TEMPLATES)); }); it("is Poppins 400/700 and Montserrat 400 only, in the design's four colours", () => { for (const [name, token] of Object.entries(HIGHLIGHTER_ROLES)) { expect(["Poppins", "Montserrat"], name).toContain(token.face); expect([400, 700], name).toContain(token.weight); if (token.face === "Montserrat") expect(token.weight, name).toBe(400); } const dir = path.join(process.cwd(), "src/pdf/templates/highlighter"); const palette = [INK, INDIGO, YELLOW, BLACK].map((c) => c.toLowerCase()); 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) => !palette.includes(c.toLowerCase())), f).toEqual([]); } expect(highlighter.decor.ink).toBe(INK); }); }); describe("page geometry", () => { it("template margins put the 3 pt frame's outer edge on the content box (479.4 pt wide on A4)", () => { expect(marginsFor(highlighter, "template")).toEqual({ top: 50, right: 57.8, bottom: 36, left: 58.1 }); expect(marginsFor(highlighter, "narrow").left).toBe(36); expect(frameForPrefs(prefsFor("A4", { templateId: "highlighter" })).contentWidth).toBeCloseTo(479.4, 0); }); it("Letter keeps the physical margins and takes the missing height from the gaps and the empty rows", () => { const a4 = frameForPrefs(prefsFor("A4", { templateId: "highlighter" })); const letter = frameForPrefs(prefsFor("LETTER", { templateId: "highlighter" })); expect(letter.pad).toEqual(a4.pad); expect(letter.heightDeficit).toBeCloseTo(49.89, 2); }); }); describe("header and fields", () => { it("matches the design: title baseline 98 at x 60.3, the fields at the 34 pt pitch from 132.2, the payment block at 162.9 / 185.1", async () => { const { items } = await canva(); const title = find(items, "Invoice"); expect(title.bottom).toBeCloseTo(98.0, 0); expect(title.x0).toBeCloseTo(60.3, 0); expect(find(items, "Date:").bottom).toBeCloseTo(132.2, 0); expect(find(items, "Invoice number:").bottom).toBeCloseTo(166.0, 0); expect(find(items, "Billed to:").bottom).toBeCloseTo(200.4, 0); expect(find(items, "Billed to:").x0).toBeCloseTo(60.3, 0); const pay = find(items, "Payment Method:"); expect(pay.bottom).toBeCloseTo(162.9, 0); expect(pay.x0).toBeCloseTo(352.5, 0); expect(find(items, "Account Name").bottom).toBeCloseTo(185.1, 0); expect(find(items, "Account Name").bottom + 16.5).toBeCloseTo(find(items, "Bank Name").bottom, 0); }); it("each field's value sits on a 0.75 pt indigo underline 5 pt after its label, ending at x 266 (the first three underlines at y 137.4 / 170.4 / 204.8)", async () => { const { r } = await canva(); const meta = byId(r, META_ID); const rules = onPage(r, 0).filter((n) => n.type === "view" && Math.abs(n.box.h - 19.25) < 0.3 && Math.abs(n.box.x + n.box.w - 266) < 0.6 && n.box.y >= meta.box.y - 0.1); expect(rules.length).toBeGreaterThanOrEqual(3); const ys = [...new Set(rules.map((n) => Math.round((n.box.y + n.box.h) * 10) / 10))].sort((a, b) => a - b); // The design's pitch is 33.8 / 34.4 pt; ours is a steady 34.1. for (const [got, want] of [[ys[0], 137.8], [ys[1], 170.8], [ys[2], 205.2]] as const) expect(Math.abs(got - want), `underline ${got} vs ${want}`).toBeLessThan(1.3); }); it("a long invoice number, client name or reference wraps inside its underline and the payment block stays beside it", async () => { const r = await renderFixture({ name: "t", items: 3, logo: "none", invoice: { number: "AB/2026-27/0012345/REV-3/LONG-REFERENCE-NUMBER", clientName: "The Board of Trustees of the Mahatma Gandhi Memorial Institute of Education" } }, prefsFor("A4", { templateId: "highlighter" })); expect(r.result.issues.filter((i) => i.severity === "error")).toEqual([]); const meta = byId(r, META_ID); const pay = byId(r, PAY_ID); expect(hits(meta.box, pay.box)).toBe(false); expect(meta.box.x + meta.box.w).toBeLessThanOrEqual(pay.box.x + 0.5); }); it("TAX INVOICE never reaches the vendor stack, on either size", async () => { for (const size of ["A4", "LETTER"] as const) { const r = await renderFixture({ name: "t", items: 3, logo: "none", vendor: { vendorName: "Venkatachalam Longsurname Ramasubramanian Voice Studios Pvt" } }, prefsFor(size, { templateId: "highlighter" })); const title = textLines(byId(r, TITLE_ID)); const supplier = textLines(byId(r, SUPPLIER_ID)); expect(Math.max(...title.map((l) => l.right)), size).toBeLessThan(Math.min(...supplier.map((l) => l.x)) - 4); expect(r.result.issues.filter((i) => i.severity === "error"), size).toEqual([]); } }); it("a cancelled invoice is marked in the vendor stack and closes with its sentence", async () => { const r = await renderFixture(FIXTURES.find((f) => f.name === "cancelled")!, prefsFor("A4", { templateId: "highlighter" })); expect(textLines(byId(r, SUPPLIER_ID)).some((l) => l.text === "CANCELLED")).toBe(true); expect(r.pdf.pages[r.result.pages - 1].items.map((i) => i.str).join(" ")).toContain("This invoice has been cancelled."); }); }); describe("logo", () => { it("is an opt-in slot (the design has no placeholder): off by default, 150 x 50 at the top right", () => { expect(SLOTS.highlighter).toBeNull(); expect(highlighter.logo.slot).toBe(slot); expect(highlighter.logo.optIn).toBe(true); expect(highlighter.logo.defaultOn).toBe(false); expect(slot.lockup).toMatchObject({ x: 386, y: 50, w: 150, h: 50 }); expect(highlighter.logo.box).toEqual({ w: 150, h: 50 }); }); it("without the opt-in no logo is drawn, even when the invoice has one", async () => { const r = await renderFixture({ name: "o", items: 2, logo: "real" }, prefsFor("A4", { templateId: "highlighter" })); expect(imageBoxes(r)).toHaveLength(0); }); it("every opted-in logo fits the 150 x 50 slot, ends on the right edge at the slot's top, and the fields flow below it", 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 }, optIn(size)); const imgs = imageBoxes(r); expect(imgs, `${size}/${logo}`).toHaveLength(1); const [img] = imgs; expect(img.box.w).toBeLessThanOrEqual(150.01); expect(img.box.h).toBeLessThanOrEqual(50.01); expect(img.box.x + img.box.w, `${size}/${logo} right edge`).toBeCloseTo(contentRight(r) - V.rightInset, 0); expect(img.box.y, `${size}/${logo} top`).toBeCloseTo(frameForPrefs(r.prefs).pad.top, 0); expect(byId(r, SUPPLIER_ID).box.y + byId(r, SUPPLIER_ID).box.h, `${size}/${logo}`).toBeLessThanOrEqual(byId(r, META_ID).box.y + 0.5); expect(r.result.issues.filter((i) => i.severity === "error"), `${size}/${logo}`).toEqual([]); } } }); it("a tall logo is held to the slot's 50 pt 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: optIn() }); 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(50.01); }); it("a wordmark replaces the vendor name text; a mark keeps it under the mark", async () => { const stack = (r: R) => textLines(byId(r, SUPPLIER_ID)).map((l) => l.text); expect(stack(await renderFixture({ name: "o", items: 2, logo: "real" }, optIn()))).not.toContain("Test Vendor"); expect(stack(await renderFixture({ name: "o", items: 2, logo: "square" }, optIn()))).toContain("Test Vendor"); }); }); describe("table", () => { it("the bar is #FFE535 inside a 3 pt ink frame, 38.4 pt tall, with the headings centred over the design's columns (Qty x 277.4, Price x 360.8)", async () => { const { r, items } = await canva(); const bar = byId(r, TABLE_HEAD_ID); expect(bar.box.h).toBeCloseTo(V.table.barH, 0); expect(bar.box.x).toBeCloseTo(58.1, 0); expect(bar.box.x + bar.box.w).toBeCloseTo(537.5, 0); const centre = (t: string) => { const i = find(items, t); return (i.x0 + i.x1) / 2; }; expect(centre("Qty")).toBeCloseTo(277.4, 0); expect(centre("Price")).toBeCloseTo(360.8, 0); const [fill, frame, divider] = await colourAt(r.result.bytes, [[200, bar.box.y + 3 + 3], [200, bar.box.y + 1.5], [245.5, bar.box.y + 5]]); expect(near(fill, YELLOW), `bar ${fill}`).toBe(true); expect(near(frame, INK, 10), `frame ${frame}`).toBe(true); expect(near(divider, INDIGO, 40), `divider ${divider}`).toBe(true); }); it("the body follows 4.5 pt under the bar with its own 3 pt frame; dividers are hairlines, the sides and the last row's bottom are the frame", async () => { const { r } = await canva(); const bar = byId(r, TABLE_HEAD_ID).box; const row0 = byId(r, `${TABLE_ROW_PREFIX}0`).box; expect(row0.y - (bar.y + bar.h)).toBeCloseTo(V.table.bodyGap + V.frame, 0); expect(row0.x).toBeCloseTo(58.1, 0); expect(row0.w).toBeCloseTo(479.4, 0); const pads = onPage(r, 0).filter((n) => n.id?.startsWith(PAD_PREFIX)); expect(pads.length).toBeGreaterThan(0); const last = pads[pads.length - 1].box; const [side, hair, bottom] = await colourAt(r.result.bytes, [[59.6, row0.y + 10], [150, row0.y + row0.h - 0.4], [150, last.y + last.h - 1.5]]); expect(near(side, INK, 10), `side ${side}`).toBe(true); expect(near(hair, INDIGO, 60), `hairline ${hair}`).toBe(true); expect(near(bottom, INK, 10), `bottom frame ${bottom}`).toBe(true); }); it("the form look pads to 8 empty rows on A4 and 7 on Letter while the page holds them, and to none for a long invoice", async () => { const a4 = await canva("A4"); const rowsA4 = onPage(a4.r, 0).filter((n) => n.id?.startsWith(TABLE_ROW_PREFIX) || n.id?.startsWith(PAD_PREFIX)).length; expect(rowsA4).toBe(8); const letter = await canva("LETTER"); expect(onPage(letter.r, 0).filter((n) => n.id?.startsWith(TABLE_ROW_PREFIX) || n.id?.startsWith(PAD_PREFIX)).length).toBe(7); const many = await renderFixture({ name: "m", items: 12 }, prefsFor("A4", { templateId: "highlighter" })); expect(nodes(many.result.core.layout).filter((n) => n.id?.startsWith(PAD_PREFIX))).toHaveLength(0); // A GST invoice with its longer stack drops pad rows rather than spill the sign-off onto a second page when one page can hold it. const gst = await renderFixture({ name: "g", items: 2, logo: "none", vendor: { vendorEmail: "", vendorPhone: "" }, invoice: { clientAddress: "" } }, prefsFor("A4", { templateId: "highlighter" })); expect(gst.result.pages).toBe(1); }); it("description left (9 pt in), qty centred, price and total right-aligned 9 pt inside their cells", async () => { const { r } = await canva(); const plan = planHighlighter(r.fixture.model, frameForPrefs(r.prefs), V, null, false); const row = textLines(byId(r, `${TABLE_ROW_PREFIX}0`)); const left = frameForPrefs(r.prefs).pad.left + V.frame; expect(row[0].x).toBeCloseTo(left + V.table.descPad, 0); const amount = row[row.length - 1]; expect(amount.right).toBeCloseTo(frameForPrefs(r.prefs).pad.left + plan.W - V.frame - V.table.numPad, 0); }); it("repeats the bar on every continuation page, never leaves it alone, and closes the frame after the last row", async () => { const r = await renderFixture({ name: "rows", items: 40 }, prefsFor("A4", { templateId: "highlighter" })); expect(r.result.pages).toBeGreaterThan(1); for (let p = 0; p < r.result.pages; p++) { const rows = onPage(r, p).filter((n) => n.id?.startsWith(TABLE_ROW_PREFIX)).length; const heads = onPage(r, p).filter((n) => n.id === TABLE_HEAD_ID).length; if (rows > 0) expect(heads, `page ${p + 1}`).toBe(1); if (heads > 0) expect(rows, `page ${p + 1}`).toBeGreaterThan(0); } expect(r.result.issues.filter((i) => i.severity === "error")).toEqual([]); }); }); describe("totals and closing", () => { it("the totals box lines up with the table's price and total columns; only the total row's label cell is yellow", async () => { const r = await renderFixture({ name: "t", items: 3, logo: "none" }, prefsFor("A4", { templateId: "highlighter" })); const last = r.result.core.layout.findIndex((p) => nodes([p]).some((n) => n.id === TOTALS_ID)); const totals = byId(r, TOTALS_ID, last); const plan = planHighlighter(r.fixture.model, frameForPrefs(r.prefs), V, null, false); const frame = frameForPrefs(r.prefs); const box = onPage(r, last).filter((n) => n.type === "view" && n.box.y > totals.box.y && Math.abs(n.box.w - plan.tail.boxW) < 0.5).sort((a, b) => a.box.y - b.box.y)[0]; expect(box.box.x + box.box.w).toBeCloseTo(frame.pad.left + plan.W, 0); const lines = textLines(totals); expect(lines.map((l) => l.text)).toEqual(expect.arrayContaining(["Subtotal :", "Taxable value :", "CGST @ 9% :", "SGST @ 9% :", "total :"])); const totalLabel = lines.find((l) => l.text === "total :")!; const subLabel = lines.find((l) => l.text === "Subtotal :")!; const [yellow, white] = await colourAt(r.result.bytes, [[totalLabel.x - 3, totalLabel.y + 8], [subLabel.x - 3, subLabel.y + 8]], last + 1); expect(near(yellow, YELLOW), `total label ${yellow}`).toBe(true); expect(near(white, "#FFFFFF"), `subtotal label ${white}`).toBe(true); expect(lines.find((l) => l.text === r.fixture.model.totals.totalText)!.right).toBeCloseTo(box.box.x + box.box.w - V.frame - V.totals.valuePad, 0); }); it("the amount in words stands at the left of the box, top-aligned with its first row, and never meets it, even with a long amount", async () => { const r = await renderFixture({ name: "t", items: [{ id: 1, description: "Retainer", mode: "fixed", rate: 0, unit: "unit", quantity: 1, amount: 98765432.1, sortOrder: 0, hsnSac: "998397" }], logo: "none", invoice: { discount: 5000 } }, prefsFor("A4", { templateId: "highlighter" })); expect(r.result.issues.filter((i) => i.severity === "error")).toEqual([]); const at = r.result.core.layout.findIndex((p) => nodes([p]).some((n) => n.id === TOTALS_ID)); const totals = byId(r, TOTALS_ID, at); const words = onPage(r, at).find((n) => n.id === SLOT_ID.amountInWords)!; const box = onPage(r, at).filter((n) => n.type === "view" && n.box.y >= totals.box.y && n.box.h > 100).sort((a, b) => b.box.x - a.box.x)[0]; expect(words.box.x + words.box.w).toBeLessThanOrEqual(box.box.x + 0.5); }); it("the sign-off is THANK YOU and the contact rows with icons at the left, the ruled lines at the right (212 pt, notes on them, the signature on the last)", async () => { const r = await renderFixture({ name: "c", items: 3, logo: "none", signature: true, invoice: { notes: "Payment due within 30 days.\nLate payments attract 2% monthly interest." } }, prefsFor("A4", { templateId: "highlighter" })); const last = r.result.pages - 1; const closing = byId(r, CLOSING_ID, last); const lines = textLines(closing).map((l) => l.text); expect(lines).toEqual(expect.arrayContaining(["THANK YOU", "9000000000", "vendor@example.com", "Payment due within 30 days."])); expect(onPage(r, last).filter((n) => n.type === "svg" && n.box.y >= closing.box.y)).toHaveLength(3); const sign = byId(r, SLOT_ID.signatory, last); expect(sign.box.w).toBeCloseTo(V.closing.ruledW, 0); expect(sign.box.x + sign.box.w).toBeCloseTo(frameForPrefs(r.prefs).page.width - frameForPrefs(r.prefs).pad.right - V.rightInset, 0); const note = textLines(closing).find((l) => l.text === "Payment due within 30 days.")!; expect(closing.box.x + note.x - closing.box.x).toBeGreaterThan(300); }); it("long terms flow as a block of their own and keep the ruled lines bounded", async () => { const r = await renderFixture(FIXTURES.find((f) => f.name === "long-notes")!, prefsFor("A4", { templateId: "highlighter" })); const plan = planHighlighter(r.fixture.model, frameForPrefs(r.prefs), V, null, false); expect(plan.closing.notesFlow).toBe(true); 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("a long address wraps in the contact row and pushes nothing into the ruled lines", async () => { const r = await renderFixture(FIXTURES.find((f) => f.name === "long-address")!, prefsFor("A4", { templateId: "highlighter" })); expect(r.result.issues.filter((i) => i.severity === "error")).toEqual([]); const closing = byId(r, CLOSING_ID, r.result.pages - 1); const left = textLines(closing).filter((l) => l.x < 300); const right = textLines(closing).filter((l) => l.x >= 300); for (const a of left) for (const b of right) expect(hits({ x: a.x, y: a.y, w: a.right - a.x, h: a.h }, { x: b.x, y: b.y, w: b.right - b.x, h: b.h }), `${a.text} / ${b.text}`).toBe(false); }); }); describe("the GST slots", () => { it("every GST slot has a home on a tax invoice, and the supplier's identifiers are on page 1 whatever the length", async () => { const r = await renderFixture({ name: "t", items: 60, signature: true }, prefsFor("A4", { templateId: "highlighter" })); 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(onPage(r, 0).some((n) => n.id === `${SLOT_ID.gstin}-supplier`)).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:", "CGST@9%:", "SGST@9%:", "HSN/SAC:998397", "PLACEOFSUPPLY", "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: "highlighter" })); 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: "highlighter" })); expect(r.result.issues.filter((i) => i.severity === "error").map((i) => `${i.code}: ${i.message}`), `${size}/${name}`).toEqual([]); } } }); it("the header's three columns never overlap, whatever the vendor and client", async () => { for (const size of ["A4", "LETTER"] as const) { const r = await renderFixture(FIXTURES.find((f) => f.name === "id-tokens")!, prefsFor(size, { templateId: "highlighter" })); const boxes = [byId(r, TITLE_ID), byId(r, SUPPLIER_ID), byId(r, META_ID), byId(r, PAY_ID)]; for (let i = 0; i < boxes.length; i++) for (let j = i + 1; j < boxes.length; j++) expect(hits(boxes[i].box, boxes[j].box), `${size}: ${boxes[i].id} / ${boxes[j].id}`).toBe(false); } }); }); 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: "highlighter" }); const frame = frameForPrefs(prefs); // Logo-less (the opt-in is off), so the plan made here is the one the layout made. const r = await renderFixture({ ...spec, logo: "none" } as FixtureSpec, prefs); const heights = (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 = planHighlighter(r.fixture.model, frame, V, null, false); expect(plan.tailH - heights(TOTALS_ID), "tail prediction vs layout").toBeGreaterThanOrEqual(-0.01); expect(plan.tailH - heights(TOTALS_ID)).toBeLessThanOrEqual(24); expect(plan.closingH - heights(CLOSING_ID), "closing prediction vs layout").toBeGreaterThanOrEqual(-0.01); expect(plan.closingH - heights(CLOSING_ID)).toBeLessThanOrEqual(24); }); } } }); describe("design docs", () => { it("records its implementation notes", () => { const text = readFileSync(path.join(process.cwd(), "docs/templates/02-highlighter.md"), "utf8"); expect(text).toContain("## Implementation notes"); }); });