doxa-maps/internal/check-heartbeat-threshold.mjs
Doxa Maps 0b9b068b44 Gates: pin-dim-slider runs again, heartbeat follows the translated panel, and the new engine checks are registered
- check:pin-dim-slider: its description sat in "scripts" under the same key as
  its command, so the prose won and bash failed on the apostrophe. It moves to
  "//scripts".
- check💓 the panel's bin labels now call T(key, en), defined above the
  slice the gate evaluates, so the gate threw "T is not defined". It now passes
  the same shape, answering in English.
- check:map-props-unit (Node, 26 cases) and check:auto-engine (browser) had no
  script, so check:no-orphan-gates failed. Both are registered; map-props-unit
  joins gates:post. auto-engine stays a browser run, outside gates:post.
2026-09-26 03:35:18 +00:00

379 lines
21 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* check-heartbeat-threshold.mjs — THE HEARTBEAT THRESHOLD MUST COME FROM CONFIG, NOT A LITERAL.
*
* WHAT A HEARTBEAT ZONE IS. The top bin of the research-dashboard choropleth: the countries
* holding the most people groups. Driver: "the heartbeat zones are the most people groups in
* them... that's going to be 150 plus people groups in those heartbeat zones."
*
* ★ THE DEFECT THIS CATCHES, AND WHY NOTHING ELSE COULD. The ramp's edges were a 5-stop array
* literal — `upTo: 5, 20, 60, 150, Infinity` — sitting in DashboardMapPanel.vue. Those numbers are
* not a property of choropleths; they are a property of THIS DATASET (~2000 people groups). Point
* the dashboard at a 200-group dataset and every country falls in the bottom bin: the map renders
* uniformly pale and says nothing. Driver: "that filter of 150 ... should be a variable. It
* shouldn't be a static ... I need to be able to reuse this dashboard for other maps, other data
* sets." A dataset assumption wearing the costume of a constant.
*
* That failure is INVISIBLE to every other gate in this repo. The code is correct, it parses, it
* builds, it renders, it throws nothing, and the screenshot looks like a map. Only the MEANING is
* wrong. So the regression this guards is not a crash — it is someone "simplifying" the resolver
* back into a literal because the literal is shorter, and nothing complaining.
*
* ★ WHY THIS GATE ASSERTS ON BEHAVIOUR AND NOT ON A GREP. The obvious gate is "fail if 150 appears
* in DashboardMapPanel.vue". THAT GATE WOULD BE USELESS IN BOTH DIRECTIONS. It false-POSITIVES:
* 150 legitimately appears as the DEFAULT in HEARTBEAT_DEFAULTS, which is exactly what preserves
* today's rendering. And it false-NEGATIVES: a re-hard-coding that used 200, or that hard-coded
* only the three lower edges, would sail straight through. Substring presence is not the property.
*
* The property is TWO-SIDED, and getting only one side is how this gate was wrong once already:
* 1. DOES CHANGING THE CONFIG CHANGE THE MAP? (configurability — assertion B)
* 2. DOES THE SHIPPED CONFIG STILL RENDER TODAY'S MAP? (no regression — assertion A2)
* So this gate EXECUTES the resolver extracted from the live SFC, against BOTH synthetic configs
* and the REAL dashboard.config.js. A literal fails (1); an edited shipped default fails (2).
*
* ASSERTIONS
* A. THE PANEL'S FALLBACK IS INTACT — with NO config at all, the resolver yields EXACTLY the
* historical ramp (edges 5/20/60/150/Infinity, labels included).
* A2. THE SHIPPED CONFIGURATION RENDERS THAT SAME RAMP — resolved the way the RUNNING APP does,
* with dashboard.config.js's map.* layered on top of the panel defaults. ★ A2 IS THE
* ASSERTION THAT PROTECTS "TODAY'S MAP MUST NOT CHANGE", AND IT WAS MISSING AT FIRST: A
* alone proves only the FALLBACK, which is strictly weaker, and F below proves only that the
* key is DECLARED, not what it is SET TO. Measured consequence — editing the shipped config
* to heartbeatThreshold:999 changed the real map while this gate printed "default ramp
* unchanged" and exited 0. Changing the shipped ramp is now a deliberate act that must also
* update HISTORICAL below, in the same commit.
* B. THE THRESHOLD IS LIVE — setting heartbeatThreshold to 40 and to 500 must MOVE the top edge
* to exactly that number and must RE-BIN the fixture's countries. This is the assertion a
* re-hard-coded literal fails.
* C. THE LEGEND FOLLOWS THE EDGES — the top label must CONTAIN "<threshold>+" and name the
* heartbeat zone. A moved edge with a stale '150+' legend is confidently wrong, which is
* worse than no legend. Matched by property, not equality — see the note on HISTORICAL.
* D. NO DEAD BINS — every bin must be reachable (`colorFor` tests `count < upTo`, so an edge of
* 1 creates a band no count can enter: the ramp paints 4 of its 5 colours while the legend
* advertises 5). Found live during this card at threshold 40, and again in quantile mode.
* E. EDGES ARE STRICTLY ASCENDING — a non-monotonic ramp lets an earlier bin shadow a later one.
* F. THE CONFIG DECLARES IT — dashboard.config.js must carry map.heartbeatThreshold. PRESENCE
* ONLY; the VALUE is A2's job. These are separate on purpose, because conflating them is
* exactly how the 999 regression shipped past this gate.
*
* ── DATA ────────────────────────────────────────────────────────────────────────────────────
* internal/fixtures/facet-one-field-rows.json — 114 REAL rows (59 countries). Not synthetic: this
* repo deleted a synthetic fallback precisely because it produced confident wrong answers.
*
* ── NEGATIVE TESTS, AND EXACTLY WHAT THEY COVER ─────────────────────────────────────────────
* `bun run check:heartbeat:self-test` runs THREE cases, each requiring the assertion that exists
* for it to fire. Stated precisely so the next reader does not over-trust this gate:
*
* [literal] the resolver is replaced by the old hard-coded array → must fire B
* [shipped-default] dashboard.config.js's heartbeatThreshold is changed → must fire A2
* [shipped-mode] dashboard.config.js flips heartbeatMode to 'quantile' → must fire A2
*
* ★ THE FIRST VERSION OF THIS GATE SHIPPED WITH [literal] ONLY, and its header said it had been
* "negative-tested" — true of that case, misleading overall. Testing that THE FEATURE WORKS (a
* configured threshold moves the edge) is NOT testing that THE REGRESSION IS CAUGHT (the default
* silently changing), and only the second protects the Driver. One negative test per regression
* claimed. A gate that cannot fail is worse than no gate — this repo has shipped one.
*
* NOT COVERED, deliberately: this gate reasons about BIN ASSIGNMENT and LABELS only. It says
* nothing about the colours themselves (contrast against the pin is argued and measured in
* DashboardMapPanel.vue) and nothing about whether the map actually PAINTS (see verify:map-paint).
*/
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const PANEL = resolve(ROOT, 'plugin-designs/research-dashboard/src/components/dashboards/DashboardMapPanel.vue')
const CONFIG = resolve(ROOT, 'plugin-designs/research-dashboard/dashboard.config.js')
const FIXTURE = resolve(ROOT, 'internal/fixtures/facet-one-field-rows.json')
const SELF_TEST = process.argv.includes('--self-test')
// The historical ramp, recorded here as the frozen expectation for assertion A.
//
// ★ THE TOP LABEL IS MATCHED BY PROPERTY, NOT BY EQUALITY — a correction this gate earned on its
// FIRST RUN. It originally demanded the top label equal exactly "150+", and immediately failed
// CORRECT code: the label reads "150+ · heartbeat zone", because naming the band in the Driver's
// own words is a requirement of this work, not a defect. An over-strict gate that fires on correct
// code gets switched off, and then the real regression walks through. So the assertion is the
// property that actually matters: the top label must CARRY THE CURRENT THRESHOLD (so a moved edge
// can never leave a stale number on the legend) and must NAME THE ZONE. Any prose around those is
// free to change without this gate objecting.
const HISTORICAL = [
{ upTo: 5, l: '1–4' }, { upTo: 20, l: '5–19' }, { upTo: 60, l: '20–59' },
{ upTo: 150, l: '60–149' }, { upTo: Infinity, l: null },
]
// The top label must contain "<threshold>+" and must say "heartbeat".
function topLabelOk(label, threshold) {
return typeof label === 'string' && label.includes(`${threshold}+`) && /heartbeat/i.test(label)
}
// ── extract the resolver from the live SFC ──────────────────────────────────────────────────
// Sliced out of the real file rather than re-implemented: a re-implementation would keep passing
// after the shipped code regressed, which is the classic way a gate goes blind.
function resolverSource() {
const src = readFileSync(PANEL, 'utf8')
const a = src.indexOf('const BIN_COLORS')
const b = src.indexOf('const BINS = computed')
if (a < 0 || b < 0 || b <= a) {
fail('Could not locate the heartbeat resolver in DashboardMapPanel.vue.\n' +
' Expected a `const BIN_COLORS` ... `const BINS = computed` region.\n' +
' If the ramp was moved, update this gate. If it was re-hard-coded into a literal, THAT IS THE BUG.')
}
return src.slice(a, b)
}
function makeResolver(mapCfg, srcOverride) {
const body = srcOverride !== undefined ? srcOverride : resolverSource()
// The slice calls the panel's `T(key, en)` (DashboardMapPanel.vue: `const T = (key, en) => tStatic(key)
// || en`), which lives above the slice. Hand it the same shape answering in English, so the "names
// the zone" assertion still reads the Driver's words.
const fn = new Function('cfg', 'T', body + '\nreturn resolveBins;')
return fn({ map: mapCfg }, (key, en) => en)
}
// ── the SHIPPED map config ──────────────────────────────────────────────────────────────────
// Read as SOURCE TEXT, not `import()`. dashboard.config.js imports '@map/…' aliases and a JSON
// module, so importing it from a bare node gate would need Vite's resolver — a dependency that
// would make this gate fragile for no benefit. The `map:` literal is a self-contained bag of
// scalars, so brace-matching and evaluating just that block reads the real shipped values with
// nothing else to resolve.
// Set by the self-test to simulate an edited dashboard.config.js WITHOUT touching the file — a
// self-test that mutated the repo could leave the tree poisoned if it crashed mid-run.
let SHIPPED_CONFIG_OVERRIDE = null
function shippedMapConfig() {
if (SHIPPED_CONFIG_OVERRIDE) return SHIPPED_CONFIG_OVERRIDE
const src = readFileSync(CONFIG, 'utf8')
const at = src.search(/^\s{2}map:\s*\{/m)
if (at < 0) {
fail('A2. Could not find the top-level `map: {` block in dashboard.config.js.\n' +
' This gate must read the SHIPPED map config; if the block moved, update this gate.')
return null
}
let depth = 0
const start = src.indexOf('{', at)
let i = start
for (; i < src.length; i++) {
if (src[i] === '{') depth++
else if (src[i] === '}') { depth--; if (!depth) { i++; break } }
}
try {
// eslint-disable-next-line no-eval
return eval('(' + src.slice(start, i) + ')')
} catch (e) {
fail(`A2. The \`map\` block in dashboard.config.js did not evaluate: ${e.message}`)
return null
}
}
// ── the fixture, binned ─────────────────────────────────────────────────────────────────────
function countryCounts() {
const rows = JSON.parse(readFileSync(FIXTURE, 'utf8'))
const m = new Map()
for (const r of rows) {
const c = r.country_code
const iso = c && c.value ? c.value : null
if (iso) m.set(iso, (m.get(iso) || 0) + 1)
}
return m
}
function binIndex(count, bins) {
if (!count) return -1
for (let i = 0; i < bins.length; i++) if (count < bins[i].upTo) return i
return bins.length - 1
}
function assignment(bins, counts) {
return [...counts.entries()].sort().map(([iso, n]) => `${iso}:${n}:${binIndex(n, bins)}`).join(',')
}
const errors = []
function fail(msg) { errors.push(msg) }
function run(srcOverride) {
errors.length = 0
const counts = countryCounts()
const vals = [...counts.values()]
const resolve0 = (cfg) => makeResolver(cfg, srcOverride)(vals)
// Compare a resolved ramp against the historical one. Shared by A and A2 so the two can never
// drift apart — the whole defect A2 fixes was one path being asserted and the other not.
const expectHistorical = (bins, tag, why) => {
HISTORICAL.forEach((exp, i) => {
const got = bins[i]
if (!got || got.upTo !== exp.upTo) {
fail(`${tag}. RAMP MOVED at bin ${i}: expected upTo=${exp.upTo}, got ${got ? got.upTo : '(missing)'}.\n ${why}`)
} else if (exp.l === null) {
if (!topLabelOk(got.l, 150)) {
fail(`${tag}. TOP LABEL is "${got.l}" — it must contain "150+" and name the heartbeat zone.\n ${why}`)
}
} else if (got.l !== exp.l) {
fail(`${tag}. LEGEND LABEL MOVED at bin ${i}: expected "${exp.l}", got "${got.l}".\n ${why}`)
}
})
if (bins.length !== HISTORICAL.length) fail(`${tag}. RAMP has ${bins.length} bins, expected ${HISTORICAL.length}.\n ${why}`)
}
// A. THE PANEL'S FALLBACK IS INTACT — with NO config supplied at all.
const def = resolve0({})
expectHistorical(def, 'A', 'With no config supplied the panel must fall back to EXACTLY the historical ramp.')
// ── A2. THE SHIPPED CONFIGURATION RENDERS THE HISTORICAL RAMP ───────────────────────────────
// ★ THIS IS THE ASSERTION THAT PROTECTS THE DRIVER, AND IT WAS MISSING. A alone proves the
// PANEL'S FALLBACK is intact — a strictly weaker property than "the map still looks the same",
// because the running app resolves with dashboard.config.js's map.* layered ON TOP of that
// fallback. Assertion F only checked that heartbeatThreshold was DECLARED, never what it was
// SET TO. So editing the shipped config to 999 changed the real map while this gate printed
// "default ramp unchanged" and exited 0. Measured, reproduced, and the reason A2 exists.
//
// A2 resolves the ramp THE WAY THE RUNNING APP DOES and requires it to equal the historical one.
// Changing the shipped default is now a deliberate act that must also update HISTORICAL here.
if (srcOverride === undefined) {
const shipped = shippedMapConfig()
if (shipped) {
expectHistorical(
makeResolver(shipped, srcOverride)(vals), 'A2',
'This is the ramp dashboard.config.js ACTUALLY SHIPS (map.heartbeatThreshold=' +
`${shipped.heartbeatThreshold}, binThresholds=${JSON.stringify(shipped.binThresholds)}, ` +
`mode=${shipped.heartbeatMode}). Today\'s map must not change. If this move is intended, ` +
'update HISTORICAL in this gate in the same commit.',
)
if (shipped.heartbeatMode && shipped.heartbeatMode !== 'fixed') {
fail(`A2. dashboard.config.js ships heartbeatMode='${shipped.heartbeatMode}'.\n` +
" 'fixed' is the shipped default on purpose: a data-derived ramp changes per dataset,\n" +
' which is right for an unknown dataset and wrong for this one, where 150 is deliberate.')
}
}
}
const baseline = assignment(def, counts)
// B/C/D/E. THE THRESHOLD IS LIVE
for (const t of [40, 500]) {
const bins = resolve0({ heartbeatThreshold: t })
const top = bins[bins.length - 1]
const edge = bins[bins.length - 2]
if (!edge || edge.upTo !== t) {
fail(`B. heartbeatThreshold=${t} DID NOT MOVE THE HEARTBEAT EDGE (got ${edge ? edge.upTo : '(missing)'}).\n` +
' The thresholds are not coming from config — this is the re-hard-coded-literal regression.')
}
if (assignment(bins, counts) === baseline) {
fail(`B. heartbeatThreshold=${t} produced the SAME bin assignment as the default over ${counts.size} real countries.\n` +
' The config knob is inert.')
}
if (!topLabelOk(top.l, t)) {
fail(`C. LEGEND DID NOT FOLLOW THE EDGE at threshold ${t}: top label reads "${top.l}".\n` +
` It must contain "${t}+" and name the heartbeat zone. A moved edge with a stale number on the\n` +
' legend is confidently wrong, which is worse than no legend.')
}
bins.forEach((b, i) => {
const lo = i === 0 ? 1 : bins[i - 1].upTo
if (Number.isFinite(b.upTo) && b.upTo <= lo) {
fail(`D. DEAD BIN at index ${i} (threshold ${t}): range ${lo}..<${b.upTo} can never be entered.\n` +
' colorFor tests `count < upTo`, so this colour stop never paints while the legend still shows it.')
}
if (i > 0 && b.upTo <= bins[i - 1].upTo) {
fail(`E. NON-MONOTONIC EDGES at index ${i} (threshold ${t}): ${bins[i - 1].upTo} then ${b.upTo}.`)
}
})
}
// F. THE CONFIG DECLARES IT.
// NOTE this is a PRESENCE check only, and presence is NOT value — A2 is what asserts the value.
// Keeping them separate (and saying so) is deliberate: F alone once let heartbeatThreshold:999
// ship while this gate printed "default ramp unchanged".
const cfgSrc = readFileSync(CONFIG, 'utf8')
if (!/heartbeatThreshold\s*:/.test(cfgSrc)) {
fail('F. dashboard.config.js does not declare map.heartbeatThreshold.\n' +
' The knob must have a documented home in the dashboard config, not only a default in the panel.')
}
return errors.slice()
}
// ── NEGATIVE TESTS ──────────────────────────────────────────────────────────────────────────
// ★ TWO CASES, BECAUSE THERE ARE TWO DISTINCT REGRESSIONS AND THEY ARE NOT THE SAME ASSERTION.
// This gate originally shipped with case 1 only, and its header claimed it was "negative-tested".
// That was true of case 1 and MISLEADING overall: case 2 — the one that actually protects the
// Driver's "today's map must not change" requirement — was unasserted, and a one-line edit to
// dashboard.config.js changed the shipped ramp while the gate printed PASS. Verified by
// reproducing it, not by reasoning about it.
//
// The general shape, worth more than either case: testing that THE FEATURE WORKS (a configured
// threshold moves the edge) is not testing that THE REGRESSION IS CAUGHT (the default silently
// changing). A gate needs a negative test per regression it claims to prevent.
const CASES = [
{
id: 'literal',
what: 'the resolver is replaced by a re-hard-coded literal ramp (configurability removed)',
mustFire: 'B',
apply: () => ({
srcOverride: `
const BIN_COLORS = ['#f8e0da', '#fccec4', '#febbad', '#ffa797', '#ff9380']
function resolveBins() {
return [
{ upTo: 5, c: BIN_COLORS[0], l: '1–4' }, { upTo: 20, c: BIN_COLORS[1], l: '5–19' },
{ upTo: 60, c: BIN_COLORS[2], l: '20–59' }, { upTo: 150, c: BIN_COLORS[3], l: '60–149' },
{ upTo: Infinity, c: BIN_COLORS[4], l: '150+' },
]
}
`,
}),
},
{
id: 'shipped-default',
what: "dashboard.config.js's shipped map.heartbeatThreshold is changed (today's map moves)",
mustFire: 'A2',
// Simulates `sed -i 's/heartbeatThreshold: 150,/heartbeatThreshold: 999,/'` WITHOUT editing
// the file, so a crash mid-self-test cannot leave the repo poisoned.
apply: () => {
SHIPPED_CONFIG_OVERRIDE = { mapStyle: 'paper', heartbeatThreshold: 999, binThresholds: [5, 20, 60], heartbeatMode: 'fixed', heartbeatQuantile: 0.1 }
return {}
},
},
{
id: 'shipped-mode',
what: "dashboard.config.js flips heartbeatMode to 'quantile' (ramp becomes data-derived)",
mustFire: 'A2',
apply: () => {
SHIPPED_CONFIG_OVERRIDE = { mapStyle: 'paper', heartbeatThreshold: 150, binThresholds: [5, 20, 60], heartbeatMode: 'quantile', heartbeatQuantile: 0.1 }
return {}
},
},
]
if (SELF_TEST) {
let bad = 0
for (const c of CASES) {
SHIPPED_CONFIG_OVERRIDE = null
const { srcOverride } = c.apply()
const got = run(srcOverride)
SHIPPED_CONFIG_OVERRIDE = null
if (!got.length) {
console.error(`SELF-TEST FAILED [${c.id}]: the gate ACCEPTED ${c.what}.\n` +
' A gate that cannot fail is worse than no gate.')
bad++
continue
}
if (!got.some((e) => e.startsWith(c.mustFire + '.'))) {
console.error(`SELF-TEST FAILED [${c.id}]: rejected, but NOT via assertion ${c.mustFire}, which is the one\n` +
` that exists for this regression. Rejections seen:\n ` + got.map((e) => e.split('\n')[0]).join('\n '))
bad++
continue
}
console.log(` ✓ [${c.id}] REJECTED via ${c.mustFire} — ${c.what}`)
got.forEach((e) => console.log(' · ' + e.split('\n')[0]))
}
if (bad) { console.error(`\ncheck:heartbeat SELF-TEST FAILED — ${bad} of ${CASES.length} case(s) not caught.`); process.exit(1) }
console.log(`\ncheck:heartbeat SELF-TEST PASS — all ${CASES.length} regression case(s) rejected by their own assertion.`)
process.exit(0)
}
const found = run()
if (found.length) {
console.error('check:heartbeat FAIL — the heartbeat threshold is not behaving as a variable.\n')
found.forEach((e) => console.error(' ✗ ' + e + '\n'))
process.exit(1)
}
console.log('check:heartbeat PASS — the SHIPPED dashboard.config.js resolves to the historical ramp ' +
'(5/20/60/150/∞ over 59 real countries) and so does the panel fallback; heartbeatThreshold 40 and 500 ' +
'both move the edge, relabel the legend, and re-bin the fixture; no dead or non-monotonic bins.')