UPG-100 batch selection Copy: line 1 is a machine contract, so it carries the names apply-grouping resolves in every UI language (a country as its ISO3, a state as its shape name); the reader's names stay on line 2, in the HUD and the CSV. The gate runs apply-grouping's own dry run on an Arabic selection, with a negative control

This commit is contained in:
Doxa Maps 2026-09-30 21:19:02 +00:00
parent e7fe0504ef
commit 307feab55c
2 changed files with 48 additions and 12 deletions

View file

@ -25,10 +25,11 @@ const fails = []
const notes = []
const ok = (cond, msg, note) => { if (!cond) fails.push(msg); else if (note) notes.push(` ok ${note}`) }
let X, parsePayload
let X, parsePayload, runApply, formatClipboard
try {
X = await import(pathToFileURL(P('plugin-designs/my-upg-100-list/data/selection-export.js')).href)
;({ parsePayload } = await import(pathToFileURL(P('plugin-designs/my-upg-100-list/data/apply-grouping.mjs')).href))
;({ parsePayload, runApply } = await import(pathToFileURL(P('plugin-designs/my-upg-100-list/data/apply-grouping.mjs')).href))
;({ formatClipboard } = await import(pathToFileURL(P('plugin-designs/my-upg-100-list/data/territory-counts.js')).href))
} catch (e) {
console.error(`BATCH-SELECTION-EXPORT: NOT MEASURED (exit 75) — an input did not load: ${e.message}`)
process.exit(75)
@ -54,16 +55,37 @@ const text = X.selectionClipboard(selection, { color }, csv)
let parsed = null
try { parsed = parsePayload(text) } catch (e) { fails.push(`apply-grouping's parsePayload REFUSED the Copy text: ${e.message}`) }
if (parsed) {
ok(JSON.stringify(parsed.names) === JSON.stringify(selection.map((s) => s.name)),
`apply-grouping reads the names as ${JSON.stringify(parsed.names)}, expected ${JSON.stringify(selection.map((s) => s.name))}`,
'Copy\'s first line parses in apply-grouping: the 3 territory names, in selection order')
const machine = ['Mahārāshtra', 'Goa', 'NPL']
ok(JSON.stringify(parsed.names) === JSON.stringify(machine),
`apply-grouping reads the names as ${JSON.stringify(parsed.names)}, expected ${JSON.stringify(machine)}`,
'Copy\'s first line parses in apply-grouping: the 3 territories in machine names (a country as its ISO3), in order')
ok(parsed.total === 93, `apply-grouping reads the total as ${parsed.total}, expected 93`, 'the UUPG total (93) and the colour survive the CSV after them')
ok(parsed.hex === color, `apply-grouping reads the colour as ${parsed.hex}, expected ${color}`)
}
ok(text.split('\n')[0] === 'Mahārāshtra, Goa, Nepal — 93 UUPG · #e69f00', `Copy's first line is "${text.split('\n')[0]}"`)
ok(text.split('\n')[0] === 'Mahārāshtra, Goa, NPL — 93 UUPG · #e69f00', `Copy's first line is "${text.split('\n')[0]}"`)
ok(text.split('\n')[1] === 'Mahārāshtra 54, Goa 5, Nepal 34', `Copy's second line (the reader's names) is "${text.split('\n')[1]}"`)
ok(text.includes('\n\n' + csv), 'Copy does not carry the CSV after a blank line')
ok(X.selectionClipboard(selection, {}, '').split('\n').length === 2, 'Copy without people groups is not exactly the two territory lines')
// ── A2. apply-grouping RESOLVES line 1 in ANY UI language ───────────────────────────────────────────────
// parsePayload only splits the names; resolution happens later (upsertPayloadZone), so this runs the tool's own
// dry run. In Arabic the HUD shows "نيبال", and a line 1 carrying it was rejected ("unresolved territory name(s)").
// The NEGATIVE CONTROL proves this check can fail: the same selection formatted with the reader's names must be
// refused by the same dry run.
const arabic = [
{ key: 'adm0:NPL', name: 'نيبال', count: 34, kind: 'adm0' },
{ key: 'adm0:BTN', name: 'بوتان', count: 13, kind: 'adm0' },
]
const dry = (payloadText) => { try { const r = runApply({ payloadText, dry: true, quiet: true }); return r && r.ok !== false ? 'ok' : 'not ok' } catch (e) { return 'threw: ' + String(e.message || e).slice(0, 120) } }
const arText = X.selectionClipboard(arabic, { color: '#0072b2' })
ok(dry(arText) === 'ok', `apply-grouping's dry run REFUSES the Arabic Copy: ${dry(arText)} (line 1 "${arText.split('\n')[0]}")`,
'an ARABIC selection\'s Copy resolves in apply-grouping\'s own dry run (line 1 "NPL, BTN — 47 UUPG · #0072b2")')
ok(arText.split('\n')[1] === 'نيبال 34, بوتان 13', `the Arabic Copy's second line is "${arText.split('\n')[1]}", expected the reader's names`)
ok(dry(formatClipboard(arabic, { color: '#0072b2' })) !== 'ok', 'NEGATIVE CONTROL: a line 1 in the reader\'s Arabic names should be refused by the dry run, and was not: this check cannot fail',
'negative control: the same selection in the reader\'s names IS refused (the check can fail)')
const indiaText = X.selectionClipboard([{ key: 'adm1:Mahārāshtra', name: 'Mahārāshtra', count: 54, kind: 'adm1' }, { key: 'adm1:Goa', name: 'Goa', count: 5, kind: 'adm1' }], {})
ok(dry(indiaText) === 'ok', `apply-grouping's dry run refuses an India state selection: ${dry(indiaText)}`, 'an India state selection resolves too (shape names are canonical)')
// ── B. the CSV ───────────────────────────────────────────────────────────────────────────────────
function parseCsv(s) { // RFC 4180: quoted fields, doubled quotes, CRLF records
const rows = []; let row = [], cell = '', q = false

View file

@ -6,9 +6,9 @@
// the posters. Without the export the maps cannot be reproduced.
//
// THE CLIPBOARD TEXT, in this order:
// line 1 "Name1, Name2, … — {total} UUPG · #hex" formatClipboard's line: data/apply-grouping.mjs parses
// exactly this (names, total, colour), so it MUST stay first
// line 2 "Name1 c1, Name2 c2, …" the per-territory breakdown
// line 1 "NPL, BTN, … — {total} UUPG · #hex" formatClipboard's line in MACHINE names (payloadName):
// data/apply-grouping.mjs parses AND resolves it, so it MUST stay first
// line 2 "Nepal 34, Bhutan 13, …" the per-territory breakdown, in the reader's names
// blank line, then the SAME CSV that Export CSV downloads (when the people groups could be detected).
//
// THE CSV: one row per people group, header first, CRLF line ends. The cells follow the library's rules
@ -40,9 +40,23 @@ export function selectionCsv(records, territoriesBySlug = new Map(), opts = {})
return [SELECTION_CSV_COLUMNS.join(','), ...rows].join('\r\n');
}
/** The clipboard text: apply-grouping's two lines, then (when present) the CSV after a blank line. */
// ★ LINE 1 IS A MACHINE CONTRACT: data/apply-grouping.mjs resolves every name in it, so it carries the names that
// tool resolves in EVERY UI language, never the reader's translation (an Arabic "نيبال, بوتان" was rejected:
// "unresolved territory name(s)"). A country is its ISO3 code, the only country form apply-grouping resolves for
// every country (its friendly-name table has ~15); a state or province is its shape name, already canonical; a
// zone keeps its name. The reader's own names stay on line 2, in the HUD and in the CSV.
export function payloadName(t) {
if (t && t.kind === 'adm0') return String(t.key || '').replace(/^adm0:/, '') || t.name;
return t ? t.name : '';
}
/** The clipboard text: apply-grouping's line (machine names), the reader's breakdown, then the CSV after a blank line. */
export function selectionClipboard(selection, opts = {}, csv = '') {
const head = formatClipboard(selection, { color: opts && opts.color ? opts.color : '' });
const color = opts && opts.color ? opts.color : '';
const sel = Array.isArray(selection) ? selection : [];
const line1 = formatClipboard(sel.map((t) => ({ ...t, name: payloadName(t) })), { color }).split('\n')[0];
const line2 = formatClipboard(sel, { color }).split('\n')[1];
const head = line1 + '\n' + line2;
return csv ? head + '\n\n' + csv : head;
}
@ -53,4 +67,4 @@ export function selectionCsvFileName(label) {
return `upg-100-${base}.csv`;
}
export default { SELECTION_CSV_COLUMNS, csvCell, selectionCsv, selectionClipboard, selectionCsvFileName };
export default { SELECTION_CSV_COLUMNS, csvCell, payloadName, selectionCsv, selectionClipboard, selectionCsvFileName };