doxa-maps/internal/check-gospel-map-paints.mjs
Doxa Maps 28a0083310
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Update from source a59db6c4a
2026-09-28 08:59:18 +00:00

147 lines
7.6 KiB
JavaScript

/**
* check-gospel-map-paints.mjs — the map must put PIXELS on the screen, not just features.
*
* THE FAILURE THIS EXISTS FOR, which I shipped into my own tree and nearly landed. Injecting
* mapbox-gl.css into this profile's shadow root was correct, and it collapsed the canvas from
* 1440x900 to 1440x300 because .mapboxgl-map carries position:relative and cancelled the
* container's absolute box. The result was a WHITE RECTANGLE. And every assertion I had went
* green over it:
* - verify-gospel-map : 2,107 of 2,107 pins "rendered"
* - check:leaves-mount : element defined, shadow root full of text
* - the i18n gates : every label translated, nothing clipped
* queryRenderedFeatures does not care whether anything is visible, and neither does text. I
* caught it by opening the screenshot, which is not a system.
*
* SO THIS ASKS THE ONE QUESTION NONE OF THEM ASK: is there a map there? It screenshots the
* map element itself and counts distinct colours, the same technique check-embed-basemap
* uses in an iframe — a basemap with land, water, borders and pins has hundreds; a blank
* rectangle has one or two. It also checks the canvas fills its container, because a
* correctly-coloured 300px strip in a 900px box is the exact defect that started this.
*
* Run with --poison to prove it can fail: it hides the canvas and requires a red verdict.
*/
import { chromium } from './lib/playwright-guarded.mjs'
import { serveReaped } from './_serve-reaped.mjs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { TREE_NAME } from './_tree-name.mjs'
const HERE = dirname(fileURLToPath(import.meta.url))
const REPO = resolve(HERE, '..')
const PORT = Number(process.env.PORT || 5399)
const BASE = process.env.BASE_URL || ('http://127.0.0.1:' + PORT)
const POISON = process.argv.includes('--poison')
const MIN_COLOURS = Number(process.env.MIN_MAP_COLOURS || 40)
const MIN_FILL = 0.7
let srv = null
if (!process.env.BASE_URL) srv = serveReaped(resolve(REPO, 'public'), PORT)
const countColours = async (page, b64) => page.evaluate(async (data) => {
const img = new Image()
img.src = 'data:image/png;base64,' + data
await img.decode()
const c = document.createElement('canvas')
c.width = img.width; c.height = img.height
const ctx = c.getContext('2d')
ctx.drawImage(img, 0, 0)
const d = ctx.getImageData(0, 0, c.width, c.height).data
const seen = new Set()
for (let i = 0; i < d.length; i += 4) seen.add(((d[i] >> 3) << 10) | ((d[i+1] >> 3) << 5) | (d[i+2] >> 3))
return seen.size
}, b64)
let browser
try {
browser = await chromium.launch()
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } })
await page.goto(BASE + `/${TREE_NAME}/gospel-resources/resources-map/index.html`,
{ waitUntil: 'networkidle', timeout: 90000 })
await page.waitForFunction(() => window.__grState && window.__grState().pins > 0, null, { timeout: 60000 })
await page.waitForTimeout(9000)
if (POISON) {
/* Reproduce the REAL defect rather than an approximation of it. Hiding the canvas was
the first attempt and it did not work: the region still showed 316 colours, because a
hidden GL canvas is not the same as an empty rectangle and other chrome sits in the
frame. What actually happened was a stylesheet collapsing the container to 300px in a
900px box, so that is what this injects — and the fill assertion is the arm that
should catch it. A poison that does not mirror the incident tests the wrong thing. */
await page.evaluate(() => {
const sr = document.querySelector('gospel-resources-map').shadowRoot
const map = sr.querySelector('.gm-map')
if (map) { map.style.position = 'relative'; map.style.height = '300px'; map.style.inset = 'auto' }
const cv = sr.querySelector('canvas')
if (cv) { cv.style.height = '300px' }
})
await page.waitForTimeout(800)
}
/* THE RATIO THAT MATTERS IS MAP-TO-HOST, NOT CANVAS-TO-MAP. The first version compared the
canvas to its own container, and the poison proved that useless: in the real incident the
stylesheet collapsed the CONTAINER and the canvas dutifully followed, so canvas/container
stayed 1.0 and the check would have passed the very defect it was written for. What
actually went wrong is that the map stopped filling the ELEMENT, so that is the
measurement. */
const geom = await page.evaluate(() => {
const el = document.querySelector('gospel-resources-map')
const sr = el.shadowRoot
const map = sr.querySelector('.gm-map')
const cv = sr.querySelector('canvas')
const eb = el.getBoundingClientRect()
const mb = map ? map.getBoundingClientRect() : null
const cb = cv ? cv.getBoundingClientRect() : null
return {
host: Math.round(eb.width) + 'x' + Math.round(eb.height),
container: mb ? Math.round(mb.width) + 'x' + Math.round(mb.height) : 'none',
canvas: cb ? Math.round(cb.width) + 'x' + Math.round(cb.height) : 'none',
fill: mb && eb.height ? mb.height / eb.height : 0,
}
})
/* ═══ SAMPLE THE MAP, NOT THE PANEL OVER IT ═════════════════════════════════════════
* The first version screenshotted the whole custom element and counted 427 colours with
* the canvas HIDDEN — because the legend card is itself full of colour and sits on top.
* The poison arm caught that, which is the entire reason a gate carries one: a check that
* measures the wrong rectangle passes for the wrong reason. The legend occupies the left
* edge, so the sample is taken from the right of it and looks at map alone. */
const clip = await page.evaluate(() => {
const sr = document.querySelector('gospel-resources-map').shadowRoot
const map = sr.querySelector('.gm-map') || sr.host
const legend = sr.querySelector('.gm-legend')
const m = map.getBoundingClientRect()
const left = legend ? legend.getBoundingClientRect().right + 12 : m.left
return { x: Math.round(left), y: Math.round(m.top), width: Math.max(8, Math.round(m.right - left)), height: Math.max(8, Math.round(m.height)) }
})
const shot = await page.screenshot({ clip })
const colours = await countColours(page, shot.toString('base64'))
console.log(' host ' + geom.host + ' · map ' + geom.container + ' · canvas ' + geom.canvas +
' · map fills ' + Math.round(geom.fill * 100) + '% of the element')
console.log(' distinct colours on screen: ' + colours + ' (floor ' + MIN_COLOURS + ')')
const problems = []
if (colours < MIN_COLOURS) problems.push('only ' + colours + ' distinct colours — this is a blank rectangle, not a map')
if (geom.fill < MIN_FILL) problems.push('the map fills only ' + Math.round(geom.fill * 100) + '% of the element height; a stylesheet has collapsed it')
if (POISON) {
if (!problems.length) {
console.error('MAP-PAINTS: SELF-TEST FAIL — a collapsed map passed as a painted one.')
process.exitCode = 1
} else {
console.log('MAP-PAINTS --poison: caught it — ' + problems[0])
console.log('MAP-PAINTS: SELF-TEST PASS — the check fails as designed.')
}
} else if (problems.length) {
console.error('MAP-PAINTS: FAIL — ' + problems.length + ' problem(s):')
for (const p of problems) console.error(' ' + p)
console.error(' Pin counts and rendered text cannot see this: queryRenderedFeatures')
console.error(' answers happily over a white rectangle. Open the screenshot.')
process.exitCode = 1
} else {
console.log('MAP-PAINTS: PASS — the map paints, and the canvas fills its container.')
}
} finally {
await browser?.close()
srv?.stop()
}