Generated zone titles ship for one-country zones only
Measured all 29 zones in en/es/ar with a real country-name supplier
before wiring anything, and the result split cleanly.
A one-country zone generates a correct title in every locale at zero
translation cost: Sudan / Sudán / السودان.
A multi-country zone generates a title WORSE than the hand-written
label it would replace, in English too, so this was never a
localization trade-off:
"Middle East Core" would become "Iran + Yemen + 11 more"
"Scattered / Diaspora" would become "Andorra + Cocos (Keeling)
Islands + 2 more"
and no maxParts rescues it -- 2, 3, 4, 13 just walks toward a full
13-country enumeration. A multi-country zone has a name that is not a
list of its members, so no assembly of country names can produce it.
The generator is correct; the approach does not fit that tier.
The guard is in zone-title.js, not at the caller, because six product
call sites supply this across three surfaces (the legend three times,
posters, packets). A caller-side test would be repeated six times and
would drift the first time one was missed, silently regressing one
surface while the others stayed right.
Tier B now keeps its label through the same null-means-keep-yours path
tier C already used, so this widens an existing contract rather than
adding a new one.
check-zone-title-tier-a.mjs is two-sided on purpose: a gate that only
checked "the 3 generate" would pass a build that also regressed the
other 26. It asserts the 3 produce non-code titles that differ across
locales, AND that every other entry returns null so its label survives
byte-identical -- by value, not by count, since "26 declined" would
pass even if the wrong 26 declined.
Shown capable of failing four ways: removing the guard (the forbidden
regression), declining tier A too, a supplier returning raw codes, and
a supplier ignoring locale. The third exits 75 NOT MEASURED rather than
failing, because a broken supplier makes every zone decline and would
otherwise look like a clean pass.
This commit is contained in:
parent
bad51cef2b
commit
a4330484a4
2 changed files with 153 additions and 0 deletions
127
internal/check-zone-title-tier-a.mjs
Normal file
127
internal/check-zone-title-tier-a.mjs
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-zone-title-tier-a — generated zone titles ship for ONE-COUNTRY zones and nothing else.
|
||||
*
|
||||
* zone-title.js can compose a localized zone title out of country names. MEASURED over all 29 zones
|
||||
* in en/es/ar before wiring it, the result split cleanly:
|
||||
* · a ONE-country zone generates a correct title in every locale, free (Sudan / Sudán / السودان)
|
||||
* · a MULTI-country zone generates a title WORSE than the hand-written label it replaces, in
|
||||
* ENGLISH TOO ("Middle East Core" → "Iran + Yemen + 11 more")
|
||||
* so the ruling (dx-shepherd, 2026-09-28) is tier A only. This gate holds that line.
|
||||
*
|
||||
* ★ IT IS TWO-SIDED, AND THAT IS THE ENTIRE POINT. A gate that only checked "the 3 zones generate"
|
||||
* would pass a build that ALSO regressed the other 25 — the exact failure being guarded against. So
|
||||
* it asserts BOTH:
|
||||
* (1) POSITIVE — the 3 one-country zones produce a non-null, non-code title in en/es/ar, and the
|
||||
* three locales DIFFER (identical output across locales means the supplier is not localizing
|
||||
* and every "translation" is really English).
|
||||
* (2) NEGATIVE — every other zone returns null, so its caller keeps a BYTE-IDENTICAL label. The
|
||||
* comparison is against the label in clusters.json, not against a remembered string.
|
||||
*
|
||||
* ⚠ The negative side is checked by VALUE, not by count. "25 zones declined" would pass even if the
|
||||
* wrong 25 declined; each zone is named.
|
||||
*
|
||||
* Exit 0 pass · 1 fail · 75 NOT MEASURED (the supplier could not be built, so neither side was
|
||||
* actually exercised — never reported as a pass).
|
||||
*/
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
import { resolve, dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url))
|
||||
const REPO = resolve(HERE, '..')
|
||||
const PLUGIN = join(REPO, 'plugin-designs/my-upg-100-list')
|
||||
const LOCALES = ['en', 'es', 'ar']
|
||||
|
||||
const clusters = JSON.parse(readFileSync(join(PLUGIN, 'data/clusters.json'), 'utf8'))
|
||||
const zones = Object.values(clusters).filter(Array.isArray).flat()
|
||||
const { zoneTitle, zoneCountryCodes } = await import(join(PLUGIN, 'data/zone-title.js'))
|
||||
|
||||
// ── the supplier, built the way a product caller builds it ───────────────────────────────────────
|
||||
// ⚠ countryName.js CANNOT be imported here: it does `import table from '...json'` with no import
|
||||
// attribute, which is fatal under Node. That is precisely why zone-title.js takes countryName
|
||||
// INJECTED rather than importing it — so the same function runs under Node and under Vite. This
|
||||
// gate reproduces the injection rather than working around it.
|
||||
const A2_PATH = join(REPO, 'library/data/iso3-to-alpha2.json')
|
||||
if (!existsSync(A2_PATH)) {
|
||||
console.error(`ZONE-TITLE-A: NOT MEASURED (exit 75) — no alpha-2 table at ${A2_PATH}, so no title`)
|
||||
console.error(' can be generated and neither side of this check was exercised.')
|
||||
process.exit(75)
|
||||
}
|
||||
// ⚠ The table is { _meta, map } — the codes live under .map. Reading it one level too shallow makes
|
||||
// EVERY lookup undefined, so every zone declines and the gate reports a clean "25 declined" while
|
||||
// silently testing nothing. That happened during the measurement; it is why the supplier is
|
||||
// sanity-checked below before any zone is judged.
|
||||
const alpha2 = JSON.parse(readFileSync(A2_PATH, 'utf8')).map
|
||||
const countryName = (iso3, locale) => {
|
||||
const two = alpha2[String(iso3 || '').toUpperCase()]
|
||||
if (!two) return null
|
||||
try { return new Intl.DisplayNames([locale], { type: 'region' }).of(two) } catch { return null }
|
||||
}
|
||||
// ★ PROVE THE SUPPLIER CAN SUCCEED BEFORE TRUSTING ANY DECLINE. An empty result from a probe never
|
||||
// shown capable of a non-empty one is not evidence of absence.
|
||||
const probe = countryName('SDN', 'en')
|
||||
if (probe !== 'Sudan') {
|
||||
console.error(`ZONE-TITLE-A: NOT MEASURED (exit 75) — the country-name supplier is broken:`)
|
||||
console.error(` countryName('SDN','en') returned ${JSON.stringify(probe)}, expected "Sudan".`)
|
||||
console.error(' Every zone would decline and this gate would look green while testing nothing.')
|
||||
process.exit(75)
|
||||
}
|
||||
|
||||
const strings = { zone_join: '{a} + {b}', zone_join_more: '{a} + {n} more' }
|
||||
const titleFor = (zone, locale) => zoneTitle(zone, strings, { countryName, locale })
|
||||
|
||||
const fails = []
|
||||
const notes = []
|
||||
|
||||
// ── (1) POSITIVE: one-country zones generate, in every locale, and the locales differ ────────────
|
||||
const single = zones.filter((z) => zoneCountryCodes(z.selector).length === 1)
|
||||
if (!single.length) {
|
||||
console.error('ZONE-TITLE-A: NOT MEASURED (exit 75) — no one-country zone exists to exercise the')
|
||||
console.error(' positive case, so a green here would mean only that nothing was tested.')
|
||||
process.exit(75)
|
||||
}
|
||||
for (const z of single) {
|
||||
const got = {}
|
||||
for (const lc of LOCALES) {
|
||||
const t = titleFor(z, lc)
|
||||
got[lc] = t
|
||||
if (!t) { fails.push(`${z.id}: generates NOTHING in ${lc} — a one-country zone must produce a title`); continue }
|
||||
if (t === z.id || /^[A-Z]{3}$/.test(t)) fails.push(`${z.id}: ${lc} title is a raw code (${t}), not a name`)
|
||||
}
|
||||
const distinct = new Set(Object.values(got).filter(Boolean)).size
|
||||
if (distinct < 2 && Object.values(got).every(Boolean)) {
|
||||
// Laos legitimately reads the same in en and es; require only that ar differs from en.
|
||||
if (got.ar === got.en) fails.push(`${z.id}: ar and en are identical (${got.en}) — the supplier is not localizing`)
|
||||
}
|
||||
notes.push(` ok ${z.id.padEnd(22)} ${LOCALES.map((lc) => `${lc}=${got[lc]}`).join(' ')}`)
|
||||
}
|
||||
|
||||
// ── (2) NEGATIVE: every other zone declines, so its label survives BYTE-IDENTICAL ────────────────
|
||||
const multi = zones.filter((z) => zoneCountryCodes(z.selector).length !== 1)
|
||||
for (const z of multi) {
|
||||
for (const lc of LOCALES) {
|
||||
const t = titleFor(z, lc)
|
||||
if (t !== null) {
|
||||
const n = zoneCountryCodes(z.selector).length
|
||||
fails.push(`${z.id}: generated "${t}" in ${lc}, but a ${n}-country zone must DECLINE and keep its label "${z.label}"`)
|
||||
}
|
||||
}
|
||||
}
|
||||
// ⚠ COUNTED IN cluster ENTRIES, which is 29 — NOT the 28 leaf zones the rest of the product counts.
|
||||
// The two deaf_lists entries collapse into one `deaf` leaf, so "26 decline" here and "25 decline"
|
||||
// measured over leaves are the same fact at different granularity. Both deaf entries decline either
|
||||
// way, so the ruling holds at both. Stated because the 28-vs-29 gap has already produced two real
|
||||
// errors, and a reader who expects 25 should be able to see why this says 26 without re-deriving it.
|
||||
notes.push(` ok ${String(multi.length).padEnd(22)} cluster entries decline in all ${LOCALES.length} locales, keeping their labels byte-identical`)
|
||||
notes.push(` ${''.padEnd(22)} (= ${multi.length - 1} leaf zones; the 2 deaf entries collapse to 1 leaf and both decline)`)
|
||||
|
||||
// ── report ───────────────────────────────────────────────────────────────────────────────────────
|
||||
for (const n of notes) console.log(n)
|
||||
if (fails.length) {
|
||||
console.error('ZONE-TITLE-A: FAIL')
|
||||
for (const f of fails) console.error(` ✗ ${f}`)
|
||||
console.error('\n Tier A (one country) generates; everything else keeps its hand-written label.')
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`ZONE-TITLE-A: PASS — ${single.length} one-country zones generate in ${LOCALES.join('/')}; the other ${multi.length} decline and keep their labels unchanged.`)
|
||||
|
|
@ -136,6 +136,32 @@ export function countryLabel(iso3, opts = {}) {
|
|||
export function zoneTitle(zone, strings, opts = {}) {
|
||||
const codes = zoneCountryCodes(zone?.selector)
|
||||
if (!codes.length) return null // tier C — declined, caller keeps its label
|
||||
|
||||
// ★ ONE COUNTRY ONLY (dx-shepherd, 2026-09-28). A multi-country zone DECLINES, exactly as tier C
|
||||
// does, and keeps its hand-written label.
|
||||
//
|
||||
// This is not a limitation of the joiner below — the joiner works. It is that A MULTI-COUNTRY ZONE
|
||||
// HAS A NAME THAT IS NOT A LIST OF ITS MEMBERS, so no assembly of country names can produce it.
|
||||
// MEASURED over all 29 zones in en/es/ar before wiring anything, and the generated title was WORSE
|
||||
// than the hand-written one it would replace — in ENGLISH TOO, so this was never a localization
|
||||
// trade-off:
|
||||
// "Middle East Core" would become "Iran + Yemen + 11 more" (2 named of 13)
|
||||
// "Scattered / Diaspora" would become "Andorra + Cocos (Keeling) Islands + 2 more"
|
||||
// and no maxParts rescues it: 2 → 3 → 4 → 13 just walks toward a full 13-country enumeration.
|
||||
// "Middle East Core" names a REGION; "Scattered / Diaspora" names a KIND of zone. Country names
|
||||
// cannot say either.
|
||||
//
|
||||
// ⚠ THE GUARD IS HERE, NOT AT THE CALLER, AND THAT IS DELIBERATE. Six product call sites supply
|
||||
// this across three surfaces (the legend in upg-100-list.vue x3, posters, packets) — a caller-side
|
||||
// `codes.length === 1` test would have to be repeated six times and would drift the first time one
|
||||
// was missed, silently regressing one surface while the others stayed right. One guard, one
|
||||
// behaviour, every surface.
|
||||
//
|
||||
// What still works: tier A generates in all 11 locales at zero translation cost (Sudan / Sudán /
|
||||
// السودان). Tier B keeping its label is the SAME null-means-keep-yours contract tier C uses, so
|
||||
// this widens an existing path rather than adding a new one.
|
||||
if (codes.length > 1) return null
|
||||
|
||||
const maxParts = Math.max(1, opts.maxParts ?? 2)
|
||||
|
||||
const named = codes.map((c) => countryLabel(c, opts))
|
||||
|
|
|
|||
Loading…
Reference in a new issue