#!/usr/bin/env node
/**
* check-packet-a1-structure — the prayer packet is built to its specified structure.
*
* FULL DETAIL is four front pages, the profiles, four back pages:
* map · triangle · life calendar · % evangelical · profiles… · bounded set · centered set · wheel · points
* LIGHTWEIGHT is two front pages, the profiles, two back pages:
* map · triangle · profiles… · wheel · points
* and the pages that carry the design are the ones described: page 1 is the zone's map on a VERTICAL
* page with a title saying which set it belongs to; page 2 is one inverted triangle narrowing from
* every people group to THIS packet; the wheel is the Metacamp Module 1 wheel, in its colours and its
* orientation.
*
* ★ ORDER IS READ FROM EACH SHEET'S CONTENT, NOT COUNTED. A sheet total can hold while two pages swap,
* or while one page is lost and another duplicated. Every sheet is identified by what is on it.
*
* ★ TWO-SIDED WHERE A ONE-SIDED CHECK WOULD PASS A CONSTANT: the packets tier must appear WITH a packet
* count and must NOT appear without one; the series line likewise; a "− N" step must appear where the
* sets nest and must NOT appear where they do not.
*
* Exit 0 pass · 1 fail · 75 NOT MEASURED.
*/
import { readFileSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const HERE = dirname(fileURLToPath(import.meta.url))
const REPO = resolve(HERE, '..')
let createPacketRenderer, zonesWithSelectors, DEAF_SELECTOR, strings, clusters, funnelRaw, christian
try {
;({ createPacketRenderer } = await import(join(REPO, 'library/packet/packet-render.js')))
;({ zonesWithSelectors, DEAF_SELECTOR } = await import(join(REPO, 'plugin-designs/my-upg-100-list/data/legend-tree.js')))
strings = JSON.parse(readFileSync(join(REPO, 'library/scripts/posters/packet-strings.json'), 'utf8'))
clusters = JSON.parse(readFileSync(join(REPO, 'plugin-designs/my-upg-100-list/data/clusters.json'), 'utf8'))
funnelRaw = JSON.parse(readFileSync(join(REPO, 'data/people-groups/vision-funnel.json'), 'utf8'))
christian = JSON.parse(readFileSync(join(REPO, 'data/people-groups/christian-share.json'), 'utf8'))
} catch (e) {
console.error(`PACKET-A1: NOT MEASURED (exit 75) — an input did not load: ${e.message}`)
process.exit(75)
}
const fails = []
const notes = []
const ok = (cond, msg, note) => { if (!cond) fails.push(msg); else if (note) notes.push(` ok ${note}`) }
const R = createPacketRenderer({ strings, lang: 'en' })
const { W, H } = R
const M = 60
const zone = { id: 'test', label: 'Test Zone' }
const groups = Array.from({ length: 45 }, (_, i) => ({ slug: `g${i}`, name: `Group ${i}`, country_code: 'MYS', population: 1000, imb_gsec: 1 }))
const sets = {
bounded: Array.from({ length: 30 }, (_, i) => ({ id: `b${i}`, reached: i % 3 === 0 ? true : (i % 3 === 1 ? false : undefined) })),
centered: Array.from({ length: 24 }, (_, i) => ({ id: `c${i}`, ring: (i % 12) - 1 })),
}
const withK = { ...funnelRaw, listCount: 28, listTotal: funnelRaw.steps[funnelRaw.steps.length - 1].groups }
const noK = { steps: funnelRaw.steps }
const MAP_ONLY_SHEET = ''
const POSTER_SHEET = ''
const ICON_PAGE = '
'
// ── A. PAGE ORDER, READ FROM CONTENT ────────────────────────────────────────────────────────────
function sequence(html) {
const starts = [...html.matchAll(/
/g)]
const out = []
starts.forEach((m, i) => {
const cls = m[1]
const seg = html.slice(m.index, i + 1 < starts.length ? starts[i + 1].index : html.length)
const kind = /sheet-map\b/.test(cls) ? 'map'
: /funnel-title/.test(seg) ? 'triangle'
: /sheet-life/.test(cls) ? 'life'
: /sheet-christian/.test(cls) ? 'christian'
: /sheet-sets-bounded/.test(cls) ? 'bounded'
: /sheet-sets-centered/.test(cls) ? 'centered'
: /sheet-worksheet/.test(cls) ? 'worksheet'
: /wheel-title/.test(seg) ? 'wheel'
: /class="card"/.test(seg) ? 'profiles'
: /word-title/.test(seg) ? 'word' // point 4's passage in full + the Father's-heart list (2026-09-30)
: /points-title|points_title|Prayer Points/.test(seg) ? 'points'
: `?${cls.trim()}`
if ((kind === 'profiles' || kind === 'worksheet') && out[out.length - 1] === kind) return // one entry per run
out.push(kind)
})
return out.join(' · ')
}
const liftedMap = R.liftMap(MAP_ONLY_SHEET)
const full = R.buildPacket(zone, groups, new Map(), liftedMap, withK, christian, 'full', sets)
const light = R.buildPacket(zone, groups, new Map(), liftedMap, withK, christian, 'lightweight', sets)
const FULL = 'map · triangle · life · christian · profiles · bounded · centered · wheel · points'
const LIGHT = 'map · triangle · worksheet · wheel · points'
ok(sequence(full) === FULL, `full-detail order is\n ${sequence(full)}\n expected\n ${FULL}`, `full detail: ${FULL}`)
ok(sequence(light) === LIGHT, `lightweight order is\n ${sequence(light)}\n expected\n ${LIGHT}`, `lightweight: ${LIGHT}`)
// ── B. PAGE 1 — the map, on a vertical page, saying which set it belongs to ─────────────────────
const box = (html) => {
const m = html.match(/data-map-kind="([^"]+)" data-map-scale="([\d.]+)" style="position:absolute;left:([\d.]+)px;top:([\d.]+)px;width:([\d.]+)px;height:([\d.]+)px/)
return m && { kind: m[1], s: +m[2], x: +m[3], y: +m[4], w: +m[5], h: +m[6] }
}
{
const b = box(liftedMap)
ok(b && b.kind === 'map-only', 'a map-only sheet was not placed as one', 'page 1 places a map-only sheet as the whole page')
// its label columns start 34px inside the sheet; they must land on the packet's own margin
if (b) ok(Math.abs(b.x + 34 * b.s - M) < 1 && b.y + 34 * b.s >= M - 1,
`the map-only sheet's names start ${(b.x + 34 * b.s).toFixed(1)}px from the edge; every packet page keeps ${M}px`,
`its names land on the same ${M}px margin as every other page (scale ${b.s})`)
// ★ A POSTER FILLS THE PAGE inside the white margin M (2026-09-29: page 1 is the map alone).
const p = box(R.liftMap(POSTER_SHEET))
ok(p && p.kind === 'poster' && p.x >= M - 0.5 && p.y >= M - 0.5 && p.x + p.w <= W - M + 0.5 && p.y + p.h <= H - M + 0.5
&& (Math.abs(p.w - (W - 2 * M)) < 1 || Math.abs(p.h - (H - 2 * M)) < 1),
`a 3x4 poster does not fill the page inside the margin: ${JSON.stringify(p)}`, `a 3x4 poster fills the page inside the ${M}px margin (scale ${p && p.s})`)
// the lifted artwork is laid out left-to-right and must not inherit an rtl page's direction
ok(/class="map-sheet" dir="ltr"/.test(liftedMap) && /class="map-sheet" dir="ltr"/.test(R.liftMap(POSTER_SHEET)),
'the lifted map sheet does not pin dir="ltr", so an Arabic packet mirrors the English artwork', 'the lifted sheet pins dir="ltr" inside an rtl page')
ok(R.liftMap(ICON_PAGE) === '', 'an icon-sized