doxa-maps/internal/check-facet-changes-map.mjs
Doxa Maps 810c568f60
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Update from source b2142ac0b
2026-09-28 10:14:50 +00:00

493 lines
28 KiB
JavaScript

#!/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 agent'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 agent 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 the agent 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=/<TREE_NAME>/... node internal/check-facet-changes-map.mjs
*/
import { chromium } from './lib/playwright-guarded.mjs'
// 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.
//
// ⚠ THE DEV DEFAULT IS FLAKY UNDER LOAD, AND A FLAKY GATE IS WORSE THAN A FAILING ONE — it
// teaches you to re-run until green. MEASURED 2026-09-24 on a box with several agents working:
// dev 5173 -> 1 PASS, 3 FAIL in four consecutive runs ("the map never populated after 90s")
// built 4173 -> PASS, PASS
// The product is fine; the VARIANCE is the dev server's. A dev page load pulls 88 unbundled
// module requests against the built leaf's 3, and Vite compiles them on demand — so the wait
// this gate already retries THREE TIMES at 90s can still lose. Raising the timeout is the wrong
// fix for the same reason: it is not one slow moment, it is contention.
//
// SO: when you need a trustworthy answer rather than a quick one, run it against the BUILT tree:
// BASE_URL=http://127.0.0.1:4173 \
// PROBE_PATH=/<TREE_NAME>/research-dashboard/research-dashboard/index.html \
// node internal/check-facet-changes-map.mjs
// The built tree can be stale — that is the trade — but it is DETERMINISTIC, which is what a gate
// needs. The dev default is kept because it is right for hand-running against uncommitted source.
// This is also why the gate is NOT wired into a wave: a wave step that is green 1 run in 4 would
// train everyone to ignore the wave.
const BASE = process.env.BASE_URL || 'http://127.0.0.1:5173'
const PATH_ = process.env.PROBE_PATH || '/'
// ⚠ THE SERVER THAT ANSWERS IS NOT NECESSARILY THIS CHECKOUT. Several worktrees of this repo
// run on one machine and a default port belongs to whichever started first — measured
// 2026-09-24 on :4173. This gate reports on how a map's source data changes when a facet is
// picked, so a run against another tree describes somebody else's data flow. The fingerprint
// is public/<TREE_NAME>/manifest.json; a dev server serves it straight off disk, so this stays
// quiet in the normal `bun run dev` case and only speaks when the bytes really differ.
{
const { servesThisTree } = await import('./_serves-this-tree.mjs')
const { fileURLToPath } = await import('node:url')
const { dirname, resolve } = await import('node:path')
const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const tree = await servesThisTree(BASE, repo)
if (/DIFFERENT build/.test(tree.why)) {
console.log('FACET-CHANGES-MAP: SKIP — the server is not serving THIS build.')
console.log(` ${tree.why}`)
console.log(' Point BASE_URL at this checkout (bun run dev, or bun run serve).')
process.exit(0)
}
}
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 agent 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 agent looks at — plus the paint
// property that decides what each one LOOKS like.
// ★ THE MAP HANDLE CAN GO STALE. The panel destroys and re-creates its Map on a re-render (and
// the dev server's HMR does this freely), which leaves window.__rdashMap pointing at a torn-down
// instance whose getSource() throws "Cannot read properties of undefined (reading 'getOwnSource')".
// Measured: one run in three failed that way on the dev surface while the product was fine.
// A gate that reports a product bug when its own handle went stale is a gate that gets ignored, so
// __live() proves the handle still works before any assertion reads through it.
// ★ READ A GEOJSON SOURCE'S FEATURES WITHOUT ASSUMING ONE ENGINE'S PRIVATE SHAPE.
// This gate could only ever measure Mapbox (BUG-FACET-GATE-CANNOT-MEASURE-MAPLIBRE), and the cause
// is four reads of 'source._data'. The two engines store it differently — cited from the bundles
// this repo actually loads, same constructor in each:
// mapbox-gl v3.24.0 this._data = i.data (the raw GeoJSON)
// maplibre-gl this._data = "string"==typeof i.data ? {url:…} : {geojson: i.data}
// So '_data.features' is undefined under MapLibre and every read fell to an empty list. A GATE that
// cannot see MapLibre's features PASSES A MAP THAT IS BROKEN THERE, which is worse than a plugin
// having the same bug.
//
// ⚠ THE 's._data || s.serialize().data' FALLBACK DOES NOT FIX IT, and that is why this is a helper
// rather than a copied one-liner: '||' only fires when the left side is FALSY, and '{geojson: …}'
// is TRUTHY. The old __state() carried that form and was broken under MapLibre exactly like the
// three unguarded reads. The wrapper has to be UNWRAPPED, not fallen back from.
//
// ⚠ AND IT LIVES HERE, IN THE PAGE, not as an import of library/maps/pin-selection.js. All four
// call sites run inside page.evaluate / page.waitForFunction bodies, which are serialised to the
// browser — a Node-side import does not exist there. This is the same logic as readSourceFeatures(),
// deliberately duplicated across the process boundary, which is the one place duplication is not a
// smell. If that helper's rules change, this must follow.
function __srcFeatures(m, id) {
let s; try { s = m.getSource(id) } catch (e) { return null }
if (!s) return null;
let data = s._data !== undefined ? s._data : (s.serialize && s.serialize().data);
if (data && typeof data === 'object' && !Array.isArray(data)) {
if (data.geojson !== undefined) data = data.geojson;
// A REMOTE SOURCE IS NOT AN EMPTY ONE. Its features live on a server and cannot be enumerated
// here; returning [] would read as "the map drew nothing", which is the bug this gate exists to
// catch. null means "could not measure", and the caller must not treat it as zero.
else if (data.url !== undefined) return null;
}
return (data && data.features) || null;
}
function __live() {
const m = window.__rdashMap;
if (!m) return null;
try { m.getSource('rd-bubbles'); return m } catch (e) { return null }
}
function __state() {
const m = __live();
if (!m) return { stale: true, err: 'map handle is stale (torn down and re-created)' };
let s; try { s = m.getSource('rd-bubbles') } catch (e) { return { stale: true, err: 'map torn down: ' + e.message } }
if (!s) return { err: 'no rd-bubbles source' };
const feats = __srcFeatures(m, 'rd-bubbles') || [];
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) },
]
// ── SHAPE TEST (headless, no browser) ────────────────────────────────────────────────────────────
// ★ NEGATIVE-TESTS __srcFeatures AGAINST BOTH ENGINE SHAPES, in sr's check-pin-selection style.
// ⚠ NOT `--self-test`: that already means "run the browser gate with the bug put back, it MUST
// fail". This one never opens a browser, so it can run anywhere and in gates:pre.
// It evaluates the helper OUT OF THE WALK STRING ITSELF, so the thing tested is the thing injected
// into the page — a copy pasted here would pass while the shipped helper rotted.
if (process.argv.includes('--shape-test')) {
const FC = { type: 'FeatureCollection', features: [{ type: 'Feature', properties: { slug: 'a' } },
{ type: 'Feature', properties: { slug: 'b' } }] }
const srcFeatures = new Function(`${WALK}\nreturn __srcFeatures`)()
const mapWith = (src) => ({ getSource: (id) => (id === 'rd-bubbles' ? src : null) })
const cases = [
['mapbox shape (_data = FeatureCollection)', () => srcFeatures(mapWith({ _data: FC }), 'rd-bubbles'), 2],
['maplibre shape (_data = { geojson })', () => srcFeatures(mapWith({ _data: { geojson: FC } }), 'rd-bubbles'), 2],
['public fallback (no _data, serialize())', () => srcFeatures(mapWith({ serialize: () => ({ data: FC }) }), 'rd-bubbles'), 2],
// A REMOTE SOURCE MUST NOT READ AS EMPTY. null means "could not measure"; 0 would mean "the map
// drew nothing", which is the finding this gate exists to report.
['remote (_data = { url }) -> null, not 0', () => srcFeatures(mapWith({ _data: { url: 'https://x.invalid/a.json' } }), 'rd-bubbles'), null],
['absent source -> null', () => srcFeatures(mapWith(null), 'rd-bubbles'), null],
]
let ok = 0
for (const [name, run, want] of cases) {
const got = run()
const n = got === null || got === undefined ? null : got.length
const pass = n === want
console.log(` ${pass ? '✓' : '✗'} ${name} -> ${n === null ? 'null' : n + ' feature(s)'}`)
if (pass) ok++
}
console.log(ok === cases.length
? `FACET-SHAPE-TEST: PASS — ${ok}/${cases.length}, both engine shapes measured`
: `FACET-SHAPE-TEST: FAIL — ${ok}/${cases.length}`)
process.exit(ok === cases.length ? 0 : 1)
}
async function reinject(page) { await page.addScriptTag({ content: WALK }) }
// Read __state(), tolerating a STALE map handle: if the panel re-created its Map mid-interaction
// we wait for the new one to populate and read again, rather than reporting a product failure for
// what is our own dangling reference. Only a state that is still stale after the grace period is
// treated as real — and it is reported as an infrastructure fault, not a filter bug.
async function readState(page, label) {
for (let attempt = 0; attempt < 3; attempt++) {
const st = await page.evaluate('__state()')
if (!st.stale) return st
if (attempt === 0) console.log(` …${label}: map handle went stale (re-render); waiting for the new instance`)
try {
await page.waitForFunction(`(() => {
const m = window.__rdashMap; if (!m) return false;
try { const f = __srcFeatures(m, 'rd-bubbles'); return !!(f && f.length > 0) }
catch (e) { return false }
})()`, { timeout: 30000 })
} catch (e) { /* fall through to a final attempt */ }
await page.waitForTimeout(1500)
await reinject(page)
}
const final = await page.evaluate('__state()')
if (final.stale) fail(`${label}: the map was torn down and never came back — cannot measure (infrastructure, not a filter verdict)`)
return final
}
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 f = __srcFeatures(m, 'rd-bubbles'); return !!(f && f.length > 0);
})()`, { timeout: 90000 })
} catch (e) {
// ★ DISTINGUISH "I CANNOT MEASURE THIS ENGINE" FROM "THE PRODUCT IS BROKEN".
// This gate reads the source's CURRENT GeoJSON off `_data`, a private Mapbox internal. MEASURED
// on one dashboard, one build, only the engine differing, by calling the app's own setData() to
// shrink 2107 features to 5:
// mapbox _data 2107 -> 5 _options.data 2107 -> 2107 (_data is LIVE)
// maplibre _data never an FC _options.data 2107 -> 2107 (NEITHER is live)
// So under MapLibre no private field tracks setData and the wait above can never succeed —
// and the gate then reported "the map never populated" about a dashboard that visibly renders
// all 2107 dots with a working choropleth. A control that accuses correct code is worse than no
// control; the same false-FAIL shape was just removed from check:filter-width.
// Everything below is SELF-CONTAINED page code on purpose: an earlier attempt at this called a
// node-side helper from inside a page-evaluated string, where it is simply undefined.
let diag = null
try {
diag = await page.evaluate(`(() => {
const m = window.__rdashMap; if (!m) return { noMap: true };
let s = null; try { s = m.getSource('rd-bubbles') } catch (e) {}
let engine = 'unknown';
try { engine = m.getContainer && m.getContainer().classList.contains('maplibregl-map') ? 'maplibre' : 'mapbox' } catch (e) {}
const f = __srcFeatures(m, 'rd-bubbles');
return { engine, hasSource: !!s, payloadReadable: !!f, layer: !!m.getLayer('rd-bubble-layer') };
})()`)
} catch (_) { /* page gone — fall through to the original failure */ }
// ★ THE MAPLIBRE SKIP IS GONE, because the reason for it is. This branch used to bail out with
// "SKIP — this gate reads the GeoJSON source through a private Mapbox field (_data) that
// MapLibre does not keep live", exiting 0 while announcing SKIP — NOT MEASURED, correct for the
// limitation but a gate that never looks at half the product. __srcFeatures() unwraps
// MapLibre's {geojson} wrapper, so the features ARE readable on both engines now and a failure
// here is a real one.
// If payloadReadable is still false with a source present, that is a NEW condition (a url-backed
// source, or an engine shape neither bundle documents) and it should be investigated, not
// skipped past — so it now falls through to the ordinary failure below, with the diagnostic.
if (diag && diag.hasSource && !diag.payloadReadable) {
await browser.close()
fail(`the ${diag.engine} source 'rd-bubbles' exists but its features could not be read `
+ `(payload unreadable — a remote/url source, or a shape neither engine bundle documents). `
+ `This is NOT the old MapLibre _data limitation: that is fixed.`)
}
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 readState(page, 'baseline')
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 readState(page, `after "${lvl.label}"`)
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 readState(page, `clearing "${lvl.label}"`)
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)
})