From 0907d96de27f5916d7dd1e14d0784b1fab8c8dab Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Thu, 1 Oct 2026 01:06:44 +0000 Subject: [PATCH] UPG-100: one digit system per sentence, and no raw error after a failed load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/check-batch-selection-export.mjs | 26 ++++++++++++ internal/check-upg-count.mjs | 40 +++++++++++++++---- .../my-upg-100-list/data/selection-export.js | 13 +++++- .../my-upg-100-list/data/upg-total.js | 26 ++++++++++-- .../my-upg-100-list/poster-wall.vue | 4 +- .../my-upg-100-list/upg-100-list.vue | 15 ++++--- 6 files changed, 103 insertions(+), 21 deletions(-) diff --git a/internal/check-batch-selection-export.mjs b/internal/check-batch-selection-export.mjs index 3f4b49fc8..41958427f 100644 --- a/internal/check-batch-selection-export.mjs +++ b/internal/check-batch-selection-export.mjs @@ -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), diff --git a/internal/check-upg-count.mjs b/internal/check-upg-count.mjs index f5e2aefaa..3a49c0030 100644 --- a/internal/check-upg-count.mjs +++ b/internal/check-upg-count.mjs @@ -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') diff --git a/plugin-designs/my-upg-100-list/data/selection-export.js b/plugin-designs/my-upg-100-list/data/selection-export.js index bdfe81ccf..ca430a6e2 100644 --- a/plugin-designs/my-upg-100-list/data/selection-export.js +++ b/plugin-designs/my-upg-100-list/data/selection-export.js @@ -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 }; diff --git a/plugin-designs/my-upg-100-list/data/upg-total.js b/plugin-designs/my-upg-100-list/data/upg-total.js index bb480d40d..128f4730e 100644 --- a/plugin-designs/my-upg-100-list/data/upg-total.js +++ b/plugin-designs/my-upg-100-list/data/upg-total.js @@ -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 } diff --git a/plugin-designs/my-upg-100-list/poster-wall.vue b/plugin-designs/my-upg-100-list/poster-wall.vue index e05822e9b..99b39403d 100644 --- a/plugin-designs/my-upg-100-list/poster-wall.vue +++ b/plugin-designs/my-upg-100-list/poster-wall.vue @@ -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 diff --git a/plugin-designs/my-upg-100-list/upg-100-list.vue b/plugin-designs/my-upg-100-list/upg-100-list.vue index 5d0a281cd..7ade167d6 100644 --- a/plugin-designs/my-upg-100-list/upg-100-list.vue +++ b/plugin-designs/my-upg-100-list/upg-100-list.vue @@ -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