The template harness, tests and thumbnails now use a neutral vendor, bank account and a generated sample wordmark (scripts/logo/make-sample-logo.mjs). Layout goldens and the 14 picker thumbnails are regenerated. Fixture strings keep their original lengths where layout tests depend on them.
484 lines
28 KiB
TypeScript
484 lines
28 KiB
TypeScript
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { describe, expect, it } from "vitest";
|
|
import { FONT_MANIFEST } from "../../fonts/manifest";
|
|
import { familySources } from "../../fonts/stack";
|
|
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 { 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 { IDS_ID, PAYABLE_ID } from "./Layout";
|
|
import { marble } from "./marble";
|
|
import { planMarble } from "./plan";
|
|
import { MARBLE_ROLES, MARBLE_VARIANT, RULE, ZEBRA } from "./tokens";
|
|
|
|
ensureSetup();
|
|
|
|
const V = MARBLE_VARIANT;
|
|
const slot = OPTIN_SLOTS.marble!;
|
|
type R = Awaited<ReturnType<typeof renderFixture>>;
|
|
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 squash = (s: string) => s.replace(/\s+/g, "");
|
|
/** Text items of page `p` whose text, spaces removed, is `text`. */
|
|
const items = (r: R, text: string, p = 0) => r.pdf.pages[p].items.filter((i) => squash(i.str) === squash(text));
|
|
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: "marble", logo: { show: true } });
|
|
/** The Canva sample's shape: three rows, no GST data, no due date. */
|
|
const PLAIN: FixtureSpec = {
|
|
name: "plain",
|
|
items: ["Brand consultation", "Logo design", "Website design"].map((description, i) => ({ id: i + 1, description, mode: "rate" as const, rate: 100, unit: "hour" as const, quantity: 1, amount: 0, sortOrder: i })),
|
|
registered: false,
|
|
logo: "none",
|
|
invoice: { dueDate: "", notes: "" },
|
|
};
|
|
|
|
/** RGB of pixels of page `page` at 4x. */
|
|
async function colourAt(bytes: Uint8Array, points: Array<[number, number]>, page = 1): Promise<number[][]> {
|
|
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("marble")).toBe(marble);
|
|
expect(marble.family).toBe("standalone");
|
|
expect(catalogEntry("marble").name).toBe("Marble");
|
|
expect(catalogEntry("marble").logoOptIn).toBe(true);
|
|
expect(existsSync(path.join(process.cwd(), "public", marble.thumbnail)), marble.thumbnail).toBe(true);
|
|
expect(Object.keys(TEMPLATES).slice(-2)).toEqual(["marble", "highlighter"]);
|
|
expect(TEMPLATE_CATALOG.map((e) => e.id)).toEqual(Object.keys(TEMPLATES));
|
|
});
|
|
|
|
it("sets Lustria for the tracked caps and Now (drawn in Jost) for the text, in the design's palette only", () => {
|
|
for (const [name, token] of Object.entries(MARBLE_ROLES)) {
|
|
expect(["Lustria", "Now"], name).toContain(token.face);
|
|
expect(token.weight, name).toBe(400);
|
|
}
|
|
const lustria = FONT_MANIFEST.families.find((f) => f.family === "Lustria")!;
|
|
expect(lustria.license).toBe("OFL-1.1");
|
|
expect(lustria.fonts.map((f) => f.file)).toEqual(["Lustria-Regular.ttf"]);
|
|
// Lustria has no italic: the registration reuses the upright file, so a fallback stack never fails to resolve one.
|
|
expect(familySources(lustria).map((s) => `${s.fontWeight}/${s.fontStyle}`)).toEqual(["400/normal", "400/italic"]);
|
|
const dir = path.join(process.cwd(), "src/pdf/templates/marble");
|
|
const palette = ["#000000", RULE, ZEBRA, "#F4F3F5", "#ECEAEE"].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(marble.decor.ink).toBe("#000000");
|
|
});
|
|
|
|
it("the tracked rupee amounts come from the fallback font: every amount extracts with its sign", async () => {
|
|
const r = await renderFixture({ name: "t", items: 3 }, prefsFor("A4", { templateId: "marble" }));
|
|
const flat = r.pdf.pages.flatMap((p) => p.items.map((i) => i.str)).join("");
|
|
expect(flat).toContain("₹");
|
|
expect(r.pdf.raw).not.toContain("/BaseFont /Helvetica");
|
|
});
|
|
});
|
|
|
|
describe("page geometry", () => {
|
|
it("template margins, floors and the bands", () => {
|
|
expect(marginsFor(marble, "template")).toEqual({ top: 86, right: 45.8, bottom: 24, left: 31.5 });
|
|
expect(marginsFor(marble, "narrow").left).toBe(36);
|
|
expect(marginsFor(marble, "narrow").top).toBe(86);
|
|
// The flow starts under the 64 pt top band (and the continuation text) and stops above the 130 pt foot band.
|
|
const frame = frameForPrefs(prefsFor("A4", { templateId: "marble" }));
|
|
expect(frame.pad.top).toBeGreaterThanOrEqual(V.bands.top + 18);
|
|
expect(frame.page.height - frame.pad.bottom).toBeLessThanOrEqual(frame.page.height - V.bands.bottom - 10);
|
|
});
|
|
|
|
it("Letter keeps the physical margins and takes the missing height from the gaps", () => {
|
|
const a4 = frameForPrefs(prefsFor("A4", { templateId: "marble" }));
|
|
const letter = frameForPrefs(prefsFor("LETTER", { templateId: "marble" }));
|
|
expect(letter.pad).toEqual(a4.pad);
|
|
expect(letter.heightDeficit).toBeCloseTo(49.89, 2);
|
|
});
|
|
|
|
it("both bands are on every page, full bleed", async () => {
|
|
const r = await renderFixture({ name: "rows", items: 40, signature: true }, prefsFor("A4", { templateId: "marble" }));
|
|
expect(r.result.pages).toBeGreaterThan(1);
|
|
for (let p = 0; p < r.result.pages; p++) {
|
|
const decor = onPage(r, p).filter((n) => n.decor && n.type === "svg");
|
|
const boxes = decor.map((n) => n.box);
|
|
expect(boxes.some((b) => b.y === 0 && Math.abs(b.h - V.bands.top) < 0.01 && b.w >= 595), `page ${p + 1} top band`).toBe(true);
|
|
expect(boxes.some((b) => Math.abs(b.y + b.h - 841.89) < 0.5 && Math.abs(b.h - V.bands.bottom) < 0.01), `page ${p + 1} foot band`).toBe(true);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("header", () => {
|
|
it("matches the design: title baseline 124.9, then the number, Issued and Due lines at the 17.5 pt pitch, all ending on the right edge", async () => {
|
|
const r = await renderFixture(PLAIN, prefsFor("A4", { templateId: "marble" }));
|
|
const right = contentRight(r);
|
|
const title = items(r, "INVOICE")[0];
|
|
expect(title.bottom).toBeCloseTo(124.9, 0);
|
|
expect(title.x1).toBeCloseTo(right, 0);
|
|
const number = r.pdf.pages[0].items.find((i) => squash(i.str).startsWith("InvoiceNo."))!;
|
|
const issued = r.pdf.pages[0].items.find((i) => squash(i.str).startsWith("Issued:"))!;
|
|
const due = r.pdf.pages[0].items.find((i) => squash(i.str).startsWith("Due:"))!;
|
|
for (const [what, it, baseline] of [["number", number, 142.1], ["issued", issued, 159.6], ["due", due, 177.1]] as const) {
|
|
expect(it.bottom, what).toBeCloseTo(baseline, 0);
|
|
expect(it.x1, what).toBeCloseTo(right, 0);
|
|
}
|
|
expect(squash(due.str)).toBe("Due:OnReceipt");
|
|
});
|
|
|
|
it("the vendor name stands in the empty left corner, level with the title, in Lustria caps", async () => {
|
|
const r = await renderFixture(PLAIN, prefsFor("A4", { templateId: "marble" }));
|
|
const name = items(r, "TEST VENDOR")[0];
|
|
expect(name.bottom).toBeCloseTo(124.9, 0);
|
|
expect(name.x0).toBeCloseTo(38.5, 0);
|
|
});
|
|
|
|
it("TAX INVOICE and a long invoice number never reach the vendor name", 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/REV-3/LONG-REFERENCE-NUMBER" } }, prefsFor(size, { templateId: "marble" }));
|
|
const supplier = textLines(byId(r, SUPPLIER_ID));
|
|
const stack = [...textLines(byId(r, TITLE_ID)), ...textLines(byId(r, META_ID))];
|
|
expect(Math.max(...supplier.map((l) => l.right)), size).toBeLessThan(Math.min(...stack.map((l) => l.x)) - 10);
|
|
expect(r.result.issues.filter((i) => i.severity === "error"), size).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: "marble" }));
|
|
expect(textLines(byId(r, META_ID)).some((l) => l.text === "CANCELLED")).toBe(true);
|
|
expect(squash(r.pdf.pages[r.result.pages - 1].items.map((i) => i.str).join(""))).toContain(squash("This invoice has been cancelled."));
|
|
});
|
|
});
|
|
|
|
describe("parties band", () => {
|
|
it("BILL TO and the first lines sit where the design has them (heading 247.9, lines at the 18.2 pt pitch from 269.0)", async () => {
|
|
const r = await renderFixture(PLAIN, prefsFor("A4", { templateId: "marble" }));
|
|
const heading = items(r, "BILL TO:")[0];
|
|
expect(heading.bottom).toBeCloseTo(247.9, 0);
|
|
expect(heading.x0).toBeCloseTo(38.5, 0);
|
|
const first = items(r, "Acme Learning Pvt Ltd")[0];
|
|
expect(first.bottom).toBeCloseTo(269.0, 0);
|
|
const pay = items(r, "PAYABLE TO:")[0];
|
|
expect(pay.bottom).toBeCloseTo(247.9, 0);
|
|
expect(pay.x0).toBeCloseTo(211.4, 0);
|
|
});
|
|
|
|
it("the supplier's GSTIN and PAN are in the third column, and the hairlines are the mauve-grey 0.75 pt rules", async () => {
|
|
const r = await renderFixture({ name: "t", items: 3, logo: "none" }, prefsFor("A4", { templateId: "marble" }));
|
|
const ids = textLines(byId(r, IDS_ID)).map((l) => l.text);
|
|
expect(ids).toEqual(expect.arrayContaining(["SUPPLIER:", "GSTIN: 29AAAAA0000A1Z5", "PAN: ABCDE1234F"]));
|
|
expect(textLines(byId(r, PAYABLE_ID)).map((l) => l.text)).toContain("PAYABLE TO:");
|
|
const plan = planMarble(r.fixture.model, frameForPrefs(r.prefs), V, null, false);
|
|
const rules = onPage(r, 0).filter((n) => n.type === "view" && Math.abs(n.box.h - V.rule) < 0.01 && Math.abs(n.box.w - plan.W) < 0.5);
|
|
expect(rules.length).toBeGreaterThanOrEqual(2);
|
|
const [c] = await colourAt(r.result.bytes, [[300, rules[0].box.y + 0.375]]);
|
|
expect(near(c, RULE, 14), `rule ${c}`).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("logo", () => {
|
|
it("is an opt-in slot (the design has no placeholder): off by default, 150 x 50 at the left, bottom edge on the title's baseline", () => {
|
|
expect(SLOTS.marble).toBeNull();
|
|
expect(marble.logo.slot).toBe(slot);
|
|
expect(marble.logo.optIn).toBe(true);
|
|
expect(marble.logo.defaultOn).toBe(false);
|
|
expect(slot.lockup).toMatchObject({ x: 38.5, y: 86, w: 150, h: 50 });
|
|
expect(marble.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: "marble" }));
|
|
expect(imageBoxes(r)).toHaveLength(0);
|
|
expect(items(r, "TEST VENDOR")).toHaveLength(1);
|
|
});
|
|
|
|
it("every opted-in logo fits the 150 x 50 slot, starts at the text inset and rests on its bottom edge", 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, `${size}/${logo} left`).toBeCloseTo(frameForPrefs(r.prefs).pad.left + V.inset.left, 0);
|
|
expect(img.box.y + img.box.h, `${size}/${logo} bottom`).toBeCloseTo(frameForPrefs(r.prefs).pad.top + 50, 0);
|
|
// The logo box and the supplier block never reach the title stack.
|
|
const stack = byId(r, META_ID);
|
|
expect(byId(r, SUPPLIER_ID).box.x + byId(r, SUPPLIER_ID).box.w).toBeLessThanOrEqual(stack.box.x + 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 header = (r: R) => r.pdf.pages[0].items.filter((i) => squash(i.str) === "TESTVENDOR" && i.bottom < 200);
|
|
const wide = await renderFixture({ name: "o", items: 2, logo: "real" }, optIn());
|
|
expect(header(wide)).toHaveLength(0);
|
|
const square = await renderFixture({ name: "o", items: 2, logo: "square" }, optIn());
|
|
const [name] = header(square);
|
|
expect(name).toBeTruthy();
|
|
expect(name.bottom - 12).toBeGreaterThan(imageBoxes(square)[0].box.y + imageBoxes(square)[0].box.h - 1);
|
|
});
|
|
});
|
|
|
|
describe("table", () => {
|
|
it("zebra rows on the 1st, 3rd ... (0-based even) in #F7F7F8, white between, no rule or fill on the heading", async () => {
|
|
const r = await renderFixture(PLAIN, prefsFor("A4", { templateId: "marble" }));
|
|
const row = (i: number) => byId(r, `${TABLE_ROW_PREFIX}${i}`).box;
|
|
const head = byId(r, TABLE_HEAD_ID).box;
|
|
const pts: Array<[number, number]> = [[34, row(0).y + 2], [34, row(1).y + 2], [34, row(2).y + 2], [34, head.y + 4]];
|
|
const colours = await colourAt(r.result.bytes, pts);
|
|
if (colours.length === 0) return;
|
|
expect(near(colours[0], ZEBRA, 4), `row 1 ${colours[0]}`).toBe(true);
|
|
expect(near(colours[1], "#FFFFFF", 4), `row 2 ${colours[1]}`).toBe(true);
|
|
expect(near(colours[2], ZEBRA, 4), `row 3 ${colours[2]}`).toBe(true);
|
|
expect(near(colours[3], "#FFFFFF", 4), `heading ${colours[3]}`).toBe(true);
|
|
});
|
|
|
|
it("rows are 32 pt, the first band starts 8.85 pt under the heading and qty is centred where the design centres it (x 355.5)", async () => {
|
|
const r = await renderFixture(PLAIN, prefsFor("A4", { templateId: "marble" }));
|
|
const head = byId(r, TABLE_HEAD_ID).box;
|
|
const r0 = byId(r, `${TABLE_ROW_PREFIX}0`).box;
|
|
const r1 = byId(r, `${TABLE_ROW_PREFIX}1`).box;
|
|
expect(r0.h).toBeCloseTo(32, 0);
|
|
expect(r1.y - r0.y).toBeCloseTo(32, 0);
|
|
expect(r0.y - (head.y + head.h)).toBeCloseTo(V.table.headGap, 0);
|
|
const qty = textLines(byId(r, `${TABLE_ROW_PREFIX}0`)).find((l) => l.text === "1")!;
|
|
expect((qty.x + qty.right) / 2).toBeCloseTo(355.5, 0);
|
|
expect(r0.x).toBeCloseTo(31.5, 0);
|
|
});
|
|
|
|
it("amounts are right-aligned 9.5 pt inside the edge so the rupee figures line up", async () => {
|
|
const r = await renderFixture(PLAIN, prefsFor("A4", { templateId: "marble" }));
|
|
const right = contentRight(r);
|
|
for (let i = 0; i < 3; i++) {
|
|
const lines = textLines(byId(r, `${TABLE_ROW_PREFIX}${i}`));
|
|
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);
|
|
}
|
|
});
|
|
|
|
it("the rate keeps its unit on one line while the column can grow to take it, and an HSN/SAC code is a small sub-line", async () => {
|
|
const r = await renderFixture({ name: "t", items: 3, logo: "none" }, prefsFor("A4", { templateId: "marble" }));
|
|
const row0 = textLines(byId(r, `${TABLE_ROW_PREFIX}0`)).map((l) => l.text);
|
|
expect(row0).toContain("₹1,200.00/hr");
|
|
expect(row0).toContain("HSN/SAC: 998397");
|
|
});
|
|
|
|
it("repeats the heading on every continuation page and never leaves it alone", async () => {
|
|
const r = await renderFixture({ name: "rows", items: 40 }, prefsFor("A4", { templateId: "marble" }));
|
|
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, closing and the foot band", () => {
|
|
it("AMOUNT DUE ends on the totals edge (x 535.4), under a stack of SUBTOTAL and the tax rows", async () => {
|
|
const r = await renderFixture({ name: "t", items: 3, logo: "none" }, prefsFor("A4", { templateId: "marble" }));
|
|
const totals = byId(r, TOTALS_ID, r.result.pages - 1);
|
|
const lines = textLines(totals);
|
|
const due = lines.find((l) => /^AMOUNT DUE:/.test(l.text))!;
|
|
expect(due.right).toBeCloseTo(contentRight(r) - V.totals.rightInset, 0);
|
|
const labels = lines.map((l) => l.text);
|
|
expect(labels).toEqual(expect.arrayContaining(["SUBTOTAL", "TAXABLE VALUE", "CGST 9%", "SGST 9%"]));
|
|
expect(due.y).toBeGreaterThan(lines.find((l) => l.text === "SGST 9%")!.y);
|
|
});
|
|
|
|
it("the totals stack, the words and the reverse-charge line never overlap, 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: "marble" }));
|
|
expect(r.result.issues.filter((i) => i.severity === "error")).toEqual([]);
|
|
const totals = byId(r, TOTALS_ID, r.result.pages - 1);
|
|
const lines = textLines(totals);
|
|
for (let i = 0; i < lines.length; i++)
|
|
for (let j = i + 1; j < lines.length; j++) {
|
|
const a = lines[i];
|
|
const b = lines[j];
|
|
const overlap = Math.min(a.right, b.right) - 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;
|
|
expect(overlap, `${a.text} / ${b.text}`).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("the signature sits in the flow above the foot band, never inside it", async () => {
|
|
for (const size of ["A4", "LETTER"] as const) {
|
|
const r = await renderFixture({ name: "c", items: 3, logo: "none", signature: true }, prefsFor(size, { templateId: "marble" }));
|
|
const last = r.result.pages - 1;
|
|
const sign = byId(r, SLOT_ID.signatory, last);
|
|
expect(sign, size).toBeTruthy();
|
|
const frame = frameForPrefs(r.prefs);
|
|
expect(sign.box.y + sign.box.h, size).toBeLessThanOrEqual(frame.page.height - V.bands.bottom + 0.5);
|
|
expect(byId(r, CLOSING_ID, last).box.x + byId(r, CLOSING_ID, last).box.w).toBeLessThanOrEqual(frame.page.width - frame.pad.right + 0.5);
|
|
}
|
|
});
|
|
|
|
it("the foot band carries the vendor's name and address in column 1 (x 40.7) and the phone and e-mail in column 2 (x 197.8 while the address is short)", async () => {
|
|
const short = await renderFixture({ ...PLAIN, vendor: { vendorAddress: "123 Anywhere St.\nAny City, ST 12345", vendorName: "Olivia Wilson", vendorPhone: "123-456-7890", vendorEmail: "hello@reallygreatsite.com" } }, prefsFor("A4", { templateId: "marble" }));
|
|
const name = items(short, "Olivia Wilson").filter((i) => i.bottom > 700)[0];
|
|
expect(name.x0).toBeCloseTo(40.7, 0);
|
|
expect(name.bottom).toBeCloseTo(746.9, 0);
|
|
expect(items(short, "123 Anywhere St.")[0].bottom).toBeCloseTo(762.2, 0);
|
|
expect(items(short, "Any City, ST 12345")[0].bottom).toBeCloseTo(777.5, 0);
|
|
const phone = items(short, "123-456-7890")[0];
|
|
expect(phone.x0).toBeCloseTo(197.8, 0);
|
|
expect(phone.bottom).toBeCloseTo(746.9, 0);
|
|
// A long address takes the first column's full width and the second column moves right of it.
|
|
const long = await renderFixture({ ...PLAIN }, prefsFor("A4", { templateId: "marble" }));
|
|
expect(items(long, "9000000000")[0].x0).toBeGreaterThan(197.8 + 20);
|
|
});
|
|
|
|
it("a short invoice with the sample's data fits one A4 page by giving back the gaps and row padding", 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: "marble" }));
|
|
expect(r.result.pages).toBe(1);
|
|
expect(r.result.issues.filter((i) => i.severity === "error")).toEqual([]);
|
|
});
|
|
});
|
|
|
|
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: "marble" }));
|
|
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 === IDS_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", "CGST9%", "SGST9%", "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: "marble" }));
|
|
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: "marble" }));
|
|
expect(r.result.issues.filter((i) => i.severity === "error"), `${size}/${name}`).toEqual([]);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("the foot band's text stays inside the band whatever the address, and its two columns never overlap", async () => {
|
|
for (const size of ["A4", "LETTER"] as const) {
|
|
const r = await renderFixture({ name: "long", items: 3, vendor: { vendorAddress: Array.from({ length: 8 }, (_, i) => `Address line ${i + 1} of a very long Indian address with a locality name`).join("\n"), vendorEmail: "anon.longsurname.voiceover.studio.bangalore@examplestudios-india.co.in", vendorName: "Supercalifragilisticexpialidocious-Pneumonoultramicroscopic-1234567890" } }, prefsFor(size, { templateId: "marble" }));
|
|
const frame = frameForPrefs(r.prefs);
|
|
const decorText = onPage(r, 0).filter((n) => n.decor && n.type === "text" && n.box.y > frame.page.height - V.bands.bottom);
|
|
const lines = decorText.flatMap((n) => textLines(n));
|
|
expect(lines.length).toBeGreaterThan(2);
|
|
for (const l of lines) {
|
|
expect(l.y + l.h, `${size} "${l.text}" bottom`).toBeLessThanOrEqual(frame.page.height - 12);
|
|
expect(l.right, `${size} "${l.text}" right`).toBeLessThanOrEqual(frame.page.width - frame.pad.right + 1);
|
|
}
|
|
for (let i = 0; i < lines.length; i++)
|
|
for (let j = i + 1; j < lines.length; j++) {
|
|
const a = lines[i];
|
|
const b = lines[j];
|
|
const hit = Math.min(a.right, b.right) - 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;
|
|
expect(hit, `${size}: "${a.text}" / "${b.text}"`).toBe(false);
|
|
}
|
|
}
|
|
});
|
|
|
|
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: "marble" }));
|
|
const plan = planMarble(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(squash(r.pdf.pages.map((p) => p.items.map((i) => i.str).join("")).join(""))).toContain("Note18");
|
|
});
|
|
|
|
it("a long client name and the supplier columns stay in their own columns", async () => {
|
|
const r = await renderFixture(FIXTURES.find((f) => f.name === "id-tokens")!, prefsFor("A4", { templateId: "marble" }));
|
|
const a = textLines(byId(r, "marble-bill"));
|
|
const b = textLines(byId(r, PAYABLE_ID));
|
|
expect(Math.max(...a.map((l) => l.right))).toBeLessThan(Math.min(...b.map((l) => l.x)));
|
|
});
|
|
});
|
|
|
|
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: "marble" });
|
|
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" }, 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 = planMarble(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/01-marble.md"), "utf8");
|
|
expect(text).toContain("## Implementation notes");
|
|
});
|
|
});
|