From 563df3bad9bb3cb74d282db9c0e8fb3061d3f1f4 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 30 Oct 2026 12:37:43 +0400 Subject: [PATCH] fix(hud): AI summary in the UI language, canonical feed state, partial aggregation The HUD sends its locale with the summary context; the proxy names that language in the instructions and the input, English when missing or unsupported. The model returns {summary, feedState}; the HUD shows the summary only when feedState matches its own state for a view that is not nominal, and appends its own translated state tag. partial joins the shared feed-state table between fallback and nominal, so one partial layer no longer reads off and partial plus nominal no longer reads nominal. --- scripts/package-boundaries.json | 1 + server/providers/openai/hud-summary.js | 34 +++- src/data/layerSnapshot.js | 9 +- src/data/layerSnapshot.test.mjs | 34 +++- src/hud.js | 13 +- src/hudSummaryResponse.js | 125 +++++++++++--- src/hudSummaryResponse.test.mjs | 226 ++++++++++++++++++++++--- 7 files changed, 386 insertions(+), 56 deletions(-) diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 7a5424365..749fb1e63 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -336,6 +336,7 @@ "exports": ["./server/providers/openai"], "modules": [ "src/i18n/marker.js", + "src/i18n/locale.js", "src/hudLayoutPolicy.js", "server/providers/common/rate-limit.js", "server/providers/common/request.js", diff --git a/server/providers/openai/hud-summary.js b/server/providers/openai/hud-summary.js index 9033c7cb3..d7b4b19cd 100644 --- a/server/providers/openai/hud-summary.js +++ b/server/providers/openai/hud-summary.js @@ -1,5 +1,8 @@ import { - HUD_SUMMARY_INSTRUCTIONS, + HUD_SUMMARY_FEED_STATES, + HUD_SUMMARY_RESPONSE_FORMAT, + hudSummaryInput, + hudSummaryInstructions, keylessHudSummaryResponse, } from '../../../src/hudSummaryResponse.js'; import { enforceRateLimit, openAiRateLimiter } from './rate-limit.js'; @@ -18,6 +21,23 @@ function extractOpenAiResponseText(data) { .trim(); } +/** The structured reply, or nulls when the model did not follow the format. */ +function parseHudSummaryReply(text) { + let reply = null; + try { + reply = JSON.parse(text); + } catch { + /* not JSON: no summary */ + } + const feedState = HUD_SUMMARY_FEED_STATES.includes(reply?.feedState) + ? reply.feedState + : null; + return { + summary: toFiveWordHudSummary(reply?.summary), + feedState: feedState === 'none' ? null : feedState, + }; +} + function toFiveWordHudSummary(value) { return String(value || '') .replace(/[^\p{L}\p{N}\s-]/gu, ' ') @@ -77,14 +97,17 @@ async function handleHudSummary(req, res) { model: process.env.OPENAI_HUD_SUMMARY_MODEL || OPENAI_HUD_SUMMARY_MODEL_DEFAULT, - instructions: HUD_SUMMARY_INSTRUCTIONS, - input: JSON.stringify(context), + instructions: hudSummaryInstructions(context?.locale), + input: hudSummaryInput(context), + text: { format: HUD_SUMMARY_RESPONSE_FORMAT }, reasoning: { effort: 'minimal' }, - max_output_tokens: 100, + max_output_tokens: 200, }), }); const data = await response.json().catch(() => ({})); - const summary = toFiveWordHudSummary(extractOpenAiResponseText(data)); + const { summary, feedState } = parseHudSummaryReply( + extractOpenAiResponseText(data), + ); res.statusCode = response.ok && summary ? 200 : response.status || 502; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.setHeader('Cache-Control', 'no-store'); @@ -93,6 +116,7 @@ async function handleHudSummary(req, res) { res.end( JSON.stringify({ summary: summary || null, + feedState, // Never relay `data.error.message`: that is OpenAI's own wording, and // it carries request ids, organization hints and quota phrasing. error: response.ok ? null : 'OpenAI HUD summary request failed', diff --git a/src/data/layerSnapshot.js b/src/data/layerSnapshot.js index 13fd3afe2..7a2041208 100644 --- a/src/data/layerSnapshot.js +++ b/src/data/layerSnapshot.js @@ -17,6 +17,7 @@ export const LAYER_FEED_STATES = Object.freeze([ 'loading', 'degraded', 'stale', + 'partial', 'fallback', 'unavailable', ]); @@ -27,6 +28,7 @@ export const LAYER_FEED_STATE_LABELS = Object.freeze({ loading: 'LOADING', degraded: 'DEGRADED', stale: 'STALE', + partial: 'PARTIAL', fallback: 'FALLBACK', unavailable: 'UNAVAILABLE', }); @@ -48,8 +50,9 @@ export const FEED_STATE_SEVERITY = Object.freeze({ degraded: 2, stale: 3, fallback: 4, - nominal: 5, - off: 6, + partial: 5, + nominal: 6, + off: 7, }); export { layerFeedState } from './feedState.js'; @@ -90,7 +93,7 @@ export function snapshotAgeLabel(lastUpdate, now = Date.now()) { /** * Pick the most severe feed-state from a list. * @param {Iterable} states Feed-state strings. - * @returns {'nominal'|'loading'|'degraded'|'stale'|'fallback'|'unavailable'|'off'|null} + * @returns {'nominal'|'loading'|'degraded'|'stale'|'partial'|'fallback'|'unavailable'|'off'|null} */ export function worstFeedState(states) { let worst = null; diff --git a/src/data/layerSnapshot.test.mjs b/src/data/layerSnapshot.test.mjs index 9e3b08c80..a354a7a07 100644 --- a/src/data/layerSnapshot.test.mjs +++ b/src/data/layerSnapshot.test.mjs @@ -90,7 +90,15 @@ test('layerFeedState matches the chip classifier for every honesty class', () => test('chip labels and severity cover every feed-state the envelope can emit', () => { assert.deepEqual( [...LAYER_FEED_STATES], - ['nominal', 'loading', 'degraded', 'stale', 'fallback', 'unavailable'], + [ + 'nominal', + 'loading', + 'degraded', + 'stale', + 'partial', + 'fallback', + 'unavailable', + ], ); assert.deepEqual([...SNAPSHOT_FEED_STATES], [...LAYER_FEED_STATES, 'off']); for (const state of LAYER_FEED_STATES) { @@ -107,6 +115,30 @@ test('chip labels and severity cover every feed-state the envelope can emit', () assert.equal(worstFeedState([]), null); }); +test('partial is a feed-state of its own: above nominal, below every fault', () => { + const layer = (id, stats) => ({ id, name: id, enabled: true, stats }); + const partial = layer('flights', { + partial: true, + count: 4, + lastUpdate: NOW, + }); + const nominal = layer('vessels', { count: 9, lastUpdate: NOW }); + const stale = layer('cctv', { stale: true, count: 2, lastUpdate: NOW }); + const overall = (layers) => + feedProvenanceEnvelope(layers, { now: NOW }).overall; + assert.equal(overall([partial]), 'partial'); + assert.equal(overall([partial, nominal]), 'partial'); + assert.equal(overall([nominal, partial]), 'partial'); + assert.equal(overall([partial, stale]), 'stale'); + assert.equal(overall([stale, partial]), 'stale'); + assert.equal(LAYER_FEED_STATE_LABELS.partial, 'PARTIAL'); + assert.equal( + FEED_STATE_SEVERITY.fallback < FEED_STATE_SEVERITY.partial && + FEED_STATE_SEVERITY.partial < FEED_STATE_SEVERITY.nominal, + true, + ); +}); + test('a disabled layer is off even when leftover stats would read stale', () => { const snap = layerSnapshot( { diff --git a/src/hud.js b/src/hud.js index 6eff65df7..ed1b5f07e 100644 --- a/src/hud.js +++ b/src/hud.js @@ -31,13 +31,14 @@ import { hudSummaryLayerContext, hudTelemetryProvenance, isHudSummaryUnconfigured, + provenanceTag, } from './hudSummaryResponse.js'; import { applyHudUiTheme, DEFAULT_HUD_LAYOUT, normalizeHudLayout, } from './hudLayouts.js'; -import { formatNumber, msg, t } from './i18n/index.js'; +import { formatNumber, getLocale, msg, t } from './i18n/index.js'; import { localizeLayerName } from './ui/layerPanel.js'; import { isEmbeddedInline } from './app/embed.js'; @@ -892,8 +893,13 @@ export class IntelHUD { if (!response.ok || !data?.summary) { throw new Error(data?.error || `HTTP ${response.status}`); } - if (hudSummaryMatchesProvenance(data.summary, context.feedProvenance)) - this._lastSummaryText = data.summary; + if (hudSummaryMatchesProvenance(data, context.feedProvenance)) { + // The warning is the HUD's own translated tag, not the model's words. + const tag = localizedProvenanceTag( + provenanceTag(context.feedProvenance), + ); + this._lastSummaryText = tag ? `${data.summary} | ${tag}` : data.summary; + } this._setSummaryText(this._lastSummaryText || fallbackText, animate); } catch (error) { // The 5 s timeout is a failure like any other; a move's abort (or @@ -959,6 +965,7 @@ export class IntelHUD { ?.filter((layer) => layer.enabled) .map((layer) => layer.name) || []; return { + locale: getLocale(), placeLabels: labels.placeLabels, streetLabels: labels.streetLabels, nearbyPlaceLabels: labels.nearbyPlaceLabels, diff --git a/src/hudSummaryResponse.js b/src/hudSummaryResponse.js index 82a5c2a23..50be07036 100644 --- a/src/hudSummaryResponse.js +++ b/src/hudSummaryResponse.js @@ -1,7 +1,9 @@ import { + SNAPSHOT_FEED_STATES, feedProvenanceEnvelope, layerSnapshots, } from './data/layerSnapshot.js'; +import { DEFAULT_LOCALE, normalizeLocale } from './i18n/locale.js'; export const HUD_SUMMARY_UNCONFIGURED_CODE = 'OPENAI_NOT_CONFIGURED'; @@ -43,19 +45,83 @@ export function isHudSummaryUnconfigured(status, data) { } /** - * HUD summary model instructions. The five-word line may only describe - * supplied labels, and must carry feed-state when any enabled layer is not - * nominal. + * HUD summary model instructions. The five words may only describe supplied + * labels; the feed state comes back as its own canonical field, so the check + * does not depend on the summary's language or wording. */ export const HUD_SUMMARY_INSTRUCTIONS = [ "Write one concise intelligence-HUD summary for God's Eye View.", '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.', - 'If any enabled layer is not nominal, the five words MUST include that feedState token (STALE, DEGRADED, FALLBACK, LOADING, or UNAVAILABLE) and must not present the view as live.', + 'Set feedState to feedProvenance.overall exactly as given, or none when it is null.', + 'If feedProvenance.overall is not nominal, the summary must not present the view as live or healthy.', 'Do not infer from coordinates or invent a place or feed state.', - 'Output exactly five words with no title, punctuation, markdown, or introductory phrase.', + 'The summary is exactly five words with no title, punctuation, markdown, or introductory phrase.', ].join(' '); +/** feedState values the model may return; none stands for a null overall. */ +export const HUD_SUMMARY_FEED_STATES = Object.freeze([ + ...SNAPSHOT_FEED_STATES, + 'none', +]); + +/** Responses API structured output: the five words plus the canonical state. */ +export const HUD_SUMMARY_RESPONSE_FORMAT = Object.freeze({ + type: 'json_schema', + name: 'hud_summary', + strict: true, + schema: { + type: 'object', + properties: { + summary: { type: 'string' }, + feedState: { type: 'string', enum: [...HUD_SUMMARY_FEED_STATES] }, + }, + required: ['summary', 'feedState'], + additionalProperties: false, + }, +}); + +/** A shipped locale for the request, English when missing or unsupported. */ +export function hudSummaryLocale(value) { + return normalizeLocale(value) || DEFAULT_LOCALE; +} + +/** The language a request's summary is written in, named in English. */ +function summaryLanguage(tag) { + try { + return new Intl.DisplayNames(['en'], { type: 'language' }).of(tag) || tag; + } catch { + return tag; + } +} + +/** + * The instructions for one request: English as before, otherwise one more + * sentence naming the language the five words are written in. + * @param {unknown} locale The `locale` the HUD sent. + * @returns {string} + */ +export function hudSummaryInstructions(locale) { + const tag = hudSummaryLocale(locale); + if (tag === DEFAULT_LOCALE) return HUD_SUMMARY_INSTRUCTIONS; + const language = summaryLanguage(tag); + return `${HUD_SUMMARY_INSTRUCTIONS} Write the summary in ${language} (${tag}), not English, giving places and layers their usual ${language} names; feedState stays the English value.`; +} + +/** + * The model input: the HUD context, led by the summary language when it is + * not English. A small model follows the language far more reliably when it + * is part of the input as well as the instructions. + * @param {object} context The HUD summary context. + * @returns {string} + */ +export function hudSummaryInput(context) { + const tag = hudSummaryLocale(context?.locale); + const json = JSON.stringify(context ?? {}); + if (tag === DEFAULT_LOCALE) return json; + return `Summary language: ${summaryLanguage(tag)} (${tag}).\n${json}`; +} + /** * Stamp HUD summary context with the same layer snapshots voice tools use. * @param {Array} [layers] `DataLayerManager.getAll()` rows. @@ -83,32 +149,45 @@ export function hudSummaryLayerContext(layers = [], options) { } /** - * The feed state and up to two layer names for the HUD telemetry suffix when - * an enabled feed is not nominal. Values stay English (`state` is a feed-state - * id, `names` are `layer.name`); the HUD translates them for display. + * 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. + * @param {{ overall?: string|null, layers?: Array }|null} envelope + * @returns {{ state: string, names: string[] }|null} + */ +export function provenanceTag(envelope) { + const overall = envelope?.overall; + if (!overall || overall === 'nominal' || overall === 'off') return null; + const names = (envelope.layers || []) + .filter( + (s) => s.feedState && s.feedState !== 'nominal' && s.feedState !== 'off', + ) + .map((s) => String(s.name || s.id || 'LAYER')) + .slice(0, 2); + return { state: overall, names }; +} + +/** + * The feed-state tag for the HUD telemetry suffix when an enabled feed is not + * nominal. * @param {Array} [layers] * @param {{ now?: number }} [options] * @returns {{ state: string, names: string[] }|null} */ export function hudTelemetryProvenance(layers = [], options) { const snapshots = layerSnapshots(layers, options).filter((s) => s.enabled); - const envelope = feedProvenanceEnvelope(snapshots, options); - if (!envelope.overall || envelope.overall === 'nominal') return null; - const names = envelope.layers - .filter( - (s) => s.feedState && s.feedState !== 'nominal' && s.feedState !== 'off', - ) - .map((s) => String(s.name || s.id || 'LAYER')) - .slice(0, 2); - return { state: envelope.overall, names }; + return provenanceTag(feedProvenanceEnvelope(snapshots, options)); } -/** Require the supplied non-nominal state before showing an AI summary. */ -export function hudSummaryMatchesProvenance(summary, provenance) { +/** + * Show an AI summary only when its canonical feedState is the HUD's own: a + * view that is not nominal falls back on a missing or different state. + * @param {{ feedState?: string|null }|null} response The proxy's reply. + * @param {{ overall?: string|null }|null} provenance The context's envelope. + * @returns {boolean} + */ +export function hudSummaryMatchesProvenance(response, provenance) { const state = provenance?.overall; if (!state || state === 'nominal' || state === 'off') return true; - return String(summary || '') - .toUpperCase() - .split(/\W+/) - .includes(String(state).toUpperCase()); + return response?.feedState === state; } diff --git a/src/hudSummaryResponse.test.mjs b/src/hudSummaryResponse.test.mjs index d7da91129..ca31f988d 100644 --- a/src/hudSummaryResponse.test.mjs +++ b/src/hudSummaryResponse.test.mjs @@ -1,13 +1,21 @@ 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, HUD_SUMMARY_INSTRUCTIONS, + HUD_SUMMARY_RESPONSE_FORMAT, HUD_SUMMARY_UNCONFIGURED_CODE, + hudSummaryInput, + hudSummaryInstructions, hudSummaryLayerContext, + hudSummaryLocale, + hudSummaryMatchesProvenance, hudTelemetryProvenance, isHudSummaryUnconfigured, keylessHudSummaryResponse, + provenanceTag, } from './hudSummaryResponse.js'; import { openAiRealtimeProxy } from '../vite.config.js'; @@ -206,46 +214,222 @@ test('HUD summary context and telemetry tag carry chip-identical feed-state', () ); }); -test('HUD summary instructions require non-nominal feedState in the five words', () => { +test('HUD summary instructions ask for the canonical feedState as its own field', () => { assert.match( HUD_SUMMARY_INSTRUCTIONS, - /five words MUST include that feedState token/, + /Set feedState to feedProvenance\.overall exactly/, ); - assert.match( - HUD_SUMMARY_INSTRUCTIONS, - /STALE, DEGRADED, FALLBACK, LOADING, or UNAVAILABLE/, + assert.match(HUD_SUMMARY_INSTRUCTIONS, /must not present the view as live/); + assert.deepEqual(HUD_SUMMARY_RESPONSE_FORMAT.schema.required, [ + 'summary', + 'feedState', + ]); + assert.deepEqual( + HUD_SUMMARY_RESPONSE_FORMAT.schema.properties.feedState.enum, + [...HUD_SUMMARY_FEED_STATES], ); - assert.match(HUD_SUMMARY_INSTRUCTIONS, /feedProvenance/); + assert.ok(HUD_SUMMARY_FEED_STATES.includes('partial')); }); -test('the HUD proxy uses the shared provenance instructions', () => { +test('the instructions name the UI language, with English as the fallback', () => { + assert.match(hudSummaryInstructions('es'), /in Spanish \(es\)/); + assert.match(hudSummaryInstructions('ru-RU'), /in Russian \(ru\)/); + assert.equal(hudSummaryInstructions('en'), HUD_SUMMARY_INSTRUCTIONS); + assert.equal(hudSummaryInstructions('xx-YY'), HUD_SUMMARY_INSTRUCTIONS); + assert.equal(hudSummaryInstructions(undefined), HUD_SUMMARY_INSTRUCTIONS); + assert.equal(hudSummaryLocale('es-MX'), 'es'); + const english = { locale: 'en', placeLabels: ['Austin'] }; + assert.equal(hudSummaryInput(english), JSON.stringify(english)); + assert.match( + hudSummaryInput({ locale: 'ru' }), + /^Summary language: Russian \(ru\)\.\n\{/, + ); + assert.equal(hudSummaryLocale(42), 'en'); +}); + +test('the HUD sends its UI locale inside the context it signs for dedupe', () => { + const hud = readFileSync(new URL('./hud.js', import.meta.url), 'utf8'); + assert.match(hud, /locale: getLocale\(\),\s+placeLabels:/); + assert.match(hud, /const signature = JSON\.stringify\(context\);/); +}); + +let hudSummaryClient = 0; + +async function postHudSummary(context, reply) { + // A fresh address per request: the per-IP limiter outlives a single test. + hudSummaryClient += 1; + const previousKey = process.env.OPENAI_API_KEY; + const previousLimit = process.env.GEV_RATELIMIT_OPENAI_PER_MIN; + const previousFetch = globalThis.fetch; + process.env.OPENAI_API_KEY = 'test-key'; + process.env.GEV_RATELIMIT_OPENAI_PER_MIN = '0'; + let sent = null; + globalThis.fetch = async (url, init) => { + sent = JSON.parse(init.body); + return { + ok: true, + status: 200, + json: async () => ({ output_text: reply }), + }; + }; + try { + // The handler itself: the installed route adds a same-site gate that a + // bare test request does not pass. + const { handleHudSummary: hud } = + await import('../server/providers/openai/hud-summary.js'); + const req = Object.assign( + Readable.from([Buffer.from(JSON.stringify(context))]), + { + method: 'POST', + url: '/', + headers: {}, + socket: { remoteAddress: `10.0.0.${hudSummaryClient}` }, + }, + ); + const response = await new Promise((resolve, reject) => { + const res = { + statusCode: 200, + setHeader() {}, + end(body = '') { + resolve({ statusCode: this.statusCode, body: JSON.parse(body) }); + }, + }; + Promise.resolve(hud(req, res)).catch(reject); + }); + return { sent, response }; + } finally { + globalThis.fetch = previousFetch; + if (previousKey === undefined) delete process.env.OPENAI_API_KEY; + else process.env.OPENAI_API_KEY = previousKey; + if (previousLimit === undefined) + delete process.env.GEV_RATELIMIT_OPENAI_PER_MIN; + else process.env.GEV_RATELIMIT_OPENAI_PER_MIN = previousLimit; + } +} + +test('an es request reaches the model in Spanish and returns the canonical state', async () => { + const { sent, response } = await postHudSummary( + { + locale: 'es', + placeLabels: ['Austin'], + feedProvenance: { overall: 'stale' }, + }, + JSON.stringify({ + summary: 'Austin vuelos con datos antiguos', + feedState: 'stale', + }), + ); + assert.match(sent.instructions, /in Spanish \(es\)/); + assert.equal(sent.text.format.name, 'hud_summary'); + const [lead, json] = sent.input.split('\n'); + assert.equal(lead, 'Summary language: Spanish (es).'); + assert.equal(JSON.parse(json).locale, 'es'); + assert.equal(response.statusCode, 200); + assert.deepEqual(response.body, { + summary: 'Austin vuelos con datos antiguos', + feedState: 'stale', + error: null, + }); +}); + +test('a reply that skips the format carries no summary and no state', async () => { + const { response } = await postHudSummary( + { locale: 'ru', feedProvenance: { overall: 'stale' } }, + 'Остин рейсы данные устарели сейчас', + ); + assert.equal(response.body.summary, null); + assert.equal(response.body.feedState, null); + const unknown = await postHudSummary( + { locale: 'ru', feedProvenance: { overall: 'stale' } }, + JSON.stringify({ summary: 'Остин рейсы', feedState: 'fine' }), + ); + assert.equal(unknown.response.body.feedState, null); +}); + +test('the HUD proxy builds its instructions from the shared contract', () => { const local = readFileSync( new URL('../server/providers/openai/hud-summary.js', import.meta.url), 'utf8', ); - assert.match(local, /HUD_SUMMARY_INSTRUCTIONS/); + assert.match(local, /hudSummaryInstructions\(context\?\.locale\)/); + assert.match(local, /hudSummaryInput\(context\)/); + assert.match(local, /HUD_SUMMARY_RESPONSE_FORMAT/); assert.doesNotMatch(local, /enabled-layer text labels/); }); -test('AI summaries missing a non-nominal provenance token fall back deterministically', async () => { - const { hudSummaryMatchesProvenance } = - await import('./hudSummaryResponse.js'); +test("a localized summary is shown only with the HUD's own canonical state", () => { + const stale = { overall: 'stale' }; assert.equal( - hudSummaryMatchesProvenance('Austin flights operating normally today', { - overall: 'stale', - }), + hudSummaryMatchesProvenance( + { summary: 'Остин рейсы данные устарели сейчас', feedState: 'stale' }, + stale, + ), + true, + ); + assert.equal( + hudSummaryMatchesProvenance( + { summary: 'Austin vuelos en vivo ahora', feedState: 'nominal' }, + stale, + ), false, ); assert.equal( - hudSummaryMatchesProvenance('Austin stale flights over downtown', { - overall: 'stale', - }), - true, + hudSummaryMatchesProvenance( + { summary: 'Austin stale flights over downtown', feedState: null }, + stale, + ), + false, ); assert.equal( - hudSummaryMatchesProvenance('Austin flights operating normally today', { - overall: 'nominal', - }), + hudSummaryMatchesProvenance( + { summary: 'Austin flights' }, + { overall: 'partial' }, + ), + false, + ); + assert.equal( + hudSummaryMatchesProvenance( + { summary: 'Austin flights operating normally today' }, + { overall: 'nominal' }, + ), true, ); + assert.equal(hudSummaryMatchesProvenance({ summary: 'x' }, null), true); +}); + +test('a partial view keeps its warning tag, translated in es and ru', async () => { + const now = 1_000_000; + const layers = [ + { + id: 'flights', + name: 'Live Flights', + enabled: true, + stats: { partial: true, count: 4, lastUpdate: now }, + }, + { + id: 'vessels', + name: 'Live AIS Vessels', + enabled: true, + stats: { count: 9, lastUpdate: now }, + }, + ]; + const context = hudSummaryLayerContext(layers, { now }); + assert.equal(context.feedProvenance.overall, 'partial'); + assert.deepEqual(provenanceTag(context.feedProvenance), { + state: 'partial', + names: ['Live Flights'], + }); + assert.deepEqual(hudTelemetryProvenance(layers, { now }), { + 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'); + } });