205 lines
13 KiB
JavaScript
205 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* check-language-code-link.mjs — the map's language selection must reach the grid, or narrow nothing.
|
|
*
|
|
* THE BUG (rega, 2026-09-26, main 49992a5d, MapLibre, BOTH locales). Languages > Language >
|
|
* "Arabic": the map emitted `map-filtered` with {language:"Arabic", languageCode:""}, the composite
|
|
* handed the grid an empty code, and the grid went 2,107 -> 0. An empty grid reads as "no results",
|
|
* which is the worst shape a wiring bug can take — the reader believes the data.
|
|
*
|
|
* THE CAUSE, and it is one line. The emitter looked for a row whose language LABEL === the selected
|
|
* name. A LANGUAGE NODE'S LABEL IS NOT A LABEL ANY ROW CARRIES: useLanguageFamilyLegendData builds
|
|
* family > language > dialect and derives the language tier by splitting the API label at the comma.
|
|
* MEASURED on the live source: 2,107 rows, all carrying primary_language as {value,label}, and NO
|
|
* row whose label is "Arabic" — what exists is "Arabic, Sudanese", "Arabic, Levantine", "Arabic,
|
|
* Gulf", 22 codes in all. The exact match could never hit, in any locale, which is why it failed
|
|
* identically in en and ar.
|
|
*
|
|
* ★ THE NUMBERS THIS GATE PINS, measured on the committed fixture (864 distinct label/value pairs,
|
|
* 805 base language tiers):
|
|
* OLD exact-label rule: 61 tiers resolved "" -> the grid went to zero. "Arabic" was one.
|
|
* NEW rule: 764 tiers resolve ONE code (the grid filters), 41 cover several and
|
|
* abstain honestly.
|
|
* The 41 are real ambiguity, not a bug: "Arabic" alone means 22 ISO codes, so no single value can
|
|
* express it. Sending one of them would show a fraction of the selection while looking correct;
|
|
* sending the tier NAME shows zero. Abstaining is the only option that does not lie, until
|
|
* filterSet goes multi-value (SPEC-DASHBOARD-KIT D1).
|
|
*
|
|
* ★ ONE APPROXIMATION, STATED RATHER THAN HIDDEN. The gate derives the tier set by splitting each
|
|
* label at the first comma. The legend derives `langKey` by a longer route (family lookup, suffix
|
|
* patterns), so the two sets are close but not provably identical — a tier the legend shows and
|
|
* this split does not would go unchecked. What IS exact is the part that broke: check B proves the
|
|
* emitter's predicate and the legend's filter agree on every (label, tier) pair, so the two ends
|
|
* cannot drift apart again whatever the tier set turns out to be.
|
|
*
|
|
* WHAT IT CHECKS — all of it against the SHIPPING SOURCE, lifted, never re-typed:
|
|
* A. the emitter's match rule exists and is the legend's three-arm rule (base / "base," prefix /
|
|
* " base" suffix), evaluated against the fixture.
|
|
* B. the legend's own filter expression still has those same three arms — because the ONE thing
|
|
* that caused this bug was two spellings of one rule drifting apart. A fourth spelling is the
|
|
* next bug, so the gate compares the two that exist.
|
|
* C. for every base tier: exactly one code -> a NON-EMPTY languageCode; several -> an EMPTY
|
|
* languageCode AND a populated languageCodes.
|
|
* D. the composite fails OPEN: gridLanguage() must return '' when the selection covers several
|
|
* codes. Returning the label there is what produced the empty grid.
|
|
* E. no tier may produce a value that selects zero rows. That is the user-visible invariant and
|
|
* it is asserted directly, not inferred.
|
|
*
|
|
* NO NETWORK, NO BROWSER (Playwright is rega's alone). The fixture is committed and was taken from
|
|
* the live source; refresh it with --refresh-fixture when the source's language set changes.
|
|
*
|
|
* NEGATIVE-TESTED 2026-09-26, each FAILS:
|
|
* the emitter's rule reverted to `lab === base` -> A, then C/E naming "Arabic"
|
|
* the comma-prefix arm dropped from the emitter -> A (disagrees with the legend)
|
|
* the legend's filter arms changed without the emitter -> B
|
|
* gridLanguage() reverted to `value || label` -> D
|
|
*/
|
|
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
|
|
import { resolve, dirname, join } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
const TAG = 'LANGUAGE-CODE-LINK'
|
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
const ROOT = resolve(HERE, '..', '..', '..')
|
|
const EMITTER = join(ROOT, 'plugin-designs/doxa-research-map/research-map.vue')
|
|
const COMPOSITE = join(ROOT, 'plugin-designs/research-map-plus-grid/research-map-plus-grid.vue')
|
|
const LEGEND = join(ROOT, 'library/composables/legends/useLanguageFamilyLegendData.js')
|
|
const FIXTURE = join(HERE, 'fixtures', 'language-codes.json')
|
|
|
|
const fails = []
|
|
const notes = []
|
|
|
|
// ── --refresh-fixture: the only thing here that touches the network ──────────────────────────
|
|
if (process.argv.includes('--refresh-fixture')) {
|
|
const sources = JSON.parse(readFileSync(join(ROOT, 'library/api/sources.json'), 'utf8'))
|
|
const cfg = sources.sources[sources.activeSource]
|
|
const url = `https://pray.doxa.life${cfg.endpoints.bulk}?fields=${cfg.defaultFields.join(',')}&lang=en`
|
|
const j = await (await fetch(url, { signal: AbortSignal.timeout(60000) })).json()
|
|
const rows = j.posts || j.data || []
|
|
// Deduplicate by the (label,value) PAIR — never sample. Both questions this fixture answers
|
|
// (does the exact label exist; how many codes does this tier cover) must be asked of the
|
|
// complete language set, or the counts above are about a sample and mean nothing.
|
|
const seen = new Set(); const keep = []
|
|
for (const r of rows) {
|
|
const l = r.primary_language
|
|
if (!l || !l.label || !l.value) continue
|
|
const k = l.label + '\u0000' + l.value
|
|
if (seen.has(k)) continue
|
|
seen.add(k)
|
|
keep.push({ primary_language: { value: l.value, label: l.label } })
|
|
}
|
|
writeFileSync(FIXTURE, JSON.stringify(keep) + '\n')
|
|
console.log(`${TAG}: fixture refreshed — ${rows.length} api rows -> ${keep.length} distinct (label,value) pairs.`)
|
|
process.exit(0)
|
|
}
|
|
|
|
if (!existsSync(FIXTURE)) { console.log(`${TAG}: SKIP — no fixture (run with --refresh-fixture).`); process.exit(0) }
|
|
const rows = JSON.parse(readFileSync(FIXTURE, 'utf8'))
|
|
const emitterSrc = readFileSync(EMITTER, 'utf8')
|
|
const compositeSrc = readFileSync(COMPOSITE, 'utf8')
|
|
const legendSrc = readFileSync(LEGEND, 'utf8')
|
|
|
|
// ── A. lift the emitter's match rule and run it ──────────────────────────────────────────────
|
|
// Lifted, not re-typed: a gate that carries its own copy of the rule passes while the product is
|
|
// broken, which is precisely how the bug above survived a green check:linked-vocabulary.
|
|
const mRule = /const matchesLanguage = \(lab, base\) =>\s*([\s\S]*?)\n\s*const selectedRows/.exec(emitterSrc)
|
|
let matchesLanguage = null
|
|
if (!mRule) {
|
|
fails.push('A research-map.vue: no `const matchesLanguage = (lab, base) =>` — the emitter\'s match rule moved or was renamed, so this gate is blind to the line that broke.')
|
|
} else {
|
|
try { matchesLanguage = new Function('lab', 'base', `return (${mRule[1].trim()})`) } catch (e) {
|
|
fails.push(`A research-map.vue: the lifted match rule does not evaluate (${e.message}).`)
|
|
}
|
|
}
|
|
|
|
// ── B. the legend's own filter must still be the same three arms ─────────────────────────────
|
|
// This is the drift guard. The bug was two spellings of one rule disagreeing; the fix mirrors the
|
|
// legend's expression in JS, so the gate's job is to notice when the original moves.
|
|
{
|
|
const node = /id:\s*`lang:\$\{fam\.key\}__\$\{langKey\}`[\s\S]*?filter: \[([\s\S]*?)\],\n\s*children: \[\],/.exec(legendSrc)
|
|
if (!node) {
|
|
fails.push('B useLanguageFamilyLegendData.js: could not find the `lang:` node\'s filter expression — the legend\'s own rule moved, and the emitter mirrors it.')
|
|
} else {
|
|
const f = node[1]
|
|
const arms = [
|
|
[/\['==', \['get', 'language'\], langKey\]/, 'exact match on the base name'],
|
|
[/\['==', \['slice', \['get', 'language'\], 0, langKey\.length \+ 1\], langKey \+ ','\]/, 'the "base," comma-prefix arm'],
|
|
[/\['in', ' ' \+ langKey, \['get', 'language'\]\]/, 'the " base" suffix-contains arm'],
|
|
]
|
|
for (const [re, what] of arms) {
|
|
if (!re.test(f)) fails.push(`B useLanguageFamilyLegendData.js: the language node's filter no longer has ${what}. research-map.vue's matchesLanguage() mirrors these three arms — change both together, or the map and the grid select different rows again.`)
|
|
}
|
|
if (matchesLanguage) {
|
|
// And prove the mirror on real data rather than on the text: the JS rule must agree with the
|
|
// expression's semantics for every label in the fixture.
|
|
const expr = (lab, base) => lab === base || lab.slice(0, base.length + 1) === base + ',' || lab.includes(' ' + base)
|
|
let disagreements = 0
|
|
const bases = new Set(rows.map((r) => String(r.primary_language.label).split(',')[0].trim()))
|
|
for (const base of bases) for (const r of rows) {
|
|
const lab = String(r.primary_language.label).trim()
|
|
if (expr(lab, base) !== !!matchesLanguage(lab, base)) disagreements++
|
|
}
|
|
if (disagreements) fails.push(`B the emitter's matchesLanguage() disagrees with the legend's filter on ${disagreements} (label, tier) pair(s).`)
|
|
else notes.push(`match rule: agrees with the legend's three-arm filter on every one of ${bases.size} tiers x ${rows.length} labels`)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── C/E. every tier resolves to a code, or abstains — and never to an empty grid ──────────────
|
|
if (matchesLanguage) {
|
|
const byBase = new Map()
|
|
for (const r of rows) {
|
|
const b = String(r.primary_language.label).split(',')[0].trim()
|
|
if (!byBase.has(b)) byBase.set(b, [])
|
|
byBase.get(b).push(r)
|
|
}
|
|
let single = 0, many = 0
|
|
const zeroSelecting = []
|
|
for (const [base, tierRows] of byBase) {
|
|
const selected = rows.filter((r) => matchesLanguage(String(r.primary_language.label).trim(), base))
|
|
// E: the tier the user clicked must select SOMETHING. A tier that selects nothing is the
|
|
// 2,107 -> 0 screen by another route.
|
|
if (!selected.length) { zeroSelecting.push(base); continue }
|
|
const codes = [...new Set(selected.map((r) => r.primary_language.value).filter(Boolean))]
|
|
const languageCode = codes.length === 1 ? codes[0] : ''
|
|
if (codes.length === 1) {
|
|
single++
|
|
if (!languageCode) fails.push(`C "${base}" covers exactly one code but resolved an empty languageCode.`)
|
|
} else {
|
|
many++
|
|
if (languageCode) fails.push(`C "${base}" covers ${codes.length} codes but still emitted a single languageCode — one of them would show a fraction of the selection while looking correct.`)
|
|
if (!codes.length) fails.push(`C "${base}" resolved no codes at all.`)
|
|
}
|
|
}
|
|
if (zeroSelecting.length) {
|
|
fails.push(`E ${zeroSelecting.length} language tier(s) select ZERO rows, so clicking them empties the grid: ${zeroSelecting.slice(0, 6).join(', ')}`)
|
|
}
|
|
notes.push(`tiers: ${byBase.size} — ${single} resolve one code (the grid filters), ${many} cover several (the axis is left open)`)
|
|
if (many === 0) fails.push('C no tier in the fixture covers several codes, so the ambiguous path is untested — the fixture was sampled rather than deduplicated.')
|
|
}
|
|
|
|
// ── D. the composite fails OPEN ──────────────────────────────────────────────────────────────
|
|
{
|
|
const g = /function gridLanguage\(\) \{([\s\S]*?)\n\}/.exec(compositeSrc)
|
|
if (!g) {
|
|
fails.push('D research-map-plus-grid.vue: no gridLanguage() — the composite is back to sending `value || label`, which hands the grid a tier NAME no row carries and empties it.')
|
|
} else {
|
|
let fn = null
|
|
try { fn = new Function('linked', `${g[1]}`) } catch (e) { fails.push(`D gridLanguage() does not evaluate (${e.message}).`) }
|
|
if (fn) {
|
|
const many = { language: { value: '', label: 'Arabic', codes: ['apd', 'acm', 'ayl'] } }
|
|
const one = { language: { value: 'ben', label: 'Bengali', codes: ['ben'] } }
|
|
if (fn(many) !== '') fails.push(`D gridLanguage() returned ${JSON.stringify(fn(many))} for a selection covering 3 codes — it must return '' and leave the axis unfiltered. Returning the label is what took the grid to 0.`)
|
|
if (fn(one) !== 'ben') fails.push(`D gridLanguage() returned ${JSON.stringify(fn(one))} for an unambiguous selection — it must send the single code.`)
|
|
if (!fails.some((f) => f.startsWith('D'))) notes.push('composite: gridLanguage() sends the code when there is one and abstains when there is not')
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const n of notes) console.log(' · ' + n)
|
|
if (fails.length) {
|
|
console.error(`\n${TAG}: FAIL`)
|
|
for (const f of fails) console.error(' ' + f)
|
|
process.exit(1)
|
|
}
|
|
console.log(`${TAG}: PASS — every language tier reaches the grid as a code, or honestly narrows nothing.`)
|