doxa-maps/internal/check-describe-group.mjs
Doxa Maps 18bcaec446 Number({value:"1"}) is NaN, so the whole reachedness sentence vanished
The live API sends imb_gsec as { value, label }, not a number. describeGroup read it
with Number(), got NaN, matched no GSEC level, and dropped the level sentence — not an
error, not a fallback, simply absent, so the row described a people group nobody has a
reachedness figure for. upg100 hit it and fixed their own normalizer; that fixes their
caller and nobody else's, and every other consumer handing this function a RAW API
record lost the sentence the same silent way.

The unwrap belongs at the READ, because this function's contract is "an API or
normalized row" and both spellings are inside that contract. pick() now flattens
{ value, label } to its value, exactly as normalize.js:22 does.

THE LANGUAGE CLAUSE WAS GOING THE SAME WAY, FOR THE SAME REASON, and fixing only GSEC
would have left it. On a raw record the language NAME is at primary_language.label; the
field itself holds an ISO code. The existing read deliberately refuses that code -- it
would print "and speak abf" -- so a raw record simply had no language and fell to the
country-only sentence. labelOf() takes the display half of the same pair, and ONLY for
a nested object: consulting a flattened primary_language would print the code, which is
the defect the refusal exists to prevent. Same for country_code, whose nested label is
also the English fall-back when no resolver is injected.

A raw record now produces a string identical to the normalized one, which is the
assertion that makes the rest of it hard to regress.

Negative-tested: removing the unwrap, dropping either nested-label read, or letting
labelOf read bare strings (so the code prints) each turn it red; restored, green.
2026-09-28 15:52:47 +00:00

184 lines
14 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'
import { readFileSync } from 'node:fs'
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')
// 3e. ★ A RAW API RECORD MUST DESCRIBE THE SAME PEOPLE AS A NORMALIZED ONE. The live API sends several
// fields as { value, label } pairs, and Number({value:'1'}) is NaN — so on a raw record the GSEC sentence
// did not fall back, it silently VANISHED, and the row read as a people group with no reachedness figure
// at all. upg100 fixed their own normalizer; every other caller passing the raw record lost it the same
// way, which is why the unwrap belongs at the read. The language clause went the same way for the same
// reason: the NAME is at primary_language.label on a raw record.
const RAW = {
name: 'Abai Sungai',
country_code: { value: 'MYS', label: 'Malaysia' },
primary_language: { value: 'abf', label: 'Abai Sungai' },
imb_gsec: { value: '1', label: 'Fewer than 2% evangelical' },
}
eq('a raw record reads exactly like the normalized one', describeGroup(RAW, 'en', O), describeGroup(G, 'en', O))
has('the level sentence survives a nested imb_gsec', describeGroup(RAW, 'en', O), 'Fewer than 2% are evangelical')
has('the language NAME comes from the nested label, not the code', describeGroup(RAW, 'en', O), 'speak Abai Sungai.')
hasNot('and the nested code is never printed', describeGroup(RAW, 'en', O), 'abf')
has('a nested country_code still localizes from its value', describeGroup(RAW, 'ar', O), 'ماليزيا')
// The nested label is also the English fall-back when no resolver is injected — the same ladder, reached
// through the raw shape rather than through country_code_label.
has('no resolver -> the nested country label, not the bare code',
describeGroup(RAW, 'en', { strings: S }), 'live in Malaysia')
// An inverted name arrives inverted in the nested shape too, and must still be un-inverted.
has('a nested inverted label is un-inverted',
describeGroup({ ...RAW, primary_language: { value: 'ssh', label: 'Arabic, Shihhi' } }, 'en', O), 'speak Shihhi Arabic.')
// ...and the rule that a MISSING field drops its clause is unchanged by any of this.
eq('a nested pair with an empty label drops the clause rather than printing nothing',
describeGroup({ ...RAW, primary_language: { value: 'abf', label: '' } }, 'en', O),
'The Abai Sungai live in Malaysia. Fewer than 2% are evangelical, and no one has been planting churches.')
eq('a nested imb_gsec with no value drops the level, not the row',
describeGroup({ ...RAW, imb_gsec: { value: '', label: '' } }, 'en', O),
'The Abai Sungai live in Malaysia and speak Abai Sungai.')
// 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)}`)
// ── 7. THE CATALOGUE ARM ─────────────────────────────────────────────────────────────────────────
// ★ EVERYTHING ABOVE TESTS THE LOGIC AGAINST A FIXTURE, AND THAT IS THE RIGHT DESIGN — but it means
// this gate stayed GREEN while describeGroup returned "" for every group in the product, because the
// ten keys it needs (desc_where_both/_country/_language, desc_gsec_0..6) were not in the SHIPPING
// packet-strings.json. A green gate proved the function and said nothing about the catalogue. Wiring
// the profile table then would have blanked the wide description column on all 100 rows, with no
// error and no empty state — the same "returns nothing, looks like no data" shape as a fetch missing
// its selector field. So: run the REAL catalogue, and assert a non-empty sentence per GSEC level.
// en AND an RTL locale, because a per-key fallback can hide a missing block behind English.
const REAL = JSON.parse(readFileSync(resolve(ROOT, 'library/scripts/posters/packet-strings.json'), 'utf8'))
// ★ NON-EMPTY IS NOT ENOUGH, and a real defect proved it. imb_gsec arrives from the API as
// { value:'1', label:'…' }; it was missing from normalize.js's NESTED_FIELDS, so describeGroup saw
// an object, Number({value:'1'}) was NaN, and the LEVEL SENTENCE was dropped for every group — while
// the where-clause still rendered. A non-empty assertion passes that happily and the packet's widest
// column ships at half length. So each level asserts ITS OWN sentence is present, not merely that
// something is.
const WHERE_ONLY = describeGroup({ ...G, imb_gsec: null }, 'en', { strings: REAL, countryName: CN })
for (const locale of ['en', 'ar']) {
for (const gsec of [0, 1, 2, 3, 4, 5, 6]) {
const row = { ...G, imb_gsec: gsec }
const out = describeGroup(row, locale, { strings: REAL, countryName: CN })
if (!out) {
fails.push(`CATALOGUE: describeGroup renders nothing for GSEC ${gsec} in '${locale}' — the shipping packet-strings.json is missing its desc_* keys, so the packet's widest column would print empty`)
continue
}
// The level clause is what `out` has BEYOND the where-clause. If the two are the same length,
// the sentence for this level never appeared — a missing desc_gsec_N key, or a row whose
// imb_gsec did not survive normalisation.
const levelText = String(REAL[locale] && REAL[locale][`desc_gsec_${gsec}`] || REAL.en[`desc_gsec_${gsec}`] || '')
if (!levelText) fails.push(`CATALOGUE: desc_gsec_${gsec} is absent from the catalogue (checked '${locale}', then en)`)
else if (out.length <= WHERE_ONLY.length) fails.push(`CATALOGUE: GSEC ${gsec} in '${locale}' rendered only the where-clause — the level sentence is missing, so every profile row would print at half length`)
}
}
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, a raw { value, label } record describes the same people as a normalized one, 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.')