264 lines
18 KiB
JavaScript
264 lines
18 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", 23 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 23 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 sends the WHOLE code set when a selection covers several (SPEC-DASHBOARD-KIT
|
|
* D1), and never the tier NAME. Before D1 the honest answer was to abstain, because the grid
|
|
* could hold one value; now that it takes a list, abstaining would leave a selection
|
|
* unexpressed that the receiving end can express. Asserted per tier: the joined set must
|
|
* select exactly the rows the map selected.
|
|
* E. no tier may produce a value that selects zero rows. That is the user-visible invariant and
|
|
* it is asserted directly, not inferred.
|
|
* F. a DIALECT reports a NAME and not a tree node id, and the emitter sends ONE event per
|
|
* settled selection rather than a burst. Both from rega's row 43 case 3.
|
|
*
|
|
* 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.`)
|
|
// D1: the SET is what the grid now filters on, so it has to select exactly the rows the map
|
|
// selected. Asserted per tier rather than on one example, because a join that dropped or
|
|
// duplicated a code would still look plausible on "Arabic".
|
|
const joined = codes.join('|')
|
|
const wants = new Set(joined.split('|'))
|
|
const gridRows = rows.filter((r) => wants.has(r.primary_language.value))
|
|
if (gridRows.length !== selected.length) {
|
|
fails.push(`C "${base}": the map selects ${selected.length} row(s) but the joined code set selects ${gridRows.length} — the two ends of the link disagree.`)
|
|
}
|
|
}
|
|
}
|
|
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, ${many} cover several and cross as the whole set (D1); every tier narrows the grid to the rows the map selected`)
|
|
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'] } }
|
|
// ★ UPDATED FOR D1. This used to require '' for a multi-code tier, and that was the right
|
|
// answer while the grid could hold one value: the map narrowed, the grid did not, and the
|
|
// reader could see both. Now that valueMatches() takes a LIST, abstaining would be leaving a
|
|
// selection unexpressed that the receiving end can express — so the whole code set crosses.
|
|
// What has NOT changed, and is asserted below, is that it never sends the tier NAME: "Arabic"
|
|
// matches no row reading "Arabic, Sudanese", and that is what emptied the grid originally.
|
|
const got = fn(many)
|
|
if (got !== 'apd|acm|ayl') fails.push(`D gridLanguage() returned ${JSON.stringify(got)} for a selection covering 3 codes — it must send all three, pipe-joined, so the grid narrows to the same rows the map did. (`|` and not `,`: a language label routinely contains a comma.)`)
|
|
if (/[A-Z]/.test(String(got))) fails.push(`D gridLanguage() returned ${JSON.stringify(got)}, which looks like a display NAME rather than codes — a tier name matches no row and empties the grid.`)
|
|
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 single code, or every code in the selection — never the tier name')
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── E. the dialect tier: a name, and one event ───────────────────────────────────────────────
|
|
// rega, row 43 case 3, on BOTH engines in BOTH locales: clicking "Arabic, Sudanese" emitted the
|
|
// right languageCode and narrowed the grid in only ONE of four runs, while the `language` slot read
|
|
// ", Afro-Asiatic__Arabic__Sudanese".
|
|
// THE NAME: selectedDialect.key is the TREE NODE ID (family__baseLang__dialect) and
|
|
// selectedLanguage is empty by the time a dialect is active, so the template produced a leading
|
|
// comma and an internal identifier. The labels the legend grouped are the right words, and they
|
|
// are already in the reader's locale.
|
|
// THE ONE EVENT: a dialect click changes several store values across ticks, so the watcher fired
|
|
// three or four times and this element dispatched a BURST for one click. The composite ignores
|
|
// anything arriving while it pushes state into its children — a guard it needs, because the grid
|
|
// re-emits during a push — and that window is 1.2s. Whether the settled event landed inside it
|
|
// was timing. Three runs lost it; one did not.
|
|
// Both are shapes, so both are asserted against the source and neither needs a browser.
|
|
{
|
|
const src = emitterSrc
|
|
const line = /language: ([^\n]*),\n/.exec(src.slice(src.indexOf('const detail = {')))
|
|
if (!line) fails.push('E research-map.vue: could not find the `language:` field of the map-filtered detail — this gate is blind to the name it reports.')
|
|
else {
|
|
if (/dialect\??\.key/.test(line[1])) fails.push(`E the dialect's display name is built from selectedDialect.key, which is the TREE NODE ID (family__baseLang__dialect), not a name: ${line[1].trim()}`)
|
|
if (!/dialectLabels/.test(line[1])) fails.push(`E the dialect's display name does not come from dialectLabels (the original API labels, already localized): ${line[1].trim()}`)
|
|
}
|
|
if (!/clearTimeout\(_emitTimer\)[\s\S]{0,200}setTimeout\(emitFilterState/.test(src)) {
|
|
fails.push('E research-map.vue no longer coalesces map-filtered — one click emits a BURST, and the composite drops whatever arrives inside its 1.2s push window, so whether the settled state lands is timing.')
|
|
} else {
|
|
// ★ THE WATCHER LINE SPECIFICALLY, not the file. A first version searched the whole source for
|
|
// `emitFilterStateSettled()`, which the helper's own declaration satisfies — so reverting the
|
|
// WATCHER to the raw emit left the gate green while the burst came back. A check that its own
|
|
// subject can satisfy is not a check.
|
|
const w = /watch\(\(\) => \[mapStore\?\.selectedRegion[\s\S]{0,240}?\)\n/.exec(src)
|
|
if (!w) fails.push('E could not find the emitter watcher — this gate is blind to whether the burst is coalesced.')
|
|
else if (!/emitFilterStateSettled\(\)/.test(w[0])) {
|
|
fails.push('E the settle helper exists but the WATCHER still calls the raw emit, so one click still dispatches a burst.')
|
|
} else notes.push('dialect: named from its labels, and one event per settled selection')
|
|
}
|
|
}
|
|
|
|
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.`)
|