UPG-100: the list's total is never written into a sentence. The wall intro (upgMap.wallIntroA, {n} in 11 locales) and the fetching status take it from the funnel's last step (2,107 today; the intro printed a stale 2,106), formatted for the reader. New gate check:upg-count (gates:pre), mutation-tested
This commit is contained in:
parent
7166ca560f
commit
85508b899d
5 changed files with 98 additions and 3 deletions
69
internal/check-upg-count.mjs
Normal file
69
internal/check-upg-count.mjs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
#!/usr/bin/env node
|
||||
// check-upg-count — UPG-100 never writes its list's TOTAL into a sentence. The wall's intro printed "2,106" in all
|
||||
// eleven languages after the list had grown to 2,107, because the number lived inside the translated text. Now every
|
||||
// sentence takes it as {n}, from the funnel's last step (plugin-designs/my-upg-100-list/data/upg-total.js), formatted
|
||||
// for the reader's locale. Headless. Asserts:
|
||||
// A. the total is known (vision-funnel.json's last step) and upgTotal() reads it;
|
||||
// B. no upgMap value in any of the 11 locales contains the total (or the stale 2,106) in ANY digit system: Latin
|
||||
// with any separator, Arabic-Indic, Devanagari. A built-in NEGATIVE CONTROL plants "2,106" and must be caught;
|
||||
// C. upgMap.wallIntroA carries {n} in all 11 locales;
|
||||
// D. the consumers pass the live total: poster-wall.vue reads upgMap.wallIntroA with n (not the frozen
|
||||
// messages.upg100WallIntroA), and upg-100-list.vue passes no number literal to fetchingUupgs.
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve, dirname, join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
|
||||
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const P = (...a) => join(REPO, ...a)
|
||||
const fails = [], notes = []
|
||||
const ok = (cond, msg, note) => { if (!cond) fails.push(msg); else if (note) notes.push(` ok ${note}`) }
|
||||
const LANGS = ['en', 'es', 'fr', 'pt', 'de', 'it', 'ro', 'ru', 'ar', 'hi', 'zh']
|
||||
|
||||
let upgTotal, funnel
|
||||
try {
|
||||
;({ upgTotal } = await import(pathToFileURL(P('plugin-designs/my-upg-100-list/data/upg-total.js')).href))
|
||||
funnel = JSON.parse(readFileSync(P('data/people-groups/vision-funnel.json'), 'utf8'))
|
||||
} catch (e) {
|
||||
console.error(`UPG-COUNT: NOT MEASURED (exit 75) — an input did not load: ${e.message}`)
|
||||
process.exit(75)
|
||||
}
|
||||
|
||||
// ── A ──
|
||||
const total = upgTotal(funnel)
|
||||
ok(total > 1000, `the list's total reads as ${total} from vision-funnel.json`, `the list's total is ${total}, from the funnel`)
|
||||
|
||||
// ── B: a digit-system-blind scan ──
|
||||
const NATIVE = { '٠': 0, '١': 1, '٢': 2, '٣': 3, '٤': 4, '٥': 5, '٦': 6, '٧': 7, '٨': 8, '٩': 9, '०': 0, '१': 1, '२': 2, '३': 3, '४': 4, '५': 5, '६': 6, '७': 7, '८': 8, '९': 9 }
|
||||
const numbersIn = (s) => String(s).replace(/[٠-٩०-९]/g, (d) => NATIVE[d])
|
||||
.replace(/(\d)[,. ٬'](?=\d{3}\b)/g, '$1') // drop thousands separators
|
||||
.match(/\d+/g) || []
|
||||
const FORBIDDEN = new Set([String(total), '2106'])
|
||||
const hasTotal = (s) => numbersIn(s).some((n) => FORBIDDEN.has(n))
|
||||
ok(hasTotal('We did the same with the 2,106 unreached') && hasTotal('فعلنا المثل مع ٢١٠٦ شعوب') && hasTotal(`mit den ${String(total).replace(/(\d)(\d{3})$/, '$1.$2')} Gruppen`),
|
||||
'NEGATIVE CONTROL: the scanner missed a planted total, so it cannot guard anything', 'negative control: a planted 2,106 / ٢١٠٦ / 2.107 is caught')
|
||||
const leaks = []
|
||||
for (const l of LANGS) {
|
||||
const cat = JSON.parse(readFileSync(P('library/i18n/locales', l, 'upgMap.json'), 'utf8'))
|
||||
for (const [k, v] of Object.entries(cat)) if (typeof v === 'string' && hasTotal(v)) leaks.push(`${l} upgMap.${k}`)
|
||||
}
|
||||
ok(!leaks.length, `the total is written into a sentence: ${leaks.slice(0, 6).join(', ')}`, `no upgMap sentence in the 11 locales carries the total (${total}) or the stale 2,106`)
|
||||
|
||||
// ── C ──
|
||||
const noN = LANGS.filter((l) => !String(JSON.parse(readFileSync(P('library/i18n/locales', l, 'upgMap.json'), 'utf8')).wallIntroA || '').includes('{n}'))
|
||||
ok(!noN.length, `upgMap.wallIntroA has no {n} in: ${noN.join(', ')}`, 'the wall intro takes the total as {n} in all 11 locales')
|
||||
|
||||
// ── D ──
|
||||
const wall = readFileSync(P('plugin-designs/my-upg-100-list/poster-wall.vue'), 'utf8')
|
||||
const map = readFileSync(P('plugin-designs/my-upg-100-list/upg-100-list.vue'), 'utf8')
|
||||
ok(/t\('upgMap\.wallIntroA', \{ n: formatCount\(upgTotal\(/.test(wall) && !/t\('messages\.upg100WallIntroA'\)/.test(wall),
|
||||
'poster-wall.vue does not render the intro from upgMap.wallIntroA with the live total', 'the wall intro renders the live total')
|
||||
ok(!/fetchingUupgs', \{ n: \d/.test(map) && /fetchingUupgs', \{ n: formatCount\(upgTotal\(/.test(map),
|
||||
'upg-100-list.vue passes a number literal to fetchingUupgs', 'the fetching status shows the live total')
|
||||
|
||||
for (const n of notes) console.log(n)
|
||||
if (fails.length) {
|
||||
console.error('UPG-COUNT: FAIL')
|
||||
for (const f of fails) console.error(` ✗ ${f}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`UPG-COUNT: PASS — the list's total (${total}) lives in one place and every sentence takes it as {n}.`)
|
||||
|
|
@ -226,6 +226,7 @@
|
|||
"check:packet-a1-structure": "node internal/check-packet-a1-structure.mjs",
|
||||
"check:india-states": "node internal/check-india-states.mjs",
|
||||
"check:batch-selection-export": "node internal/check-batch-selection-export.mjs",
|
||||
"check:upg-count": "node internal/check-upg-count.mjs",
|
||||
"check:zone-title-tier-a": "node internal/check-zone-title-tier-a.mjs",
|
||||
"check:evangelical-share": "node internal/check-evangelical-share.mjs",
|
||||
"check:map-labels": "node internal/check-map-labels.mjs",
|
||||
|
|
@ -347,7 +348,7 @@
|
|||
"fix:sri": "node internal/fix-sri.mjs",
|
||||
"gallery": "node internal/generate-gallery.mjs",
|
||||
"gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:auto-engine check:engine-contract check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:no-silent-engine:self-test check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:instance-config-keys check:frozen-labels check:plural-forms check:region-fill-repaint check:region-paint-single-source check:poster-print-fallback check:posters-exportable check:country-names check:reduced-motion check:no-dead-library check:universal-props check:readme-size check:tree-relative check:leaf-hints check:snippet-engine check:snippet-engine:self-test check:embed-base check:link-coverage check:no-public-binds check:shared-reached check:tree-weight",
|
||||
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:i18n-never-translated check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:board-map-options check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:india-states check:batch-selection-export check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-new-driver-references check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:popup-pan-controls check:country-map-refetch check:pane-config-reforward check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:country-sheet-one-path check:country-sheet-one-path:self-test check:posterverse-shelf check:host-locale-fetch check:print-frame-reclaim check:click-sheet check:coverage-consistent:chained check:coverage-consistent:poison check:dashboard-filter-a11y check:filter-multivalue check:hover-channel check:kit-options-reachable check:layout-key-namespaced check:legend-mirrors check:map-unmount-race check:no-conflict-markers check:series-isolation check:snapshot-guard check:stale-sweep-claim check:surfaces-read-the-tier check:translator-contacts check:dxlife check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch",
|
||||
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:i18n-never-translated check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:board-map-options check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:india-states check:batch-selection-export check:upg-count check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-new-driver-references check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:popup-pan-controls check:country-map-refetch check:pane-config-reforward check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:country-sheet-one-path check:country-sheet-one-path:self-test check:posterverse-shelf check:host-locale-fetch check:print-frame-reclaim check:click-sheet check:coverage-consistent:chained check:coverage-consistent:poison check:dashboard-filter-a11y check:filter-multivalue check:hover-channel check:kit-options-reachable check:layout-key-namespaced check:legend-mirrors check:map-unmount-race check:no-conflict-markers check:series-isolation check:snapshot-guard check:stale-sweep-claim check:surfaces-read-the-tier check:translator-contacts check:dxlife check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch",
|
||||
"gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs",
|
||||
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
|
||||
"library-index": "node internal/generate-library-index.mjs",
|
||||
|
|
@ -560,6 +561,7 @@
|
|||
"check:packet-a1-structure": "the prayer packet is built to its specified structure, read page by page from each sheet's CONTENT: full detail = map, triangle, life calendar, % evangelical, profiles, bounded set, centered set, wheel, points; lightweight = map, triangle, profiles, wheel, points. Page 1 places a map-only vertical sheet on the packet's own margin and says it is one of the UPG 100 prayer lists only when a count is supplied; page 2 is one inverted triangle whose packets tier and \"- N\" steps appear only where true; the wheel carries the Metacamp Module 1 PDF's colours and opens at nine o'clock; all 28 legend zones resolve, Deaf included.",
|
||||
"check:india-states": "India's states-and-union-territories table (data/people-groups/india-states.json) stays true to the stamping it is derived from: all 36 ISO 3166-2:IN divisions (28 state + 8 union_territory, Nagaland + Mizoram listed even when empty), every India people group of admin-membership.json in exactly ONE division, every shapeName a feature of the served geometry, NO DRIFT (re-derived from today's inputs it is byte-identical and meta's input sha384s are the files on disk), and pin == table == served copy under a size ceiling. Mutation-tested. Headless; in gates:pre. By the UPG-100 owner.",
|
||||
"check:batch-selection-export": "UPG-100's batch selection EXPORTS its selection (restored 2026-09-30): Copy's first line parses in data/apply-grouping.mjs (names, UUPG total, colour) with the people-group CSV after it; the CSV header, quoting and formula-neutralising hold under a real CSV parse; the HUD wires the colour swatches, hex input, Copy, Export CSV and the records prefetch; the 9 new strings are in all 11 locales, translated. Mutation-tested (CSV first, no formula guard, Copy unwired: each FAILS). Headless; in gates:pre. By the UPG-100 owner.",
|
||||
"check:upg-count": "UPG-100 never writes its list's TOTAL into a sentence (the wall intro printed a stale 2,106 in eleven languages): the total comes from the funnel's last step (data/upg-total.js), no upgMap value in any locale carries it or 2,106 in any digit system (Latin, Arabic-Indic, Devanagari; a built-in negative control plants it), upgMap.wallIntroA takes {n} ×11, and the wall intro + the fetching status pass the live total. Mutation-tested. Headless; in gates:pre. By the UPG-100 owner.",
|
||||
"check:tree-relative": "the committed tree names no host of its own. With embedBaseUrl empty in the build config, the manifest must say embedBase \".\" and every scriptUrl, iframeUrl and embed snippet src must be relative. check:drop cannot see this: it looks for root-relative \"/...\" references and skips https:// ones, so a tree built with EMBED_BASE_URL (a deploy build) would pass it and pin every snippet to one host, the way the retired CDN route broke. Data addresses (runtime.apiBaseUrl) are not checked. SKIPs when the config sets embedBaseUrl. Static; takes an optional manifest path for poison tests.",
|
||||
"check:universal-props": "every plugin reads the props the Plugin Studio header sets for all of them through the library: map engine, locale, Mapbox token and token URL, and the people-group list. It reads each bundle's source AND its shipped bundle and fails on a bypass (a mapbox:// style literal, a private token global, a list fetched around the library); a by-design exception is ALLOWed by name with its reason. --report also prints the matrix. Headless.",
|
||||
"check:zone-title-tier-a": "generated zone titles ship for ONE-country zones only: the 3 such zones produce a localized, non-code title in en/es/ar that differs across languages, AND every other zone declines so its hand-written label survives unchanged (a multi-country zone has a name that is not a list of its members). Two-sided, and NOT MEASURED when the country-name supplier cannot resolve a known code.",
|
||||
|
|
|
|||
18
plugin-designs/my-upg-100-list/data/upg-total.js
vendored
Normal file
18
plugin-designs/my-upg-100-list/data/upg-total.js
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// upg-total.js — how many people groups the UPG-100 list covers, from ONE place: the funnel's last step
|
||||
// (data/people-groups/vision-funnel.json, the same figure the packet's footnote uses). Never write the number into
|
||||
// a string: the list grows (2,106 was printed in eleven languages after it had become 2,107), so every sentence
|
||||
// takes it as {n}, formatted for the reader's locale. check:upg-count keeps it that way.
|
||||
// PURE (no JSON import, so node gates can import it): the .vue callers import the funnel and pass it in.
|
||||
|
||||
/** The list's total: the funnel's last step (the whole DOXA set). 0 when the funnel is missing. */
|
||||
export function upgTotal(funnel) {
|
||||
const steps = (funnel && Array.isArray(funnel.steps)) ? funnel.steps : []
|
||||
return Number(steps.length ? steps[steps.length - 1].groups : 0) || 0
|
||||
}
|
||||
|
||||
/** A count as the reader writes numbers (2,107 · 2.107 · 2 107 · ٢٬١٠٧). */
|
||||
export function formatCount(n, locale) {
|
||||
try { return new Intl.NumberFormat(locale || undefined).format(n) } catch (_) { return String(n) }
|
||||
}
|
||||
|
||||
export default { upgTotal, formatCount }
|
||||
|
|
@ -46,6 +46,9 @@ import PosterWall from '@map/components/poster/PosterWall.vue'
|
|||
import { resolveAssetsBase } from '@map/utils/embedBase.js'
|
||||
// The reader's language, for the DATA fetch below. Not for interface text — that goes through t().
|
||||
import { getRuntimeLocale, detectLocale } from '@map/i18n/index.js'
|
||||
// The list's total for the intro, from the funnel (never a number written into a string; see data/upg-total.js).
|
||||
import visionFunnel from '../../data/people-groups/vision-funnel.json'
|
||||
import { upgTotal, formatCount } from './data/upg-total.js'
|
||||
// Named imports: only the list arrays ship in the bundle; the file's bookkeeping fields stay out.
|
||||
import { zonesForList } from './data/zones-for-list.js'
|
||||
// The SAME normalize+evaluate pipeline the sibling map profile uses to turn the live
|
||||
|
|
@ -244,7 +247,8 @@ const intro = computed(() => {
|
|||
// The three plain-text keys studio adds (rule 5B — the catalogue is theirs). Until they land, the
|
||||
// single legacy key is still the source of truth, so this renders identically before AND after: no
|
||||
// flag day, and my landing does not have to be ordered against theirs.
|
||||
const a = t('messages.upg100WallIntroA')
|
||||
// The total is {n}, formatted for the reader (it printed a stale 2,106 in all eleven languages).
|
||||
const a = t('upgMap.wallIntroA', { n: formatCount(upgTotal(visionFunnel), getRuntimeLocale() || detectLocale()) })
|
||||
const b = t('messages.upg100WallIntroEmph')
|
||||
const c = t('messages.upg100WallIntroB')
|
||||
const missing = (v, key) => !v || v === key
|
||||
|
|
|
|||
|
|
@ -129,6 +129,8 @@ import { colorForZone } from './data/zone-colors.js'
|
|||
// either to { key, name, count } from admin-counts/membership (NEVER edits them).
|
||||
import { resolveTerritory, buildCountryNameMap, formatClipboard, ADM1_COUNTS, ISO3_NAME } from './data/territory-counts.js'
|
||||
import { selectionCsv, selectionClipboard, selectionCsvFileName, regroupable } from './data/selection-export.js'
|
||||
import visionFunnel from '../../data/people-groups/vision-funnel.json'
|
||||
import { upgTotal, formatCount } from './data/upg-total.js'
|
||||
// 09b-search-bar §1(a-c): unified search index (PG + zone + territory) + the
|
||||
// tokenized contains matcher. Pure data + pure fns; this profile builds the rows
|
||||
// from the live records (PG), the legend leafZones (zone), and admin-counts +
|
||||
|
|
@ -2742,7 +2744,7 @@ onMounted(async () => {
|
|||
// The pins need none of it: they are built from `records` (lat/lng) and slugToZone, and the
|
||||
// selector evaluate() at the pin build maps slugs to zone keys without touching any geometry.
|
||||
// 68 KB, arriving at ~2.9 s cold.
|
||||
status.value = t('messages.fetchingUupgs', { n: 2106 })
|
||||
status.value = t('messages.fetchingUupgs', { n: formatCount(upgTotal(visionFunnel), packetLang()) }) // the funnel's total, never a literal
|
||||
const records = normalizeRecords(await applyPeopleGroupList(extractRecords(await recordsPromise), profileConfig))
|
||||
// 09b shift-select §1(b): build iso3→display-name from the LIVE records so an
|
||||
// ADM0 country click resolves to the exact label the legend shows (the ADM0
|
||||
|
|
|
|||
Loading…
Reference in a new issue