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.
243 lines
18 KiB
JavaScript
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)
|