Some checks are pending
build-gate / build (push) Waiting to run
Some languages need a face the system stack does not carry — Hindi renders as
tofu boxes in Georgia/Palatino/serif. When the packet declares one, a failed
@font-face is SILENT: the text is present, the strings compare equal, every
other assertion stays green, and the page prints as empty rectangles.
The gate now asserts every declared face reaches status "loaded". Confirmed to
fire before being accepted: corrupting the inlined base64 reports
'Noto Serif Devanagari:error — text in that script will print as empty boxes'.
★ THIS ASSERTS THE MECHANICAL HALF ONLY, deliberately. A general tofu detector
was attempted twice and abandoned, and the attempts are worth recording:
- comparing shaped width against a deliberately-missing font FALSE-POSITIVES on
every system-covered script, because Cyrillic, CJK and Arabic legitimately
measure the same in both stacks when both resolve to the same system font;
- an ink-column-profile heuristic scored CORRECTLY RENDERED Devanagari the same
as tofu (3 vs 2 distinct column heights), because its baseline bar gives a
uniform profile.
Both would have blocked correct Arabic, Chinese, Russian and Hindi packets. A
detector that fails working output is worse than no detector, so glyph
correctness stays a human reading a screenshot, and the gate covers what it can
check without lying.
196 lines
11 KiB
JavaScript
196 lines
11 KiB
JavaScript
// check-packet-render — the UPG-100 prayer packet must RENDER, not merely generate.
|
|
//
|
|
// Every bug this gate exists for produced a file of the right size, with no error, no
|
|
// broken image, and a generator that reported success. They were only visible at the
|
|
// RENDERED layer, which is why this drives a real browser and reads computed style.
|
|
//
|
|
// ★ THE LAW THIS GATE ENCODES: never print a number without asserting on it. An earlier
|
|
// version of this probe PRINTED "PDF pages: 16" for several runs while reporting
|
|
// "✓ packet verified", because the failure counter never looked at that value. A number
|
|
// that is displayed but not asserted on is decoration, not verification.
|
|
//
|
|
// What it catches, each measured on a real regression:
|
|
// 1. PAGE COUNT. transform:scale() is PAINT-time, so a scaled sheet still RESERVES its
|
|
// full layout box and the paginator splits it — 8 sheets came out as 16 PDF pages,
|
|
// inking only the top of each. (zoom is LAYOUT-time and paginates correctly.)
|
|
// The packet is printed front-and-back, so an exact page count is load-bearing.
|
|
// 2. MAP PALETTE. The map SVG carries almost no presentation attributes; it is styled
|
|
// from the poster document's stylesheet. Lift the <svg> without those rules and SVG
|
|
// `fill` falls back to its INITIAL value, which is BLACK — 670 elements painted solid
|
|
// black with no error of any kind. Asserted as a WHOLE-MAP property, because the first
|
|
// attempt at this check tested one hand-picked <rect> (the one just repaired) and went
|
|
// green while 647 others were still black.
|
|
// 3. Content the Driver specified explicitly: Mark 5:1-20 in full, 12 wheel segments,
|
|
// the prayer rhythms, and exactly two minimal metacamp.org attributions.
|
|
//
|
|
// Usage: node internal/check-packet-render.mjs [path/to/packet.html]
|
|
// Default path is overridable with PACKET_HTML so this is not machine-specific.
|
|
|
|
import { chromium } from 'playwright'
|
|
import { existsSync, readFileSync } from 'node:fs'
|
|
import { resolve, dirname } from 'node:path'
|
|
import { pathToFileURL } from 'node:url'
|
|
|
|
const target = process.argv[2] || process.env.PACKET_HTML
|
|
if (!target) {
|
|
console.log('PACKET-RENDER: SKIP — no packet given (pass a path or set PACKET_HTML).')
|
|
console.log(' The packet is generated on demand, so this gate is opt-in rather than')
|
|
console.log(' failing a build on a file that is not expected to exist.')
|
|
process.exit(0)
|
|
}
|
|
const file = resolve(target)
|
|
if (!existsSync(file)) {
|
|
console.error(`PACKET-RENDER: FAIL — no such packet: ${file}`)
|
|
process.exit(1)
|
|
}
|
|
|
|
const browser = await chromium.launch()
|
|
const page = await browser.newPage({ viewport: { width: 1500, height: 1941 } })
|
|
const errs = []
|
|
page.on('pageerror', (e) => errs.push(String(e).slice(0, 120)))
|
|
await page.goto(pathToFileURL(file).href, { waitUntil: 'networkidle' })
|
|
|
|
// Resolve the rhythm words for THIS packet's language from the shared catalog.
|
|
// Per-key fallback to English, the same contract the renderer honours.
|
|
const lang = await page.evaluate(() => document.documentElement.lang || 'en')
|
|
// RTL languages must actually be laid out RTL. Without dir="rtl" an Arabic packet
|
|
// renders readable glyphs LEFT-aligned — it looks like a styling quirk rather than
|
|
// the bug it is, and no other assertion here would notice.
|
|
const RTL = new Set(['ar', 'he', 'fa', 'ur', 'ps', 'sd', 'yi'])
|
|
const dir = await page.evaluate(() => document.documentElement.dir || 'ltr')
|
|
// SCRIPT FONT. Some languages need a face the system stack does not carry — Hindi
|
|
// renders as tofu boxes in Georgia/Palatino/serif. When the packet declares one,
|
|
// it must actually LOAD; a declared-but-failed @font-face is silent, and the page
|
|
// then prints as empty rectangles with every other assertion still green.
|
|
//
|
|
// ⚠ This asserts the FONT LOADED, not that glyphs are correct. A general tofu
|
|
// detector was attempted and abandoned: comparing shaped width against a missing
|
|
// font false-positives on every system-covered script (Cyrillic, CJK, Arabic all
|
|
// legitimately measure the same in both stacks), and an ink-profile heuristic
|
|
// scored CORRECTLY-RENDERED Devanagari the same as tofu, because its baseline bar
|
|
// gives a uniform column profile. A detector that blocks working packets is worse
|
|
// than none. Read the pixels for that; this covers the mechanical half.
|
|
const fontStatus = await page.evaluate(async () => {
|
|
await document.fonts.ready
|
|
const faces = [...document.fonts]
|
|
return { declared: faces.length, loaded: faces.filter((f) => f.status === 'loaded').length,
|
|
names: faces.map((f) => `${f.family}:${f.status}`) }
|
|
})
|
|
// The catalog sits beside the generator that produced this packet, so derive it
|
|
// from the packet's own path rather than hardcoding a machine-specific location.
|
|
const catalogPath = process.env.PACKET_STRINGS
|
|
|| resolve(dirname(file), '..', '..', 'packet-strings.json')
|
|
if (!existsSync(catalogPath)) {
|
|
console.error(`PACKET-RENDER: FAIL — no strings catalog at ${catalogPath}`)
|
|
console.error(' Set PACKET_STRINGS if the packet lives outside the generator tree.')
|
|
process.exit(1)
|
|
}
|
|
const catalog = JSON.parse(readFileSync(catalogPath, 'utf8'))
|
|
const enR = catalog.en.rhythms
|
|
const langR = (catalog[lang] || {}).rhythms || {}
|
|
const expected = [...new Set(Object.keys(enR).map((k) => langR[k] ?? enR[k]))]
|
|
// ★ A NON-ENGLISH PACKET MUST ACTUALLY DIFFER FROM ENGLISH. The failure this
|
|
// catches is a language block that exists but does nothing — a typo'd key, a
|
|
// block under the wrong ISO code, or a renderer that ignored the lang argument.
|
|
// Every assertion below would still pass in that case, because an all-English
|
|
// packet is a perfectly valid packet; only the comparison reveals it.
|
|
const translated = lang !== 'en'
|
|
? Object.keys(catalog.en).filter((k) => typeof catalog.en[k] === 'string'
|
|
&& (catalog[lang] || {})[k] && catalog[lang][k] !== catalog.en[k]).length
|
|
: null
|
|
await page.evaluate((words) => { window.__EXPECTED_RHYTHMS = words }, expected)
|
|
|
|
const r = await page.evaluate(() => {
|
|
const body = document.body.innerText
|
|
const imgs = [...document.querySelectorAll('.card img')]
|
|
// ⚠ The expected rhythm words come from the STRINGS CATALOG FOR THIS PACKET'S
|
|
// LANGUAGE, never a hardcoded English list. A fixed list tests the opposite of
|
|
// what i18n means: it passed a packet whose translation silently did nothing and
|
|
// FAILED every correctly-translated one (measured — the Spanish packet reported
|
|
// 0/11 rhythms while rendering all twelve correctly as ALABANZA/ESPERAR/...).
|
|
// Read the packet's own <html lang> and resolve per-key, exactly as the renderer
|
|
// does, so this asserts the PROPERTY (all rhythms present, in this language)
|
|
// rather than a vocabulary.
|
|
const RHYTHMS = window.__EXPECTED_RHYTHMS || []
|
|
|
|
// Whole-map property, NOT one sampled element — see header note 2.
|
|
const mapFaults = (() => {
|
|
const svg = document.querySelector('.sheet svg')
|
|
if (!svg) return ['no map svg found']
|
|
const faults = []
|
|
const names = [...new Set((svg.outerHTML.match(/var\((--[a-z0-9-]+)/g) || []).map((v) => v.slice(4)))]
|
|
const cs = getComputedStyle(svg)
|
|
for (const n of names) if (!cs.getPropertyValue(n).trim()) faults.push(`unresolved ${n}`)
|
|
const all = [...svg.querySelectorAll('path,circle,text,rect,line')]
|
|
const black = all.filter((e) => getComputedStyle(e).fill === 'rgb(0, 0, 0)')
|
|
if (all.length && black.length > all.length * 0.25) {
|
|
faults.push(`${black.length}/${all.length} map elements fill BLACK`)
|
|
}
|
|
return faults
|
|
})()
|
|
|
|
return {
|
|
sheets: document.querySelectorAll('.sheet').length,
|
|
cards: document.querySelectorAll('.card').length,
|
|
imgs: imgs.length,
|
|
broken: imgs.filter((i) => !i.complete || i.naturalWidth === 0).length,
|
|
nophoto: document.querySelectorAll('.nophoto').length,
|
|
rhythms: RHYTHMS.filter((w) => body.includes(w)).length,
|
|
rhythmsTotal: RHYTHMS.length,
|
|
wheelSegs: document.querySelectorAll('svg path[fill^="hsl"]').length,
|
|
hasMark5: body.includes('Gadarenes'), // a word unique to Mark 5:1-20
|
|
metacamp: (body.match(/metacamp\.org/g) || []).length,
|
|
mapFaults,
|
|
}
|
|
})
|
|
|
|
const pdf = await page.pdf({ preferCSSPageSize: true })
|
|
await browser.close()
|
|
|
|
const raw = pdf.toString('latin1')
|
|
const boxes = [...raw.matchAll(/MediaBox\s*\[\s*0\s+0\s+([\d.]+)\s+([\d.]+)\s*\]/g)].map((m) => [+m[1], +m[2]])
|
|
const pages = (raw.match(/\/Type\s*\/Page[^s]/g) || []).length
|
|
const landscape = boxes.filter(([w, h]) => h <= w).length
|
|
|
|
// Counts are RELATIVE (sheets vs pages, cards vs photos) so this gate is not pinned to
|
|
// one zone's group count — a per-zone packet has a different number of both.
|
|
const fails = []
|
|
if (!r.sheets) fails.push('no .sheet elements — the packet did not render')
|
|
if (!r.cards) fails.push('no people-group cards')
|
|
if (r.broken) fails.push(`${r.broken} broken photo(s)`)
|
|
if (r.wheelSegs !== 12) fails.push(`prayer wheel has ${r.wheelSegs} segments, expected 12`)
|
|
if (r.rhythms !== r.rhythmsTotal) fails.push(`only ${r.rhythms}/${r.rhythmsTotal} prayer rhythms present`)
|
|
if (!r.hasMark5) fails.push('Mark 5:1-20 is NOT printed in full (Driver requirement for point 4)')
|
|
if (r.metacamp !== 2) fails.push(`${r.metacamp} metacamp.org attributions, expected exactly 2`)
|
|
if (r.mapFaults.length) fails.push(`map: ${r.mapFaults.join('; ')}`)
|
|
if (fontStatus.declared && fontStatus.loaded !== fontStatus.declared) {
|
|
fails.push(`a declared @font-face did not load (${fontStatus.names.join(', ')}) — `
|
|
+ 'text in that script will print as empty boxes')
|
|
}
|
|
if (RTL.has(lang) && dir !== 'rtl') fails.push(`lang="${lang}" is right-to-left but dir="${dir}"`)
|
|
if (!RTL.has(lang) && dir === 'rtl') fails.push(`lang="${lang}" is not RTL but dir="rtl"`)
|
|
if (translated === 0) {
|
|
fails.push(`lang="${lang}" but no string differs from English — `
|
|
+ 'the language block is missing, mis-keyed, or the renderer ignored it')
|
|
}
|
|
if (errs.length) fails.push(`${errs.length} page error(s): ${errs[0]}`)
|
|
if (landscape) fails.push(`${landscape} landscape page(s) — the packet must be portrait`)
|
|
if (pages !== r.sheets) fails.push(`${pages} PDF pages for ${r.sheets} sheets — one sheet must be exactly one page`)
|
|
|
|
console.log(` sheets/PDF pages : ${r.sheets} / ${pages}`)
|
|
console.log(` people cards : ${r.cards} (${r.imgs} photos, ${r.nophoto} typographic)`)
|
|
console.log(` wheel segments : ${r.wheelSegs}`)
|
|
console.log(` Mark 5 in full : ${r.hasMark5}`)
|
|
console.log(` metacamp.org : ${r.metacamp}`)
|
|
console.log(` map : ${r.mapFaults.length ? r.mapFaults.join('; ') : 'palette resolves, not black'}`)
|
|
console.log(` page size : ${boxes[0] ? boxes[0].join(' x ') : '?'} pt`)
|
|
console.log(` script font : ${fontStatus.declared ? fontStatus.names.join(', ') : 'system stack'}`)
|
|
console.log(` language : ${lang} (dir=${dir}) (${r.rhythms}/${r.rhythmsTotal} rhythms in ${lang}`
|
|
+ `${translated === null ? '' : `, ${translated} strings translated`})`)
|
|
|
|
if (fails.length) {
|
|
console.error(`\nPACKET-RENDER: FAIL — ${fails.length} problem(s)`)
|
|
for (const f of fails) console.error(` - ${f}`)
|
|
process.exit(1)
|
|
}
|
|
console.log(`\nPACKET-RENDER: PASS — ${r.sheets} sheets, ${r.sheets} pages, portrait, map painted, content intact.`)
|