Standalone layouts for reference 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.
323 lines
18 KiB
TypeScript
323 lines
18 KiB
TypeScript
import { existsSync, readFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { describe, expect, it } from "vitest";
|
|
import { swooshKeepOuts } from "../../decor/CornerSwoosh";
|
|
import { CHIP_PADDING } from "../../engine/logo";
|
|
import { CLOSING_ID, TOTALS_ID } from "../../engine/ids";
|
|
import { walk, type LayoutNode } from "../../engine/layoutTree";
|
|
import { frameForPrefs } from "../../render/invoice";
|
|
import { loadCanvas, pagePng } from "../../testing/rasterize";
|
|
import { SLOT_ID } from "../../blocks/slots";
|
|
import { catalogEntry } from "../catalog";
|
|
import { marginsFor } from "../contract";
|
|
import { buildFixture, ensureSetup, FIXTURES, prefsFor, renderFixture, type FixtureSpec } from "../harness";
|
|
import { getTemplate, TEMPLATES } from "../registry";
|
|
import { SLOTS } from "../slots.generated";
|
|
import { planBand } from "./plan";
|
|
import { slateBand, SLATE_VARIANT } from "./slate";
|
|
import { tealSwoosh, TEAL_VARIANT } from "./teal";
|
|
|
|
ensureSetup();
|
|
|
|
const BAND = [slateBand, tealSwoosh];
|
|
const VARIANTS = { "slate-band": SLATE_VARIANT, "teal-swoosh": TEAL_VARIANT } as const;
|
|
const nodes = (layout: { nodes: LayoutNode[] }[]) => {
|
|
const out: LayoutNode[] = [];
|
|
for (const p of layout) walk(p.nodes, (n) => out.push(n));
|
|
return out;
|
|
};
|
|
const withLogo = { name: "logo", items: 2, logo: "real" as const };
|
|
const imageBoxes = (r: Awaited<ReturnType<typeof renderFixture>>) => nodes(r.result.core.layout).filter((n) => n.type === "image" && !n.decor);
|
|
|
|
/** The colour of one pixel 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 isWhite = ([r, g, b]: number[]) => r > 245 && g > 245 && b > 245;
|
|
const isSlate = ([r, g, b]: number[]) => Math.abs(r - 0x4d) < 6 && Math.abs(g - 0x58) < 6 && Math.abs(b - 0x69) < 6;
|
|
|
|
describe("registry and catalog", () => {
|
|
it("lists Slate Band and Teal Swoosh in the Band family, with their thumbnails on disk", () => {
|
|
for (const t of BAND) {
|
|
expect(getTemplate(t.id)).toBe(t);
|
|
expect(t.family).toBe("band");
|
|
expect(catalogEntry(t.id).name).toBe(t.name);
|
|
expect(existsSync(path.join(process.cwd(), "public", t.thumbnail)), t.thumbnail).toBe(true);
|
|
}
|
|
expect(Object.keys(TEMPLATES)).toEqual(["classic", "linea", "monogram", "serenity", "crimson-grid", "tangerine-ledger", "slate-band", "teal-swoosh", "purple-pop", "citrus-split", "monolith", "cobalt-stripe"]);
|
|
});
|
|
|
|
it("both are Montserrat throughout, and their slots are the measured reference ones", () => {
|
|
for (const t of BAND) for (const [name, token] of Object.entries(t.roles)) expect(token.face, `${t.id}.${name}`).toBe("Montserrat");
|
|
expect(slateBand.logo.slot).toBe(SLOTS["slate-band"]);
|
|
expect(tealSwoosh.logo.slot).toBe(SLOTS["teal-swoosh"]);
|
|
expect(slateBand.logo.slot?.darkSurface).toBe(true);
|
|
expect(tealSwoosh.logo.slot?.darkSurface).toBe(false);
|
|
expect(slateBand.roles.title).toMatchObject({ weight: 800, size: 30 });
|
|
});
|
|
});
|
|
|
|
describe("page geometry", () => {
|
|
it("template margins", () => {
|
|
expect(marginsFor(slateBand, "template")).toEqual({ top: 55.75, right: 59.55, bottom: 20, left: 59.55 });
|
|
expect(marginsFor(tealSwoosh, "template")).toEqual({ top: 55, right: 59.55, bottom: 24, left: 59.55 });
|
|
});
|
|
|
|
it("the flow stays above the footer rule (Slate) and the bottom-left swoosh (Teal) on every preset and size", () => {
|
|
for (const margins of ["template", "narrow", "normal"] as const) {
|
|
for (const size of ["A4", "LETTER"] as const) {
|
|
expect(frameForPrefs(prefsFor(size, { templateId: "slate-band", margins })).pad.bottom, `slate ${margins}/${size}`).toBeGreaterThanOrEqual(86.7 + 12 - 1e-6);
|
|
expect(frameForPrefs(prefsFor(size, { templateId: "teal-swoosh", margins })).pad.bottom, `teal ${margins}/${size}`).toBeGreaterThanOrEqual(110 - 1e-6);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("Letter keeps the physical margins", () => {
|
|
for (const t of BAND) {
|
|
const a4 = frameForPrefs(prefsFor("A4", { templateId: t.id }));
|
|
const letter = frameForPrefs(prefsFor("LETTER", { templateId: t.id }));
|
|
expect(letter.pad).toEqual(a4.pad);
|
|
expect(letter.heightDeficit).toBeCloseTo(49.89, 2);
|
|
}
|
|
});
|
|
|
|
it("the table text is inset 9.8 pt and the header band bleeds to both page edges, on every page", async () => {
|
|
for (const t of BAND) {
|
|
for (const size of ["A4", "LETTER"] as const) {
|
|
const r = await renderFixture({ name: "p", items: 30, logo: "none" }, prefsFor(size, { templateId: t.id }));
|
|
const frame = frameForPrefs(r.prefs);
|
|
expect(r.result.pages).toBeGreaterThan(1);
|
|
r.result.core.layout.forEach((page, i) => {
|
|
const heads = nodes([page]).filter((n) => n.id === "tbl-head");
|
|
if (heads.length === 0) return;
|
|
expect(heads[0].box.x, `${t.id}/${size} p${i + 1} head x`).toBeCloseTo(frame.pad.left + 9.8, 1);
|
|
// The fixed group around the header row is the band: page edge to page edge.
|
|
const band = nodes([page]).find((n) => n.type === "view" && n.fixed && n.box.x < 0.5 && n.box.w > frame.page.width - 0.5 && n.box.h > 40);
|
|
expect(band, `${t.id}/${size} p${i + 1} band`).toBeTruthy();
|
|
});
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Slate Band: dark band and logo", () => {
|
|
it("the two full-bleed bands are page 1 only, from the page top, as wide as the page", async () => {
|
|
for (const size of ["A4", "LETTER"] as const) {
|
|
const r = await renderFixture({ name: "p", items: 40, logo: "none" }, prefsFor(size, { templateId: "slate-band" }));
|
|
const decor = (i: number) => nodes([r.result.core.layout[i]]).filter((n) => n.decor && n.type === "view" && n.box.x < 0.5 && n.box.w > frameForPrefs(r.prefs).page.width - 0.5 && n.box.h > 20);
|
|
expect(decor(0).length, size).toBeGreaterThanOrEqual(2);
|
|
expect(Math.min(...decor(0).map((n) => n.box.y)), size).toBeCloseTo(0, 1);
|
|
// 103.5 pt of slate, then 53.8 of blue, at the design's margins.
|
|
const heights = decor(0).map((n) => n.box.h).sort((a, b) => a - b);
|
|
expect(heights[0]).toBeGreaterThan(50);
|
|
// 103.5 on A4; Letter takes up to 8.5 pt of the band's bottom padding.
|
|
expect(heights[heights.length - 1]).toBeGreaterThan(94);
|
|
expect(decor(1).filter((n) => n.box.y < 5), `${size} no band on page 2`).toHaveLength(0);
|
|
}
|
|
});
|
|
|
|
it("a logo with a white knockout draws the knockout on the band", async () => {
|
|
const lib = await loadCanvas();
|
|
const r = await renderFixture({ ...withLogo, logo: "knockout" }, prefsFor("A4", { templateId: "slate-band" }));
|
|
const [img] = imageBoxes(r);
|
|
expect(imageBoxes(r)).toHaveLength(1);
|
|
const slot = SLOTS["slate-band"]!.lockup!;
|
|
expect(img.box.x).toBeGreaterThanOrEqual(slot.x - 0.6);
|
|
expect(img.box.h).toBeLessThanOrEqual(slot.h + 0.01);
|
|
expect(img.box.w).toBeLessThanOrEqual(slot.w + 0.01);
|
|
if (!lib) return;
|
|
const [centre, beside] = await colourAt(r.result.bytes, [[img.box.x + img.box.w / 2, img.box.y + img.box.h / 2], [img.box.x - 3, img.box.y + img.box.h / 2]]);
|
|
expect(isWhite(centre), `knockout ink ${centre}`).toBe(true);
|
|
expect(isSlate(beside), `band beside the logo ${beside}`).toBe(true);
|
|
});
|
|
|
|
it("a logo without alpha sits on a white chip, padded by CHIP_PADDING, still inside the band", async () => {
|
|
const lib = await loadCanvas();
|
|
const r = await renderFixture({ ...withLogo, logo: "wide" }, prefsFor("A4", { templateId: "slate-band" }));
|
|
const [img] = imageBoxes(r);
|
|
if (!lib) return;
|
|
const [inChip, outside, centre] = await colourAt(r.result.bytes, [
|
|
[img.box.x - CHIP_PADDING + 1, img.box.y + img.box.h / 2],
|
|
[img.box.x - CHIP_PADDING - 3, img.box.y + img.box.h / 2],
|
|
[img.box.x + img.box.w / 2, img.box.y + img.box.h / 2],
|
|
]);
|
|
expect(isWhite(inChip), `chip ${inChip}`).toBe(true);
|
|
expect(isSlate(outside), `band outside the chip ${outside}`).toBe(true);
|
|
// The print image (navy) is what is drawn on the chip, not a white knockout.
|
|
expect(isWhite(centre) || centre[2] > centre[0], `print ink ${centre}`).toBe(true);
|
|
});
|
|
|
|
it("a mark keeps the business name beside it, in white; a wordmark replaces the name", async () => {
|
|
const square = await renderFixture({ ...withLogo, logo: "square" }, prefsFor("A4", { templateId: "slate-band" }));
|
|
expect(square.pdf.pages[0].items.filter((i) => i.str.trim() === "Test Vendor" && i.bottom < 110)).toHaveLength(1);
|
|
const wide = await renderFixture({ ...withLogo, logo: "knockout" }, prefsFor("A4", { templateId: "slate-band" }));
|
|
expect(wide.pdf.pages[0].items.filter((i) => i.str.trim() === "Test Vendor" && i.bottom < 110)).toHaveLength(0);
|
|
const none = await renderFixture({ ...withLogo, logo: "none" }, prefsFor("A4", { templateId: "slate-band" }));
|
|
expect(none.pdf.pages[0].items.filter((i) => i.str.trim() === "Test Vendor" && i.bottom < 110)).toHaveLength(1);
|
|
// The number sits in the band, right-aligned to the content edge.
|
|
const no = none.pdf.pages[0].items.find((i) => i.str.startsWith("NO."))!;
|
|
expect(no.bottom).toBeLessThan(110);
|
|
expect(no.x1).toBeLessThanOrEqual(frameForPrefs(none.prefs).page.width - frameForPrefs(none.prefs).pad.right + 0.6);
|
|
});
|
|
|
|
it("the contact footer is on every page", async () => {
|
|
const r = await renderFixture({ name: "p", items: 40, logo: "none" }, prefsFor("A4", { templateId: "slate-band" }));
|
|
expect(r.result.pages).toBeGreaterThan(1);
|
|
for (const [i, page] of r.pdf.pages.entries()) {
|
|
const text = page.items.map((t) => t.str).join(" ");
|
|
expect(text, `page ${i + 1} phone`).toContain("9000000000");
|
|
expect(text, `page ${i + 1} e-mail`).toContain("vendor@example.com");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Teal Swoosh: corner art and keep-outs", () => {
|
|
it("the bottom-left swoosh repeats on every page; the top-right one is page 1 only", async () => {
|
|
const r = await renderFixture({ name: "p", items: 40, logo: "none" }, prefsFor("A4", { templateId: "teal-swoosh" }));
|
|
const frame = frameForPrefs(r.prefs);
|
|
const svgs = (i: number) => nodes([r.result.core.layout[i]]).filter((n) => n.type === "svg" && n.decor);
|
|
expect(svgs(0).length).toBe(2);
|
|
for (let i = 1; i < r.result.pages; i++) {
|
|
const s = svgs(i);
|
|
expect(s, `page ${i + 1}`).toHaveLength(1);
|
|
expect(s[0].box.x).toBeCloseTo(0, 1);
|
|
expect(s[0].box.y + s[0].box.h).toBeCloseTo(frame.page.height, 1);
|
|
}
|
|
});
|
|
|
|
it("keep-outs: bottom-left on every page, top-right on page 1 only; both clusters on Letter too", () => {
|
|
for (const size of ["A4", "LETTER"] as const) {
|
|
const frame = frameForPrefs(prefsFor(size, { templateId: "teal-swoosh" }));
|
|
const k = tealSwoosh.decor.keepOuts!(frame);
|
|
const bl = swooshKeepOuts(frame, "bottom-left");
|
|
const tr = swooshKeepOuts(frame, "top-right");
|
|
expect(k).toHaveLength(bl.length + tr.length);
|
|
expect(k.filter((r) => r.page === undefined)).toHaveLength(bl.length);
|
|
expect(k.filter((r) => r.page === 1)).toHaveLength(tr.length);
|
|
}
|
|
});
|
|
|
|
it("no flowing text or image enters the keep-outs, with a long vendor name, 60 rows, on A4 and Letter", async () => {
|
|
for (const size of ["A4", "LETTER"] as const) {
|
|
for (const spec of [FIXTURES.find((f) => f.name === "id-tokens")!, FIXTURES.find((f) => f.name === "rows-60")!, FIXTURES.find((f) => f.name === "long-address")!] satisfies FixtureSpec[]) {
|
|
const r = await renderFixture(spec, prefsFor(size, { templateId: "teal-swoosh" }));
|
|
const keep = r.result.issues.filter((i) => i.code === "keepout");
|
|
expect(keep.map((i) => i.message), `${size}/${spec.name}`).toEqual([]);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("the keep-out audit does fire for a block placed in the swoosh", async () => {
|
|
// Teal with page margins pulled in so the closing block would reach the swoosh: the audit must say so.
|
|
const r = await renderFixture({ name: "k", items: 2, logo: "none" }, prefsFor("A4", { templateId: "teal-swoosh" }));
|
|
const frame = frameForPrefs(r.prefs);
|
|
const { auditLayout } = await import("../../engine/audit");
|
|
const closing = nodes(r.result.core.layout).find((n) => n.id === CLOSING_ID)!;
|
|
const inside = { x: 0, y: closing.box.y - 1, w: frame.page.width, h: closing.box.h + 2 };
|
|
expect(auditLayout({ pages: r.result.core.layout }, frame, { keepOuts: [inside] }).some((i) => i.code === "keepout")).toBe(true);
|
|
});
|
|
|
|
it("the logo stays inside its measured lockup box; a wordmark replaces the name, a mark keeps it", async () => {
|
|
const slot = SLOTS["teal-swoosh"]!.lockup!;
|
|
const wide = await renderFixture(withLogo, prefsFor("A4", { templateId: "teal-swoosh" }));
|
|
const [img] = imageBoxes(wide);
|
|
expect(img.box.x).toBeGreaterThanOrEqual(slot.x - 0.6);
|
|
expect(img.box.y).toBeGreaterThanOrEqual(slot.y - 0.6);
|
|
expect(img.box.x + img.box.w).toBeLessThanOrEqual(slot.x + slot.w + 0.6);
|
|
expect(img.box.y + img.box.h).toBeLessThanOrEqual(slot.y + slot.h + 0.6);
|
|
expect(wide.pdf.pages[0].items.filter((i) => i.str.trim() === "Test Vendor" && i.bottom < 120)).toHaveLength(0);
|
|
const square = await renderFixture({ ...withLogo, logo: "square" }, prefsFor("A4", { templateId: "teal-swoosh" }));
|
|
expect(square.pdf.pages[0].items.filter((i) => i.str.trim() === "Test Vendor" && i.bottom < 120)).toHaveLength(1);
|
|
const none = await renderFixture({ ...withLogo, logo: "none" }, prefsFor("A4", { templateId: "teal-swoosh" }));
|
|
expect(none.pdf.pages[0].items.filter((i) => i.str.trim() === "Test Vendor" && i.bottom < 120)).toHaveLength(1);
|
|
});
|
|
|
|
it("the continuation band and footer stay out of the swoosh on page 2", async () => {
|
|
const r = await renderFixture({ name: "p", items: 40, logo: "none" }, prefsFor("A4", { templateId: "teal-swoosh" }));
|
|
const band = r.pdf.pages[1].items.find((t) => t.str.startsWith("Tax Invoice"))!;
|
|
expect(band.x0).toBeCloseTo(frameForPrefs(r.prefs).pad.left, 0);
|
|
const footer = r.pdf.pages[1].items.filter((t) => t.top > 790);
|
|
for (const t of footer) expect(t.x0, t.str).toBeGreaterThan(190);
|
|
});
|
|
});
|
|
|
|
describe("the TOTAL row and the totals stack", () => {
|
|
it("TOTAL carries the accent dash and every tax row sits above it", async () => {
|
|
for (const t of BAND) {
|
|
const r = await renderFixture({ name: "t", items: 2, logo: "none" }, prefsFor("A4", { templateId: t.id }));
|
|
const text = r.pdf.pages.flatMap((p) => p.items.map((i) => i.str));
|
|
// Tracked labels are extracted letter by letter, so compare without spaces.
|
|
const flat = text.join("").replace(/\s+/g, "").toUpperCase();
|
|
expect(flat, t.id).toContain("TOTAL");
|
|
for (const word of ["SUBTOTAL", "CGST@9%", "SGST@9%", "TAXABLEVALUE"]) expect(flat, `${t.id} ${word}`).toContain(word);
|
|
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.amountInWords, SLOT_ID.signatory]) expect(ids.has(id), `${t.id} #${id}`).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("an unregistered invoice carries none of the GST slots", async () => {
|
|
for (const t of BAND) {
|
|
const r = await renderFixture(FIXTURES.find((f) => f.name === "unregistered")!, prefsFor("A4", { templateId: t.id }));
|
|
const ids = new Set(nodes(r.result.core.layout).flatMap((n) => (n.id ? [n.id] : [])));
|
|
for (const id of [`${SLOT_ID.gstin}-supplier`, SLOT_ID.pos, SLOT_ID.taxRows, SLOT_ID.reverseCharge]) expect(ids.has(id), `${t.id} #${id}`).toBe(false);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("long text", () => {
|
|
it("long names, addresses and unbroken tokens raise no audit error", async () => {
|
|
for (const t of BAND) {
|
|
for (const size of ["A4", "LETTER"] as const) {
|
|
for (const name of ["id-tokens", "long-address", "long-client"]) {
|
|
const r = await renderFixture(FIXTURES.find((f) => f.name === name)!, prefsFor(size, { templateId: t.id }));
|
|
expect(r.result.issues.filter((i) => i.severity === "error"), `${t.id}/${size}/${name}`).toEqual([]);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("plan matches the laid-out blocks", () => {
|
|
for (const t of BAND) {
|
|
for (const size of ["A4", "LETTER"] as const) {
|
|
for (const spec of FIXTURES) {
|
|
it(`${t.id}/${spec.name} on ${size}`, async () => {
|
|
const prefs = prefsFor(size, { templateId: t.id });
|
|
const frame = frameForPrefs(prefs);
|
|
// Logo-less, so the plan made here is the one the layout made (a logo only changes the header).
|
|
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 = planBand(r.fixture.model, frame, VARIANTS[t.id as keyof typeof VARIANTS], null, false);
|
|
expect(plan.tailH - find(TOTALS_ID), "tail prediction vs layout").toBeGreaterThanOrEqual(-0.01);
|
|
expect(plan.tailH - find(TOTALS_ID)).toBeLessThanOrEqual(15 + plan.gaps.tail);
|
|
expect(plan.closingH - find(CLOSING_ID), "closing prediction vs layout").toBeGreaterThanOrEqual(-0.01);
|
|
expect(plan.closingH - find(CLOSING_ID)).toBeLessThanOrEqual(15);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
describe("design docs", () => {
|
|
it("each Band doc records its implementation notes", () => {
|
|
for (const f of ["09-slate-band.md", "10-teal-swoosh.md"]) {
|
|
const text = readFileSync(path.join(process.cwd(), "docs/templates", f), "utf8");
|
|
expect(text, f).toContain("## Implementation notes");
|
|
}
|
|
});
|
|
});
|
|
|
|
void buildFixture;
|