Add the Marble and Highlighter templates, completing the 13 Canva templates
Marble uses a flat-to-gradient band in place of the marble photo and bundles Lustria (OFL). Both logo slots are opt-in. Quirk 13 documents a fontkit glyph-cache issue that the tests work around with warm-up renders; the engine fix follows separately.
This commit is contained in:
@@ -0,0 +1,483 @@
|
||||
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, "Sample P")[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, "Sample P")).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) === "SampleP" && 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: "Sample.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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user