doxa-maps/internal/check-list-prop-unit.mjs
Doxa Maps 93ca8dab91
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Update from source d3d3c3f8
2026-09-26 11:59:17 +00:00

94 lines
6.8 KiB
JavaScript

#!/usr/bin/env node
/**
* check-list-prop-unit — the people-group LIST prop (library/lists/), measured WITHOUT a browser.
*
* Runs the real library module in Node against a stand-in window/location/fetch:
* - the ladder: profile-config `list` > ?list= > window.MAP_LIST > 'doxa'; ids and aliases ('700');
* an unknown value falls back to the default;
* - the default list returns the SAME array, untouched, with no fetch at all;
* - a list keeps exactly the rows whose slug is in its membership file, fetched from the asset pool;
* - a membership that cannot load FAILS CLOSED: no rows, never the whole DOXA list;
* - every registry list with an imb filter has a membership file, and its counts match the slugs;
* - listLabel / listDefinition read the reader's catalogue (library/i18n/locales/<lc>/lists.json),
* fall back to the registry's English, the English catalogue equals the registry, and every
* locale carries a label and a definition for every list.
* USAGE node internal/check-list-prop-unit.mjs EXIT 0 pass · 1 fail
*/
import { register } from 'node:module'
import { readFileSync, existsSync, readdirSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
// Vite imports JSON without an attribute; Node 20 does not. Teach this process the same thing.
register('data:text/javascript,' + encodeURIComponent(
"export async function load(url, ctx, next) { if (url.endsWith('.json')) return { format: 'json', shortCircuit: true, source: (await import('node:fs')).readFileSync(new URL(url)) }; return next(url, ctx) }"))
globalThis.window = globalThis
let search = ''
Object.defineProperty(globalThis, 'location', { configurable: true, get: () => ({ search, href: 'https://host.example/page' + search }) })
const fetched = []
let serve = () => ({ ok: false, status: 404 })
globalThis.fetch = async (url) => { fetched.push(String(url)); const r = serve(String(url)); return { ok: r.ok, status: r.status, json: async () => r.body } }
const L = await import('../library/lists/peopleGroupList.js')
const results = []
const check = (name, got, want) => {
const ok = JSON.stringify(got) === JSON.stringify(want)
results.push(ok)
console.log(` ${ok ? 'ok ' : 'FAIL'} ${name.padEnd(66)} ${JSON.stringify(got)}${ok ? '' : ' want ' + JSON.stringify(want)}`)
}
const reset = () => { search = ''; delete globalThis.MAP_LIST; fetched.length = 0 }
console.log('ladder')
reset(); check('nothing set -> the default', L.resolvePeopleGroupList(null), 'doxa')
reset(); check('profile-config list wins', (search = '?list=unreached', globalThis.MAP_LIST = 'frontier', L.resolvePeopleGroupList({ list: 'frontier-500k' })), 'frontier-500k')
reset(); check('?list= beats window.MAP_LIST', (search = '?list=frontier', globalThis.MAP_LIST = 'unreached', L.resolvePeopleGroupList({})), 'frontier')
reset(); check('window.MAP_LIST when nothing else', (globalThis.MAP_LIST = 'unreached', L.resolvePeopleGroupList(null)), 'unreached')
reset(); check('an alias: 700', L.resolvePeopleGroupList({ list: '700' }), 'frontier-500k')
reset(); check('an alias, any case: Project-7000', L.resolvePeopleGroupList({ list: 'Project-7000' }), 'unreached')
reset(); check('a JSON-string profile-config', L.resolvePeopleGroupList('{"list":"4700"}'), 'frontier')
reset(); check('a ref-shaped profile-config', L.resolvePeopleGroupList({ value: { list: '2000' } }), 'doxa')
reset(); check('unknown -> the default', L.resolvePeopleGroupList({ list: 'project-9999' }), 'doxa')
console.log('apply')
const rows = [{ slug: 'a' }, { slug: 'b' }, { slug: 'c' }]
reset(); { const out = await L.applyPeopleGroupList(rows, null); check('default: the same array, no fetch', [out === rows, fetched.length], [true, 0]) }
reset(); serve = (u) => (u.endsWith('/people-groups/lists/frontier.json') ? { ok: true, status: 200, body: { slugs: ['a', 'c'] } } : { ok: false, status: 404 })
{ const out = await L.applyPeopleGroupList(rows, { list: 'frontier', assetsBase: 'https://cdn.example/tree/assets' }); check('a list keeps exactly its members', out.map((r) => r.slug), ['a', 'c']) }
check('…fetched from the asset pool', fetched, ['https://cdn.example/tree/assets/people-groups/lists/frontier.json'])
reset(); serve = () => ({ ok: false, status: 404 })
{ const err = console.error; console.error = () => {}; const out = await L.applyPeopleGroupList(rows, { list: 'unreached', assetsBase: 'https://cdn.example/x/assets' }); console.error = err; check('an unloadable list FAILS CLOSED: no rows', out, []) }
console.log('membership files')
for (const l of L.peopleGroupLists().filter((x) => x.source === 'imb')) {
const f = join(ROOT, 'data/people-groups/lists', `${l.id}.json`)
if (!existsSync(f)) { check(`${l.id}: membership file exists`, false, true); continue }
const d = JSON.parse(readFileSync(f, 'utf8'))
check(`${l.id}: file matches its registry filter, counts match its slugs`, [JSON.stringify(d.filter) === JSON.stringify(l.filter), d.counts.doxa === d.slugs.length, d.counts.imb >= d.counts.doxa], [true, true, true])
}
console.log('labels in the reader\'s language')
const cat = (lc) => JSON.parse(readFileSync(join(ROOT, 'library/i18n/locales', lc, 'lists.json'), 'utf8'))
const tFrom = (lc) => (key) => key.split('.').slice(1).reduce((o, k) => (o == null ? o : o[k]), cat(lc)) ?? key
check('no translator -> the registry English', L.listLabel('700'), 'Project 700: frontier people groups of 500,000 or more')
check('a translator that returns the key -> English', L.listLabel('frontier', (k) => k), 'Project 4700: frontier people groups')
check('Arabic catalogue, by alias', L.listLabel('4700', tFrom('ar')), cat('ar').frontier.label)
check('definition, German catalogue', L.listDefinition('unreached', tFrom('de')), cat('de').unreached.definition)
check('an unknown list -> empty, not a crash', L.listLabel('project-9999', tFrom('en')), '')
{ const en = cat('en'), bad = []
for (const l of L.peopleGroupLists()) {
if (en[l.id]?.label !== l.label) bad.push(`${l.id}.label`)
if ((en[l.id]?.definition || '') !== (l.definition || '')) bad.push(`${l.id}.definition`)
}
check('the English catalogue matches the registry, word for word', bad, []) }
{ const missing = []
for (const lc of readdirSync(join(ROOT, 'library/i18n/locales'))) {
const c = cat(lc)
for (const l of L.peopleGroupLists()) for (const f of ['label', 'definition']) if (!c[l.id]?.[f]) missing.push(`${lc}:${l.id}.${f}`)
}
check('every locale has a label and a definition for every list', missing, []) }
const failed = results.filter((x) => !x).length
console.log(failed ? `LIST-PROP: FAIL — ${failed} case(s)` : `LIST-PROP: PASS — ${results.length} cases: the ladder, aliases, the untouched default, membership filtering, fail-closed, the membership files, and labels in every locale`)
process.exit(failed ? 1 : 0)