A second look: the bounded set on one page and the centered set on another. Each chart keeps everything the redesign gave it — its heading, the cross at the centre, the nine numbered target bands with dots spread through them, the full ring legend — as one large square panel (960px) centred on its page, instead of two 660px panels sharing one. The centered set's page carries the legend; both carry the pair's heading so they read as one spread. Also the profile row's photo URL is escaped (esc(src)), the identical hunk regions landed in 1aeafead0, so the two merge cleanly: a quote in an API image_url closed the attribute and injected markup into the printed document. check:packet-a1-structure and check:packet-variants assert the two-page order and the derived lightweight delta (four back pages dropped).
200 lines
14 KiB
JavaScript
200 lines
14 KiB
JavaScript
#!/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 = '<html><style>.x{}</style><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1500 1941" width="1500" height="1941" data-sheet="map-only"><rect width="1500" height="1941" fill="#f7f4ec"/></svg></html>'
|
||
const POSTER_SHEET = '<html><style>.x{}</style><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1500 2000" width="1500" height="2000"><rect width="1500" height="2000"/></svg></html>'
|
||
const ICON_PAGE = '<html><body><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M0 0h24v24H0z"/></svg></body></html>'
|
||
|
||
// ── A. PAGE ORDER, READ FROM CONTENT ────────────────────────────────────────────────────────────
|
||
function sequence(html) {
|
||
const starts = [...html.matchAll(/<div class="sheet([^"]*)">/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'
|
||
: /p4-passage|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 <svg> (an SPA fallback page) was lifted as if it were a map', 'an icon-sized <svg> is refused, not printed as the map')
|
||
ok(R.liftMap('<html>no svg</html>') === '', 'a page with no <svg> did not lift to empty')
|
||
// A MAP-ONLY sheet leaves its top band empty for the packet's title, so there the title is drawn — after the map.
|
||
const firstSheet = (html) => { const a = html.indexOf('<div class="sheet sheet-map'); return html.slice(a, html.indexOf('<div class="sheet', a + 10)) }
|
||
const p1 = firstSheet(full)
|
||
ok(p1.indexOf('class="map-sheet"') >= 0 && p1.indexOf('class="map-sheet"') < p1.indexOf('map-series'),
|
||
'page 1 draws its title BEFORE the map, so the map-only sheet\'s background paints over it', 'on a map-only sheet the map is drawn first, the title over it')
|
||
// two-sided: the series line WITH a packet count, and NOT without one
|
||
ok(/class="map-series"[\s\S]{0,200}One of 28 UPG 100 prayer lists/.test(p1), 'page 1 (map-only) does not say it is one of the 28 UPG 100 prayer lists')
|
||
const noKp1 = R.mapPage(zone, groups, liftedMap, noK)
|
||
ok(!/map-series/.test(noKp1), 'page 1 prints a series line with NO packet count to state', 'a map-only page 1 says "One of 28 UPG 100 prayer lists" — and stays silent without a count')
|
||
// ★ A POSTER PAGE 1 HAS ONE HEADER, THE POSTER'S — two-sided against the map-only page above, which has the packet's.
|
||
const posterP1 = R.mapPage(zone, groups, R.liftMap(POSTER_SHEET), withK)
|
||
ok(/sheet-map-poster/.test(posterP1) && !/map-series/.test(posterP1) && !posterP1.includes(strings.en.map_page_sub.split('{count}')[0]),
|
||
'a poster page 1 still draws the packet\'s own title over the poster — two headers', 'a poster page 1 is the poster alone: no second title, no series line')
|
||
ok(/class="map-missing"/.test(R.mapPage(zone, groups, R.liftMap(ICON_PAGE), withK)), 'a refused sheet does not fall through to the missing-map notice')
|
||
}
|
||
|
||
// ── C. PAGE 2 — the triangle ──────────────────────────────────────────────────────────────────
|
||
{
|
||
const tri = (html) => {
|
||
const i = html.indexOf('funnel-title')
|
||
const a = html.lastIndexOf('<div class="sheet', i)
|
||
const b = html.indexOf('<div class="sheet', i)
|
||
return html.slice(a, b < 0 ? html.length : b)
|
||
}
|
||
const page = tri(full)
|
||
const tiers = (page.match(/class="funnel-tier/g) || []).length
|
||
const nums = (page.match(/class="funnel-n/g) || []).length
|
||
ok(tiers === funnelRaw.steps.length + 2 && nums === tiers,
|
||
`with a packet count the triangle should have ${funnelRaw.steps.length + 2} tiers each with its number; got ${tiers} tiers, ${nums} numbers`,
|
||
`${tiers} tiers, every one with its number: ${funnelRaw.steps.length} global steps, the packets, this packet`)
|
||
ok(/UPG 100 prayer packets divide/.test(page), 'the packets tier is missing although a packet count was supplied')
|
||
const pageNoK = tri(R.buildPacket(zone, groups, new Map(), liftedMap, noK, christian, 'full', sets))
|
||
const tiersNoK = (pageNoK.match(/class="funnel-tier/g) || []).length
|
||
ok(tiersNoK === funnelRaw.steps.length + 1 && !/UPG 100 prayer packets divide/.test(pageNoK),
|
||
`without a packet count the packets tier must be absent (${tiersNoK} tiers)`, 'no packet count, no packets tier')
|
||
ok(/class="[^"]*funnel-mine/.test(page), 'the reader\'s own count (.funnel-mine) is missing from the triangle')
|
||
// two-sided: a step is subtracted where the sets nest, and only there
|
||
const steps = funnelRaw.steps
|
||
const byId = Object.fromEntries(steps.map((s) => [s.id, s.groups]))
|
||
const eq = [...page.matchAll(/class="funnel-eq"[^>]*>− ([\d,]+)</g)].map((m) => Number(m[1].replace(/,/g, '')))
|
||
const want = [byId.all - byId.unreached, byId.unreached - byId.frontier]
|
||
ok(JSON.stringify(eq) === JSON.stringify(want),
|
||
`the "− N" steps are ${JSON.stringify(eq)}; only the nesting steps may carry one: ${JSON.stringify(want)}`,
|
||
`"− N" appears only where the sets nest: ${want.map((n) => n.toLocaleString('en')).join(', ')}`)
|
||
const doxa = steps.find((s) => s.id === 'doxa')
|
||
if (doxa && doxa.frontier) {
|
||
ok(page.includes(`${doxa.frontier.toLocaleString('en')} of them are frontier`),
|
||
'the network\'s list is not told apart from a subset of frontier (its "N of them are frontier" note is missing)',
|
||
'the network\'s list states how many of its groups are frontier instead of a false "− N"')
|
||
}
|
||
}
|
||
|
||
// ── D. THE WHEEL — the Metacamp Module 1 wheel ─────────────────────────────────────────────────
|
||
{
|
||
const i = full.indexOf('wheel-title')
|
||
const page = full.slice(full.lastIndexOf('<div class="sheet', i), full.indexOf('<div class="sheet', i) < 0 ? full.length : full.indexOf('<div class="sheet', i))
|
||
const PDF = ['#fe936a', '#febb64', '#fed164', '#fffa83', '#e6f37d', '#a3d979', '#47ccfc', '#6293fe', '#722cfd', '#c632fd', '#e6578d', '#ff766f']
|
||
const fills = [...page.matchAll(/<path d="M [\d.]+ [\d.]+ L [\d.-]+ [\d.-]+ A[^"]*" fill="(#[0-9a-f]{6})"/g)].map((m) => m[1])
|
||
ok(JSON.stringify(fills) === JSON.stringify(PDF), `the wheel's colours are ${JSON.stringify(fills)}, not the source PDF's`, 'the wheel carries the source PDF\'s twelve colours, in order')
|
||
const first = page.match(/<path d="M ([\d.]+) ([\d.]+) L ([\d.-]+) ([\d.-]+) A/)
|
||
ok(first && Math.abs(+first[4] - +first[2]) < 1 && +first[3] < +first[1],
|
||
'the opening PRAISE does not start at nine o\'clock as on the source wheel', 'the hour opens at nine o\'clock, as on the source wheel')
|
||
ok((page.match(/>5<\/text>/g) || []).length === 12, 'the wheel does not carry twelve "5 minutes" badges')
|
||
ok(/metacamp\.org/.test(page), 'the wheel lost its attribution (source and website)', 'twelve "5 minutes" badges and the source attribution')
|
||
}
|
||
|
||
// ── E. EVERY ZONE A READER CAN PICK ────────────────────────────────────────────────────────────
|
||
{
|
||
const zones = zonesWithSelectors(clusters, { deafCount: 123 })
|
||
const deaf = zones.find((z) => z.id === 'deaf')
|
||
ok(zones.length === 28, `${zones.length} printable zones; the legend offers 28`)
|
||
ok(deaf && deaf.selector === DEAF_SELECTOR, 'the Deaf zone has no selector, so its packet cannot be resolved')
|
||
const own = {}
|
||
for (const k of ['lists', 'asia_lists', 'uncovered_lists']) for (const z of clusters[k] || []) own[z.id] = z.selector
|
||
const wrong = zones.filter((z) => z.id !== 'deaf' && z.selector !== own[z.id]).map((z) => z.id)
|
||
ok(!wrong.length, `zones whose selector is not the catalogue's own: ${wrong.join(', ')}`,
|
||
`all 28 zones resolvable, Deaf by its affinity-bloc selector and the other 27 by their own`)
|
||
}
|
||
|
||
for (const n of notes) console.log(n)
|
||
if (fails.length) {
|
||
console.error('PACKET-A1: FAIL')
|
||
for (const f of fails) console.error(` ✗ ${f}`)
|
||
process.exit(1)
|
||
}
|
||
console.log('PACKET-A1: PASS — full detail and lightweight are built to their specified structure, page by page, read from each page\'s content.')
|