test(packet): gate the prayer packet at the RENDERED layer
Some checks are pending
build-gate / build (push) Waiting to run

Every bug this gate exists for produced a file of the right size, with no
error, no broken image, and a generator reporting success. They were only
visible once a browser laid the document out, so this drives a real browser
and reads computed style rather than inspecting the HTML source.

★ The law it encodes: never print a number without asserting on it. The
throwaway probe this replaces PRINTED "PDF pages: 16" for several runs while
reporting "packet verified", because the failure counter never looked at that
value. A number that is displayed but not asserted on is decoration.

Two regressions it catches, both confirmed to FAIL before this was accepted:

  transform instead of zoom -> 16 PDF pages for 8 sheets
  map stylesheet removed    -> unresolved --paper/--sea/--frameline;
                               660/661 map elements fill BLACK

The second is the subtler one. The map SVG carries almost no presentation
attributes; it is styled from the poster document's stylesheet. Lift the
<svg> without those rules and SVG fill falls back to its initial value, which
is black — the whole map paints as a solid rectangle with nothing to see in
the DOM, no console error, and no broken image. It is asserted as a
WHOLE-MAP property because the first attempt tested one hand-picked <rect>
(the one just repaired) and went green while 647 others were still black.

Page count is load-bearing rather than cosmetic: the packet is printed
front-and-back, so "one sheet is exactly one page" is a correctness property,
not a tidiness one.

Counts are relative (sheets vs pages, cards vs photos) so the gate is not
pinned to one zone's group count, and the packet path is an argument or
PACKET_HTML rather than a hardcoded path. With no packet given it SKIPS
rather than failing, since the packet is generated on demand.
This commit is contained in:
doxa-maps-88655c70 2026-09-24 01:53:21 +00:00
parent 992e5ff2df
commit cb5285017d
2 changed files with 127 additions and 0 deletions

View file

@ -0,0 +1,126 @@
// check-packet-render — the UPG-100 prayer packet must RENDER, not merely generate.
//
// Every bug this gate exists for produced a file of the right size, with no error, no
// broken image, and a generator that reported success. They were only visible at the
// RENDERED layer, which is why this drives a real browser and reads computed style.
//
// ★ THE LAW THIS GATE ENCODES: never print a number without asserting on it. An earlier
// version of this probe PRINTED "PDF pages: 16" for several runs while reporting
// "✓ packet verified", because the failure counter never looked at that value. A number
// that is displayed but not asserted on is decoration, not verification.
//
// What it catches, each measured on a real regression:
// 1. PAGE COUNT. transform:scale() is PAINT-time, so a scaled sheet still RESERVES its
// full layout box and the paginator splits it — 8 sheets came out as 16 PDF pages,
// inking only the top of each. (zoom is LAYOUT-time and paginates correctly.)
// The packet is printed front-and-back, so an exact page count is load-bearing.
// 2. MAP PALETTE. The map SVG carries almost no presentation attributes; it is styled
// from the poster document's stylesheet. Lift the <svg> without those rules and SVG
// `fill` falls back to its INITIAL value, which is BLACK — 670 elements painted solid
// black with no error of any kind. Asserted as a WHOLE-MAP property, because the first
// attempt at this check tested one hand-picked <rect> (the one just repaired) and went
// green while 647 others were still black.
// 3. Content the Driver specified explicitly: Mark 5:1-20 in full, 12 wheel segments,
// the prayer rhythms, and exactly two minimal metacamp.org attributions.
//
// Usage: node internal/check-packet-render.mjs [path/to/packet.html]
// Default path is overridable with PACKET_HTML so this is not machine-specific.
import { chromium } from 'playwright'
import { existsSync } from 'node:fs'
import { resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
const target = process.argv[2] || process.env.PACKET_HTML
if (!target) {
console.log('PACKET-RENDER: SKIP — no packet given (pass a path or set PACKET_HTML).')
console.log(' The packet is generated on demand, so this gate is opt-in rather than')
console.log(' failing a build on a file that is not expected to exist.')
process.exit(0)
}
const file = resolve(target)
if (!existsSync(file)) {
console.error(`PACKET-RENDER: FAIL — no such packet: ${file}`)
process.exit(1)
}
const browser = await chromium.launch()
const page = await browser.newPage({ viewport: { width: 1500, height: 1941 } })
const errs = []
page.on('pageerror', (e) => errs.push(String(e).slice(0, 120)))
await page.goto(pathToFileURL(file).href, { waitUntil: 'networkidle' })
const r = await page.evaluate(() => {
const body = document.body.innerText
const imgs = [...document.querySelectorAll('.card img')]
const RHYTHMS = ['PRAISE', 'WAIT', 'CONFESS', 'READ THE WORD', 'ASK', 'INTERCESSION',
'PRAY THE WORD', 'THANK', 'SING', 'MEDITATE', 'LISTEN']
// Whole-map property, NOT one sampled element — see header note 2.
const mapFaults = (() => {
const svg = document.querySelector('.sheet svg')
if (!svg) return ['no map svg found']
const faults = []
const names = [...new Set((svg.outerHTML.match(/var\((--[a-z0-9-]+)/g) || []).map((v) => v.slice(4)))]
const cs = getComputedStyle(svg)
for (const n of names) if (!cs.getPropertyValue(n).trim()) faults.push(`unresolved ${n}`)
const all = [...svg.querySelectorAll('path,circle,text,rect,line')]
const black = all.filter((e) => getComputedStyle(e).fill === 'rgb(0, 0, 0)')
if (all.length && black.length > all.length * 0.25) {
faults.push(`${black.length}/${all.length} map elements fill BLACK`)
}
return faults
})()
return {
sheets: document.querySelectorAll('.sheet').length,
cards: document.querySelectorAll('.card').length,
imgs: imgs.length,
broken: imgs.filter((i) => !i.complete || i.naturalWidth === 0).length,
nophoto: document.querySelectorAll('.nophoto').length,
rhythms: RHYTHMS.filter((w) => body.includes(w)).length,
rhythmsTotal: RHYTHMS.length,
wheelSegs: document.querySelectorAll('svg path[fill^="hsl"]').length,
hasMark5: body.includes('Gadarenes'), // a word unique to Mark 5:1-20
metacamp: (body.match(/metacamp\.org/g) || []).length,
mapFaults,
}
})
const pdf = await page.pdf({ preferCSSPageSize: true })
await browser.close()
const raw = pdf.toString('latin1')
const boxes = [...raw.matchAll(/MediaBox\s*\[\s*0\s+0\s+([\d.]+)\s+([\d.]+)\s*\]/g)].map((m) => [+m[1], +m[2]])
const pages = (raw.match(/\/Type\s*\/Page[^s]/g) || []).length
const landscape = boxes.filter(([w, h]) => h <= w).length
// Counts are RELATIVE (sheets vs pages, cards vs photos) so this gate is not pinned to
// one zone's group count — a per-zone packet has a different number of both.
const fails = []
if (!r.sheets) fails.push('no .sheet elements — the packet did not render')
if (!r.cards) fails.push('no people-group cards')
if (r.broken) fails.push(`${r.broken} broken photo(s)`)
if (r.wheelSegs !== 12) fails.push(`prayer wheel has ${r.wheelSegs} segments, expected 12`)
if (r.rhythms !== r.rhythmsTotal) fails.push(`only ${r.rhythms}/${r.rhythmsTotal} prayer rhythms present`)
if (!r.hasMark5) fails.push('Mark 5:1-20 is NOT printed in full (Driver requirement for point 4)')
if (r.metacamp !== 2) fails.push(`${r.metacamp} metacamp.org attributions, expected exactly 2`)
if (r.mapFaults.length) fails.push(`map: ${r.mapFaults.join('; ')}`)
if (errs.length) fails.push(`${errs.length} page error(s): ${errs[0]}`)
if (landscape) fails.push(`${landscape} landscape page(s) — the packet must be portrait`)
if (pages !== r.sheets) fails.push(`${pages} PDF pages for ${r.sheets} sheets — one sheet must be exactly one page`)
console.log(` sheets/PDF pages : ${r.sheets} / ${pages}`)
console.log(` people cards : ${r.cards} (${r.imgs} photos, ${r.nophoto} typographic)`)
console.log(` wheel segments : ${r.wheelSegs}`)
console.log(` Mark 5 in full : ${r.hasMark5}`)
console.log(` metacamp.org : ${r.metacamp}`)
console.log(` map : ${r.mapFaults.length ? r.mapFaults.join('; ') : 'palette resolves, not black'}`)
console.log(` page size : ${boxes[0] ? boxes[0].join(' x ') : '?'} pt`)
if (fails.length) {
console.error(`\nPACKET-RENDER: FAIL — ${fails.length} problem(s)`)
for (const f of fails) console.error(` - ${f}`)
process.exit(1)
}
console.log(`\nPACKET-RENDER: PASS — ${r.sheets} sheets, ${r.sheets} pages, portrait, map painted, content intact.`)

View file

@ -55,6 +55,7 @@
"check:heartbeat": "node internal/check-heartbeat-threshold.mjs # the HEARTBEAT ZONE threshold (the choropleth's top bin) must come from config AND the shipped config must still render today's ramp. Two sides: (B) a configured threshold must MOVE the edge — catches re-hard-coding; (A2) dashboard.config.js's own map.* must still resolve to 5/20/60/150/Infinity — catches the shipped default silently changing. A2 was MISSING at first and heartbeatThreshold:999 shipped past this gate printing 'default ramp unchanged'. A grep for '150' cannot do either job: it false-positives on the legitimate default and false-negatives on a re-hard-coding at 200. run check:heartbeat:self-test for the NEGATIVE tests (3 cases).",
"check:heartbeat:self-test": "node internal/check-heartbeat-threshold.mjs --self-test # THREE regression cases, each required to fire ITS OWN assertion: [literal] resolver replaced by the old hard-coded array -> B; [shipped-default] dashboard.config.js heartbeatThreshold changed -> A2; [shipped-mode] heartbeatMode flipped to quantile -> A2. Poisons IN MEMORY, never the file. Testing that the FEATURE works is not testing that the REGRESSION is caught — this gate shipped with only the first and missed the second.",
"build:unlocked": "bun run gates:pre && bun run library-index && bun run check:library-index && bun run clean && bun run token && bun run build:bundles && bun run tree && bun run gates:post && bun run gallery",
"check:packet-render": "node internal/check-packet-render.mjs",
"check:template-literal": "node internal/template-literal-check.mjs",
"verify:map-paint": "node internal/verify-map-paint.mjs",
"check:sri": "node internal/sri-consistency-check.mjs",