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