From d9d4cbd0da0392e8999a6ed2dbb7d18943519f2e Mon Sep 17 00:00:00 2001 From: doxa-maps-88655c70 <88627970+doxa-maps-88655c70@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:56:02 +0000 Subject: [PATCH] spa tab-order gate: the product honoured the Driver, the gate over-read him MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Red since the rail gained its three-section structure on 2026-09-23. The Driver said "cap-5 tab order (Big Picture, UPG-100, Prayer Progress, Gospel Resources)". That names an ORDER, not a row count, and nothing in it forbids a row between. The gate quoted him correctly and then asserted those four names at positions 1-4 of a flat rail. Measured at the rendered layer: the rail is Big Picture, Simple Research, Regions, UPG-100 List, Prayer Progress, Cities with Internet, Gospel Resources, plus five lab-badged Experimental rows when that folder is open. The Driver's four sit at 1/4/5/7, all present, relative order intact. The instruction is honoured. Provenance rather than timestamps was the tiebreaker. My first instinct was "source is newer, so the gate is stale" — but this gate cites a Driver instruction verbatim, so I checked what actually moved. The suspect commit turned out not to touch the main rail at all; the restructure was two days earlier. A second defect in the same gate: '.vtl-tab' matched nested elements, returning 35 hits for 7 real rows. The old code's "first four" were therefore all fragments of rows 1 and 2 — which is why it reported row 2 as "Simple Research" when Simple Research is genuinely row 2 of seven. Now keeps only outermost matches. The expectation is presence, plus tab 1 positional, plus relative order for the rest. Tab 1 stays positional because that is exactly what the Driver claimed: the landing surface. Mutation-tested both kinds — reversing the order fails naming the rows, expecting a different landing surface fails naming tab 1. PASS now prints the whole rail with the Driver's four starred, so a reader sees the rows between them. A gate printing only its own four would hide the structure that made the old assertion wrong. Still not wave-wired, and the reason is on the script: it FAILS rather than skips when the SPA is unreachable, deliberately — "a gate that cannot reach its subject has not passed, it has not run" — which is right standalone and incompatible with gates:post running on server-less checkouts. --- internal/check-spa-tab-order.mjs | 70 +++++++++++++++++++++++++------- package.json | 2 +- 2 files changed, 57 insertions(+), 15 deletions(-) diff --git a/internal/check-spa-tab-order.mjs b/internal/check-spa-tab-order.mjs index 82ac5a36e..2bb4a5703 100644 --- a/internal/check-spa-tab-order.mjs +++ b/internal/check-spa-tab-order.mjs @@ -37,6 +37,21 @@ const BASE = process.env.BASE_URL || 'http://127.0.0.1:4173/doxa-maps' // The Driver's sequence, verbatim from the open-work brief. Prefix-matched (case-insensitive, // whitespace-collapsed) so a row may carry its subtitle. +// The Driver's four, asserted as a RELATIVE ORDER rather than as positions 1-4. +// +// ⚠ THIS USED TO DEMAND A FOUR-ROW RAIL, and it had been FAILING ever since the rail gained its +// three-section structure (4c74bd7, 2026-09-23, "Restore three-section tab structure: +// Dashboards / Prayer Mobilization / Gospel Research"). MEASURED 2026-09-24: the rail renders +// SEVEN rows — +// Big Picture · Simple Research · Regions · UPG-100 List · Prayer Progress · +// Cities with Internet · Gospel Resources +// — and the Driver's four are ALL PRESENT, at 0/3/4/6, in their specified relative order. The +// product honours the instruction; this gate was asserting a shape the instruction never named. +// It quoted the Driver correctly and then over-read it: "(Big Picture, UPG-100, Prayer Progress, +// Gospel Resources)" names an ORDER, not a row count, and nothing there forbids a row between. +// +// So: assert what was actually specified. Tab 1 must still be Big Picture — that IS positional, +// it is the landing surface — and the other three must appear after it, in order. const EXPECTED = ['Big Picture', 'UPG-100', 'Prayer Progress', 'Gospel Resources'] const WALK = `(sel)=>{const o=[],s=new Set();const w=(r)=>{if(!r||s.has(r))return;s.add(r);try{o.push(...r.querySelectorAll(sel))}catch(e){};for(const e of r.querySelectorAll('*'))if(e.shadowRoot)w(e.shadowRoot)};w(document);return o}` @@ -59,8 +74,14 @@ try { // last remaining directory URL in internal/. await page.goto(`${BASE}/doxa-maps-spa/doxa-maps-spa/index.html`, { waitUntil: 'load', timeout: 45000 }) await page.waitForTimeout(8000) + // ⚠ DEDUPE: '.vtl-tab' matches NESTED elements, so each rendered row comes back ~5 times — + // measured 35 matches for 7 real rows. The old code took the first four of that list, which + // were all fragments of rows 1 and 2, and then reported them as rows 1-4. Keep only the + // OUTERMOST match per row (one that has no .vtl-tab ancestor), which is the row itself. rows = await page.evaluate( - `(${WALK})('.vtl-tab').map(t => (t.textContent || '').trim().replace(/\\s+/g, ' '))` + `(${WALK})('.vtl-tab') + .filter(t => !t.parentElement || !t.parentElement.closest('.vtl-tab')) + .map(t => (t.textContent || '').trim().replace(/\\s+/g, ' '))` ) } catch (e) { err = e.message.slice(0, 160) @@ -82,19 +103,34 @@ if (!rows.length) { } const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim() -const actual = rows.slice(0, EXPECTED.length) -const mismatches = EXPECTED.map((want, i) => { - const got = actual[i] || '(missing)' - return norm(got).startsWith(norm(want)) ? null : { i, want, got: got.slice(0, 52) } -}).filter(Boolean) +// Where each expected name lands in the RENDERED rail. -1 = absent. +const at = EXPECTED.map((want) => rows.findIndex((r) => norm(r).startsWith(norm(want)))) +const fails = [] -if (mismatches.length) { - console.error(`SPA-TAB-ORDER: FAIL — ${mismatches.length} of ${EXPECTED.length} rail rows are out of order.\n`) - EXPECTED.forEach((want, i) => { - const got = (actual[i] || '(missing)').slice(0, 52) - const ok = norm(got).startsWith(norm(want)) - console.error(` ${ok ? 'ok ' : 'FAIL'} ${i + 1}. want "${want}" got "${got}"`) - }) +// 1. PRESENCE. A name the Driver specified must be in the rail at all. +EXPECTED.forEach((want, i) => { + if (at[i] === -1) fails.push(`"${want}" is not in the rail at all`) +}) +// 2. TAB 1 IS POSITIONAL, and only tab 1. It is the landing surface — the first thing anyone +// opening the SPA sees — which is a claim about position, not merely about order. +if (at[0] > 0) { + fails.push(`tab 1 is "${(rows[0] || '').slice(0, 40)}", not "${EXPECTED[0]}" — the SPA opens ` + + 'somewhere other than the landing surface') +} +// 3. THE REST ARE RELATIVE. The instruction named an order, not a row count, so a row sitting +// between two of them is not a violation — a row OUT OF ORDER is. +for (let i = 1; i < EXPECTED.length; i++) { + if (at[i] !== -1 && at[i - 1] !== -1 && at[i] < at[i - 1]) { + fails.push(`"${EXPECTED[i]}" (row ${at[i] + 1}) comes before "${EXPECTED[i - 1]}" ` + + `(row ${at[i - 1] + 1}) — the Driver's order is reversed here`) + } +} + +if (fails.length) { + console.error(`SPA-TAB-ORDER: FAIL — ${fails.length} problem(s) with the Driver's rail order.\n`) + for (const f of fails) console.error(' - ' + f) + console.error('\n Rendered rail:') + rows.forEach((r, i) => console.error(` ${i + 1}. ${r.slice(0, 52)}`)) console.error('\n Tab 1 is the LANDING SURFACE — the first thing anyone opening the product SPA') console.error(' sees, and the opening step of the onboarding ramp. A reordered rail produces no') console.error(' error and a SPA that still works; it just opens somewhere else.') @@ -103,7 +139,13 @@ if (mismatches.length) { } console.log(`SPA-TAB-ORDER: PASS — the rail opens in the specified order (${rows.length} rows total).`) -EXPECTED.forEach((w, i) => console.log(` ${i + 1}. ${(actual[i] || '').slice(0, 56)}`)) +// Print the WHOLE rendered rail and mark the Driver's four, so a reader can see the rows that +// sit between them rather than only the names being asserted. A gate that prints just its own +// four would hide the very structure that made the old positional assertion wrong. +rows.forEach((r, i) => { + const k = at.indexOf(i) + console.log(` ${String(i + 1).padStart(2)}. ${k >= 0 ? '★' : ' '} ${r.slice(0, 54)}`) +}) console.log(' Asserted on the RENDERED rail rather than on NAV_MODEL: the array is edited for') console.log(' reasons unrelated to ordering (badges, sections, narrow-screen flags), and a source') console.log(' parse can pass while a rendering bug shows something else.') diff --git a/package.json b/package.json index 24f4e9d56..29e74aaee 100644 --- a/package.json +++ b/package.json @@ -145,7 +145,7 @@ "check:synced-css": "node internal/check-synced-css-copies.mjs", "check:composite-reg": "node internal/check-composite-registration.mjs", "check:filter-width": "node internal/check-filter-width-stable.mjs", - "check:spa-tab-order": "node internal/check-spa-tab-order.mjs", + "check:spa-tab-order": "node internal/check-spa-tab-order.mjs # the cap-5 SPA must open on Big Picture and keep the Driver's order (Big Picture, UPG-100, Prayer Progress, Gospel Resources) — asserted as PRESENCE + tab-1 + RELATIVE order on the RENDERED rail, because the rail gained three sections and now renders 12 rows with others between them. ⚠ NOT WAVE-WIRED: it FAILS rather than skips when the SPA is unreachable, deliberately (\"a gate that cannot reach its subject has not passed, it has not run\") — correct standalone, but gates:post runs on checkouts with no server. Needs `bun run serve` on 4173.", "check:backlog-status": "node internal/check-backlog-status-enum.mjs", "check:mjs-parse": "node internal/check-mjs-parses.mjs", "check:snapshot-guard": "node internal/check-snapshot-guard.mjs # every gospel-resources catalogue fetcher must write through scripts/_snapshot-guard.mjs, which refuses a write that loses more than a tenth of the previous snapshot. Written after a rate-limited FCBH run silently replaced 1,666 video scopes with 1,103. Static, sub-second. Self-test: `bun run check:snapshot-guard:poison` injects a raw write and the check must fail on it.",