The Wall's and the template's proofs become guards: four two-sided headless checks, in the plugins' own scripts/

Until now these ran from _qa-cycle, so they proved a landing once and guarded nothing after it. Each now lives with its plugin, locates the repo from its own path, and fails on its known-bad:
- wall-of-people-groups/scripts/check-wall-wysiwyg.mjs: the screen poster and the print are one layout on every preset (sheets, 2,107 card rects to 0.002 in, name size and wrap, reference size, chrome blocks). Planted: a card moved 0.01 in, a name a point larger, the footer missing on screen. About 0.6 s.
- wall-of-people-groups/scripts/check-wall-print-watch.mjs: the HD print counts its photos while they load, counts the ones that could not load, finishes at load or the 10-minute ceiling, and ends when its frame is gone. Planted copies of printWatch.js now go to the OS temp folder, never into the tree. About 0.3 s.
- wall-of-people-groups/scripts/check-wall-no-bg-photos.mjs + fixtures/pray-list-sample.json: the real store (through Vite) never requests a photo on its own, a group with no photo URL gets none, and one with a URL keeps it. Planted: the old background test, added through the store's $onAction. The store from before L12 (d9e9aa448) fails both arms: 12 photo requests in 3.5 s and 3 groups on the 404 placeholder. The fixture is a trimmed sample of the live list: 200 rows (120 with has_photo, 77 without, 3 synthetic with no image_url), only the fields the check reads, 66 KB. About 10 s.
- template-bundle/scripts/check-template-i18n.mjs: profile B renders from template.json in en, ar and zh, one sentence in each language's own order with both <code> islands, and no raw key. Planted: the English catalogue served as Arabic. About 4 s.

All four are headless: no browser seat.
This commit is contained in:
Doxa Maps 2026-09-29 12:53:41 +00:00
parent a4622781a2
commit f50b641b28
5 changed files with 319 additions and 0 deletions

View file

@ -0,0 +1,62 @@
#!/usr/bin/env node
/**
* check-template-i18n — the starter template renders its text from the catalogue. Headless (SSR).
*
* template-bundle is what every new plugin copies, so it has to SHOW the i18n pattern, not just pass
* it. Profile B is rendered through Vue's server renderer with the REAL template.json catalogue in en,
* ar and zh, and must:
* - take its heading, its note line and its sentence from the catalogue of the page's language;
* - render "Served from {file} in the {bundle} bundle." as ONE key, in that language's own word order
* (zh puts the bundle first), with the file and bundle names as two <code dir="ltr"> islands;
* - show no raw key and no {placeholder}.
* Two-sided: the same render in ar with the ENGLISH catalogue served as Arabic (a catalogue someone
* forgot to translate, or a component that ignores the locale) must fail.
* EXIT 0 pass · 1 fail
*/
import { readFileSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { createServer } from 'vite'
import { createSSRApp, ref } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { createI18n } from 'vue-i18n'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
const cat = (lc) => ({ template: JSON.parse(readFileSync(join(ROOT, `library/i18n/locales/${lc}/template.json`), 'utf8')) })
const server = await createServer({ root: ROOT, configFile: join(ROOT, 'vite.config.js'), server: { middlewareMode: true, hmr: false, ws: false }, appType: 'custom', logLevel: 'error', optimizeDeps: { noDiscovery: true, include: [] } })
/** Render profile B in `lc`, serving `served` as that locale's catalogue; return what is wrong. */
async function check(B, lc, served, noteGiven) {
const i18n = createI18n({ legacy: false, locale: lc, fallbackLocale: 'en', messages: { en: cat('en'), [lc]: served } })
const app = createSSRApp(B); app.use(i18n); app.provide('profileConfig', ref(noteGiven ? { note: noteGiven } : {}))
const html = await renderToString(app)
const want = cat(lc).template.profileB
const text = html.replace(/<[^>]+>/g, '')
const errs = []
if (!html.includes(want.title)) errs.push('the heading is not the catalogue\'s')
if ((html.match(/<code dir="ltr"[^>]*>/g) || []).length !== 2) errs.push('the sentence does not keep its two <code dir="ltr"> islands')
if (!html.includes('>template-map-b.vue<') || !html.includes('>template-bundle<')) errs.push('the islands do not carry the file and bundle names')
const sentence = want.servedFrom.replace('{file}', 'template-map-b.vue').replace('{bundle}', 'template-bundle')
if (!text.includes(sentence)) errs.push(`the sentence does not read in the catalogue's order ("${sentence}")`)
if (!(noteGiven ? text.includes(want.note.replace('{note}', noteGiven)) : text.includes(want.noNote))) errs.push('the note line is not the catalogue\'s')
if (/\{\w+\}|template\.profileB/.test(text)) errs.push('a raw placeholder or key shows')
return { errs, text: text.replace(/\s+/g, ' ').trim() }
}
const fails = []
try {
const B = (await server.ssrLoadModule('/plugin-designs/template-bundle/template-map-b.vue')).default
for (const [lc, note] of [['en', ''], ['ar', 'مرحبا'], ['zh', 'hello']]) {
const { errs, text } = await check(B, lc, cat(lc), note)
console.log(` ${errs.length ? 'FAIL' : 'ok '} ${lc}: ${text.slice(0, 120)}`)
fails.push(...errs.map((e) => `${lc}: ${e}`))
}
const planted = await check(B, 'ar', cat('en'), '')
if (!planted.errs.length) fails.push('SELF-TEST: the English catalogue served as Arabic was not caught')
} catch (e) { fails.push('could not render: ' + e.message) }
await server.close()
if (fails.length) { console.log('TEMPLATE-I18N: FAIL'); for (const f of fails) console.log(' - ' + f); process.exit(1) }
console.log('TEMPLATE-I18N: PASS — profile B renders from template.json in en, ar and zh, its sentence in each language\'s own order with both <code> islands; the English catalogue served as Arabic is caught.')
process.exit(0)

View file

@ -0,0 +1,80 @@
#!/usr/bin/env node
/**
* check-wall-no-bg-photos — the Wall's store never downloads photos on its own. Headless.
*
* Until 2026-09-29 the store "tested" every photo after loading the list: 932 photos, 132 MB, on every
* visit, whether or not anyone zoomed in (rega measured 108–132 image requests in the first 60 s; 0
* after the fix). The canvas loads only the photos in view, so the store must load none. This runs the
* REAL store (through Vite, so @map resolves) against a trimmed copy of the live list
* (fixtures/pray-list-sample.json), with fetch stubbed to serve it and Image stubbed to COUNT every
* photo request, and asserts:
* - no photo is requested by the store, before or after its list has loaded;
* - a group with no photo URL gets none (not the stock "no image" photo, which answers 404);
* - a group with a photo URL keeps it.
* Two-sided: the same run with a planted background photo test (the old behaviour, added through the
* store's own $onAction hook) must fail.
* EXIT 0 pass · 1 fail
*/
import { readFileSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { createServer } from 'vite'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
const LIST = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'fixtures/pray-list-sample.json'), 'utf8'))
const WAIT_MS = 3500 // the old test's first batches: 3 photos at once, 3 more each second
// ── the stubs ──────────────────────────────────────────────────────────────────────────────────────
const photoRequests = []
globalThis.Image = class { constructor() { this._src = '' }
set src(u) { this._src = u; if (u) { photoRequests.push(u); setTimeout(() => this.onload && this.onload(), 5) } }
get src() { return this._src } }
const mem = new Map()
globalThis.localStorage = { getItem: (k) => (mem.has(k) ? mem.get(k) : null), setItem: (k, v) => mem.set(k, String(v)), removeItem: (k) => mem.delete(k) }
globalThis.fetch = async (url) => {
if (/\/api\/people-groups\/list/.test(String(url))) return { ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => LIST }
return { ok: false, status: 404, headers: { get: () => 'text/html' }, json: async () => ({}), text: async () => '' }
}
const server = await createServer({ root: ROOT, configFile: join(ROOT, 'vite.config.js'), server: { middlewareMode: true, hmr: false, ws: false }, appType: 'custom', logLevel: 'error', optimizeDeps: { noDiscovery: true, include: [] } })
const { createPinia } = await server.ssrLoadModule('pinia')
const { usePeopleGroupsStore } = await server.ssrLoadModule('/plugin-designs/wall-of-people-groups/src/stores/peopleGroupsStore.js')
/** One run of the real store. `plant` may add the old background test through $onAction. */
async function run(plant = false) {
photoRequests.length = 0
const store = usePeopleGroupsStore(createPinia())
store.profileConfig = {}
if (plant) {
store.$onAction(({ name, after }) => {
if (name === 'fetchFromPrayAPI') after(() => { for (const g of store.peopleGroups.slice(0, 3)) if (g.imageUrl) new Image().src = g.imageUrl })
})
}
await store.fetchPeopleGroups()
await new Promise((r) => setTimeout(r, WAIT_MS))
const groups = store.peopleGroups
const errs = []
if (!groups.length) errs.push('the store loaded no groups (the stub or the fixture is wrong, so nothing else here means anything)')
if (photoRequests.length) errs.push(`the store requested ${photoRequests.length} photo(s) on its own; the canvas loads only what is in view`)
const noUrl = groups.filter((g) => !g.image_url)
if (!noUrl.length) errs.push('the fixture has no group without a photo URL, so that arm cannot run')
const bad = noUrl.filter((g) => g.imageUrl)
if (bad.length) errs.push(`${bad.length} group(s) with no photo URL carry one (e.g. ${bad[0].imageUrl}), so the print shows a broken image instead of its grey box`)
const lost = groups.filter((g) => g.image_url && g.imageUrl !== g.image_url)
if (lost.length) errs.push(`${lost.length} group(s) with a photo lost their photo URL`)
return { errs, groups: groups.length, noUrl: noUrl.length }
}
const fails = []
let real = null
try {
real = await run(false)
fails.push(...real.errs)
const planted = await run(true)
if (!planted.errs.some((e) => /requested \d+ photo/.test(e))) fails.push('SELF-TEST: a planted background photo test was not caught')
} catch (e) { fails.push('could not run the store: ' + e.message) }
await server.close()
if (fails.length) { console.log('WALL-NO-BG-PHOTOS: FAIL'); for (const f of fails) console.log(' - ' + f); process.exit(1) }
console.log(`WALL-NO-BG-PHOTOS: PASS — the real store loads ${real.groups} groups and requests 0 photos in ${WAIT_MS} ms; the ${real.noUrl} groups without a photo URL get none; a planted background photo test is caught.`)
process.exit(0)

View file

@ -0,0 +1,102 @@
#!/usr/bin/env node
/**
* check-wall-print-watch — the Wall's HD poster print never prints silently incomplete. Headless.
*
* The print frame prints on its own 'load', which waits for every photo (library printInPlace.js);
* src/printWatch.js reports while it waits. This asserts, with fake timers and a fake frame whose images
* load over fake time, that the watch:
* - counts the photos as they load (the button's "Preparing the poster: n of 2,107 photos");
* - counts the ones that could not load (complete, no pixels), which the toast then names;
* - finishes exactly once, at 'load', or at the 10-minute ceiling for a load that never ends;
* - ends at once when the frame is gone, so the busy button never outlives it.
* Two-sided: each rule is removed from a copy of printWatch.js (written to the OS temp folder, never into
* the tree), and every copy must fail at least one arm.
* EXIT 0 pass · 1 fail
*/
import { readFileSync, writeFileSync, rmSync, mkdtempSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { tmpdir } from 'node:os'
import { fileURLToPath, pathToFileURL } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
const SRC = join(ROOT, 'plugin-designs/wall-of-people-groups/src/printWatch.js')
const TMP = mkdtempSync(join(tmpdir(), 'wall-print-watch-'))
// fake timers
let now = 0, seq = 0
const timers = new Map()
globalThis.setTimeout = (fn, ms = 0) => { const id = ++seq; timers.set(id, { at: now + ms, fn }); return id }
globalThis.clearTimeout = (id) => timers.delete(id)
globalThis.setInterval = (fn, ms) => { const id = ++seq; const t = { at: now + ms, fn, every: ms }; timers.set(id, t); return id }
globalThis.clearInterval = (id) => timers.delete(id)
function advanceTo(t) {
for (;;) {
let next = null
for (const [id, x] of timers) if (x.at <= t && (!next || x.at < next[1].at)) next = [id, x]
if (!next) break
const [id, x] = next; now = x.at
if (x.every) x.at += x.every; else timers.delete(id)
x.fn()
}
now = t
}
function fakeWin(n) {
const images = Array.from({ length: n }, () => ({ complete: false, naturalWidth: 0 }))
const on = {}
return { images, document: { images }, addEventListener: (type, fn) => { on[type] = fn }, fire: (type) => on[type] && on[type]() }
}
const settle = (win, k, fail = 0) => { for (let i = 0; i < k; i++) { win.images[i].complete = true; win.images[i].naturalWidth = i < fail ? 0 : 320 } }
async function arms(mod) {
const out = []
const ok = (c, what) => { if (!c) out.push(what) }
// A. all load: progress climbs, onDone once at 'load' with 0 failed
{ now = 0; timers.clear(); const w = fakeWin(10), seen = [], done = []
mod.watchPrintFrame(w, { onProgress: (c) => seen.push(c.n), onDone: (c) => done.push(c) })
settle(w, 4); advanceTo(600); settle(w, 10); advanceTo(1200); w.fire('load'); advanceTo(700000)
ok(seen[0] === 0 && seen.includes(4) && seen.includes(10), `progress counts the photos as they load (saw ${seen.join(',')})`)
ok(done.length === 1 && done[0] && done[0].n === 10 && done[0].total === 10 && done[0].failed === 0, 'onDone once at load: 10 of 10, 0 failed') }
// B. some fail: complete with no pixels are counted as failed
{ now = 0; timers.clear(); const w = fakeWin(10), done = []
mod.watchPrintFrame(w, { onDone: (c) => done.push(c) })
settle(w, 10, 3); advanceTo(1000); w.fire('load')
ok(done.length === 1 && done[0] && done[0].failed === 3, `3 photos that could not load are counted (got ${done[0] && done[0].failed})`) }
// C. a load that never ends: onDone once, at the ceiling
{ now = 0; timers.clear(); const w = fakeWin(10), done = []
mod.watchPrintFrame(w, { onDone: (c) => done.push([now, c]) })
settle(w, 6); advanceTo(599999)
ok(done.length === 0, 'no onDone before the ceiling while the frame is still loading')
advanceTo(600000)
ok(done.length === 1 && done[0][0] === 600000 && done[0][1].n === 6, 'onDone once at the 10-minute ceiling, with the count so far') }
// D. the frame is removed before its load: the watch ends at the next tick, not at the ceiling
{ now = 0; timers.clear(); const w = fakeWin(10), done = []
mod.watchPrintFrame(w, { onDone: (c) => done.push([now, c]) })
advanceTo(60000); w.closed = true; advanceTo(61000)
ok(done.length === 1 && done[0][0] <= 61000 && done[0][1] === null, `a frame that is gone ends the watch within a tick (ended at ${done[0] ? done[0][0] : 'never'})`) }
return out
}
let fails = []
const real = await import(pathToFileURL(SRC).href)
fails = await arms(real)
for (const f of fails) console.log(' FAIL ' + f)
// planted defects: each must make at least one arm fail
const src = readFileSync(SRC, 'utf8')
const PLANTS = [
['failed photos are not counted', 'if (!im.naturalWidth) failed++', ''],
['no ceiling: a load that never ends never finishes', 'cap = setTimeout(() => finish(countImages(win)), ceilingMs)', 'cap = null'],
['no progress while loading', 'iv = setInterval(tick, every)', 'iv = null'],
['a removed frame holds the watch until the ceiling', 'if (win.closed) return finish(null); ', ''],
]
let planted = 0
for (const [name, from, to] of PLANTS) {
if (!src.includes(from)) { fails.push(`SELF-TEST: the planted defect "${name}" no longer matches the source`); continue }
const p = join(TMP, `printWatch.planted-${planted}.mjs`) // printWatch.js imports nothing, so it runs from anywhere
writeFileSync(p, src.replace(from, to))
try { const bad = await arms(await import(pathToFileURL(p).href)); if (!bad.length) fails.push(`SELF-TEST: the planted defect "${name}" was not caught`); else planted++ }
finally { rmSync(p, { force: true }) }
}
rmSync(TMP, { recursive: true, force: true })
if (fails.length) { console.log(`WALL-PRINT-WATCH: FAIL — ${fails.length}`); for (const f of fails) console.log(' - ' + f); process.exit(1) }
console.log(`WALL-PRINT-WATCH: PASS — the print counts its photos while they load, counts the ones that could not load, and finishes at load or at the ceiling; ${planted} planted defects each caught.`)

View file

@ -0,0 +1,74 @@
#!/usr/bin/env node
/**
* check-wall-wysiwyg — the Wall's screen IS its poster. Headless.
*
* The Bless view draws the poster on screen (screenPoster) and prints it (buildBlessPosterHtml) from one
* layout (blessPoster.js). This asserts they stay one, for every preset: the same number of sheets, the
* same 2,107 card rects (to 0.002 in), the same per-card name size and wrap (cardText), the same verse
* reference size, the same chrome blocks (banner, themes, footer, journey), and sheets at the preset's
* aspect. A change to either path that is not made to both fails here.
*
* Two-sided: each arm is also run against a planted defect (one screen card moved 0.01 in, one name
* printed a point larger, one chrome block missing on screen), and each must be caught.
* EXIT 0 pass · 1 fail
*/
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
const m = await import(pathToFileURL(join(ROOT, 'plugin-designs/wall-of-people-groups/src/blessPoster.js')).href)
const names = Array.from({ length: 2107 }, (_, i) => (i % 97 === 0 ? 'Han Chinese, Mandarin-speaking lowlands ' + i : i % 13 === 0 ? 'Accukattavandlu' : 'Group ' + i))
const groups = names.map((name) => ({ name }))
const CARD_RE = /<div class="card" style="left:([\d.]+)in;top:([\d.]+)in;width:([\d.]+)in;height:([\d.]+)in"><div class="nm( w2)?" dir="auto" style="font-size:([\d.]+)pt(?:;-webkit-line-clamp:\d)?">/g
/** Every way the screen and the print disagree for one preset. `plant` may damage the screen side. */
function compare(id, plant = null) {
const html = m.buildBlessPosterHtml(groups, id, {})
const sp = m.screenPoster(groups, id, {})
let cardText = m.cardText
if (plant) ({ cardText = m.cardText } = plant(sp) || {})
const P = sp.plan.preset, px = sp.pxPerIn, step = P.sheetH + m.SCREEN_GAP_IN
const cards = [...html.matchAll(CARD_RE)]
const pages = (html.match(/<section class="sheet">/g) || []).length
const errs = []
if (pages !== P.sheets || sp.sheets.length !== P.sheets) errs.push(`sheets: print ${pages}, screen ${sp.sheets.length}`)
if (cards.length !== groups.length || sp.cards.length !== groups.length) errs.push(`cards: print ${cards.length}, screen ${sp.cards.length}`)
for (const R of sp.sheets) if (Math.abs(R.w / R.h - P.sheetW / P.sheetH) > 1e-9) errs.push('a screen sheet is not at the preset\'s aspect')
sp.cards.forEach((c, k) => {
const pc = sp.plan.cards[k], h = cards[k]
if (!h) return
const ct = cardText(sp.plan, names[c.i])
const d = Math.max(Math.abs(c.x / px - +h[1]), Math.abs(c.y / px - (pc.s * step + +h[2])), Math.abs(c.w / px - +h[3]), Math.abs(c.h / px - +h[4]))
if (d > 0.002) errs.push(`card ${c.i}: rect off by ${d.toFixed(4)} in`)
if (+h[6] !== ct.name.pt || !!h[5] !== ct.name.wrap) errs.push(`card ${c.i}: name prints ${h[6]} pt${h[5] || ''}, screen says ${ct.name.pt} pt${ct.name.wrap ? ' w2' : ''}`)
})
const ref = /\.card \.rf\{font-size:([\d.]+)pt/.exec(html)
if (!ref || +ref[1] !== cardText(sp.plan, '').refPt) errs.push('the verse reference size differs')
for (const k of ['class="ban"', 'class="themes"', 'class="ftr"', 'class="jny"']) {
if (html.includes(k) !== sp.chromeHtml.includes(k)) errs.push(`chrome block ${k} is on one side only`)
}
return { errs, P, sp }
}
let fails = 0
for (const id of Object.keys(m.PRESETS)) {
const { errs, P, sp } = compare(id)
console.log(` ${errs.length ? 'FAIL' : 'ok '} ${id}: ${P.sheets} sheet(s) of ${P.sheetW}×${P.sheetH} in, ${groups.length} cards at the same rects and sizes; screen world ${sp.worldW}×${sp.worldH} px${errs.length ? '\n ' + errs.slice(0, 5).join('\n ') : ''}`)
fails += errs.length ? 1 : 0
}
// Planted defects: each must make the comparison fail.
const id0 = Object.keys(m.PRESETS)[0]
const PLANTS = [
['one screen card moved 0.01 in', (sp) => { sp.cards[5].x += 0.01 * sp.pxPerIn }],
['one name a point larger on screen', () => ({ cardText: (plan, name) => { const t = m.cardText(plan, name); return name === names[7] ? { ...t, name: { ...t.name, pt: t.name.pt + 1 } } : t } })],
['the footer missing on screen', (sp) => { sp.chromeHtml = sp.chromeHtml.replace('class="ftr"', 'class="xx"') }],
]
let caught = 0
for (const [what, plant] of PLANTS) {
if (compare(id0, plant).errs.length) caught++
else { console.log(` FAIL SELF-TEST: a planted defect was not caught (${what})`); fails++ }
}
if (fails) { console.log(`WALL-WYSIWYG: FAIL — ${fails} problem(s)`); process.exit(1) }
console.log(`WALL-WYSIWYG: PASS — screen and print are one layout on every preset (${Object.keys(m.PRESETS).join(', ')}); ${caught} planted defects each caught.`)

File diff suppressed because one or more lines are too long