Gate the default drop: with no poster and no source, nothing prints and the reader is told

check:poster-print-fallback proves the GOOD path — no .webp, .html present, browser
rasterises. On a tree with NEITHER it SKIPs. And neither is the DEFAULT, since
posterSources and bundlePosters both default false, so the behaviour a reader is
most likely to meet was the one behaviour nothing asserted.

BUILD-POSTERS-GENERATE-ON-DEMAND-DROP-52MB claims in prose that export and print
"degrade honestly, with the preview refusing to open rather than printing an error
page". That claim was true and untested. Measured on a real dropped folder: the
wall requests the .webp (404), then the .html fallback (404), then shows "poster
not found (404)" with a Close button. 0 print frames, 0 downloads, 0 page errors.

The failure this guards is NOT a 404. It is a print dialog that OPENS on a poster
that does not exist — a blank sheet, or a low-resolution PREVIEW thumbnail printed
as though it were the 3ft poster. Both look like the feature working.

MUTATION-TESTED, two plausible wrong fixes, each caught distinctly: falling back to
printing the preview ("1 print frame(s) opened with NO poster and NO source"), and
failing silently by clearing exportState ("the reader was told nothing"). The gate
also asserts BOTH paths were attempted, so a wall that gives up before trying to
rasterise fails even while showing the right message.

★ SERVE THE TREE ROOT, NOT public/ — and this is in the gate's header because I got
it wrong first. A leaf resolves assetsBase to '../../assets', which from
<tree>/<bundle>/<profile>/ is <tree>/assets. Serving public/ puts public/assets on
that path, a directory still holding 378 posters in a dev checkout, so the poster
loads with a 200 and the gate tests the opposite of what it claims. The same click
returned 200 under `--directory public` and 404 under `--directory public/doxa-maps`.
My first reading of this was "a print frame opened on a missing poster", which would
have been a serious product defect; it was the harness.

Also audited check-packet-render this tick: all thirteen printed metrics now have
assertions behind them. No decoration left on that surface.
This commit is contained in:
doxa-maps-88655c70 2026-09-24 10:16:10 +00:00
parent ebac982fcb
commit 8f7a2f875d
2 changed files with 154 additions and 1 deletions

View file

@ -0,0 +1,152 @@
#!/usr/bin/env node
// check-poster-degrades-honestly — with NO poster and NO source, say so. Do not
// open a print dialog on nothing.
//
// ★ THE UNCOVERED HALF. check:poster-print-fallback proves the GOOD path: when the
// .webp is missing but posters/<id>.html ships, the browser rasterises it. On a
// tree with neither — which is the DEFAULT drop, since posterSources and
// bundlePosters both default false — that gate SKIPs. So the behaviour a reader is
// most likely to meet was the one behaviour nothing asserted.
//
// The failure this guards against is not a 404. It is a print dialog that OPENS on
// a poster that does not exist: a blank sheet, or worse a low-resolution preview
// thumbnail printed as though it were the 3ft poster. Both look like the feature
// working. An honest refusal — "poster not found (404)" with a Close button — is
// the correct product behaviour and the one BUILD-POSTERS-GENERATE-ON-DEMAND-DROP-52MB
// claims in prose. This turns that claim into an assertion.
//
// ⚠ IT MUST BE RUN AGAINST THE TREE ROOT, NOT public/. A leaf resolves assetsBase
// to '../../assets', which from <tree>/<bundle>/<profile>/ is <tree>/assets. Serving
// public/ instead puts public/assets on that path — a directory that still holds
// 378 posters in a dev checkout — and the poster then loads with a 200, so this gate
// would silently test the opposite of what it claims. Measured 2026-09-24: the same
// click returned 200 under `--directory public` and 404 under
// `--directory public/doxa-maps`. Serve the TREE.
//
// Usage: node internal/check-poster-degrades-honestly.mjs <wallUrl> [zoneId]
import { chromium } from 'playwright'
const WALL = process.argv[2] || process.env.POSTER_WALL_URL
if (!WALL) {
console.log('POSTER-DEGRADES: SKIP — no wall url given.')
console.log(' Serve the TREE ROOT (public/<name>), not public/, or assetsBase resolves')
console.log(' outside the tree and the poster loads when it should 404.')
process.exit(0)
}
// The precondition is the INVERSE of the other gate's: there must be NO poster and
// NO source. If either is reachable this is the wrong tree to be testing.
const probeId = process.argv[3] || 'laos'
const srcUrl = new URL(`../../assets/posters/${probeId}.html`, WALL).href
const imgUrl = new URL(`../../assets/posters/${probeId}.webp`, WALL).href
let reachable = []
for (const [name, u] of [['html', srcUrl], ['webp', imgUrl]]) {
try { const r = await fetch(u); if (r.ok) reachable.push(name) } catch { /* unreachable is the point */ }
}
if (reachable.length) {
console.log(`POSTER-DEGRADES: SKIP — this tree DOES serve the poster ${reachable.join(' and ')}.`)
console.log(' That is the check:poster-print-fallback case. This gate asserts the tree with NEITHER.')
process.exit(0)
}
const browser = await chromium.launch({ args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader'] })
const page = await browser.newPage({ viewport: { width: 1500, height: 1100 }, acceptDownloads: true })
const errs = []
page.on('pageerror', (e) => errs.push(String(e).slice(0, 140)))
let downloads = 0
page.on('download', () => { downloads += 1 })
const posterHits = []
page.on('response', (r) => { if (/\/posters\//.test(r.url())) posterHits.push(`${r.url().split('/').pop()}=${r.status()}`) })
await page.goto(WALL, { waitUntil: 'load', timeout: 45000 })
await page.waitForTimeout(8000)
// Count print frames at creation: openPrintFrame's frame self-removes on afterprint,
// so polling for iframes finds none and would read as a pass for the wrong reason.
let printFrames = 0
await page.exposeFunction('__notePrintFrame', () => { printFrames += 1 })
await page.evaluate(() => {
const realAppend = Node.prototype.appendChild
Node.prototype.appendChild = function (n) {
const r = realAppend.call(this, n)
try {
if (n && n.tagName === 'IFRAME') {
window.__notePrintFrame()
try { n.contentWindow.print = () => {} } catch { /* cross-realm */ }
}
} catch { /* never break the page to observe it */ }
return r
}
window.print = () => {}
})
const clicked = await page.evaluate(() => {
const walk = (root, out = []) => {
for (const el of root.querySelectorAll('*')) { out.push(el); if (el.shadowRoot) walk(el.shadowRoot, out) }
return out
}
const all = walk(document)
// Skip cards that declare themselves unsupported — they short-circuit before any
// fetch, so they would prove nothing about the missing-poster path.
for (const card of all.filter((e) => (e.className || '').toString().split(' ').includes('card'))) {
if (card.querySelector('.ph.is-soon')) continue
const btn = [...card.querySelectorAll('button')]
.find((e) => /poster/i.test(e.textContent || '') && !/show on map/i.test(e.textContent || ''))
if (btn) { btn.click(); return (btn.textContent || '').trim() }
}
return null
})
let modal = []
for (let i = 0; i < 25; i++) {
await page.waitForTimeout(1000)
modal = await page.evaluate(() => {
const walk = (root, out = []) => {
for (const el of root.querySelectorAll('*')) { out.push(el); if (el.shadowRoot) walk(el.shadowRoot, out) }
return out
}
return walk(document)
.filter((e) => /modal-(kind|title|msg|err|soon)/.test((e.className || '').toString()))
.map((e) => (e.textContent || '').trim()).filter(Boolean)
})
if (modal.some((t) => /not found|unavailable|failed|error/i.test(t))) break
}
await browser.close()
const said = modal.join(' | ')
const fails = []
if (!clicked) fails.push('no poster print control found on any supported card')
// THE LOAD-BEARING ASSERTIONS.
if (printFrames > 0) {
fails.push(`${printFrames} print frame(s) opened with NO poster and NO source — a print dialog on `
+ 'nothing is a blank sheet, or a preview thumbnail printed as a 3ft poster. Both look like it worked.')
}
if (downloads > 0) fails.push(`${downloads} download(s) started with no poster to deliver`)
if (clicked && !/not found|unavailable|failed|error/i.test(said)) {
fails.push(`the reader was told nothing: modal read ${JSON.stringify(said) || '(empty)'} — `
+ 'a silent no-op is indistinguishable from a slow one')
}
// Both paths must have been TRIED, or this ran a shorter route than the product does.
if (clicked && !posterHits.some((h) => h.endsWith('=404') && h.includes('.webp'))) {
fails.push(`the .webp was never requested (poster responses: ${JSON.stringify(posterHits)})`)
}
if (clicked && !posterHits.some((h) => h.includes('.html'))) {
fails.push(`the .html fallback was never attempted (poster responses: ${JSON.stringify(posterHits)}) — `
+ 'the wall gave up before trying to rasterise')
}
if (errs.length) fails.push(`${errs.length} page error(s): ${errs[0]}`)
console.log('POSTER-DEGRADES')
console.log(` wall : ${WALL}`)
console.log(` clicked : ${clicked || '(nothing)'}`)
console.log(` poster fetches: ${JSON.stringify(posterHits)}`)
console.log(` print frames : ${printFrames} downloads: ${downloads}`)
console.log(` told the user : ${JSON.stringify(said) || '(nothing)'}`)
if (fails.length) {
console.error(`\nPOSTER-DEGRADES: FAIL — ${fails.length} problem(s)`)
for (const f of fails) console.error(' - ' + f)
process.exit(1)
}
console.log('\nPOSTER-DEGRADES: PASS — with neither poster nor source, both are tried, nothing '
+ 'prints, and the reader is told why.')

View file

@ -147,7 +147,8 @@
"check:tabs-localized": "node internal/check-tabs-localized.mjs # a chosen locale must CHANGE the rendered tab text. check:i18n-parity compares catalogs to each OTHER, so it cannot tell a shipped translation from a dead one; tabs.json is reached only via a DYNAMIC key (SemanticTreeLegend.vue:40), invisible to any grep. Renders it and reads the result.",
"check:packets-all": "node internal/check-packets-all.mjs # runs check:packet-render over EVERY generated packet, not one hand-named file. The single-file gate SKIPs with no argument, so it was in package.json, passing in all 11 locales, and in neither gates:pre nor gates:post — it only ran when someone typed a filename. SKIPs when no packets are generated.",
"check:poster-print-fallback": "node internal/check-poster-print-fallback.mjs # clicks the REAL poster print control with every posters/*.webp aborted, and asserts the preview RASTERISES from posters/<id>.html instead of opening on \"Poster not available yet\". Needs a served tree built with posterSources:true; SKIPs otherwise.",
"check:packet-strings-parity": "node internal/check-packet-strings-parity.mjs # the PACKET catalog (packet-strings.json) must be whole in every language: all string keys, all 12 prayer points with every field, all 12 wheel rhythms. check:i18n-parity covers library/i18n/locales only — six gates READ this file and none checked it. Asserts PRESENCE, not translatedness, with point4_passage declared-absent on record."
"check:packet-strings-parity": "node internal/check-packet-strings-parity.mjs # the PACKET catalog (packet-strings.json) must be whole in every language: all string keys, all 12 prayer points with every field, all 12 wheel rhythms. check:i18n-parity covers library/i18n/locales only — six gates READ this file and none checked it. Asserts PRESENCE, not translatedness, with point4_passage declared-absent on record.",
"check:poster-degrades": "node internal/check-poster-degrades-honestly.mjs # with NEITHER poster nor source — the DEFAULT drop — nothing must print and the reader must be told why. check:poster-print-fallback SKIPs that case, so the behaviour a reader most often meets had no gate. MUST be served from the TREE ROOT, not public/, or assetsBase resolves outside the tree and the poster loads when it should 404."
},
"devDependencies": {
"@capacitor/android": "^7.6.9",