doxa-maps/internal/check-describe-group.mjs
Doxa Maps 98065a34ab The packet's generated description, and the language name it prints was index-ordered
describeGroup(group, locale, opts) -> one localized sentence pair for a people
group's profile row: where they live, what they speak, and how reached they are.
Pure, exported, no refs or fetch; strings and the country resolver are injected.

The level is phrased from GSEC, a number, never from GSECDescription, which is
English prose the API hands us -- interpolating it would leave the widest column
of every non-English packet in English.

THE LANGUAGE FIELD WAS BOTH WRONG FIELDS AT ONCE.

primary_language is in normalize.js's NESTED_FIELDS, so the API's { value, label }
is flattened to its VALUE: an ISO 639-3 code. Reading it prints "and speak abf".
The name is in primary_language_label, and it arrives INDEX-ORDERED -- "Arabic,
Shihhi" for what a reader calls "Shihhi Arabic". 725 of the 12,379 rows carry an
inverted label, one row in seventeen.

The un-inversion is not a second copy: useLanguageFamilyLegendData already built
one to make a lookup key, so the split moves to library/utils/languageLabel.js and
both call it. Doing that surfaced why the two uses are not the same use -- 13 real
names carry a comma INSIDE a parenthetical ("Boano (Indonesia, Central Sulawesi)"),
and a naive split reverses those into "Central Sulawesi) Boano (Indonesia". As a
lookup key that garbage simply misses and the next candidate is tried; printed in a
sentence there is no such safety net. The shared split is top-level-only, and a
trailing parenthetical is hoisted past the reversal so "Naga, Tangkhul (India)"
reads "Tangkhul Naga (India)".

The legend's own behaviour is unchanged, checked rather than assumed: old and new
lookup compared over all 3,498 labels in langFamilyByLanguage plus every
LanguageName in the dataset, 0 differences.

countryName.js is NOT imported. It resolves only through the Vite '@map/' alias and
imports two JSON files without an import attribute -- fine in a bundle, fatal to a
node gate. Importing it would make this module unloadable by the test that proves it
right in eleven languages, so the renderer passes it in, and a missing injection
degrades down countryName.js's own ladder (localized -> English label -> alpha-3)
rather than blanking the clause.

check-describe-group asserts every GSEC level, each missing field, both locales,
truncation, the inverted label, the parenthetical that must not be shredded, and
that a bare language code is dropped rather than printed. Negative-tested: reading
primary_language, skipping the un-inversion, a naive split, and dropping the
parenthetical hoist each turn it red; restored, it is green.

Not yet wired into the renderer and not yet in gates:pre -- register with
"check:describe-group": "node internal/check-describe-group.mjs".
2026-09-28 13:56:06 +00:00

117 lines
8.2 KiB
JavaScript

#!/usr/bin/env node
/**
* check-describe-group — the packet's generated profile description, asserted per level, per missing
* field and per locale.
*
* WHY A TEST AND NOT A GLANCE: this string is generated for ~2,000 people groups in eleven languages
* and printed ~20 to a page. Nobody proofreads two thousand of them, so every failure mode here is
* silent — a missing field blanks a row, a wrong GSEC branch states the wrong fact about a real people,
* and an English fragment in an Arabic packet reads as a bug in the data rather than in the code.
*
* Headless, no network, no map. Strings are INJECTED, so this tests the function and not the catalogue.
* Exit 0 pass · 1 fail.
*/
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const { describeGroup } = await import(resolve(ROOT, 'library/packets/describeGroup.js'))
// Two locales is enough to prove the lookup: one that exists, one that does not (falls back to en).
const S = {
en: {
desc_where_both: 'The {people} live in {country} and speak {language}.',
desc_where_country: 'The {people} live in {country}.',
desc_where_language: 'The {people} speak {language}.',
desc_gsec_0: 'There are no known evangelical Christians among them.',
desc_gsec_1: 'Fewer than 2% are evangelical, and no one has been planting churches.',
desc_gsec_4: 'Between 2% and 5% are evangelical.',
desc_gsec_6: 'More than 10% are evangelical.',
},
ar: {
desc_where_both: 'يعيش {people} في {country} ويتحدثون {language}.',
desc_where_country: 'يعيش {people} في {country}.',
desc_gsec_1: 'أقل من 2% إنجيليون.',
},
}
// The country resolver is INJECTED (see describeGroup's header: countryName.js is Vite-alias-only and
// imports JSON without an attribute, so importing it here would make this gate unrunnable). A two-entry
// stub proves the wiring and the localization; the ladder below proves what happens without it.
const CN = (iso, lc) => ({ MYS: { en: 'Malaysia', ar: 'ماليزيا' } }[iso] || {})[lc] || ''
const O = { strings: S, countryName: CN }
const G = { name: 'Abai Sungai', country_code: 'MYS', primary_language_label: 'Abai Sungai', imb_gsec: 1 }
const fails = []
const eq = (what, got, want) => { if (got !== want) fails.push(`${what}\n got: ${JSON.stringify(got)}\n want: ${JSON.stringify(want)}`) }
const has = (what, got, sub) => { if (!String(got).includes(sub)) fails.push(`${what}: ${JSON.stringify(sub)} missing from ${JSON.stringify(got)}`) }
const hasNot = (what, got, sub) => { if (String(got).includes(sub)) fails.push(`${what}: ${JSON.stringify(sub)} SHOULD NOT be in ${JSON.stringify(got)}`) }
// 1. every GSEC level the catalogue defines resolves to ITS OWN sentence
for (const [g, frag] of [[0, 'no known evangelical'], [1, 'no one has been planting'], [4, 'Between 2% and 5%'], [6, 'More than 10%']]) {
has(`gsec ${g} picks its own level sentence`, describeGroup({ ...G, imb_gsec: g }, 'en', O), frag)
}
eq('gsec 1 in full', describeGroup(G, 'en', O),
'The Abai Sungai live in Malaysia and speak Abai Sungai. Fewer than 2% are evangelical, and no one has been planting churches.')
// 2. the country is resolved to the READER's language from its code, not left English
has('country localized from the code (ar)', describeGroup(G, 'ar', O), 'ماليزيا')
hasNot('and the English name is gone', describeGroup(G, 'ar', O), 'Malaysia')
// 3. a missing clause is DROPPED, never blanked — the row still describes the people
const noLang = { ...G }; delete noLang.primary_language_label
eq('language missing -> country sentence only', describeGroup(noLang, 'en', O),
'The Abai Sungai live in Malaysia. Fewer than 2% are evangelical, and no one has been planting churches.')
const noCountry = { ...G }; delete noCountry.country_code
eq('country missing -> language sentence only', describeGroup(noCountry, 'en', O),
'The Abai Sungai speak Abai Sungai. Fewer than 2% are evangelical, and no one has been planting churches.')
has('level survives when both clauses are gone', describeGroup({ imb_gsec: 4 }, 'en', O), 'Between 2% and 5%')
// 3b. THE LANGUAGE LABEL IS INDEX-ORDERED AND MUST BE UN-INVERTED BEFORE IT IS PRINTED.
// The API returns "Arabic, Shihhi"; the sentence has to read "Shihhi Arabic". 725 of the 12,379 rows
// carry an inverted label, so this is the single most-printed transformation in the description.
const shihhi = describeGroup({ ...G, name: 'Shihuh', primary_language_label: 'Arabic, Shihhi' }, 'en', O)
has('inverted label reads naturally', shihhi, 'speak Shihhi Arabic.')
hasNot('and the raw index ordering is NOT printed', shihhi, 'Arabic, Shihhi')
// A comma INSIDE a parenthetical is not an inversion marker — 13 real names would be shredded by a
// naive split ("Central Sulawesi) Boano (Indonesia"). These must pass through untouched.
has('parenthetical comma is left alone', describeGroup({ ...G, primary_language_label: 'Boano (Indonesia, Central Sulawesi)' }, 'en', O), 'speak Boano (Indonesia, Central Sulawesi).')
has('a trailing parenthetical stays at the end', describeGroup({ ...G, primary_language_label: 'Naga, Tangkhul (India)' }, 'en', O), 'speak Tangkhul Naga (India).')
has('an uninverted label is unchanged', describeGroup({ ...G, primary_language_label: 'Ainu (China)' }, 'en', O), 'speak Ainu (China).')
// 3c. ★ THE FLATTENED CODE IS NEVER PRINTED. normalize.js flattens primary_language to its .value, an
// ISO 639-3 code; reading that field would print "and speak abf". A group carrying ONLY the code must
// fall to the country sentence — dropping the clause, which is the rule, rather than printing a code.
const codeOnly = { ...G }; delete codeOnly.primary_language_label; codeOnly.primary_language = 'abf'
eq('a bare language code is dropped, not printed', describeGroup(codeOnly, 'en', O),
'The Abai Sungai live in Malaysia. Fewer than 2% are evangelical, and no one has been planting churches.')
// 3d. NO RESOLVER: the clause degrades down countryName.js's own ladder instead of blanking.
eq('no resolver, no label -> the alpha-3 code, and the level still states its fact', describeGroup(G, 'ar', { strings: S }),
'يعيش Abai Sungai في MYS ويتحدثون Abai Sungai. أقل من 2% إنجيليون.')
eq('no resolver, label present -> the label', describeGroup({ ...G, country_code_label: 'Malaysia' }, 'en', { strings: S }),
'The Abai Sungai live in Malaysia and speak Abai Sungai. Fewer than 2% are evangelical, and no one has been planting churches.')
has('resolver returning nothing falls through to the code', describeGroup(G, 'en', { strings: S, countryName: () => '' }), 'live in MYS')
// 4. missing or nonsense GSEC omits the level rather than inventing one or throwing
hasNot('no gsec -> no level sentence', describeGroup({ ...G, imb_gsec: undefined }, 'en', O), 'evangelical')
hasNot('gsec 9 is not a level', describeGroup({ ...G, imb_gsec: 9 }, 'en', O), 'evangelical')
eq('an empty group is an empty string, not a crash', describeGroup({}, 'en', O), '')
eq('a null group is an empty string', describeGroup(null, 'en', O), '')
// 5. a locale the catalogue lacks falls back to English rather than printing the key
has('unknown locale falls back to en', describeGroup(G, 'zz', O), 'The Abai Sungai live in')
has('partial locale falls back PER KEY', describeGroup(G, 'ar', O), 'أقل من 2%')
// 6. truncation cuts at a word boundary and only when asked
const long = describeGroup(G, 'en', O)
eq('no cap -> untouched', describeGroup(G, 'en', { ...O, maxChars: 0 }), long)
const cut = describeGroup(G, 'en', { ...O, maxChars: 40 })
if (cut.length > 40) fails.push(`maxChars 40 honoured: got ${cut.length}`)
if (!cut.endsWith('…')) fails.push(`truncation is marked: ${JSON.stringify(cut)}`)
if (/\s$/.test(cut.slice(0, -1))) fails.push(`truncation trims its tail: ${JSON.stringify(cut)}`)
if (fails.length) {
console.error('DESCRIBE-GROUP: FAIL')
for (const f of fails) console.error(` ✗ ${f}`)
process.exit(1)
}
console.log('DESCRIBE-GROUP: PASS — every GSEC level states its own fact, an index-ordered language label is un-inverted without shredding a parenthetical, a missing clause is dropped rather than blanked, the country is localized from its code, and an unknown locale falls back per key.')