UPG-100: one digit system per sentence, and no raw error after a failed load

The wall intro writes its total in the digits the translated sentence already uses
(countInSentence): ar shows ٢٬١٠٧ beside its own ٢٧ and ٩:١٤, while CLDR's bare
'ar' would have formatted 2,107. Locales whose sentence uses Western digits are
unchanged. The fetching status goes through the same helper.

A people-group load that fails without a catalogue key (the 30 s abort, a dropped
network) now reads as the stall text, "still loading, try again in a moment",
instead of the browser's own English ("signal is aborted without reason"). Keyed
errors keep their own text, and the failed load leaves the cache, so the next Copy
fetches again.

check:upg-count arm E renders the intro through each locale's catalogue and
requires one digit system, with a negative control on the old ar render. Its
separator scan now also reads fr U+202F and ru U+00A0. check:batch-selection-export
arm A5 aborts a real fetch and refuses a real connection; both must read as the
stall text, and the old path must still show the raw message.
This commit is contained in:
Doxa Maps 2026-10-01 01:06:44 +00:00
parent 95c3b0cd45
commit 0907d96de2
6 changed files with 103 additions and 21 deletions

View file

@ -127,6 +127,29 @@ ok(!disagree.length, `Copy's regroup warning disagrees with apply-grouping: ${di
'a NEVER-settling people-group load still lets Copy settle in time (and a value / a failure pass through)')
}
// ── A5. A FAILED load never shows the reader a raw browser message ─────────────────────────────────────
// The second Copy after the 30 s abort flashed "signal is aborted without reason". The self-test aborts a REAL fetch
// (the signal is aborted before the request starts, so nothing is sent) and makes a real one fail (a refused
// localhost port); both must read as the keyed stall text. A keyed error keeps its own text. The NEGATIVE CONTROL is
// the old path, errorText without asReaderError, which must still show the raw message, so this test can see the defect.
{
const { keyedError, errorText } = await import(pathToFileURL(P('library/i18n/keyedError.js')).href)
const en = JSON.parse(readFileSync(P('library/i18n/locales/en/upgMap.json'), 'utf8'))
const tr = (k, p = {}) => { const v = k.startsWith('upgMap.') ? en[k.slice(7)] : undefined; return v == null ? k : v.replace(/\{(\w+)\}/g, (m, x) => (p[x] != null ? String(p[x]) : m)) }
const stillLoading = () => keyedError('upgMap.peopleStillLoading', 'the people groups are still loading, try again in a moment')
const aborted = await fetch('http://127.0.0.1:9/', { signal: AbortSignal.abort() }).then(() => null, (e) => e)
const refused = await fetch('http://127.0.0.1:9/').then(() => null, (e) => e)
const keyed = keyedError('upgMap.selectionAdm1NoData', 'no people-group data for {names}', { names: 'Yunnan' })
const shown = (e) => errorText(X.asReaderError(e, stillLoading), tr)
const want = en.peopleStillLoading
ok(aborted && aborted.name === 'AbortError' && /abort/i.test(errorText(aborted, tr)),
`NEGATIVE CONTROL: an aborted fetch gave ${aborted && aborted.name} "${aborted && errorText(aborted, tr)}" on the old path, so the test cannot see the defect`,
'negative control: on the old path an aborted fetch shows the raw "' + (aborted ? errorText(aborted, tr) : '') + '"')
ok(shown(aborted) === want && refused && shown(refused) === want && shown(keyed) === tr('upgMap.selectionAdm1NoData', { names: 'Yunnan' }),
`a failed load reads: aborted "${shown(aborted)}", refused "${refused && shown(refused)}", keyed "${shown(keyed)}"`,
'an aborted or refused people-group fetch reads as the stall text; a keyed error keeps its own')
}
// ── B. the CSV ───────────────────────────────────────────────────────────────────────────────────
function parseCsv(s) { // RFC 4180: quoted fields, doubled quotes, CRLF records
const rows = []; let row = [], cell = '', q = false
@ -167,6 +190,9 @@ const wired = {
// India's states resolve through the served table (library/data/indiaStates.js), fetched only once a state is selected
'Copy waits at most COPY_WAIT_MS for the people groups': /settleWithin\(selectionPeople\(\), COPY_WAIT_MS, STALLED\)/.test(vue) && /if \(p === STALLED\) \{ note = t\('upgMap\.peopleStillLoading'\); return selectionClipboard\(sel, \{ color \}\); \}/.test(vue),
'the records fetch times out and leaves the cache': /setTimeout\(\(\) => ctrl\.abort\(\), RECORDS_TIMEOUT_MS\)/.test(vue) && /signal: ctrl \? ctrl\.signal : undefined/.test(vue),
'every failed load reads as the stall, never a raw message': /const readerError = \(e\) => asReaderError\(e, stillLoading\)/.test(vue)
&& /\}\)\(\)\.catch\(\(e\) => \{ throw readerError\(e\); \}\);/.test(vue)
&& /note = errorText\(readerError\(e\), t\)/.test(vue) && /csvFailed', \{ reason: errorText\(readerError\(e\), t\)/.test(vue),
'the regroup warning on Copy': /const warn = regroupable\(sel\) \? '' : t\('upgMap\.copyNotRegroupable'\)/.test(vue),
'India states detected from the served table': /import \{ loadIndiaStates \} from '@map\/data\/indiaStates\.js'/.test(vue)
&& /india\.byShapeName\.get\(t\.name\)/.test(vue) && /t\.kind === 'adm1'\) indiaStates\(\)\.catch/.test(vue),

View file

@ -8,7 +8,10 @@
// with any separator, Arabic-Indic, Devanagari. A built-in NEGATIVE CONTROL plants "2,106" and must be caught;
// C. upgMap.wallIntroA carries {n} in all 11 locales;
// D. the consumers pass the live total: poster-wall.vue reads upgMap.wallIntroA with n (not the frozen
// messages.upg100WallIntroA), and upg-100-list.vue passes no number literal to fetchingUupgs.
// messages.upg100WallIntroA), and upg-100-list.vue passes no number literal to fetchingUupgs;
// E. ONE SENTENCE, ONE DIGIT SYSTEM: the wall intro, rendered through each locale's real catalogue, writes its
// numbers in one digit system (ar: Arabic-Indic, so ٢٬١٠٧ beside ٢٧ and ٩:١٤). A NEGATIVE CONTROL renders ar the
// old way (plain Intl 'ar', which CLDR formats Western) and must be flagged as mixed.
import { readFileSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
@ -19,9 +22,9 @@ const fails = [], notes = []
const ok = (cond, msg, note) => { if (!cond) fails.push(msg); else if (note) notes.push(` ok ${note}`) }
const LANGS = ['en', 'es', 'fr', 'pt', 'de', 'it', 'ro', 'ru', 'ar', 'hi', 'zh']
let upgTotal, funnel
let upgTotal, formatCount, countInSentence, funnel
try {
;({ upgTotal } = await import(pathToFileURL(P('plugin-designs/my-upg-100-list/data/upg-total.js')).href))
;({ upgTotal, formatCount, countInSentence } = await import(pathToFileURL(P('plugin-designs/my-upg-100-list/data/upg-total.js')).href))
funnel = JSON.parse(readFileSync(P('data/people-groups/vision-funnel.json'), 'utf8'))
} catch (e) {
console.error(`UPG-COUNT: NOT MEASURED (exit 75) — an input did not load: ${e.message}`)
@ -35,12 +38,13 @@ ok(total > 1000, `the list's total reads as ${total} from vision-funnel.json`, `
// ── B: a digit-system-blind scan ──
const NATIVE = { '٠': 0, '١': 1, '٢': 2, '٣': 3, '٤': 4, '٥': 5, '٦': 6, '٧': 7, '٨': 8, '٩': 9, '०': 0, '१': 1, '२': 2, '३': 3, '४': 4, '५': 5, '६': 6, '७': 7, '८': 8, '९': 9 }
const numbersIn = (s) => String(s).replace(/[٠-٩०-९]/g, (d) => NATIVE[d])
.replace(/(\d)[,. ٬'](?=\d{3}\b)/g, '$1') // drop thousands separators
.replace(/(\d)[,.'\u0020\u00A0\u202F\u2009\u066C](?=\d{3}\b)/g, '$1') // drop thousands separators (fr U+202F, ru U+00A0, ar U+066C)
.match(/\d+/g) || []
const FORBIDDEN = new Set([String(total), '2106'])
const hasTotal = (s) => numbersIn(s).some((n) => FORBIDDEN.has(n))
ok(hasTotal('We did the same with the 2,106 unreached') && hasTotal('فعلنا المثل مع ٢١٠٦ شعوب') && hasTotal(`mit den ${String(total).replace(/(\d)(\d{3})$/, '$1.$2')} Gruppen`),
'NEGATIVE CONTROL: the scanner missed a planted total, so it cannot guard anything', 'negative control: a planted 2,106 / ٢١٠٦ / 2.107 is caught')
ok(hasTotal('We did the same with the 2,106 unreached') && hasTotal('فعلنا المثل مع ٢١٠٦ شعوب') && hasTotal(`mit den ${String(total).replace(/(\d)(\d{3})$/, '$1.$2')} Gruppen`)
&& hasTotal(`les ${new Intl.NumberFormat('fr').format(total)} groupes`) && hasTotal(`из ${new Intl.NumberFormat('ru').format(total)} групп`),
'NEGATIVE CONTROL: the scanner missed a planted total, so it cannot guard anything', 'negative control: a planted 2,106 / ٢١٠٦ / 2.107 / fr 2 107 / ru 2 107 is caught')
const leaks = []
for (const l of LANGS) {
const cat = JSON.parse(readFileSync(P('library/i18n/locales', l, 'upgMap.json'), 'utf8'))
@ -55,11 +59,31 @@ ok(!noN.length, `upgMap.wallIntroA has no {n} in: ${noN.join(', ')}`, 'the wall
// ── D ──
const wall = readFileSync(P('plugin-designs/my-upg-100-list/poster-wall.vue'), 'utf8')
const map = readFileSync(P('plugin-designs/my-upg-100-list/upg-100-list.vue'), 'utf8')
ok(/t\('upgMap\.wallIntroA', \{ n: formatCount\(upgTotal\(/.test(wall) && !/t\('messages\.upg100WallIntroA'\)/.test(wall),
ok(/countInSentence\(t, 'upgMap\.wallIntroA', upgTotal\(/.test(wall) && !/t\('messages\.upg100WallIntroA'\)/.test(wall),
'poster-wall.vue does not render the intro from upgMap.wallIntroA with the live total', 'the wall intro renders the live total')
ok(!/fetchingUupgs', \{ n: \d/.test(map) && /fetchingUupgs', \{ n: formatCount\(upgTotal\(/.test(map),
ok(!/fetchingUupgs', \{ n: \d/.test(map) && /countInSentence\(t, 'messages\.fetchingUupgs', upgTotal\(/.test(map),
'upg-100-list.vue passes a number literal to fetchingUupgs', 'the fetching status shows the live total')
// ── E: one sentence, one digit system ──
const SYSTEMS = [['latn', /[0-9]/], ['arab', /[\u0660-\u0669]/], ['arabext', /[\u06F0-\u06F9]/], ['deva', /[\u0966-\u096F]/], ['beng', /[\u09E6-\u09EF]/]]
const systemsIn = (s) => SYSTEMS.filter(([, re]) => re.test(String(s))).map(([nu]) => nu)
const trFor = (l) => {
const c = JSON.parse(readFileSync(P('library/i18n/locales', l, 'upgMap.json'), 'utf8'))
return (k, p = {}) => String(c[k.replace(/^upgMap\./, '')] ?? k).replace(/\{(\w+)\}/g, (m, x) => (p[x] != null ? String(p[x]) : m))
}
const arOld = trFor('ar')('upgMap.wallIntroA', { n: formatCount(total, 'ar') })
ok(systemsIn(arOld).length > 1, `NEGATIVE CONTROL: the old ar render reads as ONE digit system (${systemsIn(arOld).join('+')}), so this check cannot see the defect`,
`negative control: the old ar render (plain Intl 'ar' = "${formatCount(total, 'ar')}") is flagged as mixed (${systemsIn(arOld).join('+')})`)
const mixed = []
for (const l of LANGS) {
const s = countInSentence(trFor(l), 'upgMap.wallIntroA', total, l)
if (systemsIn(s).length > 1) mixed.push(`${l} ${systemsIn(s).join('+')}`)
if (!numbersIn(s).includes(String(total))) mixed.push(`${l} does not show ${total}`)
}
const arNew = countInSentence(trFor('ar'), 'upgMap.wallIntroA', total, 'ar')
ok(!mixed.length, `the wall intro: ${mixed.join('; ')}`,
`the wall intro shows ${total} in ONE digit system in all 11 locales (ar: "${(arNew.match(/[\u0660-\u0669٬]{3,}/) || ['?'])[0]}")`)
for (const n of notes) console.log(n)
if (fails.length) {
console.error('UPG-COUNT: FAIL')

View file

@ -85,6 +85,17 @@ export function settleWithin(promise, ms, marker) {
return Promise.race([promise, late]).finally(() => clearTimeout(timer));
}
/**
* The error a READER sees for a failed load. One that already carries a catalogue key (keyedError: an HTTP status,
* no service configured) passes through, because its text is already true and translated. Anything else (the
* request aborted by our own timeout, the network gone, a body that is not JSON) arrives as the browser's raw English
* ("signal is aborted without reason"), so it becomes `fallback(e)`, the keyed "still loading, try again" text. The
* failed load leaves the cache, so trying again really does fetch again.
*/
export function asReaderError(e, fallback) {
return e && e.i18nKey ? e : fallback(e);
}
/** A file name a partner can recognise: the selection's label, reduced to safe characters. */
export function selectionCsvFileName(label) {
const base = String(label || 'selection').normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
@ -92,4 +103,4 @@ export function selectionCsvFileName(label) {
return `upg-100-${base}.csv`;
}
export default { SELECTION_CSV_COLUMNS, csvCell, payloadName, regroupKind, regroupable, selectionCsv, selectionClipboard, settleWithin, selectionCsvFileName };
export default { SELECTION_CSV_COLUMNS, csvCell, payloadName, regroupKind, regroupable, selectionCsv, selectionClipboard, settleWithin, asReaderError, selectionCsvFileName };

View file

@ -10,9 +10,27 @@ export function upgTotal(funnel) {
return Number(steps.length ? steps[steps.length - 1].groups : 0) || 0
}
/** A count as the reader writes numbers (2,107 · 2.107 · 2 107 · ٢٬١٠٧). */
export function formatCount(n, locale) {
try { return new Intl.NumberFormat(locale || undefined).format(n) } catch (_) { return String(n) }
/** A count as the reader writes numbers (2,107 · 2.107 · 2 107), in `numberingSystem` when one is given (٢٬١٠٧). */
export function formatCount(n, locale, numberingSystem) {
try { return new Intl.NumberFormat(locale || undefined, numberingSystem ? { numberingSystem } : undefined).format(n) } catch (_) { return String(n) }
}
export default { upgTotal, formatCount }
// The non-Western digit system a text already writes its numbers in, or null (Western digits, or none).
const DIGIT_SYSTEMS = [['arab', /[\u0660-\u0669]/], ['arabext', /[\u06F0-\u06F9]/], ['deva', /[\u0966-\u096F]/], ['beng', /[\u09E6-\u09EF]/]]
export function digitSystemOf(text) {
for (const [nu, re] of DIGIT_SYSTEMS) if (re.test(String(text || ''))) return nu
return null
}
// ★ ONE SENTENCE, ONE DIGIT SYSTEM. {n} is written in the digits the translated sentence already uses for its own
// numbers. CLDR's default for a bare `ar` is Western (Intl.NumberFormat('ar') gives "2,107"), but the ar wall intro
// writes "٢٧" and "٩:١٤" in Arabic-Indic, so a plain locale format would put "2,107" between them. The sentence is
// rendered once with an empty slot to read its digits, then with the count. Locales whose sentence uses Western
// digits (or none) format exactly as before.
// `tr` is the caller's t(); passing it in keeps this module pure for the node gate.
export function countInSentence(tr, key, n, locale, params = {}) {
const bare = tr(key, { ...params, n: '' })
return tr(key, { ...params, n: formatCount(n, locale, digitSystemOf(bare)) })
}
export default { upgTotal, formatCount, digitSystemOf, countInSentence }

View file

@ -48,7 +48,7 @@ import { resolveAssetsBase } from '@map/utils/embedBase.js'
import { getRuntimeLocale, detectLocale } from '@map/i18n/index.js'
// The list's total for the intro, from the funnel (never a number written into a string; see data/upg-total.js).
import visionFunnel from '../../data/people-groups/vision-funnel.json'
import { upgTotal, formatCount } from './data/upg-total.js'
import { upgTotal, countInSentence } from './data/upg-total.js'
// Named imports: only the list arrays ship in the bundle; the file's bookkeeping fields stay out.
import { zonesForList } from './data/zones-for-list.js'
// The SAME normalize+evaluate pipeline the sibling map profile uses to turn the live
@ -248,7 +248,7 @@ const intro = computed(() => {
// single legacy key is still the source of truth, so this renders identically before AND after: no
// flag day, and my landing does not have to be ordered against theirs.
// The total is {n}, formatted for the reader (it printed a stale 2,106 in all eleven languages).
const a = t('upgMap.wallIntroA', { n: formatCount(upgTotal(visionFunnel), getRuntimeLocale() || detectLocale()) })
const a = countInSentence(t, 'upgMap.wallIntroA', upgTotal(visionFunnel), getRuntimeLocale() || detectLocale())
const b = t('messages.upg100WallIntroEmph')
const c = t('messages.upg100WallIntroB')
const missing = (v, key) => !v || v === key

View file

@ -128,9 +128,9 @@ import { colorForZone } from './data/zone-colors.js'
// clicked tapestry feature carries shapeName (ADM1) OR iso3 (ADM0) and this maps
// either to { key, name, count } from admin-counts/membership (NEVER edits them).
import { resolveTerritory, buildCountryNameMap, formatClipboard, ADM1_COUNTS, ISO3_NAME } from './data/territory-counts.js'
import { selectionCsv, selectionClipboard, selectionCsvFileName, regroupable, settleWithin } from './data/selection-export.js'
import { selectionCsv, selectionClipboard, selectionCsvFileName, regroupable, settleWithin, asReaderError } from './data/selection-export.js'
import visionFunnel from '../../data/people-groups/vision-funnel.json'
import { upgTotal, formatCount } from './data/upg-total.js'
import { upgTotal, countInSentence } from './data/upg-total.js'
// 09b-search-bar §1(a-c): unified search index (PG + zone + territory) + the
// tokenized contains matcher. Pure data + pure fns; this profile builds the rows
// from the live records (PG), the legend leafZones (zone), and admin-counts +
@ -1830,6 +1830,9 @@ function selectedAdm1Names() {
let _selRecords = null; // { lang, promise }
const RECORDS_TIMEOUT_MS = 30000; // the people-group list: abort a request that has not answered by then
const COPY_WAIT_MS = 8000; // Copy waits this long for the people groups, then copies the territories alone
// Any load failure without a catalogue key (the timeout's abort, a dropped network) reads as the stall does.
const stillLoading = () => keyedError('upgMap.peopleStillLoading', 'the people groups are still loading, try again in a moment');
const readerError = (e) => asReaderError(e, stillLoading);
function selectionRecordsLoad() {
const lang = packetLang();
if (_selRecords && _selRecords.lang === lang) return _selRecords.promise;
@ -1844,7 +1847,7 @@ function selectionRecordsLoad() {
.finally(() => clearTimeout(timer));
if (!resp.ok) throw keyedError('wall.hud.fetchFailed', 'could not load the people groups ({status})', { status: resp.status });
return normalizeRecords(await applyPeopleGroupList(extractRecords(await resp.json()), profileConfig));
})();
})().catch((e) => { throw readerError(e); });
_selRecords = { lang, promise };
promise.catch(() => { if (_selRecords && _selRecords.promise === promise) _selRecords = null; });
return promise;
@ -1934,7 +1937,7 @@ function copySelection() {
if (p === STALLED) { note = t('upgMap.peopleStillLoading'); return selectionClipboard(sel, { color }); }
count = p.records.length; note = selectionNote(p.missing);
return selectionClipboard(sel, { color }, selectionCsv(p.records, p.from, { color }));
}, (e) => { note = errorText(e, t); return selectionClipboard(sel, { color }); });
}, (e) => { note = errorText(readerError(e), t); return selectionClipboard(sel, { color }); });
text.then((v) => { try { window.__UPG_LAST_CLIPBOARD__ = v } catch (_) {} });
// A selection apply-grouping cannot regroup in one go (a mix of kinds, or a legend zone) still copies; the
// flash says so, so a partner does not hand an agent a line it will refuse.
@ -1992,7 +1995,7 @@ async function exportSelectionCsv() {
const note = selectionNote(p.missing);
flashRef(copyFlash, t('upgMap.csvSaved', { count: p.records.length }) + (note ? ' · ' + note.slice(0, 90) : ''), note ? 6000 : 3000);
} catch (e) {
flashRef(copyFlash, t('upgMap.csvFailed', { reason: errorText(e, t).slice(0, 90) }), 5000);
flashRef(copyFlash, t('upgMap.csvFailed', { reason: errorText(readerError(e), t).slice(0, 90) }), 5000);
} finally { batchBusy.value = false; busyAction.value = ''; }
}
@ -2753,7 +2756,7 @@ onMounted(async () => {
// The pins need none of it: they are built from `records` (lat/lng) and slugToZone, and the
// selector evaluate() at the pin build maps slugs to zone keys without touching any geometry.
// 68 KB, arriving at ~2.9 s cold.
status.value = t('messages.fetchingUupgs', { n: formatCount(upgTotal(visionFunnel), packetLang()) }) // the funnel's total, never a literal
status.value = countInSentence(t, 'messages.fetchingUupgs', upgTotal(visionFunnel), packetLang()) // the funnel's total, never a literal
const records = normalizeRecords(await applyPeopleGroupList(extractRecords(await recordsPromise), profileConfig))
// 09b shift-select §1(b): build iso3→display-name from the LIVE records so an
// ADM0 country click resolves to the exact label the legend shows (the ADM0