A reader who picks a religion family on the map now sees the grid narrow to the
same peoples, and a language selection travels the same way. Region already
linked; these two did not, because the two halves do not name a religion the
same way and nothing yet proved what the honest translation was.
The map filters religion through its legend's flat tab, keyed by the FIRST
LETTER of the religion code ('B'); the grid filters through a derived axis that
runs library/colors/religion.js forward per row ('BLA' -> 'Buddhism'). One
shared table, read in opposite directions, copied by neither half — so the wire
carries the family NAME and each side resolves it against its own data.
Two measurements changed the design on the way:
- mapStore.filters.religions, which the map's filter panel writes, is read by
NOTHING: the mapLayers.applyFilters() it was meant to reach is called and
never implemented anywhere in the tree. A link routed through it would have
set state, passed a smoke test and dimmed no pin.
- the map's legend selections are mutually exclusive by design, so it can
light exactly one of region/religion/language. The composite mirrors that
rather than letting the grid hold three facets the map cannot show.
Religion links at the FAMILY and not at the code, deliberately: a map cannot
distinguish 'BLA' from 'BMA' — both dim as Buddhism — so linking at the code
would promise a precision one half cannot keep.
check:linked-vocabulary proves it by measurement rather than by reading: it
compares the total reached over the composite's wire against the total the grid
gives for the same facet on its own (83 of 2,107 both ways), and runs the MUTANT
first — the legend key 'B' instead of 'Buddhism' — failing as NO-TEETH if the
wrong mapping scores the same as the right one. Measured: mutant 0, real 83.
The CLI doc's apk:debug quote was realigned in passing; package.json had gained
app:start-page and the doc still described the older chain.
219 lines
12 KiB
JavaScript
219 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* check-linked-vocabulary.mjs — the two halves of this composite must mean the SAME THING by a
|
|
* religion family, and this proves it by measuring, on the built tree, that the link delivers
|
|
* exactly what the facet delivers on its own.
|
|
*
|
|
* WHY THIS GATE EXISTS. Linking a facet across two elements is the one place in this repo where a
|
|
* bug is INVISIBLE: both halves keep working, both look filtered, and they are simply showing
|
|
* different peoples. Region survived that risk because the two halves share a slug. Religion does
|
|
* not:
|
|
*
|
|
* the map — filters religion through its legend's flat tab, keyed by the FIRST LETTER of the
|
|
* religion code ('B'), named by RELIGION_FAMILIES ('Buddhism')
|
|
* the grid — filters religion through a DERIVED axis, `__religion_family`, which runs that same
|
|
* RELIGION_FAMILIES table forward per row (code 'BLA' -> 'Buddhism')
|
|
*
|
|
* One shared table in library/colors/religion.js, read in opposite directions, copied by neither.
|
|
* That is the mapping under test. A composite that sent the letter instead of the name, or the raw
|
|
* code instead of the family, would look exactly as healthy — and show a grid of nothing, or a grid
|
|
* of everything, beside a correctly dimmed map.
|
|
*
|
|
* MEASURED, AND IT CHANGED THE DESIGN: `mapStore.filters.religions`, which the map's filter panel
|
|
* writes, is read by NOTHING — `mapLayers.applyFilters()` is called and never implemented. A link
|
|
* routed through it would have passed every plausible smoke test and dimmed no pin. That is why
|
|
* this gate compares COUNTS reached two different ways rather than asserting that state was set.
|
|
*
|
|
* HOW. Serves the built tree on a free port, loads the composite's own leaf, and for one religion
|
|
* family that the data actually contains:
|
|
*
|
|
* DIRECT set `religion-family` on the nested grid itself — the element's own documented prop,
|
|
* no link involved — and record the total it reports. Must be >0 and < unfiltered.
|
|
* LINKED clear, then drive the composite's published wire instead: a `map-filtered` event
|
|
* carrying the family NAME, exactly as the map emits it. Record the total again.
|
|
* ASSERT linked === direct. The link delivered the facet, not something adjacent to it.
|
|
*
|
|
* ★ THE MUTANT RUNS FIRST, AND THIS GATE FAILS IF THE MUTANT PASSES. Before the real name is sent,
|
|
* the same wire is driven with the legend's LETTER KEY ('B') — the single most likely wrong version
|
|
* of this mapping, and the one a refactor would reach for. A grid that narrows to the same total on
|
|
* 'B' as on 'Buddhism' is a grid whose linking cannot tell the two apart, so the gate reports
|
|
* NO-TEETH and fails. A check that has never been seen to fail is not evidence.
|
|
*
|
|
* Exit: 0 pass or SKIP (no built tree, no browser, no data) · 1 fail.
|
|
*/
|
|
import { createServer } from 'node:http'
|
|
import { spawn } from 'node:child_process'
|
|
import { existsSync, readFileSync } from 'node:fs'
|
|
import { resolve, dirname, join } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
const TAG = 'LINKED-VOCABULARY'
|
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
|
|
const MANIFEST = join(ROOT, 'public', 'doxa-maps', 'manifest.json')
|
|
const skip = (why) => { console.log(`${TAG}: SKIP — ${why}`); process.exit(0) }
|
|
|
|
if (!existsSync(MANIFEST)) skip('no built manifest (run `bun run tree` first).')
|
|
let chromium
|
|
try { ({ chromium } = await import('playwright')) } catch { skip('playwright is not installed.') }
|
|
|
|
const manifest = JSON.parse(readFileSync(MANIFEST, 'utf8'))
|
|
const bundle = (manifest.bundles || []).find((b) => b.bundle === 'research-map-plus-grid')
|
|
const profile = bundle && (bundle.profiles || []).find((p) => p.iframeUrl)
|
|
if (!profile) skip('the manifest declares no research-map-plus-grid leaf to load.')
|
|
|
|
const freePort = () => new Promise((ok) => {
|
|
const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => ok(p)) })
|
|
})
|
|
const PORT = await freePort()
|
|
const TREE = `http://127.0.0.1:${PORT}/doxa-maps`
|
|
const LEAF = `${TREE}/${String(profile.iframeUrl).replace(/^\.\//, '')}`
|
|
|
|
const tree = spawn(process.execPath, ['internal/serve-strict.mjs', '--port', String(PORT)], { cwd: ROOT, stdio: 'ignore' })
|
|
const cleanup = () => { try { tree.kill() } catch { /* already gone */ } }
|
|
await new Promise((r) => setTimeout(r, 900))
|
|
const reachable = await fetch(`${TREE}/manifest.json`, { signal: AbortSignal.timeout(4000) }).then((r) => r.ok).catch(() => false)
|
|
if (!reachable) { cleanup(); skip('could not serve the built tree locally.') }
|
|
|
|
// The tree we just started is the tree we built — but say so out loud rather than trust the port.
|
|
const { requireThisTree } = await import(join(ROOT, 'internal', '_serves-this-tree.mjs'))
|
|
await requireThisTree(TREE, ROOT, TAG)
|
|
|
|
const browser = await chromium.launch({ args: ['--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'] })
|
|
const fails = []
|
|
const lines = []
|
|
try {
|
|
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } })
|
|
await page.goto(LEAF, { waitUntil: 'domcontentloaded', timeout: 60000 })
|
|
|
|
// Everything below runs in ONE evaluate: the totals have to be read from the same live page, and
|
|
// a `groups-filtered` listener installed from Node would miss events fired between round trips.
|
|
const out = await page.evaluate(async () => {
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
const deep = (sel) => {
|
|
const seen = new Set()
|
|
const walk = (root) => {
|
|
if (!root || seen.has(root)) return null
|
|
seen.add(root)
|
|
const hit = root.querySelector?.(sel)
|
|
if (hit) return hit
|
|
for (const el of root.querySelectorAll?.('*') || []) {
|
|
if (el.shadowRoot) { const r = walk(el.shadowRoot); if (r) return r }
|
|
}
|
|
return null
|
|
}
|
|
return walk(document)
|
|
}
|
|
|
|
const host = document.querySelector('research-map-plus-grid')
|
|
if (!host) return { skip: 'the composite element never appeared on its own leaf' }
|
|
const root = host.shadowRoot || host
|
|
let grid = null
|
|
for (let i = 0; i < 120 && !grid; i++) { grid = deep('people-groups-grid'); if (!grid) await sleep(500) }
|
|
if (!grid) return { skip: 'the nested grid never mounted' }
|
|
|
|
// The grid reports its total on every filter pass. Listening is how a total is read here: the
|
|
// rendered count line is localized and the DOM shape is not this gate's business.
|
|
let total = null
|
|
const seen = []
|
|
document.addEventListener('groups-filtered', (e) => { total = e.detail?.total ?? null; seen.push(total) }, true)
|
|
|
|
// Wait for the first fetch to settle — a total of null means the data never arrived, which is a
|
|
// SKIP (no network in the sandbox), not a failure of the mapping.
|
|
for (let i = 0; i < 120 && total === null; i++) await sleep(500)
|
|
if (total === null) return { skip: 'the grid never reported a total (no data reached it)' }
|
|
const unfiltered = total
|
|
|
|
// WHICH FAMILY TO TEST: ask both halves what they can name, and take one they agree on. A
|
|
// hard-coded 'Islam' would be a table of exactly the kind this whole design refuses.
|
|
const gridVocab = (grid.__vocabulary || window.__peopleGroupsGridVocabulary)?.('__religion_family') || []
|
|
// The map loads its own records independently of the grid's fetch, and in this composite it is
|
|
// the slower half. Waiting for its vocabulary to be non-empty is waiting for its DATA — asking
|
|
// before then reports "the two halves share nothing", which is a lie about the mapping.
|
|
let mapVocab = []
|
|
for (let i = 0; i < 160; i++) {
|
|
mapVocab = window.__researchMapVocabulary?.('religionFamily') || []
|
|
if (mapVocab.length) break
|
|
await sleep(500)
|
|
}
|
|
if (!mapVocab.length) return { skip: 'the nested map never reported a religion vocabulary (its data never arrived)' }
|
|
const shared = gridVocab.filter((n) => mapVocab.includes(n))
|
|
if (!shared.length) {
|
|
return { fail: `the two halves share NO religion family name. map=[${mapVocab.join(', ')}] grid=[${gridVocab.join(', ')}]` }
|
|
}
|
|
|
|
const settle = async () => { const before = seen.length; for (let i = 0; i < 40 && seen.length === before; i++) await sleep(250); await sleep(400) }
|
|
const clear = async () => {
|
|
grid.setAttribute('religion-family', '')
|
|
host.dispatchEvent(new CustomEvent('map-filtered', { detail: { region: '', religion: '', language: '' }, bubbles: true, composed: true }))
|
|
await settle()
|
|
}
|
|
const driveLinked = async (value) => {
|
|
await clear()
|
|
// ★ WAIT OUT THE ECHO WINDOW, OR MEASURE AN ARTIFACT. The composite ignores child reports for
|
|
// 1200ms after it pushes (its `pushing` guard, which exists so a push does not read back as a
|
|
// user action). A drive fired inside that window is DROPPED, and the grid then reports its
|
|
// previous total — which for the mutant looks exactly like "the wrong mapping matched
|
|
// nothing". MEASURED: the first green here had the mutant at 2107 (untouched), not 0
|
|
// (rejected), and the two are indistinguishable from the outside.
|
|
await sleep(1500)
|
|
// The composite listens on its own root for the map's published event. Firing it from the
|
|
// MAP element is what the map itself does; firing it from the host reaches the same listener.
|
|
const map = deep('doxa-research-map') || host
|
|
map.dispatchEvent(new CustomEvent('map-filtered', { detail: { region: '', religion: value, language: '' }, bubbles: true, composed: true }))
|
|
await settle()
|
|
return total
|
|
}
|
|
|
|
// Pick a family whose own filtered total is a real subset — a family matching everything or
|
|
// nothing proves nothing about the link.
|
|
let family = null, direct = null
|
|
for (const name of shared) {
|
|
await clear()
|
|
grid.setAttribute('religion-family', name)
|
|
await settle()
|
|
if (total > 0 && total < unfiltered) { family = name; direct = total; break }
|
|
}
|
|
if (!family) return { skip: 'no religion family narrows this grid to a strict subset' }
|
|
|
|
const letter = family.charAt(0).toUpperCase()
|
|
|
|
// ── MUTANT FIRST ──────────────────────────────────────────────────────────────────────────
|
|
const mutant = await driveLinked(letter)
|
|
// ── THEN THE REAL THING ───────────────────────────────────────────────────────────────────
|
|
const linked = await driveLinked(family)
|
|
|
|
return { unfiltered, family, letter, direct, linked, mutant, mapVocab, gridVocab: gridVocab.slice(0, 12) }
|
|
})
|
|
|
|
if (out.skip) { await browser.close(); cleanup(); skip(out.skip) }
|
|
if (out.fail) fails.push(out.fail)
|
|
else {
|
|
lines.push(` · vocabulary — the map names ${out.mapVocab.length} religion families, the grid names ${out.gridVocab.length}+; '${out.family}' is shared`)
|
|
lines.push(` · direct — religion-family="${out.family}" set on the grid itself: ${out.direct} of ${out.unfiltered}`)
|
|
lines.push(` · linked — the same family over the composite's wire: ${out.linked}`)
|
|
lines.push(` · mutant — the legend KEY '${out.letter}' over the same wire: ${out.mutant}`)
|
|
|
|
if (out.mutant === out.direct) {
|
|
fails.push(`NO TEETH — sending the legend key '${out.letter}' narrowed the grid to the same ${out.direct} rows as the family name '${out.family}'. This gate cannot tell a right mapping from a wrong one, so its pass would mean nothing.`)
|
|
}
|
|
if (out.linked !== out.direct) {
|
|
fails.push(`the link does not deliver the facet — '${out.family}' set on the grid gives ${out.direct} rows, the same family over the composite's wire gives ${out.linked}.`)
|
|
}
|
|
}
|
|
} catch (err) {
|
|
fails.push(`the measurement itself failed: ${err && err.message}`)
|
|
} finally {
|
|
await browser.close().catch(() => {})
|
|
cleanup()
|
|
}
|
|
|
|
for (const l of lines) console.log(l)
|
|
if (fails.length) {
|
|
console.log(`\n${TAG}: FAIL`)
|
|
for (const f of fails) console.log(` ✗ ${f}`)
|
|
console.log('\n The religion link is one shared table (library/colors/religion.js) read in two')
|
|
console.log(' directions: the map reverses family NAME -> legend letter, the grid runs code -> family')
|
|
console.log(' NAME per row. Both halves must speak the NAME on the wire.')
|
|
process.exit(1)
|
|
}
|
|
console.log(`${TAG}: PASS — the composite's religion link delivers exactly the facet the grid delivers on its own, and a wrong mapping does not.`)
|