When only the closing block moved to page 2, the page-1 deficit was shared across every gap, including the closing block's own gaps, which cannot help page 1. Page 1 then came out a few points short and the totals spilled over with the closing block. Monolith and Cobalt Stripe now squeeze only the page-1 gaps in that case, as Marble and Highlighter already do. Monolith's flow notes block could start in the last few points of a page and be cut into zero-height lines. It now holds 70 pt of room under its label first, as Marble does. Adds tests for both cases (they failed before the fix) and notes in the template specs. Fingerprint goldens and thumbnails are unchanged.
434 lines
25 KiB
TypeScript
434 lines
25 KiB
TypeScript
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { describe, expect, it } from "vitest";
|
|
import { CLOSING_ID, TABLE_HEAD_ID, TABLE_ROW_PREFIX, TOTALS_ID } from "../../engine/ids";
|
|
import { walk, type LayoutNode } from "../../engine/layoutTree";
|
|
import { SLOT_ID } from "../../blocks/slots";
|
|
import { frameForPrefs, renderInvoicePdf } from "../../render/invoice";
|
|
import { loadCanvas, pagePng } from "../../testing/rasterize";
|
|
import { 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 { SLOTS } from "../slots.generated";
|
|
import { cobaltStripe } from "./cobalt";
|
|
import { planCobalt } from "./plan";
|
|
import { COBALT, COBALT_ROLES, COBALT_VARIANT, SKY, ZEBRA } from "./tokens";
|
|
|
|
ensureSetup();
|
|
|
|
const V = COBALT_VARIANT;
|
|
const slot = SLOTS["cobalt-stripe"]!;
|
|
const nodes = (layout: { nodes: LayoutNode[] }[]) => {
|
|
const out: LayoutNode[] = [];
|
|
for (const p of layout) walk(p.nodes, (n) => out.push(n));
|
|
return out;
|
|
};
|
|
type R = Awaited<ReturnType<typeof renderFixture>>;
|
|
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 }> = [];
|
|
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 });
|
|
});
|
|
return out;
|
|
};
|
|
const contentRight = (r: R) => {
|
|
const f = frameForPrefs(r.prefs);
|
|
return f.page.width - f.pad.right;
|
|
};
|
|
/** The reference sample's shape: no due date, no logo, nothing the design has no slot for. */
|
|
const NAMES = ["Logo Design", "Website Design", "Brand Design", "Banner Design", "Flyer Design", "Social Media Template", "Name Card", "Web Developer"];
|
|
const PLAIN: FixtureSpec = {
|
|
name: "plain",
|
|
items: NAMES.map((description, i) => ({ id: i + 1, description, mode: "rate" as const, rate: 100, unit: "hour" as const, quantity: 1, amount: 0, sortOrder: i })),
|
|
registered: false,
|
|
logo: "none",
|
|
invoice: { dueDate: "", notes: "" },
|
|
};
|
|
|
|
/** RGB 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));
|
|
}
|
|
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 before Marble and Highlighter, as a standalone template with its thumbnail on disk", () => {
|
|
expect(getTemplate("cobalt-stripe")).toBe(cobaltStripe);
|
|
expect(cobaltStripe.family).toBe("standalone");
|
|
expect(catalogEntry("cobalt-stripe").name).toBe("Cobalt Stripe");
|
|
expect(existsSync(path.join(process.cwd(), "public", cobaltStripe.thumbnail)), cobaltStripe.thumbnail).toBe(true);
|
|
expect(Object.keys(TEMPLATES).slice(-3, -2)).toEqual(["cobalt-stripe"]);
|
|
expect(TEMPLATE_CATALOG.map((e) => e.id)).toEqual(Object.keys(TEMPLATES));
|
|
});
|
|
|
|
it("uses Poppins 400/700 and Open Sans 400/700 only, and the design's palette only", () => {
|
|
for (const [name, token] of Object.entries(COBALT_ROLES)) {
|
|
expect(["Poppins", "Open Sans"], name).toContain(token.face);
|
|
expect([400, 700], name).toContain(token.weight);
|
|
}
|
|
const palette = new Set([COBALT, ZEBRA, SKY, "#1B1B1B", "#595A5A", "#A6A6A6", "#FFFFFF", "#000000"].map((c) => c.toLowerCase()));
|
|
const dir = path.join(process.cwd(), "src/pdf/templates/cobalt");
|
|
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]{6}\b/g) ?? [];
|
|
expect(colours.filter((c) => !palette.has(c.toLowerCase())), f).toEqual([]);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("page geometry", () => {
|
|
it("template margins: the logo's top is the page margin, the footer's bottom sits 59.5 pt above the page foot", () => {
|
|
expect(marginsFor(cobaltStripe, "template")).toEqual({ top: 54.2, right: 59.55, bottom: 35.5, left: 59.55 });
|
|
const frame = frameForPrefs(prefsFor("A4", { templateId: "cobalt-stripe" }));
|
|
expect(frame.pad.bottom).toBeCloseTo(59.5, 1);
|
|
expect(frame.contentWidth).toBeCloseTo(476.18, 1);
|
|
});
|
|
|
|
it("Letter keeps the physical margins and gives the extra width to the description", async () => {
|
|
const a4 = frameForPrefs(prefsFor("A4", { templateId: "cobalt-stripe" }));
|
|
const letter = frameForPrefs(prefsFor("LETTER", { templateId: "cobalt-stripe" }));
|
|
expect(letter.pad).toEqual(a4.pad);
|
|
expect(letter.heightDeficit).toBeCloseTo(49.89, 2);
|
|
const model = buildFixture(PLAIN).model;
|
|
const pa = planCobalt(model, a4, V, null, false);
|
|
const pl = planCobalt(model, letter, V, null, false);
|
|
expect(pl.cw).toEqual(pa.cw);
|
|
expect(pl.table.widths[1] - pa.table.widths[1]).toBeCloseTo(letter.contentWidth - a4.contentWidth, 1);
|
|
});
|
|
});
|
|
|
|
describe("header", () => {
|
|
it("matches the design: name, INVOICE and the rule text at the reference baselines, INVOICE and the rule text ending on the right edge", async () => {
|
|
const r = await renderFixture(PLAIN, prefsFor("A4", { templateId: "cobalt-stripe" }));
|
|
const items = r.pdf.pages[0].items;
|
|
const right = contentRight(r);
|
|
const name = items.find((i) => i.str.trim() === "Test Vendor" && i.bottom < 130)!;
|
|
const title = items.find((i) => i.str.trim() === "INVOICE")!;
|
|
const pan = items.find((i) => i.str.startsWith("PAN"))!;
|
|
expect(name.bottom).toBeCloseTo(74.6, 0);
|
|
expect(name.x0).toBeCloseTo(59.5, 0);
|
|
expect(title.bottom).toBeCloseTo(87.3, 0);
|
|
expect(title.x0).toBeCloseTo(395.6, 0);
|
|
expect(pan.bottom).toBeCloseTo(117.1, 0);
|
|
expect(pan.x1).toBeCloseTo(right, 0);
|
|
// INVOICE is tracked: its ink ends on the edge (the trailing letter-spacing is cancelled by Tracked).
|
|
expect(title.x1).toBeLessThanOrEqual(right + 1.5);
|
|
expect(title.x1).toBeGreaterThan(right - 3);
|
|
});
|
|
|
|
it("the GSTIN is the tagline under the name and the PAN is the rule text; the reference script signature is not reproduced", async () => {
|
|
const r = await renderFixture({ name: "t", items: 3, logo: "none", signature: true }, prefsFor("A4", { templateId: "cobalt-stripe" }));
|
|
const supplier = textLines(byId(r, SUPPLIER_ID)).map((l) => l.text);
|
|
expect(supplier).toEqual(expect.arrayContaining(["Test Vendor", "GSTIN: 29AAAAA0000A1Z5"]));
|
|
expect(r.pdf.pages[0].items.some((i) => i.str.startsWith("PAN: ABCDE1234F"))).toBe(true);
|
|
expect(r.pdf.raw).not.toMatch(/BDScript|BD Script/i);
|
|
});
|
|
|
|
it("TAX INVOICE takes the room it needs and the left block shrinks; a long invoice number never meets the title", 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" } }, prefsFor(size, { templateId: "cobalt-stripe" }));
|
|
expect(r.result.issues.filter((i) => i.severity === "error")).toEqual([]);
|
|
const meta = textLines(byId(r, META_ID));
|
|
expect(Math.max(...meta.map((l) => l.right))).toBeLessThanOrEqual(contentRight(r) + 0.5);
|
|
const supplier = byId(r, SUPPLIER_ID);
|
|
const title = byId(r, TITLE_ID);
|
|
expect(supplier.box.x + supplier.box.w).toBeLessThanOrEqual(title.box.x + 0.5);
|
|
}
|
|
});
|
|
|
|
it("the grey rule has its sky accent at the left and sits under the header at the design's y (113.8)", async () => {
|
|
const r = await renderFixture(PLAIN, prefsFor("A4", { templateId: "cobalt-stripe" }));
|
|
const frame = frameForPrefs(r.prefs);
|
|
const svgs = onPage(r, 0).filter((n) => n.type === "svg" && !n.decor);
|
|
const rule = svgs.find((n) => n.box.h === V.head.rule.accent && n.box.w > 300)!;
|
|
expect(rule.box.y + rule.box.h / 2).toBeCloseTo(113.8, 0);
|
|
const [accent, grey] = await colourAt(r.result.bytes, [
|
|
[rule.box.x + 20, rule.box.y + rule.box.h / 2],
|
|
[rule.box.x + 120, rule.box.y + rule.box.h / 2],
|
|
]);
|
|
expect(near(accent, SKY), `accent ${accent}`).toBe(true);
|
|
expect(near(grey, "#A6A6A6"), `grey ${grey}`).toBe(true);
|
|
expect(rule.box.x).toBeCloseTo(frame.pad.left, 1);
|
|
});
|
|
});
|
|
|
|
describe("logo", () => {
|
|
it("a wordmark fills the measured lockup box from its left edge and replaces the name; the tagline stays under it", async () => {
|
|
const lockup = slot.lockup!;
|
|
for (const size of ["A4", "LETTER"] as const) {
|
|
const r = await renderFixture({ name: "o", items: 2, logo: "real" }, prefsFor(size, { templateId: "cobalt-stripe" }));
|
|
const [img] = imageBoxes(r);
|
|
expect(imageBoxes(r)).toHaveLength(1);
|
|
expect(img.box.h).toBeLessThanOrEqual(lockup.h + 0.6);
|
|
expect(img.box.w).toBeLessThanOrEqual(lockup.w + 0.6);
|
|
expect(img.box.x).toBeCloseTo(frameForPrefs(r.prefs).pad.left, 1);
|
|
expect(r.pdf.pages[0].items.filter((i) => i.str.trim() === "Test Vendor" && i.bottom < 130)).toHaveLength(0);
|
|
const tag = r.pdf.pages[0].items.find((i) => i.str.startsWith("GSTIN: 29AAAAA"))!;
|
|
expect(tag.top).toBeGreaterThanOrEqual(img.box.y + img.box.h - 0.5);
|
|
}
|
|
});
|
|
|
|
it("a mark keeps to the measured mark box and keeps the name beside it; with no logo the name is Poppins Bold at the design's baseline", async () => {
|
|
const mark = slot.mark!;
|
|
const square = await renderFixture({ name: "o", items: 2, logo: "square" }, prefsFor("A4", { templateId: "cobalt-stripe" }));
|
|
const [img] = imageBoxes(square);
|
|
expect(img.box.h).toBeLessThanOrEqual(mark.h + 0.6);
|
|
expect(img.box.w).toBeLessThanOrEqual(mark.w + 0.6);
|
|
const name = square.pdf.pages[0].items.find((i) => i.str.trim() === "Test Vendor" && i.bottom < 130)!;
|
|
expect(name.x0).toBeGreaterThanOrEqual(img.box.x + img.box.w + V.head.markGap - 0.6);
|
|
const none = await renderFixture({ name: "o", items: 2, logo: "none" }, prefsFor("A4", { templateId: "cobalt-stripe" }));
|
|
expect(imageBoxes(none)).toHaveLength(0);
|
|
expect(none.pdf.pages[0].items.find((i) => i.str.trim() === "Test Vendor" && i.bottom < 130)!.bottom).toBeCloseTo(74.6, 0);
|
|
});
|
|
|
|
it("a tall logo is held to the lockup 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: prefsFor("A4", { templateId: "cobalt-stripe" }) });
|
|
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(slot.mark!.h + 0.6);
|
|
});
|
|
});
|
|
|
|
describe("table", () => {
|
|
it("is a cobalt bar over zebra rows: 20.76 pt header, 17.82 pt rows, white and light-blue alternating, 1.5 pt light-blue separators", async () => {
|
|
const r = await renderFixture(PLAIN, prefsFor("A4", { templateId: "cobalt-stripe" }));
|
|
const frame = frameForPrefs(r.prefs);
|
|
const head = byId(r, TABLE_HEAD_ID);
|
|
expect(head.box.h).toBeCloseTo(V.table.headH, 0);
|
|
const rows = [0, 1, 2, 3].map((i) => byId(r, `${TABLE_ROW_PREFIX}${i}`));
|
|
for (const row of rows) expect(row.box.h).toBeCloseTo(V.table.rowH, 0);
|
|
const plan = planCobalt(r.fixture.model, frame, V, null, false);
|
|
const left1 = frame.pad.left + plan.cw.no;
|
|
const colours = await colourAt(r.result.bytes, [
|
|
[head.box.x + 1.5, head.box.y + 10],
|
|
[rows[0].box.x + 1.5, rows[0].box.y + 8],
|
|
[rows[1].box.x + 1.5, rows[1].box.y + 8],
|
|
[left1 - 0.75, rows[0].box.y + 8],
|
|
[left1 - 0.75, head.box.y + 10],
|
|
[left1 - 0.75, rows[1].box.y + 8],
|
|
]);
|
|
if (colours.length) {
|
|
expect(near(colours[0], COBALT), `header ${colours[0]}`).toBe(true);
|
|
expect(near(colours[1], "#FFFFFF"), `row 1 ${colours[1]}`).toBe(true);
|
|
expect(near(colours[2], ZEBRA), `row 2 ${colours[2]}`).toBe(true);
|
|
expect(near(colours[3], ZEBRA), `separator on a white row ${colours[3]}`).toBe(true);
|
|
expect(near(colours[4], COBALT), `no separator in the header ${colours[4]}`).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("columns follow the design: NO 24.9, QTY 84.9, PRICE 105.4, TOTAL 89.3 pt; amounts end 3.1 pt inside the edge, prices 31.7 pt inside their column", async () => {
|
|
const r = await renderFixture(PLAIN, prefsFor("A4", { templateId: "cobalt-stripe" }));
|
|
const frame = frameForPrefs(r.prefs);
|
|
const plan = planCobalt(r.fixture.model, frame, V, null, false);
|
|
expect(plan.cw).toMatchObject({ no: 24.9, qty: 84.9, price: 105.4, amount: 89.3 });
|
|
const right = contentRight(r);
|
|
const priceRight = right - plan.cw.amount - V.table.priceInset;
|
|
// The headings follow the design: DESCRIPTION is centred over its left-aligned column, PRICE ends where the prices do.
|
|
const heads = r.pdf.pages[0].items;
|
|
const desc = heads.find((i) => i.str.trim() === "DESCRIPTION")!;
|
|
const descLeft = frame.pad.left + plan.cw.no;
|
|
expect((desc.x0 + desc.x1) / 2).toBeCloseTo(descLeft + plan.table.widths[1] / 2, 0);
|
|
expect(heads.find((i) => i.str.trim() === "PRICE")!.x1).toBeCloseTo(priceRight, 0);
|
|
for (let i = 0; i < 3; i++) {
|
|
const lines = textLines(byId(r, `${TABLE_ROW_PREFIX}${i}`));
|
|
const amount = lines.find((l) => /^₹[\d,]+\.\d\d$/.test(l.text) && l.right > right - 20)!;
|
|
expect(amount.right, `row ${i}`).toBeCloseTo(right - V.table.amountInset, 0);
|
|
const price = lines.find((l) => l.text.endsWith("/hr"))!;
|
|
expect(price.right, `row ${i} price`).toBeCloseTo(priceRight, 0);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("totals", () => {
|
|
it("Sub Total rows end 3.1 pt inside the edge, labels end 95.9 pt into the 214.5 pt column; the GRAND TOTAL and PAYMENT METHOD bars share a top", async () => {
|
|
const r = await renderFixture({ name: "t", items: 3, logo: "none" }, prefsFor("A4", { templateId: "cobalt-stripe" }));
|
|
const frame = frameForPrefs(r.prefs);
|
|
const totals = byId(r, TOTALS_ID);
|
|
const lines = textLines(totals);
|
|
const right = contentRight(r);
|
|
const colLeft = right - V.totals.rightW;
|
|
const sub = lines.find((l) => l.text === "Sub Total :")!;
|
|
expect(sub.right).toBeCloseTo(colLeft + V.totals.labelW, 0);
|
|
expect(lines.find((l) => l.text === r.fixture.model.totals.subtotalText)!.right).toBeCloseTo(right - V.totals.valuePad, 0);
|
|
const bars = onPage(r, 0).filter((n) => n.type === "view" && Math.abs(n.box.h - V.totals.barH) < 0.5 && n.box.y >= totals.box.y);
|
|
// The Sub Total rows are as tall and wide as the bar, so the bar is the lowest of them.
|
|
const grand = bars.filter((n) => Math.abs(n.box.w - V.totals.rightW) < 0.5).sort((a, b) => b.box.y - a.box.y)[0];
|
|
const pay = bars.find((n) => Math.abs(n.box.w - V.totals.leftBarW) < 0.5)!;
|
|
expect(grand.box.x).toBeCloseTo(colLeft, 1);
|
|
expect(pay.box.x).toBeCloseTo(frame.pad.left, 1);
|
|
expect(pay.box.y).toBeCloseTo(grand.box.y, 0);
|
|
const [g, p] = await colourAt(r.result.bytes, [
|
|
[grand.box.x + 3, grand.box.y + 2],
|
|
[pay.box.x + 3, pay.box.y + 2],
|
|
]);
|
|
if (g) {
|
|
expect(near(g, COBALT)).toBe(true);
|
|
expect(near(p, COBALT)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("amount in words sits right-aligned under the GRAND TOTAL bar", async () => {
|
|
const r = await renderFixture({ name: "t", items: 3, logo: "none" }, prefsFor("A4", { templateId: "cobalt-stripe" }));
|
|
const words = byId(r, SLOT_ID.amountInWords);
|
|
const totals = byId(r, TOTALS_ID);
|
|
const grand = onPage(r, 0).filter((n) => n.type === "view" && Math.abs(n.box.h - V.totals.barH) < 0.5 && Math.abs(n.box.w - V.totals.rightW) < 0.5 && n.box.y >= totals.box.y).sort((a, b) => b.box.y - a.box.y)[0];
|
|
expect(words.box.y).toBeGreaterThanOrEqual(grand.box.y + grand.box.h);
|
|
for (const l of textLines(words)) expect(l.right).toBeCloseTo(contentRight(r), 0);
|
|
});
|
|
});
|
|
|
|
describe("closing and footer", () => {
|
|
it("ends with the icon footer: a rule with accents at both ends and three contacts, all inside the content box", async () => {
|
|
const r = await renderFixture({ name: "c", items: 3, logo: "none", signature: true }, prefsFor("A4", { templateId: "cobalt-stripe" }));
|
|
const last = r.result.pages - 1;
|
|
const closing = byId(r, CLOSING_ID, last);
|
|
const frame = frameForPrefs(r.prefs);
|
|
const svgs = onPage(r, last).filter((n) => n.type === "svg" && !n.decor && n.box.y >= closing.box.y - 0.01);
|
|
const icons = svgs.filter((n) => n.box.w === V.closing.icon.size);
|
|
expect(icons).toHaveLength(3);
|
|
const rule = svgs.find((n) => n.box.w > 400 && n.box.h === V.head.rule.accent)!;
|
|
expect(rule.box.w).toBeCloseTo(frame.contentWidth, 1);
|
|
for (const i of icons) {
|
|
expect(i.box.y).toBeGreaterThanOrEqual(rule.box.y + rule.box.h);
|
|
expect(i.box.x + i.box.w).toBeLessThanOrEqual(contentRight(r) + 0.5);
|
|
}
|
|
expect(r.result.pages).toBe(1);
|
|
const sample = await colourAt(r.result.bytes, [[rule.box.x + 20, rule.box.y + 1.5], [rule.box.x + 200, rule.box.y + 1.5], [rule.box.x + rule.box.w - 20, rule.box.y + 1.5]]);
|
|
if (sample.length) {
|
|
expect(near(sample[0], SKY), `left accent ${sample[0]}`).toBe(true);
|
|
expect(near(sample[1], "#A6A6A6"), `grey ${sample[1]}`).toBe(true);
|
|
expect(near(sample[2], SKY), `right accent ${sample[2]}`).toBe(true);
|
|
}
|
|
// The closing block ends the page: its bottom is the wrap area's bottom.
|
|
expect(closing.box.y + closing.box.h).toBeCloseTo(frame.page.height - frame.pad.bottom, 0);
|
|
const text = r.pdf.pages[last].items.map((i) => i.str).join(" ");
|
|
expect(text).toContain("9000000000");
|
|
expect(text).toContain("vendor@example.com");
|
|
});
|
|
|
|
it("a pasted signature image replaces the script: right-aligned, at most 36 pt tall, with the signatory name and designation under it", async () => {
|
|
const r = await renderFixture({ name: "s", items: 2, logo: "none", signature: true }, prefsFor("A4", { templateId: "cobalt-stripe" }));
|
|
const sig = byId(r, SLOT_ID.signatory, r.result.pages - 1);
|
|
const img = onPage(r, r.result.pages - 1).find((n) => n.type === "image" && n.box.y >= sig.box.y - 0.01 && n.box.y + n.box.h <= sig.box.y + sig.box.h + 0.01)!;
|
|
expect(img.box.h).toBeLessThanOrEqual(V.closing.signatureImageH + 0.01);
|
|
const text = textLines(sig).map((l) => l.text);
|
|
expect(text).toEqual(expect.arrayContaining(["Test Vendor", "Proprietor"]));
|
|
// Without an image: the computer-generated note stands in.
|
|
const plain = await renderFixture({ name: "s", items: 2, logo: "none", signature: false }, prefsFor("A4", { templateId: "cobalt-stripe" }));
|
|
expect(plain.pdf.pages[plain.result.pages - 1].items.map((i) => i.str).join(" ")).toContain("Computer-generated");
|
|
});
|
|
|
|
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: "cobalt-stripe" }));
|
|
const plan = planCobalt(r.fixture.model, frameForPrefs(r.prefs), V, null, false);
|
|
expect(plan.closing.termsBeside).toBe(false);
|
|
expect(byId(r, CLOSING_ID, r.result.pages - 1).box.h).toBeLessThan(300);
|
|
expect(r.pdf.pages.map((p) => p.items.map((i) => i.str).join(" ")).join(" ")).toContain("Note 18");
|
|
});
|
|
|
|
it("when only the closing block moves to page 2 the page-1 gaps are squeezed enough to keep the items and totals on page 1", async () => {
|
|
// Row counts where the overflow before the closing block is within the gaps that help page 1 (A4 / Letter).
|
|
const cases: Array<["A4" | "LETTER", number]> = [["A4", 7]];
|
|
for (const [size, n] of cases) {
|
|
const r = await renderFixture({ name: "s", items: n, logo: "none" }, prefsFor(size, { templateId: "cobalt-stripe" }));
|
|
const where = (id: string) => {
|
|
const pages: number[] = [];
|
|
r.result.core.layout.forEach((pg, i) => walk(pg.nodes, (x) => x.id === id && pages.push(i)));
|
|
return pages;
|
|
};
|
|
const rowPages = new Set<number>();
|
|
r.result.core.layout.forEach((pg, i) => walk(pg.nodes, (x) => x.id?.startsWith(TABLE_ROW_PREFIX) && rowPages.add(i)));
|
|
expect([...rowPages], `${size} n=${n} rows`).toEqual([0]);
|
|
expect(where(TOTALS_ID), `${size} n=${n} totals`).toEqual([0]);
|
|
expect(where(CLOSING_ID), `${size} n=${n} closing`).toEqual([1]);
|
|
expect(r.result.pages).toBe(2);
|
|
}
|
|
});
|
|
|
|
it("an address, e-mail and phone that are too long for one row shrink and wrap inside the footer", async () => {
|
|
const r = await renderFixture(FIXTURES.find((f) => f.name === "id-tokens")!, prefsFor("A4", { templateId: "cobalt-stripe" }));
|
|
expect(r.result.issues.filter((i) => i.severity === "error")).toEqual([]);
|
|
const closing = byId(r, CLOSING_ID, r.result.pages - 1);
|
|
for (const l of textLines(closing)) expect(l.right).toBeLessThanOrEqual(contentRight(r) + 0.5);
|
|
});
|
|
});
|
|
|
|
describe("the GST slots", () => {
|
|
it("every GST slot has a home on a tax invoice", async () => {
|
|
const r = await renderFixture({ name: "t", items: 2, logo: "none", signature: true }, prefsFor("A4", { templateId: "cobalt-stripe" }));
|
|
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);
|
|
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", "PAN:ABCDE1234F"]) expect(flat, word).toContain(word);
|
|
});
|
|
|
|
it("an unregistered invoice carries none of the GST slots", async () => {
|
|
const r = await renderFixture(FIXTURES.find((f) => f.name === "unregistered")!, prefsFor("A4", { templateId: "cobalt-stripe" }));
|
|
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: "cobalt-stripe" }));
|
|
expect(r.result.issues.filter((i) => i.severity === "error"), `${size}/${name}`).toEqual([]);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("wrapped description rows grow and keep their zebra colour and separators (no row is taller than its text)", async () => {
|
|
const r = await renderFixture(FIXTURES.find((f) => f.name === "multiline")!, prefsFor("A4", { templateId: "cobalt-stripe" }));
|
|
const rows = [0, 1, 2].map((i) => byId(r, `${TABLE_ROW_PREFIX}${i}`));
|
|
expect(rows[0].box.h).toBeGreaterThan(3 * 13.6);
|
|
expect(rows[2].box.h).toBeGreaterThan(rows[1].box.h);
|
|
expect(r.result.issues.filter((i) => i.severity === "error")).toEqual([]);
|
|
});
|
|
});
|
|
|
|
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: "cobalt-stripe" });
|
|
const frame = frameForPrefs(prefs);
|
|
// Logo-less, 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 = planCobalt(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(32);
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
describe("design docs", () => {
|
|
it("records its implementation notes", () => {
|
|
const text = readFileSync(path.join(process.cwd(), "docs/templates/08-cobalt-stripe.md"), "utf8");
|
|
expect(text).toContain("## Implementation notes");
|
|
});
|
|
});
|