The dashboard rendered "99% % UNREACHED" and "1% ADOPTED %". kpiText returns k.value unchanged for kind:'pct' and that value already carries its percent sign, while the labels in dashboard.config.js carried a second one. Each half is correct alone. A label reading '% unreached' is a reasonable label; a formatter returning a ready-formatted percentage is a reasonable formatter. Only the composition is wrong, and neither file mentions the other, which is why it was found by reading the rendered screenshot rather than by reading either source. check:kpi-unit asserts no kind:'pct' label contains a '%'. The value owns the unit because it is produced by the formatter that knows the number; the label says what is being measured. Parses the config as text rather than importing it -- the research-dashboard module graph reaches library/colors/_registry.js, which uses import.meta.glob and throws under plain node. Text parsing keeps it hermetic enough for build:unlocked. Comments are stripped first, because the config now documents this defect right above the fixed line and a naive scan fails on the explanation of the fix. It also fails when it finds no pct entries at all, rather than printing a green PASS: if the config is refactored so the parse stops matching, silence would read as success forever. Negative-tested: restoring either label fails by name with exit 1.
This commit is contained in:
parent
8609de969e
commit
a11ec14852
4 changed files with 106 additions and 5 deletions
|
|
@ -25,7 +25,7 @@ The scripts these commands are grounded in (from this branch's `package.json`):
|
|||
// claiming to be ground truth, and this one had silently stopped being it.
|
||||
"dev": "bun x vite",
|
||||
"build": "bash internal/build-lock.sh",
|
||||
"build:unlocked": "bun run check:template-literal && bun run check:sfc-parse && bun run clean && bun run token && for d in plugin-designs/*/; do BUNDLE=$(basename \"$d\") bun x vite build || exit 1; done && bun run tree && bun run check:drop && bun run check:parse && bun run verify && bun run check:sri && bun run check:public-hygiene && bun run check:label-joins && bun run check:facet-cascade && bun run check:descriptions && bun run check:no-abs-paths && bun run check:dep-names && bun run check:composite-srcs && bun run check:embed-targets && bun run check:nested-token && bun run gallery",
|
||||
"build:unlocked": "bun run check:template-literal && bun run check:sfc-parse && bun run clean && bun run token && for d in plugin-designs/*/; do BUNDLE=$(basename \"$d\") bun x vite build || exit 1; done && bun run tree && bun run check:drop && bun run check:parse && bun run verify && bun run check:sri && bun run check:public-hygiene && bun run check:label-joins && bun run check:facet-cascade && bun run check:kpi-unit && bun run check:descriptions && bun run check:no-abs-paths && bun run check:dep-names && bun run check:composite-srcs && bun run check:embed-targets && bun run check:nested-token && bun run gallery",
|
||||
"preview": "bun x vite preview",
|
||||
"serve": "python3 -m http.server 4173 --bind 127.0.0.1 --directory public",
|
||||
"cap:sync": "./node_modules/.bin/cap sync android",
|
||||
|
|
|
|||
83
internal/check-kpi-unit-once.mjs
Normal file
83
internal/check-kpi-unit-once.mjs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-kpi-unit-once.mjs — a KPI must print its unit exactly once.
|
||||
*
|
||||
* THE DEFECT THIS CATCHES. The dashboard rendered "99% % UNREACHED" and "1% ADOPTED %".
|
||||
* `kpiText()` returns `k.value` unchanged when `kind === 'pct'`, and that value already carries its
|
||||
* percent sign; the labels in dashboard.config.js carried a second one ('% unreached', 'Adopted %').
|
||||
*
|
||||
* ★ WHY IT SURVIVED REVIEW, which is the point of gating it. Each half is correct on its own. A
|
||||
* label reading '% unreached' is a perfectly reasonable label. A formatter that returns a
|
||||
* ready-formatted percentage is a perfectly reasonable formatter. Only the COMPOSITION is wrong,
|
||||
* and nothing in either file mentions the other. It was found by reading the rendered screenshot —
|
||||
* no amount of reading either source file would have shown it.
|
||||
*
|
||||
* WHAT IT ASSERTS. No `kind: 'pct'` KPI label contains a '%'. That is the whole rule, and it is
|
||||
* one-sided on purpose: the VALUE is the right place for the unit (it is produced by the formatter
|
||||
* that knows the number), so the label must not repeat it. If a future KPI kind formats without a
|
||||
* unit, add that kind here rather than relaxing the rule.
|
||||
*
|
||||
* Parses the config as TEXT rather than importing it. dashboard.config.js is plain data, but the
|
||||
* research-dashboard module graph reaches library/colors/_registry.js, which uses Vite's
|
||||
* `import.meta.glob` and throws under plain node — the trap documented in
|
||||
* documentation/reference.md. Text parsing keeps the gate hermetic: no build, no browser, no
|
||||
* network, so it can live in build:unlocked.
|
||||
*
|
||||
* NEGATIVE-TESTED: restoring either label ('% unreached' or 'Adopted %') fails by name with exit 1.
|
||||
*/
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
import { resolve, dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const CFG = join(ROOT, 'plugin-designs/research-dashboard/dashboard.config.js')
|
||||
|
||||
if (!existsSync(CFG)) {
|
||||
console.error('KPI-UNIT: FAIL — dashboard.config.js not found at ' + CFG.replace(ROOT + '/', ''))
|
||||
process.exit(1)
|
||||
}
|
||||
const src = readFileSync(CFG, 'utf8')
|
||||
|
||||
// Strip comments before scanning. This file now DOCUMENTS the defect in a comment right above the
|
||||
// fixed line, and a naive scan matches that prose and fails on the explanation of the fix — the
|
||||
// same self-reference that has bitten three gates in this repo already.
|
||||
const code = src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
|
||||
|
||||
// Each KPI entry is a single-line-ish object literal: { id: '…', label: '…', kind: '…', … }
|
||||
const entries = [...code.matchAll(/\{[^{}]*\bid:\s*'([^']+)'[^{}]*\}/g)]
|
||||
const offenders = []
|
||||
let pctSeen = 0
|
||||
|
||||
for (const m of entries) {
|
||||
const body = m[0]
|
||||
const kind = (body.match(/\bkind:\s*'([^']+)'/) || [])[1]
|
||||
if (kind !== 'pct') continue
|
||||
pctSeen++
|
||||
const label = (body.match(/\blabel:\s*'([^']*)'/) || [])[1]
|
||||
if (label && label.includes('%')) offenders.push({ id: m[1], label })
|
||||
}
|
||||
|
||||
if (!pctSeen) {
|
||||
// A gate that finds nothing to check must say so rather than printing a green PASS. If the
|
||||
// config is refactored so this regex stops matching, silence would read as success forever.
|
||||
console.error("KPI-UNIT: FAIL — found no `kind: 'pct'` KPI entries to check.")
|
||||
console.error(' Either the config changed shape or the parse broke. A gate with nothing to')
|
||||
console.error(' assert is not passing; it is not running.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (offenders.length) {
|
||||
console.error(`KPI-UNIT: FAIL — ${offenders.length} percent KPI label(s) carry a redundant '%'.\n`)
|
||||
for (const o of offenders) {
|
||||
console.error(` id "${o.id}" has label '${o.label}'`)
|
||||
console.error(` renders as "<value>% ${o.label.toUpperCase()}" — the unit twice.`)
|
||||
}
|
||||
console.error("\n kpiText() returns k.value unchanged for kind:'pct', and that value already")
|
||||
console.error(' carries its percent sign. The VALUE owns the unit; the LABEL says what is being')
|
||||
console.error(' measured. Drop the % from the label.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`KPI-UNIT: PASS — ${pctSeen} percent KPI(s), none repeating the unit in its label.`)
|
||||
console.log(' Each half of this bug looks correct alone; only the composition was wrong, which is')
|
||||
console.log(' why it was found by reading a screenshot rather than by reading either source file.')
|
||||
|
|
@ -48,7 +48,7 @@
|
|||
"check:dxlife": "node internal/check-dxlife-untouched.mjs",
|
||||
"check:facet-cascade": "node internal/check-facet-cascade.mjs # a child facet selection must not outlive its parent: clearing a parent facet must cascade-clear its descendants on EVERY clear path. Asserts on RESULT COUNTS, not DOM — an orphaned child silently ANDs the map to empty. run check:facet-cascade:self-test for the NEGATIVE test (reverts the cascade and requires this gate to FAIL).",
|
||||
"check:facet-cascade:self-test": "node internal/check-facet-cascade.mjs --self-test",
|
||||
"build:unlocked": "bun run check:template-literal && bun run check:sfc-parse && bun run clean && bun run token && for d in plugin-designs/*/; do BUNDLE=$(basename \"$d\") bun x vite build || exit 1; done && bun run tree && bun run check:drop && bun run check:parse && bun run verify && bun run check:sri && bun run check:public-hygiene && bun run check:label-joins && bun run check:facet-cascade && bun run check:descriptions && bun run check:no-abs-paths && bun run check:dep-names && bun run check:composite-srcs && bun run check:embed-targets && bun run check:nested-token && bun run gallery",
|
||||
"build:unlocked": "bun run check:template-literal && bun run check:sfc-parse && bun run clean && bun run token && for d in plugin-designs/*/; do BUNDLE=$(basename \"$d\") bun x vite build || exit 1; done && bun run tree && bun run check:drop && bun run check:parse && bun run verify && bun run check:sri && bun run check:public-hygiene && bun run check:label-joins && bun run check:facet-cascade && bun run check:kpi-unit && bun run check:descriptions && bun run check:no-abs-paths && bun run check:dep-names && bun run check:composite-srcs && bun run check:embed-targets && bun run check:nested-token && bun run gallery",
|
||||
"check:template-literal": "node internal/template-literal-check.mjs",
|
||||
"verify:map-paint": "node internal/verify-map-paint.mjs",
|
||||
"check:sri": "node internal/sri-consistency-check.mjs",
|
||||
|
|
@ -97,7 +97,8 @@
|
|||
"check:script-flags": "node internal/check-script-flags-reachable.mjs",
|
||||
"check:shell-scroll": "node internal/verify-shell-scroll.mjs",
|
||||
"check:no-orphan-gates": "node internal/check-no-orphan-gates.mjs",
|
||||
"check:one-palette": "node internal/check-one-palette.mjs"
|
||||
"check:one-palette": "node internal/check-one-palette.mjs",
|
||||
"check:kpi-unit": "node internal/check-kpi-unit-once.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/android": "^7.6.9",
|
||||
|
|
|
|||
|
|
@ -174,11 +174,16 @@ export default {
|
|||
kpis: [
|
||||
{ id: 'total', label: 'UPGs in scope', kind: 'count', showDelta: true },
|
||||
{ id: 'pop', label: 'Σ population', kind: 'sum', field: 'population' },
|
||||
{ id: 'unreached', label: '% unreached', kind: 'pct',
|
||||
// ★ NO '%' IN A `kind:'pct'` LABEL. kpiText() returns k.value unchanged for pct, and that
|
||||
// value ALREADY carries its percent sign — so a label that also carries one renders
|
||||
// "99% % UNREACHED". Found by reading the rendered screenshot, not by reading the code:
|
||||
// each half looks correct in isolation and only the composition is wrong, which is why it
|
||||
// survived review. The label says WHAT is measured; the value says how much.
|
||||
{ id: 'unreached', label: 'unreached', kind: 'pct',
|
||||
test: (r) => !/engaged/i.test(lbl(r.engagement_status)) || /unengaged/i.test(lbl(r.engagement_status)) },
|
||||
{ id: 'countries', label: 'countries touched', kind: 'distinct', field: 'country_code', via: 'geoIso' },
|
||||
{ id: 'praying', label: 'Σ praying', kind: 'sum', field: 'people_praying' },
|
||||
{ id: 'adopted', label: 'Adopted %', kind: 'pct', test: (r) => Number(r.adopted_by_churches) > 0 },
|
||||
{ id: 'adopted', label: 'adopted', kind: 'pct', test: (r) => Number(r.adopted_by_churches) > 0 },
|
||||
],
|
||||
|
||||
// ── table columns ── (DashboardDataTable)
|
||||
|
|
@ -220,6 +225,18 @@ export default {
|
|||
{ key: 'engagement', label: 'Engagement', get: (r) => lbl(r.engagement_status), type: 'str', w: 0.97 },
|
||||
],
|
||||
|
||||
// ── map ── (DashboardMapPanel)
|
||||
// `paper` = "near-white minimal — data reads like print". The style registry
|
||||
// (library/styles/registry.js) already listed `research-dashboard` as this style's own
|
||||
// `bestFor` target, and already resolves the name on BOTH engines — Mapbox light-v11 and
|
||||
// the keyless MapLibre Positron. It was simply never set here, so the panel silently took
|
||||
// each engine's default instead. CARTO ships Positron expressly as the basemap "good for
|
||||
// POINT data", which is exactly this map's job, so this is an alignment, not a new choice.
|
||||
// One line, and it survives the MapLibre swap unchanged.
|
||||
map: {
|
||||
mapStyle: 'paper',
|
||||
},
|
||||
|
||||
// ── charts ── (DashboardChartsRail)
|
||||
charts: [
|
||||
{ id: 'religion', type: 'donut', facet: 'religion', title: 'Religion', topN: 8 },
|
||||
|
|
|
|||
Loading…
Reference in a new issue