From 3129e531e681a0c63414b96d1b03fcdb71164ca6 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Mon, 12 Oct 2026 19:24:42 +0400 Subject: [PATCH] feat(i18n): translate the whole interface Mark every UI string the unmarked-text check reported (534) plus text it cannot see (helpers, joined labels, runtime-built panels), and translate the result to Spanish and Russian: 1,679 messages each. - Kept on purpose, with the reason where the text lives: provider and product names, place and camera names, codes and units, dB values, the HUD terminal readouts, demo shot titles that saved projects look up by title, and text for AI tools and the voice model. - Modules shared with the server or AI tools stay free of the i18n import (local msg); the wind reading and voice session stay English and are translated where the page shows them. - npm test now runs the unmarked-text check. - Fixes on the way: a duplicated radio privacy paragraph, the contact panel toggle saying Expand while open, the CSS filter mistaking & for CSS. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 +- docs/CURRENT-STATE.md | 7 +- docs/I18N.md | 4 +- docs/translations/ru.md | 7 +- scripts/i18n.mjs | 6 +- scripts/package-boundaries.json | 30 +- src/annotations/annotationEngine.js | 9 + src/annotations/drawTool.js | 17 +- src/annotations/screenAnnotationRenderer.js | 16 +- src/app/globePanelRuntime.js | 3 + src/cockpitMarkup.test.mjs | 830 +++++++++++++---- src/data/bhoteKoshiEmbeddedMedia.js | 81 +- src/data/bhoteKoshiEvent.js | 182 ++-- src/data/bhoteKoshiLocator.js | 61 +- src/data/layerState.js | 9 +- src/data/satelliteClass.js | 20 +- src/firstRunExperience.test.mjs | 627 ++++++++++--- src/hud.js | 10 +- src/i18n/catalog.test.mjs | 8 + src/i18n/locales/es.json | 824 ++++++++++++++++- src/i18n/locales/ru.json | 850 +++++++++++++++++- src/keySetup.js | 43 +- src/keySetupCore.mjs | 20 +- src/layers/alpr/index.js | 51 +- src/layers/alpr/presentation.js | 3 +- src/layers/alpr/source.js | 7 +- src/layers/cctv/policy.js | 36 +- src/layers/cyclones/index.js | 110 ++- src/layers/directions/index.js | 120 ++- src/layers/firms/queries.js | 8 +- src/layers/firms/selection.js | 7 +- src/layers/perimeters/index.js | 12 +- src/layers/recentImagery/index.js | 24 +- src/layers/recentImagery/model.js | 16 +- src/layers/satellites/labels.js | 2 +- src/layers/submarineCables/bundledSource.js | 2 +- src/layers/weather/index.js | 157 +++- src/layers/wind/fields.js | 6 +- src/layers/wind/index.js | 85 +- src/layers/wind/inspection.js | 49 +- src/layers/wind/presentation.js | 32 +- src/loadingFeedback.js | 4 + src/locationReadouts.test.mjs | 2 +- src/main.js | 5 +- src/mapStackChips.test.mjs | 2 +- src/maps/catalog.js | 10 +- src/overlays/worldOverlay.js | 7 +- .../worldOverlayAllocation.worker.mjs | 9 + src/scenes/dataPacks/presentation.js | 3 +- src/scenes/director.js | 76 +- src/scenes/interactions.js | 23 +- src/scenes/nepalEvidencePack.js | 12 + src/scenes/project.js | 4 + src/scenes/recipes.js | 43 +- src/scenes/sharing.js | 106 ++- src/sdr/controller.js | 61 +- src/sources/live/standalone.js | 3 + src/styles/surveillance.js | 2 +- src/ui/cameraOrientationControls.js | 9 +- src/ui/cctvCalibration.js | 22 +- src/ui/cockpitBriefing.js | 2 +- src/ui/cockpitContext.js | 20 +- src/ui/cockpitInstruments.js | 18 +- src/ui/cockpitLayout.js | 11 +- src/ui/cyberTheme.test.mjs | 2 +- src/ui/frameRateMonitor.js | 6 +- src/ui/layerPanel.js | 27 +- src/ui/layerPanel.test.mjs | 4 +- src/ui/localSdrPresentation.js | 54 +- src/ui/panelPositionControls.js | 4 +- src/ui/radioBindings.js | 7 +- src/ui/railCardBlocks.js | 5 +- src/ui/railTimeline.js | 19 +- src/ui/recentImagery.js | 254 ++++-- src/ui/scenePresentation.js | 42 +- src/ui/sceneSharing.js | 6 +- src/ui/shareRestoration.js | 6 +- src/ui/shellFeedback.js | 3 + src/ui/streetLevelControls.js | 2 +- src/ui/templates/cockpit.html | 44 +- src/ui/templates/command-dock.html | 10 +- src/ui/templates/context.html | 131 ++- src/ui/templates/display-controls.html | 30 +- src/ui/templates/hud-loading.html | 2 +- src/ui/templates/layer-panels.html | 18 +- src/ui/templates/scene-chrome.html | 8 +- src/ui/templates/welcome.html | 4 +- src/ui/visualPresets.js | 20 +- src/ui/visualSettings.js | 14 +- src/ui/weatherPanel.js | 51 +- src/voice/control.js | 25 +- src/voice/gevActions.js | 6 + src/voice/realtimeCost.js | 21 +- src/voice/realtimeInput.js | 1 + src/voice/sessionCommands.js | 30 +- src/voice/speech.js | 246 ++++- src/voice/voiceCard.test.mjs | 2 +- src/voice/voiceCardPresentation.js | 7 +- src/voice/voiceCost.js | 4 +- 99 files changed, 4670 insertions(+), 1294 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08c03cc5c..cd22dcefd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,9 @@ switch in the Visual Presets dock, then the browser language; English stays the default and the fallback. `npm run i18n`, also run by `npm test`, fails on missing, unused or broken translations. Built on the i18n foundation by - @meirm (#601), with the Russian catalog started from #312. + @meirm (#601), with the Russian catalog started from #312. It also fails on UI text that is + not marked at all, so the whole interface, including surfaces added after + the original extraction, is translated (1,679 messages). - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 0eadf2ac5..26e39a5dd 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3030,8 +3030,11 @@ Shipped locales: **en** (source and fallback), **es** and **ru**. The code lives - **Not translated by design:** layer ids, share-link and hash parameters, API fields, voice tool schemas, provider names, feed data (callsigns, vessel and station names) and attribution. -- **Gaps.** Strings added after #601's extraction (for example the Street Level - panel) are still raw English; there is no lint for unmarked UI text yet. +- **Unmarked text.** The same check lists UI text that is not marked at all + (markup without `data-i18n*`, literals reaching the page without `t`/`tn`/`msg`) + and fails on it. Text kept on purpose says why: `translate="no"`, + `data-i18n-runtime`, or `// i18n-ignore: `. `src/tools/` is skipped: + it writes for AI clients, not the page. ## Canonical Docs Order diff --git a/docs/I18N.md b/docs/I18N.md index d59ef8bcf..ac099f0c6 100644 --- a/docs/I18N.md +++ b/docs/I18N.md @@ -60,10 +60,10 @@ To see a language, open the app with `?lang=ru`, or use the language switch in t ## Text the check reports as unmarked -`npm run i18n` also lists UI text that is not marked at all, and fails while there is any: +`npm run i18n` also lists UI text that is not marked at all, and fails while there is any; `npm test` runs the same check: - **Markup:** element text without `data-i18n`, and `title`, `aria-label` or `placeholder` without the matching `data-i18n-*`. -- **Code:** string literals that reach the page without `t()`, `tn()` or `msg()`: assigned to `textContent`, `innerText`, `innerHTML`, `title`, `placeholder`; passed to `setAttribute('title' | 'aria-label' | 'aria-valuetext' | 'placeholder' | 'alt', …)`, `createTextNode`, `insertAdjacentHTML`, `showToast`, `toast`, `setHint`, `announce`; or kept in `label:`, `title:`, `tooltip:`, `placeholder:`, `hint:`, `ariaLabel:`, `caption:`, `subtitle:`, `kicker:`, `emptyText:` properties. +- **Code:** string literals that reach the page without `t()`, `tn()` or `msg()`: assigned to `textContent`, `innerText`, `innerHTML`, `title`, `placeholder`; passed to `setAttribute('title' | 'aria-label' | 'aria-valuetext' | 'placeholder' | 'alt', …)`, `createTextNode`, `insertAdjacentHTML`, `showToast`, `toast`, `setHint`, `announce`; or kept in `label:`, `title:`, `tooltip:`, `placeholder:`, `hint:`, `ariaLabel:`, `caption:`, `subtitle:`, `kicker:`, `emptyText:`, `blurb:`, `countLabel:` properties. Fix it by marking the text. When the text must stay as it is, say so where it lives: diff --git a/docs/translations/ru.md b/docs/translations/ru.md index bb6f4bd95..8dda853ec 100644 --- a/docs/translations/ru.md +++ b/docs/translations/ru.md @@ -21,7 +21,7 @@ A `tn()` entry needs four forms. `Intl.PluralRules('ru')` picks: | `many` | 0, 5–20, 25–30, 111–114… | 5 камер | | `other` | fractions: 1.5, 2.25… | 1,5 камеры | -When a word next to a number cannot agree in every form, move the number after a colon instead: «доступно {bikes}», «ответов: {responses}». +When a word next to a number cannot agree in every form, move the number after a colon instead: «доступно {bikes}», «ответов: {responses}». The same trick keeps place names and tool names out of a preposition's case: «откуда: {origin}», «Активно: {name}». ## Glossary @@ -53,4 +53,9 @@ When a word next to a number cannot agree in every form, move the number after a | HDG / BRG / CRS / FOV / DEST / SPD / RANGE | КУРС / ПЕЛ. / ПУТЬ / ОБЗОР / НАЗН. / СКОР / ДАЛЬН. | | PREV / NEXT | ПРЕД. / СЛЕД. | | Previous / Next (button text) | Назад / Далее | +| anchor / data pack / bundle (scene) | якорь / пакет данных / архив | +| basemap / box (Recent Imagery area) | подложка / область | +| camera move | перелёт | +| SIG / NEWS / LOCAL (briefing tabs) | СИГН. / НОВОСТИ / МЕСТН. | +| compass points (N, NNE, …) | С, ССВ, … | | L / R (relative bearing) | Л / П | diff --git a/scripts/i18n.mjs b/scripts/i18n.mjs index 773419c56..30e339ca8 100644 --- a/scripts/i18n.mjs +++ b/scripts/i18n.mjs @@ -207,7 +207,7 @@ const JS_SINKS = [ /\b(?:createTextNode|insertAdjacentText)\(\s*(?:['"][a-z]+['"]\s*,\s*)?/g, /\binsertAdjacentHTML\(\s*['"][a-z]+['"]\s*,\s*/g, /\b(?:showToast|toast|setHint|announce)\(\s*/g, - /(?:^|[{,\s])(?:label|title|tooltip|placeholder|hint|ariaLabel|caption|subtitle|kicker|emptyText)\s*:\s*/gm, + /(?:^|[{,\s])(?:label|title|tooltip|placeholder|hint|ariaLabel|caption|subtitle|kicker|emptyText|blurb|countLabel)\s*:\s*/gm, ]; const MARKERS = /\b(?:t|tn|msg)\(/y; @@ -300,11 +300,11 @@ function visibleText(literal, html) { const attrs = [ ...body.matchAll(/\s(?:title|aria-label|placeholder|alt)="([^"]*)"/g), ].map((m) => m[1]); - body = [body.replace(/<[^>]*>/g, ' '), ...attrs].join(' '); + body = decodeHtml([body.replace(/<[^>]*>/g, ' '), ...attrs].join(' ')); } const text = normalize(body); if (!hasWords(text)) return null; - if (/[{};]/.test(text) && /:\s*[^\s]/.test(text)) return null; // CSS + if (/\{[^{}]*:[^{}]*\}|^[\w-]+\s*:\s*[^;]+;/.test(text)) return null; // CSS if (/^(?:rgba?|hsla?)\(|\b\d+px\b/.test(text)) return null; // colours, fonts if (/^[a-z0-9_.:/-]+$/.test(text) || /^https?:/.test(text)) return null; return text; diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 12ac3e34c..2f8e042f0 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1003,7 +1003,11 @@ "src/layers/recentImagery/catalog.js", "src/layers/recentImagery/rendering.js", "src/layers/recentImagery/thumbnails.js", - "src/maps/imageryHost.js" + "src/maps/imageryHost.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json" ], "external": ["cesium"] }, @@ -1342,7 +1346,11 @@ "src/sources/httpBody.js", "src/sources/vectorTiles.js", "src/sources/phaseTiming.js", - "src/layers/alpr/floors.js" + "src/layers/alpr/floors.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json" ], "external": ["cesium", "@mapbox/vector-tile", "pbf"] }, @@ -3080,7 +3088,11 @@ "modules": [ "src/layers/directions/index.js", "src/layers/directions/source.js", - "src/data/routeSteps.js" + "src/data/routeSteps.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json" ], "external": ["cesium"] }, @@ -3358,7 +3370,11 @@ "src/sources/overpassFeaturesRecords.js", "src/sources/overpassFeatures.js", "src/sources/featureSource.js", - "src/sources/capability.js" + "src/sources/capability.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json" ], "external": ["cesium", "egm96-universal", "pbf"] }, @@ -3440,7 +3456,11 @@ "src/layers/perimeters/source.js", "src/layers/perimeters/records.js", "src/layers/perimeters/cards.js", - "src/layers/perimeters/inciweb.js" + "src/layers/perimeters/inciweb.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json" ], "external": ["cesium"] }, diff --git a/src/annotations/annotationEngine.js b/src/annotations/annotationEngine.js index 27b049bb8..a85a143f9 100644 --- a/src/annotations/annotationEngine.js +++ b/src/annotations/annotationEngine.js @@ -1137,12 +1137,14 @@ export function createAnnotationEngine({ { type: 'highlight', target: 'Palace of Fine Arts, San Francisco', + // i18n-ignore: dev demo standing in for voice-agent labels, which are not translated label: 'Palace of Fine Arts', color: 'amber', }, { type: 'area', target: 'Presidio of San Francisco', + // i18n-ignore: dev demo standing in for voice-agent labels, which are not translated label: 'The Presidio (former Army base)', color: 'green', footprint: true, @@ -1150,6 +1152,7 @@ export function createAnnotationEngine({ { type: 'pin', target: 'Letterman Digital Arts Center, San Francisco', + // i18n-ignore: dev demo standing in for voice-agent labels, which are not translated label: 'ILM / Lucasfilm', color: 'cyan', }, @@ -1157,6 +1160,7 @@ export function createAnnotationEngine({ type: 'arrow', target: 'Palace of Fine Arts, San Francisco', toTarget: 'Marina District, San Francisco', + // i18n-ignore: dev demo standing in for voice-agent labels, which are not translated label: 'next to the Marina', }, ], @@ -1186,6 +1190,7 @@ export function createAnnotationEngine({ { type: 'highlight', target: 'Palace of Fine Arts, San Francisco', + // i18n-ignore: dev demo standing in for voice-agent labels, which are not translated label: 'Palace of Fine Arts', color: 'amber', }, @@ -1200,6 +1205,7 @@ export function createAnnotationEngine({ type: 'arrow', target: 'Palace of Fine Arts, San Francisco', toTarget: 'Marina Green, San Francisco', + // i18n-ignore: dev demo standing in for voice-agent labels, which are not translated label: 'next to the Marina', color: 'cyan', }, @@ -1223,6 +1229,7 @@ export function createAnnotationEngine({ { type: 'area', target: 'Presidio of San Francisco', + // i18n-ignore: dev demo standing in for voice-agent labels, which are not translated label: 'The Presidio — a former Army base', color: 'green', footprint: true, @@ -1237,6 +1244,7 @@ export function createAnnotationEngine({ { type: 'pin', target: 'Letterman Digital Arts Center, San Francisco', + // i18n-ignore: dev demo standing in for voice-agent labels, which are not translated label: 'ILM / Lucasfilm', color: 'red', }, @@ -1250,6 +1258,7 @@ export function createAnnotationEngine({ { type: 'route', color: 'amber', + // i18n-ignore: dev demo standing in for voice-agent labels, which are not translated label: 'Crissy Field shoreline', points: [ { target: 'Palace of Fine Arts, San Francisco' }, diff --git a/src/annotations/drawTool.js b/src/annotations/drawTool.js index 5ffe1e6df..66743c65b 100644 --- a/src/annotations/drawTool.js +++ b/src/annotations/drawTool.js @@ -198,7 +198,10 @@ export function initDrawTool({ viewer, annotations }) { // a limit rather than the harmless tail of a double-click. if (reason === 'full') setHint( - `That shape already has ${MAX_VERTICES} points — finish it or press Backspace.`, + t( + 'That shape already has {count} points — finish it or press Backspace.', + { count: MAX_VERTICES }, + ), ); else if (reason === 'invalid') setHint(t('That point is off the globe — click on the world.')); @@ -241,7 +244,11 @@ export function initDrawTool({ viewer, annotations }) { return result; } catch (error) { if (destroyed || attempt !== generation) return null; - setHint(`Could not place the shape: ${error?.message || error}`); + setHint( + t('Could not place the shape: {error}', { + error: error?.message || error, + }), + ); return null; } }; @@ -325,7 +332,11 @@ export function initDrawTool({ viewer, annotations }) { // Somebody else is using the pointer — possibly an older instance of // this same tool that has not finished tearing down. Say so instead of // half-starting. - setHint(`${pointerOwner()} is using the pointer — close it first.`); + setHint( + t('{owner} is using the pointer — close it first.', { + owner: pointerOwner(), + }), + ); return active; } } diff --git a/src/annotations/screenAnnotationRenderer.js b/src/annotations/screenAnnotationRenderer.js index a2447ecce..c6c214fff 100644 --- a/src/annotations/screenAnnotationRenderer.js +++ b/src/annotations/screenAnnotationRenderer.js @@ -4,6 +4,12 @@ import { getActiveTrackedReadoutId, TRACKED_OVERLAY_SOURCE_ID, } from '../data/trackedReadout.js'; +import { t } from '../i18n/index.js'; + +/** A label whose detailed outline could not be resolved. */ +function outlineUnavailableLabel(label) { + return t('{label} · Detailed outline unavailable', { label: label || '' }); +} /** * Screen-space annotation renderer (Direction B — the "whiteboard" aesthetic). @@ -163,7 +169,7 @@ export function createScreenAnnotationRenderer( group.appendChild(parts.poly); parts.label = makeCallout( anno.outlineUnavailable - ? `${anno.label || ''} · Detailed outline unavailable` + ? outlineUnavailableLabel(anno.label) : anno.label, c, ); @@ -181,7 +187,7 @@ export function createScreenAnnotationRenderer( group.appendChild(parts.path); parts.label = makeCallout( anno.outlineUnavailable - ? `${anno.label || ''} · Detailed outline unavailable` + ? outlineUnavailableLabel(anno.label) : anno.label, c, ); @@ -215,7 +221,7 @@ export function createScreenAnnotationRenderer( }); parts.label = makeCallout( anno.outlineUnavailable - ? `${anno.label || ''} · Detailed outline unavailable` + ? outlineUnavailableLabel(anno.label) : anno.label, c, ); @@ -259,7 +265,7 @@ export function createScreenAnnotationRenderer( group.appendChild(parts.leader); parts.label = makeCallout( anno.outlineUnavailable - ? `${anno.label || ''} · Detailed outline unavailable` + ? outlineUnavailableLabel(anno.label) : anno.label, c, ); @@ -322,7 +328,7 @@ export function createScreenAnnotationRenderer( } rec.anno = anno; if (anno.outlineUnavailable && rec.parts.label) { - rec.parts.label.text.textContent = `${anno.label || ''} · Detailed outline unavailable`; + rec.parts.label.text.textContent = outlineUnavailableLabel(anno.label); sizeCallout(rec.parts.label); } diff --git a/src/app/globePanelRuntime.js b/src/app/globePanelRuntime.js index 2b259d7b9..464bcbac3 100644 --- a/src/app/globePanelRuntime.js +++ b/src/app/globePanelRuntime.js @@ -541,6 +541,7 @@ export function panelRuntime(config) { const notice = document.createElement('div'); notice.id = 'status'; notice.textContent = + // i18n-ignore: panel runtime runs as source text in the host page, with no i18n module or app locale "This panel lost its 3D graphics, likely because other God's Eye " + "View panels in this conversation hold them. Use Open in God's " + 'Eye View above, or show it in a new conversation.'; @@ -616,6 +617,7 @@ export function panelRuntime(config) { if (context?.displayMode) displayMode = context.displayMode; if (context?.availableDisplayModes) expand.hidden = !context.availableDisplayModes.includes('fullscreen'); + // i18n-ignore: panel runtime runs as source text in the host page, with no i18n module or app locale expand.textContent = displayMode === 'fullscreen' ? 'Collapse' : 'Expand'; }; expand.addEventListener('click', () => { @@ -640,6 +642,7 @@ export function panelRuntime(config) { appCapabilities: { availableDisplayModes: ['inline', 'fullscreen'] }, appInfo: { name: 'gods-eye-view', + // i18n-ignore: product name sent to the MCP host title: "God's Eye View", version: '1.0.0', }, diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index 744423c90..0b376d9fc 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -1,4 +1,7 @@ -import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs'; +import { + readShellSource, + shellMethod, +} from './testSupport/readShellSource.mjs'; import { expandApplicationHtml } from '../build/application-html.js'; import { readLayerSource } from './testSupport/readLayerSource.mjs'; import { PanelLayoutController } from './ui/panelLayoutController.js'; @@ -7,10 +10,19 @@ import { readStylesheet } from './testSupport/readStylesheet.mjs'; import { SceneControls } from './ui/sceneControls.js'; import { isRenderedOnScreen } from './ui/cockpitPresentation.js'; import { onKeyDown as cockpitKeyDown } from './ui/cockpitInput.js'; -import { enter as cockpitEnter, exit as cockpitExit, readAircraftInfo, _adoptTrackedEntity } from './ui/cockpitTrackingController.js'; +import { + enter as cockpitEnter, + exit as cockpitExit, + readAircraftInfo, + _adoptTrackedEntity, +} from './ui/cockpitTrackingController.js'; import { setVisionMode, cycleVisionMode } from './ui/cockpitInstruments.js'; import { updateContext as cockpitContext } from './ui/cockpitContext.js'; -import { syncSignalLayout, setContextCollapsed, setSignalCollapsed } from './ui/cockpitLayout.js'; +import { + syncSignalLayout, + setContextCollapsed, + setSignalCollapsed, +} from './ui/cockpitLayout.js'; import { setBriefAutoRotate } from './ui/cockpitBriefing.js'; import { CockpitDisplayPortal } from './ui/cockpitDisplayPortal.js'; import { _handleContextLayerChange } from './ui/contextLayerChanges.js'; @@ -18,8 +30,14 @@ import { clearSelectedLayers } from './ui/contextActions.js'; import { _selectContextMode } from './ui/contextTransactions.js'; import { _restoreContextSession } from './ui/contextSession.js'; import { readFileSync as readRadioSource } from 'node:fs'; -const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8'); -const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8'); +const radioBindings = readRadioSource( + new URL('./ui/radioBindings.js', import.meta.url), + 'utf8', +); +const radioPresentation = readRadioSource( + new URL('./ui/radioPresentation.js', import.meta.url), + 'utf8', +); import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; @@ -28,13 +46,27 @@ import { fileURLToPath } from 'node:url'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const html = expandApplicationHtml(fs.readFileSync(path.join(ROOT, 'index.html'), 'utf8')); +const html = expandApplicationHtml( + fs.readFileSync(path.join(ROOT, 'index.html'), 'utf8'), +); const ui = readShellSource(); const css = readStylesheet(path.join(ROOT, 'style.css')); -const sceneDirector = fs.readFileSync(path.join(ROOT, 'src', 'scenes', 'director.js'), 'utf8'); -const manager = fs.readFileSync(path.join(ROOT, 'src', 'data', 'lifecycle.js'), 'utf8'); -const contextLayer = readLayerSource(path.join(ROOT, 'src', 'data', 'militaryAwareness.js'), 'utf8'); -const voiceActions = fs.readFileSync(path.join(ROOT, 'src', 'voice', 'gevActions.js'), 'utf8'); +const sceneDirector = fs.readFileSync( + path.join(ROOT, 'src', 'scenes', 'director.js'), + 'utf8', +); +const manager = fs.readFileSync( + path.join(ROOT, 'src', 'data', 'lifecycle.js'), + 'utf8', +); +const contextLayer = readLayerSource( + path.join(ROOT, 'src', 'data', 'militaryAwareness.js'), + 'utf8', +); +const voiceActions = fs.readFileSync( + path.join(ROOT, 'src', 'voice', 'gevActions.js'), + 'utf8', +); test('Cockpit has one reset action beside its bottom exit path', () => { assert.doesNotMatch(html, /id="cockpit-quick-entry"/); @@ -43,24 +75,48 @@ test('Cockpit has one reset action beside its bottom exit path', () => { html, /id="map-view-switch"[^>]*aria-label="Exit cockpit view"[^>]*aria-keyshortcuts="Escape C"[\s\S]*?close_fullscreen[\s\S]*?EXIT COCKPIT/, ); - const topCenterActions = html.match(/