Cell C has a gate now: the composite must filter, not navigate

The claim worth testing about the composed dashboard is not that it renders. It
is that it keeps the reader INSIDE it. Standalone, "Every resource in
<language>" is a link to the tables page; composed, it must be a button that
filters in place, because navigating away throws out the map the reader just
positioned and the group they just found. Same words, two affordances, one right
in each context.

Six assertions, each a way the composite can look fine and be broken: the
control is a <button> not an <a>, the URL does not change, the table's row count
goes DOWN, it does not go to zero, the pin count survives, and nothing 4xx'd or
threw.

It picks its subject from the live source rather than by position, and that is
not a detail. The first run opened pin 0 — Abai Sungai, a group with nothing
found — so the filter correctly produced an empty table and the check failed a
working composite. The test was wrong, not the product. It now opens a tier-2
pin, so "narrowed but not emptied" is a question the subject can answer.

Measured: 2,107 pins and 50 rows before, then Telugu narrows the tables to 13
with the map intact and the URL unchanged.
This commit is contained in:
doxa-maps-88655c70 2026-09-25 06:32:02 +00:00
parent 40750a153e
commit 6be1395d1f
2 changed files with 110 additions and 2 deletions

View file

@ -0,0 +1,106 @@
/**
* check-gospel-composed-filters.mjs — cell C: the map COMPOSED above its own tables.
*
* THE CLAIM UNDER TEST is not "it renders". It is that the composite keeps the reader INSIDE
* it. Standalone, the sheet's "Every resource in <language>" is a LINK to the tables page.
* Composed, it must become an in-place FILTER, because navigating away throws out the map the
* reader has just positioned and the group they have just found. Two different affordances
* behind the same words, and only one of them is right in each context.
*
* WHAT IT ASSERTS, and each one is a way the composite can look fine and be broken:
* - the control is a <button>, not an <a> (a link here navigates)
* - the URL does not change (proves it did not navigate)
* - the table's ROW COUNT goes down (proves the filter reached the data)
* - the row count stays above zero (proves it filtered rather than emptied)
* - the pin count is unchanged (proves the map survived the click)
* - no response >= 400 and no page error (proves it did not paper over a failure)
*
* IT PICKS ITS SUBJECT FROM THE DATA, and that is not a detail. The first run of this check
* opened pin 0 — Abai Sungai, a group with NOTHING found — so the filter correctly produced an
* empty table and the check failed a working composite. The test was wrong, not the product.
* It now selects a tier-2 pin from the live source, so "rows went down but not to zero" is a
* question the subject can actually answer.
*
* BROWSER GATE. Run it under both rendezvous locks, product lock first — the fixed order is
* what makes the pair deadlock-free:
* flock -w 1200 <product-lock> flock -w 1200 <agent-lock> bun run check:composed-filters
*/
import { chromium } from 'playwright'
import { serveReaped } from './_serve-reaped.mjs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const HERE = dirname(fileURLToPath(import.meta.url))
const PUBLIC = resolve(HERE, '../public')
const PORT = Number(process.env.PORT || 5399)
const BASE = process.env.BASE_URL || `http://127.0.0.1:${PORT}`
const PATHNAME = '/doxa-maps/gospel-resources/gospel-dashboard/index.html'
let srv = null
if (!process.env.BASE_URL) srv = serveReaped(PUBLIC, PORT)
const fail = (msg) => { console.error(`COMPOSED-FILTERS: FAIL — ${msg}`); srv?.stop(); process.exit(1) }
let browser
try {
browser = await chromium.launch()
const p = await browser.newPage({ viewport: { width: 1440, height: 1200 } })
const bad = []
p.on('response', (r) => { if (r.status() >= 400) bad.push(`${r.status()} ${r.url()}`) })
p.on('pageerror', (e) => bad.push(`pageerror: ${String(e.message).slice(0, 120)}`))
await p.goto(`${BASE}${PATHNAME}`, { waitUntil: 'networkidle', timeout: 90000 })
await p.waitForFunction(() => window.__grState && window.__grState().pins > 0, { timeout: 60000 })
await p.waitForTimeout(8000)
const read = () => p.evaluate(() => {
const r = document.querySelector('gospel-resources-map').shadowRoot
return {
pins: window.__grState().rendered,
rows: r.querySelectorAll('.tabulator-row').length,
url: location.href,
chip: (r.querySelector('.gt-filter')?.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 100),
}
})
const before = await read()
if (!before.rows) fail('the composed tables rendered no rows at all, so a filter proves nothing.')
const idx = await p.evaluate(() => {
const f = window.__grMap.getSource('gr-pin-src')._data.features
const hit = f.find((x) => x.properties.tier === 2) || f[0]
return hit.properties.i
})
await p.evaluate((i) => window.__grOpenPin(i), idx)
await p.waitForTimeout(2000)
const clicked = await p.evaluate(() => {
const r = document.querySelector('gospel-resources-map').shadowRoot
const btn = [...r.querySelectorAll('.gm-tablink')].find((e) => /Every resource in/i.test(e.textContent))
if (!btn) return null
const tag = btn.tagName.toLowerCase()
btn.click()
return { tag, text: btn.textContent.trim().slice(0, 60) }
})
if (!clicked) fail('no "Every resource in ..." control in the group sheet.')
if (clicked.tag !== 'button') fail(`the control is a <${clicked.tag}>, not a <button> — composed, it must filter, not navigate.`)
await p.waitForTimeout(3000)
const after = await read()
console.log(` control : pins=${before.pins} rows=${before.rows}`)
console.log(` clicked : <${clicked.tag}> "${clicked.text}"`)
console.log(` after : pins=${after.pins} rows=${after.rows}`)
console.log(` filter : ${after.chip || '(none)'}`)
if (after.url !== before.url) fail(`it NAVIGATED (${before.url} -> ${after.url}); the map the reader positioned is gone.`)
if (after.pins !== before.pins) fail(`the map lost pins on the click: ${before.pins} -> ${after.pins}.`)
if (!(after.rows < before.rows)) fail(`the table did not narrow: ${before.rows} -> ${after.rows}.`)
if (after.rows === 0) fail('the table emptied rather than filtered — subject had no resources, so this proves nothing.')
if (bad.length) fail(`${bad.length} failed request(s) or page error(s): ${[...new Set(bad)].slice(0, 3).join(' | ')}`)
console.log('COMPOSED-FILTERS: PASS — filters in place, keeps the map and the URL, no failed requests.')
} finally {
await browser?.close()
srv?.stop()
}

View file

@ -188,7 +188,8 @@
"check:shared-reached": "node internal/check-shared-component-reached-bundles.mjs",
"check:tree-weight": "node internal/check-published-tree-weight.mjs",
"check:i18n-never-translated": "node internal/check-i18n-never-translated.mjs",
"check:embed-base": "node internal/check-embed-base-resolution.mjs"
"check:embed-base": "node internal/check-embed-base-resolution.mjs",
"check:composed-filters": "node internal/check-gospel-composed-filters.mjs"
},
"//scripts": {
"_": "What each script does. These notes used to sit inside the command strings as trailing \"# …\" comments, which made the shell discard any arguments passed after \"--\" (so a gate given --url silently measured its hardcoded default port — possibly another agent's server). Keep notes here, never in a command.",
@ -282,7 +283,8 @@
"check:i18n-never-translated": "a key PRESENT in all 11 catalogs but IDENTICAL in all of them is copied, not translated. check:i18n-parity asserts presence; presence and translatedness are different properties. Carries an allowlist for keys correctly identical everywhere (pure format strings, brand names, ISO designations) — each with a reason, so the list records decisions rather than silencing findings.",
"check:embed-base": "the shared URL resolver, pinned by ARITHMETIC — no browser, no network, no served tree, so it cannot SKIP itself into irrelevance. It computes what a leaf's own \"../../assets\" means and asserts the resolver agrees. Caught nothing for fifteen ticks because it did not exist: resolveAssetsBase computed one segment too high, every local gate passed (the dev web-root has assets BOTH inside and beside the tree), and the byte-level publish gates went green on the broken publish.",
"check:map-unmount-race": "browser-free, sub-second: runs the shipped ensureEngineCSS with a null element (what an unmounted pane hands it) against a copy with its guard stripped; the guarded arm must resolve and the stripped arm must throw \"reading getRootNode\". By gospel-plugin, adopted by studio.",
"check:map-unmount-mid-init": "end-to-end, deterministic: holds mapbox-gl.js, opens a map plugin in the studio, switches away (destroying the pane) mid-initialise, then releases the script. Fails on any page error. REQUIRES --url. Exit 2 if the race was not staged."
"check:map-unmount-mid-init": "end-to-end, deterministic: holds mapbox-gl.js, opens a map plugin in the studio, switches away (destroying the pane) mid-initialise, then releases the script. Fails on any page error. REQUIRES --url. Exit 2 if the race was not staged.",
"check:composed-filters": "CELL C — the gospel map composed above its own tables. Asserts the composite keeps the reader INSIDE it: \"Every resource in <language>\" must be a <button> that filters in place, not an <a> that navigates away and throws out the map the reader just positioned. Checks the tag, that the URL did not change, that the table NARROWED (row count down but not to zero), that the pin count survived, and that nothing 4xx'd. Picks a tier-2 pin from the live source — the first version opened pin 0, a group with nothing found, and failed a working composite on an empty table. Browser gate: run under both rendezvous locks, product lock first."
},
"devDependencies": {
"@capacitor/android": "^7.6.9",