diff --git a/internal/check-batch-selection-export.mjs b/internal/check-batch-selection-export.mjs index ab675773f..bd42c99b9 100644 --- a/internal/check-batch-selection-export.mjs +++ b/internal/check-batch-selection-export.mjs @@ -112,6 +112,38 @@ ok(!noCountry.length && resolveTerritory({ shapeName: 'Mahārāshtra' }).country ok(!disagree.length, `Copy's regroup warning disagrees with apply-grouping: ${disagree.join('; ')}`, `the regroup warning matches apply-grouping's own verdict in all ${Object.keys(REGROUP_CASES).length} cases (India, China, countries, and the three it refuses)`) +// ── A6. A STATE-ONLY selection titles its Prayer list / Card deck ─────────────────────────────────────── +// Development threw "Reduce of empty array" for a state-only selection: the title named zones and countries only, so +// a selection of Gujarāt alone had NO names. On the REAL served table: Gujarāt alone is titled "Gujarāt"; a state + +// a country names both; a province with no table beside it is not named; Mizoram (no groups listed) alone gives the +// keyed "no people groups" text. NEGATIVE CONTROL: the old naming (zones + countries) gives [] and reduce() throws. +{ + const { keyedError, errorText } = await import(pathToFileURL(P('library/i18n/keyedError.js')).href) + const wallEn = JSON.parse(readFileSync(P('library/i18n/locales/en/wall.json'), 'utf8')) + const tr = (k) => k.split('.').slice(1).reduce((o, x) => (o == null ? o : o[x]), wallEn) ?? k + const states = JSON.parse(readFileSync(P('data/people-groups/india-states.json'), 'utf8')).states + const st = (shape) => states.find((x) => x.shapeName === shape) + const guj = st('Gujarāt'), miz = st('Mizoram') + const fromOf = (pairs) => { const m = new Map(); for (const [slugs, name] of pairs) for (const s of slugs) m.set(s, [...(m.get(s) || []), name]); return m } + const noRecords = () => keyedError('wall.hud.noRecords', 'that list has no people groups to print') + const join = (a, b) => `${a} + ${b}` + const T = (kind, name) => ({ kind, name }) + const alone = X.selectionTitleNames([], [T('adm1', 'Gujarāt')], fromOf([[guj.slugs, 'Gujarāt']]), noRecords) + const both = X.selectionTitleNames([], [T('adm0', 'Nepal'), T('adm1', 'Gujarāt')], fromOf([[['npl-a', 'npl-b'], 'Nepal'], [guj.slugs, 'Gujarāt']]), noRecords) + const withProvince = X.selectionTitleNames([], [T('adm1', 'Guizhou Province'), T('adm1', 'Gujarāt')], fromOf([[guj.slugs, 'Gujarāt']]), noRecords) + let empty = '' + try { X.selectionTitleNames([], [T('adm1', 'Mizoram')], fromOf([[miz.slugs, 'Mizoram']]), noRecords); empty = 'no throw' } catch (e) { empty = errorText(e, tr) } + let oldThrows = false + try { [].reduce(join) } catch (_) { oldThrows = true } + const oldNames = [...[], ...[T('adm1', 'Gujarāt')].filter((t) => t.kind === 'adm0').map((t) => t.name)] + ok(guj.count === 90 && miz.count === 0 && oldNames.length === 0 && oldThrows, + `NEGATIVE CONTROL: Gujarāt ${guj.count} / Mizoram ${miz.count} groups, the old naming gave ${JSON.stringify(oldNames)}`, + 'negative control: on the old naming a Gujarāt-only selection has no names, and reduce() throws') + ok(alone.length === 1 && alone.reduce(join) === 'Gujarāt' && both.join('|') === 'Nepal|Gujarāt' && withProvince.join('|') === 'Gujarāt' && empty === tr('wall.hud.noRecords'), + `titles: alone ${JSON.stringify(alone)}, with a country ${JSON.stringify(both)}, with a province ${JSON.stringify(withProvince)}, Mizoram alone "${empty}"`, + `a state-only selection is titled "Gujarāt"; a state + a country names both; a province with no table is not named; Mizoram alone reads "${empty}"`) +} + // ── B. the CSV ─────────────────────────────────────────────────────────────────────────────────── function parseCsv(s) { // RFC 4180: quoted fields, doubled quotes, CRLF records const rows = []; let row = [], cell = '', q = false @@ -161,6 +193,9 @@ const wired = { return !/toggleTerritory|toggleByProps|toggleZoneSelection/.test(body) })(), 'the regroup warning on Copy': /const warn = regroupable\(sel\) \? '' : t\('upgMap\.copyNotRegroupable'\)/.test(vue), + // The packet / card-deck title goes through selectionTitleNames (arm A6 proves it), with the keyed empty case. + 'the title names come from selectionTitleNames': /const names = selectionTitleNames\(zoneNames, selectedTerritories\.value, from,\s*\n\s*\(\) => keyedError\('wall\.hud\.noRecords'/.test(vue) + && !/const names = \[\.\.\.zoneNames, \.\.\.countryNames\]/.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/plugin-designs/my-upg-100-list/data/selection-export.js b/plugin-designs/my-upg-100-list/data/selection-export.js index b8c96d7f9..8578c0971 100644 --- a/plugin-designs/my-upg-100-list/data/selection-export.js +++ b/plugin-designs/my-upg-100-list/data/selection-export.js @@ -74,6 +74,22 @@ export function selectionClipboard(selection, opts = {}, csv = '') { return csv ? head + '\n\n' + csv : head; } +/** + * The names a selection's Prayer list and Card deck are titled by: its zones (already localized by the caller), its + * countries, and the India states and union territories that BROUGHT people (`from`: slug → the territory names + * that contain it). A province with no table, or a state with none listed, is not named as though it were printed. + * ★ THE STATES WERE MISSING, and a state-only selection then had NO names: reduce() threw "Reduce of empty array" + * at the reader. A selection that brought nobody throws `noRecords()` (the caller's keyed error) instead, so the + * list is never empty. + */ +export function selectionTitleNames(zoneNames, territories, from, noRecords) { + if (!from || !from.size) throw noRecords(); + const contributing = new Set([...from.values()].flat()); + const countries = territories.filter((t) => t.kind === 'adm0').map((t) => t.name); + const states = territories.filter((t) => t.kind === 'adm1' && contributing.has(t.name)).map((t) => t.name); + return [...zoneNames, ...countries, ...states]; +} + /** 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, '') @@ -81,4 +97,4 @@ export function selectionCsvFileName(label) { return `upg-100-${base}.csv`; } -export default { SELECTION_CSV_COLUMNS, csvCell, payloadName, regroupKind, regroupable, selectionCsv, selectionClipboard, selectionCsvFileName }; +export default { SELECTION_CSV_COLUMNS, csvCell, payloadName, regroupKind, regroupable, selectionCsv, selectionClipboard, selectionTitleNames, selectionCsvFileName }; 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 035da7ba5..40f203db4 100644 --- a/plugin-designs/my-upg-100-list/upg-100-list.vue +++ b/plugin-designs/my-upg-100-list/upg-100-list.vue @@ -128,7 +128,7 @@ 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 } from './data/selection-export.js' +import { selectionCsv, selectionClipboard, selectionCsvFileName, regroupable, selectionTitleNames } from './data/selection-export.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 + @@ -1894,8 +1894,9 @@ async function selectionAsList() { const lists = selectedLists(); const { from } = await selectionPeople(); const zoneNames = (await Promise.all(lists.map(localizedZone))).map((l) => l.label || l.id); - const countryNames = selectedTerritories.value.filter((t) => t.kind === 'adm0').map((t) => t.name); - const names = [...zoneNames, ...countryNames]; + // Zones, countries AND the states that brought people (data/selection-export.js says why the states matter). + const names = selectionTitleNames(zoneNames, selectedTerritories.value, from, + () => keyedError('wall.hud.noRecords', 'that list has no people groups to print')); const S = await packetStrings(); const join2 = (a, b) => fillJoin(S.zone_join, { a, b }) ?? `${a} + ${b}`; const label = names.length <= 3