import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { swooshKeepOuts } from "../../decor/CornerSwoosh"; import { CHIP_PADDING } from "../../engine/logo"; import { CLOSING_ID, TOTALS_ID } from "../../engine/ids"; import { walk, type LayoutNode } from "../../engine/layoutTree"; import { frameForPrefs } from "../../render/invoice"; import { loadCanvas, pagePng } from "../../testing/rasterize"; import { SLOT_ID } from "../../blocks/slots"; import { catalogEntry } from "../catalog"; import { marginsFor } from "../contract"; import { buildFixture, ensureSetup, FIXTURES, prefsFor, renderFixture, type FixtureSpec } from "../harness"; import { getTemplate, TEMPLATES } from "../registry"; import { SLOTS } from "../slots.generated"; import { planBand } from "./plan"; import { slateBand, SLATE_VARIANT } from "./slate"; import { tealSwoosh, TEAL_VARIANT } from "./teal"; ensureSetup(); const BAND = [slateBand, tealSwoosh]; const VARIANTS = { "slate-band": SLATE_VARIANT, "teal-swoosh": TEAL_VARIANT } as const; const nodes = (layout: { nodes: LayoutNode[] }[]) => { const out: LayoutNode[] = []; for (const p of layout) walk(p.nodes, (n) => out.push(n)); return out; }; const withLogo = { name: "logo", items: 2, logo: "real" as const }; const imageBoxes = (r: Awaited>) => nodes(r.result.core.layout).filter((n) => n.type === "image" && !n.decor); /** The colour of one pixel of page 1, at 4x. */ async function colourAt(bytes: Uint8Array, points: Array<[number, number]>): Promise { 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 isWhite = ([r, g, b]: number[]) => r > 245 && g > 245 && b > 245; const isSlate = ([r, g, b]: number[]) => Math.abs(r - 0x4d) < 6 && Math.abs(g - 0x58) < 6 && Math.abs(b - 0x69) < 6; describe("registry and catalog", () => { it("lists Slate Band and Teal Swoosh in the Band family, with their thumbnails on disk", () => { for (const t of BAND) { expect(getTemplate(t.id)).toBe(t); expect(t.family).toBe("band"); expect(catalogEntry(t.id).name).toBe(t.name); expect(existsSync(path.join(process.cwd(), "public", t.thumbnail)), t.thumbnail).toBe(true); } expect(Object.keys(TEMPLATES)).toEqual(["classic", "linea", "monogram", "serenity", "crimson-grid", "tangerine-ledger", "slate-band", "teal-swoosh", "purple-pop", "citrus-split", "monolith", "cobalt-stripe"]); }); it("both are Montserrat throughout, and their slots are the measured reference ones", () => { for (const t of BAND) for (const [name, token] of Object.entries(t.roles)) expect(token.face, `${t.id}.${name}`).toBe("Montserrat"); expect(slateBand.logo.slot).toBe(SLOTS["slate-band"]); expect(tealSwoosh.logo.slot).toBe(SLOTS["teal-swoosh"]); expect(slateBand.logo.slot?.darkSurface).toBe(true); expect(tealSwoosh.logo.slot?.darkSurface).toBe(false); expect(slateBand.roles.title).toMatchObject({ weight: 800, size: 30 }); }); }); describe("page geometry", () => { it("template margins", () => { expect(marginsFor(slateBand, "template")).toEqual({ top: 55.75, right: 59.55, bottom: 20, left: 59.55 }); expect(marginsFor(tealSwoosh, "template")).toEqual({ top: 55, right: 59.55, bottom: 24, left: 59.55 }); }); it("the flow stays above the footer rule (Slate) and the bottom-left swoosh (Teal) on every preset and size", () => { for (const margins of ["template", "narrow", "normal"] as const) { for (const size of ["A4", "LETTER"] as const) { expect(frameForPrefs(prefsFor(size, { templateId: "slate-band", margins })).pad.bottom, `slate ${margins}/${size}`).toBeGreaterThanOrEqual(86.7 + 12 - 1e-6); expect(frameForPrefs(prefsFor(size, { templateId: "teal-swoosh", margins })).pad.bottom, `teal ${margins}/${size}`).toBeGreaterThanOrEqual(110 - 1e-6); } } }); it("Letter keeps the physical margins", () => { for (const t of BAND) { const a4 = frameForPrefs(prefsFor("A4", { templateId: t.id })); const letter = frameForPrefs(prefsFor("LETTER", { templateId: t.id })); expect(letter.pad).toEqual(a4.pad); expect(letter.heightDeficit).toBeCloseTo(49.89, 2); } }); it("the table text is inset 9.8 pt and the header band bleeds to both page edges, on every page", async () => { for (const t of BAND) { for (const size of ["A4", "LETTER"] as const) { const r = await renderFixture({ name: "p", items: 30, logo: "none" }, prefsFor(size, { templateId: t.id })); const frame = frameForPrefs(r.prefs); expect(r.result.pages).toBeGreaterThan(1); r.result.core.layout.forEach((page, i) => { const heads = nodes([page]).filter((n) => n.id === "tbl-head"); if (heads.length === 0) return; expect(heads[0].box.x, `${t.id}/${size} p${i + 1} head x`).toBeCloseTo(frame.pad.left + 9.8, 1); // The fixed group around the header row is the band: page edge to page edge. const band = nodes([page]).find((n) => n.type === "view" && n.fixed && n.box.x < 0.5 && n.box.w > frame.page.width - 0.5 && n.box.h > 40); expect(band, `${t.id}/${size} p${i + 1} band`).toBeTruthy(); }); } } }); }); describe("Slate Band: dark band and logo", () => { it("the two full-bleed bands are page 1 only, from the page top, as wide as the page", async () => { for (const size of ["A4", "LETTER"] as const) { const r = await renderFixture({ name: "p", items: 40, logo: "none" }, prefsFor(size, { templateId: "slate-band" })); const decor = (i: number) => nodes([r.result.core.layout[i]]).filter((n) => n.decor && n.type === "view" && n.box.x < 0.5 && n.box.w > frameForPrefs(r.prefs).page.width - 0.5 && n.box.h > 20); expect(decor(0).length, size).toBeGreaterThanOrEqual(2); expect(Math.min(...decor(0).map((n) => n.box.y)), size).toBeCloseTo(0, 1); // 103.5 pt of slate, then 53.8 of blue, at the design's margins. const heights = decor(0).map((n) => n.box.h).sort((a, b) => a - b); expect(heights[0]).toBeGreaterThan(50); // 103.5 on A4; Letter takes up to 8.5 pt of the band's bottom padding. expect(heights[heights.length - 1]).toBeGreaterThan(94); expect(decor(1).filter((n) => n.box.y < 5), `${size} no band on page 2`).toHaveLength(0); } }); it("a logo with a white knockout draws the knockout on the band", async () => { const lib = await loadCanvas(); const r = await renderFixture({ ...withLogo, logo: "knockout" }, prefsFor("A4", { templateId: "slate-band" })); const [img] = imageBoxes(r); expect(imageBoxes(r)).toHaveLength(1); const slot = SLOTS["slate-band"]!.lockup!; expect(img.box.x).toBeGreaterThanOrEqual(slot.x - 0.6); expect(img.box.h).toBeLessThanOrEqual(slot.h + 0.01); expect(img.box.w).toBeLessThanOrEqual(slot.w + 0.01); if (!lib) return; const [centre, beside] = await colourAt(r.result.bytes, [[img.box.x + img.box.w / 2, img.box.y + img.box.h / 2], [img.box.x - 3, img.box.y + img.box.h / 2]]); expect(isWhite(centre), `knockout ink ${centre}`).toBe(true); expect(isSlate(beside), `band beside the logo ${beside}`).toBe(true); }); it("a logo without alpha sits on a white chip, padded by CHIP_PADDING, still inside the band", async () => { const lib = await loadCanvas(); const r = await renderFixture({ ...withLogo, logo: "wide" }, prefsFor("A4", { templateId: "slate-band" })); const [img] = imageBoxes(r); if (!lib) return; const [inChip, outside, centre] = await colourAt(r.result.bytes, [ [img.box.x - CHIP_PADDING + 1, img.box.y + img.box.h / 2], [img.box.x - CHIP_PADDING - 3, img.box.y + img.box.h / 2], [img.box.x + img.box.w / 2, img.box.y + img.box.h / 2], ]); expect(isWhite(inChip), `chip ${inChip}`).toBe(true); expect(isSlate(outside), `band outside the chip ${outside}`).toBe(true); // The print image (navy) is what is drawn on the chip, not a white knockout. expect(isWhite(centre) || centre[2] > centre[0], `print ink ${centre}`).toBe(true); }); it("a mark keeps the business name beside it, in white; a wordmark replaces the name", async () => { const square = await renderFixture({ ...withLogo, logo: "square" }, prefsFor("A4", { templateId: "slate-band" })); expect(square.pdf.pages[0].items.filter((i) => i.str.trim() === "Test Vendor" && i.bottom < 110)).toHaveLength(1); const wide = await renderFixture({ ...withLogo, logo: "knockout" }, prefsFor("A4", { templateId: "slate-band" })); expect(wide.pdf.pages[0].items.filter((i) => i.str.trim() === "Test Vendor" && i.bottom < 110)).toHaveLength(0); const none = await renderFixture({ ...withLogo, logo: "none" }, prefsFor("A4", { templateId: "slate-band" })); expect(none.pdf.pages[0].items.filter((i) => i.str.trim() === "Test Vendor" && i.bottom < 110)).toHaveLength(1); // The number sits in the band, right-aligned to the content edge. const no = none.pdf.pages[0].items.find((i) => i.str.startsWith("NO."))!; expect(no.bottom).toBeLessThan(110); expect(no.x1).toBeLessThanOrEqual(frameForPrefs(none.prefs).page.width - frameForPrefs(none.prefs).pad.right + 0.6); }); it("the contact footer is on every page", async () => { const r = await renderFixture({ name: "p", items: 40, logo: "none" }, prefsFor("A4", { templateId: "slate-band" })); expect(r.result.pages).toBeGreaterThan(1); for (const [i, page] of r.pdf.pages.entries()) { const text = page.items.map((t) => t.str).join(" "); expect(text, `page ${i + 1} phone`).toContain("9000000000"); expect(text, `page ${i + 1} e-mail`).toContain("vendor@example.com"); } }); }); describe("Teal Swoosh: corner art and keep-outs", () => { it("the bottom-left swoosh repeats on every page; the top-right one is page 1 only", async () => { const r = await renderFixture({ name: "p", items: 40, logo: "none" }, prefsFor("A4", { templateId: "teal-swoosh" })); const frame = frameForPrefs(r.prefs); const svgs = (i: number) => nodes([r.result.core.layout[i]]).filter((n) => n.type === "svg" && n.decor); expect(svgs(0).length).toBe(2); for (let i = 1; i < r.result.pages; i++) { const s = svgs(i); expect(s, `page ${i + 1}`).toHaveLength(1); expect(s[0].box.x).toBeCloseTo(0, 1); expect(s[0].box.y + s[0].box.h).toBeCloseTo(frame.page.height, 1); } }); it("keep-outs: bottom-left on every page, top-right on page 1 only; both clusters on Letter too", () => { for (const size of ["A4", "LETTER"] as const) { const frame = frameForPrefs(prefsFor(size, { templateId: "teal-swoosh" })); const k = tealSwoosh.decor.keepOuts!(frame); const bl = swooshKeepOuts(frame, "bottom-left"); const tr = swooshKeepOuts(frame, "top-right"); expect(k).toHaveLength(bl.length + tr.length); expect(k.filter((r) => r.page === undefined)).toHaveLength(bl.length); expect(k.filter((r) => r.page === 1)).toHaveLength(tr.length); } }); it("no flowing text or image enters the keep-outs, with a long vendor name, 60 rows, on A4 and Letter", async () => { for (const size of ["A4", "LETTER"] as const) { for (const spec of [FIXTURES.find((f) => f.name === "id-tokens")!, FIXTURES.find((f) => f.name === "rows-60")!, FIXTURES.find((f) => f.name === "long-address")!] satisfies FixtureSpec[]) { const r = await renderFixture(spec, prefsFor(size, { templateId: "teal-swoosh" })); const keep = r.result.issues.filter((i) => i.code === "keepout"); expect(keep.map((i) => i.message), `${size}/${spec.name}`).toEqual([]); } } }); it("the keep-out audit does fire for a block placed in the swoosh", async () => { // Teal with page margins pulled in so the closing block would reach the swoosh: the audit must say so. const r = await renderFixture({ name: "k", items: 2, logo: "none" }, prefsFor("A4", { templateId: "teal-swoosh" })); const frame = frameForPrefs(r.prefs); const { auditLayout } = await import("../../engine/audit"); const closing = nodes(r.result.core.layout).find((n) => n.id === CLOSING_ID)!; const inside = { x: 0, y: closing.box.y - 1, w: frame.page.width, h: closing.box.h + 2 }; expect(auditLayout({ pages: r.result.core.layout }, frame, { keepOuts: [inside] }).some((i) => i.code === "keepout")).toBe(true); }); it("the logo stays inside its measured lockup box; a wordmark replaces the name, a mark keeps it", async () => { const slot = SLOTS["teal-swoosh"]!.lockup!; const wide = await renderFixture(withLogo, prefsFor("A4", { templateId: "teal-swoosh" })); const [img] = imageBoxes(wide); expect(img.box.x).toBeGreaterThanOrEqual(slot.x - 0.6); expect(img.box.y).toBeGreaterThanOrEqual(slot.y - 0.6); expect(img.box.x + img.box.w).toBeLessThanOrEqual(slot.x + slot.w + 0.6); expect(img.box.y + img.box.h).toBeLessThanOrEqual(slot.y + slot.h + 0.6); expect(wide.pdf.pages[0].items.filter((i) => i.str.trim() === "Test Vendor" && i.bottom < 120)).toHaveLength(0); const square = await renderFixture({ ...withLogo, logo: "square" }, prefsFor("A4", { templateId: "teal-swoosh" })); expect(square.pdf.pages[0].items.filter((i) => i.str.trim() === "Test Vendor" && i.bottom < 120)).toHaveLength(1); const none = await renderFixture({ ...withLogo, logo: "none" }, prefsFor("A4", { templateId: "teal-swoosh" })); expect(none.pdf.pages[0].items.filter((i) => i.str.trim() === "Test Vendor" && i.bottom < 120)).toHaveLength(1); }); it("the continuation band and footer stay out of the swoosh on page 2", async () => { const r = await renderFixture({ name: "p", items: 40, logo: "none" }, prefsFor("A4", { templateId: "teal-swoosh" })); const band = r.pdf.pages[1].items.find((t) => t.str.startsWith("Tax Invoice"))!; expect(band.x0).toBeCloseTo(frameForPrefs(r.prefs).pad.left, 0); const footer = r.pdf.pages[1].items.filter((t) => t.top > 790); for (const t of footer) expect(t.x0, t.str).toBeGreaterThan(190); }); }); describe("the TOTAL row and the totals stack", () => { it("TOTAL carries the accent dash and every tax row sits above it", async () => { for (const t of BAND) { const r = await renderFixture({ name: "t", items: 2, logo: "none" }, prefsFor("A4", { templateId: t.id })); const text = r.pdf.pages.flatMap((p) => p.items.map((i) => i.str)); // Tracked labels are extracted letter by letter, so compare without spaces. const flat = text.join("").replace(/\s+/g, "").toUpperCase(); expect(flat, t.id).toContain("TOTAL"); for (const word of ["SUBTOTAL", "CGST@9%", "SGST@9%", "TAXABLEVALUE"]) expect(flat, `${t.id} ${word}`).toContain(word); 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.amountInWords, SLOT_ID.signatory]) expect(ids.has(id), `${t.id} #${id}`).toBe(true); } }); it("an unregistered invoice carries none of the GST slots", async () => { for (const t of BAND) { const r = await renderFixture(FIXTURES.find((f) => f.name === "unregistered")!, prefsFor("A4", { templateId: t.id })); 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), `${t.id} #${id}`).toBe(false); } }); }); describe("long text", () => { it("long names, addresses and unbroken tokens raise no audit error", async () => { for (const t of BAND) { for (const size of ["A4", "LETTER"] as const) { for (const name of ["id-tokens", "long-address", "long-client"]) { const r = await renderFixture(FIXTURES.find((f) => f.name === name)!, prefsFor(size, { templateId: t.id })); expect(r.result.issues.filter((i) => i.severity === "error"), `${t.id}/${size}/${name}`).toEqual([]); } } } }); }); describe("plan matches the laid-out blocks", () => { for (const t of BAND) { 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 frame = frameForPrefs(prefs); // Logo-less, so the plan made here is the one the layout made (a logo only changes the header). 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 = planBand(r.fixture.model, frame, VARIANTS[t.id as keyof typeof VARIANTS], null, false); expect(plan.tailH - find(TOTALS_ID), "tail prediction vs layout").toBeGreaterThanOrEqual(-0.01); expect(plan.tailH - find(TOTALS_ID)).toBeLessThanOrEqual(15 + plan.gaps.tail); expect(plan.closingH - find(CLOSING_ID), "closing prediction vs layout").toBeGreaterThanOrEqual(-0.01); expect(plan.closingH - find(CLOSING_ID)).toBeLessThanOrEqual(15); }); } } } }); describe("design docs", () => { it("each Band doc records its implementation notes", () => { for (const f of ["09-slate-band.md", "10-teal-swoosh.md"]) { const text = readFileSync(path.join(process.cwd(), "docs/templates", f), "utf8"); expect(text, f).toContain("## Implementation notes"); } }); }); void buildFixture;