Purple Pop (cream page, purple pills, asterisk and dome decor, opt-in logo) and Citrus Split (grey split panel, halftone chevrons, icon contacts, logo in the measured slot) are rebuilt from the Canva PDFs on the engine with A4/Letter support, GST slots and zero audit errors. Adds static DM Sans, Jost and Poppins 800 as look-alikes for the commercial fonts. The picker now lists ten templates.
464 lines
28 KiB
TypeScript
464 lines
28 KiB
TypeScript
import { existsSync, readFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { describe, expect, it } from "vitest";
|
|
import { ASTERISK_PATH, asteriskWatermark, DOME_PATH, WATERMARK } from "../../decor/Asterisk";
|
|
import { CITRUS_HALFTONE, halftoneDots, halftoneField, HALFTONE } from "../../decor/Halftone";
|
|
import { CLOSING_ID, TABLE_HEAD_ID, TABLE_ROW_PREFIX, TOTALS_ID } from "../../engine/ids";
|
|
import { walk, type LayoutNode } from "../../engine/layoutTree";
|
|
import { measureToken, getMeasurer } from "../../engine/measure";
|
|
import { resolveFace } from "../../fonts/roles";
|
|
import { SLOT_ID } from "../../blocks/slots";
|
|
import { frameForPrefs } from "../../render/invoice";
|
|
import { loadCanvas, pagePng } from "../../testing/rasterize";
|
|
import { catalogEntry, TEMPLATE_CATALOG } 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 { OPTIN_SLOTS } from "../slots.optin";
|
|
import { citrusSplit, CITRUS_ROLES, CITRUS_VARIANT } from "./citrus";
|
|
import { planCitrus, planPurple } from "./plan";
|
|
import { purplePop, PURPLE_ROLES, PURPLE_VARIANT } from "./purple";
|
|
import { gotham, GOTHAM_SIZE } from "./variant";
|
|
|
|
ensureSetup();
|
|
|
|
const NORTHLINE = [purplePop, citrusSplit];
|
|
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 textsOf = (r: Awaited<ReturnType<typeof renderFixture>>, page = 0) => r.pdf.pages[page].items.map((i) => i.str).join(" ");
|
|
|
|
/** The colour 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));
|
|
}
|
|
/** Items with short names, like the Canva sample: the narrow Purple Pop table and the one-page Citrus Split layout both need them. */
|
|
const SHORT = ["Consultation", "Logo design", "Web design"].map((description, i) => ({ id: i + 1, description, mode: "rate" as const, rate: 100, unit: "hour" as const, quantity: 1, amount: 0, sortOrder: i, hsnSac: "998397" }));
|
|
/** The same without an HSN/SAC code, so every row is one line tall. */
|
|
const PLAIN = SHORT.map(({ hsnSac, ...rest }) => ({ ...rest, ...(hsnSac ? {} : {}) }));
|
|
|
|
const near = (c: number[], hex: string, tol = 6) => {
|
|
const want = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
|
|
return want.every((w, i) => Math.abs(c[i] - w) <= tol);
|
|
};
|
|
|
|
describe("registry, catalog and fonts", () => {
|
|
it("lists Purple Pop and Citrus Split last, in the Northline family, with their thumbnails on disk", () => {
|
|
for (const t of NORTHLINE) {
|
|
expect(getTemplate(t.id)).toBe(t);
|
|
expect(t.family).toBe("northline");
|
|
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).slice(-2)).toEqual(["purple-pop", "citrus-split"]);
|
|
expect(TEMPLATE_CATALOG.map((e) => e.id)).toEqual(Object.keys(TEMPLATES));
|
|
expect(TEMPLATE_CATALOG.filter((e) => e.logoOptIn).map((e) => e.id)).toEqual(["linea", "purple-pop"]);
|
|
});
|
|
|
|
it("Gotham tokens carry the Montserrat size factor, and the licensed faces resolve to the bundled look-alikes", () => {
|
|
expect(GOTHAM_SIZE).toBe(0.96);
|
|
expect(gotham(12)).toBe(11.52);
|
|
expect(PURPLE_ROLES.label.size).toBe(gotham(12));
|
|
expect(CITRUS_ROLES.headNum.size).toBe(gotham(14.17));
|
|
expect(resolveFace("Gotham")).toBe("Montserrat");
|
|
expect(resolveFace("Now")).toBe("Jost");
|
|
expect(resolveFace("Open Sauce One")).toBe("Poppins");
|
|
// A user-imported font replaces the licensed name before the substitute applies.
|
|
expect(resolveFace("Gotham", { Gotham: "user:Gotham" })).toBe("user:Gotham");
|
|
// The wordmarks: Poppins 800 at -0.08 em (Purple) and Jost 700 at -0.081 em (Citrus).
|
|
expect(PURPLE_ROLES.wordmark).toMatchObject({ face: "Open Sauce One", weight: 800, size: 94.07, tracking: -0.08 });
|
|
expect(CITRUS_ROLES.title).toMatchObject({ face: "Now", weight: 700, size: 47.94, tracking: -0.081 });
|
|
});
|
|
|
|
it("amounts are never set in Jost (it has no rupee sign): Jost is only the Citrus wordmark", () => {
|
|
for (const t of NORTHLINE) {
|
|
for (const [name, token] of Object.entries(t.roles)) {
|
|
const face = resolveFace(token.face);
|
|
if (face === "Jost") expect(name, `${t.id}.${name}`).toBe("title");
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("page geometry", () => {
|
|
it("template margins", () => {
|
|
expect(marginsFor(purplePop, "template")).toEqual({ top: 70, right: 52.48, bottom: 40, left: 54.8 });
|
|
expect(marginsFor(citrusSplit, "template")).toEqual({ top: 58, right: 59.68, bottom: 40, left: 73.3 });
|
|
});
|
|
|
|
it("Letter keeps the physical margins and takes the missing height from the gaps", () => {
|
|
for (const t of NORTHLINE) {
|
|
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 cream (Purple Pop) and blush (Citrus Split) page fills are the Page background, free of any page-level block", async () => {
|
|
const purple = await renderFixture({ name: "p", items: 3, logo: "none" }, prefsFor("A4", { templateId: "purple-pop" }));
|
|
const [a] = await colourAt(purple.result.bytes, [[20, 60]]);
|
|
if (a) expect(near(a, "#FEFFEE"), `cream ${a}`).toBe(true);
|
|
const citrus = await renderFixture({ name: "p", items: 3, logo: "none" }, prefsFor("A4", { templateId: "citrus-split" }));
|
|
const [b] = await colourAt(citrus.result.bytes, [[20, 300]]);
|
|
if (b) expect(near(b, "#FFF7F7"), `blush ${b}`).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("decor primitives", () => {
|
|
it("the asterisk has eight arms of 16.5% and the dome fits its box", () => {
|
|
expect(ASTERISK_PATH.startsWith("M100 41.7H69.9")).toBe(true);
|
|
expect(DOME_PATH).toContain("M89.7,565.3");
|
|
expect(WATERMARK.size).toBe(352.4);
|
|
});
|
|
|
|
it("each watermark box lies on the page (A4 and Letter), anchored to its corner", () => {
|
|
for (const size of ["A4", "LETTER"] as const) {
|
|
const frame = frameForPrefs(prefsFor(size, { templateId: "purple-pop" }));
|
|
const tr = asteriskWatermark(frame, "top-right", "#FFF7F7");
|
|
const bl = asteriskWatermark(frame, "bottom-left", "#FFF7F7");
|
|
expect(tr.x + tr.width).toBeCloseTo(frame.page.width, 6);
|
|
expect(tr.y).toBe(0);
|
|
expect(bl.x).toBe(0);
|
|
expect(bl.y + bl.height).toBeCloseTo(frame.page.height, 6);
|
|
}
|
|
});
|
|
|
|
it("a halftone field is 19 rows of 4 dots with the chevron's diameters, and every field lies on the page", () => {
|
|
const dots = halftoneDots();
|
|
expect(dots).toHaveLength(HALFTONE.rows * HALFTONE.perRow);
|
|
expect(Math.min(...dots.map((d) => d.d))).toBeCloseTo(HALFTONE.minD, 2);
|
|
expect(Math.max(...dots.map((d) => d.d))).toBeCloseTo(HALFTONE.maxD, 2);
|
|
for (const size of ["A4", "LETTER"] as const) {
|
|
const frame = frameForPrefs(prefsFor(size, { templateId: "citrus-split" }));
|
|
for (const f of CITRUS_HALFTONE) {
|
|
const item = halftoneField(frame, f);
|
|
expect(item.x, f.id).toBeGreaterThanOrEqual(0);
|
|
expect(item.y, f.id).toBeGreaterThanOrEqual(0);
|
|
expect(item.x + item.width, f.id).toBeLessThanOrEqual(frame.page.width + 1e-6);
|
|
expect(item.y + item.height, f.id).toBeLessThanOrEqual(frame.page.height + 1e-6);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Purple Pop", () => {
|
|
it("the watermarks are fixed under the flow: two decor svgs at the page corners on every page", async () => {
|
|
const r = await renderFixture({ name: "p", items: 40, logo: "none" }, prefsFor("A4", { templateId: "purple-pop" }));
|
|
const frame = frameForPrefs(r.prefs);
|
|
expect(r.result.pages).toBeGreaterThan(1);
|
|
for (let i = 0; i < r.result.pages; i++) {
|
|
const wm = onPage(r, i).filter((n) => n.type === "svg" && n.decor && n.fixed && n.box.w > 250);
|
|
expect(wm, `page ${i + 1}`).toHaveLength(2);
|
|
const tr = wm.find((n) => n.box.y < 1)!;
|
|
const bl = wm.find((n) => n.box.x < 1)!;
|
|
expect(tr.box.x + tr.box.w).toBeCloseTo(frame.page.width, 1);
|
|
expect(bl.box.y + bl.box.h).toBeCloseTo(frame.page.height, 1);
|
|
}
|
|
});
|
|
|
|
it("a short invoice uses the narrow table beside the asterisk (page 1 only); a longer one the full width, without it", async () => {
|
|
const frame = frameForPrefs(prefsFor("A4", { templateId: "purple-pop" }));
|
|
const short = await renderFixture({ name: "s", items: PLAIN, logo: "none", registered: false }, prefsFor("A4", { templateId: "purple-pop" }));
|
|
const head = onPage(short, 0).find((n) => n.id === TABLE_HEAD_ID)!;
|
|
expect(head.box.x).toBeCloseTo(frame.pad.left + PURPLE_VARIANT.table.narrowLeft, 1);
|
|
const stars = (r: typeof short, i: number) => onPage(r, i).filter((n) => n.type === "svg" && n.decor && !n.fixed);
|
|
expect(stars(short, 0)).toHaveLength(1);
|
|
expect(stars(short, 0)[0].box.w).toBeCloseTo(PURPLE_VARIANT.asterisk.size, 1);
|
|
const long = await renderFixture({ name: "l", items: 12, logo: "none" }, prefsFor("A4", { templateId: "purple-pop" }));
|
|
const head2 = onPage(long, 0).find((n) => n.id === TABLE_HEAD_ID)!;
|
|
expect(head2.box.x).toBeCloseTo(frame.pad.left + PURPLE_VARIANT.table.fullLeft, 1);
|
|
expect(stars(long, 0)).toHaveLength(0);
|
|
// Every continuation page repeats the header at the full-width position.
|
|
for (let i = 1; i < long.result.pages; i++) {
|
|
const h = onPage(long, i).find((n) => n.id === TABLE_HEAD_ID);
|
|
if (h) expect(h.box.x).toBeCloseTo(frame.pad.left + PURPLE_VARIANT.table.fullLeft, 1);
|
|
}
|
|
});
|
|
|
|
it("header and rows are pills: purple head, then grey and yellow rows, flat colours", async () => {
|
|
const r = await renderFixture({ name: "s", items: PLAIN, logo: "none", registered: false }, prefsFor("A4", { templateId: "purple-pop" }));
|
|
const head = onPage(r, 0).find((n) => n.id === TABLE_HEAD_ID)!;
|
|
const rows = [0, 1, 2].map((i) => onPage(r, 0).find((n) => n.id === `${TABLE_ROW_PREFIX}${i}`)!);
|
|
const sample = [head, ...rows].map((n): [number, number] => [n.box.x + 8, n.box.y + n.box.h / 2]);
|
|
const colours = await colourAt(r.result.bytes, sample);
|
|
if (colours.length) {
|
|
expect(near(colours[0], "#530895"), `head ${colours[0]}`).toBe(true);
|
|
expect(near(colours[1], "#EDEDE8"), `row 1 ${colours[1]}`).toBe(true);
|
|
expect(near(colours[2], "#FFD80B"), `row 2 ${colours[2]}`).toBe(true);
|
|
expect(near(colours[3], "#EDEDE8"), `row 3 ${colours[3]}`).toBe(true);
|
|
}
|
|
// Pills are flush: each row starts where the one above ends.
|
|
for (let i = 1; i < rows.length; i++) expect(rows[i].box.y).toBeCloseTo(rows[i - 1].box.y + rows[i - 1].box.h, 1);
|
|
expect(rows[0].box.h).toBeCloseTo(PURPLE_VARIANT.table.rowH, 1);
|
|
expect(head.box.h).toBeCloseTo(PURPLE_VARIANT.table.headH, 1);
|
|
});
|
|
|
|
it("the INVOICE NO pill and both dates are explicit lines; TAX sits above the wordmark on a tax invoice only", async () => {
|
|
const tax = await renderFixture({ name: "t", items: 3, logo: "none" }, prefsFor("A4", { templateId: "purple-pop" }));
|
|
const text = tax.pdf.pages[0].items.map((i) => i.str.replace(/\s+/g, "")).join("").toUpperCase();
|
|
expect(text).toContain("INVOICENO:AP/2026-001");
|
|
expect(text).toContain("INVOICEDATE:15SEPT2026");
|
|
expect(text).toContain("DUEDATE:15OCT2026");
|
|
expect(tax.pdf.pages[0].items.some((i) => i.str.replace(/\s+/g, "") === "TAX")).toBe(true);
|
|
const plain = await renderFixture({ name: "t", items: 3, logo: "none", registered: false }, prefsFor("A4", { templateId: "purple-pop" }));
|
|
expect(plain.pdf.pages[0].items.some((i) => i.str.replace(/\s+/g, "") === "TAX")).toBe(false);
|
|
});
|
|
|
|
it("the wordmark's ink stays inside its box, whatever the page size", async () => {
|
|
for (const size of ["A4", "LETTER"] as const) {
|
|
const r = await renderFixture({ name: "w", items: 3, logo: "none" }, prefsFor(size, { templateId: "purple-pop" }));
|
|
expect(r.result.issues.filter((i) => i.code === "overflow-x"), size).toEqual([]);
|
|
}
|
|
});
|
|
|
|
it("the logo is opt-in: off by default, on in the opt-in area, a mark keeps the name, a wordmark replaces it", async () => {
|
|
expect(purplePop.logo.optIn).toBe(true);
|
|
expect(purplePop.logo.defaultOn).toBe(false);
|
|
expect(purplePop.logo.slot).toBe(OPTIN_SLOTS["purple-pop"]);
|
|
const off = await renderFixture({ name: "o", items: 2, logo: "real" }, prefsFor("A4", { templateId: "purple-pop" }));
|
|
expect(imageBoxes(off)).toHaveLength(0);
|
|
expect(off.pdf.pages[0].items.filter((i) => i.str.trim() === "Arun P" && i.bottom < 130)).toHaveLength(1);
|
|
const on = (logo: FixtureSpec["logo"]) => renderFixture({ name: "o", items: 2, logo }, prefsFor("A4", { templateId: "purple-pop", logo: { show: true } }));
|
|
const wide = await on("real");
|
|
const [img] = imageBoxes(wide);
|
|
expect(imageBoxes(wide)).toHaveLength(1);
|
|
const slot = OPTIN_SLOTS["purple-pop"]!.lockup!;
|
|
expect(img.box.h).toBeLessThanOrEqual(slot.h + 0.01);
|
|
expect(img.box.w).toBeLessThanOrEqual(slot.w + 0.01);
|
|
expect(img.box.x).toBeCloseTo(frameForPrefs(wide.prefs).pad.left + PURPLE_VARIANT.head.nameInset, 0);
|
|
expect(wide.pdf.pages[0].items.filter((i) => i.str.trim() === "Arun P" && i.bottom < 130)).toHaveLength(0);
|
|
const square = await on("square");
|
|
expect(imageBoxes(square)).toHaveLength(1);
|
|
expect(square.pdf.pages[0].items.filter((i) => i.str.trim() === "Arun P" && i.bottom < 130)).toHaveLength(1);
|
|
});
|
|
|
|
it("the dome and the TOTAL pill travel with the totals; amount in words sits under the pill", async () => {
|
|
const r = await renderFixture({ name: "t", items: SHORT, logo: "none" }, prefsFor("A4", { templateId: "purple-pop" }));
|
|
const totals = onPage(r, 0).find((n) => n.id === TOTALS_ID)!;
|
|
const inside = (n: LayoutNode) => n.box.y >= totals.box.y - 0.5 && n.box.y + n.box.h <= totals.box.y + totals.box.h + 0.5;
|
|
expect(onPage(r, 0).filter((n) => n.type === "svg" && !n.decor && inside(n))).toHaveLength(1);
|
|
const words = onPage(r, 0).find((n) => n.id === SLOT_ID.amountInWords)!;
|
|
const pill = onPage(r, 0).find((n) => n.type === "view" && Math.abs(n.box.h - PURPLE_VARIANT.totals.pillH) < 0.6 && n.box.w > 300 && inside(n))!;
|
|
expect(words.box.y).toBeGreaterThanOrEqual(pill.box.y + pill.box.h);
|
|
});
|
|
|
|
it("long terms flow as a block of their own and never make the closing block taller than a page", async () => {
|
|
const spec = FIXTURES.find((f) => f.name === "long-notes")!;
|
|
const r = await renderFixture(spec, prefsFor("A4", { templateId: "purple-pop" }));
|
|
const closing = nodes(r.result.core.layout).find((n) => n.id === CLOSING_ID)!;
|
|
expect(closing.box.h).toBeLessThan(260);
|
|
const plan = planPurple(r.fixture.model, frameForPrefs(r.prefs), PURPLE_VARIANT, null, false);
|
|
expect(plan.terms.beside).toBe(false);
|
|
expect(r.pdf.pages.map((p, i) => textsOf(r, i)).join(" ")).toContain("Note 18");
|
|
});
|
|
});
|
|
|
|
describe("Citrus Split", () => {
|
|
it("the grey stripe is a full-height underlay on every page, anchored to the right margin, on both sizes and every margin preset", async () => {
|
|
for (const size of ["A4", "LETTER"] as const) {
|
|
for (const margins of ["template", "narrow", "normal"] as const) {
|
|
const r = await renderFixture({ name: "p", items: 40, logo: "none" }, prefsFor(size, { templateId: "citrus-split", margins }));
|
|
const frame = frameForPrefs(r.prefs);
|
|
expect(r.result.pages).toBeGreaterThan(1);
|
|
for (let i = 0; i < r.result.pages; i++) {
|
|
const stripe = onPage(r, i).filter((n) => n.type === "view" && n.id === "decor" && n.fixed && Math.abs(n.box.w - CITRUS_VARIANT.stripeW) < 0.01 && n.box.h > frame.page.height - 1);
|
|
expect(stripe, `${size}/${margins} p${i + 1}`).toHaveLength(1);
|
|
expect(stripe[0].box.x).toBeCloseTo(frame.page.width - frame.pad.right - CITRUS_VARIANT.stripeW, 1);
|
|
expect(stripe[0].box.y).toBeCloseTo(0, 1);
|
|
// Three halftone fields on every page.
|
|
expect(onPage(r, i).filter((n) => n.type === "svg" && n.decor && n.fixed), `${size}/${margins} p${i + 1} halftone`).toHaveLength(3);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
it("the numeric columns fill the stripe exactly, so amounts always print on the grey", async () => {
|
|
for (const size of ["A4", "LETTER"] as const) {
|
|
const r = await renderFixture({ name: "p", items: 6, logo: "none" }, prefsFor(size, { templateId: "citrus-split" }));
|
|
const frame = frameForPrefs(r.prefs);
|
|
const stripeX = frame.page.width - frame.pad.right - CITRUS_VARIANT.stripeW;
|
|
const plan = planCitrus(r.fixture.model, frame, CITRUS_VARIANT, null, false);
|
|
expect(plan.cw.qty + plan.cw.rate + plan.cw.amount).toBeCloseTo(CITRUS_VARIANT.stripeW, 1);
|
|
expect(plan.table.widths[0] + frame.pad.left + CITRUS_VARIANT.table.left).toBeCloseTo(stripeX, 1);
|
|
// Every body amount starts inside the stripe.
|
|
for (const t of r.pdf.pages[0].items) if (/^₹[\d,]+\.\d\d$/.test(t.str.trim()) && t.top > 250) expect(t.x0, t.str).toBeGreaterThan(stripeX - 0.5);
|
|
}
|
|
});
|
|
|
|
it("the header: SERVICE on an orange cell, the numeric headings on white; zebra rows span the left part", async () => {
|
|
const r = await renderFixture({ name: "s", items: 3, logo: "none", registered: false }, prefsFor("A4", { templateId: "citrus-split" }));
|
|
const frame = frameForPrefs(r.prefs);
|
|
const head = onPage(r, 0).find((n) => n.id === TABLE_HEAD_ID)!;
|
|
const row1 = onPage(r, 0).find((n) => n.id === `${TABLE_ROW_PREFIX}1`)!;
|
|
expect(head.box.h).toBeCloseTo(CITRUS_VARIANT.table.headH, 0);
|
|
const stripeX = frame.page.width - frame.pad.right - CITRUS_VARIANT.stripeW;
|
|
const colours = await colourAt(r.result.bytes, [
|
|
[head.box.x + 3, head.box.y + 3],
|
|
[stripeX + 3, head.box.y + 3],
|
|
[head.box.x + 3, row1.box.y + 3],
|
|
[head.box.x + 3, row1.box.y - 3],
|
|
]);
|
|
if (colours.length) {
|
|
expect(near(colours[0], "#FF9220"), `SERVICE cell ${colours[0]}`).toBe(true);
|
|
expect(near(colours[1], "#FFFFFF"), `numeric heading band ${colours[1]}`).toBe(true);
|
|
expect(near(colours[2], "#F1F1F1"), `zebra ${colours[2]}`).toBe(true);
|
|
expect(near(colours[3], "#FFF7F7"), `row above is blush ${colours[3]}`).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("the client block sits beside the totals when the invoice fits a page, above the table when it does not", async () => {
|
|
const short = await renderFixture({ name: "s", items: PLAIN, logo: "none", registered: false }, prefsFor("A4", { templateId: "citrus-split" }));
|
|
expect(planCitrus(short.fixture.model, frameForPrefs(short.prefs), CITRUS_VARIANT, null, false).clientAbove).toBe(false);
|
|
const client = short.pdf.pages[0].items.find((i) => i.str.toUpperCase().includes("ACME"))!;
|
|
const headY = onPage(short, 0).find((n) => n.id === TABLE_HEAD_ID)!.box.y;
|
|
expect(client.top).toBeGreaterThan(headY);
|
|
const long = await renderFixture({ name: "l", items: 60, logo: "none" }, prefsFor("A4", { templateId: "citrus-split" }));
|
|
expect(planCitrus(long.fixture.model, frameForPrefs(long.prefs), CITRUS_VARIANT, null, false).clientAbove).toBe(true);
|
|
const client2 = long.pdf.pages[0].items.find((i) => i.str.toUpperCase().includes("ACME"))!;
|
|
expect(client2.top).toBeLessThan(onPage(long, 0).find((n) => n.id === TABLE_HEAD_ID)!.box.y);
|
|
});
|
|
|
|
it("the contact list carries three icons in the closing block, right-aligned beside the stripe's right edge", async () => {
|
|
const r = await renderFixture({ name: "s", items: 3, logo: "none", registered: false }, prefsFor("A4", { templateId: "citrus-split" }));
|
|
const closing = onPage(r, r.result.pages - 1).find((n) => n.id === CLOSING_ID)!;
|
|
const icons = onPage(r, r.result.pages - 1).filter((n) => n.type === "svg" && !n.decor && n.box.y >= closing.box.y && n.box.w === CITRUS_VARIANT.closing.iconSize);
|
|
expect(icons).toHaveLength(3);
|
|
const frame = frameForPrefs(r.prefs);
|
|
for (const i of icons) expect(i.box.x + i.box.w).toBeLessThanOrEqual(frame.page.width - frame.pad.right + 0.5);
|
|
const text = textsOf(r, r.result.pages - 1);
|
|
expect(text).toContain("9738284459");
|
|
expect(text).toContain("arunakira113@gmail.com");
|
|
});
|
|
|
|
it("the logo stays inside its measured lockup box; a wordmark replaces the name, a mark keeps it", async () => {
|
|
const slot = SLOTS["citrus-split"]!.lockup!;
|
|
const withLogo = { name: "o", items: 2, logo: "real" as const };
|
|
const wide = await renderFixture(withLogo, prefsFor("A4", { templateId: "citrus-split" }));
|
|
const [img] = imageBoxes(wide);
|
|
expect(imageBoxes(wide)).toHaveLength(1);
|
|
expect(img.box.h).toBeLessThanOrEqual(slot.h + 0.6);
|
|
expect(img.box.w).toBeLessThanOrEqual(slot.w + 0.6);
|
|
const frame = frameForPrefs(wide.prefs);
|
|
expect(img.box.x).toBeGreaterThanOrEqual(frame.page.width - frame.pad.right - CITRUS_VARIANT.stripeW);
|
|
expect(wide.pdf.pages[0].items.filter((i) => i.str.trim() === "Arun P" && i.bottom < 130)).toHaveLength(0);
|
|
const square = await renderFixture({ ...withLogo, logo: "square" }, prefsFor("A4", { templateId: "citrus-split" }));
|
|
expect(square.pdf.pages[0].items.filter((i) => i.str.trim() === "Arun P" && i.bottom < 130)).toHaveLength(1);
|
|
const none = await renderFixture({ ...withLogo, logo: "none" }, prefsFor("A4", { templateId: "citrus-split" }));
|
|
expect(none.pdf.pages[0].items.filter((i) => i.str.trim() === "Arun P" && i.bottom < 130)).toHaveLength(1);
|
|
});
|
|
|
|
it("the totals label column never wraps ('TAXABLE VALUE', 'UTGST @ 14%')", async () => {
|
|
const r = await renderFixture({ name: "t", items: 3, logo: "none", vendor: { vendorStateCode: "04", vendorGstin: "04AAAAA0000A1Z5" }, invoice: { placeOfSupplyStateCode: "04" } }, prefsFor("A4", { templateId: "citrus-split" }));
|
|
const totals = onPage(r, 0).find((n) => n.id === TOTALS_ID)!;
|
|
const labels = nodes([r.result.core.layout[0]]).filter((n) => n.type === "text" && n.box.y >= totals.box.y && /TAXABLE|UTGST|CGST/.test(n.text ?? ""));
|
|
expect(labels.length).toBeGreaterThan(2);
|
|
for (const l of labels) expect(l.lines).toHaveLength(1);
|
|
});
|
|
|
|
it("long terms flow as a block of their own", async () => {
|
|
const r = await renderFixture(FIXTURES.find((f) => f.name === "long-notes")!, prefsFor("A4", { templateId: "citrus-split" }));
|
|
expect(planCitrus(r.fixture.model, frameForPrefs(r.prefs), CITRUS_VARIANT, null, false).closing.termsBeside).toBe(false);
|
|
const closing = nodes(r.result.core.layout).find((n) => n.id === CLOSING_ID)!;
|
|
expect(closing.box.h).toBeLessThan(300);
|
|
});
|
|
});
|
|
|
|
describe("the GST slots and the closing", () => {
|
|
it("every GST slot has a home on a tax invoice", async () => {
|
|
for (const t of NORTHLINE) {
|
|
const r = await renderFixture({ name: "t", items: 2, logo: "none", signature: true }, 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.gstin}-client`, SLOT_ID.pos, SLOT_ID.taxRows, SLOT_ID.amountInWords, SLOT_ID.signatory, SLOT_ID.reverseCharge]) expect(ids.has(id), `${t.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", "CGST@9%", "SGST@9%", "HSN/SAC:998397", "PAN:CPOPP7388K"]) expect(flat, `${t.id} ${word}`).toContain(word);
|
|
}
|
|
});
|
|
|
|
it("an unregistered invoice carries none of the GST slots", async () => {
|
|
for (const t of NORTHLINE) {
|
|
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 NORTHLINE) {
|
|
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([]);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
it("a long invoice number shrinks inside the Purple Pop pill and the vendor name keeps its room", async () => {
|
|
const r = await renderFixture({ name: "n", items: 2, logo: "none", invoice: { number: "AB/2026-27/0012345" } }, prefsFor("A4", { templateId: "purple-pop" }));
|
|
expect(r.result.issues.filter((i) => i.severity === "error")).toEqual([]);
|
|
expect(r.pdf.pages[0].items.map((i) => i.str.replace(/\s+/g, "")).join("")).toContain("AB/2026-27/0012345");
|
|
});
|
|
});
|
|
|
|
describe("plan matches the laid-out blocks", () => {
|
|
for (const t of NORTHLINE) {
|
|
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 (Purple Pop's logo is off anyway), 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 = t.id === "purple-pop" ? planPurple(r.fixture.model, frame, PURPLE_VARIANT, null, false) : planCitrus(r.fixture.model, frame, CITRUS_VARIANT, null, false);
|
|
expect(plan.tailH - find(TOTALS_ID), "tail prediction vs layout").toBeGreaterThanOrEqual(-0.01);
|
|
expect(plan.tailH - find(TOTALS_ID)).toBeLessThanOrEqual(20 + plan.gaps.tail);
|
|
expect(plan.closingH - find(CLOSING_ID), "closing prediction vs layout").toBeGreaterThanOrEqual(-0.01);
|
|
// A wrapped footer or contact line is estimated at most a line or two high (never low).
|
|
expect(plan.closingH - find(CLOSING_ID)).toBeLessThanOrEqual(24);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
describe("measured fit", () => {
|
|
it("the Purple Pop wordmark and the Citrus title take about the design's width in the substitute faces", () => {
|
|
const m = getMeasurer();
|
|
// Canva: 'Invoice' 292.2 pt wide (Open Sauce One 700); 'INVOICE' 189.7 pt (Now Bold).
|
|
expect(Math.abs(measureToken(m, PURPLE_ROLES.wordmark, "Invoice") - 292.2)).toBeLessThan(16);
|
|
expect(Math.abs(measureToken(m, CITRUS_ROLES.title, "Invoice") - 189.7)).toBeLessThan(16);
|
|
});
|
|
});
|
|
|
|
describe("design docs", () => {
|
|
it("each Northline doc records its implementation notes", () => {
|
|
for (const f of ["12-purple-pop.md", "13-citrus-split.md"]) {
|
|
const text = readFileSync(path.join(process.cwd(), "docs/templates", f), "utf8");
|
|
expect(text, f).toContain("## Implementation notes");
|
|
}
|
|
});
|
|
});
|
|
|
|
void buildFixture;
|