mirror of https://github.com/garrytan/gstack.git
test(make-pdf): width-policy unit suite + landscape e2e gate with negative fixtures
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 <noreply@anthropic.com>
This commit is contained in:
parent
89c35352cc
commit
a2c1eae16e
|
|
@ -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}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 9.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.8 KiB |
|
|
@ -0,0 +1,52 @@
|
|||
# Landscape Gate
|
||||
|
||||
Intro text under the first heading.
|
||||
|
||||
## Negative: screenshot stays portrait
|
||||
|
||||

|
||||
|
||||
## Positive: alt-hinted wide image promotes
|
||||
|
||||

|
||||
|
||||
## Positive: directive forces a small image
|
||||
|
||||
{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.
|
||||
|
|
@ -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 `<p><img ${attrs}></p>`;
|
||||
}
|
||||
|
||||
// ─── 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(`<p><img src="x.png" alt="a">{width=50%}</p>`);
|
||||
expect(out).toContain('data-gstack-width="50%"');
|
||||
expect(out).not.toContain("{width=50%}");
|
||||
});
|
||||
test("unrecognized brace group is left as literal text", () => {
|
||||
const html = `<p><img src="x.png">{not a directive}</p>`;
|
||||
expect(applyImageDirectives(html)).toBe(html);
|
||||
});
|
||||
test("non-adjacent braces untouched", () => {
|
||||
const html = `<p>set {width=full} in config</p>`;
|
||||
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('<div class="page-wide"><img');
|
||||
expect(r.html).not.toContain("<p><img");
|
||||
expect(warnings[0]).toContain("landscape");
|
||||
});
|
||||
test("page=landscape forces promotion regardless of size", () => {
|
||||
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 = "") =>
|
||||
`<figure class="diagram" role="img" aria-label="d"${figAttrs}>\n<svg ${svgAttrs}><g/></svg>\n</figure>`;
|
||||
|
||||
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('<div class="page-wide"><figure');
|
||||
});
|
||||
test("normal flowchart stays portrait", () => {
|
||||
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 = `<figure class="diagram diagram-error" role="img" aria-label="x"><svg viewBox="0 0 4000 600"></svg></figure>`;
|
||||
const r = applyImagePolicy(html, OPTS);
|
||||
expect(r.hasLandscape).toBe(false);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue