doxa-maps/internal/check-basemap-labels-localized.mjs
doxa-maps-88655c70 44ef31b2c6 Tooling reads the tree's name from the build config, in one place
The output tree's name has always been configurable (vite.doxa-maps-build-config.json "name"),
but 61 tooling files ignored it and hardcoded 'doxa-maps' (181 literals), so a rename would have
moved the build while the gates kept reading the old folder.

- internal/_tree-name.mjs reads it once (validated as one URL-safe path segment; falls back to the
  historical name) and exports TREE_NAME / TREE_NAME_RE; `node internal/_tree-name.mjs` prints it
  for shell scripts.
- A codemod rewrote 171 literals in internal/ to TREE_NAME; the regex literals, messages with
  backticks, atomic-public-swap.sh (fails loudly if the name cannot be read), build-lock.sh and
  vite.config.js (its duplicate readMfeName is gone) were done by hand.
- The name is unchanged: old and new tooling produce all 1408 files under public/ byte for byte.
  Code lines still naming the tree in internal/ + vite.config.js: 2, both intended (the helper's
  fallback, the private-repo-name pattern in public-hygiene-check).

This renames nothing visible; the eventual rename becomes a one-line config change.
2026-09-25 16:25:22 +00:00

98 lines
7.6 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-basemap-labels-localized — the BASEMAP's place names follow the reader's language, on BOTH
* engines (card BUILD-LOCALIZATION-SURVIVES-THE-PROVIDER-SWAP; the code under test is
* library/maps/localize-labels.js, 319064a1 / 4839f879).
*
* USAGE: node internal/check-basemap-labels-localized.mjs [--dir <tree root>] [--leaf bundle/profile]
* Serves THIS checkout's public/ on an OS-assigned port (so it cannot measure another tree),
* or --dir (used for the negative control: a tree whose bundle predates the fix).
* Exit 0 = both engines localize · 1 = an engine does not · 2 = could not measure.
*
* WHAT IS READ — the RENDERED label, not our own guess at an expression:
* Mapbox Standard keeps its labels in the IMPORTED basemap, invisible to a plain
* queryRenderedFeatures. The 'place-labels' featureset of the 'basemap' import returns
* each country label with the `name` the engine drew (setLanguage localizes it).
* MapLibre queryRenderedFeatures returns each symbol's EVALUATED text-field, i.e. the text drawn.
* WHAT IS ASSERTED, per engine, between ?lang=en and ?lang=ru on the same view (30°E 45°N, z3):
* Mapbox known countries flip: Germany→Германия, Ukraine→Украина, Turkey→Турция, Egypt→Египет
* (at least two of them seen in both runs).
* MapLibre for the country labels (identified by their name:en property), the ru run draws the
* feature's name:ru, and the en run does not. (English on MapLibre restores the style's
* ORIGINAL label, OSM's Latin name — 'Deutschland', not 'Germany' — so no English word
* is expected there; the flip to Cyrillic is the evidence.)
*
* NEGATIVE-TESTED 2026-09-25: the same leaf built from 319064a1's parent (before localize-labels
* existed), served with --dir, FAILS on both engines.
*/
import { chromium } from 'playwright'
import { spawn } from 'node:child_process'
import { createServer } from 'node:http'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { TREE_NAME } from './_tree-name.mjs'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const argv = process.argv.slice(2)
const arg = (k, d) => { const i = argv.indexOf(k); return i >= 0 && argv[i + 1] ? argv[i + 1] : d }
const DIR = resolve(arg('--dir', resolve(ROOT, 'public')))
const LEAF = arg('--leaf', 'doxa-simple-map/doxa-simple-map')
const PAIRS = { Germany: 'Германия', Ukraine: 'Украина', Turkey: 'Турция', Egypt: 'Египет', Kazakhstan: 'Казахстан', Poland: 'Польша' }
const port = await new Promise((ok) => { const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => ok(p)) }) })
const srv = spawn(process.execPath, [resolve(ROOT, 'internal/serve-strict.mjs'), '--port', String(port), '--directory', DIR], { stdio: 'ignore' })
await new Promise((r) => setTimeout(r, 900))
const up = await fetch(`http://127.0.0.1:${port}/${TREE_NAME}/${LEAF}/index.html`).then((r) => r.ok).catch(() => false)
if (!up) { srv.kill(); console.log(`BASEMAP-LABELS: NOT MEASURED — could not serve ${DIR}`); process.exit(2) }
const browser = await chromium.launch({ args: ['--use-gl=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist'] })
async function labels(provider, lang) {
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } })
await page.goto(`http://127.0.0.1:${port}/${TREE_NAME}/${LEAF}/index.html?lang=${lang}${provider === 'maplibre' ? '&provider=maplibre' : ''}`, { waitUntil: 'networkidle' })
const FIND = `(() => { const f = (r) => { for (const e of r.querySelectorAll('*')) { if (e.__doxaMap) return e.__doxaMap; if (e.shadowRoot) { const m = f(e.shadowRoot); if (m) return m } } return null }; return f(document) })()`
let ok = false
for (let i = 0; i < 60 && !ok; i++) { await page.waitForTimeout(500); ok = await page.evaluate(`(() => { const m = ${FIND}; return !!(m && m.isStyleLoaded && m.isStyleLoaded()) })()`).catch(() => false) }
if (!ok) { await page.close(); return null }
await page.evaluate(`(() => { const m = ${FIND}; m.jumpTo({ center: [30, 45], zoom: 3 }) })()`)
await page.waitForTimeout(7000)
const out = await page.evaluate(`(() => { const m = ${FIND}
if (${JSON.stringify(provider)} === 'mapbox') {
let fs = []; try { fs = m.queryRenderedFeatures({ target: { featuresetId: 'place-labels', importId: 'basemap' } }) } catch (e) { return { error: e.message } }
return { names: fs.filter((f) => f.properties && f.properties.class === 'country').map((f) => f.properties.name) }
}
const text = (tf) => (tf && Array.isArray(tf.sections)) ? tf.sections.map((s) => s.text).join('') : (typeof tf === 'string' ? tf : '')
const feats = m.queryRenderedFeatures().filter((f) => f.layer && f.layer.type === 'symbol' && /country/.test(f.layer.id) && f.properties['name:en'])
return { countries: feats.map((f) => ({ en: f.properties['name:en'], ru: f.properties['name:ru'] || '', drawn: text(f.layer.layout && f.layer.layout['text-field']) })) }
})()`)
await page.close()
return out
}
const results = []
for (const provider of ['mapbox', 'maplibre']) {
const en = await labels(provider, 'en'), ru = await labels(provider, 'ru')
if (!en || !ru || en.error || ru.error) { results.push({ provider, measured: false, why: (en && en.error) || (ru && ru.error) || 'map never finished its style' }); continue }
if (provider === 'mapbox') {
const seen = Object.keys(PAIRS).filter((k) => en.names.includes(k))
const flipped = seen.filter((k) => ru.names.includes(PAIRS[k]))
// MEASURED = the engine drew enough country labels to judge. Not "saw two known English names":
// without localization Mapbox Standard draws a different default form, and that absence IS the
// defect — the first version of this line reported the pre-fix build as NOT MEASURED instead of
// FAIL (68 country labels drawn, none English), which would let the gate pass by abstaining.
results.push({ provider, measured: en.names.length >= 5 && ru.names.length >= 5, ok: seen.length >= 2 && flipped.length === seen.length,
detail: `en saw ${seen.join(', ') || '(none)'}; ru drew ${flipped.map((k) => PAIRS[k]).join(', ') || '(none of them)'} (${en.names.length}/${ru.names.length} country labels)` })
} else {
const byEn = (arr) => new Map(arr.map((c) => [c.en, c]))
const E = byEn(en.countries), R = byEn(ru.countries)
const common = [...R.keys()].filter((k) => E.has(k) && R.get(k).ru)
const localized = common.filter((k) => R.get(k).drawn === R.get(k).ru && E.get(k).drawn !== R.get(k).ru)
results.push({ provider, measured: common.length >= 2, ok: common.length >= 2 && localized.length === common.length,
detail: `${localized.length}/${common.length} countries drawn in Russian under ru and not under en` + (common[0] ? ` — e.g. ${common[0]}: en "${E.get(common[0]).drawn}" → ru "${R.get(common[0]).drawn}"` : '') })
}
}
await browser.close(); srv.kill()
for (const r of results) console.log(` ${r.provider.padEnd(9)} ${!r.measured ? 'NOT MEASURED' : r.ok ? 'ok ' : 'FAIL'} ${r.detail || r.why}`)
if (results.some((r) => r.measured && !r.ok)) { console.log('BASEMAP-LABELS: FAIL — a basemap does not follow the reader\'s language on every engine.'); process.exit(1) }
if (results.some((r) => !r.measured)) { console.log('BASEMAP-LABELS: NOT MEASURED — an engine gave too few labels to judge.'); process.exit(2) }
console.log(`BASEMAP-LABELS: PASS — ${LEAF}'s basemap place names follow the reader's language on Mapbox AND MapLibre.`)