doxa-maps/internal/check-packet-charts.mjs
Doxa Maps 5b51153b82 A life-calendar anchor outside the grid says so instead of drawing nothing
By the regions owner. An anchor week past the grid, negative or fractional passed an isFinite
guard and matched no cell, which on paper reads the same as the deliberate no-anchor default.
anchorPlacement() returns a reason, LifeCalendar prints it, and check:packet-charts covers six
edge cases while pinning that no anchor asked for stays silent.
2026-09-28 16:49:01 +00:00

243 lines
18 KiB
JavaScript

#!/usr/bin/env node
/**
* check-packet-charts — the arithmetic printed on a UPG-100 packet page. Headless, no mount.
*
* ★ WHY THIS ONE MATTERS MORE THAN A SCREEN CHART. These figures print. A packet is handed to a
* reader on paper: no hover, no tooltip, no correction, no redeploy. Every number the triangle and
* the two calendars show passes through packetCharts.js, so it is asserted here against the SHIPPED
* data rather than a fixture — the gap arms in check-list-selection taught that a fixture proves the
* module behaves and says nothing about what we print.
*/
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
import {
anchorPlacement, assertNested, assertOnePublisher, bracketSpan, fractionFromPercent, overlayPlacement, scatterCells, scatterSpread, overlayCaption, cellCount, shareCells, peoplePerCell, shareOfLists,
} from '../library/components/charts/packetCharts.js'
const DATA = join(dirname(fileURLToPath(import.meta.url)), '..', 'data', 'people-groups')
const readJson = (f) => JSON.parse(readFileSync(join(DATA, f), 'utf8'))
let fails = 0
const ok = (c, m) => { console.log(` ${c ? 'ok ' : 'FAIL'} - ${m}`); if (!c) fails++ }
const funnel = readJson('vision-funnel.json')
const byId = Object.fromEntries(funnel.steps.map((s) => [s.id, s]))
const STEPS = ['all', 'unreached', 'frontier'].map((id) => ({ id, ...byId[id] }))
const DOXA = { id: 'doxa', ...byId.doxa, within: { unreached: byId.doxa.unreached, frontier: byId.doxa.frontier } }
// ── the steps nest, and a mixed publisher is refused ────────────────────────────────────────────
ok(assertNested(STEPS) === STEPS, 'the shipped steps nest: all 12,379 > unreached 7,341 > frontier 4,754')
let threw = null
try { assertNested([{ id: 'frontier', groups: 4754 }, { id: 'unreached', groups: 7341 }]) } catch (e) { threw = e.message }
ok(!!threw && /do not nest/.test(threw) && /4754|7341/.test(threw),
'a step LARGER than the one above it throws, naming both counts')
// ★ THE PUBLISHER HAZARD NEEDS ITS OWN DETECTOR, and writing this test is what proved it.
// I first put the Joshua-Project warning in assertNested's message. Then the assertion failed:
// 16,426 stacked on 12,379 is perfectly monotonic, so nesting arithmetic accepts it. The two are
// not the same KIND of count — peoples-by-country vs peoples — and no comparison of magnitudes can
// see that. An error message naming a cause its own code cannot detect is worse than none.
let mixed = null
try {
assertOnePublisher([{ id: 'all', groups: 16426, source: 'joshua-project' }, { id: 'unreached', groups: 7341, source: 'imb' }])
} catch (e) { mixed = e.message }
ok(!!mixed && /mix publishers/.test(mixed) && /joshua-project/.test(mixed) && /imb/.test(mixed),
'mixing Joshua Project and IMB steps throws and NAMES both, which nesting arithmetic cannot catch')
ok(assertNested([{ id: 'all', groups: 16426 }, ...STEPS]).length === 4,
'CONTROL: the same mixed pair passes NESTING — proving the two checks measure different things')
// ── DOXA IS NOT A SLICE, and this is the assertion upg100 asked to be un-simplifiable ───────────
// Measured from the shipped file, not quoted: of 2,107 DOXA groups, 2,082 are unreached and 1,745
// are frontier. So 25 are not unreached at all and 362 — 17.2%, ONE GROUP IN SIX — are not frontier.
// A chart drawing DOXA as a fourth nested band inside frontier misstates one group in six, with a
// clean shape and no error of any kind. That is the whole reason overlays exist; if anyone later
// "simplifies" this back to four slices, these three assertions are what stops them.
const place = overlayPlacement(DOXA, STEPS)
ok(place.isSlice === false, 'DOXA is NOT a slice of any single step, so it must never be drawn as one')
ok(DOXA.groups - DOXA.within.frontier === 362 && Math.round(1000 * (DOXA.groups - DOXA.within.frontier) / DOXA.groups) / 10 === 17.2,
'362 of its 2,107 groups — 17.2%, one in six — are NOT frontier, which is what a nested chart would misstate')
ok(DOXA.groups - DOXA.within.unreached === 25, 'and 25 are not unreached at all, so it is not a slice of unreached either')
ok(place.spans.join(',') === 'unreached,frontier', 'it spans unreached AND frontier, in step order, so a legend can say so')
// ── the caption carries the parts as TEXT (upg100: a printed page has no hover) ─────────────────
const cap = overlayCaption(DOXA, STEPS)
ok(cap.total === 2107 && cap.parts.length === 2, 'the caption exposes the within numbers, not only the geometry')
ok(cap.parts.map((p) => `${p.stepId}:${p.groups}`).join(' ') === 'unreached:2082 frontier:1745',
'as inkable parts in step order — an unexplained band on paper tells a reader nothing')
ok(cap.isSlice === false, 'and the caption says it is not a slice, so the component need not decide that itself')
// ── an overlay that cannot be placed must not be DRAWN (found by reading the template) ──────────
// ★ THIS SHIPPED IN L61 AND I CAUGHT IT BY READING, NOT BY A TEST. The component computed its
// bracket with Math.min(...idx) / Math.max(...idx) over the steps an overlay touches. On an EMPTY
// list those return Infinity and -Infinity, so the bracket rendered at y=Infinity with height
// -Infinity — an invalid rect that SVG discards without a word. The overlay vanished and took its
// groups with it: no error, no gap, just a chart missing a band nobody knew to look for.
// The input is easy to get wrong because `within` is the unusual half of the API — an overlay
// written as { id, label, groups } looks complete and is unplaceable.
ok(bracketSpan(DOXA, STEPS) !== null, 'a well-formed overlay places')
ok(bracketSpan({ id: 'list', groups: 100 }, STEPS) === null,
'an overlay with NO within returns null rather than a bracket at Infinity')
ok(bracketSpan({ id: 'list', groups: 100, within: { typo: 50 } }, STEPS) === null,
'and so does one whose within names a step that does not exist — a typo cannot silently delete a band')
ok(bracketSpan({ id: 'list', groups: 100, within: {} }, STEPS) === null, 'an empty within too')
// NEG CONTROL: the old expression really did produce the broken geometry, so this is not theory.
const emptyIdx = []
ok(Math.min(...emptyIdx) === Infinity && Math.max(...emptyIdx) === -Infinity,
'NEG CONTROL: Math.min/max of an empty span ARE Infinity/-Infinity, which is what drew the invisible rect')
// ── the calendars ───────────────────────────────────────────────────────────────────────────────
ok(cellCount(100, 52) === 5200, 'a 100-year life in weeks is 5,200 cells')
const share = readJson('christian-share.json')
const pct = Number(share.pctEvangelical)
ok(Number.isFinite(pct) && pct > 0, `the shipped evangelical share is a usable number (${pct})`)
// pctEvangelical is a PERCENT (0.6133 means 0.6133%), so the conversion happens HERE, at the only
// place that can see which the source meant. See the refusal in shareCells.
const cells = shareCells(100, 52, fractionFromPercent(pct))
ok(cells.filled === 32 && Math.abs(cells.exact - 31.9) < 0.15,
`${cells.filled} cells of 5,200 are filled, from an exact ${cells.exact.toFixed(2)} — the drawn integer is a ROUNDING`)
ok(cells.exact !== cells.filled,
'exact and filled differ, which is why the caption must print the unrounded value: 32 reads as precise when it is not')
const tiny = shareCells(100, 52, 0.00015) // 0.78 of a cell
ok(tiny.exact < 1 && tiny.filled === 1 && tiny.roundedUpFromBelowOne === true,
'a share too small to fill one cell is FLAGGED — drawing it as one cell says "at least one" where the data said "almost none"')
const vanishing = shareCells(100, 52, 0.00001) // 0.05 of a cell
ok(vanishing.filled === 0 && vanishing.roundedToZero === true,
'and a share that rounds away to nothing is flagged too, so "no cells" is never silently read as "none"')
// ★ THE PERCENT/FRACTION CONFUSION, AND THE HONEST LIMIT OF GUARDING IT. I wrote this bug: a
// `share > 1 ? share/100 : share` heuristic filled 3,189 cells of 5,200 instead of 32, a
// hundredfold error whose only symptom is a darker chart. The >1 refusal catches a percent of 1 or
// more — and CANNOT catch ours, because 0.6133 is a valid fraction AND a valid percent. Every share
// this packet prints is below 1%, since the subject is unreached peoples, so the guard is provably
// useless for the real case. It is the NAME that carries it. Both halves asserted, including the
// half that does not work, because a guard believed to cover more than it does is the worse failure.
let pctErr = null
try { shareCells(100, 52, 61.33) } catch (e) { pctErr = e.message }
ok(!!pctErr && /FRACTION/.test(pctErr), 'a percent of 1 or more is refused outright')
ok(shareCells(100, 52, pct).filled === 3189,
'but OUR percent passes the guard silently and fills 3,189 cells — the guard cannot see this case, so the call site must name the unit')
ok(Math.abs(fractionFromPercent(pct) - 0.006133) < 1e-12 && shareCells(100, 52, fractionFromPercent(pct)).filled === 32,
'fractionFromPercent states the unit in the source line, which is the only thing that actually prevents it')
const doxaPop = byId.doxa.population
const per = peoplePerCell(doxaPop, 100, 52)
ok(Math.round(per / 100) * 100 === 40200,
`each cell stands for about ${Math.round(per).toLocaleString('en')} people — the figure the brief asks to be stated`)
// ── the shading is a SCATTER, which no count-based gate can see ────────────────────────────────
// ★ THIS IS THE ASSERTION THAT WAS MISSING WHEN THE BUG SHIPPED. packet-render's own comment records
// it: 32 contiguous cells fill most of row 1 of 52, so the page printed as one solid bar across the
// header — the ordinary chart the life grid exists to avoid — and the existing gate asserts only
// that the shaded COUNT is between 0 and 5,200, which is equally true of a bar and of a scatter.
// A count cannot distinguish a distribution. rega's eyes caught it; nothing else could have.
const picked = scatterCells(5200, 32, 'upg-100')
const spread = scatterSpread(picked, 52)
ok(spread.cells === 32, '32 shaded cells are picked, distinct')
ok(spread.rows >= 20, `they land on ${spread.rows} different rows of 100 — a SCATTER, so the eye has to hunt, which is the message`)
// NEG CONTROL: the arm must be able to fail, and the bar is the exact shape it exists to reject.
const bar = new Set(Array.from({ length: 32 }, (_, i) => i))
ok(scatterSpread(bar, 52).rows === 1,
'NEG CONTROL: the contiguous block that actually shipped touches ONE row, so this arm can fail')
// Deterministic, because two copies of one packet must print identically, and different BETWEEN
// zones so a print run does not carry the same constellation on every sheet.
const again = scatterCells(5200, 32, 'upg-100')
ok([...picked].sort((a, b) => a - b).join() === [...again].sort((a, b) => a - b).join(),
'the same seed gives the same cells — a packet reprints identically')
ok([...scatterCells(5200, 32, 'europe')].sort((a, b) => a - b).join() !== [...picked].sort((a, b) => a - b).join(),
'and a different zone gives a different constellation')
// ★ GOLDEN FINGERPRINT, pinning the EXTRACTION. scatterCells was lifted verbatim out of
// packet-render.js's lifeGrid so the printed packet and the reusable chart cannot drift. I proved
// the move behaviour-preserving against the pre-refactor code over 49 seed/count combinations, but
// that probe was one-off and is gone; this is the durable half. If the algorithm is ever "tidied",
// these exact cells change and every already-printed packet stops being reproducible.
const sorted = [...picked].sort((a, b) => a - b)
ok(sorted.slice(0, 5).join(',') === '139,140,221,399,418' && sorted[sorted.length - 1] === 5196 && sorted.reduce((a, b) => a + b, 0) === 67368,
'the exact cells match the pre-extraction implementation, so a printed packet stays reproducible')
ok(scatterCells(5200, 99999, 'x').size === 5200 && scatterCells(5200, -5, 'x').size === 0,
'and the count is clamped both ways rather than throwing on a page mid-print')
// ── an anchor that cannot be drawn is NAMED, not dropped (same class as the bracket) ────────────
// ★ FOUND BY SWEEPING FOR THE CLASS, not by waiting for a second report. After the Infinity bracket
// I read the other two templates asking the same question — what does this expression do with an
// edge-case input? LifeCalendar matched its anchor with only an isFinite guard, so week 6000 on a
// 5,200-cell grid, a negative week, or a fractional one matched NO cell and drew nothing. On paper
// that is indistinguishable from having chosen not to mark it, which is the packet's own reason for
// leaving the anchor un-defaulted in the first place — so the silent version quietly tells the
// reader the opposite of what a missing anchor is supposed to mean.
const TOTAL_CELLS = cellCount(100, 52)
ok(anchorPlacement({ week: 0 }, TOTAL_CELLS).index === 0, 'week 0 is drawable — the first cell')
ok(anchorPlacement({ week: 5199 }, TOTAL_CELLS).index === 5199, 'and the last cell is 5,199, not 5,200')
ok(anchorPlacement({ week: 5200 }, TOTAL_CELLS).reason === 'out-of-range',
'week 5,200 is one past the end and says so rather than drawing nothing')
ok(anchorPlacement({ week: -1 }, TOTAL_CELLS).reason === 'out-of-range', 'a negative week is named')
ok(anchorPlacement({ week: 1.5 }, TOTAL_CELLS).reason === 'not-a-whole-week', 'a fractional week is named')
ok(anchorPlacement({ week: 'soon' }, TOTAL_CELLS).reason === 'not-a-number', 'a non-numeric week is named')
// The deliberate case must stay SILENT: no anchor asked for is not an error, it is the default.
ok(anchorPlacement(null, TOTAL_CELLS).reason === null && anchorPlacement({}, TOTAL_CELLS).reason === null,
'and NO anchor at all reports nothing — the packet supplies none on purpose, so that is not a fault')
// ── "1 of K lists", where K is unsettled and this asserts nothing about it ──────────────────────
ok(shareOfLists(1, 27).percent.toFixed(2) === '3.70' && shareOfLists(1, 29).percent.toFixed(2) === '3.45',
'K is a prop: 27 and 29 both compute, and this check takes no side on DECISION-SHEET row 41')
ok(shareOfLists(1, 0) === null && shareOfLists(1, undefined) === null,
'and a missing K returns null rather than printing Infinity on a page nobody can recall')
// ── the two constraints that live in a PROP DECLARATION, parsed rather than grepped ─────────────
// ★ A DEFAULT IS HOW EITHER OF THESE DIES SILENTLY. ShareCalendar refuses to name its own metric
// because the shipped figure is EVANGELICAL and the brief says "% Christian"; one `default:
// '% Christian'` added by a well-meaning edit reintroduces the exact confusion this project already
// retracted a P0 over, and nothing would look wrong. LifeCalendar's anchor is un-defaulted because
// the packet prints for a reader we do not know and shading their life is a guess presented as
// fact. Both are decisions recorded in a prop object, so the prop object is what gets asserted —
// with the parser, since a regex cannot tokenise JavaScript.
const { parse: parseSfc } = await import('@vue/compiler-sfc')
const { parse: parseJs } = await import('@babel/parser')
const CHARTS = join(dirname(fileURLToPath(import.meta.url)), '..', 'library', 'components', 'charts')
function propNode(file, propName) {
const src = readFileSync(join(CHARTS, file), 'utf8')
const sfc = parseSfc(src)
const script = sfc.descriptor.scriptSetup || sfc.descriptor.script
const ast = parseJs(script.content, { sourceType: 'module', plugins: ['jsx'] })
let found = null
const walk = (n) => {
if (!n || typeof n !== 'object') return
if (n.type === 'CallExpression' && n.callee && n.callee.name === 'defineProps') {
const obj = n.arguments && n.arguments[0]
if (obj && obj.type === 'ObjectExpression') {
for (const pr of obj.properties) {
const key = pr.key && (pr.key.name || pr.key.value)
if (key === propName) found = pr.value
}
}
}
for (const k of Object.keys(n)) {
const v = n[k]
if (Array.isArray(v)) v.forEach(walk)
else if (v && typeof v.type === 'string') walk(v)
}
}
walk(ast.program)
return found
}
const keysOf = (node) => (node && node.type === 'ObjectExpression'
? node.properties.map((pr) => pr.key && (pr.key.name || pr.key.value)) : [])
const shareLabel = propNode('ShareCalendar.vue', 'shareLabel')
ok(keysOf(shareLabel).includes('required') && !keysOf(shareLabel).includes('default'),
'ShareCalendar.shareLabel is required and has NO default, so the component cannot name its own metric')
const anchor = keysOf(propNode('LifeCalendar.vue', 'anchor'))
ok(anchor.includes('default'),
'LifeCalendar.anchor declares a default...')
const anchorSrc = readFileSync(join(CHARTS, 'LifeCalendar.vue'), 'utf8')
ok(/anchor:\s*\{[^}]*default:\s*null/.test(anchorSrc),
'...and that default is null — no invented age, because shading a stranger\'s life is a guess presented as fact')
// NEG CONTROL: the parser really does see a default when one is there, so the arm above can fail.
ok(keysOf(propNode('ShareCalendar.vue', 'population')).includes('default'),
'NEG CONTROL: a sibling prop WITH a default is detected, so the shareLabel arm is not vacuous')
console.log(fails ? `\nPACKET-CHARTS: ${fails} FAIL(s)` : '\nPACKET-CHARTS: the printed numbers add up, and DOXA is not drawn as a slice')
process.exit(fails ? 1 : 0)