From 9cdb59c1f1ce7f5d80176f50d6d6af58912fdf08 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Tue, 29 Sep 2026 13:56:18 +0000 Subject: [PATCH] A gate that the band the pan reserves is the band the controls occupy check:popup-pan-controls proves the ARITHMETIC: given a band height, the pan clears it. Nothing proves the band height is real. If bottomControlInset returns 0 -- a selector typo, an engine renaming its control classes, a control moving outside the container -- then panPlanFor is handed bottomInset 0, behaves exactly as it did before the fix, and check:popup-pan-controls still passes every case because its arithmetic is still correct. The bug would be back with both gates green. That is the failure this guards. Seven cases against a real DOM, running the SHIPPED bottomControlInset read out of the SFC (it is not exported), never a re-typed copy: a visible attribution reserves a band EQUAL to what the DOM independently says, not merely non-zero, which a stub returning 1 would satisfy; no controls reserves exactly 0; a zero-sized control reserves nothing, since counting it would reserve the whole map and refuse every popup; the taller of two corners wins, because the pin hint is bottom-left while the attribution is bottom-right; a wrapped attribution reserves more than one line, which is what a hard-coded constant gets wrong at 390; a control outside the container is ignored; a null container returns 0 rather than throwing. It needs a real browser: every assertion rests on getBoundingClientRect, and no DOM library here computes layout, so a headless stand-in would return zeros and pass vacuously. NOT REGISTERED. Browsers are rega's seat, so this has not been run, and verify-browser-all's rule is that every gate listed there was run before it was listed. It is registered only after rega's PASS. --- internal/check-control-inset-measured.mjs | 172 ++++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 internal/check-control-inset-measured.mjs diff --git a/internal/check-control-inset-measured.mjs b/internal/check-control-inset-measured.mjs new file mode 100644 index 000000000..4db1ff502 --- /dev/null +++ b/internal/check-control-inset-measured.mjs @@ -0,0 +1,172 @@ +#!/usr/bin/env node +/** + * check-control-inset-measured — bottomControlInset actually MEASURES the map's bottom controls. + * + * ★ WHY THIS EXISTS SEPARATELY FROM check:popup-pan-controls. That gate proves the ARITHMETIC: given + * a band height, the pan clears it. This one proves the band height is real. The two halves fail + * differently, and the second failure is the silent one: if this function returns 0 — a selector + * typo, an engine that renames its control classes, a control that lives outside the container — + * then panPlanFor is handed bottomInset 0, behaves exactly as it did before the fix, and + * check:popup-pan-controls still passes every case, because its arithmetic is still correct. + * The bug would be back and both gates would be green. + * + * So this asserts the measurement against a REAL DOM: the engine's own class names, the box model it + * actually produces, and the cases that make a naive implementation wrong. + * + * ★ IT RUNS THE SHIPPED FUNCTION, not a copy. bottomControlInset is not exported — it lives inside + * the SFC — so the source is read and evaluated in the page, the same reach + * check:popup-pan-controls uses for panPlanFor. A re-typed copy would pass forever while the + * component drifted, which is the one failure this gate must not have. + * + * Exit 0 pass · 1 fail · 2 the function could not be found · 75 no browser slot. + */ +import { readFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium } from './lib/playwright-guarded.mjs' + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') +const SRC = join(ROOT, 'library/components/maps/RegionCountryMap.vue') +const src = readFileSync(SRC, 'utf8') + +const m = src.match(/function bottomControlInset\(container\)[\s\S]*?\n\}/) +if (!m) { + console.error('CONTROL-INSET: NOT MEASURED — bottomControlInset was not found in RegionCountryMap.vue.') + console.error(' Renamed or moved? Re-point this gate rather than dropping it: without it, an inset that') + console.error(' silently returns 0 restores the original bug while every other gate stays green.') + process.exit(2) +} +const FN = m[0] + +// A map container shaped like the real one: the engine's four corner containers, its attribution in +// the bottom-right, and our own pin hint. Sizes are CSS, so the browser computes the boxes. +const PAGE = (inner) => ` + +
${inner}
` + +const ATTRIB = (text = '© OpenStreetMap contributors') => + `
+ i
${text}
` + +const browser = await chromium.launch() +const page = await browser.newPage({ viewport: { width: 390, height: 844 }, isMobile: true }) +let bad = 0 +const ok = (cond, what) => { if (!cond) bad++; console.log(` ${cond ? 'ok ' : 'FAIL'} ${what}`) } + +/** Evaluate the SHIPPED function against a page, and also report what the DOM says, independently. */ +async function measure(inner) { + await page.setContent(PAGE(inner)) + return page.evaluate(([fnSrc]) => { + const fn = new Function(`${fnSrc}\nreturn bottomControlInset`)() + const container = document.querySelector('.map') + const box = container.getBoundingClientRect() + // The independent answer: the highest top of anything that takes a pointer event down there. + let top = Infinity + for (const el of container.querySelectorAll('.maplibregl-ctrl, .pin-hint-pill')) { + const r = el.getBoundingClientRect() + if (r.width > 0 && r.height > 0 && r.top < top) top = r.top + } + const expected = Number.isFinite(top) ? Math.max(0, Math.round(box.bottom - top)) : 0 + return { got: fn(container), expected } + }, [FN]) +} + +// ── 1. THE ORDINARY CASE: one line of attribution. The inset must be positive and must match what +// the DOM independently says — not merely "some number", which a stub returning 1 would satisfy. +{ + const { got, expected } = await measure(ATTRIB()) + ok(got > 0, `a visible attribution reserves a band (${got}px), not 0 — the silent-failure case`) + ok(got === expected, `and it equals what the DOM says (${got} vs ${expected})`) +} + +// ── 2. NOTHING TO RESERVE. No controls at all must give exactly 0, so a map without chrome is not +// penalised and the pan behaves as it did before this rule existed. +{ + const { got } = await measure('') + ok(got === 0, `no bottom controls reserves exactly 0 (${got})`) +} + +// ── 3. A COLLAPSED CONTROL RESERVES NOTHING. An element present but zero-sized takes no click, and +// counting it would reserve the whole map height — the pan would then refuse every popup. +{ + const { got } = await measure('
') + ok(got === 0, `a zero-sized control reserves nothing (${got})`) +} + +// ── 4. THE TALLEST WINS, ACROSS CORNERS. Our pin hint sits bottom-LEFT while the attribution is +// bottom-RIGHT; the band has to cover whichever reaches higher, or the taller one still takes the +// click. Measured with a deliberately tall hint. +{ + const inner = ATTRIB() + '
' + const { got, expected } = await measure(inner) + ok(got === expected && got >= 80, `the taller of two corners wins (${got}px covers an 80px hint)`) +} + +// ── 5. A WRAPPED ATTRIBUTION IS TALLER, AND THE INSET FOLLOWS IT. This is the case a hard-coded +// constant gets wrong: at 390 a long credit wraps to two lines and the band grows. +{ + const oneLine = await measure(ATTRIB()) + const wrapped = await measure(ATTRIB('© OpenStreetMap contributors, © OpenMapTiles, © Natural Earth, and a long credit that wraps at this width')) + ok(wrapped.got > oneLine.got, `a wrapped credit reserves more than one line (${wrapped.got} > ${oneLine.got})`) + ok(wrapped.got === wrapped.expected, `and still equals the DOM (${wrapped.got} vs ${wrapped.expected})`) +} + +// ── 6. A CONTROL OUTSIDE THE CONTAINER IS NOT OURS TO RESERVE. querySelectorAll is scoped to the +// container; a stray control elsewhere on the page must not inflate the band. +{ + await page.setContent(PAGE('') .replace('', `${ATTRIB()}`)) + const got = await page.evaluate(([fnSrc]) => { + const fn = new Function(`${fnSrc}\nreturn bottomControlInset`)() + return fn(document.querySelector('.map')) + }, [FN]) + ok(got === 0, `a control outside the map container is ignored (${got})`) +} + +// ── 7b. THE MAPBOX ENGINE'S CORNERS COUNT TOO. The component takes a provider prop and swaps +// engines, and the selector names BOTH prefixes — but every case above renders maplibre class names, +// so the `.mapboxgl-ctrl-bottom-*` half was never exercised. A typo there would be invisible: the map +// would draw, the pan would run, and on Mapbox the popup's buttons would sit under the attribution +// exactly as they did before the fix. Same page shape, mapbox class names. +{ + const mb = `
+
© Mapbox
` + await page.setContent(PAGE(mb)) + const r = await page.evaluate(([fnSrc]) => { + const fn = new Function(`${fnSrc}\nreturn bottomControlInset`)() + const container = document.querySelector('.map') + const box = container.getBoundingClientRect() + let top = Infinity + for (const el of container.querySelectorAll('.mapboxgl-ctrl, .pin-hint-pill')) { + const q = el.getBoundingClientRect() + if (q.width > 0 && q.height > 0 && q.top < top) top = q.top + } + return { got: fn(container), expected: Number.isFinite(top) ? Math.max(0, Math.round(box.bottom - top)) : 0 } + }, [FN]) + ok(r.got > 0, `a Mapbox attribution reserves a band (${r.got}px), not 0 — the provider swap is covered`) + ok(r.got === r.expected, `and it equals what the DOM says (${r.got} vs ${r.expected})`) +} + +// ── 7. NO CONTAINER AT ALL. A map mid-teardown must plan no pan rather than throw. +{ + const got = await page.evaluate(([fnSrc]) => { + const fn = new Function(`${fnSrc}\nreturn bottomControlInset`)() + return fn(null) + }, [FN]) + ok(got === 0, `a null container returns 0 rather than throwing (${got})`) +} + +await browser.close() +if (bad) { + console.error(`\nCONTROL-INSET: FAIL — ${bad} case(s). The band the pan reserves is not the band the`) + console.error('controls occupy, so a popup can be panned "clear" and still have its buttons covered.') + process.exit(1) +} +console.log('\nCONTROL-INSET: PASS — the inset measures the real control band, in both engines\' corners.')