doxa-maps/internal/check-batch-selection-export.mjs

228 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
// check-batch-selection-export — UPG-100's batch selection EXPORTS its selection (restored 2026-09-30).
//
// WHY IT MATTERS: when the people-group data changes, partners redraw the zones on the map, Copy or Export the
// selection, and hand it to an agent that regroups the zones (data/apply-grouping.mjs) and regenerates the
// posters. The copy/export path was removed once and the maps stopped being reproducible, so this pins it.
//
// Headless, no browser, no network. Asserts:
// A. Copy's FIRST line is exactly what data/apply-grouping.mjs parses: fed through its own parsePayload, the
// text returns the selected territory names, the UUPG total and the colour. The CSV after it must not
// change that.
// B. The CSV: the header is the declared columns; a cell with a comma, a quote or a line break survives a
// real CSV parse; a leading = + - @ is neutralised (a spreadsheet never runs a name as a formula); one row
// per people group, carrying the territories that contain it and the picked colour.
// C. The HUD wires it: the colour swatches + hex input, Copy and Export CSV buttons with their handlers, and
// batch mode prefetching the records so a tap copies inside its user activation.
// D. The nine new strings exist in all 11 locales, non-empty, and translated (not English) outside en.
import { readFileSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const P = (...a) => join(REPO, ...a)
const fails = []
const notes = []
const ok = (cond, msg, note) => { if (!cond) fails.push(msg); else if (note) notes.push(` ok ${note}`) }
let X, parsePayload, runApply, formatClipboard
try {
X = await import(pathToFileURL(P('plugin-designs/my-upg-100-list/data/selection-export.js')).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)
}
// ── A. the clipboard's first line round-trips through apply-grouping ─────────────────────────────
const selection = [
{ key: 'adm1:Mahārāshtra', name: 'Mahārāshtra', count: 54, kind: 'adm1' },
{ key: 'adm1:Goa', name: 'Goa', count: 5, kind: 'adm1' },
{ key: 'adm0:NPL', name: 'Nepal', count: 34, kind: 'adm0' },
]
const records = [
{ slug: 'agaria', name: 'Agaria', country_code: 'IND', population: 12000 },
{ slug: 'q-comma', name: 'Bhil, Tadvi "Hill"', country_code: 'IND', population: 3100 },
{ slug: 'q-formula', name: '=HYPERLINK("x")', country_code: 'NPL', population: 450 },
{ slug: 'q-newline', name: 'Line one\nLine two', country_code: 'NPL', population: 7 },
{ slug: 'q-tab', name: '\t=cmd', country_code: 'NPL', population: 1 },
]
const from = new Map([['agaria', ['Mahārāshtra', 'Goa']], ['q-comma', ['Goa']], ['q-formula', ['Nepal']], ['q-newline', ['Nepal']], ['q-tab', ['Nepal']]])
const color = '#e69f00'
const csv = X.selectionCsv(records, from, { color })
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) {
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, 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)')
// ── A3. the client's regroupable() agrees with apply-grouping's own verdict ─────────────────────────────
// Copy warns when its first line will not regroup (a mix of kinds, or a legend zone). The warning is only honest if
// regroupable() says exactly what the tool's dry run says, so each case is run through BOTH.
const T = (key, name, count, kind, country) => ({ key, name, count, kind, ...(country ? { country } : {}) })
const REGROUP_CASES = {
'India states': [T('adm1:Mahārāshtra', 'Mahārāshtra', 54, 'adm1', 'IND'), T('adm1:Goa', 'Goa', 5, 'adm1', 'IND')],
'a China province': [T('adm1:Gansu Province', 'Gansu Province', 3, 'adm1', 'CHN')],
'countries': [T('adm0:NPL', 'Nepal', 34, 'adm0'), T('adm0:BTN', 'Bhutan', 13, 'adm0')],
'a state + a country': [T('adm1:Goa', 'Goa', 5, 'adm1', 'IND'), T('adm0:NPL', 'Nepal', 34, 'adm0')],
'India + China': [T('adm1:Goa', 'Goa', 5, 'adm1', 'IND'), T('adm1:Gansu Province', 'Gansu Province', 3, 'adm1', 'CHN')],
'a legend zone': [T('zone:test', 'Test Zone', 12, 'zone')],
}
const disagree = []
for (const [name, sel] of Object.entries(REGROUP_CASES)) {
const tool = dry(X.selectionClipboard(sel, {})) === 'ok'
if (X.regroupable(sel) !== tool) disagree.push(`${name}: regroupable ${X.regroupable(sel)}, apply-grouping ${tool ? 'accepts' : 'refuses'}`)
}
// …and the COUNTRY an ADM1 territory needs for that verdict is always known: from the feature's own props on a
// map tap, else from the baked table (a props-only toggle once left it null, which read as a false warning).
const { resolveTerritory, ADM1_COUNTS, ADM1_COUNTRY } = await import(pathToFileURL(P('plugin-designs/my-upg-100-list/data/territory-counts.js')).href)
const noCountry = Object.keys(ADM1_COUNTS).filter((n) => !['IND', 'CHN'].includes(ADM1_COUNTRY[n]))
ok(!noCountry.length && resolveTerritory({ shapeName: 'Mahārāshtra' }).country === 'IND' && resolveTerritory({ shapeName: 'Guizhou Province' }).country === 'CHN',
`an ADM1 territory without a known country: ${noCountry.slice(0, 5).join(', ') || 'a props-only toggle'}`, 'every ADM1 name resolves its country, even from a props-only toggle')
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)`)
// ── A4. A STALLED people-group load can never hang Copy ─────────────────────────────────────────────────
// Copy races the people-group detection against COPY_WAIT_MS through settleWithin(); past it, it copies the territory
// lines alone (they need no network) and says why. The self-test: a promise that NEVER settles must still resolve to
// the marker in time; a value passes through; a failure still rejects (its own message is kept).
{
const MARK = {}, t0 = Date.now()
// The gate's own backstop: if settleWithin waited for ever, this reads 'GATE-TIMEOUT' and FAILS by name.
const stalled = await Promise.race([X.settleWithin(new Promise(() => {}), 60, MARK), new Promise((r) => setTimeout(() => r('GATE-TIMEOUT'), 2000))])
const quick = await X.settleWithin(Promise.resolve('v'), 1000, MARK)
const failed = await X.settleWithin(Promise.reject(new Error('down')), 1000, MARK).then(() => 'resolved', (e) => e.message)
ok(stalled === MARK && Date.now() - t0 < 1000 && quick === 'v' && failed === 'down',
`settleWithin: a stalled source gave ${stalled === MARK ? 'the marker' : JSON.stringify(stalled)}, a value gave ${quick}, a failure gave ${failed}`,
'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, bounded at 2 s); 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)
// Bounded: a host running the discard service ACCEPTS port 9 and would hang; a timeout is a failure too.
const refused = await fetch('http://127.0.0.1:9/', { signal: AbortSignal.timeout(2000) }).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
for (let i = 0; i < s.length; i++) {
const c = s[i]
if (q) { if (c === '"') { if (s[i + 1] === '"') { cell += '"'; i++ } else q = false } else cell += c }
else if (c === '"') q = true
else if (c === ',') { row.push(cell); cell = '' }
else if (c === '\r' && s[i + 1] === '\n') { row.push(cell); rows.push(row); row = []; cell = ''; i++ }
else cell += c
}
row.push(cell); rows.push(row)
return rows
}
const rows = parseCsv(csv)
ok(JSON.stringify(rows[0]) === JSON.stringify(X.SELECTION_CSV_COLUMNS), `the CSV header is ${JSON.stringify(rows[0])}`, `the CSV header: ${X.SELECTION_CSV_COLUMNS.join(',')}`)
ok(rows.length === 1 + records.length, `the CSV has ${rows.length - 1} data rows for ${records.length} people groups`)
const col = (r, k) => r[X.SELECTION_CSV_COLUMNS.indexOf(k)]
ok(col(rows[2], 'name') === 'Bhil, Tadvi "Hill"', `a name with a comma and quotes reads back as ${JSON.stringify(col(rows[2], 'name'))}`)
ok(col(rows[4], 'name') === 'Line one\nLine two', `a name with a line break reads back as ${JSON.stringify(col(rows[4], 'name'))}`,
'commas, quotes and line breaks survive a real CSV parse')
ok(col(rows[3], 'name') === '\'=HYPERLINK("x")', `a leading = reads back as ${JSON.stringify(col(rows[3], 'name'))}: a spreadsheet would run it`,
'a leading = + - @ is neutralised')
ok(col(rows[5], 'name') === "'\t=cmd", `a leading TAB reads back as ${JSON.stringify(col(rows[5], 'name'))}: OWASP lists it as a formula trigger`,
'a leading TAB or CR is neutralised too (OWASP)')
ok(col(rows[1], 'territories') === 'Mahārāshtra; Goa' && col(rows[1], 'color') === color && col(rows[1], 'slug') === 'agaria',
`row 1 is ${JSON.stringify(rows[1])}`, 'each row carries its slug, the territories that contain it, and the colour')
// ── C. the HUD wires it ──────────────────────────────────────────────────────────────────────────
const vue = readFileSync(P('plugin-designs/my-upg-100-list/upg-100-list.vue'), 'utf8')
const wired = {
'the colour swatches': /class="upg-swatch"[\s\S]{0,400}@click="setSelectionColor\(c\)"/.test(vue),
'the custom hex input': /class="upg-hex"[\s\S]{0,200}@change="onHexInput"/.test(vue),
'the Copy button': /@click="copySelection">\{\{ t\('upgMap\.copySelection'\) \}\}/.test(vue),
'the Export CSV button': /@click="exportSelectionCsv">/.test(vue) && vue.includes("t('upgMap.exportCsv')"),
'the prefetch on batch mode': /if \(next\) selectionRecordsLoad\(\)/.test(vue),
'one detection for every action': (vue.match(/await selectionPeople\(\)/g) || []).length >= 2 && /settleWithin\(selectionPeople\(\)/.test(vue),
// 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),
}
const missingWires = Object.entries(wired).filter(([, v]) => !v).map(([k]) => k)
ok(!missingWires.length, `the HUD is missing: ${missingWires.join(', ')}`, 'the HUD wires the swatches, hex input, Copy, Export CSV, the prefetch, and India\'s states from the served table')
// ── D. the strings, ×11 ──────────────────────────────────────────────────────────────────────────
const LANGS = ['en', 'es', 'fr', 'pt', 'de', 'it', 'ro', 'ru', 'ar', 'hi', 'zh']
const KEYS = {
upgMap: ['copySelection', 'exportCsv', 'copyNotRegroupable', 'peopleStillLoading', 'selectionColourGroup', 'selectionColour', 'customColour', 'copiedSelection', 'copiedTerritoriesOnly', 'csvSaved', 'csvFailed'] }
const cat = (l, f) => JSON.parse(readFileSync(P('library/i18n/locales', l, `${f}.json`), 'utf8'))
const bad = []
for (const [f, keys] of Object.entries(KEYS)) {
const en = cat('en', f)
for (const l of LANGS) {
const c = cat(l, f)
for (const k of keys) {
if (typeof c[k] !== 'string' || !c[k].trim()) bad.push(`${l} ${f}.${k} missing`)
else if (l !== 'en' && c[k] === en[k]) bad.push(`${l} ${f}.${k} is English`)
}
}
}
ok(!bad.length, `strings: ${bad.slice(0, 8).join('; ')}${bad.length > 8 ? ` (+${bad.length - 8})` : ''}`, `the ${KEYS.upgMap.length} new strings in all ${LANGS.length} locales, translated`)
for (const n of notes) console.log(n)
if (fails.length) {
console.error('BATCH-SELECTION-EXPORT: FAIL')
for (const f of fails) console.error(` ✗ ${f}`)
process.exit(1)
}
console.log('BATCH-SELECTION-EXPORT: PASS — Copy parses in apply-grouping, the CSV is well-formed and safe, the HUD wires both, and the strings are in all 11 locales.')