Add the Monolith and Cobalt Stripe templates
Standalone layouts for Canva templates 3 and 8, with the user's logo sized to the placeholder box, a CapRule decor primitive, optional TableRows rule/heading options, and a separate 1..45-row sweep test file.
This commit is contained in:
@@ -0,0 +1,414 @@
|
||||
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 Canva 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 last, 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).at(-1)).toBe("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 Canva 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() === "Arun P" && 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 Canva 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(["Arun P", "GSTIN: 29AAAAA0000A1Z5"]));
|
||||
expect(r.pdf.pages[0].items.some((i) => i.str.startsWith("PAN: CPOPP7388K"))).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() === "Arun P" && 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() === "Arun P" && 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() === "Arun P" && 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("9738284459");
|
||||
expect(text).toContain("arunakira113@gmail.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(["Arun P", "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("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:CPOPP7388K"]) 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user