import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { PAGE_SIZES } from "../../engine/geometry"; import { CLOSING_ID, TOTALS_ID } from "../../engine/ids"; import { walk, type LayoutNode } from "../../engine/layoutTree"; import { frameForPrefs } from "../../render/invoice"; import { marginsFor } from "../contract"; import { TEMPLATE_CATALOG, catalogEntry } from "../catalog"; import { buildFixture, ensureSetup, FIXTURES, prefsFor, renderFixture } from "../harness"; import { DEFAULT_TEMPLATE_ID, TEMPLATES, getTemplate } from "../registry"; import { classic } from "../classic"; import { elastic, geometry, initialsOf, planNeutral, rateCell } from "./plan"; import { linea, LINEA_VARIANT } from "./linea"; import { monogram, MONOGRAM_VARIANT } from "./monogram"; import { serenity, SERENITY_VARIANT } from "./serenity"; import { getMeasurer } from "../../engine/measure"; ensureSetup(); const NEUTRAL = [linea, monogram, serenity]; const VARIANTS = { linea: LINEA_VARIANT, monogram: MONOGRAM_VARIANT, serenity: SERENITY_VARIANT } as const; const nodes = (layout: LayoutNode[]) => { const out: LayoutNode[] = []; walk(layout, (n) => out.push(n)); return out; }; describe("registry and catalog", () => { it("lists Classic first and Linea second, then the Neutral rest, the Ledger and the Band families", () => { expect(Object.keys(TEMPLATES)).toEqual(["classic", "linea", "monogram", "serenity", "crimson-grid", "tangerine-ledger", "slate-band", "teal-swoosh", "purple-pop", "citrus-split", "monolith", "cobalt-stripe", "marble", "highlighter"]); expect(TEMPLATE_CATALOG.map((e) => e.id)).toEqual(Object.keys(TEMPLATES)); }); it("falls back to Classic for an unknown, missing or prototype-key id", () => { for (const id of ["no-such-template", undefined, "", "constructor", "__proto__", "toString"]) expect(getTemplate(id), String(id)).toBe(classic); expect(getTemplate("serenity")).toBe(serenity); expect(DEFAULT_TEMPLATE_ID).toBe("classic"); expect(catalogEntry("nope").id).toBe("classic"); }); it("the catalog and the definitions agree, and every thumbnail is on disk", () => { for (const entry of TEMPLATE_CATALOG) { const def = TEMPLATES[entry.id]; expect(def.name).toBe(entry.name); expect(def.family).toBe(entry.family); expect(def.thumbnail).toBe(entry.thumbnail); expect(def.badge).toBe(entry.badge); expect(existsSync(path.join(process.cwd(), "public", entry.thumbnail)), entry.thumbnail).toBe(true); } }); it("only Linea carries the badge and the opt-in logo", () => { expect(TEMPLATE_CATALOG.filter((e) => e.badge).map((e) => e.id)).toEqual(["linea"]); expect(linea.badge).toBe("Matches your original format"); expect(linea.logo.optIn).toBe(true); expect(linea.logo.defaultOn).toBe(false); expect(monogram.logo.defaultOn && serenity.logo.defaultOn).toBe(true); expect(monogram.logo.slot?.mark).toBeTruthy(); expect(serenity.logo.slot?.mark).toBeTruthy(); }); it("every Neutral template declares its own role table in a bundled face", () => { expect(linea.roles.body.face).toBe("Montserrat"); expect(monogram.roles.body.face).toBe("Open Sans"); expect(serenity.roles.body.face).toBe("Open Sans"); expect(serenity.roles).not.toBe(monogram.roles); for (const t of NEUTRAL) for (const [name, token] of Object.entries(t.roles)) expect(token.size, `${t.id}.${name}`).toBeGreaterThan(0); }); }); describe("page geometry", () => { it("template margins and floors", () => { expect(marginsFor(linea, "template")).toEqual({ top: 56, right: 59.55, bottom: 60, left: 59.55 }); expect(marginsFor(monogram, "template")).toEqual({ top: 58, right: 69.64, bottom: 60, left: 69.64 }); expect(marginsFor(serenity, "template")).toEqual({ top: 56, right: 64.1, bottom: 60, left: 162.91 }); // Serenity's left margin never goes under the decorative band, whatever the preset. expect(marginsFor(serenity, "narrow").left).toBe(162.91); expect(marginsFor(serenity, "normal").left).toBe(162.91); // Narrow and Normal are absolute presets, only lifted to the floors (36 pt here). expect(marginsFor(linea, "narrow")).toEqual({ top: 36, right: 36, bottom: 36, left: 36 }); expect(marginsFor(monogram, "normal")).toEqual({ top: 54, right: 54, bottom: 54, left: 54 }); }); it("the rules sit where the Canva ones do on A4 (x 69.64-525.86 for Linea and Monogram, 162.91-531.39 for Serenity)", () => { for (const [t, left, right] of [ [linea, 69.64, 525.86], [monogram, 69.64, 525.86], [serenity, 162.91, 531.39], ] as const) { const frame = frameForPrefs(prefsFor("A4", { templateId: t.id })); const v = VARIANTS[t.id as keyof typeof VARIANTS]; const g = geometry(v, frame); const x0 = frame.pad.left + v.ruleInset; // Canva's page is 0.22 pt wider than A4, so the right end is allowed that much. expect(Math.abs(x0 - left), t.id).toBeLessThanOrEqual(0.3); expect(Math.abs(x0 + g.ruleW - right), t.id).toBeLessThanOrEqual(0.3); expect(frame.pad.bottom - frame.footerReserve).toBe(60); } }); it("Letter keeps the physical margins and gives the extra width to the content", () => { for (const t of NEUTRAL) { const a4 = frameForPrefs(prefsFor("A4", { templateId: t.id })); const letter = frameForPrefs(prefsFor("LETTER", { templateId: t.id })); expect(letter.pad).toEqual(a4.pad); expect(letter.contentWidth - a4.contentWidth).toBeCloseTo(PAGE_SIZES.LETTER.width - PAGE_SIZES.A4.width, 2); expect(letter.heightDeficit).toBeCloseTo(49.89, 2); } }); it("elastic gaps give back exactly the deficit and never pass their minimum", () => { const ranges = { a: { nominal: 70, min: 34 }, b: { nominal: 40, min: 20 }, c: { nominal: 50, min: 24 } }; expect(elastic(ranges, 0)).toEqual({ a: 70, b: 40, c: 50 }); const e = elastic(ranges, 49.89); const given = 160 - (e.a + e.b + e.c); expect(given).toBeCloseTo(49.89, 1); expect(e.a).toBeGreaterThanOrEqual(34); expect(elastic(ranges, 1000)).toEqual({ a: 34, b: 20, c: 24 }); }); }); describe("helpers", () => { it("initialsOf takes the first and last word", () => { expect(initialsOf("Test Vendor")).toBe("TV"); expect(initialsOf("Venkatachalam Longsurname Ramasubramanian Voice Studios Pvt")).toBe("VP"); expect(initialsOf("Bongbetic")).toBe("B"); expect(initialsOf(" ")).toBe(""); expect(initialsOf("& Co")).toBe("C"); }); it("rateCell keeps '₹1,200.00/hr' on one line when it fits and splits the unit otherwise", () => { const body = linea.roles.tableBody; expect(rateCell("₹1,200.00 per hour", 90, body)).toBe("₹1,200.00/hr"); expect(rateCell("₹1,200.00 per session", 60, body)).toEqual(["₹1,200.00", "per session"]); expect(rateCell(null, 90, body)).toBe("—"); expect(getMeasurer()).toBeTruthy(); }); }); describe("what the layout does with a logo", () => { const withLogo = { name: "logo", items: 2, logo: "real" as const }; const imageCount = (r: Awaited>) => nodes(r.result.core.layout[0].nodes).filter((n) => n.type === "image" && !n.decor).length; it("Monogram and Serenity draw the logo inside the slot; with no logo nothing, or the initials", async () => { for (const t of [monogram, serenity]) { const r = await renderFixture(withLogo, prefsFor("A4", { templateId: t.id })); expect(imageCount(r), t.id).toBe(1); const img = nodes(r.result.core.layout[0].nodes).find((n) => n.type === "image")!; expect(img.box.x + img.box.w, t.id).toBeLessThanOrEqual(frameForPrefs(r.prefs).page.width - frameForPrefs(r.prefs).pad.right + 0.5); const none = await renderFixture({ ...withLogo, logo: "none" }, prefsFor("A4", { templateId: t.id })); expect(imageCount(none), t.id).toBe(0); const text = none.pdf.pages[0].items.map((i) => i.str).join(" "); if (t.id === "monogram") expect(text).toContain("TV"); // No Canva ring or stamp is ever drawn. expect(nodes(none.result.core.layout[0].nodes).filter((n) => n.type === "svg" && !n.decor), t.id).toHaveLength(0); } }); it("Linea is logo-free until the user turns the logo on", async () => { const off = await renderFixture(withLogo, prefsFor("A4", { templateId: "linea" })); expect(imageCount(off)).toBe(0); const on = await renderFixture(withLogo, prefsFor("A4", { templateId: "linea", logo: { show: true } })); expect(imageCount(on)).toBe(1); const img = nodes(on.result.core.layout[0].nodes).find((n) => n.type === "image")!; expect(img.box.h).toBeLessThanOrEqual(48 + 0.01); expect(img.box.w).toBeLessThanOrEqual(150 + 0.01); expect(img.box.x).toBeCloseTo(59.55, 1); }); it("a logo wide as a wordmark stays inside its box in Monogram, a tall one too", async () => { for (const logo of ["wide", "square"] as const) { const r = await renderFixture({ ...withLogo, logo }, prefsFor("A4", { templateId: "monogram" })); const img = nodes(r.result.core.layout[0].nodes).find((n) => n.type === "image")!; expect(img.box.w).toBeLessThanOrEqual(142.75 + 0.01); expect(img.box.h).toBeLessThanOrEqual(116.5 + 0.01); } }); }); describe("the fonts the templates ask for are the ones embedded", () => { it("Linea embeds Montserrat, Monogram and Serenity embed Open Sans, and none falls back to Helvetica", async () => { for (const [t, family] of [ [linea, "Montserrat"], [monogram, "OpenSans"], [serenity, "OpenSans"], ] as const) { const r = await renderFixture({ name: "fonts", items: 2, logo: "none" }, prefsFor("A4", { templateId: t.id })); expect(r.pdf.raw, t.id).toContain(family); expect(r.pdf.raw, t.id).not.toContain("/BaseFont /Helvetica"); } }); }); describe("plan matches the laid-out blocks", () => { // The totals tail and the closing block carry minPresenceAhead values predicted from the measurer; // too low and a block can start a page without its spacer. for (const t of NEUTRAL) { 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 { model } = buildFixture(spec); const frame = frameForPrefs(prefs); const r = await renderFixture(spec, prefs); const logo = null; void logo; 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]; }; // The same plan the layout used: logo-less here, so it only differs in the header (not asserted). const plan = planNeutral(model, frame, VARIANTS[t.id as keyof typeof VARIANTS], null); const tailGap = plan.tailH - find(TOTALS_ID); expect(tailGap, "tail prediction vs layout").toBeGreaterThanOrEqual(-0.01); // Wrap is predicted greedily and can only err by whole lines, on the safe side. expect(tailGap).toBeLessThanOrEqual(15); const closingGap = plan.closingH - find(CLOSING_ID); expect(closingGap, "closing prediction vs layout").toBeGreaterThanOrEqual(-0.01); expect(closingGap).toBeLessThanOrEqual(15); }); } } } }); describe("design docs", () => { it("each Neutral doc records its implementation notes", () => { for (const f of ["04-linea.md", "05-monogram.md", "06-serenity.md"]) { const text = readFileSync(path.join(process.cwd(), "docs/templates", f), "utf8"); expect(text, f).toContain("## Implementation notes"); } }); });