Gate the rule that a facet selection must change the dots — and that the dots say what they mean

The prayer filter was reported as "not applying" to the map. Measuring it first
changed the fix: the filter was never broken. Selecting "Has Prayer" already
narrowed the rd-bubbles source 2107 -> 689 features, exactly matching the
dropdown's own tally, disjoint from "Needs Prayer" (1418), summing to 2107 and
round-tripping on clear.

What was broken is that circle-color was the flat literal '#ef6c00' — read back
off the live layer, the bare string, not an expression — so every dot painted the
same orange whether it had prayer or not. A prayer question got an answer with no
prayer information in it, and the surviving dots looked identical to the ones the
user started with. That is why a working filter reads as "not applying".

This gate asserts all three halves, because any one alone is a false pass:
  1. the selection changes WHICH dots are on the map, by count AND by identity
  2. every surviving dot satisfies the level that was selected
  3. while a prayer filter is active the colour is a data-driven expression
     reading peoplePraying, not a flat literal

Assertion 3 is the one that matters: 1 and 2 were already green while the bug was
visible. Identity is checked as well as count because the filter removes from the
tail of an alphabetical list, so the first five slugs are byte-identical before
and after — a gate reading the head of the list would report "unchanged".

NEGATIVE-TESTED in four modes, each isolating one assertion, all verified caught:
  colour  reverts only the paint (the reported bug)    -> caught by assertion 3
  props   strips peoplePraying                          -> caught by assertion 2
  frozen  blocks the source update entirely             -> caught by assertion 1
  both    reverts the lot                               -> caught
A gate that cannot fail is worse than no gate.

Verified on both hosts against the BUILT tree: the cap-5 shell and the standalone
leaf. Browser gate, so not in build:unlocked.
This commit is contained in:
doxa-maps-88655c70 2026-09-22 22:42:42 +00:00
parent 1de86ede8f
commit 04c2a3d2eb
2 changed files with 318 additions and 0 deletions

View file

@ -0,0 +1,316 @@
#!/usr/bin/env node
/**
* check-facet-changes-map.mjs — THE GATE FOR BUG-PRAYER-FILTER-NOT-APPLYING-TO-THE-MAP.
*
* ★ WHAT IT ASSERTS, and why each assertion exists rather than the obvious one.
*
* THE OBVIOUS ASSERTION — "selecting a facet changes the map's feature COUNT" — PASSES TODAY AND
* PASSED WHILE THE DRIVER COULD SEE THE BUG. That is measured, not hypothetical: selecting
* "Has Prayer" already narrowed rd-bubbles 2107 -> 689 features, disjoint from "Needs Prayer"
* (1418), summing exactly to 2107 and round-tripping on clear. The filter was never broken.
* What was broken is that every dot was painted the SAME flat orange ('circle-color' read back
* off the live layer as the bare string '#ef6c00', not an expression), so a prayer question got
* an answer with no prayer information in it. The Driver's words are a COLOUR request:
* "when I sort by has prayer it needs to SHOW ME WHICH ONES HAVE PRAYER AND WHICH ONE…
* The dots are not changing based on that filter."
* So this gate asserts BOTH halves, because either one alone is a false pass:
* 1. SET — the selection changes WHICH dots are on the map (count + identity).
* 2. MEANING — the surviving dots all genuinely satisfy the selected level.
* 3. COLOUR — while a prayer selection is active the dot colour is a DATA-DRIVEN EXPRESSION,
* not a flat literal, and it reads the property the features actually carry.
*
* ★ ASSERTION 3 IS THE ONE THAT WOULD HAVE CAUGHT THE REPORTED BUG. Assertions 1 and 2 were
* already green when the Driver reported it.
*
* ★ IDENTITY, NOT JUST COUNT. The first five slugs are BYTE-IDENTICAL before and after, because
* the filter removes from the tail of an alphabetical list. A gate that eyeballed the head of the
* list, or that compared only counts, would report "looks the same" / "looks fine" for the wrong
* reason. We compare SETS of slugs and require a strict, non-empty subset relation.
*
* ★ WHY THE COLOUR CHECK IS CONDITIONAL. Orange (#ef6c00) is a Driver decision with a contrast
* analysis behind it and is correct with NO prayer filter active; the 3-tier prayer ramp is
* correct WITH one. So the gate requires a literal before and an expression after — asserting the
* SWITCH, which is the actual contract, rather than hardcoding either colour as always-right.
*
* ★ SHADOW DOM DOES NOT PIERCE. Every lookup is a recursive walk with a seen-Set, re-injected after
* each interaction (a re-render drops anything defined earlier). The heavy tab LAZY-MOUNTS, so we
* click it and then WAIT FOR THE MAP SOURCE TO BE POPULATED — querying earlier returns 0 features
* and reads as "no bug".
*
* ★ NEGATIVE-TESTED. `--self-test` reverts the fix IN THE LIVE PAGE (repoints circle-color at the
* flat literal and strips peoplePraying from the features) and requires this gate to FAIL. A gate
* nobody has broken is a gate nobody knows works — and this repo has shipped one before.
*
* USAGE
* node internal/check-facet-changes-map.mjs # against BASE_URL (default dev 5173)
* node internal/check-facet-changes-map.mjs --self-test # NEGATIVE test: the gate must FAIL
* BASE_URL=http://127.0.0.1:4173 PROBE_PATH=/doxa-maps/... node internal/check-facet-changes-map.mjs
*/
import { chromium } from 'playwright'
// Defaults to the DEV server, because this gate drives a live interaction and the dev surface is
// the one that always reflects current source. Point BASE_URL/PROBE_PATH at the built tree
// (`bun run serve` on 4173) to run it against a built leaf or the cap-5 shell.
const BASE = process.env.BASE_URL || 'http://127.0.0.1:5173'
const PATH_ = process.env.PROBE_PATH || '/'
const TAB = process.env.PROBE_TAB || 'research-dashboard'
const SELF_TEST = process.argv.includes('--self-test')
// WHICH HALF OF THE FIX TO REVERT in --self-test. Default 'colour' reverts ONLY the paint, leaving
// the feature properties intact — that is the EXACT bug the Driver reported (a correct filter whose
// dots all look the same), and it isolates assertion 3, the one assertions 1-2 cannot catch.
// 'props' strips peoplePraying instead; 'both' does the lot; 'frozen' blocks the source update
// entirely, simulating "the filter never reaches the map" and negative-testing assertion 1.
const SELF_TEST_MODE = (process.env.SELF_TEST_MODE || 'colour').toLowerCase()
// The SwiftShader launch — without these a headless Chromium here has NO GL context, the map never
// paints, and this gate cannot see the layer at all. Same flags as verify-map-paint.mjs.
const GL_ARGS = [
'--use-gl=swiftshader',
'--enable-unsafe-swiftshader',
'--ignore-gpu-blocklist',
'--enable-webgl',
'--no-sandbox',
]
// Shadow DOM DOES NOT PIERCE — recursive walk with a seen-Set, re-injected after every interaction.
const WALK = `
function __walk(sel) {
const seen = new Set(); const out = [];
const visit = (root) => {
if (!root || seen.has(root)) return; seen.add(root);
let els = []; try { els = Array.from(root.querySelectorAll('*')) } catch (e) { return }
for (const el of els) {
try { if (el.matches && el.matches(sel)) out.push(el) } catch (e) {}
if (el.shadowRoot) visit(el.shadowRoot);
}
};
visit(document); return out;
}
// THE MEASUREMENT: the map's own bubble source — the DOTS the Driver looks at — plus the paint
// property that decides what each one LOOKS like.
function __state() {
const m = window.__rdashMap;
if (!m) return { err: 'no map' };
let s; try { s = m.getSource('rd-bubbles') } catch (e) { return { err: 'map torn down: ' + e.message } }
if (!s) return { err: 'no rd-bubbles source' };
const d = s._data || (s.serialize && s.serialize().data);
const feats = (d && d.features) || [];
let color = null; try { color = m.getPaintProperty('rd-bubble-layer', 'circle-color') } catch (e) {}
return {
n: feats.length,
slugs: feats.map((f) => f.properties && f.properties.slug),
praying: feats.map((f) => (f.properties ? f.properties.peoplePraying : undefined)),
color,
colorIsExpression: Array.isArray(color),
colorJson: JSON.stringify(color),
};
}
function __openPrayer() {
const dd = __walk('.filter-dropdown').find((d) => (d.textContent || '').includes('Prayer'));
if (!dd) return false;
const b = dd.querySelector('button'); if (!b) return false;
b.click(); return true;
}
function __pickPrayer(label) {
const dd = __walk('.filter-dropdown').find((d) => (d.textContent || '').includes('Prayer'));
if (!dd) return 'no dropdown';
const o = Array.from(dd.querySelectorAll('[role=option]')).find((x) => (x.textContent || '').trim().startsWith(label));
if (!o) return 'no option: ' + label;
o.click(); return 'ok';
}
`
// ★ THE SELF-TEST INVERTS THE EXIT CODE, and it must do so HERE rather than at the end.
// Every assertion below calls fail() and exits immediately, so a self-test run that correctly
// DETECTS the reverted bug would otherwise exit 1 and look like a broken gate. In --self-test a
// detection is the PASS: we print what was caught and exit 0. If the run instead reaches the end
// without ever calling fail(), the gate missed the bug and THAT exits 1 (see the tail of run()).
const fail = (m) => {
if (SELF_TEST) {
console.log('FACET-CHANGES-MAP: SELF-TEST OK — the gate REJECTED the reverted build:')
console.log(' caught: ' + m)
process.exit(0)
}
console.error('FACET-CHANGES-MAP: FAIL — ' + m)
process.exit(1)
}
const ok = (m) => console.log(' ok ' + m)
// The prayer levels, and the predicate each one promises about a surviving dot. Kept in step with
// library/colors/prayer-progress.js (threshold 144) — the ONE owner of the split.
const FULL_PRAYER_THRESHOLD = 144
const LEVELS = [
{ label: 'Has Prayer', holds: (n) => Number(n) > 0 },
{ label: 'Needs Prayer', holds: (n) => !(Number(n) > 0) },
]
async function reinject(page) { await page.addScriptTag({ content: WALK }) }
const run = async () => {
const browser = await chromium.launch({ args: GL_ARGS })
const page = await browser.newPage({ viewport: { width: 1600, height: 1000 } })
const url = BASE + PATH_
console.log(`FACET-CHANGES-MAP: ${url} (tab "${TAB}")${SELF_TEST ? ' [SELF-TEST: the gate MUST fail]' : ''}`)
try {
await page.goto(url, { waitUntil: 'load', timeout: 60000 })
} catch (e) {
console.error('FACET-CHANGES-MAP: SKIP — cannot reach ' + url + ' (' + e.message + ')')
console.error(' start the surface first: bun run dev (or `bun run serve` + BASE_URL=http://127.0.0.1:4173)')
await browser.close(); process.exit(2)
}
await page.waitForTimeout(2500)
await reinject(page)
// The heavy tab LAZY-MOUNTS: querying before this click returns 0 and reads as "no bug".
await page.evaluate(`(() => {
const els = __walk('[role=tab], .vtl-tab, button');
const hit = els.find((e) => (e.textContent || '').includes(${JSON.stringify(TAB)}));
if (hit) hit.click();
})()`)
// Wait for the map to EXIST and be POPULATED. Measuring earlier reads "no map" and would make
// every before/after comparison below meaningless.
try {
await page.waitForFunction(`(() => {
const m = window.__rdashMap; if (!m) return false;
let s; try { s = m.getSource('rd-bubbles') } catch (e) { return false }
if (!s) return false;
const d = s._data; return !!(d && d.features && d.features.length > 0);
})()`, { timeout: 90000 })
} catch (e) {
await browser.close(); fail('the map never populated (no rd-bubbles features after 90s)')
}
await page.waitForTimeout(2500)
await reinject(page)
// ── THE SELF-TEST: put the BUG back, in the live page. ────────────────────────────────────────
// Reverts BOTH halves of the fix — the flat literal colour AND the missing property — which is
// exactly the state this gate exists to reject.
if (SELF_TEST) {
console.log(` [self-test] reverting the fix in-page, mode = ${SELF_TEST_MODE}`)
await page.evaluate(`((mode) => {
const m = window.__rdashMap;
const s = m.getSource('rd-bubbles');
const killColour = mode === 'colour' || mode === 'both';
const killProps = mode === 'props' || mode === 'both';
if (killColour) {
// Pin circle-color at the flat literal so the app's own applyPaint() cannot undo the
// revert mid-run. This alone IS the reported bug: the filter still works, every dot orange.
m.setPaintProperty('rd-bubble-layer', 'circle-color', '#ef6c00');
const orig = m.setPaintProperty.bind(m);
m.setPaintProperty = (layer, prop, val) => {
if (layer === 'rd-bubble-layer' && prop === 'circle-color') return orig(layer, prop, '#ef6c00');
return orig(layer, prop, val);
};
}
if (killProps) {
const strip = (data) => { for (const f of ((data && data.features) || [])) { delete f.properties.peoplePraying } return data };
strip(s._data); s.setData(s._data);
const os = s.setData.bind(s);
s.setData = (data) => os(strip(data));
}
if (mode === 'frozen') {
// The map never learns about the filter — the shape the brief hypothesised as (a)/(c).
s.setData = () => {};
}
})(${JSON.stringify(SELF_TEST_MODE)})`)
await page.waitForTimeout(500)
await reinject(page)
}
const before = await page.evaluate('__state()')
if (before.err) { await browser.close(); fail('before-state: ' + before.err) }
if (!before.n) { await browser.close(); fail('the map had 0 dots before any filter — nothing to compare') }
ok(`baseline: ${before.n} dots, circle-color = ${before.colorJson}`)
// A flat literal with NO prayer filter active is CORRECT (the pin's own orange identity).
if (before.colorIsExpression) {
ok('baseline colour is already data-driven (acceptable; the switch below is what matters)')
}
let checked = 0
for (const lvl of LEVELS) {
if (!(await page.evaluate('__openPrayer()'))) { await browser.close(); fail('no "Prayer" dropdown found — the facet is not reachable as a control') }
await page.waitForTimeout(900)
await reinject(page)
const picked = await page.evaluate(`__pickPrayer(${JSON.stringify(lvl.label)})`)
if (picked !== 'ok') { await browser.close(); fail(`could not select "${lvl.label}": ${picked}`) }
await page.waitForTimeout(5000)
await reinject(page)
const after = await page.evaluate('__state()')
if (after.err) { await browser.close(); fail(`after "${lvl.label}": ` + after.err) }
// ── 1. THE SET CHANGED (count) ──
if (after.n === before.n) {
await browser.close()
fail(`selecting "${lvl.label}" did not change the map: still ${after.n} dots. The filter is not reaching the map's bubble source.`)
}
if (after.n === 0) { await browser.close(); fail(`selecting "${lvl.label}" emptied the map (0 dots) — a filter that matches nothing`) }
// ── 1b. THE SET CHANGED (identity — count alone can coincide) ──
const sBefore = new Set(before.slugs)
const subset = after.slugs.every((s) => sBefore.has(s))
if (!subset) { await browser.close(); fail(`"${lvl.label}" produced dots that were not in the unfiltered set — the map is not a subset of its own data`) }
const removed = before.slugs.filter((s) => !new Set(after.slugs).has(s)).length
if (!removed) { await browser.close(); fail(`"${lvl.label}" removed no dots by identity`) }
// ── 2. THE SURVIVORS MEAN WHAT THE LABEL SAYS ──
const bad = after.praying.filter((n) => !lvl.holds(n)).length
if (bad) {
await browser.close()
fail(`"${lvl.label}" left ${bad}/${after.n} dots that do NOT satisfy it — the dots shown are not the dots asked for`)
}
// ── 3. THE DOTS CARRY THEIR PRAYER STATE, AND THE COLOUR READS IT ──
const missing = after.praying.filter((v) => v === undefined || v === null).length
if (missing) {
await browser.close()
fail(`"${lvl.label}": ${missing}/${after.n} dots carry no peoplePraying property, so the colour expression has nothing to read (every dot would paint as the default)`)
}
if (!after.colorIsExpression) {
await browser.close()
fail(`"${lvl.label}": circle-color is the flat literal ${after.colorJson} — every dot paints the same regardless of prayer, so the map cannot "show which ones have prayer". It must be a data-driven expression while a prayer filter is active.`)
}
if (!after.colorJson.includes('peoplePraying')) {
await browser.close()
fail(`"${lvl.label}": circle-color is an expression but never reads peoplePraying (${after.colorJson.slice(0, 120)}) — it is not painting prayer state`)
}
ok(`"${lvl.label}": ${before.n} -> ${after.n} dots (${removed} removed), all satisfy the level, colour is data-driven on peoplePraying`)
checked++
// clear for the next level (the chip's x), and confirm the map restores
await page.evaluate(`(() => { const x = __walk('.sfh__chip-x')[0]; if (x) x.click() })()`)
await page.waitForTimeout(4000)
await reinject(page)
const cleared = await page.evaluate('__state()')
if (cleared.err) { await browser.close(); fail('after clearing: ' + cleared.err) }
if (cleared.n !== before.n) {
await browser.close()
fail(`clearing "${lvl.label}" left ${cleared.n} dots, expected the full ${before.n} — the filter does not round-trip`)
}
ok(`"${lvl.label}" cleared: restored to ${cleared.n} dots`)
}
await browser.close()
if (!checked) fail('no prayer levels were exercised')
// Reaching here in self-test means NO assertion fired while the bug was present in the page.
if (SELF_TEST) {
console.error('FACET-CHANGES-MAP: SELF-TEST FAILED — the bug was reverted in the page and this gate still PASSED.')
console.error(' A gate that cannot fail is worse than no gate. Fix the gate, not the product.')
process.exit(1)
}
console.log(`FACET-CHANGES-MAP: PASS — ${checked} prayer level(s): the dots change, mean what they say, and carry their prayer colour.`)
}
run().catch((e) => {
// An EXCEPTION is not a detection. In self-test we require the gate to fail through one of its
// own assertions (fail()), not to crash — a crash would "pass" the self-test for the wrong
// reason and would hide a genuinely broken gate.
console.error('FACET-CHANGES-MAP: ERROR — ' + e.message)
process.exit(1)
})

View file

@ -50,6 +50,8 @@
"check:facet-cascade:self-test": "node internal/check-facet-cascade.mjs --self-test",
"check:facet-one-field": "node internal/check-facet-one-field.mjs # ONE AXIS = ONE CONTROL-REACHABLE FACET. Asserts on the PARTITION each facet induces over the rows, NOT on which r.<field> it reads: three fields (religion, primary_language, rop1) are legitimately read by two facets each as tier-1/tier-2 pairs, so a gate on duplicate field READS would fail correct code. Scoped to facets a user can actually select (rail + dropdowns + charts), because a search-only projection sharing a partition is fine until it becomes a control. run check:facet-one-field:self-test for the NEGATIVE test.",
"check:facet-one-field:self-test": "node internal/check-facet-one-field.mjs --self-test",
"check:facet-changes-map": "node internal/check-facet-changes-map.mjs # a facet selection must CHANGE THE DOTS — and the dots must SAY what they mean. Asserts (1) the rd-bubbles feature set changes by count AND identity, (2) every surviving dot satisfies the selected level, (3) while a prayer filter is active circle-color is a DATA-DRIVEN EXPRESSION reading peoplePraying, not a flat literal. Assertion 3 is the one that matters: the prayer filter ALREADY narrowed 2107->689 correctly while the Driver reported it broken, because every dot painted the same orange. Browser gate (needs `bun run dev` on 5173, or BASE_URL/PROBE_PATH at a served tree), so NOT in build:unlocked. run the `check:facet-changes-map:self-test` script for the NEGATIVE test.",
"check:facet-changes-map:self-test": "node internal/check-facet-changes-map.mjs --self-test # reverts the fix IN THE LIVE PAGE and requires the gate to FAIL. SELF_TEST_MODE=colour (default, isolates the colour assertion — the reported bug) | props | both | frozen (blocks the source update, isolating the set assertion). All four modes verified caught.",
"check:heartbeat": "node internal/check-heartbeat-threshold.mjs # the HEARTBEAT ZONE threshold (the choropleth's top bin) must come from config, not a literal. The old 5/20/60/150 array was a DATASET assumption (~2000 people groups) wearing the costume of a constant: on a 200-group dataset every country falls in the bottom bin and the map renders uniformly pale — correct code, no crash, no error, meaningless map, invisible to every other gate. EXECUTES the resolver from the live SFC and asserts the bins MOVE when the config moves, because a grep for '150' both false-positives (it is the legitimate default) and false-negatives (a re-hard-coding at 200 sails through). run check:heartbeat:self-test for the NEGATIVE test.",
"check:heartbeat:self-test": "node internal/check-heartbeat-threshold.mjs --self-test # re-hard-codes the ramp IN MEMORY and requires check:heartbeat to REJECT it (asserting specifically that assertion B, threshold-is-live, fires). A gate that cannot fail is worse than no gate.",
"build:unlocked": "bun run check:template-literal && bun run check:mjs-parse && bun run check:sfc-parse && bun run clean && bun run token && for d in plugin-designs/*/; do BUNDLE=$(basename \"$d\") bun x vite build || exit 1; done && bun run tree && bun run check:drop && bun run check:parse && bun run verify && bun run check:sri && bun run check:public-hygiene && bun run check:label-joins && bun run check:facet-cascade && bun run check:facet-one-field && bun run check:heartbeat && bun run check:kpi-unit && bun run check:composite-reg && bun run check:descriptions && bun run check:no-abs-paths && bun run check:no-codename && bun run check:dep-names && bun run check:composite-srcs && bun run check:embed-targets && bun run check:nested-token && bun run gallery",