diff --git a/internal/check-facet-changes-map.mjs b/internal/check-facet-changes-map.mjs new file mode 100644 index 000000000..6edf747bb --- /dev/null +++ b/internal/check-facet-changes-map.mjs @@ -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) +}) diff --git a/package.json b/package.json index 0c0304a81..f94a28f97 100644 --- a/package.json +++ b/package.json @@ -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. 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",