The shared poster wall's locale is asserted, in locales that can show a leak
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions

UPG-100 carried a ❌ for a PosterWall i18n leak: useI18n() said to throw
where the wall mounts, leaving the print buttons English in every
locale on upg-100-mfe, poster-wall and country-poster-wall.

IT DOES NOT REPRODUCE. Measured on the current build, three leaves x
ro/ar/zh, tree identity checked against the served manifest first: zero
English leaks, zero page errors. ro "Listă de rugăciune / Harta afișului
/ Pachet de cartonașe", ar "قائمة الصلاة / خريطة الملصق", zh "禱告名單 /
海报地图", legend "Opțiuni de tipărire" / "خيارات الطباعة" / "列印選項".
The diagnosis does not fit either: this bundle installs i18n per element
via configureApp, and createI18n is legacy:false, so useI18n() resolves
for every descendant including the wall.

THE REAL DEFECT WAS THE MISSING ASSERTION, and it is worse than the bug
would have been: nothing in the repo checked the shared wall's RENDERED
locale, so a ❌ could sit on the scoreboard with nothing able to confirm
or refute it.

★ AND IT CANNOT BE CHECKED IN ENGLISH. The wall's guard HUMANIZES the
key when useI18n() throws - buttons.prayerList becomes "Prayer List" -
which is deliberate, so a bare host shows readable English rather than a
raw key. It also means "the button says Prayer List" is true both when
i18n worked and when it threw. An en-only check passes forever while
every other locale leaks. This gate therefore runs ONLY in non-English
locales, where the translated string differs from the humanized key and
seeing the key IS the defect.

It covers FIVE consumer leaves, not the three the report named:
key-cities and regions-dashboard mount the same shared wall and were
never checked. A gate that copies a report's sampling inherits its
blind spot.

TWO WAYS IT COULD HAVE PASSED WITHOUT CHECKING ANYTHING, both real on
the first run and both now hard skips rather than green ticks: a wall
with no cards renders no chrome, and regions-list rendered 7 cards with
ZERO wall chrome because that profile mounts the wall without the print
row. Skips are named in the output with their reason - a summary that
counts only the green ones reads as full coverage.

MUTATION TESTED by reproducing the reported condition exactly - forcing
the useI18n() guard to throw - rather than corrupting a string: the gate
goes red with 28-33 leaked strings on every measured combination, which
proves both that it detects the defect and that the defect is genuinely
absent from the unmutated build. Restored, 9 combinations PASS, 6
reported as not covered.

No source change: there was nothing to fix.
This commit is contained in:
doxa-maps-88655c70 2026-09-25 10:48:26 +00:00
parent c77207b53a
commit cd53582ea4
2 changed files with 170 additions and 1 deletions

View file

@ -0,0 +1,168 @@
#!/usr/bin/env node
/**
* check-poster-wall-localised — the SHARED poster wall's own chrome must be translated
* on every leaf that mounts it, in every locale.
*
* ★ WHY THIS CANNOT BE CHECKED IN ENGLISH, which is the whole reason the gap survived.
* PosterWall guards useI18n() (it THROWS on a host that never installed vue-i18n, which blanked
* people-groups-grid once via library FilterDropdown). The fallback HUMANIZES the key:
* buttons.prayerList -> "Prayer List" buttons.printOptions -> "Print Options"
* That is deliberate and good — a bare host shows readable English rather than a raw dotted key
* or a blank button. But it means "the button says Prayer List" is TRUE both when i18n worked and
* when it threw. An en-only check passes forever while every other locale leaks English.
*
* So this gate runs in NON-ENGLISH locales only. There, the translated string differs from the
* humanized key, so seeing the humanized key IS the defect, with no ambiguity.
*
* ⚠ IT CHECKS THE RENDERED PAGE, NOT THE SOURCE. A static scan can see `useI18n()` wrapped in a
* try/catch and conclude either "guarded, fine" or "might throw, bad" — neither is a measurement.
* Whether the catch FIRES depends on what the host installed at the mount point, which only
* exists at runtime. A scoreboard entry for this was open with nothing able to confirm or refute
* it; that is what this closes.
*
* Usage: node internal/check-poster-wall-localised.mjs [baseUrl]
* SKIPs when nothing is served; exits 2 (NOT MEASURED) when the server is not this tree.
*/
import { chromium } from 'playwright'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { servesThisTree } from './_serves-this-tree.mjs'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const BASE = process.argv[2] || process.env.BASE_URL || 'http://127.0.0.1:4173'
// EVERY leaf that mounts the shared wall. key-cities and regions-dashboard are here because they
// consume PosterWall too — the original report named only three, and a gate that copies a
// report's sampling inherits its blind spot.
const LEAVES = [
'my-upg-100-list/upg-100-mfe',
'my-upg-100-list/poster-wall',
'country-posters/country-poster-wall',
'key-cities/city-poster-wall',
'regions-dashboard/regions-list',
]
// ro is the widest-rendering Latin locale, ar is RTL, zh is double-width CJK: three different
// ways a translation can be wrong, and all three differ from the humanized English key.
const LANGS = (process.env.PW_LANG || 'ro,ar,zh').split(',').map((s) => s.trim()).filter(Boolean)
// The humanizeKey outputs for every key the wall renders. Seeing ONE of these in a non-English
// locale means the fallback fired. Derived from the same transform the component uses, so the
// two cannot drift: last path segment, camelCase split, first letter upper.
const KEYS = ['buttons.prayerList', 'buttons.posterMap', 'buttons.cardDeck', 'buttons.printOptions',
'buttons.postersPerRow', 'buttons.showOnMap', 'buttons.clearSelection', 'buttons.cardStockAria']
const humanizeKey = (k) => String(k).split('.').pop()
.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase())
const FALLBACKS = new Set(KEYS.map(humanizeKey))
let reachable = false
try { const r = await fetch(`${BASE}/doxa-maps/manifest.json`, { signal: AbortSignal.timeout(4000) }); reachable = r.ok } catch { /* precondition */ }
if (!reachable) {
console.log(`POSTER-WALL-LOCALISED: SKIP — nothing served at ${BASE}.`)
console.log(' Serve the built tree: bun run serve (or set BASE_URL).')
process.exit(0)
}
const treeCheck = await servesThisTree(BASE, ROOT)
if (!treeCheck.ok) {
console.error('POSTER-WALL-LOCALISED: NOT MEASURED — the server is not serving this checkout.')
console.error(` ${treeCheck.why}`)
process.exit(2)
}
const WALK = `(root) => { const o=[]; const rec=(r)=>{ for (const e of r.querySelectorAll('*')) { o.push(e); if (e.shadowRoot) rec(e.shadowRoot) } }; rec(root); return o }`
const browser = await chromium.launch({ args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader'] })
const rows = []
const fails = []
for (const leaf of LEAVES) {
for (const lang of LANGS) {
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } })
const errs = []
page.on('pageerror', (e) => errs.push(String(e).slice(0, 120)))
let r = null
try {
const res = await page.goto(`${BASE}/doxa-maps/${leaf}/index.html?lang=${lang}`,
{ waitUntil: 'load', timeout: 45000 })
if (!res || !res.ok()) { rows.push({ leaf, lang, skipped: `HTTP ${res ? res.status() : '?'}` }); await page.close(); continue }
await page.waitForTimeout(13000)
r = await page.evaluate(`(() => {
const all = (${WALK})(document)
const cls = (e, n) => new RegExp('\\\\b' + n + '\\\\b').test(e.className || '')
const texts = (n) => [...new Set(all.filter((e) => cls(e, n)).map((e) => e.textContent.trim()).filter(Boolean))]
const legend = all.find((e) => e.tagName === 'LEGEND' && e.parentElement && /\\bpfs\\b/.test(e.parentElement.className || ''))
const perrow = all.find((e) => cls(e, 'perrow__label'))
return {
cards: all.filter((e) => cls(e, 'card')).length,
chrome: [
...texts('pbtn'),
...(legend ? [legend.textContent.trim()] : []),
...(perrow ? [perrow.textContent.trim()] : []),
...texts('pstock'),
],
showOnMap: texts('tape-h'),
}
})()`)
} catch (e) {
rows.push({ leaf, lang, skipped: String(e.message || e).slice(0, 60) })
await page.close(); continue
}
await page.close()
// ⚠ TWO WAYS THIS GATE CAN PASS WITHOUT CHECKING ANYTHING, and both were REAL on the first
// run — a gate that cannot tell "clean" from "saw nothing" is the same failure it exists to
// catch. A wall with no cards renders no chrome; and regions-list rendered 7 cards with
// chrome 0, because that profile mounts the wall WITHOUT the print row, so there was no
// translatable string on the page at all and "all translated" was vacuously true.
if (!r.cards) { rows.push({ leaf, lang, skipped: 'no poster cards rendered' }); continue }
if (!r.chrome.length && !r.showOnMap.length) {
rows.push({ leaf, lang, skipped: `${r.cards} card(s) but ZERO wall chrome — nothing to check` })
continue
}
// "Show on map" is a COMPOSED string ("116 UPG·Show on map"), so compare by containment.
const leaked = [
...r.chrome.filter((t) => FALLBACKS.has(t)),
...r.showOnMap.filter((t) => [...FALLBACKS].some((f) => t.includes(f))),
]
rows.push({ leaf, lang, cards: r.cards, chrome: r.chrome.length, leaked, errs: errs.length })
if (leaked.length) {
fails.push(`${leaf} @ ${lang}: ${leaked.length} untranslated string(s) — ${[...new Set(leaked)].join(' · ')}`)
}
if (errs.length) fails.push(`${leaf} @ ${lang}: ${errs.length} page error(s) — ${errs[0]}`)
}
}
await browser.close()
console.log('POSTER-WALL-LOCALISED')
console.log(` base : ${BASE}`)
for (const row of rows) {
if (row.skipped) { console.log(` ${row.leaf} @ ${row.lang} SKIP — ${row.skipped}`); continue }
console.log(` ${row.leaf} @ ${row.lang} cards ${row.cards} · chrome ${row.chrome} string(s) · `
+ `${row.leaked.length ? `LEAKED ${row.leaked.length}` : 'all translated'} · ${row.errs} error(s)`)
}
const measured = rows.filter((x) => !x.skipped).length
const skipped = rows.filter((x) => x.skipped)
if (!measured) {
console.error('\nPOSTER-WALL-LOCALISED: NOT MEASURED — every leaf skipped, so nothing was checked.')
process.exit(2)
}
// ★ NO SILENT CAPS. A leaf that never got measured is not a leaf that passed, and a summary that
// counts only the green ones reads as full coverage. Name what was dropped and why, every run —
// key-cities ships NO leaf pages (bundle JS only), so it can never be reached by URL, and that
// is a coverage fact a reader of this output needs rather than a 404 buried mid-list.
if (skipped.length) {
const byLeaf = new Map()
for (const s of skipped) byLeaf.set(s.leaf, s.skipped)
console.log(`\n NOT COVERED (${skipped.length} combination(s)):`)
for (const [leaf, why] of byLeaf) console.log(` ${leaf} — ${why}`)
}
if (fails.length) {
console.error(`\nPOSTER-WALL-LOCALISED: FAIL — ${fails.length} problem(s)`)
for (const f of fails) console.error(` - ${f}`)
console.error('\n The shared wall humanizes its i18n keys when useI18n() throws, so the fallback')
console.error(' reads as fluent English ("Prayer List"). That is why this can only be seen in a')
console.error(' non-English locale, and why an en-only check would pass while every other')
console.error(' locale leaked.')
process.exit(1)
}
console.log(`\nPOSTER-WALL-LOCALISED: PASS — ${measured} leaf x locale combination(s), `
+ `${LANGS.join('/')}. The shared wall's own chrome is translated everywhere it mounts.`)

View file

@ -202,7 +202,8 @@
"check:leaves-mount:poison": "node internal/check-gospel-leaves-mount.mjs --poison",
"check:gospel-all": "node internal/check-gospel-all.mjs",
"check:no-conflict-markers": "node internal/check-no-conflict-markers.mjs",
"check:no-conflict-markers:poison": "node internal/check-no-conflict-markers.mjs --poison"
"check:no-conflict-markers:poison": "node internal/check-no-conflict-markers.mjs --poison",
"check:poster-wall-localised": "node internal/check-poster-wall-localised.mjs"
},
"//scripts": {
"_": "What each script does. These notes used to sit inside the command strings as trailing \"# …\" comments, which made the shell discard any arguments passed after \"--\" (so a gate given --url silently measured its hardcoded default port — possibly another agent's server). Keep notes here, never in a command.",