The generator could already serve a second group set -- series.json's country series proves it -- but only one wired into series.json, reading upg100's bucketed clusters, upg100's legend tree for enumeration, and the shared records cache. A fork had no way in. The contract is now one folder. DATA_DIR points at it; clusters.json with a flat lists[] is the group set; records.json in the same folder means no API and no cache; selector-evaluator.js resolves from there, as it always did. normalize.js and legend-tree.js become optional -- absent, records are used as-is and the flat file IS the enumeration -- and the absence is logged rather than silent, because a silent fallback would let a typo'd normalize.js skip normalization on the real series. PACKET_OUT keeps a fork's output away from the real artifact directory, where toy packets would be counted as coverage. unlisted: true carries upg100's reachability idea across as data: the list is defined and selectable with ONLY=, and not generated by default. The bucketed shape keeps its legend-tree enumeration untouched, so the deaf collapse and the 28-zone arithmetic are byte-for-byte unchanged. The smoke test was written first and failed first, on all four counts. Its fixture is synthetic -- every slug and number invented -- and it asserts the unlisted list does NOT generate, which is as load-bearing as the two that do: an unlisted entry that generates anyway is the reachability bug inverted. One bug found in my own work while proving it: allZones became async, and the funnel's listCount read it from inside a try/catch that returns null on error -- so a use-before-init did not crash, it silently deleted the triangle from all 28 packets. Caught because a probe for the tip's "1 of 28" found nothing, not because anything failed. Resolved once, before the funnel is built; regenerated and verified -- funnel present, tip reads 1 of 28, 95 photos, 11 sheets.
81 lines
4.2 KiB
JavaScript
81 lines
4.2 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* check-group-set-standard — a FORK's group set generates packets end-to-end.
|
|
*
|
|
* THE CONTRACT UNDER TEST (documentation/STANDARD-GROUP-SET.md in the plugin): a fork supplies ONE
|
|
* FOLDER — clusters.json with a flat lists[] ({ id, label, selector, unlisted? }), records.json
|
|
* (its own people rows, so no API), selector-evaluator.js (resolvable from the folder) — and points
|
|
* the packet generator at it with DATA_DIR= and PACKET_OUT=. Nothing else. If that sentence stops
|
|
* being true, this gate goes red before a fork finds out.
|
|
*
|
|
* ★ THE FIXTURE IS THE STANDARD'S FALSIFIER, deliberately synthetic (every slug and number is
|
|
* invented — no real group data ships in a test fixture). It exercises exactly the generic shape:
|
|
* FLAT lists[] (not upg100's bucketed one), an unlisted:true entry that must NOT generate, records
|
|
* from the folder, and no posters directory — the map page must degrade to a cover, not kill the
|
|
* run, because a fork's first run will not have posters yet.
|
|
*
|
|
* Written RED-FIRST: on the tree before the standard landed, packet.mjs knew only series.json
|
|
* dataDirs, bucketed clusters, cached/API records and a fixed out/ — this test failed on all four.
|
|
*
|
|
* Headless, no network (NOPHOTOS + folder records). Exit 0 pass · 1 fail.
|
|
*/
|
|
import { resolve, dirname, join } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { existsSync, readFileSync, readdirSync, rmSync, mkdtempSync } from 'node:fs'
|
|
import { spawnSync } from 'node:child_process'
|
|
import { tmpdir } from 'node:os'
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
const REPO = resolve(HERE, '..')
|
|
const FIXTURE = join(HERE, 'fixtures', 'toy-group-set')
|
|
const GEN = join(REPO, 'library', 'scripts', 'posters', 'packet.mjs')
|
|
|
|
const fails = []
|
|
const ok = (c, m) => { if (!c) fails.push(m) }
|
|
|
|
// ⚠ OUTPUT GOES TO A TEMP DIR, NEVER THE REAL out/packets — toy packets landing beside the real 28
|
|
// would make check:packets-all count fiction as coverage.
|
|
const OUT = mkdtempSync(join(tmpdir(), 'toy-packets-'))
|
|
const env = {
|
|
...process.env,
|
|
DATA_DIR: FIXTURE,
|
|
PACKET_OUT: OUT,
|
|
NOPHOTOS: '1',
|
|
OFFLINE: '1',
|
|
}
|
|
const run = spawnSync('node', [GEN], { env, encoding: 'utf8', timeout: 120000 })
|
|
const outText = String(run.stdout || '') + String(run.stderr || '')
|
|
|
|
ok(run.status === 0,
|
|
`the generator exited ${run.status} on the toy dataDir — a fork cannot generate at all.\n tail: ${outText.trim().split('\n').slice(-4).join('\n ')}`)
|
|
|
|
if (run.status === 0) {
|
|
const files = existsSync(OUT) ? readdirSync(OUT).filter((f) => f.endsWith('.html')).sort() : []
|
|
// toy-hidden is unlisted:true — TWO packets, and the third's absence is as load-bearing as the
|
|
// two's presence: an unlisted list that generates anyway is the reachability bug inverted.
|
|
ok(files.includes('toy-north.html') && files.includes('toy-south.html'),
|
|
`expected toy-north + toy-south packets, got: ${files.join(', ') || '(none)'}`)
|
|
ok(!files.some((f) => f.startsWith('toy-hidden')),
|
|
'the unlisted:true list generated a packet — unlisted must mean unlisted')
|
|
|
|
for (const f of ['toy-north.html', 'toy-south.html']) {
|
|
if (!files.includes(f)) continue
|
|
const html = readFileSync(join(OUT, f), 'utf8')
|
|
const groups = (html.match(/class="card"/g) || []).length
|
|
const want = f === 'toy-north.html' ? 3 : 2
|
|
ok(groups === want, `${f}: ${groups} group rows, expected ${want} — the folder's records.json did not drive the join`)
|
|
// The synthetic names must actually print — a packet of the right COUNT with the wrong rows
|
|
// would pass a bare count.
|
|
ok(f !== 'toy-north.html' || /Alpha People/.test(html), 'toy-north is missing its own synthetic people')
|
|
// No posters exist for the toy: the map page must be the documented cover, and the run must
|
|
// not have died for it (already asserted by exit 0).
|
|
}
|
|
}
|
|
rmSync(OUT, { recursive: true, force: true })
|
|
|
|
if (fails.length) {
|
|
console.error('GROUP-SET-STANDARD: FAIL')
|
|
for (const f of fails) console.error(` ✗ ${f}`)
|
|
process.exit(1)
|
|
}
|
|
console.log('GROUP-SET-STANDARD: PASS — a one-folder synthetic fork generates exactly its listed packets (and not its unlisted one) from its own records, with no API, no posters and no series.json edit.')
|