fix(hud): keep localized summary words whole and drop padding

- The proxy keeps combining marks, in-word apostrophes and name dots
  (ar vowel signs, fr d'Orléans, ja ・), normalizes to NFC, caps every
  summary in characters and cuts at a word boundary.
- feedState is nullable instead of a `none` token, and the summary has at
  most five words: "exactly five" made the model pad short views with
  "no data" or "unavailable".
- The HUD caches only the model's words and adds the current warning on
  every paint; an RTL locale gets a leading right-to-left mark.
- The echo check also rejects an all-Latin reply in a non-Latin language,
  translated layer ids and, with every feed healthy, the UNAVAILABLE word;
  places are matched by word and one-word layer names are skipped in
  Latin-script locales.
- The warning tag names the worst layers first; a partial analyst card
  shows one partial chip.
This commit is contained in:
German Khokhlov committed 2026-11-02 13:44:53 +04:00
1 parent 0d3c1ef1d9
commit 818d2c8295
12 files changed
+2521 -755

No files matched your search

+8 -2
View File
@@ -259,8 +259,9 @@ export function viewStateLayerRow(layer, options) {
/**
* The feed state and up to two layer names of a provenance envelope that is
* not nominal, or null. Values stay English (`state` is a feed-state id,
* `names` are `layer.name`); the HUD translates them for display.
* not nominal, or null; the worst layers come first, so the names match the
* state. Values stay English (`state` is a feed-state id, `names` are
* `layer.name`); the HUD translates them for display.
* @param {{ overall?: string|null, layers?: Array<object> }|null} envelope
* @returns {{ state: string, names: string[] }|null}
*/
@@ -271,6 +272,11 @@ export function provenanceTag(envelope) {
.filter(
(s) => s.feedState && s.feedState !== 'nominal' && s.feedState !== 'off',
)
.sort(
(a, b) =>
(FEED_STATE_SEVERITY[a.feedState] ?? Infinity) -
(FEED_STATE_SEVERITY[b.feedState] ?? Infinity),
)
.map((s) => String(s.name || s.id || 'LAYER'))
.slice(0, 2);
return { state: overall, names };
+16
View File
@@ -11,6 +11,7 @@ import {
layerFeedState,
layerSnapshot,
layerSnapshots,
provenanceTag,
snapshotAgeLabel,
snapshotAgeSec,
viewStateLayerRow,
@@ -293,3 +294,18 @@ test('age labels match the Data Layers meta clock', () => {
assert.equal(snapshotAgeLabel(NOW - 240_000, NOW), '4m ago');
assert.equal(snapshotAgeLabel(NOW - 7_200_000, NOW), '2h ago');
});
test('the warning tag names the layers in the reported state first', () => {
const tag = provenanceTag({
overall: 'unavailable',
layers: [
{ name: 'Live Flights', feedState: 'partial' },
{ name: 'Live AIS Vessels', feedState: 'partial' },
{ name: 'CCTV', feedState: 'unavailable' },
],
});
assert.deepEqual(tag, {
state: 'unavailable',
names: ['CCTV', 'Live Flights'],
});
});
+56 -34
View File
@@ -27,7 +27,9 @@ import {
} from './data/geoid.js';
import { getBasemapLabelContext } from './voice/gevActions.js';
import {
hasWord,
hudSummaryHasEnglish,
hudSummaryIsLatin,
hudSummaryMatchesProvenance,
hudSummaryLayerContext,
hudTelemetryProvenance,
@@ -46,6 +48,7 @@ import {
onLocaleChange,
t,
} from './i18n/index.js';
import { isRtlLocale } from './i18n/locale.js';
import { localizeLayerName } from './ui/layerPanel.js';
import { isEmbeddedInline } from './app/embed.js';
import { subscribeSelectedVessel } from './hudReadouts.js';
@@ -130,28 +133,43 @@ function withProvenance(text, provenance) {
}
/**
* The English a summary outside English must not carry: feed-state ids and
* translated layers' English names. A state word the locale keeps in English
* (de FALLBACK) and words inside a place label (Radio City, Loading Dock) are
* fine.
* The AI line. In an RTL locale a leading right-to-left mark keeps a reply
* that opens with a Latin name (Times Square …) reading right to left.
*/
function summaryEnglishWords(context) {
function aiSummaryLine(summary, warning) {
const line = withProvenance(summary, warning);
return isRtlLocale(getLocale()) ? `\u200f${line}` : line;
}
/**
* What a summary outside English must not carry: feed-state ids, translated
* layers' English names and ids, and with every feed healthy the
* UNAVAILABLE word. A state word the locale keeps in English (de FALLBACK)
* and words of a place label (Radio City, Loading Dock) are fine; so is a
* one-word layer name in a Latin-script locale, where it may be a word of
* the language (fr transit).
*/
function summaryForbiddenWords(context) {
const places = [
...(context.placeLabels || []),
...(context.streetLabels || []),
...(context.nearbyPlaceLabels || []),
]
.join(' ')
.toLowerCase();
].join(' ');
const latin = hudSummaryIsLatin(context.locale);
const overall = context.feedProvenance?.overall;
return [
...Object.keys(FEED_STATE_LABELS).filter(
(state) =>
t(FEED_STATE_LABELS[state]).toLowerCase() !== state &&
!places.includes(state),
!hasWord(places, state),
),
...(context.translatedLayerNames || []).filter(
(name) => !places.includes(name.toLowerCase()),
...(context.englishLayerWords || []).filter(
(word) =>
!hasWord(places, word) && (!latin || /[^\p{L}\p{N}]/u.test(word)),
),
...(!overall || overall === 'nominal'
? [t(FEED_STATE_LABELS.unavailable)]
: []),
];
}
@@ -246,7 +264,7 @@ export class IntelHUD {
this._placeLabels = null;
this._lastSummarySignature = '';
/** The reply painted for `_lastSummarySignature`, when it was accepted. */
this._lastSummaryText = null;
this._lastSummaryReply = null;
this._summaryRevision = 0;
// One-shot guards so the very first summary lands immediately instead of
// waiting for the 15s interval tick: B) swap the "Awaiting telemetry..."
@@ -325,15 +343,11 @@ export class IntelHUD {
this._startTimers();
}
/**
* Show the HUD's own words in the new locale. The AI reply is written in
* the locale too, so the one received is dropped like a camera move drops
* it: a request in flight is aborted and the next tick asks again.
*/
/** Show the HUD's words in the new locale; drop AI text in the old one. */
_relocalize() {
this._markSummaryDirty();
this._lastSummarySignature = null;
this._lastSummaryText = null;
this._lastSummaryReply = null;
this._summaryRequest?.abort();
this._present({
mode: MODE_LABELS[this._currentStyle]
@@ -887,8 +901,7 @@ export class IntelHUD {
return;
}
if (revision !== this._summaryRevision) return;
// No place to name: with layers alone the model invents one, so the plain
// line (state and layers) is shown instead.
// Without a place label the model invents a place: show the plain line.
const described = [
context.placeLabels,
context.streetLabels,
@@ -896,7 +909,7 @@ export class IntelHUD {
].some((list) => list?.length);
if (!described) {
this._summaryDirty = false;
this._setSummaryText(fallbackText, animate);
this._setSummaryText(fallbackText, false);
return;
}
const signature = JSON.stringify(context);
@@ -904,7 +917,12 @@ export class IntelHUD {
this._summaryDirty = false;
// A settle painted the fallback line over the reply this context
// already has; reused place labels make that common, so restore it.
if (this._lastSummaryText) this._setSummaryText(this._lastSummaryText);
// The warning is the current one: layer states are not in a
// non-English signature.
if (this._lastSummaryReply)
this._setSummaryText(
aiSummaryLine(this._lastSummaryReply, context.warning),
);
return;
}
if (this._summaryRequest) return;
@@ -912,7 +930,7 @@ export class IntelHUD {
if (force) this._setSummaryText(fallbackText, false);
this._summaryDirty = false;
this._lastSummarySignature = signature;
this._lastSummaryText = null;
this._lastSummaryReply = null;
const controller = new AbortController();
let timedOut = false;
const timeout = window.setTimeout(() => {
@@ -940,13 +958,18 @@ export class IntelHUD {
!hudSummaryHasEnglish(
data.summary,
context.locale,
summaryEnglishWords(context),
summaryForbiddenWords(context),
)
) {
// The warning is the HUD's own translated state, not the model's words.
this._lastSummaryText = withProvenance(data.summary, context.warning);
this._lastSummaryReply = data.summary;
}
this._setSummaryText(this._lastSummaryText || fallbackText, animate);
// The warning is the HUD's own translated state, not the model's words.
this._setSummaryText(
this._lastSummaryReply
? aiSummaryLine(this._lastSummaryReply, context.warning)
: fallbackText,
animate,
);
} catch (error) {
// The 5 s timeout is a failure like any other; a move's abort (or
// destroy's) is not.
@@ -1021,7 +1044,6 @@ export class IntelHUD {
placeLabels: labels.placeLabels,
streetLabels: labels.streetLabels,
nearbyPlaceLabels: labels.nearbyPlaceLabels,
...layerContext,
// The model sees only the layer names the user sees. The English note
// goes too: a small model copies its words. `overall` stays the id.
enabledLayerLabels:
@@ -1034,14 +1056,14 @@ export class IntelHUD {
},
};
// Not sent and not part of the signature: the line's own warning, taken
// before the layer states are left out, and the English names a reply in
// another language must not echo.
// before the layer states are left out, and the English names and ids of
// translated layers, which a reply in another language must not echo.
Object.defineProperties(context, {
warning: { value: provenanceTag(feedProvenance) },
translatedLayerNames: {
value: layerContext.enabledLayerLabels.filter(
(name) => localizeLayerName(name) !== name,
),
englishLayerWords: {
value: layerContext.enabledLayers
.filter(({ name }) => localizeLayerName(name) !== name)
.flatMap(({ name, id }) => [name, id].filter(Boolean)),
},
});
return context;
+77 -38
View File
@@ -1,31 +1,8 @@
// The HUD summary line in another language: what the model is sent and what
// the readout shows. hud.js imports `mgrs`, which Node's ESM loader cannot
// read named exports from, so a module hook swaps it for a stub before the
// import (the same setup as hudSummaryGate.test.mjs).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { registerHooks } from 'node:module';
import './testSupport/stubMgrs.mjs';
import { hudReadouts } from './testSupport/hudReadouts.mjs';
const MGRS_STUB_URL = 'gev-test-stub:mgrs';
registerHooks({
resolve(specifier, context, next) {
if (specifier === 'mgrs') return { url: MGRS_STUB_URL, shortCircuit: true };
return next(specifier, context);
},
load(url, context, next) {
if (url === MGRS_STUB_URL) {
return {
format: 'module',
shortCircuit: true,
source:
'export function forward() { return "10SEG55776339"; }\nexport default { forward };\n',
};
}
return next(url, context);
},
});
const { IntelHUD } = await import('./hud.js');
const { setLocale } = await import('./i18n/index.js');
const { hudSummaryHasEnglish, hudSummaryInput } =
@@ -38,11 +15,11 @@ const PARTIAL_FLIGHTS = {
stats: { count: 4, partial: true, source: 'opensky' },
};
/**
* A HUD over one partial Live Flights layer and `places`, answering with
* `reply`; ru unless `locale` says otherwise.
*/
function createHud(t, reply, { places = ['Остин'], locale = 'ru' } = {}) {
function createHud(
t,
reply,
{ places = ['Остин'], locale = 'ru', layers = () => [PARTIAL_FLIGHTS] } = {},
) {
const { elements, publish } = hudReadouts();
const previous = { document: globalThis.document, window: globalThis.window };
globalThis.document = {
@@ -94,7 +71,7 @@ function createHud(t, reply, { places = ['Остин'], locale = 'ru' } = {}) {
const buildContext = hud._summaryContext.bind(hud);
hud._summaryContext = async () =>
Object.assign(await buildContext(), { placeLabels: places });
hud._dataManager = { getAll: () => [PARTIAL_FLIGHTS] };
hud._dataManager = { getAll: layers };
hud._latestMetrics = { altM: 600 };
setLocale(locale);
return {
@@ -113,7 +90,7 @@ test('a ru HUD request carries no English layer name', async (t) => {
assert.doesNotMatch(input, /Live Flights/, input);
// The locale signs the dedupe signature; the English names stay local.
assert.match(JSON.stringify(context), /"locale":"ru"/);
assert.doesNotMatch(JSON.stringify(context), /translatedLayerNames/);
assert.doesNotMatch(JSON.stringify(context), /englishLayerWords|warning/);
});
test('a ru summary shows the state word first and the layer names last', async (t) => {
@@ -128,6 +105,8 @@ test('a ru summary shows the state word first and the layer names last', async (
for (const words of [
'Live Flights частично онлайн',
'Рейсы онлайн stale данные',
'Остин flights частично онлайн',
'Austin flights partially online',
]) {
test(`a ru reply echoing English falls back: ${words}`, async (t) => {
const { hud, shown } = createHud(t, {
@@ -140,13 +119,6 @@ for (const words of [
});
}
test('a camera move during typing shows the plain line, not a fragment', (t) => {
const { hud, shown } = createHud(t, {});
hud._setSummaryText('ЧАСТИЧНО | Аэропорт задерживает рейсы', true);
hud._onCameraMoveStart();
assert.equal(shown(), hud._composeSummary());
});
test('a view with layers but no place label does not ask the model', async (t) => {
const { hud, sent, shown } = createHud(
t,
@@ -243,3 +215,70 @@ test('a language switch drops the reply in flight and asks again', async (t) =>
assert.equal(sent.at(-1).locale, 'es');
assert.ok(shown().startsWith('PARCIAL | Austin vuelos parciales'), shown());
});
test('a reused ru reply carries the current warning, not the cached one', async (t) => {
// Both layers load an empty view: no count, no lastUpdate yet.
let flights = { loading: true, count: 0 };
const { hud, shown } = createHud(
t,
{ summary: 'Остин небо над городом', feedState: 'loading' },
{
layers: () => [
{ id: 'flights', name: 'Live Flights', enabled: true, stats: flights },
{
id: 'ais',
name: 'Live AIS Vessels',
enabled: true,
stats: { loading: true, count: 0 },
},
],
},
);
await hud._updateSummary(false, true);
assert.match(shown(), /РЕЙСЫ ОНЛАЙН/, shown());
// Live Flights finishes with nothing to show; AIS is still loading.
flights = { count: 0 };
hud._markSummaryDirty();
await hud._updateSummary(false);
assert.match(
shown(),
/^ЗАГРУЗКА \| Остин небо над городом \| СУДА AIS/,
shown(),
);
assert.doesNotMatch(shown(), /РЕЙСЫ ОНЛАЙН/, shown());
});
test('with every feed healthy a reply calling one unavailable falls back', async (t) => {
const { hud, shown } = createHud(
t,
{ summary: 'Остин рейсы недоступно сейчас', feedState: null },
{ layers: () => [] },
);
await hud._updateSummary(false, true);
assert.equal(shown(), hud._composeSummary());
});
test('an ar reply opening with a Latin name keeps reading right to left', async (t) => {
const { hud, shown } = createHud(
t,
{ summary: 'Times Square نيويورك الولايات المتحدة', feedState: null },
{ locale: 'ar', places: ['Times Square'], layers: () => [] },
);
await hud._updateSummary(false, true);
assert.equal(shown(), '\u200fTimes Square نيويورك الولايات المتحدة');
});
test('a one-word layer name that is a French word is not an echo', async (t) => {
const summary = 'Bus en transit à Paris';
const { hud, shown } = createHud(
t,
{ summary, feedState: null },
{
locale: 'fr',
places: ['Paris'],
layers: () => [{ id: 'transit', name: 'Transit', enabled: true }],
},
);
await hud._updateSummary(false, true);
assert.equal(shown(), summary);
});
+86 -33
View File
@@ -60,20 +60,23 @@ export const HUD_SUMMARY_INSTRUCTIONS = [
'Use only the supplied place, street, nearby-place, enabled-layer labels, and feedProvenance.',
'Prefer the clearest named place and include a relevant enabled layer only when useful.',
'In the summary, name layers only as in enabledLayerLabels; never copy layer names or state words from feedProvenance into it.',
'Set feedState to feedProvenance.overall exactly as given, or none when it is null.',
'Set feedState to feedProvenance.overall exactly as given, or null when it is null.',
'When feedProvenance.overall is null, say nothing about feeds or layers, present or missing.',
'If feedProvenance.overall is partial, do not present the coverage as complete; if it is any other state but nominal, do not present the view as live or healthy.',
'Do not infer from coordinates or invent a place or feed state.',
'The summary is exactly five words with no title, punctuation, markdown, or introductory phrase.',
// "Exactly five words" made a small model pad a short view with words like
// "no data" or "unavailable".
'The summary has at most five words, with no title, punctuation, markdown, or introductory phrase.',
].join(' ');
/** Longest summary, in characters, for a script written without spaces. */
export const HUD_SUMMARY_MAX_UNSPACED_CHARS = 20;
/** feedState values the model may return; none stands for a null overall. */
export const HUD_SUMMARY_FEED_STATES = Object.freeze([
...SNAPSHOT_FEED_STATES,
'none',
]);
/** Longest five-word summary, in characters. */
export const HUD_SUMMARY_MAX_CHARS = 80;
/** feedState values the model may return besides null. */
export const HUD_SUMMARY_FEED_STATES = SNAPSHOT_FEED_STATES;
/** Responses API structured output: the five words plus the canonical state. */
export const HUD_SUMMARY_RESPONSE_FORMAT = Object.freeze({
@@ -84,7 +87,14 @@ export const HUD_SUMMARY_RESPONSE_FORMAT = Object.freeze({
type: 'object',
properties: {
summary: { type: 'string' },
feedState: { type: 'string', enum: [...HUD_SUMMARY_FEED_STATES] },
// Nullable rather than a `none` token, which the model wrote into the
// summary of a view without layers.
feedState: {
anyOf: [
{ type: 'string', enum: [...HUD_SUMMARY_FEED_STATES] },
{ type: 'null' },
],
},
},
required: ['summary', 'feedState'],
additionalProperties: false,
@@ -107,38 +117,82 @@ const SUMMARY_LANGUAGES = Object.freeze({
/** Scripts written without spaces, where five words do not apply. */
const UNSPACED_LOCALES = /^(?:ja|zh)(?:-|$)/;
/** Shipped locales not written in Latin letters. */
const NON_LATIN_LOCALES = /^(?:ar|ja|ru|uk|zh)(?:-|$)/;
/** Scripts whose letters extend a word; any other neighbour ends it. */
const WORD_SCRIPTS = [
'Latin',
'Cyrillic',
'Greek',
'Arabic',
'Hebrew',
'Han',
'Hiragana',
'Katakana',
'Hangul',
].map((script) => new RegExp(`\\p{Script=${script}}`, 'u'));
/** Lower case, with punctuation and brackets as spaces: `Earthquakes 24h`. */
function flatWords(value) {
return String(value || '')
.normalize('NFC')
.toLowerCase()
.replace(/[^\p{L}\p{M}\p{N}]+/gu, ' ')
.trim();
}
function sameScript(a, b) {
return WORD_SCRIPTS.some((script) => script.test(a ?? '') && script.test(b));
}
/**
* Whether a non-English summary echoes English the HUD does not show: a
* feed-state id or a translated layer's English name. Only Latin letters
* extend a word, so `stale` flush against kana or Han still counts.
* Whether `word` stands on its own in `text`. Only a letter of the same
* script extends it, so `stale` flush against kana or Han still counts.
* @param {string} text
* @param {string} word
* @returns {boolean}
*/
export function hasWord(text, word) {
const haystack = flatWords(text);
const needle = flatWords(word);
if (!needle) return false;
for (
let at = haystack.indexOf(needle);
at >= 0;
at = haystack.indexOf(needle, at + 1)
) {
const before = haystack[at - 1];
const after = haystack[at + needle.length];
if (!sameScript(before, needle[0]) && !sameScript(after, needle.at(-1)))
return true;
}
return false;
}
/**
* Whether a non-English summary carries what the HUD does not show: one of
* `words` (feed-state ids, translated layers' English names and ids), or only
* Latin letters in a language written in another script.
* @param {string} summary The model's words.
* @param {unknown} locale The request locale.
* @param {string[]} [words] The English words the summary must not carry.
* @param {string[]} [words] Words the summary must not carry.
* @returns {boolean}
*/
export function hudSummaryHasEnglish(summary, locale, words = []) {
if (hudSummaryLocale(locale) === DEFAULT_LOCALE) return false;
const flat = (value) =>
String(value || '')
.toLowerCase()
.replace(/-/g, ' ');
const text = flat(summary);
const latin = (char) => /\p{Script=Latin}/u.test(char ?? '');
return words.some((word) => {
const needle = flat(word);
if (!needle) return false;
for (
let at = text.indexOf(needle);
at >= 0;
at = text.indexOf(needle, at + 1)
) {
if (!latin(text[at - 1]) && !latin(text[at + needle.length])) return true;
}
return false;
});
const tag = hudSummaryLocale(locale);
if (tag === DEFAULT_LOCALE) return false;
const letters = String(summary || '').replace(/[^\p{L}]/gu, '');
if (!hudSummaryIsLatin(tag) && !letters.replace(/\p{Script=Latin}/gu, ''))
return true;
return words.some((word) => hasWord(summary, word));
}
/** Whether the locale is written in Latin letters. */
export function hudSummaryIsLatin(locale) {
return !NON_LATIN_LOCALES.test(hudSummaryLocale(locale));
}
/** Whether the summary for this locale is capped in characters, not words. */
export function hudSummaryIsUnspaced(locale) {
return UNSPACED_LOCALES.test(hudSummaryLocale(locale));
}
@@ -154,7 +208,6 @@ function summaryLanguage(tag) {
}
/**
* Build the summary instructions for the requested locale.
* @param {unknown} locale The `locale` the HUD sent.
* @returns {string}
*/
+33 -17
View File
@@ -1,6 +1,5 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { Readable } from 'node:stream';
import {
HUD_SUMMARY_FEED_STATES,
@@ -233,9 +232,10 @@ test('HUD summary instructions ask for the canonical feedState as its own field'
'feedState',
]);
assert.deepEqual(
HUD_SUMMARY_RESPONSE_FORMAT.schema.properties.feedState.enum,
[...HUD_SUMMARY_FEED_STATES],
HUD_SUMMARY_RESPONSE_FORMAT.schema.properties.feedState.anyOf,
[{ type: 'string', enum: [...HUD_SUMMARY_FEED_STATES] }, { type: 'null' }],
);
assert.ok(!HUD_SUMMARY_FEED_STATES.includes('none'));
assert.ok(HUD_SUMMARY_FEED_STATES.includes('partial'));
});
@@ -266,7 +266,7 @@ test('shipped catalogs get their exact language, and CJK a character limit', ()
hudSummaryInstructions('ja'),
new RegExp(`at most ${HUD_SUMMARY_MAX_UNSPACED_CHARS} characters`),
);
assert.doesNotMatch(hudSummaryInstructions('es'), /at most/);
assert.doesNotMatch(hudSummaryInstructions('es'), /characters/);
assert.match(hudSummaryInstructions('es'), /names in enabledLayerLabels/);
});
@@ -350,10 +350,35 @@ test('a Japanese summary is capped in characters, not words', async () => {
{ locale: 'ja', feedProvenance: { overall: 'stale' } },
JSON.stringify({ summary: long, feedState: 'stale' }),
);
assert.equal(
response.body.summary,
[...long].slice(0, HUD_SUMMARY_MAX_UNSPACED_CHARS).join(''),
const shown = response.body.summary;
assert.ok(long.startsWith(shown), shown);
assert.ok([...shown].length <= HUD_SUMMARY_MAX_UNSPACED_CHARS, shown);
assert.ok([...shown].length >= 10, shown);
});
for (const [locale, summary] of [
['ar', 'دبي حركة الطيران محدودة حاليًا'],
['ar', 'القاهرة تتبّع الرحلات الآن'],
['fr', "Vols au-dessus d'Orléans aujourd'hui partiels"],
['it', "Voli sull'aeroporto di Roma parziali"],
['fr', 'Données de vol à Paris'.normalize('NFD')],
['ja', 'サンフランシスコ・ベイエリア'],
]) {
test(`a ${locale} summary keeps marks, apostrophes and dots: ${summary}`, async () => {
const { response } = await postHudSummary(
{ locale, feedProvenance: { overall: null } },
JSON.stringify({ summary, feedState: null }),
);
assert.equal(response.body.summary, summary.normalize('NFC'));
});
}
test('a five-word summary is capped in characters too', async () => {
const { response } = await postHudSummary(
{ locale: 'de', feedProvenance: { overall: null } },
JSON.stringify({ summary: `Berlin ${'x'.repeat(200)}`, feedState: null }),
);
assert.equal(response.body.summary, 'Berlin');
});
for (const [locale, summary] of [
@@ -460,7 +485,7 @@ test("a localized summary is shown only with the HUD's own canonical state", ()
assert.equal(hudSummaryMatchesProvenance({ summary: 'x' }, null), true);
});
test('a partial view keeps its warning tag, translated in es and ru', async () => {
test('a partial view keeps its warning tag', () => {
const now = 1_000_000;
const layers = [
{
@@ -486,13 +511,4 @@ test('a partial view keeps its warning tag, translated in es and ru', async () =
state: 'partial',
names: ['Live Flights'],
});
const { setLocale, t } = await import('./i18n/index.js');
try {
for (const locale of ['es', 'ru']) {
setLocale(locale);
assert.notEqual(t('PARTIAL'), 'PARTIAL', locale);
}
} finally {
setLocale('en');
}
});
+22
View File
@@ -0,0 +1,22 @@
// hud.js imports `mgrs`, whose named exports Node's ESM loader cannot read:
// import this module before hud.js to swap it for a stub.
import { registerHooks } from 'node:module';
const MGRS_STUB_URL = 'gev-test-stub:mgrs';
registerHooks({
resolve(specifier, context, next) {
if (specifier === 'mgrs') return { url: MGRS_STUB_URL, shortCircuit: true };
return next(specifier, context);
},
load(url, context, next) {
if (url === MGRS_STUB_URL) {
return {
format: 'module',
shortCircuit: true,
source:
'export function forward() { return "10SEG55776339"; }\nexport default { forward };\n',
};
}
return next(url, context);
},
});
File diff suppressed because it is too large. Load diff
+4 -1
View File
@@ -422,7 +422,10 @@ function analystCard(result) {
...(result.feedState && result.feedState !== 'nominal'
? [{ label: shownFeedState(result.feedState) }]
: []),
...(result.partial ? [{ label: t('partial') }] : []),
// A partial feed state already says it.
...(result.partial && result.feedState !== 'partial'
? [{ label: t('partial') }]
: []),
],
// Coverage qualifications lead the feed detail. The bounded card groups
// overflow in its existing Notes disclosure rather than dropping them.