rega RQ3(b): the packet's "Has Prayer" selection showed 50 of 997 where the legend row said 699. The legend row is right and the packet was wrong. A pin's prayer count arrives TWICE. The GeoJSON property is whatever was true when the page loaded; from then on polling writes the current count with setFeatureState, and prayer-progress.js:94 colours from ['feature-state','peoplePraying'] once polling is on. So the lit pins a reader counts are feature-state, and pin-selection.js had no 'feature-state' case at all -- it resolved to undefined, to-number defaulted it to 0, and the comparison selected only the pins whose FROZEN property happened to be above zero. 50 of them. The other 649 people groups had been prayed for and the packet left them out, which is the one thing a prayer packet must not get wrong. resolveExpr and matchesExpression now take the state alongside the properties, and selectedPinIds reads it per pin off the MAP rather than off the feature -- the source still holds the GeoJSON it loaded, setFeatureState never touches it. The source is added with promoteId:'uniqueId', so the id feature-state is keyed by is the same props[idKey] the selection already uses. Degrading is deliberate in both directions: before the first poll there is no state and property expressions still select, so an early packet is thinner and never empty; getFeatureState throws on an unknown source, so it is guarded and a bad source loses the selection rather than aborting the print. ALSO, rega RQ3(a), a candidate and not yet a confirmed cause. prewarmRegionPaint wrapped any non-feature-reading paint value in a case to make it data-driven, including a zoom interpolate -- I argued for that explicitly. useMapLayers.js:319 already held the rule and I had not read it: zoom interpolation is kept by putting the case in the interpolate OUTPUTS, and "the reverse (zoom nested inside a 'case') is what breaks the layer, so don't do that." That reverse is the shape Mapbox classifies as composite before reaching for an interpolation factor the expression cannot supply, which is the class of the interpolationFactor page error. The wrap now skips zoom expressions too. A missed pre-warm costs a frame; an illegal paint costs the layer. None of the three properties it touches is a zoom interpolate at boot, so this is not proof -- it removes the only writer that can build that shape from a runtime value. check-pin-selection asserts the live count wins over the stale property, that the stale property alone selects nothing, that a map with no feature-state still selects on properties, and that a throwing getFeatureState degrades. Negative-tested: removing the case, not reading the state, and dropping the throw-guard each turn it red; restored, green.
119 lines
7.6 KiB
JavaScript
119 lines
7.6 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* check-pin-selection — the shared source reader understands BOTH engines, and says so when it cannot read.
|
|
*
|
|
* ★ WHY THIS EXISTS, and it is a specific bug rather than a principle. library/maps/pin-selection.js
|
|
* reads a GeoJSON source's features to answer "which pins does this expression select" — it backs
|
|
* zoomToLegendRow (the camera fit on a legend row click) for research-map, country-posters and
|
|
* doxa-simple-map, and the research map's prayer packet. It was written against Mapbox, where
|
|
* `source._data` IS the GeoJSON. maplibre-gl wraps it:
|
|
*
|
|
* this._data = typeof options.data === 'string' ? { url: options.data } : { geojson: options.data }
|
|
*
|
|
* so `data.features` was undefined and the reader returned an EMPTY LIST on MapLibre — the keyless
|
|
* engine the public builds use. Measured by rega on 2026-09-28: a language-family selection and a
|
|
* prayer selection both logged "0 pin(s) selected" with the pins plainly on screen.
|
|
*
|
|
* ★ THE FAILURE MODE IS WHY A TEST IS THE ONLY GUARD. Every caller is written to NO-OP when it finds no
|
|
* features — deliberately, so a legend row for an off-screen region degrades quietly rather than
|
|
* throwing. So the engine bug produced no error, no console line and no visual difference except a
|
|
* camera that did not move. Nothing but an assertion on both shapes can see it.
|
|
*
|
|
* WHAT IT ASSERTS, with no browser and no map:
|
|
* 1. the MAPBOX shape (raw FeatureCollection on _data) reads its features;
|
|
* 2. the MAPLIBRE shape ({ geojson }) reads the SAME features — the regression that shipped;
|
|
* 3. a REMOTE source ({ url }) returns [] and WARNS — "cannot read" is a different fact from "found
|
|
* none", and collapsing them is how the first bug hid;
|
|
* 4. the serialize() fallback still works, and a missing source is [] rather than a throw;
|
|
* 5. selectedPinIds ANDs its expressions and returns ids, so the reader and the evaluator agree.
|
|
*
|
|
* Headless, no network. Exit 0 pass · 1 fail.
|
|
*/
|
|
import { resolve, dirname } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
const TAG = 'PIN-SELECTION'
|
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
const { readSourceFeatures, selectedPinIds, matchesExpression } =
|
|
await import(resolve(ROOT, 'library/maps/pin-selection.js'))
|
|
|
|
const FEATURES = [
|
|
{ type: 'Feature', properties: { uniqueId: 'a', languageFamily: 'Afro-Asiatic' }, geometry: { type: 'Point', coordinates: [1, 1] } },
|
|
{ type: 'Feature', properties: { uniqueId: 'b', languageFamily: 'Indo-European' }, geometry: { type: 'Point', coordinates: [2, 2] } },
|
|
{ type: 'Feature', properties: { uniqueId: 'c', languageFamily: 'Afro-Asiatic' }, geometry: { type: 'Point', coordinates: [3, 3] } },
|
|
]
|
|
const COLLECTION = { type: 'FeatureCollection', features: FEATURES }
|
|
const mapWith = (src) => ({ getSource: (id) => (id === 'pins' ? src : null) })
|
|
|
|
const fails = []
|
|
const eq = (what, got, want) => { if (got !== want) fails.push(`${what}: got ${got}, expected ${want}`) }
|
|
|
|
// 1 + 2 — the two engines must agree, which is the whole point
|
|
eq('mapbox shape (_data = FeatureCollection)', readSourceFeatures(mapWith({ _data: COLLECTION }), 'pins').length, 3)
|
|
eq('maplibre shape (_data = { geojson })', readSourceFeatures(mapWith({ _data: { geojson: COLLECTION } }), 'pins').length, 3)
|
|
|
|
// 3 — a remote source: empty AND loud
|
|
const warned = []
|
|
const realWarn = console.warn
|
|
console.warn = (...a) => warned.push(a.join(' '))
|
|
const remote = readSourceFeatures(mapWith({ _data: { url: 'https://example.invalid/pins.json' } }), 'pins')
|
|
console.warn = realWarn
|
|
eq('remote ({ url }) returns no features', remote.length, 0)
|
|
if (!warned.some((w) => w.includes('remote'))) fails.push('remote ({ url }): returned [] SILENTLY — "cannot read" must not look like "found none"')
|
|
|
|
// 4 — fallbacks
|
|
eq('serialize() fallback', readSourceFeatures(mapWith({ serialize: () => ({ data: COLLECTION }) }), 'pins').length, 3)
|
|
eq('missing source', readSourceFeatures(mapWith(null), 'pins').length, 0)
|
|
eq('single Feature (not a collection)', readSourceFeatures(mapWith({ _data: FEATURES[0] }), 'pins').length, 1)
|
|
|
|
// 5 — the reader and the evaluator agree, on both shapes
|
|
for (const [label, src] of [['mapbox', { _data: COLLECTION }], ['maplibre', { _data: { geojson: COLLECTION } }]]) {
|
|
const ids = selectedPinIds(mapWith(src), {
|
|
sourceId: 'pins',
|
|
expressions: [['==', ['get', 'languageFamily'], 'Afro-Asiatic']],
|
|
})
|
|
eq(`${label}: selectedPinIds narrows to the matching pins`, [...ids].sort().join(','), 'a,c')
|
|
const all = selectedPinIds(mapWith(src), { sourceId: 'pins', expressions: [] })
|
|
eq(`${label}: no expressions means every pin, not none`, all.size, 3)
|
|
}
|
|
eq('expressions are ANDed', matchesExpression(['all', ['==', ['get', 'uniqueId'], 'a'], ['==', ['get', 'languageFamily'], 'Indo-European']], FEATURES[0].properties), false)
|
|
|
|
// ── PACKETSEL: the prayer count is LIVE, so the packet must read feature-state ──────────────────
|
|
// rega, RQ3(b): the packet's "Has Prayer" selection showed 50 of 997 where the legend row said 699.
|
|
// The legend is right. Polling writes each pin's current count with setFeatureState() and
|
|
// prayer-progress.js:94 colours from ['feature-state','peoplePraying']; the GeoJSON property is
|
|
// whatever was true at page load. Reading the property selects the wrong people to pray for, which is
|
|
// the one thing this packet exists to get right.
|
|
const HAS_PRAYER = ['>', ['to-number', ['feature-state', 'peoplePraying'], 0], 0]
|
|
// 'a' has prayer only in the live state, 'b' only in the stale property, 'c' in neither.
|
|
const PRAY_FEATURES = [
|
|
{ type: 'Feature', properties: { uniqueId: 'a', peoplePraying: 0 }, geometry: { type: 'Point', coordinates: [1, 1] } },
|
|
{ type: 'Feature', properties: { uniqueId: 'b', peoplePraying: 12 }, geometry: { type: 'Point', coordinates: [2, 2] } },
|
|
{ type: 'Feature', properties: { uniqueId: 'c', peoplePraying: 0 }, geometry: { type: 'Point', coordinates: [3, 3] } },
|
|
]
|
|
const STATE = { a: { peoplePraying: 7 } }
|
|
const prayMap = {
|
|
getSource: (id) => (id === 'pins' ? { _data: { type: 'FeatureCollection', features: PRAY_FEATURES } } : null),
|
|
getFeatureState: ({ id }) => STATE[id] || {},
|
|
}
|
|
eq('feature-state selects the LIVE prayer count, not the loaded one',
|
|
[...selectedPinIds(prayMap, { sourceId: 'pins', expressions: [HAS_PRAYER] })].sort().join(','), 'a')
|
|
eq('the stale property alone does NOT select a pin',
|
|
selectedPinIds(prayMap, { sourceId: 'pins', expressions: [HAS_PRAYER] }).has('b'), false)
|
|
// A map with no feature-state at all (no polling yet) must still select on the loaded property rather
|
|
// than collapsing to nothing — a packet printed before the first poll is thinner, never empty.
|
|
const noState = { getSource: prayMap.getSource }
|
|
eq('no getFeatureState -> property expression still works',
|
|
[...selectedPinIds(noState, { sourceId: 'pins', expressions: [['>', ['to-number', ['get', 'peoplePraying'], 0], 0]] })].join(','), 'b')
|
|
eq('feature-state on a map that has none is undefined, not a throw',
|
|
matchesExpression(HAS_PRAYER, PRAY_FEATURES[1].properties, undefined), false)
|
|
eq('a source that throws on getFeatureState degrades instead of aborting the print',
|
|
selectedPinIds({ getSource: prayMap.getSource, getFeatureState: () => { throw new Error('no such source') } },
|
|
{ sourceId: 'pins', expressions: [HAS_PRAYER] }).size, 0)
|
|
|
|
if (fails.length) {
|
|
console.error(`${TAG}: FAIL`)
|
|
for (const f of fails) console.error(` ✗ ${f}`)
|
|
process.exit(1)
|
|
}
|
|
console.log(`${TAG}: PASS — both engine shapes read the same features, a remote source says so rather than looking empty, selectedPinIds agrees with the reader, and the prayer count is read LIVE from feature-state rather than frozen at load.`)
|