mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
feat(i18n): presentation extraction — t() call sites, data-i18n templates, locale selector
Route every application-owned presentation string through the i18n core: t() call sites across the app/layers/ui/data/voice surfaces, data-i18n* attributes in the src/ui/templates/*.html markup (applied at boot by applyDocumentTranslations from src/standalone/application.js), and the dock locale selector (runtime-rendered from the offered pair — a single EN button while only English ships). Rebased onto current main, where the extraction follows the module splits: the shell wiring now lands in the owner modules (panelChrome, visualSettings, locationNavigation, shareRestoration, shellFeedback, applicationShell) and the voice status corpus in the realtime* modules; scene status lives in director.js + playbackAdapter.js. Modules shared with the node runtime (loadingFeedback, installationFeedback, regionalModel, keySetupCore, vessels ingestion) stay environment-neutral English and localize at their browser render sites (chip maps in shellFeedback/installations controls, the weather table in cockpitBriefing, keySetupRequirementEnvVars compositions), so the package-boundary graph stays clean; the browser boundary groups now own the i18n modules they reach (scripts/package-boundaries.json).
This commit is contained in:
1 parent
bde1e430ef
commit
284c4d4601
98 files changed
+3474
-1625
No files matched your search
@@ -210,6 +210,7 @@ export default {
|
||||
'cctv.summary.empty': 'No cameras available in catalog.',
|
||||
'cctv.summary.city': '{city} CCTV',
|
||||
'cctv.summary.hdg': 'HDG {value}°',
|
||||
'cctv.summary.hdgEstimated': 'HDG {value}° (ESTIMATED)',
|
||||
'cctv.summary.fov': 'FOV {value}°',
|
||||
'cctv.summary.coverage': 'COVERAGE {value}km²',
|
||||
'cctv.summary.overlap': 'OVERLAP {count} cams',
|
||||
@@ -415,8 +416,21 @@ export default {
|
||||
'firms.card.focusAria': 'Focus fire detection {title}, {details}',
|
||||
|
||||
// src/data/traffic.js feed presentation (manager meta loadingLabel values).
|
||||
'traffic.loadingSyncing': 'syncing LIVE traffic flow',
|
||||
'traffic.liveCoverage': 'LIVE · TomTom flow · {percent}% cov',
|
||||
'traffic.loadingSyncing':
|
||||
'Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated',
|
||||
'traffic.liveCoverage':
|
||||
'LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov',
|
||||
'traffic.liveCoverageUnmatched':
|
||||
'LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated',
|
||||
'traffic.simulatedNoMatches':
|
||||
'SIMULATED · Roads: OpenStreetMap · Flow: TomTom (no matches)',
|
||||
'traffic.syncingRoads': 'Syncing flow · Roads: {source}',
|
||||
'traffic.liveRoadsFlowHidden':
|
||||
'LIVE · Roads: {source} · Roads without flow hidden',
|
||||
'traffic.liveRoadsNoFlowInView':
|
||||
'LIVE · Roads: {source} · No flow roads in view',
|
||||
'traffic.roadsFlowLive': 'LIVE · Roads: {source} · Flow {percent}%',
|
||||
'traffic.roadsFlowSimulated': 'SIMULATED · Roads: {source} · Flow {percent}%',
|
||||
'traffic.simUnavailable': 'SIMULATED — traffic service unreachable',
|
||||
'traffic.simKeyless': 'SIMULATED — add TomTom key for live',
|
||||
|
||||
@@ -470,6 +484,7 @@ export default {
|
||||
'awareness.cohort.vessels': 'AIS vessels',
|
||||
'awareness.cohort.installations': 'Mapped installations',
|
||||
'awareness.coverage.viewport': 'CURRENT VIEWPORT ONLY',
|
||||
'awareness.coverage.within': 'WITHIN {km} KM',
|
||||
'awareness.hdg': 'HDG {value}°',
|
||||
'awareness.brg': 'BRG {value}°',
|
||||
'awareness.brgUnknown': 'BRG —',
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// Markup coverage gate (phase 2): every data-i18n* attribute in the static
|
||||
// application markup must name a key that resolves in EVERY shipped catalog
|
||||
// via the real catalog builder — a renamed or dropped catalog key can no
|
||||
// longer strand a static extraction silently behind the key-itself fallback.
|
||||
// index.html is now assembled from src/ui/templates/*.html by
|
||||
// build/application-html.js before Vite processes it, so the gate expands the
|
||||
// same templates the build serves and scans that assembled markup. Regex
|
||||
// parsing is deliberate: no DOM dependency in unit tests.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { getCatalog } from './index.js';
|
||||
import { CATALOG_LOCALES, availableLocales } from './locale.js';
|
||||
import { expandApplicationHtml } from '../../build/application-html.js';
|
||||
|
||||
const INDEX_HTML = new URL('../../index.html', import.meta.url);
|
||||
const UI_JS = new URL('../../src/ui/applicationShell.js', import.meta.url);
|
||||
|
||||
// The four spellings applyDocumentTranslations() knows about. Anything else
|
||||
// (a typo like data-i18n-lable) must fail here, not silently never apply.
|
||||
const I18N_ATTRIBUTE_PATTERN = /data-i18n(?:-title|-aria-label|-placeholder)?="([^"]+)"/g;
|
||||
const KNOWN_ATTRIBUTE_NAMES = new Set([
|
||||
'data-i18n',
|
||||
'data-i18n-title',
|
||||
'data-i18n-aria-label',
|
||||
'data-i18n-placeholder',
|
||||
]);
|
||||
|
||||
const html = expandApplicationHtml(readFileSync(INDEX_HTML, 'utf8'));
|
||||
|
||||
const references = [...html.matchAll(I18N_ATTRIBUTE_PATTERN)].map((match) => match[1]);
|
||||
|
||||
test('index.html carries static i18n references', () => {
|
||||
assert.ok(references.length > 0, 'no data-i18n* attributes found — extraction missing?');
|
||||
});
|
||||
|
||||
test('index.html sources its body from the application templates', () => {
|
||||
const raw = readFileSync(INDEX_HTML, 'utf8');
|
||||
assert.ok(raw.includes('<!-- gev:template '),
|
||||
'index.html must keep the gev:template markers the build expands');
|
||||
assert.equal((raw.match(/data-i18n/g) || []).length, 0,
|
||||
'data-i18n markup belongs in src/ui/templates/*.html, not the marker shell');
|
||||
});
|
||||
|
||||
test('every data-i18n* attribute value resolves in every shipped catalog', () => {
|
||||
const catalogs = Object.fromEntries(CATALOG_LOCALES.map((locale) => [locale, getCatalog(locale)]));
|
||||
for (const locale of CATALOG_LOCALES) {
|
||||
assert.ok(catalogs[locale], `catalog builder produced the ${locale} locale`);
|
||||
}
|
||||
const missing = references.filter((key) => (
|
||||
CATALOG_LOCALES.some((locale) => !(key in catalogs[locale]))
|
||||
));
|
||||
assert.deepEqual(
|
||||
[...new Set(missing)],
|
||||
[],
|
||||
'static markup references keys absent from a catalog (translation would fall back or render the key)',
|
||||
);
|
||||
});
|
||||
|
||||
test('no unknown data-i18n* attribute spelling sneaks past the application pass', () => {
|
||||
const stray = [...html.matchAll(/(data-i18n[a-z-]*)="/g)]
|
||||
.map((match) => match[1])
|
||||
.filter((name) => !KNOWN_ATTRIBUTE_NAMES.has(name));
|
||||
assert.deepEqual(
|
||||
[...new Set(stray)],
|
||||
[],
|
||||
'applyDocumentTranslations() only applies the four known attributes; a fifth spelling is dead markup',
|
||||
);
|
||||
});
|
||||
|
||||
test('attribute values are well-formed catalog keys', () => {
|
||||
for (const key of references) {
|
||||
assert.match(key, /^(shell|cockpit|layers|setup)\.[a-z][a-zA-Z0-9]*(\.[a-zA-Z0-9]+)*$/, `malformed key: ${key}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the dock language switch keeps a static group container with runtime-rendered buttons', () => {
|
||||
const selector = html.match(/<div class="dock-locale-switch"[\s\S]*?<\/div>/);
|
||||
assert.ok(selector, 'the locale switch group is missing from the control-panel tray');
|
||||
assert.match(selector[0], /data-i18n-aria-label="shell\.locale\.groupAriaLabel"/);
|
||||
// Buttons are no longer static: ui.js _initLocaleSelector() renders one per
|
||||
// locale in the configured pair (GEV_DEFAULT_LOCALE / GEV_SECONDARY_LOCALE).
|
||||
assert.doesNotMatch(
|
||||
selector[0],
|
||||
/dock-locale-btn/,
|
||||
'static selector buttons must move to the runtime renderer so the configured pair shapes the switch',
|
||||
);
|
||||
// Markup-only contract: no listener may attach from markup.
|
||||
assert.doesNotMatch(selector[0], / onclick| onsubmit| javascript:/i);
|
||||
});
|
||||
|
||||
test('every offered locale has a shell.locale.<code>.ariaLabel in every catalog; the replaced family stays deleted', () => {
|
||||
// The runtime renderer reads shell.locale.<code>.ariaLabel for every locale
|
||||
// availableLocales() can yield under any configuration — i.e. every shipped
|
||||
// catalog locale — and it must exist in every catalog.
|
||||
for (const catalogLocale of CATALOG_LOCALES) {
|
||||
const catalog = getCatalog(catalogLocale);
|
||||
for (const code of CATALOG_LOCALES) {
|
||||
assert.ok(
|
||||
catalog[`shell.locale.${code}.ariaLabel`],
|
||||
`${catalogLocale} catalog is missing shell.locale.${code}.ariaLabel for the runtime selector`,
|
||||
);
|
||||
}
|
||||
assert.equal(catalog['shell.locale.english.ariaLabel'], undefined,
|
||||
'the replaced phase-2 english.ariaLabel key stays deleted');
|
||||
assert.equal(catalog['shell.locale.spanish.ariaLabel'], undefined,
|
||||
'the replaced phase-2 spanish.ariaLabel key stays deleted');
|
||||
}
|
||||
// Default configuration pins the pair the shipped build offers: en-only
|
||||
// while English is the only shipped catalog (the selector renders a single
|
||||
// EN button; each secondary-locale PR widens this pin).
|
||||
assert.deepEqual([...availableLocales()], ['en']);
|
||||
});
|
||||
|
||||
test('ui.js _initLocaleSelector renders the configured pair (runtime contract)', () => {
|
||||
const source = readFileSync(UI_JS, 'utf8');
|
||||
const start = source.indexOf('_initLocaleSelector() {');
|
||||
assert.ok(start >= 0, '_initLocaleSelector method not found in src/ui.js');
|
||||
const body = source.slice(start, source.indexOf('\n }\n', start));
|
||||
assert.ok(body.length > 0, '_initLocaleSelector body not extracted');
|
||||
assert.match(body, /availableLocales\(\)/, 'iterates the resolved pair, not a hardcoded EN|ES');
|
||||
assert.match(body, /shell\.locale\.\$\{locale\}\.ariaLabel/, 'aria-labels come from the locale key family');
|
||||
assert.match(body, /toUpperCase\(\)/, 'labels are uppercase locale codes');
|
||||
assert.match(body, /aria-pressed/, 'pressed state is synced');
|
||||
assert.match(body, /persistLocaleAndReload/, 'clicks persist and reload');
|
||||
assert.match(body, /dock-locale-switch/, 'renders into the static group container');
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
// Repair-pass anchors (extraction correctness).
|
||||
//
|
||||
// en behavior must stay byte-identical: the values added for the director
|
||||
// status corpus, the keySetup dev surface, and the map-source wiring are
|
||||
// pinned here to the exact literals the code rendered before extraction.
|
||||
// Cross-module anchors (keySetupCore registry) catch catalog/registry drift
|
||||
// in either direction. The locale-scoped CCTV wrap rules and per-locale value
|
||||
// pins live in their locale PRs (es first); this file pins the English
|
||||
// contract plus the pixel-identical English layout.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import { getCatalog } from './index.js';
|
||||
import { KEY_SETUP_KEYS, keySetupRequirement } from '../keySetupCore.mjs';
|
||||
|
||||
const en = getCatalog('en');
|
||||
|
||||
test('director status corpus keeps the pre-extraction English literals byte-identical', () => {
|
||||
const expected = {
|
||||
'setup.scenes.status.captureCameraNotReady': 'Cannot capture shot: camera not ready',
|
||||
'setup.scenes.status.shotTitleDefault': 'Shot {n}',
|
||||
'setup.scenes.status.captured': 'Captured: {scene} / {shot}',
|
||||
'setup.scenes.status.selectShotFirst': 'Select a shot first',
|
||||
'setup.scenes.status.updated': 'Updated: {scene} / {shot}',
|
||||
'setup.scenes.status.deleteShotConfirm': 'Delete shot "{shot}"?',
|
||||
'setup.scenes.status.loaded': 'Loaded: {scene} / {shot}',
|
||||
'setup.scenes.status.cameraUnavailable': 'Camera unavailable — exit cockpit first',
|
||||
'setup.scenes.status.noShotsToRun': 'No shots to run',
|
||||
'setup.scenes.status.runningShot': 'Running {index}/{total}: {scene} / {shot}',
|
||||
'setup.scenes.status.runComplete': 'Scene run complete',
|
||||
'setup.scenes.status.runError': 'Error: {message}',
|
||||
'setup.scenes.status.contextExitFailed': 'Could not exit {mode} — scene layers may be refused',
|
||||
};
|
||||
for (const [key, value] of Object.entries(expected)) {
|
||||
assert.equal(en[key], value, `${key} drifted from the removed English literal`);
|
||||
}
|
||||
});
|
||||
|
||||
test('setup.keySetup.unlocks.* en values equal the keySetupCore registry copy', () => {
|
||||
for (const entry of KEY_SETUP_KEYS) {
|
||||
const key = `setup.keySetup.unlocks.${entry.id}`;
|
||||
assert.equal(en[key], entry.unlocks, `${key} drifted from KEY_SETUP_KEYS unlocks`);
|
||||
}
|
||||
assert.equal(en['setup.keySetup.requirement'], 'Needs {envVars} — add it in Provider Settings');
|
||||
assert.equal(
|
||||
keySetupRequirement('cesium-ion'),
|
||||
'Needs CESIUM_ION_TOKEN — add it in Provider Settings',
|
||||
'en requirement sentence must stay byte-identical (pinned by keySetupCore.test.mjs)',
|
||||
);
|
||||
});
|
||||
|
||||
test('map-source keys match the static markup pinned by mapStackChips.test.mjs', () => {
|
||||
assert.equal(en['cockpit.presets.mapSourceLabel'], 'MAP SOURCE');
|
||||
assert.equal(en['cockpit.presets.mapSourceChipsAriaLabel'], 'Map source');
|
||||
});
|
||||
|
||||
test('the seven redundant layers.radio transport seeds stay deleted', () => {
|
||||
const deleted = [
|
||||
'layers.radio.pause',
|
||||
'layers.radio.resume',
|
||||
'layers.radio.playNearestAria',
|
||||
'layers.radio.pauseSelectedAria',
|
||||
'layers.radio.pauseNearestAria',
|
||||
'layers.radio.resumeSelectedAria',
|
||||
'layers.radio.resumeNearestAria',
|
||||
];
|
||||
for (const key of deleted) {
|
||||
assert.equal(en[key], undefined, `${key} was removed by the repair pass`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the English CCTV control rows stay unwrapped: no locale-scoped wrap rule ships ahead of its locale', () => {
|
||||
// Locale wrap rules (html[lang='…'] .cctv-controls { flex-wrap: wrap }) are
|
||||
// added one per locale PR, each with a measured-width justification — never
|
||||
// speculatively. Until then the base rule must stay unwrapped so English
|
||||
// pixels are unchanged, and no html[lang] override may exist.
|
||||
const css = readFileSync(new URL('../../src/ui/styles/cctv.css', import.meta.url), 'utf8');
|
||||
const baseStart = css.indexOf('.cctv-controls {');
|
||||
const baseBody = css.slice(baseStart, css.indexOf('}', baseStart));
|
||||
assert.ok(!baseBody.includes('flex-wrap'), 'base .cctv-controls must stay unwrapped (EN pixel-identical)');
|
||||
assert.ok(!/html\[lang=/i.test(css), 'no locale-scoped CCTV rule may ship before its locale does');
|
||||
});
|
||||
Reference in new issue
Block a user