diff --git a/documentation/README-SECTION-3-cli-commands.md b/documentation/README-SECTION-3-cli-commands.md index 958e61f31..b466d15d9 100644 --- a/documentation/README-SECTION-3-cli-commands.md +++ b/documentation/README-SECTION-3-cli-commands.md @@ -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 diff --git a/internal/check-packet-browser.mjs b/internal/check-packet-browser.mjs index 1d8eb4912..85042baf8 100644 --- a/internal/check-packet-browser.mjs +++ b/internal/check-packet-browser.mjs @@ -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/.html and lifts its , + // 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(//) + if (m) { + mapSvg = `` + + `
${m[0]}
` + } + } 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) { diff --git a/package.json b/package.json index 8cbe4289f..dfddd616e 100644 --- a/package.json +++ b/package.json @@ -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.",