doxa-maps/internal/check-deck-click.mjs
doxa-maps-88655c70 53ae954a7f
Some checks are pending
build-gate / build (push) Waiting to run
The deck gate asserts its portraits, and can finally run in every locale
Two gaps on the same surface.

1. "116 portraits" was collected, printed, and NEVER asserted — only the QR codes
were checked against the card count, so a deck of 116 cards with ZERO portraits
passed. A prayer card without a face is a data tile, which is the whole difference
between the deck and a spreadsheet. Not hypothetical: card-render.js records that
the deck once shipped with no photos at all, because image_url was never requested
in fetchListRecords.

THE COUNT CANNOT BE PINNED — roughly half the groups have no portrait and the CDN
drops some — so two invariants instead: portraits + typographic === cards (a card
that is NEITHER means a third shape nobody checks), and a deck drawn from records
carrying image_url must have SOME, with DECK_EXPECT_PORTRAITS=0 to declare a
deliberately photo-less deck out loud.

2. The gate matched /card deck/i, so it could ONLY run in English. Pointed at
?lang=es it reported "no Card Deck button" and listed 28 buttons all reading
"Baraja de tarjetas" — the UI correctly translated, the GATE unable to see it. The
card deck is a core prayer artefact and had zero coverage in any of the ten
non-English locales, which is exactly where a rendering bug is most likely. The
label now resolves from the catalog for the page's own locale.

MUTATION-TESTED THROUGH THE REAL CAUSAL PATH, not synthetic DOM edits:
  - image_url removed from the records provider (the historical regression):
    FAIL, "116 cards and NOT ONE portrait", naming the cause.
  - the no-photo branch renamed so cards are NEITHER shape:
    FAIL, "116 cards but 0 portrait + 0 typographic = 0".
  - DECK_EXPECT_PORTRAITS=0 on that same deck: PASS, sum invariant still holding.
  - es cardDeck changed in the CATALOG without rebuilding: FAIL — so this now also
    catches catalog/bundle drift instead of passing on a stale bundle.

VERIFIED IN FOUR LOCALES, each matching its OWN label — en "Card Deck",
es "Baraja de tarjetas", ar "مجموعة البطاقات", hi "कार्ड सेट" — 116 cards, 116
portraits, 116 QR codes, @page 2.5in x 3.5in, 0 page errors in every one.

Same two patterns have now recurred three times: a count printed without an
assertion, and a gate keyed on English text that silently excludes every
translated surface. Worth checking for on any new gate.
2026-09-24 10:38:03 +00:00

148 lines
7.3 KiB
JavaScript

// check-deck-click — click the REAL Card Deck button and read what it prints.
//
// Every other packet/card gate drives the RENDERERS directly. This one drives the
// UI: it finds the button in the shadow root, clicks it, and captures the document
// that reaches the print frame. That is the only path that proves the wiring —
// records provider, stock picker, language, shared composable, print frame — is
// connected end to end. A renderer can be perfect while the button that calls it
// passes the wrong stock, or nothing at all.
//
// ⚠ IT INTERCEPTS THE PRINTED HTML rather than reading the iframe back.
// openPrintFrame calls window.print(), which BLOCKS in headless Chromium, and the
// frame self-removes on afterprint — reading it back measures a frame that has
// already gone and reports 0 cards for a correct deck.
//
// Usage: node internal/check-deck-click.mjs <wallUrl> [stockId] [expectedPage]
import { chromium } from 'playwright'
import { readFileSync, existsSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const URL_ = process.argv[2]
if (!URL_) { console.error('usage: check-deck-click.mjs <wallUrl> [stock] [expectedPage]'); process.exit(2) }
const STOCK = process.argv[3] || ''
const EXPECT_PAGE = process.argv[4] || (STOCK === 'pokerLandscape' ? '3.5in 2.5in' : '2.5in 3.5in')
// ★ THE LABEL COMES FROM THE CATALOG FOR THIS PAGE'S LOCALE, never a hardcoded
// English string. This matched /card deck/i, so it could only ever run in English:
// pointed at ?lang=es it reported "no Card Deck button" and listed 28 buttons all
// reading "Baraja de tarjetas" — the UI correctly translated, the GATE unable to
// see it. The card deck is a core prayer artefact and it had ZERO coverage in the
// ten locales where a rendering bug is most likely.
const REPO = process.env.DOXA_REPO || resolve(dirname(fileURLToPath(import.meta.url)), '..')
const localeOf = (u) => { try { return new URL(u).searchParams.get('lang') || 'en' } catch { return 'en' } }
const deckLabel = (loc) => {
const f = join(REPO, 'library/i18n/locales', loc, 'buttons.json')
if (!existsSync(f)) return null
try { return JSON.parse(readFileSync(f, 'utf8')).cardDeck || null } catch { return null }
}
const LOC = localeOf(URL_)
const WANT_DECK = deckLabel(LOC) || deckLabel('en') || 'Card Deck'
const b = await chromium.launch({ args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader'] })
const pg = await b.newPage({ viewport: { width: 1280, height: 1000 } })
const errs = []
pg.on('pageerror', (e) => errs.push(String(e).slice(0, 140)))
await pg.goto(URL_, { waitUntil: 'load', timeout: 45000 })
await pg.waitForTimeout(9000)
await pg.evaluate((want) => { window.__WANT_DECK = want }, WANT_DECK)
await pg.evaluate(() => {
window.__printed = ''
const real = document.createElement.bind(document)
document.createElement = (t) => {
const el = real(t)
if (t === 'iframe') {
Object.defineProperty(el, 'contentDocument', {
get: () => ({ open() {}, close() {}, write(h) { window.__printed += h } }),
})
Object.defineProperty(el, 'contentWindow', {
get: () => ({ addEventListener() {}, focus() {}, print() {} }),
})
}
return el
}
})
const r = await pg.evaluate(async (stock) => {
const host = [...document.querySelectorAll('*')]
.find((e) => e.shadowRoot && e.shadowRoot.querySelector('.pbtn'))
if (!host) return { error: 'no poster wall found on this page' }
const sr = host.shadowRoot
const btns = [...sr.querySelectorAll('.pbtn')]
const deck = btns.find((x) => (x.textContent || '').trim().includes(window.__WANT_DECK))
if (!deck) return { error: 'no Card Deck button', buttons: btns.map((x) => x.textContent.trim()) }
if (stock) {
const sel = sr.querySelector('.pstock')
if (!sel) return { error: 'no card-stock picker to drive' }
sel.value = stock
sel.dispatchEvent(new Event('change', { bubbles: true }))
await new Promise((res) => setTimeout(res, 400))
}
deck.click()
await new Promise((res) => setTimeout(res, 9000))
const doc = new DOMParser().parseFromString(window.__printed, 'text/html')
return {
cards: doc.querySelectorAll('.card').length,
qrs: doc.querySelectorAll('.qr svg').length,
imgs: doc.querySelectorAll('img.ph').length,
noph: doc.querySelectorAll('.ph-none').length,
page: (window.__printed.match(/@page\{size:([^;}]*)/) || [])[1] || '',
}
}, STOCK)
await b.close()
if (r.error) {
console.error(`DECK-CLICK: FAIL — ${r.error}`
+ (r.buttons ? ` (buttons present: ${r.buttons.join(' | ')})` : ''))
process.exit(1)
}
const fails = []
if (!r.cards) fails.push('the click printed no cards at all — the button is not wired to the renderer')
if (r.page.trim() !== EXPECT_PAGE) {
fails.push(`@page size is "${r.page.trim()}", expected "${EXPECT_PAGE}"`
+ (STOCK ? ' — the stock picker is not reaching the renderer' : ''))
}
if (r.qrs !== r.cards) fails.push(`${r.qrs} QR code(s) for ${r.cards} cards`)
// ── "116 PORTRAITS" WAS PRINTED AND NEVER ASSERTED ──────────────────────────
// A prayer card without a face is a data tile. This gate collected the count,
// displayed it, and checked only the QR codes — so a deck of 116 cards with ZERO
// portraits passed. That is not hypothetical: card-render.js records that the deck
// once shipped with no photos at all, because image_url was never requested in
// fetchListRecords (measured 95/95 groups DO have a portrait, so the omission cost
// every card its picture).
//
// ⚠ THE COUNT CANNOT BE PINNED, for the same reason the packet's could not: roughly
// half the people groups have no portrait, and the CDN drops some. So two invariants
// instead of a number:
// 1. every card is one shape or the other — portraits + typographic === cards.
// A card that is NEITHER means the renderer emitted a third nobody checks.
// 2. a deck drawn from records that carry image_url must have SOME portraits.
// DECK_EXPECT_PORTRAITS=0 declares a deliberately photo-less deck out loud,
// rather than leaving "none" indistinguishable from "silently none".
if (r.cards && r.imgs + r.noph !== r.cards) {
fails.push(`${r.cards} cards but ${r.imgs} portrait + ${r.noph} typographic = ${r.imgs + r.noph}`
+ ' — every card must be exactly one of the two')
}
if (r.cards && process.env.DECK_EXPECT_PORTRAITS !== '0' && r.imgs === 0) {
fails.push(`${r.cards} cards and NOT ONE portrait — a prayer card without a face is a data tile. `
+ 'This is what a missing image_url in the records provider looks like; set '
+ 'DECK_EXPECT_PORTRAITS=0 to declare a deliberately photo-less deck.')
}
if (errs.length) fails.push(`${errs.length} page error(s): ${errs[0]}`)
console.log(` locale : ${LOC} (matched ${JSON.stringify(WANT_DECK)})`)
console.log(` stock : ${STOCK || '(default)'}`)
console.log(` cards printed : ${r.cards} (${r.imgs} portraits, ${r.noph} typographic, ${r.qrs} QR codes)`)
console.log(` @page size : ${r.page.trim()} (want ${EXPECT_PAGE})`)
console.log(` page errors : ${errs.length}`)
if (fails.length) {
console.error(`\nDECK-CLICK: FAIL — ${fails.length} problem(s)`)
for (const f of fails) console.error(' - ' + f)
process.exit(1)
}
console.log(`\nDECK-CLICK: PASS — the real button printed ${r.cards} cards at ${r.page.trim()}.`)