"Arabic" emptied the grid, because no people group speaks "Arabic"
rega, on MapLibre, in BOTH locales: Languages > Language > "Arabic" sent
map-filtered with {language:"Arabic", languageCode:""}, the composite handed the
grid an empty code, and 2,107 rows became 0. An empty grid reads as "no
results" — the worst shape a wiring bug can take, because the reader believes
the data.
ONE LINE, AND IT WAS MINE. The emitter looked for a row whose language LABEL
equalled the selected name. A language tier's label is not a label any row
carries: the legend 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, every one carrying primary_language as {value,label} — there is NO
row whose label is "Arabic". There is "Arabic, Sudanese", "Arabic, Levantine",
"Arabic, Gulf", twenty-two codes in all. The exact match could never hit, in any
locale, which is why it failed identically in en and ar and why the locale fix
that prompted this code was right about the disease and wrong about this line.
This is the derived-value trap one tier down from where I last wrote it up: a
value that crosses a component boundary has to exist on a row. "Arabic" is a
grouping the legend invented; primary_language.label is a field.
So the rows are now matched the way the legend's own node matches pins — base,
"base," prefix, " base" suffix — rather than by a fourth hand-typed spelling of
a rule that already exists in two places. 61 tiers resolved an empty code
before; "Arabic" was one of them.
AND ONE CODE ONLY WHEN THERE IS ONE. "Arabic" means 22 ISO codes, so no single
value expresses it. Emitting one would filter the grid to a fraction of the
selection while looking correct; emitting the tier NAME is what showed zero. The
honest answer is to emit nothing for that axis and leave the grid unfiltered —
the map is narrowed, the grid is not, and the reader can see both. languageCodes
carries the whole set alongside, so this becomes a real narrowing the day
filterSet goes multi-value (SPEC-DASHBOARD-KIT D1) rather than a rewrite.
Measured on the fixture: 764 tiers now resolve one code and filter the grid; 41
cover several and abstain.
A DIALECT IS A LANGUAGE SELECTION TOO, and it emitted nothing at all — the
watcher never observed selectedDialect, so drilling from "Arabic" to "Arabic,
Sudanese" changed the map and left the grid reporting the tier above. Its
originalLabels are exact API labels, so it is the one unambiguously resolvable
case.
check:language-code-link, headless, no network at run time. It LIFTS the
emitter's match rule and the composite's gridLanguage() out of the shipping
source rather than carrying its own copy — a gate with its own copy of the rule
passes while the product is broken, which is exactly how this survived a green
check:linked-vocabulary. It proves the emitter's predicate and the legend's
filter expression agree on every (label, tier) pair, that every tier selects at
least one row, and that an ambiguous tier narrows nothing. The fixture is the
864 distinct label/value pairs from the live source, deduplicated rather than
sampled so the counts above are about the whole set. Four mutants; reverting the
emitter to the exact-label rule reproduces rega's finding exactly — 61 tiers
selecting zero rows, "Arabic" named first.
STUDIO: needs a rebuild of doxa-research-map and research-map-plus-grid.
This commit is contained in:
parent
f42daf2802
commit
f3fcce9386
5 changed files with 308 additions and 17 deletions
|
|
@ -99,6 +99,7 @@
|
|||
"check:i18n-treeshake": "node internal/check-i18n-treeshake.mjs",
|
||||
"check:kpi-unit": "node internal/check-kpi-unit-once.mjs",
|
||||
"check:label-joins": "node internal/check-label-joins.mjs",
|
||||
"check:language-code-link": "node plugin-designs/doxa-research-map/scripts/check-language-code-link.mjs",
|
||||
"check:leaf-engine-per-provider": "node internal/check-leaf-engine-per-provider.mjs",
|
||||
"check:leaf-english-leaks": "node internal/check-leaf-english-leaks.mjs",
|
||||
"check:leaf-english-leaks:self-test": "node internal/check-leaf-english-leaks.mjs --self-test",
|
||||
|
|
@ -310,6 +311,7 @@
|
|||
"check:host-locale-contract": "a HOST's profile-config.locale reaches every leaf: each leaf page is loaded with ONLY \"locale\":\"ar\" added to its profile-config (no ?lang=, the page keeps lang=\"en\") and must draw at least half the Arabic text the same page draws at ?lang=ar. regions-dashboard/regions-list is the positive control (NOT MEASURED if it does not enter Arabic). Guards ProfileLoader's inject('appI18n'): a bundle that never provides appI18n silently ignores the host. --dir <tree> for a before/after, --only a/b,c/d to narrow. Through browser-slot.sh.",
|
||||
"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:i18n-treeshake": "each built bundle carries every translation area its code asks for: every area-prefixed key literal in <bundle>.js belongs to an area the build included (the build narrows library/i18n's catalogue glob per bundle, internal/i18n-areas.mjs). Headless.",
|
||||
"check:language-code-link": "the map's language selection reaches the grid as a locale-stable CODE, or narrows nothing at all. rega measured Languages > Language > \"Arabic\" taking the grid 2,107 -> 0 in BOTH locales: the emitter looked for a row whose language LABEL equalled the selected name, but a language tier's label is derived by splitting the API label at the comma, and no row reads \"Arabic\" - they read \"Arabic, Sudanese\", \"Arabic, Levantine\", 22 codes in all. Lifts the emitter's match rule and the composite's gridLanguage() from the shipping source (never re-typed) and runs them against a committed fixture of the 864 distinct language label/value pairs: the emitter's rule must agree with the legend's own three-arm filter on every (label, tier) pair, every tier must select at least one row, a tier covering one code must emit it, a tier covering several must emit NONE and leave the axis unfiltered. No network, no browser; --refresh-fixture re-takes the fixture.",
|
||||
"check:leaf-engine-per-provider": "every leaf in the manifest at ?provider=maplibre makes ZERO requests to any *.mapbox.com host and a map leaf still draws; at its default every map leaf draws with the engine it drew with before (read from the canvas that drew). --baseline <tree> compares against another tree (e.g. main). Browser gate, @browser-slot-max-hold 2700.",
|
||||
"check:leaves-mount": "DID THE ELEMENT ACTUALLY MOUNT, on all five gospel leaves. Exists because a ReferenceError thrown during setup dies INSIDE the custom-element boundary: the page loads, every asset returns 200, pageerror fires zero times, and the element sits there with an empty shadow root. A healthy-looking dead page. This asks the cheap unambiguous question the rich gates are too slow to ask on every leaf — is anything there — in five steps that each name their own failure: tag defined, host present, shadowRoot attached, shadow root non-empty, visible text rendered. It does NOT trust the absence of console errors; zero errors is not the same as working. --poison for the negative test.",
|
||||
"check:legend-export-reachable": "the Export affordance must be reachable at BOTH widths. Selects a leaf row first (the control is gated on selectedNode) and opens the mobile sheet first (it starts closed). Asserts a REAL BOX, not CSS visibility — on mobile the elements report visibility:visible at 0x0. PASSES at both widths since the mobile sheet was given :export-enabled and :tabs. SKIPs when nothing is served.",
|
||||
|
|
|
|||
|
|
@ -2182,29 +2182,74 @@ function emitFilterState() {
|
|||
//
|
||||
// The NAME still goes out because this map's own legend selects by name; the CODE goes out for
|
||||
// anyone matching rows. Both halves get the form they can actually use.
|
||||
// ★ THE LOOKUP HAS TO MATCH THE WAY THE LEGEND BUILT THE LABEL (rega, 2026-09-26, main 49992a5d:
|
||||
// MapLibre, BOTH locales, Languages > Language > "Arabic" -> languageCode:"" -> grid 2,107 -> 0).
|
||||
//
|
||||
// WHAT I GOT WRONG. This found a row whose language LABEL equals the selected name with `===`.
|
||||
// A language node's label is NOT a label any row carries: useLanguageFamilyLegendData builds the
|
||||
// tree family > language > dialect, and the LANGUAGE tier's label is a BASE NAME derived by
|
||||
// splitting the API label at the comma. MEASURED against the live source (2,107 rows, all 2,107
|
||||
// carrying primary_language as {value,label}): there is NO row whose label is "Arabic". What
|
||||
// exists is "Arabic, Sudanese", "Arabic, Levantine", "Arabic, Gulf", ... So the exact match could
|
||||
// never hit, in any locale — which is why rega saw it fail identically in en and ar, and why the
|
||||
// locale fix that prompted this code was right about the DISEASE and wrong about this line.
|
||||
//
|
||||
// This is the derived-value trap, one tier down from where I last wrote it up: a value that
|
||||
// crosses a component boundary must exist on a row, or the two ends are comparing different
|
||||
// things. "Arabic" is a GROUPING the legend invented; `primary_language.label` is a FIELD.
|
||||
//
|
||||
// So the rows are matched the way the legend's own node matches pins — base, comma-prefix,
|
||||
// space-suffix — rather than by a fourth hand-typed rule that can drift from the other three.
|
||||
const langName = mapStore?.selectedLanguage || ''
|
||||
// A dialect is a language selection too, and it was emitting nothing at all: the watcher below
|
||||
// did not observe selectedDialect, so drilling to the deepest tier left the grid on the previous
|
||||
// state. Its originalLabels ARE exact API labels, so it is the one unambiguous case.
|
||||
const dialect = mapStore?.selectedDialect || null
|
||||
const dialectLabels = Array.isArray(dialect?.originalLabels) ? dialect.originalLabels.map((s) => String(s).trim()).filter(Boolean) : []
|
||||
// Read the records here rather than assuming a `rows` in scope — check:no-free-identifiers caught
|
||||
// exactly that on its first outing against this very edit, which is the class of bug it is for.
|
||||
const langRows = mapData?.normalizedPeopleGroups?.value || []
|
||||
const langCode = langName
|
||||
? String(
|
||||
(langRows.find((r) => {
|
||||
const l = r && (r.language ?? r.primary_language)
|
||||
const lab = l && typeof l === 'object' ? l.label : l
|
||||
return String(lab || '').trim() === String(langName).trim()
|
||||
}) || {})._raw?.primary_language?.value || ''
|
||||
).trim()
|
||||
: ''
|
||||
const rowLangLabel = (r) => {
|
||||
const l = r && (r.language ?? r.primary_language)
|
||||
return String((l && typeof l === 'object' ? l.label : l) || '').trim()
|
||||
}
|
||||
// The legend node's own predicate, in JS. Keep these three arms in step with the `filter`
|
||||
// expression in useLanguageFamilyLegendData.js — they are the same rule, and a fourth spelling
|
||||
// of it is how this broke in the first place.
|
||||
const matchesLanguage = (lab, base) =>
|
||||
lab === base || lab.startsWith(base + ',') || lab.includes(' ' + base)
|
||||
const selectedRows = dialectLabels.length
|
||||
? langRows.filter((r) => dialectLabels.includes(rowLangLabel(r)))
|
||||
: (langName ? langRows.filter((r) => matchesLanguage(rowLangLabel(r), langName.trim())) : [])
|
||||
// ★ ONE CODE ONLY WHEN THERE IS ONE. "Arabic" covers many ISO 639-3 codes (apd, acm, ayl, ...),
|
||||
// so there is no single value that means it, and inventing one would filter the grid to a
|
||||
// fraction of the selection while looking like it worked. Emitting '' is the honest answer, and
|
||||
// the composite's job is then to NOT narrow on an axis it cannot express (fail OPEN) rather than
|
||||
// to narrow to nothing. The full set travels alongside for any consumer that can use a set.
|
||||
const langCodes = [...new Set(selectedRows
|
||||
.map((r) => String(r?._raw?.primary_language?.value || (r?.language && typeof r.language === 'object' ? r.language.value : '') || '').trim())
|
||||
.filter(Boolean))]
|
||||
const langCode = langCodes.length === 1 ? langCodes[0] : ''
|
||||
|
||||
const detail = {
|
||||
region: mapStore?.selectedRegion || '',
|
||||
religion: religionName,
|
||||
language: langName,
|
||||
// A dialect selection reports the dialect's own display name, so a host that shows the value
|
||||
// shows what the user clicked rather than its parent.
|
||||
language: dialectLabels.length ? (dialect?.key ? `${langName}, ${dialect.key}` : langName) : langName,
|
||||
languageCode: langCode,
|
||||
// ADDITIVE: every code the selection covers, so a consumer with multi-value filtering can
|
||||
// narrow correctly where a single code cannot express the selection. Existing listeners that
|
||||
// only read `languageCode` are unaffected.
|
||||
languageCodes: langCodes,
|
||||
}
|
||||
host.dispatchEvent(new CustomEvent('map-filtered', { detail, bubbles: true, composed: true }))
|
||||
}
|
||||
watch(() => [mapStore?.selectedRegion, mapStore?.selectedLanguage, uiStore?.religionFilter],
|
||||
// selectedDialect joins the sources: it is the deepest tier of the Language tree and a genuine
|
||||
// language selection, but it was not observed here, so drilling from "Arabic" to "Arabic, Sudanese"
|
||||
// changed the map and left the grid reporting the tier above. `.key` rather than the object,
|
||||
// because the store replaces it wholesale and a deep watch here would re-emit on every re-render.
|
||||
watch(() => [mapStore?.selectedRegion, mapStore?.selectedLanguage, mapStore?.selectedDialect?.key, uiStore?.religionFilter],
|
||||
() => emitFilterState())
|
||||
|
||||
// ─── Universal-legend (LegendRows) → applyDimFilter bridge ───────────────────
|
||||
|
|
|
|||
|
|
@ -0,0 +1,205 @@
|
|||
#!/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.`)
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -236,7 +236,10 @@ const bundleSrc = (bundle, file) =>
|
|||
// legend selects by NAME; the grid matches rows by VALUE. Carrying one form would have meant the
|
||||
// link matching nothing the moment the grid's data followed the URL's language — and an empty
|
||||
// intersection reads as "no results", not as a broken wire.
|
||||
const linked = reactive({ region: { value: '', label: '' }, religion: '', language: { value: '', label: '' } })
|
||||
// `language.codes` is every ISO code the current language selection covers — one for a real
|
||||
// language, many for a legend TIER like "Arabic". It is what lets gridLanguage() tell "I can
|
||||
// express this" from "I cannot", instead of guessing from an empty code.
|
||||
const linked = reactive({ region: { value: '', label: '' }, religion: '', language: { value: '', label: '', codes: [] } })
|
||||
|
||||
// ★ WHAT THE LIST IS SHOWING, so the map can go there (BUILD-GRID-SELECTION-FLIES-THE-MAP).
|
||||
// The link ran one way: the map narrowed the grid, and the grid moved nothing. The grid already
|
||||
|
|
@ -254,7 +257,7 @@ const gridSlugs = ref([])
|
|||
function clearExcept(keep) {
|
||||
if (keep !== 'region' && (linked.region.value || linked.region.label)) { linked.region.value = ''; linked.region.label = '' }
|
||||
if (keep !== 'religion' && linked.religion) linked.religion = ''
|
||||
if (keep !== 'language' && (linked.language.value || linked.language.label)) { linked.language.value = ''; linked.language.label = '' }
|
||||
if (keep !== 'language' && (linked.language.value || linked.language.label)) { linked.language.value = ''; linked.language.label = ''; linked.language.codes = [] }
|
||||
}
|
||||
const slugify = (label) => String(label || '').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '')
|
||||
|
||||
|
|
@ -291,6 +294,9 @@ function adopt(next) {
|
|||
if (v !== linked.language.value || l !== linked.language.label) {
|
||||
linked.language.value = v
|
||||
linked.language.label = l
|
||||
// Every code the selection covers. The grid direction reports ONE real language, so this is
|
||||
// that one code; the map direction may report a grouping (see the note on the push below).
|
||||
linked.language.codes = Array.isArray(next.language.codes) ? next.language.codes : (v ? [v] : [])
|
||||
clearExcept('language')
|
||||
}
|
||||
return
|
||||
|
|
@ -331,11 +337,43 @@ function onMapFiltered(ev) {
|
|||
adopt({
|
||||
region: { value: label ? slugify(label) : '', label },
|
||||
religion: ev?.detail?.religion || '',
|
||||
// The map sends both: the name its legend selected by, and the locale-stable ISO code.
|
||||
language: { value: ev?.detail?.languageCode || '', label: ev?.detail?.language || '' },
|
||||
// The map sends three things: the name its legend selected by, the locale-stable ISO code when
|
||||
// there IS a single one, and every code the selection covers.
|
||||
language: {
|
||||
value: ev?.detail?.languageCode || '',
|
||||
label: ev?.detail?.language || '',
|
||||
codes: Array.isArray(ev?.detail?.languageCodes) ? ev.detail.languageCodes : [],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ★ AN AXIS THIS GRID CANNOT EXPRESS MUST NOT NARROW IT TO NOTHING (rega, 2026-09-26: Languages >
|
||||
// Language > "Arabic" took the grid 2,107 -> 0 in BOTH locales, reading as "no results").
|
||||
//
|
||||
// This line used to be `linked.language.value || linked.language.label`, and the fallback was the
|
||||
// trap rather than the safety net. When the map reports a language TIER — "Arabic" — there is no
|
||||
// single ISO code that means it (MEASURED on the live source: "Arabic" covers "Arabic, Sudanese",
|
||||
// "Arabic, Levantine", "Arabic, Gulf" ... under many different codes), so the code arrives empty
|
||||
// and the NAME was sent instead. The grid's valueMatches() normalises and compares whole values, so
|
||||
// "Arabic" matches no row that reads "Arabic, Sudanese" — an empty grid, indistinguishable from a
|
||||
// genuinely empty result.
|
||||
//
|
||||
// The three cases, and none of them may lie:
|
||||
// one code -> send it. Locale-stable, and the grid filters exactly the selection.
|
||||
// many codes -> send NOTHING and leave the axis unfiltered. The map is narrowed, the grid is not,
|
||||
// and the reader can see both — which is honest. Sending the tier NAME would show
|
||||
// zero rows; sending one of the codes would show a fraction while looking correct.
|
||||
// Both are worse than not narrowing on an axis we cannot yet express.
|
||||
// no codes -> nothing is selected; send the label if there is one (the grid->map direction
|
||||
// always carries a real, single language, so its label is a value the grid owns).
|
||||
// The `many` case closes properly when filterSet goes multi-value (SPEC-DASHBOARD-KIT D1): the grid
|
||||
// then takes the whole set and this becomes a real narrowing instead of an honest abstention.
|
||||
function gridLanguage() {
|
||||
const codes = Array.isArray(linked.language.codes) ? linked.language.codes : []
|
||||
if (codes.length > 1) return ''
|
||||
return linked.language.value || linked.language.label
|
||||
}
|
||||
|
||||
let rootEl = null
|
||||
|
||||
// ★ PUSH THE STATE ONTO THE MOUNTED CHILDREN, DON'T JUST RE-COMPUTE THE SECTIONS.
|
||||
|
|
@ -363,7 +401,7 @@ function pushToChildren() {
|
|||
// ★ THE CODE, NOT THE NAME. valueMatches compares the normalized value as well as the label,
|
||||
// so a stable ISO code matches in every locale while a display name only matches in the one it
|
||||
// was fetched in.
|
||||
grid.setAttribute('primary-language', linked.language.value || linked.language.label)
|
||||
grid.setAttribute('primary-language', gridLanguage())
|
||||
}
|
||||
}
|
||||
watch(linked, pushToChildren, { deep: true })
|
||||
|
|
@ -469,7 +507,7 @@ const sections = computed(() => [
|
|||
// reason); driving them from the composite is the same channel, not a new one.
|
||||
'wagf-region': linked.region.value,
|
||||
'religion-family': linked.religion,
|
||||
'primary-language': linked.language.value || linked.language.label,
|
||||
'primary-language': gridLanguage(),
|
||||
}
|
||||
}
|
||||
])
|
||||
|
|
|
|||
Loading…
Reference in a new issue