doxa-maps/internal/check-packet-browser.mjs
doxa-maps-88655c70 3519ca3934 check:packet-browser asserts its headings, and stops reading them in English
This gate printed nine metrics, folded six into `const bad = a || b || ...` — so a
failure said "FAIL" without naming which — and left three unchecked: page size and
the two headings, which are the very strings its own header claims it covers ("the
gate reports which headings came back translated"). Reports, not asserts.

★ IT LOOKED LIKE IT HAD FOUND A PRODUCT BUG, AND HAD NOT. Under ?lang=es it printed
an EMPTY wheel heading; under hi it blanked BOTH — while the catalog holds "Rueda de
Oración" and "प्रार्थना चक्र". That reads exactly like a broken i18n path, and it
PASSED anyway, because nothing asserted the values.

The cause was the harness. wheelTitle was innerText.match(/^.*Wheel.*$/m) and
pointsTitle /Prayer Points|Puntos/ — neither can match Hindi, and /Wheel/ cannot
match Spanish. The product was correct in every locale all along: verified after the
fix in en/es/hi/ar/zh, RTL and Devanagari included. Had I reported that as a defect
I would have been wrong.

mark5 carried the same fault: innerText.includes('Gadarenes'), an English proper
noun that goes RED the moment point4_passage is translated. Now asserted by
.p4-passage length — the same fix already applied in check-packet-render.

Headings are now compared to the CATALOG entry for the packet's language, with
per-key fallback to English exactly as the renderer resolves it. packet-render.js
gains class="points-title"/"wheel-title" as TEST HOOKS: both were inline-styled with
no selector, which is what forced the English greps in the first place.

MUTATION-TESTED: a renderer forced to ignore the language FAILS with both headings
named and the expected values shown; a renderer emitting an empty heading FAILS
("a blank heading is worse than an English one").

★ ONE MUTATION CORRECTLY PASSED, and it marks a real limit: reverting es
wheel_title to English IN THE CATALOG passes, because rendered and expected then
legitimately agree. This gate cannot tell a translated catalog from an untranslated
one — that is check:packet-strings-parity's job, and it does assert it. Two gates,
two questions.

Partial translation re-verified through the BROWSER path: a 1-of-21-key Swahili
block renders "Mambo 12 ya Maombi" with the wheel heading falling back to "Prayer
Wheel" — translated where present, English where not, neither blank.

That makes four gates with one root cause (tabs-localized, packet-render,
deck-click, and this one twice). Any new gate matching on user-visible text should
resolve it from the catalog.
2026-09-24 10:50:27 +00:00

146 lines
8.6 KiB
JavaScript

// check-packet-browser — the packet the READER gets, not the one Node writes.
//
// WHY A SECOND PACKET GATE. check-packet-render verifies a packet FILE produced by
// the build-time generator. This one verifies the BROWSER path: the legend's
// Export > Prayer packet action imports library/packet/packet-render.js, fetches
// live records, and prints in-place. Both callers share that one renderer, and this
// gate is what proves the shared module actually runs in a browser — a module can
// be perfectly correct under Node and still fail in a tab (a bare `process.env`
// reference, a Node-only import, a JSON import assertion).
//
// It also covers I18N, which is a product requirement rather than a nicety: pass a
// language and the gate reports which headings came back translated. Fallback is
// PER KEY, so a half-finished translation must render its finished keys and leave
// the rest in English — never blank.
//
// Usage: node internal/check-packet-browser.mjs [lang]
// PACKET_ORIGIN http origin serving the repo root (default http://127.0.0.1:4898)
// PACKET_API records API (default https://pray.doxa.life)
// PACKET_ISO3 country to build a test packet for (default LAO)
// Needs a static server over the repo root, e.g.: python3 -m http.server 4898
import { chromium } from 'playwright'
import { readFileSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const b = await chromium.launch()
const pg = await b.newPage({ viewport:{width:1500,height:1941} })
const errs=[]; pg.on('pageerror',e=>errs.push(String(e).slice(0,140)))
const ORIGIN = process.env.PACKET_ORIGIN || 'http://127.0.0.1:4898'
const API = process.env.PACKET_API || 'https://pray.doxa.life'
const ISO3 = process.env.PACKET_ISO3 || 'LAO'
const RENDER = `${ORIGIN}/library/packet/packet-render.js`
const STRINGS = `${ORIGIN}/library/scripts/posters/packet-strings.json`
// The SAME catalog, read from disk for the assertions below. STRINGS is the URL the
// browser fetches; this is the local path, so the gate compares against the file the
// page actually loaded rather than a second copy.
const STRINGS_PATH = resolve(dirname(fileURLToPath(import.meta.url)),
'..', 'library/scripts/posters/packet-strings.json')
const LANG = process.argv[2] || 'en'
await pg.goto(ORIGIN + '/')
const html = await pg.evaluate(async ({RENDER, STRINGS, LANG, ORIGIN, API, ISO3}) => {
const [{ createPacketRenderer }, strings, norm] = await Promise.all([
import(RENDER),
fetch(STRINGS).then(r=>r.json()),
import(`${ORIGIN}/plugin-designs/my-upg-100-list/data/normalize.js`),
])
const R = createPacketRenderer({ strings, lang: LANG })
const resp = await fetch(`${API}/api/people-groups/list?fields=slug,name,country_code,population,religion,image_url&lang=en`, {cache:'no-store'})
// Use the SAME normalizer the app uses — the API returns { posts, total }, and
// hand-rolling the extraction is how a probe ends up testing something the
// product never does.
const recs = norm.normalizeRecords(norm.extractRecords(await resp.json()))
const groups = recs.filter(r=>String(r.country_code)===ISO3)
.sort((a,b)=>String(a.name).localeCompare(String(b.name), LANG))
const photos = new Map(groups.filter(g=>g.image_url).map(g=>[g.slug,g.image_url]))
return R.buildPacket({id:ISO3.toLowerCase(),label:ISO3}, groups, photos, '')
}, {RENDER, STRINGS, LANG, ORIGIN, API, ISO3})
await pg.setContent(html, { waitUntil:'load' })
await pg.waitForTimeout(3000)
// ⚠ READ THE HEADINGS BY STRUCTURE, NOT BY ENGLISH WORDS. These were greps for
// /Wheel/ and /Prayer Points|Puntos/, so they returned EMPTY for every language
// whose heading contains neither — Spanish reported a blank wheel heading and
// Hindi blanked BOTH, while the catalog holds 'Rueda de Oración' and
// 'प्रार्थना चक्र'. Nothing asserted them, so the gate PASSED on what looked
// exactly like a product i18n failure. It was the harness. The renderer marks
// these two blocks, so select them and compare to the catalog below.
const r = await pg.evaluate(()=>({
sheets: document.querySelectorAll('.sheet').length,
cards: document.querySelectorAll('.card').length,
wheel: document.querySelectorAll('svg path[fill^="hsl"]').length,
// The passage block, not the English proper noun 'Gadarenes': that word would
// vanish the moment point4_passage is translated, so the assertion would go RED
// on a CORRECTLY translated packet (the same trap already fixed in
// check-packet-render).
mark5Len: (document.querySelector('.p4-passage')||{textContent:''}).textContent.trim().length,
metacamp: (document.body.innerText.match(/metacamp\.org/g)||[]).length,
firstCard: (document.querySelector('.card')||{}).innerText||'',
wheelTitle: (document.querySelector('.wheel-title')||{textContent:''}).textContent.trim(),
pointsTitle: (document.querySelector('.points-title')||{textContent:''}).textContent.trim(),
}))
const pdf = await pg.pdf({ preferCSSPageSize:true })
const t = pdf.toString('latin1')
const pages=(t.match(/\/Type\s*\/Page[^s]/g)||[]).length
const boxes=[...t.matchAll(/MediaBox\s*\[\s*0\s+0\s+([\d.]+)\s+([\d.]+)\s*\]/g)].map(m=>[+m[1],+m[2]])
await b.close()
console.log(` lang : ${LANG}`)
console.log(` sheets/PDF pages : ${r.sheets} / ${pages}`)
console.log(` people cards : ${r.cards}`)
console.log(` wheel segments : ${r.wheel}`)
console.log(` Mark 5 in full : ${r.mark5}`)
console.log(` metacamp.org : ${r.metacamp}`)
console.log(` page size : ${boxes[0]?boxes[0].join(' x '):'?'} pt`)
console.log(` points heading : ${r.pointsTitle.trim()}`)
console.log(` wheel heading : ${r.wheelTitle.trim()}`)
console.log(` page errors : ${errs.length} ${errs[0]||''}`)
// ── NAMED ASSERTIONS, NOT ONE COLLAPSED BOOLEAN ─────────────────────────────
// This was `const bad = a || b || c || ...`, so a failure printed "FAIL" without
// saying WHICH of six conditions tripped. Three further metrics were printed and
// never checked at all: page size, and the two headings — which are the very
// strings this gate's own header says it covers ("the gate reports which headings
// came back translated"). Reports, not asserts.
const fails = []
if (!r.sheets) fails.push('no .sheet elements — the browser packet did not render')
if (pages !== r.sheets) fails.push(`${pages} PDF pages for ${r.sheets} sheets — one sheet must be one page`)
if (!r.cards) fails.push('no people-group cards')
if (r.wheel !== 12) fails.push(`prayer wheel has ${r.wheel} segments, expected 12`)
if (r.metacamp !== 2) fails.push(`${r.metacamp} metacamp.org attributions, expected exactly 2`)
// Mark 5:1-20 in full, by LENGTH rather than an English proper noun.
if (r.mark5Len < 1200) {
fails.push(r.mark5Len === 0
? 'Mark 5:1-20 is NOT printed (no .p4-passage block)'
: `Mark 5:1-20 looks truncated: ${r.mark5Len} chars`)
}
// PAGE SIZE was printed and never compared. `@page{size:letter portrait}` is
// 612x792pt; a wrong PORTRAIT size (A4 is 595x842) is taller-than-wide too, so
// nothing else here would notice it.
if (!boxes.length) fails.push('the PDF declares no MediaBox — page geometry unreadable')
else {
const wrong = boxes.filter(([w,h]) => Math.abs(w-612)>1 || Math.abs(h-792)>1)
if (wrong.length) fails.push(`${wrong.length}/${boxes.length} page(s) are ${wrong[0].join(' x ')}pt, expected 612 x 792pt`)
}
// ── I18N, ASSERTED AGAINST THE CATALOG ──────────────────────────────────────
// The heading a reader sees must equal the catalog entry for THIS packet's
// language — per-key fallback to English when the key is absent, exactly as the
// renderer resolves it. Never a hardcoded expectation, which is what made these
// two unassertable in the first place.
const cat = JSON.parse(readFileSync(STRINGS_PATH, 'utf8'))
const expect = (key) => ((cat[LANG]||{})[key] ?? cat.en[key])
for (const [sel, key, got] of [['points-title','points_title',r.pointsTitle],
['wheel-title','wheel_title',r.wheelTitle]]) {
const want = expect(key)
if (!got) fails.push(`.${sel} is EMPTY — a blank heading is worse than an English one`)
else if (got !== want) fails.push(`.${sel} reads ${JSON.stringify(got)}, catalog says ${JSON.stringify(want)}`)
}
if (errs.length) fails.push(`${errs.length} page error(s): ${errs[0]}`)
if (fails.length) {
console.error(`\nPACKET-BROWSER: FAIL — ${fails.length} problem(s)`)
for (const f of fails) console.error(' - ' + f)
process.exit(1)
}
console.log('\nPACKET-BROWSER: PASS — the browser-rendered packet is correct, and its headings '
+ `match the ${LANG} catalog.`)