From c77207b53ab0cf1b183e944e31b3ee8b33b2a46a Mon Sep 17 00:00:00 2001 From: doxa-maps-88655c70 <88627970+doxa-maps-88655c70@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:48:33 +0000 Subject: [PATCH] check:basemap-labels: the basemap's place names follow the reader's language on both engines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BUILD-LOCALIZATION-SURVIVES-THE-PROVIDER-SWAP asked for a check; the behaviour landed in 319064a1 (library/maps/localize-labels.js) without one. This reads the labels the map actually DRAWS, at ?lang=en and ?lang=ru on the same view, under both providers: Mapbox the Standard basemap's 'place-labels' featureset (a plain queryRenderedFeatures cannot see the imported basemap): Germany→Германия, Ukraine→Украина, Egypt→Египет… must flip. MapLibre each country symbol's evaluated text-field: under ru it must be the feature's name:ru, under en not. English there restores OSM's own label, so no English word is expected. It serves this checkout on an OS-assigned port. --dir points it at another tree for the negative control. Negative-tested: doxa-simple-map built from 319064a1's parent (no localize-labels) and served with --dir FAILS on both engines (Mapbox: 68 country labels, none flip; MapLibre: 0/49 in Russian). This tree PASSES on both (5 known countries flip on Mapbox; 49/49 on MapLibre, e.g. United Kingdom → Великобритания). A first version counted the pre-fix Mapbox run as NOT MEASURED instead of FAIL, since it saw no English names. "Measured" now means enough country labels were drawn. --- internal/check-basemap-labels-localized.mjs | 97 +++++++++++++++++++++ package.json | 4 +- 2 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 internal/check-basemap-labels-localized.mjs diff --git a/internal/check-basemap-labels-localized.mjs b/internal/check-basemap-labels-localized.mjs new file mode 100644 index 000000000..fc7d887c3 --- /dev/null +++ b/internal/check-basemap-labels-localized.mjs @@ -0,0 +1,97 @@ +#!/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 ] [--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' + +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}/doxa-maps/${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}/doxa-maps/${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.`) diff --git a/package.json b/package.json index 252892a31..47800334f 100644 --- a/package.json +++ b/package.json @@ -125,6 +125,7 @@ "check:sfc-parse": "node internal/check-sfc-script-parses.mjs", "check:url-resolver": "node internal/check-url-resolver.mjs", "check:studio-all": "node internal/check-studio-every-plugin.mjs", + "check:basemap-labels": "node internal/check-basemap-labels-localized.mjs", "check:vision-card-numbers": "node internal/check-vision-card-numbers.mjs", "check:map-embed-cells": "node internal/check-map-embed-cells.mjs", "check:map-unmount-race": "node internal/check-map-unmount-guard.mjs", @@ -304,7 +305,8 @@ "check:gallery-embed:live-cors": "the Research Gallery on a foreign page under the LIVE static layer's CORS rule (Access-Control-Allow-Origin on files inside the tree's folders, NOT on the root manifest.json, measured on nuxtbase): it must still boot every tab, by reading the CORS-readable manifest copy generate-tree writes beside its bundle.", "check:leaves-mount": "DID THE ELEMENT ACTUALLY MOUNT, on all five gospel leaves. Exists because a ReferenceError thrown during setup dies INSIDE the custom-element boundary: the page loads, every asset returns 200, pageerror fires zero times, and the element sits there with an empty shadow root. A healthy-looking dead page. This asks the cheap unambiguous question the rich gates are too slow to ask on every leaf — is anything there — in five steps that each name their own failure: tag defined, host present, shadowRoot attached, shadow root non-empty, visible text rendered. It does NOT trust the absence of console errors; zero errors is not the same as working. --poison for the negative test.", "check:gospel-all": "EVERYTHING gospel-resources owns, in one command. Its ten gates were all hand-run, which is how a dead bundle shipped: a duplicate module killed all five leaves and the one gate run that day was pointed at the map. check:no-orphan-gates was right that they were reachable — reachable is not run. Deliberately NOT in gates:pre/post: three need a browser, the shared wave holds browser gates under load, and a bundle owner should not be able to slow everyone else's build by adding to a list they do not own. Static gates run first and stop the run on failure, so a scarce browser slot is never spent confirming a table is inconsistent. Exit 75 = NOT MEASURED, never folded into a pass.", - "check:no-conflict-markers": "Nothing tracked may carry an unresolved merge. Exists because a merge from this worktree committed a BUILT bundle with 21 conflict markers - both sides concatenated, 1.13MB - and every gospel leaf rendered dead while the SRI hash matched and every request returned 200. The cause was a landing script that resolved one expected file then ran \"git add -A public\", staging every other file including the ones still holding markers. For a built file: take either side, REBUILD, commit the rebuild, then run this before pushing. --poison for the negative test." + "check:no-conflict-markers": "Nothing tracked may carry an unresolved merge. Exists because a merge from this worktree committed a BUILT bundle with 21 conflict markers - both sides concatenated, 1.13MB - and every gospel leaf rendered dead while the SRI hash matched and every request returned 200. The cause was a landing script that resolved one expected file then ran \"git add -A public\", staging every other file including the ones still holding markers. For a built file: take either side, REBUILD, commit the rebuild, then run this before pushing. --poison for the negative test.", + "check:basemap-labels": "the BASEMAP's place names follow the reader's language on BOTH engines (library/maps/localize-labels.js): reads the RENDERED country labels at ?lang=en and ?lang=ru — Mapbox via the Standard basemap's place-labels featureset, MapLibre via each symbol's evaluated text — and requires them to flip. Serves this checkout on an OS-assigned port; --dir for a negative control. Negative-tested: a bundle built before 319064a1 FAILS on both engines." }, "devDependencies": { "@capacitor/android": "^7.6.9",