#!/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.')