packet browser gate: verify the packet a reader actually exports
Some checks are pending
build-gate / build (push) Waiting to run
Some checks are pending
build-gate / build (push) Waiting to run
The legend's Export > Prayer packet action is the path a reader clicks. The
build-time file is the one nobody clicks. The reader's packet was the less
verified of the two.
check:packet-render gained six assertion families over recent ticks — the 12
prayer points, the wheel's 12 LABELS rather than just its 12 segments, page 1's
geography, the print layer, the image census, the build stamp. Each was found by
mutation and each catches a real "looks fine, is empty" shape. None existed
here.
It also printed "Mark 5 in full : undefined" beside a PASS for as long as it has
existed: the measurement is r.mark5Len and the print read r.mark5, which is not
a key on r. The assertion always read the right key, so the requirement was
protected — but undefined next to a PASS reads as a gate that does not know what
it is doing. That is the inverse of this tree's usual failure, where a number is
printed and never asserted on.
A mistake caught before it became a false report. The new geography assertion
went red immediately with "no map svg on page 1", which would have read as a
serious product defect. It was the harness: this gate called buildPacket with an
EMPTY mapSvg, while the real export path fetches posters/<zone>.html and lifts
its <svg>. The shortcut was not just skipping an assertion — it meant the gate
never exercised mapCss() in a browser at all, which is the one thing it exists
to prove. It now fetches the map the way the product does and reports 13
geography paths, the same number check:packet-render gives for laos. ISO3 and
ZONE are also different strings ("lao" vs "laos.html"), so even a naive fetch
would have missed; PACKET_ZONE names it.
Mutation-tested: blanking the wheel labels gives 0/12 labelled and fails; hiding
content at print time gives 0 embedded fonts and fails.
i18n verified on the browser path for the first time. A partial locale with 3 of
12 points renders 12/12 points — the per-key merge holds here too — with the
points heading translated, the wheel heading falling back to English, Mark 5 at
2220 chars, and nothing blank.
Also made wave-safe: without a server it threw an unhandled navigation error and
dumped a stack trace, which is why it could never join a wave. It now SKIPs
cleanly naming its precondition. gates:post 35 -> 36, README re-synced. Verified
both ways — 36/36 with a server, and "0/1 passed, 1 SKIPPED" without one, which
is visible only because of the runner change in the previous commit.
This commit is contained in:
parent
a361da0a2e
commit
7a649b6845
3 changed files with 117 additions and 9 deletions
|
|
@ -49,7 +49,7 @@ The scripts these commands are grounded in (from this branch's `package.json`):
|
|||
"build": "bash internal/build-lock.sh",
|
||||
"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",
|
||||
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:wp-embed check:doc-scripts",
|
||||
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture",
|
||||
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser",
|
||||
"build:bundles": "node internal/run-parallel.mjs --bundles", // the 22 vite builds, 4 at a time
|
||||
"preview": "bun x vite preview",
|
||||
"serve": "node internal/serve-strict.mjs", // 404s a directory, like production
|
||||
|
|
|
|||
|
|
@ -37,10 +37,26 @@ const STRINGS = `${ORIGIN}/library/scripts/posters/packet-strings.json`
|
|||
// page actually loaded rather than a second copy.
|
||||
const STRINGS_PATH = resolve(dirname(fileURLToPath(import.meta.url)),
|
||||
'..', 'library/scripts/posters/packet-strings.json')
|
||||
// The POSTER zone whose map page 1 shows. ISO3 selects the people groups; the poster is
|
||||
// keyed by ZONE id, and they are not the same string (ISO3 'LAO' vs zone 'laos').
|
||||
const ZONE = process.env.PACKET_ZONE || 'laos'
|
||||
const LANG = process.argv[2] || 'en'
|
||||
|
||||
await pg.goto(ORIGIN + '/')
|
||||
const html = await pg.evaluate(async ({RENDER, STRINGS, LANG, ORIGIN, API, ISO3}) => {
|
||||
// ⚠ SKIP CLEANLY WHEN THE PRECONDITION IS ABSENT. This needs a static server over the
|
||||
// REPO ROOT and a reachable records API; without either it threw an unhandled navigation
|
||||
// error and dumped a stack trace, which is why it could never join a wave — a gate that
|
||||
// goes red with a stack on a machine that simply has no server running only teaches
|
||||
// people to disable it. Absence of a server is not a product failure; say so and exit 0.
|
||||
try {
|
||||
await pg.goto(ORIGIN + '/', { timeout: 8000 })
|
||||
} catch {
|
||||
await b.close()
|
||||
console.log(`PACKET-BROWSER: SKIP — nothing served at ${ORIGIN}.`)
|
||||
console.log(' This gate exercises the BROWSER export path, so it needs the repo root served:')
|
||||
console.log(' python3 -m http.server 4898 (or set PACKET_ORIGIN)')
|
||||
process.exit(0)
|
||||
}
|
||||
const html = await pg.evaluate(async ({RENDER, STRINGS, LANG, ORIGIN, API, ISO3, ZONE}) => {
|
||||
const [{ createPacketRenderer }, strings, norm] = await Promise.all([
|
||||
import(RENDER),
|
||||
fetch(STRINGS).then(r=>r.json()),
|
||||
|
|
@ -55,8 +71,24 @@ const html = await pg.evaluate(async ({RENDER, STRINGS, LANG, ORIGIN, API, ISO3}
|
|||
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})
|
||||
// ⚠ FETCH THE MAP THE WAY THE PRODUCT DOES. This passed '' for mapSvg, so the
|
||||
// browser packet had NO page-1 map and any geography assertion would have been
|
||||
// measuring the HARNESS's shortcut rather than the product. The real export path
|
||||
// (upg-100-list.vue:packetMapSvg) fetches posters/<zone>.html and lifts its <svg>,
|
||||
// returning '' on failure — a legitimate degraded state, the browser twin of the
|
||||
// build-time `nomap`. Exercise that code shape here instead of skipping it.
|
||||
let mapSvg = ''
|
||||
try {
|
||||
const html = await (await fetch(`${ORIGIN}/library/scripts/posters/out/posters/${ZONE}.html`)).text()
|
||||
const m = html.match(/<svg[\s\S]*?<\/svg>/)
|
||||
if (m) {
|
||||
mapSvg = `<style>${R.mapCss(html)}</style>`
|
||||
+ `<div style="position:absolute;left:0;top:250px;width:${R.W}px;`
|
||||
+ `transform:scale(0.62);transform-origin:top center">${m[0]}</div>`
|
||||
}
|
||||
} catch { /* no poster generated — page 1 renders without its map, as in the product */ }
|
||||
return R.buildPacket({id:ISO3.toLowerCase(),label:ISO3}, groups, photos, mapSvg)
|
||||
}, {RENDER, STRINGS, LANG, ORIGIN, API, ISO3, ZONE})
|
||||
|
||||
await pg.setContent(html, { waitUntil:'load' })
|
||||
await pg.waitForTimeout(3000)
|
||||
|
|
@ -76,6 +108,40 @@ const r = await pg.evaluate(()=>({
|
|||
// on a CORRECTLY translated packet (the same trap already fixed in
|
||||
// check-packet-render).
|
||||
mark5Len: (document.querySelector('.p4-passage')||{textContent:''}).textContent.trim().length,
|
||||
// ★ THE SAME PACKET, EXPORTED THROUGH A DIFFERENT PATH, WAS HELD TO A WEAKER STANDARD.
|
||||
// check-packet-render gained these over several ticks — the 12 points, the wheel's 12
|
||||
// LABELS (not just its 12 segments), page 1's geography — each found by mutation, each
|
||||
// catching a real "looks fine, is empty" shape. None of them existed here, and THIS is
|
||||
// the packet a reader actually gets: the legend's Export > Prayer packet action. The
|
||||
// build-time file is the one nobody clicks.
|
||||
// Counted the same way as the build-time gate, deliberately, so the two cannot disagree.
|
||||
points: (() => {
|
||||
const t = document.querySelector('.points-title')
|
||||
const host = t && t.parentElement && t.parentElement.parentElement
|
||||
if (!host) return -1
|
||||
return (host.innerText || '').split('\n').filter((l) => /^\s*\d{1,2}\.\s/.test(l)).length
|
||||
})(),
|
||||
// A segment without its word is a colour. `order` renders 12 from 12 keys, but `praise`
|
||||
// and `praise_end` collapse to one WORD in English — so count ELEMENTS, never distinct
|
||||
// words, or the expected number changes per language.
|
||||
wheelLabels: (() => {
|
||||
const t = document.querySelector('.wheel-title')
|
||||
const sheet = t && t.closest('.sheet')
|
||||
const svg = sheet && sheet.querySelector('svg')
|
||||
if (!svg) return -1
|
||||
return [...svg.querySelectorAll('text')]
|
||||
.map((e) => (e.textContent || '').trim())
|
||||
.filter((s) => s && !/^\d+$/.test(s) && !/^\d[\s\S]*×\s*12$/.test(s)).length
|
||||
})(),
|
||||
// Page 1 is the map one-pager. Palette-resolves and not-black are BOTH satisfied by a
|
||||
// map with zero country shapes, so count the coastlines: paths whose `d` is long enough
|
||||
// to be geography rather than an icon.
|
||||
mapGeoPaths: (() => {
|
||||
const sheet0 = document.querySelector('.sheet')
|
||||
const svg = sheet0 && sheet0.querySelector('svg')
|
||||
if (!svg) return -1
|
||||
return [...svg.querySelectorAll('path')].filter((e) => (e.getAttribute('d') || '').length > 200).length
|
||||
})(),
|
||||
metacamp: (document.body.innerText.match(/metacamp\.org/g)||[]).length,
|
||||
firstCard: (document.querySelector('.card')||{}).innerText||'',
|
||||
wheelTitle: (document.querySelector('.wheel-title')||{textContent:''}).textContent.trim(),
|
||||
|
|
@ -85,12 +151,23 @@ 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]])
|
||||
// THE PRINT LAYER. This gate already made a PDF and read only its STRUCTURE — page count
|
||||
// and MediaBox — which eight blank sheets of the right size satisfy perfectly. The packet
|
||||
// exists to be PRINTED, and a print-only CSS rule can strip content every on-screen check
|
||||
// still sees. FontFile2 = a face actually embedded, so the text travels with the file.
|
||||
const pdfFonts=(t.match(/\/FontFile2/g)||[]).length
|
||||
const pdfBytes=pdf.length
|
||||
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(` prayer points : ${r.points} / 12`)
|
||||
console.log(` wheel segments : ${r.wheel} (${r.wheelLabels}/12 labelled)`)
|
||||
console.log(` map (page 1) : ${r.mapGeoPaths} geography paths`)
|
||||
console.log(` printed artefact : ${(pdfBytes/1024/1024).toFixed(1)}MB, ${pdfFonts} embedded font(s)`)
|
||||
console.log(` Mark 5 in full : ${r.mark5Len} chars`) // ⚠ mark5Len, not mark5 — the latter is not a key on r and printed "undefined"
|
||||
// beside a PASS for as long as this gate has existed. The ASSERTION below always read
|
||||
// the right key, so the requirement was protected; only the display was blind.
|
||||
console.log(` metacamp.org : ${r.metacamp}`)
|
||||
console.log(` page size : ${boxes[0]?boxes[0].join(' x '):'?'} pt`)
|
||||
console.log(` points heading : ${r.pointsTitle.trim()}`)
|
||||
|
|
@ -107,6 +184,37 @@ if (!r.sheets) fails.push('no .sheet elements — the browser packet did not ren
|
|||
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`)
|
||||
// THE PRODUCT IS NAMED FOR THIS NUMBER: the page header reads "12 Prayer Points" in every
|
||||
// locale, so 12 is a contract. The failure it guards is silent — a partial translation once
|
||||
// rendered THREE under that heading, with nothing blank and no error.
|
||||
if (r.points !== 12) {
|
||||
fails.push(r.points === -1
|
||||
? 'no .points-title block — the 12 prayer points page did not render at all'
|
||||
: `${r.points} prayer point(s) rendered, expected 12`)
|
||||
}
|
||||
// A wheel with 12 coloured segments and no words on it is a pie chart. The document-wide
|
||||
// rhythm vocabulary cannot see this: the SAME words are rendered twice, once per segment
|
||||
// and once per prayer-point heading, so a whole-document search passes on the points page.
|
||||
if (r.wheelLabels !== 12) {
|
||||
fails.push(r.wheelLabels === -1
|
||||
? 'no prayer-wheel svg found — the wheel page did not render'
|
||||
: `the prayer wheel paints ${r.wheelLabels} segment label(s), expected 12`)
|
||||
}
|
||||
// Page 1 is the map one-pager — the first line of the packet spec.
|
||||
if (r.mapGeoPaths !== -1 && r.mapGeoPaths < 5) {
|
||||
fails.push(`only ${r.mapGeoPaths} geography path(s) on page 1 — the map one-pager is blank `
|
||||
+ 'or drew nothing but furniture')
|
||||
}
|
||||
// A missing map is a DECLARED degradation here, exactly as PACKET_EXPECT_MAP=0 is for the
|
||||
// build-time gate: out/posters/ is gitignored, so a checkout that never ran `bun run posters`
|
||||
// legitimately has none, and the product itself returns '' in that case.
|
||||
if (r.mapGeoPaths === -1) {
|
||||
console.log(` map (page 1) : ABSENT — no out/posters/${ZONE}.html (run \`bun run posters\`)`)
|
||||
}
|
||||
// The packet is a standalone file meant to print offline, so its text must travel with it.
|
||||
if (pdfFonts === 0) {
|
||||
fails.push('the PDF embeds NO fonts (0 /FontFile2) — the text depends on the printer\'s font set')
|
||||
}
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@
|
|||
"check:share-policy": "node internal/check-share-policy.mjs",
|
||||
"check:zone-census": "node internal/check-zone-census.mjs # every zone a USER CAN SELECT must have a poster (asks the LEGEND, not clusters.json — two collapsed deaf lists are unreachable and not a gap). CONDITIONAL: SKIPs without out/posters/, which is gitignored — run `bun run posters` first. It cannot be fixtured like check:packets-all, because it asserts COVERAGE of real generated output and a fixture would only assert the fixture is complete.",
|
||||
"check:print-geometry": "node internal/check-print-geometry.mjs",
|
||||
"check:packet-browser": "node internal/check-packet-browser.mjs",
|
||||
"check:packet-browser": "node internal/check-packet-browser.mjs # the packet the READER gets — the legend's Export > Prayer packet path, proving the shared renderer runs in a BROWSER. Asserts the same 12 points / 12 wheel labels / page-1 geography / print layer as check:packet-render, which it did not until 2026-09-24. CONDITIONAL: SKIPs without a server over the repo root (python3 -m http.server 4898) and a reachable records API.",
|
||||
"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",
|
||||
|
|
@ -157,7 +157,7 @@
|
|||
"check:doc-scripts:self-test": "node internal/check-doc-quotes-scripts.mjs --self-test",
|
||||
"check:legend-source": "node internal/check-legend-matches-source.mjs # the numbers the maps SHOW must match the numbers the live SOURCE has. Every other gate checks INTERNAL consistency — a page can agree with itself perfectly while its legend has stopped describing reality. Offline SKIPs (exit 0); only a reachable endpoint that disagrees fails.",
|
||||
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:wp-embed check:doc-scripts",
|
||||
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture",
|
||||
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser",
|
||||
"build:bundles": "node internal/run-parallel.mjs --bundles",
|
||||
"check:maplibre-drops": "node internal/check-maplibre-layer-drops.mjs",
|
||||
"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.",
|
||||
|
|
|
|||
Loading…
Reference in a new issue