From a2c1eae16eacae0d37377c11679a04d9c3073845 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Fri, 12 Jun 2026 00:06:46 -0700 Subject: [PATCH] test(make-pdf): width-policy unit suite + landscape e2e gate with negative fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 24 unit tests weighted toward the false-positive guards: wide screenshot without an alt hint stays portrait, sub-threshold and tall images stay portrait, deterministic 1560/1561px boundary, whole-word alt matching ('photographic' must not match 'graph'), page=portrait veto beats every heuristic, diagnostic blocks never promote. E2E gate asserts pdfinfo per-page boxes through the compiled binary: exactly 3 of 5 fixture blocks get landscape pages (alt-hinted image, directive-forced image, wide sequence diagram) while the unhinted screenshot and the veto'd diagram stay portrait — plus the --toc combo proving TOC and named-page landscape coexist. Co-Authored-By: Claude Fable 5 --- make-pdf/test/e2e/landscape-gate.test.ts | 134 ++++++++++++ .../fixtures/diagram-assets/wide-arch.png | Bin 0 -> 9302 bytes .../diagram-assets/wide-screenshot.png | Bin 0 -> 10062 bytes make-pdf/test/fixtures/landscape-gate.md | 52 +++++ make-pdf/test/image-policy.test.ts | 190 ++++++++++++++++++ 5 files changed, 376 insertions(+) create mode 100644 make-pdf/test/e2e/landscape-gate.test.ts create mode 100644 make-pdf/test/fixtures/diagram-assets/wide-arch.png create mode 100644 make-pdf/test/fixtures/diagram-assets/wide-screenshot.png create mode 100644 make-pdf/test/fixtures/landscape-gate.md create mode 100644 make-pdf/test/image-policy.test.ts diff --git a/make-pdf/test/e2e/landscape-gate.test.ts b/make-pdf/test/e2e/landscape-gate.test.ts new file mode 100644 index 000000000..cadcfc29a --- /dev/null +++ b/make-pdf/test/e2e/landscape-gate.test.ts @@ -0,0 +1,134 @@ +/** + * Landscape promotion gate — proves the conservative auto-landscape policy + * end-to-end through the compiled binary, asserted on pdfinfo per-page boxes + * (the only oracle that can't lie about orientation). + * + * The fixture encodes one of each decision: + * - wide screenshot, no alt hint → MUST stay portrait (false-positive guard) + * - wide image, alt "architecture diagram" → promotes + * - small image with {page=landscape} → promotes (directive force) + * - wide mermaid sequence diagram → promotes (provenance automatic) + * - wide mermaid with page=portrait fence → MUST stay portrait (veto) + * + * Also runs the --toc combo: Paged.js isn't shipped in v1 (TOC renders + * without page numbers, browse falls through after 3s), so named-page + * landscape must survive a --toc run unchanged. If Paged.js ever lands and + * re-paginates, this is the test that catches the interaction. + */ + +import { describe, expect, test } from "bun:test"; +import { execFileSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +import { resolvePopplerTool } from "../../src/pdftotext"; + +const FIXTURE = path.resolve(__dirname, "../fixtures/landscape-gate.md"); +const ROOT = path.resolve(__dirname, "../../.."); +const PDF_BIN = path.join(ROOT, "make-pdf/dist/pdf"); +const BROWSE_BIN = path.join(ROOT, "browse/dist/browse"); +const BUNDLE = path.join(ROOT, "lib/diagram-render/dist/diagram-render.html"); + +const CHILD_TIMEOUT_MS = 60_000; + +function prerequisitesAvailable(): { ok: true } | { ok: false; reason: string } { + if (!fs.existsSync(PDF_BIN)) return { ok: false, reason: `make-pdf binary missing (${PDF_BIN}). Run bun run build.` }; + if (!fs.existsSync(BROWSE_BIN)) return { ok: false, reason: `browse binary missing (${BROWSE_BIN}).` }; + if (!fs.existsSync(BUNDLE)) return { ok: false, reason: `diagram-render bundle missing (${BUNDLE}).` }; + if (!fs.existsSync(FIXTURE)) return { ok: false, reason: `fixture missing (${FIXTURE}).` }; + if (!resolvePopplerTool("pdfinfo")) return { ok: false, reason: "pdfinfo not found (install poppler-utils)." }; + if (!resolvePopplerTool("pdftotext")) return { ok: false, reason: "pdftotext not found (install poppler-utils)." }; + return { ok: true }; +} + +interface PageBox { + page: number; + width: number; + height: number; +} + +function pageBoxes(pdfPath: string): PageBox[] { + const pdfinfo = resolvePopplerTool("pdfinfo")!; + const out = execFileSync(pdfinfo, ["-f", "1", "-l", "99", pdfPath], { + encoding: "utf8", + timeout: CHILD_TIMEOUT_MS, + }); + const boxes: PageBox[] = []; + for (const m of out.matchAll(/Page\s+(\d+)\s+size:\s+([0-9.]+)\s+x\s+([0-9.]+)\s+pts/g)) { + boxes.push({ page: Number(m[1]), width: parseFloat(m[2]), height: parseFloat(m[3]) }); + } + if (boxes.length === 0) throw new Error(`pdfinfo reported no page sizes:\n${out}`); + return boxes; +} + +const isLandscape = (b: PageBox) => b.width > b.height; + +function generate(args: string[], outputPdf: string): void { + execFileSync(PDF_BIN, ["generate", FIXTURE, outputPdf, "--quiet", ...args], { + encoding: "utf8", + env: { ...process.env, BROWSE_BIN }, + stdio: ["ignore", "pipe", "pipe"], + timeout: CHILD_TIMEOUT_MS, + }); +} + +describe("landscape promotion gate", () => { + const avail = prerequisitesAvailable(); + + test.skipIf(!avail.ok)("exactly the promoted blocks get landscape pages", () => { + if (!avail.ok) return; + const workDir = fs.mkdtempSync("/tmp/make-pdf-landscape-gate-"); + const outputPdf = path.join(workDir, "out.pdf"); + try { + generate([], outputPdf); + const boxes = pageBoxes(outputPdf); + const landscape = boxes.filter(isLandscape); + const portrait = boxes.filter((b) => !isLandscape(b)); + + // Three promotions: alt-hinted image, directive-forced image, wide diagram. + expect(landscape.length).toBe(3); + // First page (intro + screenshot) and the veto'd diagram stay portrait. + expect(portrait.length).toBeGreaterThanOrEqual(2); + expect(isLandscape(boxes[0])).toBe(false); + + // The veto'd diagram rendered (its labels exist) on a PORTRAIT page. + const pdftotext = resolvePopplerTool("pdftotext")!; + const lastPortrait = portrait[portrait.length - 1]; + const vetoText = execFileSync( + pdftotext, + ["-f", String(lastPortrait.page), "-l", String(lastPortrait.page), outputPdf, "-"], + { encoding: "utf8", timeout: CHILD_TIMEOUT_MS }, + ); + expect(vetoText).toContain("vetoalpha"); + } finally { + try { fs.rmSync(workDir, { recursive: true, force: true }); } catch { /* ignore */ } + } + }, 120000); + + test.skipIf(!avail.ok)("--toc combo: TOC renders and landscape promotion survives", () => { + if (!avail.ok) return; + const workDir = fs.mkdtempSync("/tmp/make-pdf-landscape-toc-"); + const outputPdf = path.join(workDir, "out.pdf"); + try { + generate(["--toc"], outputPdf); + const boxes = pageBoxes(outputPdf); + expect(boxes.filter(isLandscape).length).toBe(3); + + const pdftotext = resolvePopplerTool("pdftotext")!; + const text = execFileSync(pdftotext, [outputPdf, "-"], { encoding: "utf8", timeout: CHILD_TIMEOUT_MS }); + // TOC heading extracts uppercase (small-caps styling). + expect(text.toUpperCase()).toContain("CONTENTS"); + } finally { + try { fs.rmSync(workDir, { recursive: true, force: true }); } catch { /* ignore */ } + } + }, 120000); + + if (!avail.ok) { + test("landscape gate prerequisites are present (hard-required in CI)", () => { + if (process.env.CI) { + throw new Error(`landscape gate prerequisites missing in CI: ${avail.reason}`); + } + console.warn(`[skip] ${avail.reason}`); + }); + } +}); diff --git a/make-pdf/test/fixtures/diagram-assets/wide-arch.png b/make-pdf/test/fixtures/diagram-assets/wide-arch.png new file mode 100644 index 0000000000000000000000000000000000000000..cd71db58d5c760097412e1388a1b0de6ea021889 GIT binary patch literal 9302 zcmeAS@N?(olHy`uVBq!ia0y~y;7nj(V1B{D1QfZtC+H9ZgNm%Di(^Q|oHsWec^MQ0 z4r~Z+Qd3b975@-1nd?UV=5uU7l|Z2MteO$R`q1OZ0bwb!DMA=65-kvhK#~B+WCjL@ zj!{LUK`@#MMl-@_Suk20j@AgE5Ev~BMuT896^v#CPza2c1*1VQnhHiU0w@GV%YxA$ z7)=GE837amqh-O+3xe&o)(W7pQGqZ2OPz&k6ni+Fz){H{AqX87HBjP!44x*qK*vW9 zbSSWZ6*UOL21=Ei1R&bnSfInIqXvzJ5hzKFrX%Qt!)O*6Ek-~YVzh*W%(9KvBBM1e zC_jugB1Rippme}6T0xFhkf3xhT0xFhkf3xxaSxK|>-Mh&Yb|c<1exaP>gTe~DWM4f D)));w literal 0 HcmV?d00001 diff --git a/make-pdf/test/fixtures/diagram-assets/wide-screenshot.png b/make-pdf/test/fixtures/diagram-assets/wide-screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..f1c5fff6fe36cef31f222a4185431ac1580260f0 GIT binary patch literal 10062 zcmeAS@N?(olHy`uVBq!ia0y~y;NHQ&z}&*Y1Qgk)@OnN2gSx1vi(^Q|oHw@^IT;if z4j4S1aq`HqO|4DxItQ3*&TM4_ssw=t)sHJ-Y=)SGDKNIeK@Dbz3I>O!KsG3|fn_ld zl*z#9CJAE-^%%jJDiWzMriZ~P7<0m?!J|O|34zg6F`7Z3d117y7%euTNnx}G8LdpA zNnx~IG1|m}B!$t|=4itkS}2V6K}Ne{(4;WhGac=uLzBYju)^pF3p6Q=4l4|TVTCzm zZyQ*E5#n&+|8-u^g0>9`@bo##ge26_a2O2-hS5YYng~V{0VEBKhQnw$FpMUG(L^ws z2%u?TG&785hSAJ0ni+s0FxpXoB!kg#7!3z%WCnqha~qr2hNr&(1*fO0pUXO@geCxQ Cp{oD@ literal 0 HcmV?d00001 diff --git a/make-pdf/test/fixtures/landscape-gate.md b/make-pdf/test/fixtures/landscape-gate.md new file mode 100644 index 000000000..473d7fbbc --- /dev/null +++ b/make-pdf/test/fixtures/landscape-gate.md @@ -0,0 +1,52 @@ +# Landscape Gate + +Intro text under the first heading. + +## Negative: screenshot stays portrait + +![just a screenshot of the app](./diagram-assets/wide-screenshot.png) + +## Positive: alt-hinted wide image promotes + +![architecture diagram of the system](./diagram-assets/wide-arch.png) + +## Positive: directive forces a small image + +![small forced](./diagram-assets/red-box.png){page=landscape} + +## Positive: wide diagram auto-promotes + +```mermaid title="Wide sequence" +sequenceDiagram + participant A as seqalpha + participant B as seqbeta + participant C as seqgamma + participant D as seqdelta + participant E as seqepsilon + participant F as seqzeta + participant G as seqeta + participant H as seqtheta + participant I as seqiota + participant J as seqkappa + A->>J: long hop + B->>I: cross +``` + +## Negative: directive vetoes a wide diagram + +```mermaid page=portrait +sequenceDiagram + participant A as vetoalpha + participant B as vetobeta + participant C as vetogamma + participant D as vetodelta + participant E as vetoepsilon + participant F as vetozeta + participant G as vetoeta + participant H as vetotheta + participant I as vetoiota + participant J as vetokappa + A->>J: long hop +``` + +Closing text. diff --git a/make-pdf/test/image-policy.test.ts b/make-pdf/test/image-policy.test.ts new file mode 100644 index 000000000..e46f8eb4e --- /dev/null +++ b/make-pdf/test/image-policy.test.ts @@ -0,0 +1,190 @@ +/** + * Unit tests for the image width policy + conservative auto-landscape + * (image-policy.ts). Pure HTML-in/HTML-out — no browse daemon. + * + * The promotion heuristic is deliberately conservative (eng-review P4): + * false negatives are cheap (add {page=landscape}), false positives feel + * broken. The negative cases here are the load-bearing ones. + */ +import { describe, expect, test } from "bun:test"; + +import { + applyImageDirectives, + applyImagePolicy, + parseDirectives, +} from "../src/image-policy"; + +const silent = { warn: () => {} }; + +// 6.5in content box → threshold = 6.5 × 96 × 2.5 = 1560 CSS px. +const OPTS = { contentWidthIn: 6.5, ...silent }; + +function img(attrs: string): string { + return `

`; +} + +// ─── directive parsing ──────────────────────────────────────────────── + +describe("parseDirectives", () => { + test("width grammar", () => { + expect(parseDirectives("width=full")).toEqual({ width: "full", page: undefined }); + expect(parseDirectives("width=50%")).toEqual({ width: "50%", page: undefined }); + expect(parseDirectives("width=3in")).toEqual({ width: "3in", page: undefined }); + expect(parseDirectives("width=2.5cm")).toEqual({ width: "2.5cm", page: undefined }); + }); + test("page grammar + combination", () => { + expect(parseDirectives("page=landscape")).toEqual({ width: undefined, page: "landscape" }); + expect(parseDirectives("width=full page=portrait")).toEqual({ width: "full", page: "portrait" }); + }); + test("unknown tokens reject the whole group (stays visible text)", () => { + expect(parseDirectives("widht=full")).toBeNull(); + expect(parseDirectives("width=full caption=x")).toBeNull(); + }); + test("malformed values reject", () => { + expect(parseDirectives("width=banana")).toBeNull(); + expect(parseDirectives("page=sideways")).toBeNull(); + }); +}); + +describe("applyImageDirectives", () => { + test("brace suffix becomes data attrs and is consumed", () => { + const out = applyImageDirectives(`

a{width=50%}

`); + expect(out).toContain('data-gstack-width="50%"'); + expect(out).not.toContain("{width=50%}"); + }); + test("unrecognized brace group is left as literal text", () => { + const html = `

{not a directive}

`; + expect(applyImageDirectives(html)).toBe(html); + }); + test("non-adjacent braces untouched", () => { + const html = `

set {width=full} in config

`; + expect(applyImageDirectives(html)).toBe(html); + }); +}); + +// ─── width policy ───────────────────────────────────────────────────── + +describe("width styles", () => { + test("width=full → inline 100% style", () => { + const { html } = applyImagePolicy(img(`src="x" data-gstack-width="full"`), OPTS); + expect(html).toContain("width: 100%"); + }); + test("explicit dimension passes through", () => { + const { html } = applyImagePolicy(img(`src="x" data-gstack-width="3in"`), OPTS); + expect(html).toContain("width: 3in"); + }); + test("no directive → no inline style (CSS max-width owns the default)", () => { + const { html } = applyImagePolicy(img(`src="x" data-gstack-px-width="40" data-gstack-px-height="20"`), OPTS); + expect(html).not.toContain("style="); + }); +}); + +// ─── landscape promotion ────────────────────────────────────────────── + +describe("auto-landscape: negative cases (the load-bearing ones)", () => { + test("wide screenshot with no alt hint stays portrait", () => { + const r = applyImagePolicy( + img(`src="x" alt="screenshot of the app" data-gstack-px-width="3000" data-gstack-px-height="900"`), + OPTS, + ); + expect(r.hasLandscape).toBe(false); + expect(r.html).not.toContain("page-wide"); + }); + test("wide banner with hint but below width threshold stays portrait", () => { + const r = applyImagePolicy( + img(`src="x" alt="chart" data-gstack-px-width="1200" data-gstack-px-height="400"`), + OPTS, + ); + expect(r.hasLandscape).toBe(false); + }); + test("tall diagram (aspect below 1.8) stays portrait", () => { + const r = applyImagePolicy( + img(`src="x" alt="architecture diagram" data-gstack-px-width="2000" data-gstack-px-height="1500"`), + OPTS, + ); + expect(r.hasLandscape).toBe(false); + }); + test("no intrinsic dimensions stays portrait", () => { + const r = applyImagePolicy(img(`src="x" alt="diagram"`), OPTS); + expect(r.hasLandscape).toBe(false); + }); + test("page=portrait vetoes everything", () => { + const r = applyImagePolicy( + img(`src="x" alt="diagram" data-gstack-page="portrait" data-gstack-px-width="4000" data-gstack-px-height="1000"`), + OPTS, + ); + expect(r.hasLandscape).toBe(false); + }); + test("threshold boundary is deterministic: exactly at threshold stays portrait", () => { + // threshold = 6.5 × 96 × 2.5 = 1560 + const r = applyImagePolicy( + img(`src="x" alt="diagram" data-gstack-px-width="1560" data-gstack-px-height="600"`), + OPTS, + ); + expect(r.hasLandscape).toBe(false); + const r2 = applyImagePolicy( + img(`src="x" alt="diagram" data-gstack-px-width="1561" data-gstack-px-height="600"`), + OPTS, + ); + expect(r2.hasLandscape).toBe(true); + }); +}); + +describe("auto-landscape: positive cases", () => { + test("wide + alt hint + over threshold promotes and wraps", () => { + const warnings: string[] = []; + const r = applyImagePolicy( + img(`src="x" alt="architecture diagram" data-gstack-px-width="2400" data-gstack-px-height="1000"`), + { contentWidthIn: 6.5, warn: (m) => warnings.push(m) }, + ); + expect(r.hasLandscape).toBe(true); + expect(r.html).toContain('
{ + const r = applyImagePolicy(img(`src="x" data-gstack-page="landscape"`), OPTS); + expect(r.hasLandscape).toBe(true); + }); + test("alt hint matches whole words only", () => { + const r = applyImagePolicy( + img(`src="x" alt="photographic" data-gstack-px-width="2400" data-gstack-px-height="1000"`), + OPTS, + ); + expect(r.hasLandscape).toBe(false); // "graph" inside "photographic" must not match + }); +}); + +describe("auto-landscape: diagram figures", () => { + const fig = (svgAttrs: string, figAttrs = "") => + ``; + + test("wide diagram via viewBox promotes (provenance automatic, no alt needed)", () => { + const r = applyImagePolicy(fig(`width="100%" viewBox="0 0 2050 600"`), OPTS); + expect(r.hasLandscape).toBe(true); + expect(r.html).toContain('
{ + const r = applyImagePolicy(fig(`width="100%" viewBox="0 0 800 400"`), OPTS); + expect(r.hasLandscape).toBe(false); + }); + test("fence page=portrait vetoes a wide diagram", () => { + const r = applyImagePolicy( + fig(`width="100%" viewBox="0 0 3000 600"`, ` data-gstack-page="portrait"`), + OPTS, + ); + expect(r.hasLandscape).toBe(false); + }); + test("fence page=landscape forces a small diagram", () => { + const r = applyImagePolicy( + fig(`width="100%" viewBox="0 0 400 300"`, ` data-gstack-page="landscape"`), + OPTS, + ); + expect(r.hasLandscape).toBe(true); + }); + test("diagnostic blocks are never promoted", () => { + const html = ``; + const r = applyImagePolicy(html, OPTS); + expect(r.hasLandscape).toBe(false); + }); +});