Quantities can now be time, count, distance, area, weight or volume, or a custom unit, instead of only
second/minute/hour/session/unit.
- Registry in src/lib/units.ts (mirrored in src-tauri/src/units.rs): grouped built-in units plus a custom unit of 1-12
characters. A rate line's unit is validated when an invoice or a preset is saved; migration M12 drops the old unit
allow-list from item_presets and keeps every row.
- Time is typed as h:mm: "4:30" hours counts as 4.5, "4:20" as 4.333... (kept exact, so rate x quantity rounds once, the
same in TypeScript and Rust). Other units take plain decimals up to 3 places. A bad quantity blocks issuing.
- The PDF quantity column shows the unit ("4.5 hr", "12.75 km"); the rate keeps "per hour" / "/hr". RenderItem carries
priceText, perText and rateShort, so no template parses the rate text with a regex over five hard-coded units.
- Quantity columns are sized from the widest word once a quantity with its unit is wider than 72 pt, so one long
custom unit cannot squeeze the description (the serenity and citrus-split tables overflowed in the new fixture).
Classic's quantity column is now content-sized.
- ERPNext: the default UOM map comes from the registry, with fractional-capable UOMs for time, distance, area, weight
and volume. A custom unit is sent as Nos. A quantity that is not exact to 3 decimals (4:20 hours) is still refused for
push, as before.
- Goldens and template thumbnails regenerated; the template harness has a "units" fixture with the longest texts.
Claude-Session: https://claude.ai/code/session_01PZypiWDfMkDTeEPeXjRhW5
363 lines
21 KiB
TypeScript
363 lines
21 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 { 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 { BRAND_ID, IDS_ID } from "./Layout";
|
|
import { monolith } from "./monolith";
|
|
import { planMonolith } from "./plan";
|
|
import { MONOLITH_ROLES, MONOLITH_VARIANT } from "./tokens";
|
|
|
|
ensureSetup();
|
|
|
|
const V = MONOLITH_VARIANT;
|
|
const allowance = SLOTS.monolith!.allowance;
|
|
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: Awaited<ReturnType<typeof renderFixture>>, i: number) => nodes([r.result.core.layout[i]]);
|
|
const imageBoxes = (r: Awaited<ReturnType<typeof renderFixture>>) => nodes(r.result.core.layout).filter((n) => n.type === "image" && !n.decor);
|
|
const byId = (r: Awaited<ReturnType<typeof renderFixture>>, id: string, page = 0) => onPage(r, page).find((n) => n.id === id)!;
|
|
const textsOf = (r: Awaited<ReturnType<typeof renderFixture>>, page = 0) => r.pdf.pages[page].items.map((i) => i.str).join(" ");
|
|
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;
|
|
};
|
|
/** The reference sample's shape: no due date, so the right-hand stack is the design's three lines. */
|
|
const PLAIN: FixtureSpec = { name: "plain", items: 3, registered: false, logo: "none", invoice: { dueDate: "" } };
|
|
const contentRight = (r: Awaited<ReturnType<typeof renderFixture>>) => {
|
|
const f = frameForPrefs(r.prefs);
|
|
return f.page.width - f.pad.right;
|
|
};
|
|
|
|
describe("registry, catalog and fonts", () => {
|
|
it("is registered after the Northline family, as a standalone template with its thumbnail on disk", () => {
|
|
expect(getTemplate("monolith")).toBe(monolith);
|
|
expect(monolith.family).toBe("standalone");
|
|
expect(catalogEntry("monolith").name).toBe("Monolith");
|
|
expect(existsSync(path.join(process.cwd(), "public", monolith.thumbnail)), monolith.thumbnail).toBe(true);
|
|
expect(Object.keys(TEMPLATES).slice(-4, -2)).toEqual(["monolith", "cobalt-stripe"]);
|
|
expect(TEMPLATE_CATALOG.map((e) => e.id)).toEqual(Object.keys(TEMPLATES));
|
|
});
|
|
|
|
it("is black Poppins only: every role is Poppins 400/500/700 and the sources carry no colour but #000000", () => {
|
|
for (const [name, token] of Object.entries(MONOLITH_ROLES)) {
|
|
expect(token.face, name).toBe("Poppins");
|
|
expect([400, 500, 700], name).toContain(token.weight);
|
|
}
|
|
const dir = path.join(process.cwd(), "src/pdf/templates/monolith");
|
|
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) => c.toLowerCase() !== "#000000"), f).toEqual([]);
|
|
}
|
|
expect(monolith.decor.ink).toBe("#000000");
|
|
expect(monolith.chrome.color).toBe("#000000");
|
|
});
|
|
});
|
|
|
|
describe("page geometry", () => {
|
|
it("template margins", () => {
|
|
expect(marginsFor(monolith, "template")).toEqual({ top: 33, right: 35, bottom: 22, left: 35 });
|
|
expect(marginsFor(monolith, "narrow").left).toBe(36);
|
|
});
|
|
|
|
it("Letter keeps the physical margins and takes the missing height from the gaps", () => {
|
|
const a4 = frameForPrefs(prefsFor("A4", { templateId: "monolith" }));
|
|
const letter = frameForPrefs(prefsFor("LETTER", { templateId: "monolith" }));
|
|
expect(letter.pad).toEqual(a4.pad);
|
|
expect(letter.heightDeficit).toBeCloseTo(49.89, 2);
|
|
});
|
|
});
|
|
|
|
describe("header", () => {
|
|
it("matches the design: title origin 16 pt in, baseline 96 pt; the vendor, date and number lines end on the right edge at the design's pitch", async () => {
|
|
const r = await renderFixture(PLAIN, prefsFor("A4", { templateId: "monolith" }));
|
|
const items = r.pdf.pages[0].items;
|
|
const title = items.find((i) => i.str.trim() === "INVOICE")!;
|
|
expect(title.x0).toBeCloseTo(35 + V.titleIndent, 0);
|
|
expect(title.bottom).toBeCloseTo(96.2, 0);
|
|
const right = contentRight(r);
|
|
const brand = items.find((i) => i.str.trim() === "Test Vendor" && i.bottom < 130)!;
|
|
const date = items.find((i) => i.str.includes("2026") && i.bottom < 130)!;
|
|
const number = items.find((i) => i.str.includes("INVOICE NO."))!;
|
|
for (const [what, it, baseline] of [["name", brand, 63.7], ["date", date, 84.7], ["number", number, 105.7]] as const) {
|
|
expect(it.bottom, what).toBeCloseTo(baseline, 0);
|
|
expect(it.x1, what).toBeCloseTo(right, 0);
|
|
}
|
|
});
|
|
|
|
it("TAX INVOICE shrinks to the room the right-hand stack leaves instead of meeting it", 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: "monolith" }));
|
|
const plan = planMonolith(r.fixture.model, frameForPrefs(r.prefs), V, null, false);
|
|
expect(plan.head.titleSize).toBeLessThan(MONOLITH_ROLES.title.size);
|
|
expect(plan.head.titleSize).toBeGreaterThanOrEqual(MONOLITH_ROLES.title.minSize as number);
|
|
const title = textLines(byId(r, TITLE_ID));
|
|
const stack = textLines(byId(r, META_ID));
|
|
const stackLeft = Math.min(...stack.map((l) => l.x));
|
|
expect(Math.max(...title.map((l) => l.right)), size).toBeLessThan(stackLeft - 10);
|
|
expect(r.result.issues.filter((i) => i.severity === "error")).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: "monolith" }));
|
|
expect(textLines(byId(r, META_ID)).some((l) => l.text === "CANCELLED")).toBe(true);
|
|
expect(textsOf(r, r.result.pages - 1)).toContain("This invoice has been cancelled.");
|
|
});
|
|
});
|
|
|
|
describe("logo", () => {
|
|
it("is the text-only CAVEIN slot: the allowance is 160 x 28, right-anchored, and the template offers exactly it", () => {
|
|
expect(SLOTS.monolith!.textOnly).toBe(true);
|
|
expect(allowance).toMatchObject({ w: 160, h: 28 });
|
|
expect(monolith.logo.slot).toBe(SLOTS.monolith);
|
|
expect(monolith.logo.box).toEqual({ w: allowance.w, h: allowance.h });
|
|
expect(monolith.logo.available?.(frameForPrefs(prefsFor("A4", { templateId: "monolith" })))).toEqual({ w: allowance.w, h: allowance.h });
|
|
expect(monolith.logo.defaultOn).toBe(true);
|
|
});
|
|
|
|
it("every logo fits the allowance, ends on the right edge and the date and number lines flow BELOW its box", 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 }, prefsFor(size, { templateId: "monolith" }));
|
|
const imgs = imageBoxes(r);
|
|
expect(imgs, `${size}/${logo}`).toHaveLength(1);
|
|
const [img] = imgs;
|
|
expect(img.box.h).toBeLessThanOrEqual(allowance.h + 0.01);
|
|
expect(img.box.w).toBeLessThanOrEqual(allowance.w + 0.01);
|
|
expect(img.box.x + img.box.w, `${size}/${logo} right edge`).toBeCloseTo(contentRight(r), 0);
|
|
// The brand zone reserves the logo box, so the meta lines start under it.
|
|
const brand = byId(r, BRAND_ID);
|
|
const supplier = byId(r, SUPPLIER_ID);
|
|
const meta = byId(r, META_ID);
|
|
expect(img.box.y + img.box.h, `${size}/${logo} logo bottom against meta top`).toBeLessThanOrEqual(meta.box.y + 0.01);
|
|
expect(supplier.box.y + supplier.box.h, `${size}/${logo} supplier box against meta`).toBeLessThanOrEqual(meta.box.y + 0.01);
|
|
expect(brand.box.y + brand.box.h).toBeLessThanOrEqual(meta.box.y + 0.01);
|
|
const lines = textLines(meta);
|
|
for (const l of lines) expect(l.y, `${size}/${logo} meta line`).toBeGreaterThanOrEqual(img.box.y + img.box.h - 0.01);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("a tall logo is held to the 28 pt allowance and still leaves the stack under it", 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 prefs = prefsFor("A4", { templateId: "monolith" });
|
|
const result = await renderInvoicePdf({ model, prefs });
|
|
expect(result.issues.filter((i) => i.severity === "error")).toEqual([]);
|
|
const all = nodes(result.core.layout);
|
|
const img = all.find((n) => n.type === "image" && !n.decor)!;
|
|
const meta = all.find((n) => n.id === META_ID)!;
|
|
expect(img.box.h).toBeLessThanOrEqual(allowance.h + 0.01);
|
|
expect(img.box.y + img.box.h).toBeLessThanOrEqual(meta.box.y + 0.01);
|
|
});
|
|
|
|
it("a wordmark replaces the vendor name text; a mark keeps it beside the mark; no logo prints the name in the template's type", async () => {
|
|
const header = (r: Awaited<ReturnType<typeof renderFixture>>) => r.pdf.pages[0].items.filter((i) => i.str.trim() === "Test Vendor" && i.bottom < 130);
|
|
const wide = await renderFixture({ name: "o", items: 2, logo: "real" }, prefsFor("A4", { templateId: "monolith" }));
|
|
expect(header(wide)).toHaveLength(0);
|
|
const square = await renderFixture({ name: "o", items: 2, logo: "square" }, prefsFor("A4", { templateId: "monolith" }));
|
|
const [name] = header(square);
|
|
expect(name).toBeTruthy();
|
|
expect(name.x1).toBeLessThanOrEqual(imageBoxes(square)[0].box.x + 0.5);
|
|
const none = await renderFixture({ name: "o", items: 2, logo: "none" }, prefsFor("A4", { templateId: "monolith" }));
|
|
expect(imageBoxes(none)).toHaveLength(0);
|
|
expect(header(none)).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe("table, totals and footer", () => {
|
|
it("rules only: a 1.5 pt rule above and below a 39.3 pt header, amounts right-aligned 10 pt inside the edge, qty centred", async () => {
|
|
const r = await renderFixture(PLAIN, prefsFor("A4", { templateId: "monolith" }));
|
|
const head = byId(r, TABLE_HEAD_ID);
|
|
expect(head.box.h).toBeCloseTo(V.table.headH, 0);
|
|
const right = contentRight(r);
|
|
for (let i = 0; i < 3; i++) {
|
|
const row = byId(r, `${TABLE_ROW_PREFIX}${i}`);
|
|
const lines = textLines(row);
|
|
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);
|
|
}
|
|
const qty = textLines(byId(r, `${TABLE_ROW_PREFIX}0`)).find((l) => l.text === "1 hr")!;
|
|
const plan = planMonolith(r.fixture.model, frameForPrefs(r.prefs), V, null, false);
|
|
const left = frameForPrefs(r.prefs).pad.left + plan.W - plan.cw.amount - plan.cw.qty;
|
|
expect(qty.x + (qty.right - qty.x) / 2).toBeCloseTo(left + plan.cw.qty / 2, 0);
|
|
});
|
|
|
|
it("the totals box ends on the right edge, its labels are centred and a rule sits above Total", async () => {
|
|
const r = await renderFixture({ name: "t", items: 3, logo: "none" }, prefsFor("A4", { templateId: "monolith" }));
|
|
const totals = byId(r, TOTALS_ID);
|
|
const plan = planMonolith(r.fixture.model, frameForPrefs(r.prefs), V, null, false);
|
|
const frame = frameForPrefs(r.prefs);
|
|
const lines = textLines(totals);
|
|
const labelLeft = frame.pad.left + plan.W - plan.tail.boxW;
|
|
const subtotal = lines.find((l) => l.text === "Subtotal")!;
|
|
expect(subtotal.x + (subtotal.right - subtotal.x) / 2 - labelLeft).toBeCloseTo(plan.tail.labelW / 2, 0);
|
|
expect(lines.find((l) => l.text === r.fixture.model.totals.totalText)!.right).toBeCloseTo(frame.page.width - frame.pad.right - V.totals.valuePad, 0);
|
|
const rules = nodes([r.result.core.layout[0]]).filter((n) => n.type === "view" && n.box.h <= 0.76 && n.box.h >= 0.74 && Math.abs(n.box.w - plan.tail.boxW) < 0.5);
|
|
// The rule sits in a spacing wrapper of the same size, so count distinct positions.
|
|
expect(new Set(rules.map((n) => Math.round(n.box.y * 10)))).toHaveProperty("size", 1);
|
|
const total = textLines(totals).find((l) => l.text === "Total")!;
|
|
expect(rules[0].box.y).toBeLessThan(totals.box.y + total.y);
|
|
});
|
|
|
|
it("the closing block is the design's footer: payment information as a label : value grid at 114 pt, the vendor block at the right, between two rules", async () => {
|
|
const r = await renderFixture({ name: "c", items: 3, logo: "none", signature: true }, prefsFor("A4", { templateId: "monolith" }));
|
|
const closing = byId(r, CLOSING_ID, r.result.pages - 1);
|
|
const lines = textLines(closing);
|
|
const frame = frameForPrefs(r.prefs);
|
|
const bank = lines.find((l) => l.text === "State Bank of India")!;
|
|
const label = lines.find((l) => l.text === "Bank")!;
|
|
expect(bank.x - label.x).toBeCloseTo(V.closing.labelW + V.closing.colonW, 0);
|
|
expect(lines.some((l) => l.text === "PAYMENT INFORMATION :")).toBe(true);
|
|
const plan = planMonolith(r.fixture.model, frame, V, null, false);
|
|
const vendor = lines.find((l) => l.text === "9000000000")!;
|
|
expect(vendor.x).toBeCloseTo(frame.pad.left + plan.W - V.sideW, 0);
|
|
const rules = onPage(r, r.result.pages - 1).filter((n) => n.type === "view" && Math.abs(n.box.h - V.rule) < 0.01 && Math.abs(n.box.w - plan.W) < 0.5 && n.box.y >= closing.box.y - 0.01);
|
|
expect(rules.length).toBeGreaterThanOrEqual(2);
|
|
// The signature (an image and the signatory) sits in the band above the footer's top rule.
|
|
expect(byId(r, SLOT_ID.signatory, r.result.pages - 1).box.y + byId(r, SLOT_ID.signatory, r.result.pages - 1).box.h).toBeLessThanOrEqual(rules[0].box.y + 0.5);
|
|
});
|
|
|
|
it("a short invoice with the sample's data fits one A4 page by giving back the empty band, row padding and spacing", 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: "monolith" }));
|
|
expect(r.result.pages).toBe(1);
|
|
expect(r.result.issues.filter((i) => i.severity === "error")).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("the GST slots and the supplier ids", () => {
|
|
it("every GST slot has a home on a tax invoice, and the supplier's GSTIN and PAN are on page 1 whatever the length", async () => {
|
|
const r = await renderFixture({ name: "t", items: 60, signature: true }, prefsFor("A4", { templateId: "monolith" }));
|
|
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(byId(r, IDS_ID)).toBeTruthy();
|
|
expect(textLines(byId(r, IDS_ID)).map((l) => l.text)).toEqual(expect.arrayContaining(["SUPPLIER:", "GSTIN: 29AAAAA0000A1Z5", "PAN: ABCDE1234F"]));
|
|
const flat = r.pdf.pages.flatMap((p) => p.items.map((i) => i.str)).join("").replace(/\s+/g, "").toUpperCase();
|
|
for (const word of ["SUBTOTAL", "TAXABLEVALUE", "CGST@9%", "SGST@9%", "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: "monolith" }));
|
|
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: "monolith" }));
|
|
expect(r.result.issues.filter((i) => i.severity === "error"), `${size}/${name}`).toEqual([]);
|
|
}
|
|
}
|
|
});
|
|
|
|
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: "monolith" }));
|
|
const plan = planMonolith(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(r.pdf.pages.map((p, i) => textsOf(r, i)).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", 4], ["A4", 5], ["LETTER", 4]];
|
|
for (const [size, n] of cases) {
|
|
const r = await renderFixture({ name: "s", items: n, logo: "none" }, prefsFor(size, { templateId: "monolith" }));
|
|
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("flow notes never start with less than a line of room: the block starts on the next page instead of collapsing into zero-height lines", async () => {
|
|
const base = FIXTURES.find((f) => f.name === "long-notes")!;
|
|
// Row counts where the notes' label used to land in the last few points of a page (A4 / Letter).
|
|
const cases: Array<["A4" | "LETTER", number]> = [["A4", 3], ["A4", 14], ["LETTER", 13]];
|
|
for (const [size, n] of cases) {
|
|
const r = await renderFixture({ ...base, items: n, logo: "none" }, prefsFor(size, { templateId: "monolith" }));
|
|
expect(r.result.issues.filter((i) => i.severity === "error"), `${size} n=${n}`).toEqual([]);
|
|
const frame = frameForPrefs(r.prefs);
|
|
const label = nodes(r.result.core.layout).find((x) => x.type === "text" && x.lines?.some((l) => /^notes/i.test(l.text)))!;
|
|
let page = -1;
|
|
r.result.core.layout.forEach((pg, i) => walk(pg.nodes, (x) => x === label && (page = i)));
|
|
expect(label.box.y + 70, `${size} n=${n} label room`).toBeLessThanOrEqual(frame.page.height - frame.pad.bottom);
|
|
expect(page).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
|
|
it("an over-long vendor name wraps right-aligned in its column and never reaches the title", async () => {
|
|
const r = await renderFixture({ ...FIXTURES.find((f) => f.name === "id-tokens")!, logo: "none" }, prefsFor("A4", { templateId: "monolith" }));
|
|
const brand = textLines(byId(r, BRAND_ID));
|
|
expect(brand.length).toBeGreaterThan(1);
|
|
const title = textLines(byId(r, TITLE_ID));
|
|
expect(Math.min(...brand.map((l) => l.x))).toBeGreaterThan(Math.max(...title.map((l) => l.right)));
|
|
});
|
|
});
|
|
|
|
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: "monolith" });
|
|
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 = planMonolith(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/03-monolith.md"), "utf8");
|
|
expect(text).toContain("## Implementation notes");
|
|
});
|
|
});
|