Files
gstack/make-pdf/test/image-policy.test.ts
T
Garry Tan a2c1eae16e 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>
2026-06-12 00:06:46 -07:00

191 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
});
});