doxa-maps/internal/check-india-states.mjs

108 lines
8.7 KiB
JavaScript

#!/usr/bin/env node
// check-india-states — India's states-and-union-territories table (data/people-groups/india-states.json) stays
// TRUE to the stamping it is derived from, and the served copy is the pinned one.
//
// WHY: UPG-100's batch selection finds a state's people groups in it, and Country Posters builds one poster per
// state / union territory from it. When the people-group data changes, the stamping is redone
// (pip-stamp.mjs → admin-membership.json) and this table must follow; a table that silently kept the old
// membership would print confident, wrong posters. Headless, no network. Asserts:
// A. SHAPE: all 36 divisions of ISO 3166-2:IN, 28 'state' + 8 'union_territory', unique iso and id
// (india-<code>), each with a sane bbox inside India, count == slugs.length; Nagaland + Mizoram present
// even with no people groups (each consumer decides what an empty division shows).
// B. MEMBERSHIP: the union of every division's slugs == admin-membership.json's India slugs (855 today), and
// every slug sits in exactly ONE division.
// C. SHAPES MATCH: every shapeName is a feature of the served geometry (and of the full geometry it is cut from),
// so a poster can highlight its shape by the exact property.
// D. NO DRIFT: re-deriving the table from today's inputs gives the committed bytes, and the input sha384s
// recorded in meta are the files on disk — a changed stamping without a re-emit FAILS here.
// E. PIN: INDIA_STATES_SHA384 == sha384(the committed bytes) == sha384(the served copy in the tree); size ceiling.
import { readFileSync, existsSync } from 'node:fs'
import { createHash } from 'node:crypto'
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}`) }
const sha = (buf) => 'sha384-' + createHash('sha384').update(buf).digest('base64')
const TABLE = P('data/people-groups/india-states.json')
const SERVED = P('public/dx-maps/assets/people-groups/india-states.json')
const SIZE_CEILING = 24 * 1024
let raw, table, membership, emitter, pin, full, web
try {
raw = readFileSync(TABLE)
table = JSON.parse(raw)
membership = JSON.parse(readFileSync(P('plugin-designs/my-upg-100-list/data/admin-membership.json'), 'utf8'))
emitter = await import(pathToFileURL(P('plugin-designs/my-upg-100-list/data/emit-india-states.mjs')).href)
;({ INDIA_STATES_SHA384: pin } = await import(pathToFileURL(P('library/data/indiaStates.integrity.js')).href))
full = JSON.parse(readFileSync(P('data/boundaries/gb-IND-ADM1-full.geojson'), 'utf8'))
web = JSON.parse(readFileSync(P('data', table.meta.served_geometry), 'utf8'))
} catch (e) {
console.error(`INDIA-STATES: NOT MEASURED (exit 75) — an input did not load: ${e.message}`)
process.exit(75)
}
const states = table.states || []
// ── A. shape ─────────────────────────────────────────────────────────────────────────────────────
const isos = states.map((s) => s.iso), ids = states.map((s) => s.id)
const kinds = { state: 0, union_territory: 0 }
for (const s of states) kinds[s.kind] = (kinds[s.kind] || 0) + 1
ok(states.length === 36 && new Set(isos).size === 36 && isos.every((i) => emitter.IN_DIVISIONS[i]),
`the table has ${states.length} divisions (${new Set(isos).size} distinct ISO codes); ISO 3166-2:IN has 36`)
ok(kinds.state === 28 && kinds.union_territory === 8 && Object.keys(kinds).length === 2,
`kinds are ${JSON.stringify(kinds)}, expected 28 state + 8 union_territory`, 'all 36 divisions: 28 states + 8 union territories, unique ISO codes')
ok(new Set(ids).size === 36 && states.every((s) => s.id === 'india-' + s.iso.slice(3).toLowerCase()),
'ids are not the unique india-<code> form', 'ids are india-<code>, unique (wall cards, packet file names)')
const badBox = states.filter((s) => !(Array.isArray(s.bbox) && s.bbox.length === 4 && s.bbox[0] < s.bbox[2] && s.bbox[1] < s.bbox[3]
&& s.bbox[0] >= 67 && s.bbox[2] <= 98 && s.bbox[1] >= 6 && s.bbox[3] <= 38)).map((s) => s.iso)
ok(!badBox.length, `bboxes out of shape or outside India: ${badBox.join(', ')}`)
const badCount = states.filter((s) => !Array.isArray(s.slugs) || s.count !== s.slugs.length).map((s) => s.iso)
ok(!badCount.length, `count != slugs.length for ${badCount.join(', ')}`, 'every bbox sits inside India; every count is its slugs\' length')
ok(['IN-NL', 'IN-MZ'].every((i) => isos.includes(i)), 'Nagaland or Mizoram is missing: an empty division must still be listed')
// ── B. membership ────────────────────────────────────────────────────────────────────────────────
const india = Object.entries(membership).filter(([, m]) => m && m.country === 'IND').map(([s]) => s).sort()
const seen = new Map()
for (const s of states) for (const slug of s.slugs || []) seen.set(slug, (seen.get(slug) || 0) + 1)
const twice = [...seen].filter(([, n]) => n > 1).map(([s]) => s)
const missing = india.filter((s) => !seen.has(s)), extra = [...seen.keys()].filter((s) => !india.includes(s))
ok(!twice.length, `slugs in more than one division: ${twice.slice(0, 6).join(', ')}`)
ok(!missing.length && !extra.length,
`membership differs from admin-membership.json: missing ${missing.slice(0, 5).join(', ') || '-'}; extra ${extra.slice(0, 5).join(', ') || '-'}`,
`the ${india.length} India people groups, each in exactly one division (= admin-membership.json)`)
// ── C. shapes match the geometry ─────────────────────────────────────────────────────────────────
const webNames = new Set(web.features.map((f) => f.properties[table.meta.shape_property]))
const fullNames = new Set(full.features.map((f) => f.properties.shapeName))
const noShape = states.filter((s) => !webNames.has(s.shapeName) || !fullNames.has(s.shapeName)).map((s) => s.shapeName)
ok(!noShape.length, `shapeNames not in ${table.meta.served_geometry}: ${noShape.join(', ')}`,
`every shapeName is a feature of ${table.meta.served_geometry}`)
// ── D. no drift ──────────────────────────────────────────────────────────────────────────────────
for (const [k, inp] of Object.entries(table.meta.inputs || {})) {
const now = existsSync(P(inp.path)) ? sha(readFileSync(P(inp.path))) : 'MISSING'
ok(now === inp.sha384, `meta.inputs.${k} (${inp.path}) changed since the table was emitted: re-run node plugin-designs/my-upg-100-list/data/emit-india-states.mjs`)
}
let rebuilt = null
try { rebuilt = emitter.buildIndiaStates().text } catch (e) { fails.push(`re-deriving the table threw: ${e.message}`) }
ok(rebuilt === raw.toString('utf8'), 'the committed table is not what the emitter derives from today\'s inputs: re-run the emitter',
'NO DRIFT: re-derived from today\'s inputs it is byte-identical, and meta\'s input sha384s are the files on disk')
// ── E. pin + served copy ─────────────────────────────────────────────────────────────────────────
ok(pin === sha(raw), `INDIA_STATES_SHA384 (${String(pin).slice(0, 20)}…) is not the table's sha384 (${sha(raw).slice(0, 20)}…): re-run the emitter`)
ok(existsSync(SERVED) && sha(readFileSync(SERVED)) === pin, existsSync(SERVED)
? 'the served copy (public/dx-maps/assets/people-groups/india-states.json) is not the pinned table: run bun run tree'
: 'the table is not in the tree (public/dx-maps/assets/people-groups/india-states.json): add it to generate-tree\'s allowlist and run bun run tree',
'pin == the table == the served copy')
ok(raw.length <= SIZE_CEILING, `the table is ${raw.length} B, over the ${SIZE_CEILING} B ceiling`, `${raw.length} B (ceiling ${SIZE_CEILING})`)
for (const n of notes) console.log(n)
if (fails.length) {
console.error('INDIA-STATES: FAIL')
for (const f of fails) console.error(` ✗ ${f}`)
process.exit(1)
}
console.log('INDIA-STATES: PASS — 36 divisions, every India people group in exactly one, shapes match the served geometry, no drift from the stamping, and the served copy is the pinned one.')