Two fixes from measuring what I had only modelled. 1. THE ROW RENDERED 81px AGAINST A 73.25px MODEL. rega measured it: no box-sizing, so the declared height was the CONTENT box and the 6px padding plus 1px border were added outside it. Because `top` still advanced by the modelled height, every row overlapped its successor by the difference. The stack still fitted the sheet -- it ended 8px inside the 60px bottom margin instead of on it -- so nothing clipped, nothing ran off the page, and no headless check could see it. box-sizing:border-box makes the declared height the rendered height. The ladder still fits: the content box is 66.25px and a name plus two lines at the default 17px needs 61.8px. 2. THE OFFLINE GENERATOR WOULD HAVE PRINTED A ONE-CLAUSE DESCRIPTION. Its fetch never asked for primary_language or imb_gsec -- measured on the cached records, 0 of 2107 carried either, while country_code_label was present on all 2107. So the browser packet printed two full sentences and the offline packet printed "The Amahuaca live in Peru." from the same data. Same defect class as the comment already above that field list: it reads as missing DATA and is missing FIELDS. The generator now injects the describer too, deliberately WITHOUT a country resolver: library/utils/countryName.js cannot be imported under node at all (it imports JSON without an import attribute -- measured), which is the constraint that made this seam an injection. It is not needed, because describeGroup falls back to country_code_label and every cached record has one. Verified end to end: 28 packets, 63 sentences on the first page, zero fallbacks, country localized correctly. The gate now budgets against the CONTENT box rather than the declared height, asserts the DEFAULT 17px step fits and not merely the smallest, and pins box-sizing in the source -- mutation-tested: removing border-box fails with its own message.
180 lines
13 KiB
JavaScript
180 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* check-profile-table — the packet's ~20-per-page profile TABLE: picture | generated localized
|
|
* description | three prayer checkboxes.
|
|
*
|
|
* WHAT THIS GATE CAN AND CANNOT SEE — stated up front, because the limit is the interesting part.
|
|
* It runs in Node with no layout engine, so it asserts the things that are TRUE OF THE MODEL: the
|
|
* geometry closes, every group gets a row, the fit ladder picks the size it claims, the fallback
|
|
* fires in all three states. It CANNOT assert that the rendered row is 73px tall in a browser —
|
|
* that is rega's seat (internal/check-packet-render.mjs, which launches a browser and is exit-75
|
|
* NOT MEASURED anywhere else). A character-count budget is a MODEL of wrapping, not a measurement
|
|
* of it, and the honest place to say so is here rather than in a commit message nobody re-reads.
|
|
*
|
|
* Exit 0 pass · 1 fail.
|
|
*/
|
|
import { resolve, dirname } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { readFileSync } from 'node:fs'
|
|
|
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
const read = (p) => readFileSync(resolve(ROOT, p), 'utf8')
|
|
const { createPacketRenderer } = await import(resolve(ROOT, 'library/packet/packet-render.js'))
|
|
const { describeGroup } = await import(resolve(ROOT, 'library/packets/describeGroup.js'))
|
|
const S = JSON.parse(read('library/scripts/posters/packet-strings.json'))
|
|
|
|
const fails = []
|
|
const ok = (c, m) => { if (!c) fails.push(m) }
|
|
|
|
const CN = () => 'Malaysia'
|
|
const describe = (g) => describeGroup(g, 'en', { strings: S, countryName: CN })
|
|
const mk = (d) => createPacketRenderer({ strings: S, lang: 'en', describe: d })
|
|
const G = (i) => ({ slug: `g${i}`, name: `Group ${i}`, country_code: 'MYS', primary_language_label: 'Abai Sungai', population: 1000, religion: 'Islam', imb_gsec: 1 })
|
|
|
|
// ── 1. THE GEOMETRY CLOSES ON THE PAGE, AND WITH ZERO SLACK ────────────────────────────────────
|
|
// ★ Zero slack is the FINDING, not the reassurance: 20 rows + 19 gutters fill the sheet to exactly
|
|
// the bottom margin, so a row that grows by one line does not "use up spare space" — it pushes the
|
|
// last row off the sheet. This assertion is what makes the fit ladder load-bearing rather than
|
|
// decorative, and it fails loudly if anyone adjusts a constant without re-deriving the rest.
|
|
// ⚠ READ THE REAL CONSTANTS, DO NOT RESTATE THEM. An earlier version hardcoded ROWS/M/HEAD here,
|
|
// which meant the assertion checked MY arithmetic against MY arithmetic and stayed green when the
|
|
// source's ROWS changed — it was the pagination check that caught that mutant, by luck. PER_PAGE is
|
|
// exported; the rest come out of the rendered geometry, so this compares the SOURCE to the sheet.
|
|
const { PER_PAGE, W, H } = mk(describe)
|
|
const SRC = read('library/packet/packet-render.js')
|
|
const numOf = (re, what) => {
|
|
const m = SRC.match(re)
|
|
if (!m) { fails.push(`could not read ${what} out of packet-render.js — this gate is now measuring nothing`); return NaN }
|
|
return Number(m[1])
|
|
}
|
|
const M = numOf(/const M = (\d+), HEAD = (\d+)/, 'M')
|
|
const HEAD = Number((SRC.match(/const M = \d+, HEAD = (\d+)/) || [])[1])
|
|
const GUT = numOf(/const GUT = (\d+)/, 'GUT')
|
|
const CH = (H - 2 * M - HEAD - GUT * (PER_PAGE - 1)) / PER_PAGE
|
|
// ⚠ "THE ROWS CLOSE ON THE BOTTOM MARGIN" IS A TAUTOLOGY AND IS NOT ASSERTED HERE. CH is DERIVED by
|
|
// dividing the usable height by PER_PAGE, so the rows always fill the sheet exactly, for any row
|
|
// count — I wrote that assertion first, watched a 21-row mutant sail through it, and removed it.
|
|
// An assertion that cannot fail is worse than none: it reads as coverage.
|
|
// ★ THE REAL CONSTRAINT IS THE OPPOSITE ONE: rows shrink as they multiply, so the question is
|
|
// whether a row is still TALL ENOUGH for its content — the name line plus a two-line description at
|
|
// the SMALLEST ladder size. Below that, no ladder step fits and every long row overflows. Verified
|
|
// to fire: 20 rows (73px) and 21 rows (69px) pass because the content genuinely fits; 24 rows
|
|
// (60px) and 28 fail. That is the boundary this is meant to guard.
|
|
// ⚠ THE ROW IS A BORDER-BOX, so its padding and border come OUT of the declared height rather than
|
|
// being added to it. rega measured 81px against a 73.25px model before `box-sizing:border-box` was
|
|
// set — the rows were rendering 7.75px taller than the geometry that positions them, overlapping
|
|
// their successors, while the stack still fitted the sheet (ending 8px INSIDE the bottom margin
|
|
// rather than on it). Nothing clipped and nothing ran off the page, which is exactly why only a
|
|
// MEASUREMENT could find it. Budget against the CONTENT box that survives the chrome.
|
|
const ROW_PAD = 6, ROW_BORDER = 1
|
|
const CONTENT_H = CH - ROW_PAD - ROW_BORDER
|
|
ok(CONTENT_H >= 15 * 1.15 + 2 + 2 * 14 * 1.25,
|
|
`the row's content box is ${CONTENT_H.toFixed(1)}px at PER_PAGE=${PER_PAGE} — too short for a name plus two description lines even at the smallest ladder size, so long rows will overflow onto the row below`)
|
|
// ★ AND THE BIGGEST LADDER STEP MUST FIT TOO, not just the smallest. Asserting only the 14px case
|
|
// would let the 17px default overflow on every row while the gate stayed green — the ladder's TOP
|
|
// is what almost every row actually renders at (all 11 locales fit at 17px today).
|
|
ok(CONTENT_H >= 15 * 1.15 + 2 + 2 * 17 * 1.25,
|
|
`the row's content box is ${CONTENT_H.toFixed(1)}px — too short for a name plus two lines at the DEFAULT 17px, which is what nearly every row renders at`)
|
|
// The box model itself, asserted in the source: without border-box the declared height is the
|
|
// CONTENT box and the row silently renders ${ROW_PAD + ROW_BORDER}px taller than it is positioned for.
|
|
ok(/\.card\{[^}]*box-sizing:border-box/.test(SRC),
|
|
'the .card row is not box-sizing:border-box — its padding and border will be ADDED to the declared height, so every row renders taller than the geometry that positions it')
|
|
|
|
// ── 2. EVERY GROUP GETS A ROW, ACROSS THE PAGE BOUNDARY ────────────────────────────────────────
|
|
// ★ THE LOAD-BEARING ONE. A layout rewrite is exactly where people get silently dropped: an
|
|
// off-by-one in the slice, or a row that overflows and is clipped, loses a PEOPLE GROUP with no
|
|
// error and a page that still looks complete. Asserted at a count that is NOT a multiple of
|
|
// PER_PAGE, because a clean multiple hides an off-by-one on the last page.
|
|
for (const n of [1, 19, 20, 21, 45, 100]) {
|
|
const R = mk(describe)
|
|
const html = R.listPages({ id: 'z', label: 'Z' }, Array.from({ length: n }, (_, i) => G(i)), new Map()).join('')
|
|
const rows = (html.match(/class="card"/g) || []).length
|
|
ok(rows === n, `${n} groups rendered ${rows} rows — a layout rewrite dropped people`)
|
|
const pages = R.listPages({ id: 'z', label: 'Z' }, Array.from({ length: n }, (_, i) => G(i)), new Map()).length
|
|
ok(pages === Math.ceil(n / 20), `${n} groups made ${pages} pages, expected ${Math.ceil(n / 20)}`)
|
|
}
|
|
|
|
// Each row must carry its three prayer boxes — the packet is a WORKING sheet, and a row without
|
|
// them is a reference row the reader cannot mark.
|
|
{
|
|
const html = mk(describe).listPages({ id: 'z' }, Array.from({ length: 20 }, (_, i) => G(i)), new Map()).join('')
|
|
ok((html.match(/pray-box/g) || []).length === 60, 'not every row carries three prayer checkboxes')
|
|
}
|
|
|
|
// ── 3. THE FIT LADDER PICKS THE SIZE IT CLAIMS, AND EVERY TIER FIRES ───────────────────────────
|
|
// Budgets are recomputed here from the same inputs rather than hardcoded, so this tests the LADDER
|
|
// and not my arithmetic twice.
|
|
const DESC_W = 1036, ADV = 0.52, LINES = 2
|
|
const budget = (px) => Math.floor(DESC_W / (px * ADV)) * LINES
|
|
const probe = (txt) => {
|
|
const html = mk(() => txt).listPages({ id: 'z' }, [G(0)], new Map()).join('')
|
|
const m = html.match(/class="d-tx" style="font-size:(\d+)px">([^<]*)</)
|
|
return m ? { px: +m[1], text: m[2] } : { fallback: /d-fb/.test(html) }
|
|
}
|
|
const words = (n) => 'word '.repeat(n).trim()
|
|
ok(probe(words(10)).px === 17, 'a short description did not render at the full 17px')
|
|
ok(probe(words(Math.floor(budget(17) / 5))).px === 17, 'a description just inside the 17px budget stepped down anyway')
|
|
ok(probe(words(Math.floor(budget(17) / 5) + 6)).px === 15,
|
|
'a description over the 17px budget did not step to 15px')
|
|
const long = probe(words(Math.floor(budget(14) / 5) + 40))
|
|
ok(long.px === 14, `a very long description did not clamp at the smallest size (got ${long.px}px)`)
|
|
ok(long.text.endsWith('…'), 'a clamped description is not marked with an ellipsis')
|
|
// ★ THE CUT IS WORD-AWARE. Slicing mid-word reads as a RENDERING FAULT rather than an editorial
|
|
// cut — especially inside a people-group's name, where the reader cannot tell a truncation from a
|
|
// misspelling of a name they have never seen.
|
|
// ⚠ THE INPUT MUST HAVE DISTINCT WORDS. My first version of this check fed `'word '.repeat(n)`,
|
|
// and a mid-word cut of identical repeated words is INDISTINGUISHABLE from a word-boundary cut
|
|
// ("…word word wor…" vs "…word word…" both look plausible) — the assertion passed against a
|
|
// deliberately mid-word-cutting mutant. The words must differ in length so the cut point is
|
|
// observable: every retained token is checked against the source vocabulary.
|
|
{
|
|
const vocab = ['alpha', 'bravissimo', 'cho', 'delta', 'echoing', 'foxtrotting', 'golf', 'hotelier']
|
|
const text = Array.from({ length: 200 }, (_, i) => vocab[i % vocab.length]).join(' ')
|
|
const cut = probe(text)
|
|
ok(cut.text.endsWith('…'), 'the long-word truncation is not marked')
|
|
const body = cut.text.replace(/…$/, '').trim()
|
|
const tokens = body.split(/\s+/)
|
|
const lastToken = tokens[tokens.length - 1]
|
|
ok(vocab.includes(lastToken),
|
|
`the truncation cut MID-WORD: the final token is ${JSON.stringify(lastToken)}, which is not a whole word from the source — a reader cannot tell this from a misspelling`)
|
|
}
|
|
|
|
// ── 4. THE THREE FALLBACK STATES ───────────────────────────────────────────────────────────────
|
|
// `describe` is INJECTED, so it may be absent entirely; and it returns '' for a group whose fields
|
|
// cannot form a sentence. Both must degrade to the metadata line, never to a blank cell. Without
|
|
// the empty-string branch the table would have shipped blank cells the day describeGroup returned
|
|
// '' for a group — which is a real state, not a hypothetical.
|
|
{
|
|
const has = (h, c) => new RegExp(`class="${c}"`).test(h)
|
|
const sentence = mk(describe).listPages({ id: 'z' }, [G(0)], new Map()).join('')
|
|
ok(has(sentence, 'd-tx'), 'a describable group did not render its sentence')
|
|
ok(!has(sentence, 'd-fb'), 'a describable group ALSO rendered the metadata fallback — the normal render must not carry a metadata line, or the country prints twice')
|
|
const empty = mk(() => '').listPages({ id: 'z' }, [G(0)], new Map()).join('')
|
|
ok(has(empty, 'd-fb') && !has(empty, 'd-tx'), 'an empty description did not fall back to the metadata line')
|
|
const absent = createPacketRenderer({ strings: S, lang: 'en' }).listPages({ id: 'z' }, [G(0)], new Map()).join('')
|
|
ok(has(absent, 'd-fb'), 'a MISSING describer did not fall back — the packet must not fail to print because one column has no describer')
|
|
}
|
|
|
|
// ── 5. EVERY SHIPPED LOCALE FITS, MEASURED AGAINST THE REAL CATALOGUE ──────────────────────────
|
|
// ★ This is the arm that would have caught a locale I cannot eyeball. The ladder is INSURANCE, not
|
|
// routine: if a real locale is routinely stepping down, the budget is wrong and the fix is the
|
|
// budget, not the ladder. Longest today is German at 178 against a 234-char budget.
|
|
{
|
|
const base = { name: 'Abai Sungai', country_code: 'MYS', primary_language_label: 'Abai Sungai', imb_gsec: 1 }
|
|
for (const lc of Object.keys(S).filter((k) => k !== '_meta')) {
|
|
let worst = 0
|
|
for (const gsec of [0, 1, 2, 3, 4, 5, 6]) {
|
|
const s = describeGroup({ ...base, imb_gsec: gsec }, lc, { strings: S, countryName: CN })
|
|
if (s.length > worst) worst = s.length
|
|
}
|
|
ok(worst <= budget(17),
|
|
`locale '${lc}' has a ${worst}-char description against a ${budget(17)}-char budget at 17px — it will step down on EVERY row, which means the budget is wrong, not the text`)
|
|
}
|
|
}
|
|
|
|
if (fails.length) {
|
|
console.error('PROFILE-TABLE: FAIL')
|
|
for (const f of fails) console.error(` ✗ ${f}`)
|
|
process.exit(1)
|
|
}
|
|
console.log('PROFILE-TABLE: PASS — the row is tall enough for a name plus two description lines at the smallest ladder size, every group gets a row across page boundaries, the fit ladder steps 17→15→14 and cuts at a whole word only as a last resort, a missing or empty describer degrades to the metadata line, and every shipped locale fits at full size. NOT covered here: the RENDERED row height and whether the description actually wraps to two lines — those need a browser and are rega\'s seat.')
|