FULL DETAIL is four front pages, the profiles, four back pages:
map, triangle, life calendar, % evangelical, profiles,
bounded set, centered set, prayer wheel, 12 prayer points.
LIGHTWEIGHT is two front, the profiles, two back:
map, triangle, profiles, prayer wheel, 12 prayer points.
Both are generated from data, in every packet language, from the
browser and from the offline generator.
PAGE 1 - the map, on a vertical page. One lift for every caller
(packet-render.js liftMap): the browser composable, the UPG 100
profile and the offline generator each carried an identical copy of
the old wrapper (250px down, scale 0.62), and three copies of a layout
decision is how page 1 drifts between print paths. The lift prefers
the map-only vertical sheet (mapSheetFiles: <zone>.packet.html, then
the poster) and places it so its names land on the same 60px margin
every other page keeps; a poster falls back fitted under the title at
0.81 rather than 0.62. The title block is drawn after the map, since a
map-only sheet begins with a full-page background. Anything that is not
a map sheet - an icon-sized <svg> from a host that answers a missing
file with a 200 page - is refused, and page 1 says the map is missing.
Page 1 now states which set it belongs to ("One of 28 UPG 100 prayer
lists"), only when the caller supplies the count.
PAGE 2 - one inverted triangle in the Vision Card's look. The card's
steps are the top (every people group, the unreached, the frontier,
the network's list), narrowing to the UPG 100 packets and, at the tip,
this packet. The card's greens, darkening as it narrows, walked
through its own colour stops; every numeral picks white or ink by the
WCAG ratio (all at least 3.4:1, large bold type). Tier widths are
geometric, not proportional - 2,107, 28 and ~100 against 12,379 would
be hairlines - so where two sets truly nest the caption states the
step removed, and the network's list, which does not sit inside
frontier, gets the card's "N of them are frontier" note instead of a
false subtraction. The packets tier and its count appear only when the
caller supplies one.
BACK PAGES - the bounded and centered sets get a page each at twice the
width, under the one page key, so lightweight drops the pair together.
THE WHEEL - the Metacamp Module 1 prayer wheel: its twelve colours,
sampled from the fill operators in the source PDF's content stream; its
orientation, the opening PRAISE at nine o'clock and READ THE WORD at
twelve; white labels with a shadow and a faint outline, so a print
path that drops SVG filters still leaves them legible on the palest
segments; "5 minutes" badges; the attribution with the website. The
labels stay the packet's own rhythm names, translated in every
language and matching the 12 points page.
The offline generator makes both packets (PACKET_VARIANT=lightweight;
the full packet keeps its names, lightweight is <zone>.lightweight and
stamped with its variant).
STRINGS: four new keys, in all eleven blocks - each block's own words
for "packet" and "prayer list", the frontier note aligned to the word
the frontier tier uses on the same page.
GATES: check-packet-a1-structure (registered in gates:pre) reads every
sheet's CONTENT for the order of both variants, page 1's lift in all
four cases, the triangle's tiers and steps, the wheel's colours and
orientation, and all 28 zones. Mutation-tested eleven ways, each caught
by its own message. check-packet-variants now asserts both sets pages
and a four-sheet difference. The browser gate check-packet-render
counted funnel rows and bars by inline-style substrings, which an SVG
triangle has none of - both would have read zero and passed - so it
now counts .funnel-n against .funnel-tier, its sheet arithmetic counts
the sets pages, and its Latin allowlist carries the renamed product
name. check-packet-browser used a copy of the old wrapper; it now uses
the product's lift.
188 lines
13 KiB
JavaScript
188 lines
13 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'
|
||
: /wheel-title/.test(seg) ? 'wheel'
|
||
: /class="card"/.test(seg) ? 'profiles'
|
||
: /p4-passage|points_title|Prayer Points/.test(seg) ? 'points'
|
||
: `?${cls.trim()}`
|
||
if (kind === 'profiles' && out[out.length - 1] === 'profiles') 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 · profiles · 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})`)
|
||
const p = box(R.liftMap(POSTER_SHEET))
|
||
ok(p && p.kind === 'poster' && p.y >= 250 && p.y + p.h <= H - M + 0.5 && p.w <= W - 2 * M + 0.5,
|
||
`a 3x4 poster was not fitted under the title: ${JSON.stringify(p)}`, `a 3x4 poster falls back fitted under the title (scale ${p && p.s})`)
|
||
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')
|
||
const p1 = full.slice(0, full.indexOf('<div class="sheet', full.indexOf('sheet-map') + 10))
|
||
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', '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 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', 'page 1 says "One of 28 UPG 100 prayer lists" — and stays silent without a count')
|
||
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.')
|