doxa-maps/plugin-designs/doxa-research-map/scripts/check-pin-dim-slider.mjs
Doxa Maps d3967fe37a
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 2ff0ecba
2026-09-25 23:12:08 +00:00

172 lines
9.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* check-pin-dim-slider.mjs — the side menu's pin-dim slider and the legend's dimming must COMPOSE,
* not compete, and the slider must actually reach the map.
*
* THE DEFECT. The slider wrote mapStore.clusterPinOpacity, which mapStore never declared, so
* dragging it changed a number nobody read — the same shape of defect as the clustering picker
* beside it, found while fixing that one. And the naive fix would have created a worse one: the pin
* layer's circle-opacity is ALSO owned by legend dimming (a ['case', …, 1, 0] expression), so a flat
* slider value written by a second owner would silently erase dimming, or be erased by it, depending
* on which the reader touched last.
*
* ★ SO THE VALUE IS A PRODUCT, AND THAT IS WHAT THIS ASSERTS: opacity = slider × (1 matched, 0
* dimmed). The slider scales the whole layer; dimming still dims relative to it; neither wins.
*
* ASSERTIONS, reading the LIVE paint property rather than the store:
* clustering off a plain 1 — the slider is hidden then, and dimming a map with nothing
* to dim FOR would be a regression of its own.
* clustering on, 0.5 a plain 0.5.
* + a dimming selection ['case', <expr>, 0.5, 0] — dimming survives the slider.
* slider back to 1.0 ['case', <expr>, 1, 0] — the slider survives dimming.
*
* Exit: 0 pass or SKIP (no built tree, no browser, no data) · 1 fail.
*/
import { createServer } from 'node:http'
import { spawn } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const TAG = 'PIN-DIM-SLIDER'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
// The tree's folder name is configurable and was renamed on 2026-09-25; reading it from the one
// helper is what keeps this gate pointed at the tree that exists. A hardcoded name does not error,
// it 404s — and a gate that 404s reports a clean page.
const { TREE_NAME } = await import(join(ROOT, 'internal', '_tree-name.mjs'))
const MANIFEST = join(ROOT, 'public', TREE_NAME, 'manifest.json')
const skip = (why) => { console.log(`${TAG}: SKIP — ${why}`); process.exit(0) }
if (!existsSync(MANIFEST)) skip('no built manifest (run `bun run tree` first).')
let chromium
try { ({ chromium } = await import('playwright')) } catch { skip('playwright is not installed.') }
const manifest = JSON.parse(readFileSync(MANIFEST, 'utf8'))
const bundle = (manifest.bundles || []).find((b) => b.bundle === 'doxa-research-map')
const profile = bundle && (bundle.profiles || []).find((p) => p.iframeUrl && /research-map/.test(p.iframeUrl))
if (!profile) skip('the manifest declares no research-map leaf to load.')
const freePort = () => new Promise((ok) => {
const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => ok(p)) })
})
const PORT = await freePort()
const TREE = `http://127.0.0.1:${PORT}/${TREE_NAME}`
const LEAF = `${TREE}/${String(profile.iframeUrl).replace(/^\.\//, '')}`
const tree = spawn(process.execPath, ['internal/serve-strict.mjs', '--port', String(PORT)], { cwd: ROOT, stdio: 'ignore' })
const cleanup = () => { try { tree.kill() } catch { /* already gone */ } }
await new Promise((r) => setTimeout(r, 900))
if (!await fetch(`${TREE}/manifest.json`, { signal: AbortSignal.timeout(4000) }).then((r) => r.ok).catch(() => false)) {
cleanup(); skip('could not serve the built tree locally.')
}
const { requireThisTree } = await import(join(ROOT, 'internal', '_serves-this-tree.mjs'))
await requireThisTree(TREE, ROOT, TAG)
const browser = await chromium.launch({ args: ['--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'] })
const fails = []
const lines = []
try {
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } })
await page.goto(LEAF, { waitUntil: 'domcontentloaded', timeout: 60000 })
const out = await page.evaluate(async () => {
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
let read = null
for (let i = 0; i < 120 && !read; i++) {
if (typeof window.__researchMapPinOpacity === 'function') read = window.__researchMapPinOpacity
else await sleep(500)
}
if (!read) return { skip: 'the map never exposed its pin-opacity report' }
for (let i = 0; i < 120; i++) {
if ((window.__researchMapVocabulary?.('religionFamily') || []).length) break
await sleep(500)
}
// Fresh walk each time: the slider is behind v-if="clusterMode !== 'off'", so it does not EXIST
// until a clustering mode is chosen. Caching the visited set across calls (as the clustering gate
// can) would mean searching a DOM snapshot taken before the element was created — which is how
// this gate first reported "the slider is not in the DOM" on a build where it works.
const findAll = (sel) => {
const seen = new Set()
const hits = []
const walk = (root) => {
if (!root || seen.has(root)) return
seen.add(root)
for (const el of root.querySelectorAll('*')) {
if (el.matches && el.matches(sel)) hits.push(el)
if (el.shadowRoot) walk(el.shadowRoot)
}
}
walk(document)
return hits
}
const buttons = findAll('.rm-cluster-btn')
if (buttons.length !== 4) return { skip: `expected 4 clustering buttons, found ${buttons.length}` }
const setSlider = (v) => {
const slider = findAll('#rm-pin-op')[0] || findAll('.rm-slider-row input[type=range]')[0]
if (!slider) return false
slider.value = String(v)
slider.dispatchEvent(new Event('input', { bubbles: true }))
return true
}
const host = document.querySelector('doxa-research-map')
// ★ DIM BY RELIGION, NOT BY REGION, AND THAT IS NOT ARBITRARY. MEASURED while writing this: a
// REGION selection does not dim at all — it sets a layer FILTER, so non-matching pins are
// removed rather than faded, and the opacity stays flat. Only the case-expression dim paths
// (religion / prayer / engagement / adoption / language-family) compose with the slider, so
// testing region here would have reported "the slider erased the dimming" about a path that
// never writes opacity.
const dim = () => {
let cfg = {}
try { cfg = JSON.parse(host.getAttribute('profile-config') || '{}') } catch (_) { cfg = {} }
cfg.externalFilters = { region: '', religion: 'Buddhism', language: '' }
host.setAttribute('profile-config', JSON.stringify(cfg))
}
const out = {}
buttons[0].click(); await sleep(4000) // Off
out.off = read()
buttons[2].click(); await sleep(8000) // MST — the slider only applies while clustering draws
if (!setSlider(0.5)) return { skip: 'the pin-dim slider never appeared after choosing a clustering mode' }
await sleep(2500)
out.clusteredHalf = read()
dim(); await sleep(9000)
out.dimmedHalf = read()
setSlider(1); await sleep(2500)
out.dimmedFull = read()
return out
})
if (out.skip) { await browser.close(); cleanup(); skip(out.skip) }
const show = (v) => JSON.stringify(v)
lines.push(` · clustering off ${show(out.off)}`)
lines.push(` · clustering on, 0.5 ${show(out.clusteredHalf)}`)
lines.push(` · + dimming, 0.5 ${show(out.dimmedHalf)}`)
lines.push(` · + dimming, 1.0 ${show(out.dimmedFull)}`)
const isCase = (v) => Array.isArray(v) && v[0] === 'case' && v.length === 4
if (out.off !== 1) fails.push(`with clustering off the pins should be fully opaque, got ${show(out.off)} — the slider is applying where it is not even shown.`)
if (out.clusteredHalf !== 0.5) fails.push(`the slider at 0.5 with clustering on should give a flat 0.5, got ${show(out.clusteredHalf)} — the slider is not reaching the map.`)
if (!isCase(out.dimmedHalf)) fails.push(`dimming while the slider is at 0.5 should give a ['case', …] expression, got ${show(out.dimmedHalf)} — the slider ERASED the dimming.`)
else {
if (out.dimmedHalf[2] !== 0.5) fails.push(`matched pins should render at the slider's 0.5 while dimming, got ${show(out.dimmedHalf[2])} — the dimming ERASED the slider.`)
if (out.dimmedHalf[3] !== 0) fails.push(`dimmed pins should render at 0, got ${show(out.dimmedHalf[3])}.`)
}
if (!isCase(out.dimmedFull)) fails.push(`returning the slider to 1.0 should keep the dimming expression, got ${show(out.dimmedFull)} — the slider erased it on the way back.`)
else if (out.dimmedFull[2] !== 1) fails.push(`at slider 1.0 matched pins should be fully opaque, got ${show(out.dimmedFull[2])}.`)
} catch (err) {
fails.push(`the measurement itself failed: ${err && err.message}`)
} finally {
await browser.close().catch(() => {})
cleanup()
}
for (const l of lines) console.log(l)
if (fails.length) {
console.log(`\n${TAG}: FAIL`)
for (const f of fails) console.log(` ✗ ${f}`)
console.log('\n opacity = slider × (1 matched, 0 dimmed). One writer, one product — see applyPinOpacity.')
process.exit(1)
}
console.log(`${TAG}: PASS — the slider scales the layer, dimming still dims relative to it, and neither erases the other.`)