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".
117 lines
8.2 KiB
JavaScript
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.')
|