doxa-maps/internal/check-popup-pan-clears-controls.mjs
Doxa Maps 5a73e4d37d Guard the pan's control clearance, which a cut-height check cannot see
The popup auto-pan had no gate. Its founding bug was invisible to the obvious
one: measured at 390 on a 366x732 map, the pan fitted the popup inside the
container, the cut really was zero, and a real click on Full Profile still did
nothing, because the top element at the button's centre was
div.maplibregl-ctrl-attrib-inner on one pin and span.pin-hint-pill on the other.
Two pins that looked fixed and were not.

So this asserts the property that matters -- where the popup ENDS UP relative to
the band that takes the click -- rather than whether it was clipped.

It runs the SHIPPED panPlanFor, read out of the SFC with new Function because it
is not exported, the same way check:optional-t-equivalence reaches useOptionalT.
A re-typed copy of the arithmetic would pass forever while the component drifted,
which is the one failure a geometry gate must not have. POPUP_OFFSET is read from
the source too rather than assumed.

Each case states the resulting geometry independently instead of re-deriving the
expected number from the same formula: a self-consistent oracle cannot see an
error that shifts both sides, which is how a 12px offset hid here once already.

The band is measured at runtime, never hard-coded, so the rule is swept across
0-120px rather than pinned to one number -- 120 is far beyond anything observed
and is there to prove the rule, not the value. Also swept across every width the
component renders at, since below 768px the container is 2x width.

Mutation-tested three ways, each failing the case written for it: ignoring
bottomInset (the pre-fix behaviour) fails 15 cases; reserving only half the band
fails; dropping the Math.max clamp fails exactly the negative-inset case.
24 cases, registered in all three places, verified by re-reading the parsed file.
2026-09-29 13:31:24 +00:00

143 lines
8.6 KiB
JavaScript

#!/usr/bin/env node
/**
* check-popup-pan-clears-controls — the popup auto-pan reserves the map's bottom control band, so a
* popup that clears the CLIP is also clear of the things that take the click.
*
* ★ THE BUG THIS GUARDS, measured in a browser at 390 on a 366x732 map. The pan fitted the popup
* inside the CONTAINER and stopped there. The attribution and the pin hint are painted over the
* canvas and accept pointer events, so both NER pins panned, both popups ended fully inside the
* frame (cut 0), and a real click on Full Profile did NOTHING — at y~701 the top element was
* div.maplibregl-ctrl-attrib-inner on one pin and span.pin-hint-pill on the other. Two pins that
* looked fixed and were not: a cut-height check cannot see this, because the cut really was zero.
*
* Attribution is a LICENCE TERM of the basemap, so raising the popup above it is not available to
* us. The usable frame is simply shorter than the container, and the fit test has to say so.
*
* ★ IT RUNS THE SHIPPED FUNCTION, NOT A COPY. panPlanFor is not exported — it lives inside the SFC
* — so the source is read and the function evaluated with `new Function`, the same way
* check-optional-t-equivalence reaches useOptionalT. A re-typed copy of the arithmetic would pass
* forever while the component drifted, which is the one failure a geometry gate must not have.
*
* ★ AND IT ASSERTS THE GEOMETRY, NOT THE RETURN VALUE. Re-deriving the expected number from the
* same formula would be a self-consistent oracle: an error that shifted both sides would stay
* invisible. So each case states where the popup ENDS UP and requires that to clear the band.
*
* Exit 0 pass · 1 fail · 2 the function could not be found (never a silent pass).
*/
import { readFileSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
const SRC = join(ROOT, 'library/components/maps/RegionCountryMap.vue')
const src = readFileSync(SRC, 'utf8')
// The whole function body, from its signature to the closing brace at column 0.
const m = src.match(/function panPlanFor\(\{[\s\S]*?\n\}/)
if (!m) {
console.error('POPUP-PAN-CONTROLS: NOT MEASURED — panPlanFor was not found in RegionCountryMap.vue.')
console.error(' It may have been renamed or moved. Point this gate at it rather than deleting the gate:')
console.error(' a geometry rule with no test is how the bottom-control bug shipped the first time.')
process.exit(2)
}
let panPlanFor
try { panPlanFor = new Function(`${m[0]}\nreturn panPlanFor`)() } catch (e) {
console.error(`POPUP-PAN-CONTROLS: NOT MEASURED — panPlanFor did not evaluate: ${e.message}`)
process.exit(2)
}
// POPUP_OFFSET is the component's own gap between pin and popup; read it rather than assume 12.
const offMatch = src.match(/const POPUP_OFFSET = (\d+)/)
const OFFSET = offMatch ? Number(offMatch[1]) : 12
let bad = 0
const ok = (cond, what) => { if (!cond) bad++; console.log(` ${cond ? 'ok ' : 'FAIL'} ${what}`) }
/**
* Where the popup's far edge lands after the plan is applied.
* A positive plan moves the PIN down the screen and the popup opens ABOVE it; a negative plan moves
* the pin up and the popup opens BELOW. Stated here from the geometry, independently of panPlanFor.
*/
function popupBottomAfter({ containerH, popupH, pinY, bottomInset }) {
const dy = panPlanFor({ containerH, popupH, pinY, offset: OFFSET, bottomInset })
const pin = pinY + dy
return { dy, bottom: dy > 0 ? pin - OFFSET : pin + popupH + OFFSET }
}
// ── 1. THE MEASURED CASE. NER at 390: a 366x732 map, a ~452px popup, a pin in the lower half.
{
const c = { containerH: 732, popupH: 452, pinY: 345 }
const band = 44
const withoutBand = popupBottomAfter({ ...c, bottomInset: 0 })
const withBand = popupBottomAfter({ ...c, bottomInset: band })
ok(withoutBand.bottom > 732 - band,
`without the band reserved the popup ends at ${Math.round(withoutBand.bottom)}, inside the control strip — the bug`)
ok(withBand.bottom <= 732 - band + 0.5,
`with the band reserved it ends at ${Math.round(withBand.bottom)}, clear of a ${band}px strip`)
}
// ── 2. THE BAND HEIGHT IS NOT HARD-CODED ANYWHERE. It is measured at runtime, so the rule has to
// hold across the range the bottom chrome really occupies.
//
// ★ THE RANGE IS WIDER THAN THE ATTRIBUTION, and guessing it was my mistake. This list stopped at
// 120px, described as "far beyond anything observed". Then the band was MEASURED in a browser at
// 390: the attribution is only 44px (y678..722), but the PIN-HINT PILL wraps to several lines and
// is 118px tall (y577..699) — an effective band of about 150px while the hint is showing, a quarter
// again beyond the ceiling I had invented. At 1440 the same two are 24px and 38px.
// So the sweep now runs past what has been measured rather than past what I expected, and 200px is
// deliberately unreasonable: the point is that the RULE holds for any band, since the only number
// the component ever uses is the one it measures at runtime.
for (const band of [0, 24, 35, 38, 44, 57, 70, 90, 118, 120, 150, 200]) {
const { bottom } = popupBottomAfter({ containerH: 732, popupH: 452, pinY: 345, bottomInset: band })
ok(bottom <= 732 - band + 0.5, `band ${String(band).padStart(3)}px: popup ends at ${Math.round(bottom)}, clear of ${732 - band}`)
}
// ── 2b. THE MEASURED WORST CASE, and the one width where it does NOT fit. With the pin hint
// showing at 390 the band is ~150px, so the popup needs 464 + 150 = 614px of container. Below that
// the pan must REFUSE (plan 0) rather than move the camera to no purpose — the popup stays clipped,
// which is the honest failure, instead of becoming a clipped popup AND a jumped map.
// 296px of container is a real case: a 320px viewport with 12px margins on a host page.
{
const band = 150
for (const [containerW, containerH, shouldFit] of [[320, 640, true], [296, 592, false], [390, 780, true]]) {
const dy = panPlanFor({ containerH, popupH: 452, pinY: Math.round(containerH * 0.47), offset: OFFSET, bottomInset: band })
const fits = (452 + OFFSET) <= containerH - band
ok(fits === shouldFit, `${containerW}px wide (container ${containerH}) with the hint showing: ${fits ? 'fits' : 'does not fit'}`)
if (!fits) ok(dy === 0, ` and an unfittable frame plans NO pan (${dy}) rather than a partial one`)
}
}
// ── 3. WHAT MUST NOT CHANGE. The band only ever removes space; it must never start a pan that the
// old code would not have made, and never move a popup that already fits.
{
ok(panPlanFor({ containerH: 780, popupH: 452, pinY: 390, offset: OFFSET, bottomInset: 0 })
=== panPlanFor({ containerH: 780, popupH: 452, pinY: 390, offset: OFFSET }),
'bottomInset 0 is identical to the behaviour before this rule existed')
ok(panPlanFor({ containerH: 732, popupH: 452, pinY: 600, offset: OFFSET, bottomInset: 44 }) === 0,
'a pin whose anchor already fits is not moved (measured: pan 0 in both arms on those pins)')
ok(panPlanFor({ containerH: 500, popupH: 480, pinY: 250, offset: OFFSET, bottomInset: 44 }) === 0,
'a popup taller than the USABLE frame refuses rather than jumping the camera to no purpose')
ok(panPlanFor({ containerH: 732, popupH: 452, pinY: 345, offset: OFFSET, bottomInset: -50 })
=== panPlanFor({ containerH: 732, popupH: 452, pinY: 345, offset: OFFSET }),
'a negative inset is clamped, never widening the frame beyond the container')
for (const bad of [NaN, undefined, null]) {
ok(panPlanFor({ containerH: 732, popupH: 452, pinY: bad, offset: OFFSET, bottomInset: 44 }) === 0,
`an unmeasurable pin (${String(bad)}) plans no pan rather than NaN`)
}
}
// ── 4. EVERY WIDTH THIS COMPONENT RENDERS AT still resolves. Container height is 2x width below
// 768px (aspect-ratio 1/2) and max(780, 16/9) at or above it, so a phone is TALLER relative to its
// width, not shorter. With the band reserved the popup must still fit at all of them.
for (const w of [320, 360, 390, 414, 767, 768, 1024, 1440]) {
const containerH = w < 768 ? 2 * w : Math.max(780, Math.round(w * 9 / 16))
const { bottom } = popupBottomAfter({ containerH, popupH: 452, pinY: Math.round(containerH * 0.47), bottomInset: 57 })
ok(bottom <= containerH - 57 + 0.5, `${String(w).padStart(4)}px wide (container ${containerH}): popup clears the band`)
}
if (bad) {
console.error(`\nPOPUP-PAN-CONTROLS: FAIL — ${bad} case(s). The pan no longer guarantees the popup's buttons`)
console.error('are clear of the map\'s bottom controls, so a click on them can land on the attribution instead.')
process.exit(1)
}
console.log('\nPOPUP-PAN-CONTROLS: PASS — the pan clears the bottom control band at every width and band height tested.')