i18n: fix review findings and polish ru/es strings

Fixes the 51 P0–P2 findings from the full review of the i18n branch and
the issues found while checking every new string on screen in ru and es.

- Translate errors and statuses that still reached ru/es in English:
  feed and tile errors, provider statuses, the ALPR chip, the traffic
  mode chip, voice plan step labels, fire feed values, receiver bands,
  scene save reasons and the Cesium attribution link.
- Show preset city names in the UI language (src/ui/placeNames.js);
  data and everything sent to the model stay English.
- Share layer names between the layer panel, HUD and voice card
  (src/ui/layerNames.js).
- Keep long ru/es strings whole: voice card error lines, analyst
  titles, the voice tier status, the Nepal evidence button, the ALPR
  retry chip. English layout is unchanged.
- Ask before a language switch drops unsaved work.
- Catalog fixes: glossary terms (пуск, пресет), gender agreement and
  consistent "peticiones" in es, fewer calques in both languages.
- Fix English text that was itself wrong (e.g. "Exit cockpit to fly to
  a weather", raw tool ids in voice plan steps).

Catalogs: es/ru 2535 messages each, complete.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-15 23:49:52 +04:00
1 parent 8029747b7e
commit 00704ddc89
212 files changed
+9871 -909

No files matched your search

+102
View File
@@ -0,0 +1,102 @@
// K-8: the analyst card shows its headline, scope, chips and caveats in the
// interface language; the spoken headline stays English for the model.
import assert from 'node:assert/strict';
import test from 'node:test';
import { setLocale } from '../i18n/index.js';
import { attachVoiceResult as withSpeech } from './speech.js';
import {
initialVoiceCardState,
reduceVoiceCard,
voiceCardView,
} from './voiceCardPresentation.js';
function cardFor(name, result, args = {}) {
const shaped = withSpeech(name, result, args);
return {
shaped,
view: voiceCardView(
[
{ type: 'action-call', callId: 'c1', name, arguments: args },
{ type: 'action-result', callId: 'c1', name, result: shaped },
].reduce(reduceVoiceCard, initialVoiceCardState()),
),
};
}
const analyst = {
ok: true,
count: 3,
complete: true,
scopeLabel: 'anywhere in the loaded data',
partial: true,
feedState: 'degraded',
unanswered: ['earthquakes'],
coverage: { layersQueried: [{ layerKey: 'flights', source: 'OpenSky' }] },
display: { scope: 'anywhere in the loaded data' },
items: [],
};
const radius = {
...analyst,
count: 1234,
complete: false,
partial: false,
feedState: 'nominal',
unanswered: [],
scopeLabel: 'in view',
display: {
scope: 'within 42 km of the view centre',
window: 'last 24 h',
caveat:
'counted 1,000 of 2,345 loaded records; earthquakes loading; flights: distance is ground distance, not overhead',
},
coverage: {
layersQueried: [{ layerKey: 'flights', source: 'OpenSky' }],
note: 'the flights layer loads by viewport',
},
};
test('K-8: analyst card in es has no English text', () => {
setLocale('es');
try {
const { view } = cardFor('analyst_query', analyst);
assert.doesNotMatch(view.result.title, /anywhere in the loaded data/);
assert.ok(!view.result.chips.includes('partial'));
assert.ok(!view.result.chips.includes('degraded'));
assert.ok(!view.result.notes.some((n) => n.startsWith('Not answered')));
} finally {
setLocale('en');
}
});
test('K-8: analyst scope, window and caveats in ru', () => {
setLocale('ru');
try {
const { shaped, view } = cardFor('analyst_query', radius);
assert.match(shaped.say, /^At least 1,234 aircraft in view/);
assert.doesNotMatch(
JSON.stringify(view.result),
/aircraft|within|view centre|last 24 h|counted|loading|ground distance|loads by viewport/,
);
assert.match(view.result.title, /^1\s234 /);
} finally {
setLocale('en');
}
});
test('K-8: the English card reads as before, layers by name', () => {
const { shaped, view } = cardFor('analyst_query', analyst);
assert.equal(view.result.title, '3 aircraft anywhere in the loaded data');
assert.deepEqual(view.result.chips, ['degraded', 'partial']);
assert.ok(view.result.notes.includes('Not answered: Earthquakes (24h)'));
// The model still gets the layer key.
assert.match(shaped.say, /earthquakes not answered/);
const capped = cardFor('analyst_query', radius).view.result;
assert.equal(capped.title, 'At least 1,234 aircraft in view');
assert.deepEqual(capped.lines, [
'within 42 km of the view centre',
'last 24 h',
]);
assert.ok(capped.notes.includes('counted 1,000 of 2,345 loaded records'));
assert.ok(capped.notes.includes('the flights layer loads by viewport'));
});
+2 -1
View File
@@ -8,6 +8,7 @@ import { SPEECH_BUILDERS, attachVoiceResult } from './speech.js';
import { resolveDeicticArgs } from './deixis.js';
export { readLayerLifecycleSummary } from './layerSummary.js';
import { defaultGeospatial } from '../search/defaults.js';
import { msg } from '../i18n/index.js';
import * as Cesium from 'cesium';
import {
CITY_POIS,
@@ -1442,7 +1443,7 @@ function controlScene(sceneDirector, args = {}) {
};
}
if (sceneAction === 'stop') {
sceneDirector.stopScene('Stopped by voice');
sceneDirector.stopScene(msg('Stopped by voice'));
return { ok: true, action: 'control_scene', running: false };
}
if (sceneAction === 'next') {
+3 -1
View File
@@ -1079,7 +1079,9 @@ test('generic layer visibility exposes lifecycle truth for every manager phase a
});
// The voice envelope is additive and states the failure plainly.
assert.equal(say, "Couldn't turn on radio.");
assert.equal(display.title, 'radio');
// The card names the layer, not its id; the result keeps the id.
assert.equal(display.title, 'Radio');
assert.deepEqual(display.lines, ['Radio layer unavailable']);
});
test('generic voice visibility maps Space Missions to the explicit mission layer', async () => {
+2 -1
View File
@@ -124,7 +124,8 @@ test('a partial answer names the layers it could not read', async () => {
assert.match(result.say, /Partial; earthquakes not answered/);
assert.deepEqual(result.unanswered, ['earthquakes']);
assert.ok(view.chips.includes('partial'));
assert.ok(view.notes.includes('Not answered: earthquakes'));
// The card names the layer; the result keeps its key for the model.
assert.ok(view.notes.includes('Not answered: Earthquakes (24h)'));
});
test('"the second one" is the second row the card lists', async () => {
+15 -7
View File
@@ -105,7 +105,15 @@ export class RealtimeCost {
warn: formatCostUsd(state.warnUsd),
cap: formatCostUsd(state.capUsd),
},
) + (state.note ? ` ${state.note}` : '');
) +
// `state.note` is the tracker's English diagnostic; the tooltip says
// the same in the interface language.
(state.incomplete
? ' ' +
t(
'Estimate is incomplete — a response was still in flight when the session ended, so its usage was never reported.',
)
: '');
}
}
@@ -146,12 +154,12 @@ export class RealtimeCost {
}
this.syncCostUi();
if (this.isActive() && this.ui?.detail) {
this.setStatus(
this.status,
t('{tier} applies next session', {
tier: this.voiceTier.toUpperCase(),
}),
);
// The tier as the interface names it; the stored value stays English.
const tier =
resolveVoiceModel(this.voiceTier).tier === 'mini'
? t('MINI')
: t('STANDARD');
this.setStatus(this.status, t('{tier} applies next session', { tier }));
}
return this.voiceTier;
}
@@ -0,0 +1,54 @@
// K-10: the API-key cost tooltip states an incomplete estimate in the
// interface language; the tracker keeps its English note for diagnostics.
import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale } from '../i18n/index.js';
import { RealtimeCost } from './realtimeCost.js';
import { createVoiceCostTracker } from './voiceCost.js';
for (const locale of ['es', 'ru']) {
test(`API-key cost tooltip for an incomplete session has no English (${locale})`, () => {
setLocale(locale);
try {
const costTracker = createVoiceCostTracker({ tier: 'standard' });
costTracker.markIncomplete();
const host = {
costTracker,
voiceTier: 'standard',
cloudVoiceAuth: 'api-key',
sessionCloudVoiceAuth: 'api-key',
isActive: () => false,
ui: {
costValue: { hidden: true, dataset: {}, title: '', textContent: '' },
},
};
RealtimeCost.prototype.syncCostUi.call(host);
const title = host.ui.costValue.title;
assert.ok(title.length > 0);
assert.doesNotMatch(
title,
/Estimate is incomplete|still in flight/,
title,
);
} finally {
setLocale('en');
}
});
}
test('API-key cost tooltip in English still ends with the tracker note', () => {
const costTracker = createVoiceCostTracker({ tier: 'standard' });
costTracker.markIncomplete();
const host = {
costTracker,
voiceTier: 'standard',
cloudVoiceAuth: 'api-key',
sessionCloudVoiceAuth: 'api-key',
isActive: () => false,
ui: {
costValue: { hidden: true, dataset: {}, title: '', textContent: '' },
},
};
RealtimeCost.prototype.syncCostUi.call(host);
assert.ok(host.ui.costValue.title.endsWith(` ${costTracker.state().note}`));
});
+50
View File
@@ -0,0 +1,50 @@
// K-11: the status after a tier switch names the tier in the interface
// language, as the tier button does; the stored tier value stays English.
import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale, t } from '../i18n/index.js';
import { RealtimeCost } from './realtimeCost.js';
function harness() {
const button = { textContent: '', title: '', setAttribute() {} };
const ui = { tierButton: button, detail: {} };
const statusCalls = [];
const cost = new RealtimeCost({
readUi: () => ui,
readStatus: () => 'listening',
operations: {
isActive: () => true,
isVoiceSessionSettled: () => false,
setStatus: (status, detail) => statusCalls.push(detail),
},
});
return { cost, button, statusCalls };
}
const EXPECTED = {
en: {
mini: 'MINI applies next session',
standard: 'STANDARD applies next session',
},
ru: {
mini: 'МИНИ — со следующей сессии',
standard: 'СТАНДАРТ — со следующей сессии',
},
es: {
mini: 'MINI desde la próxima sesión',
standard: 'ESTÁNDAR desde la próxima sesión',
},
};
for (const locale of Object.keys(EXPECTED)) {
test(`${locale}: tier status names the tier in the interface language`, (ctx) => {
setLocale(locale);
ctx.after(() => setLocale('en'));
for (const tier of ['mini', 'standard']) {
const { cost, button, statusCalls } = harness();
assert.equal(cost.setVoiceTier(tier), tier, 'stored tier stays English');
assert.equal(statusCalls.at(-1), EXPECTED[locale][tier]);
assert.equal(button.textContent, tier === 'mini' ? t('MINI') : t('STD'));
}
});
}
+310 -11
View File
@@ -9,11 +9,26 @@
* referents the card lists (the referent registry reads the same answer, so
* "the second one" always means the second row on screen).
*
* What the model receives stays English (`analystHeadline` feeds the spoken
* answer); the card shows the same answer in the active language.
*
* Pure: no DOM, no Cesium.
* @module voice/resultDisplay
*/
import { voiceLayer } from './layerManifest.js';
import {
formatDate,
formatNumber,
getLocale,
msg,
t,
tn,
translateKnown,
} from '../i18n/index.js';
import { layerNameForId, sourceLabel } from '../ui/layerNames.js';
import { localizePlaceName } from '../ui/placeNames.js';
import { formatQuantity } from '../ui/unitLabels.js';
const LAYER_NOUNS = Object.freeze({
flights: 'aircraft',
@@ -52,13 +67,17 @@ export function analystNounFor(layerKeys) {
/** The count/scope headline shared by the analyst card and spoken answer. */
const ANALYST_HEADLINE_MAX = 64;
// The card's headline wraps, and a translation runs longer than the English:
// "Al menos 12 aeronaves militares en un radio de 40 km de Los Ángeles" is 67.
// English keeps the spoken bound, so its card title still equals `say`.
const SHOWN_HEADLINE_MAX = 120;
function boundedHeadline(value) {
function boundedHeadline(value, max = ANALYST_HEADLINE_MAX) {
const text = String(value || '')
.replace(/\s+/g, ' ')
.trim();
if (text.length <= ANALYST_HEADLINE_MAX) return text;
return `${text.slice(0, ANALYST_HEADLINE_MAX - 1).trimEnd()}…`;
if (text.length <= max) return text;
return `${text.slice(0, max - 1).trimEnd()}…`;
}
export function analystHeadline(result) {
@@ -72,6 +91,282 @@ export function analystHeadline(result) {
);
}
/* ---------------- the analyst card in the active language ---------------- */
/**
* The count and its noun as the card shows them. The English matches
* `analystNounFor`, one form for any count as the spoken headline has it.
*/
function shownCount(count, noun) {
const values = { n: formatNumber(count) };
switch (noun) {
case 'aircraft':
return tn(count, '{n} aircraft', '{n} aircraft', values);
case 'military aircraft':
return tn(
count,
'{n} military aircraft',
'{n} military aircraft',
values,
);
case 'ships':
return tn(count, '{n} ships', '{n} ships', values);
case 'satellites':
return tn(count, '{n} satellites', '{n} satellites', values);
case 'launches':
return tn(count, '{n} launches', '{n} launches', values);
case 'earthquakes':
return tn(count, '{n} earthquakes', '{n} earthquakes', values);
case 'fires':
return tn(count, '{n} fires', '{n} fires', values);
case 'fire perimeters':
return tn(count, '{n} fire perimeters', '{n} fire perimeters', values);
case 'cyclones':
return tn(count, '{n} cyclones', '{n} cyclones', values);
case 'buses':
return tn(count, '{n} buses', '{n} buses', values);
case 'bike share':
return tn(count, '{n} bike share', '{n} bike share', values);
case 'flock cameras':
return tn(count, '{n} flock cameras', '{n} flock cameras', values);
case 'military bases':
return tn(count, '{n} military bases', '{n} military bases', values);
case 'datacenters':
return tn(count, '{n} datacenters', '{n} datacenters', values);
case 'dams':
return tn(count, '{n} dams', '{n} dams', values);
case 'results':
return tn(count, '{n} results', '{n} results', values);
default:
return `${values.n} ${noun}`;
}
}
const shownRadius = (km) => formatQuantity(Number(km), 'km');
const FIXED_SCOPES = new Map(
[msg('anywhere in the loaded data'), msg('in view')].map((text) => [
text,
text,
]),
);
/**
* The engine's scope phrases (analystEngine, the Contacts window in
* gevActions) as the card shows them. The English is exactly the engine's;
* a phrase not listed shows as given.
*/
const SCOPE_PATTERNS = [
[
/^within (\d+(?:\.\d+)?) km of the view centre$/,
([, km]) =>
t('within {radius} of the view centre', { radius: shownRadius(km) }),
],
[
/^within (\d+(?:\.\d+)?) km of (.+) \(Contacts window\)$/,
([, km, place]) =>
t('within {radius} of {place} (Contacts window)', {
radius: shownRadius(km),
place: localizePlaceName(place),
}),
],
[
/^within (\d+(?:\.\d+)?) km of (.+)$/,
([, km, place]) =>
t('within {radius} of {place}', {
radius: shownRadius(km),
place: localizePlaceName(place),
}),
],
[
/^within (\d+(?:\.\d+)?) km$/,
([, km]) => t('within {radius}', { radius: shownRadius(km) }),
],
[/^over (.+)$/, ([, region]) => t('over {region}', { region })],
];
function shownScope(text) {
const scope = String(text || '').trim();
if (!scope) return '';
if (FIXED_SCOPES.has(scope)) return t(FIXED_SCOPES.get(scope));
for (const [pattern, show] of SCOPE_PATTERNS) {
const match = pattern.exec(scope);
if (match) return show(match);
}
return translateKnown(scope);
}
/** The analyst headline in the active language, for the card. */
function shownAnalystHeadline(result) {
const keys = (result?.coverage?.layersQueried || []).map(
(layer) => layer.layerKey,
);
const count = Number(result?.count) || 0;
const counted = shownCount(count, analystNounFor(keys));
const items =
result?.complete === false
? t('At least {items}', { items: counted })
: counted;
const scope = shownScope(result?.scopeLabel);
return boundedHeadline(
scope ? t('{items} {scope}', { items, scope }) : items,
getLocale() === 'en' ? ANALYST_HEADLINE_MAX : SHOWN_HEADLINE_MAX,
);
}
/** A layer key as the card names it. */
function shownLayerKey(key) {
const text = String(key || '').trim();
return layerNameForId(text) || text;
}
const shownLayerKeys = (list) =>
String(list || '')
.split(/,\s*/)
.map(shownLayerKey)
.filter(Boolean)
.join(', ');
/** The feed windows layers declare in the voice manifest. */
const FIXED_WINDOWS = new Map(
[msg('last 24 h'), msg('past 30 d and upcoming')].map((text) => [text, text]),
);
function shownWindow(text) {
return String(text || '')
.split(/;\s*/)
.map((part) => part.trim())
.filter(Boolean)
.map((part) =>
FIXED_WINDOWS.has(part)
? t(FIXED_WINDOWS.get(part))
: translateKnown(part),
)
.join('; ');
}
const UTC_CLOCK = Object.freeze({
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23',
timeZone: 'UTC',
});
const UTC_DATE = Object.freeze({
day: '2-digit',
month: '2-digit',
year: 'numeric',
timeZone: 'UTC',
});
/**
* The snapshot time the note carries (ISO, as the model got it) as the card
* shows it: "14:32:05 UTC", with the date first ("15.10.2026, 14:32:05 UTC")
* when the snapshot is not from today (UTC). English shows the ISO time as
* before; a value that is not a time shows as written.
*/
export function shownSnapshotTime(iso, now = Date.now()) {
const time = Date.parse(iso);
if (getLocale() === 'en' || !Number.isFinite(time)) return iso;
const clock = `${formatDate(time, UTC_CLOCK)} UTC`;
const today = new Date(now).toISOString().slice(0, 10);
return new Date(time).toISOString().slice(0, 10) === today
? clock
: `${formatDate(time, UTC_DATE)}, ${clock}`;
}
/** Coverage notes and caveats the engine and the action runner write. */
const FIXED_NOTES = new Map(
[
msg('the flights layer loads by viewport'),
msg('distance is ground distance, not overhead'),
msg(
'the count is a lower bound because at least one layer reached its retained limit.',
),
].map((text) => [text, text]),
);
const LAYER_STATUS_NOTES = Object.freeze({
off: (label) => t('{label} off', { label }),
loading: (label) => t('{label} loading', { label }),
error: (label) => t('{label} error', { label }),
});
const NOTE_PATTERNS = [
[
/^counted the first ([\d,]+) loaded records$/,
([, returned]) =>
t('counted the first {returned} loaded records', {
returned: formatNumber(Number(returned.replace(/,/g, ''))),
}),
],
[
/^counted ([\d,]+) of ([\d,]+) loaded records$/,
([, returned, total]) =>
t('counted {returned} of {total} loaded records', {
returned: formatNumber(Number(returned.replace(/,/g, ''))),
total: formatNumber(Number(total.replace(/,/g, ''))),
}),
],
[
/^(.+) just turned on — still loading, counts will rise$/,
([, layers]) =>
t('{layers} just turned on — still loading, counts will rise', {
layers: shownLayerKeys(layers),
}),
],
[
// The note ends in a period unless a lower-bound clause follows it.
/^Contacts panel snapshot(?: at (\S+))? — the same retained cohort supplies the card, speech, and follow-up memory(\.?)$/,
([, time, period]) =>
(time
? t(
'Contacts panel snapshot at {time} — the same retained cohort supplies the card, speech, and follow-up memory',
{ time: shownSnapshotTime(time) },
)
: t(
'Contacts panel snapshot — the same retained cohort supplies the card, speech, and follow-up memory',
)) + period,
],
[
/^([a-z0-9-]+) (off|loading|error)$/,
([, key, status]) =>
layerNameForId(key)
? LAYER_STATUS_NOTES[status](shownLayerKey(key))
: null,
],
[
/^([a-z0-9-]+): (.+)$/,
([, key, note]) =>
layerNameForId(key) ? `${shownLayerKey(key)}: ${shownNote(note)}` : null,
],
];
function shownNote(text) {
if (FIXED_NOTES.has(text)) return t(FIXED_NOTES.get(text));
for (const [pattern, show] of NOTE_PATTERNS) {
const match = pattern.exec(text);
const shown = match ? show(match) : null;
if (shown) return shown;
}
return translateKnown(text);
}
/** Feed states as the card's chips show them; the values stay English. */
const FEED_STATE_LABELS = Object.freeze({
loading: msg('loading'),
degraded: msg('degraded'),
stale: msg('stale'),
partial: msg('partial'),
fallback: msg('fallback'),
unavailable: msg('unavailable'),
});
function shownFeedState(state) {
const key = FEED_STATE_LABELS[state];
return key ? t(key) : state;
}
function analystCard(result) {
if (!result?.ok) return null;
const display = result.display || {};
@@ -91,28 +386,32 @@ function analystCard(result) {
].map((label) => ({ label }));
return {
display: {
title: analystHeadline(result),
title: shownAnalystHeadline(result),
// The precise scope, unless the title already says exactly that.
lines: [
display.scope !== result.scopeLabel ? display.scope : null,
display.window,
display.scope !== result.scopeLabel ? shownScope(display.scope) : null,
shownWindow(display.window),
].filter(Boolean),
chips: [
...(result.feedState && result.feedState !== 'nominal'
? [{ label: result.feedState }]
? [{ label: shownFeedState(result.feedState) }]
: []),
...(result.partial ? [{ label: 'partial' }] : []),
...(result.partial ? [{ label: t('partial') }] : []),
],
// Coverage qualifications lead the feed detail. The bounded card groups
// overflow in its existing Notes disclosure rather than dropping them.
preserveNotes: true,
notes: [
...notesOf(result.coverage?.note, display.caveat),
...notesOf(result.coverage?.note, display.caveat).map(shownNote),
...(unanswered.length
? [`Not answered: ${unanswered.join(', ')}`]
? [
t('Not answered: {layers}', {
layers: unanswered.map(shownLayerKey).join(', '),
}),
]
: []),
],
sources,
sources: sources.map(({ label }) => ({ label: sourceLabel(label) })),
},
referents: Array.isArray(result.items) ? result.items : [],
resultSet: true,
+9 -1
View File
@@ -8,6 +8,7 @@ import {
import { createVoiceSession } from './session.js';
import { realtimeSessionEvent } from './realtimeEvents.js';
import { createVoiceCommands } from './sessionCommands.js';
import { describeUnsavedWork } from '../i18n/index.js';
function deferred() {
let resolve;
@@ -300,17 +301,24 @@ test('common button and annotation bindings work with an alternate adapter and c
assert.equal(ui.helpDetail.textContent, 'Activate to toggle voice');
assert.equal(ui.status.textContent, 'LISTENING');
assert.equal(ui.detail.textContent, 'Ready');
// K-27: switching language (a reload) asks first during a session.
assert.deepEqual(describeUnsavedWork(), ['the active voice session']);
subscribed({ status: 'resolved' });
assert.deepEqual(events, [
{ type: 'map_annotation_outline', status: 'resolved' },
]);
button.dispatchEvent(new Event('click'));
assert.equal(controls.state, 'idle');
assert.deepEqual(describeUnsavedWork(), []);
button.dispatchEvent(new Event('click'));
await new Promise((done) => setTimeout(done, 0));
assert.deepEqual(describeUnsavedWork(), ['the active voice session']);
lifetime.abort();
assert.deepEqual(describeUnsavedWork(), [], 'disposal unregisters');
assert.equal(subscribed, null);
assert.equal(removed, 1);
button.dispatchEvent(new Event('click'));
assert.equal(calls, 1);
assert.equal(calls, 2);
} finally {
globalThis.window = previous;
}
+7 -1
View File
@@ -2,7 +2,7 @@ import { createVoiceControl, voiceStatusLabel } from './control.js';
import { createVoiceSession } from './session.js';
import { VoiceCardControls } from './voiceCard.js';
import { PointerReticle } from './pointerReticle.js';
import { t } from '../i18n/index.js';
import { msg, registerUnsavedWork, t } from '../i18n/index.js';
function createDefaultReticle(pointer) {
const element = pointer?.element;
@@ -107,6 +107,10 @@ export function createVoiceCommands({
card?.outline(event);
session.sendMapEvent({ type: 'map_annotation_outline', ...event });
});
// A reload (the language switch) ends the conversation: ask first.
const unregisterUnsavedWork = registerUnsavedWork(() =>
session.isActive() ? msg('the active voice session') : null,
);
const buttonHandler = () => {
if (adapter.ignoreButtonClick?.()) return;
if (session.isActive()) session.stop();
@@ -117,6 +121,7 @@ export function createVoiceCommands({
'abort',
() => {
ui.button.removeEventListener('click', buttonHandler);
unregisterUnsavedWork();
annotationUnsubscribe?.();
updateStatus();
updateCard?.();
@@ -130,6 +135,7 @@ export function createVoiceCommands({
);
if (session.disposed) {
ui.button.removeEventListener('click', buttonHandler);
unregisterUnsavedWork();
annotationUnsubscribe?.();
updateStatus();
updateCard?.();
+271 -36
View File
@@ -19,11 +19,27 @@
*
* Only `display` and the plan/progress labels are interface text, translated
* with t(). `say`, `identityLine` and the progress lines are not shown: they go
* to the voice model to be spoken, so they stay as written.
* to the voice model to be spoken, so they stay as written. The tool result
* itself (layer names, sources, `error`) stays English as the model got it;
* the card shows those through the shown-name helpers and `shownError`.
*/
import { analystHeadline } from './resultDisplay.js';
import { formatNumber, msg, t, tn } from '../i18n/index.js';
import {
formatNumber,
getLocale,
msg,
t,
tn,
translateKnown,
} from '../i18n/index.js';
import {
layerNameForId,
shownLayerName,
sourceLabel,
} from '../ui/layerNames.js';
import { localizePlaceName } from '../ui/placeNames.js';
import { formatQuantity } from '../ui/unitLabels.js';
const LAYER_NOUNS = Object.freeze({
flights: 'aircraft',
@@ -78,10 +94,150 @@ function shownFeet(meters) {
return t('{value} feet', { value: formatNumber(rounded) });
}
function kilometers(km) {
function roundedKm(km) {
const value = Number(km);
if (!Number.isFinite(value)) return null;
return `${value < 10 ? Number(value.toFixed(1)) : Math.round(value)} km`;
return value < 10 ? Number(value.toFixed(1)) : Math.round(value);
}
function kilometers(km) {
const value = roundedKm(km);
return value === null ? null : `${value} km`;
}
/** The card's distance, in the active language: "2,5 км". */
function shownKm(km) {
const value = roundedKm(km);
return value === null ? null : formatQuantity(value, 'km');
}
/** Sources as the card lists them. */
function shownSources(source) {
return source ? [{ label: sourceLabel(source) }] : [];
}
/*
* Tool errors stay English in the result: the model reads them. The card shows
* the ones it knows in the interface language. Each English text here is
* exactly what gevActions returns, so English shows as before; an error not
* listed shows as given.
*/
const KNOWN_ERRORS = new Map(
[
msg('The flights layer is not enabled'),
msg('The military layer is not enabled'),
msg('The satellites layer is not enabled'),
msg('The ais-live-vessels layer is not enabled'),
msg('Could not enable the requested layer'),
msg('Could not disable the requested layer'),
msg('Radio layer unavailable'),
msg('Camera navigation is unavailable in the current view'),
msg('Camera navigation policy unavailable'),
msg('Nearest-aircraft selection supports Flights or Military Flights only'),
msg(
'Nearest-aircraft selection needs a preset, place name, or latitude and longitude',
),
msg('Could not arrive at the requested place'),
msg('The nearest airborne aircraft could not be selected'),
].map((text) => [text, text]),
);
function noneWithinView(target, radius) {
switch (target) {
case 'flights':
return t('No flights within {radius} of the current view', { radius });
case 'military':
return t('No military within {radius} of the current view', { radius });
case 'satellites':
return t('No satellites within {radius} of the current view', {
radius,
});
case 'vessels':
return t('No vessels within {radius} of the current view', { radius });
default:
return null;
}
}
const ERROR_PATTERNS = [
[
/^(.+) is enabled on the (.+) feed, but no airborne aircraft is loaded in the (.+) view yet$/,
([, layer, source, place]) =>
t(
'{layer} is enabled on the {source} feed, but no airborne aircraft is loaded in the {place} view yet',
{
layer: shownLayerName(layer),
source: sourceLabel(source),
place: localizePlaceName(place),
},
),
],
[
/^(.+) is enabled, but no airborne aircraft is loaded in the (.+) view yet$/,
([, layer, place]) =>
t(
'{layer} is enabled, but no airborne aircraft is loaded in the {place} view yet',
{ layer: shownLayerName(layer), place: localizePlaceName(place) },
),
],
[
/^(.+) is enabled, but its aircraft feed is unavailable$/,
([, layer]) =>
t('{layer} is enabled, but its aircraft feed is unavailable', {
layer: shownLayerName(layer),
}),
],
[
/^(.+) is enabled, but its destination refresh did not complete$/,
([, layer]) =>
t('{layer} is enabled, but its destination refresh did not complete', {
layer: shownLayerName(layer),
}),
],
[
/^(.+) is enabled, but (.+) is unavailable$/,
([, layer, source]) =>
t('{layer} is enabled, but {source} is unavailable', {
layer: shownLayerName(layer),
source: sourceLabel(source),
}),
],
[
/^Could not arrive at (.+)$/,
([, place]) =>
t('Could not arrive at {place}', { place: localizePlaceName(place) }),
],
[
/^(.+) is no longer loaded$/,
([, name]) => t('{name} is no longer loaded', { name }),
],
[
/^Unknown target layer: (.*)$/,
([, target]) => t('Unknown target layer: {target}', { target }),
],
[
/^No (\S+) within (\d+(?:\.\d+)?) km of the current view$/,
([, target, km]) => noneWithinView(target, shownKm(km)),
],
];
/** A tool error as the card shows it. */
function shownError(error) {
if (typeof error !== 'string' || !error) return null;
const known = KNOWN_ERRORS.get(error);
if (known) return t(known);
for (const [pattern, show] of ERROR_PATTERNS) {
const match = pattern.exec(error);
const shown = match ? show(match) : null;
if (shown) return shown;
}
return translateKnown(error);
}
// The card wraps a failed result's lines rather than cutting them: a
// translated error runs longer than the English (voiceCardPresentation).
function errorLines(result) {
return [shownError(result?.error)].filter(Boolean);
}
function sentence(text) {
@@ -189,6 +345,12 @@ function shownPair(items) {
}
/** Ids such as `austin` or `local-firms` read as names on screen. */
// A place as the card shows it: a preset city in the interface language,
// whether the call named it ("Tokyo") or gave its id ("tokyo").
function shownPlace(value, max) {
return localizePlaceName(displayName(value, max));
}
function displayName(value, max) {
const label = spokenLabel(value, max);
return /^[a-z0-9-]+$/.test(label)
@@ -203,6 +365,12 @@ function layerLabel(result) {
return spokenLabel(result?.label || result?.layerId || 'Layer');
}
/** The layer as the card names it, in the active language. */
function shownLayerLabel(result) {
const name = shownLayerName(result?.label, result?.layerId);
return spokenLabel(name || t('Layer'));
}
/* ---------------- per-tool builders ---------------- */
function setLayerVisibility(result, args = {}) {
@@ -212,7 +380,7 @@ function setLayerVisibility(result, args = {}) {
const wanted = args.enabled === false ? 'off' : 'on';
return {
say: `Couldn't turn ${wanted} ${label}.`,
display: { title: label, lines: [result?.error].filter(Boolean) },
display: { title: shownLayerLabel(result), lines: errorLines(result) },
};
}
const on = result.enabled !== false;
@@ -224,30 +392,31 @@ function setLayerVisibility(result, args = {}) {
return {
say,
display: {
title: switchedTitle(label, on),
title: switchedTitle(shownLayerLabel(result), on),
chips: on ? feedChips(result.feedState) : [],
sources: result.source ? [{ label: result.source }] : [],
sources: shownSources(result.source),
},
};
}
function flyToLocation(result) {
const label = spokenLabel(result?.label || result?.query || 'there');
const title = spokenLabel(result?.label || result?.query) || t('there');
if (result?.cancelled) return null;
if (!result?.ok) {
return {
say: sentence(`Couldn't find ${label}`),
display: { title: label, lines: [result?.error].filter(Boolean) },
display: { title, lines: errorLines(result) },
};
}
return {
say: sentence(result.arrived ? `Over ${label}` : `Flying to ${label}`),
display: {
title: label,
title,
lines: result.rangeM
? [
t('Camera range {range}', {
range: kilometers(result.rangeM / 1000),
range: shownKm(result.rangeM / 1000),
}),
]
: [],
@@ -274,7 +443,7 @@ function frameOverhead(result) {
say,
display: {
title: frameTitle(result?.layerId),
lines: [result?.error].filter(Boolean),
lines: errorLines(result),
},
};
}
@@ -294,7 +463,9 @@ function frameOverhead(result) {
display: {
title: inFrameTitle(result.layerId, count),
lines: [
t('Within {radius} of the view', { radius }),
t('Within {radius} of the view', {
radius: shownKm(result?.radiusKm),
}),
result.detectionEnabled ? t('Labels on') : null,
].filter(Boolean),
notes:
@@ -309,8 +480,10 @@ function selectNearestAircraft(result) {
result?.location?.label || result?.location || 'there',
);
const noun = LAYER_NOUNS[result?.layerId] || 'aircraft';
const shownLocation =
spokenLabel(result?.location?.label || result?.location) || t('there');
const feed = result?.feed || {};
const sources = feed.source ? [{ label: feed.source }] : [];
const sources = shownSources(feed.source);
if (result?.cancelled) return null;
if (!result?.ok) {
let say;
@@ -325,7 +498,7 @@ function selectNearestAircraft(result) {
say,
display: {
title: nearestTitle(result?.layerId),
lines: [result?.error].filter(Boolean),
lines: errorLines(result),
chips: feedChips(feed.state),
sources,
},
@@ -352,7 +525,10 @@ function selectNearestAircraft(result) {
title: name,
lines: [
distance
? t('{distance} from {location}', { distance, location })
? t('{distance} from {location}', {
distance: shownKm(aircraft.distanceKm),
location: shownLocation,
})
: null,
altitude
? t('Altitude {altitude}', {
@@ -410,8 +586,12 @@ function getEntityContext(result) {
say: null,
display: {
title,
lines: [selected.layerName].filter(Boolean),
sources: selected.source ? [{ label: selected.source }] : [],
lines: [
selected.layerName
? shownLayerName(selected.layerName, selected.layerId)
: null,
].filter(Boolean),
sources: shownSources(selected.source),
},
};
}
@@ -443,7 +623,7 @@ function getEntityContext(result) {
? t('Route {route}', { route: routeText(properties, SHOWN_ROUTE) })
: null,
].filter(Boolean),
sources: selected.source ? [{ label: selected.source }] : [],
sources: shownSources(selected.source),
},
referents: [
{
@@ -491,7 +671,7 @@ function getCurrentViewState(result) {
title: t('View state'),
lines: enabled.map(
(layer) =>
`${spokenLabel(layer.name || layer.id, 32)} · ${layer.count ?? 0} · ${feedStateLabel(layer.feedState || 'nominal')}`,
`${spokenLabel(shownLayerName(layer.name, layer.id), 32)} · ${layer.count ?? 0} · ${feedStateLabel(layer.feedState || 'nominal')}`,
),
},
schedule: 'silent',
@@ -627,8 +807,52 @@ function annotationTargets(args) {
.filter(Boolean);
}
// frame_overhead's targets as the schema names them; English is unchanged.
function frameStepLabel(target) {
switch (target) {
case 'flights':
return t('Frame flights');
case 'military':
return t('Frame military');
case 'satellites':
return t('Frame satellites');
case 'vessels':
return t('Frame vessels');
default:
return t('Frame {target}', { target });
}
}
// The other tools' plan steps, one label per tool in actionSchemas. The tool
// name itself is English model vocabulary and never shows.
const STEP_TITLES = Object.freeze({
zoom_to_globe: msg('Zoom out to globe'),
show_data_layers_menu: msg('Open data layers'),
set_panel_open: msg('Open or close a panel'),
set_context_mode: msg('Change context mode'),
control_cockpit: msg('Control cockpit'),
set_visual_style: msg('Change visual style'),
set_hud: msg('Adjust HUD'),
set_cyber_sonar: msg('Adjust cyber sonar'),
set_detection: msg('Adjust detection overlay'),
set_map_stack: msg('Change map stack'),
set_post_processing: msg('Adjust post-processing'),
control_scene: msg('Control scene'),
control_cctv: msg('Control CCTV'),
control_radio: msg('Control radio'),
track_entity: msg('Track contact'),
stop_tracking: msg('Stop tracking'),
clear_annotations: msg('Clear marks'),
move_camera: msg('Move camera'),
fly_route: msg('Fly route'),
analyst_query: msg('Analyst query'),
next_iss_pass: msg('Next ISS pass'),
next_satellite_pass: msg('Next satellite pass'),
});
/** A short on-screen label for one tool call in the voice card's plan. */
export function planStepLabel(name, args = {}) {
if (Object.hasOwn(STEP_TITLES, name)) return t(STEP_TITLES[name]);
switch (name) {
case 'annotate_map': {
const targets = annotationTargets(args);
@@ -641,29 +865,40 @@ export function planStepLabel(name, args = {}) {
})
: t('Mark {targets}', { targets: shown });
}
case 'fly_to_location':
return t('Fly to {place}', {
place: displayName(args.query || args.locationId || 'location', 40),
});
case 'select_nearest_aircraft':
return t('Nearest aircraft to {place}', {
place: displayName(args.locationQuery || args.locationId || 'here', 32),
});
case 'set_layer_visibility':
return switchedTitle(
displayName(args.layerId || 'Layer', 32),
args.enabled !== false,
);
case 'fly_to_location': {
const place = args.query || args.locationId;
return place
? t('Fly to {place}', { place: shownPlace(place, 40) })
: t('Fly to Location');
}
case 'select_nearest_aircraft': {
const place = args.locationQuery || args.locationId;
return place
? t('Nearest aircraft to {place}', { place: shownPlace(place, 32) })
: t('Nearest aircraft to Here');
}
case 'set_layer_visibility': {
const on = args.enabled !== false;
const label =
layerNameForId(args.layerId) ||
(args.layerId ? displayName(args.layerId, 32) : '');
// "Layer" alone is a noun the languages inflect the state for.
if (!label) return on ? t('Layer on') : t('Layer off');
return switchedTitle(label, on);
}
case 'adjust_camera_zoom':
return args.direction === 'out' ? t('Zoom out') : t('Zoom in');
case 'get_entity_context':
return t('Read the view');
case 'get_current_view_state':
return t('Check view state');
case 'frame_overhead':
return t('Frame {target}', {
target: spokenLabel(args.target || 'flights', 24),
});
return frameStepLabel(spokenLabel(args.target || 'flights', 24));
default:
return spokenLabel(String(name || 'Action').replace(/_/g, ' '), 40);
// A tool the card has no label for: English shows its name as before.
return getLocale() === 'en'
? spokenLabel(String(name || 'Action').replace(/_/g, ' '), 40)
: t('Action');
}
}
+109
View File
@@ -0,0 +1,109 @@
// K-24: on a failed tool step the voice card shows the English `result.error`
// from gevActions under a translated title and step status.
import assert from 'node:assert/strict';
import test from 'node:test';
import { setLocale } from '../i18n/index.js';
import { attachVoiceResult, spokenLabel } from './speech.js';
import {
initialVoiceCardState,
reduceVoiceCard,
voiceCardView,
} from './voiceCardPresentation.js';
// Error texts as gevActions.js builds them (lines 2442, 646–647, 2476).
const CASES = [
{
name: 'frame_overhead',
args: { target: 'military' },
result: {
ok: false,
action: 'frame_overhead',
layerId: 'military',
error: 'The military layer is not enabled',
},
},
{
name: 'select_nearest_aircraft',
args: { locationQuery: 'Madrid' },
result: {
ok: false,
action: 'select_nearest_aircraft',
stage: 'nearest',
cancelled: false,
error:
'Live Flights is enabled, but no airborne aircraft is loaded in the Madrid view yet',
location: { label: 'Madrid' },
layer: { label: 'Live Flights' },
feed: { state: 'nominal', source: 'OpenSky' },
count: 0,
},
},
{
name: 'frame_overhead',
args: { target: 'flights' },
result: {
ok: false,
action: 'frame_overhead',
layerId: 'flights',
radiusKm: 150,
count: 0,
error: 'No flights within 150 km of the current view',
},
},
];
function cardFor(locale, { name, args, result }) {
setLocale(locale);
let state = reduceVoiceCard(initialVoiceCardState(), {
type: 'action-call',
id: 'c1',
name,
arguments: args,
});
state = reduceVoiceCard(state, {
type: 'action-result',
id: 'c1',
name,
result: attachVoiceResult(name, result, args),
});
return voiceCardView(state);
}
for (const locale of ['es', 'ru']) {
for (const c of CASES) {
test(`${locale}: ${c.name} failure shows the card lines in the UI language`, () => {
const view = cardFor(locale, c);
try {
assert.ok(view.result.visible, 'card shows a result');
assert.notEqual(view.result.title, '', 'card has a title');
// Title and step status are translated (this part passes today).
assert.notEqual(
view.steps[0].statusText,
'failed',
'step status translated',
);
// The failure line is the raw English tool error.
for (const line of view.result.lines) {
assert.ok(
!/\b(is not enabled|no airborne aircraft|of the current view)\b/.test(
line,
),
`English tool error shown under ${locale} title "${view.result.title}" / step "${view.steps[0].label}: ${view.steps[0].statusText}": "${line}"`,
);
}
} finally {
setLocale('en');
}
});
}
}
test('en: the card shows the tool error as the model got it', () => {
for (const c of CASES) {
const view = cardFor('en', c);
// Whole, not cut at 80 characters: the card wraps an error line.
assert.deepEqual(view.result.lines, [
spokenLabel(c.result.error, Infinity),
]);
}
});
+88
View File
@@ -0,0 +1,88 @@
// K-7: the voice card names layers, sources and distances in the interface
// language; what the model receives stays English.
import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale } from '../i18n/index.js';
import { attachVoiceResult, planStepLabel } from './speech.js';
import { voiceCardView } from './voiceCardPresentation.js';
for (const locale of ['ru', 'es']) {
test(`voice card is in ${locale} for a layer switch`, (t) => {
t.after(() => setLocale('en'));
setLocale(locale);
const out = attachVoiceResult(
'set_layer_visibility',
{
ok: true,
layerId: 'flights',
label: 'Live Flights',
enabled: true,
source: 'Flights',
},
{ layerId: 'flights', enabled: true },
);
const view = voiceCardView({ steps: [], result: out });
assert.doesNotMatch(out.display.title, /Live Flights/);
assert.doesNotMatch(JSON.stringify(view), /Flights/);
});
test(`voice card distance is in ${locale}`, (t) => {
t.after(() => setLocale('en'));
setLocale(locale);
const out = attachVoiceResult('fly_to_location', {
ok: true,
label: 'Paris',
rangeM: 2500,
});
assert.doesNotMatch(out.display.lines[0], /2\.5 km/);
});
test(`plan label is in ${locale}`, (t) => {
t.after(() => setLocale('en'));
setLocale(locale);
const a = planStepLabel('select_nearest_aircraft', {});
const b = planStepLabel('set_layer_visibility', {
layerId: 'earthquakes',
enabled: true,
});
assert.doesNotMatch(a, /Here/);
assert.doesNotMatch(b, /Earthquakes/);
});
}
test('en: layer switch, distance and plan labels read as before', () => {
const out = attachVoiceResult(
'set_layer_visibility',
{
ok: true,
layerId: 'flights',
label: 'Live Flights',
enabled: true,
source: 'Flights',
},
{ layerId: 'flights', enabled: true },
);
assert.equal(out.say, 'Live Flights on.');
assert.equal(out.display.title, 'Live Flights on');
assert.deepEqual(out.display.sources, [{ label: 'Flights' }]);
const fly = attachVoiceResult('fly_to_location', {
ok: true,
label: 'Paris',
rangeM: 2500,
});
assert.equal(fly.display.lines[0].replace(/\s/g, ' '), 'Camera range 2.5 km');
assert.equal(
planStepLabel('select_nearest_aircraft', {}),
'Nearest aircraft to Here',
);
assert.equal(
planStepLabel('frame_overhead', { target: 'vessels' }),
'Frame vessels',
);
// The plan names the layer as its result card does, not by its id.
assert.equal(
planStepLabel('set_layer_visibility', {
layerId: 'earthquakes',
enabled: true,
}),
'Earthquakes (24h) on',
);
});
+246
View File
@@ -0,0 +1,246 @@
// PDF review C3, B4, E5, E6 and proposal 1: the voice card's plan steps name
// every tool in the interface language, the "Layer" step agrees in gender,
// error lines and long headlines show whole, and the Contacts snapshot time
// reads as a clock, not ISO. What the model receives stays English.
import assert from 'node:assert/strict';
import test from 'node:test';
import { setLocale } from '../i18n/index.js';
import { GEV_ACTION_SCHEMAS } from './actionSchemas.js';
import { presentResult, shownSnapshotTime } from './resultDisplay.js';
import { attachVoiceResult, planStepLabel } from './speech.js';
import {
initialVoiceCardState,
reduceVoiceCard,
voiceCardView,
} from './voiceCardPresentation.js';
const TOOLS = GEV_ACTION_SCHEMAS.map((schema) => schema.name);
function inLocale(locale, run) {
setLocale(locale);
try {
return run();
} finally {
setLocale('en');
}
}
test('C3: every tool has a plan label, never its raw name', () => {
for (const name of TOOLS) {
const label = planStepLabel(name, {});
assert.notEqual(label, name.replace(/_/g, ' '), name);
assert.doesNotMatch(label, /_/, name);
}
assert.equal(planStepLabel('analyst_query', {}), 'Analyst query');
assert.equal(
planStepLabel('adjust_camera_zoom', { direction: 'in' }),
'Zoom in',
);
assert.equal(
planStepLabel('adjust_camera_zoom', { direction: 'out' }),
'Zoom out',
);
// A tool the card does not know keeps its name in English.
assert.equal(planStepLabel('brand_new_tool', {}), 'brand new tool');
});
for (const locale of ['ru', 'es']) {
test(`C3: ${locale} plan labels are all translated`, () => {
const english = Object.fromEntries(
TOOLS.map((name) => [name, planStepLabel(name, { direction: 'out' })]),
);
inLocale(locale, () => {
for (const name of TOOLS) {
// A layer switch without a layer is checked below (B4).
const label = planStepLabel(name, { direction: 'out' });
assert.notEqual(label, english[name], `${name}: "${label}"`);
}
});
});
}
test('C3/E10: analyst query and an unknown tool in ru and es', () => {
inLocale('ru', () => {
assert.equal(planStepLabel('analyst_query', {}), 'Аналитический запрос');
assert.equal(planStepLabel('brand_new_tool', {}), 'Действие');
});
inLocale('es', () => {
assert.equal(planStepLabel('analyst_query', {}), 'Consulta analítica');
assert.equal(planStepLabel('brand_new_tool', {}), 'Acción');
});
});
test('B4: a layer step with no layer agrees with "Capa"', () => {
const step = (enabled) => planStepLabel('set_layer_visibility', { enabled });
assert.equal(step(true), 'Layer on');
assert.equal(step(false), 'Layer off');
inLocale('es', () => {
assert.equal(step(true), 'Capa: activada');
assert.equal(step(false), 'Capa: desactivada');
// A named layer keeps the shared "{label} on" wording.
assert.match(
planStepLabel('set_layer_visibility', {
layerId: 'earthquakes',
enabled: true,
}),
/: activado$/,
);
});
inLocale('ru', () => {
assert.equal(step(true), 'Слой: вкл.');
assert.equal(step(false), 'Слой: выкл.');
});
});
function cardAfter(name, result, args = {}) {
let state = reduceVoiceCard(initialVoiceCardState(), {
type: 'action-call',
id: 'c1',
name,
arguments: args,
});
state = reduceVoiceCard(state, {
type: 'action-result',
id: 'c1',
name,
result: attachVoiceResult(name, result, args),
});
return voiceCardView(state);
}
const NO_PLACE = {
ok: false,
action: 'select_nearest_aircraft',
stage: 'location',
error:
'Nearest-aircraft selection needs a preset, place name, or latitude and longitude',
feed: {},
};
test('proposal 1: an error line shows whole, in every language', () => {
for (const [locale, expected] of [
['en', NO_PLACE.error],
[
'ru',
'Для выбора ближайшего самолёта нужен пресет, название места или широта и долгота',
],
[
'es',
'Para seleccionar la aeronave más cercana se necesita un preajuste, un nombre de lugar o la latitud y la longitud',
],
]) {
const view = inLocale(locale, () =>
cardAfter('select_nearest_aircraft', NO_PLACE),
);
assert.deepEqual(view.result.lines, [expected], locale);
}
});
test('proposal 1: a successful line keeps its 80-character cap', () => {
const long = 'x'.repeat(120);
const view = voiceCardView({
...initialVoiceCardState(),
result: { display: { title: 'T', lines: [long] } },
});
assert.equal(view.result.lines[0].length, 80);
});
test('E5: a translated analyst headline fits the card title whole', () => {
const title =
'Al menos 12 aeronaves militares en un radio de 40 km de Los Angeles';
const card = () =>
voiceCardView({
...initialVoiceCardState(),
result: { display: { title, lines: [] } },
}).result.title;
assert.equal(inLocale('es', card), title);
// English keeps its 64-character title, equal to the spoken headline.
assert.equal(card().length, 64);
});
test('E6: the snapshot time reads as a UTC clock, dated when not today', () => {
const iso = '2026-10-15T14:32:05.000Z';
const sameDay = Date.parse('2026-10-15T23:00:00Z');
const nextDay = Date.parse('2026-10-16T00:10:00Z');
assert.equal(shownSnapshotTime(iso, sameDay), iso, 'English as before');
inLocale('ru', () => {
assert.equal(shownSnapshotTime(iso, sameDay), '14:32:05 UTC');
assert.equal(shownSnapshotTime(iso, nextDay), '15.10.2026, 14:32:05 UTC');
});
inLocale('es', () => {
assert.equal(shownSnapshotTime(iso, sameDay), '14:32:05 UTC');
assert.equal(shownSnapshotTime(iso, nextDay), '15/10/2026, 14:32:05 UTC');
assert.equal(shownSnapshotTime('not a time'), 'not a time');
});
});
test('E6: the Contacts note shows the clock; the model note stays ISO', () => {
const evaluatedAt = new Date();
evaluatedAt.setUTCHours(14, 32, 5, 0);
const iso = evaluatedAt.toISOString();
const note = `Contacts panel snapshot at ${iso} — the same retained cohort supplies the card, speech, and follow-up memory.`;
const result = {
ok: true,
count: 12,
complete: true,
scopeLabel: 'in view',
display: { scope: 'in view' },
coverage: { note },
items: [],
};
const notes = inLocale('es', () =>
presentResult('analyst_query', result).display.notes.join(' | '),
);
assert.match(notes, /\(14:32:05 UTC\)/);
assert.doesNotMatch(notes, /T14:32/);
assert.equal(result.coverage.note, note, 'the result is not changed');
});
// C12: preset places show in the interface language on the card; the model's
// error and scope stay English.
test('C12: plan steps and errors name a preset place in the UI language', () => {
const failed = {
ok: false,
action: 'select_nearest_aircraft',
stage: 'nearest',
error:
'Live Flights is enabled, but no airborne aircraft is loaded in the Tokyo view yet',
location: { label: 'Tokyo' },
feed: { state: 'nominal' },
};
assert.equal(
planStepLabel('fly_to_location', { query: 'Tokyo' }),
'Fly to Tokyo',
);
inLocale('es', () => {
assert.match(planStepLabel('fly_to_location', { query: 'Tokyo' }), /Tokio/);
assert.match(
planStepLabel('select_nearest_aircraft', { locationId: 'tokyo' }),
/Tokio/,
);
const view = cardAfter('select_nearest_aircraft', failed);
assert.match(view.result.lines[0], /Tokio/);
});
inLocale('ru', () => {
assert.match(planStepLabel('fly_to_location', { query: 'Tokyo' }), /Токио/);
});
assert.match(failed.error, /Tokyo view/, 'the result stays English');
});
test('C12: the analyst scope names Los Angeles in the UI language', () => {
const result = {
ok: true,
count: 12,
complete: false,
scopeLabel: 'within 40 km of Los Angeles',
display: { scope: 'within 40 km of Los Angeles' },
coverage: { layersQueried: [{ layerKey: 'military' }] },
items: [],
};
const title = inLocale(
'es',
() => presentResult('analyst_query', result).display.title,
);
assert.match(title, /Los Ángeles$/, 'the headline is not cut at 64');
assert.equal(result.scopeLabel, 'within 40 km of Los Angeles');
});
+18 -4
View File
@@ -2,7 +2,7 @@ import { planStepLabel, progressStepLabel, spokenLabel } from './speech.js';
import { presentResult } from './resultDisplay.js';
import { MAX_DISPLAYED_REFERENTS, normalizeReferents } from './referents.js';
import { msg, t } from '../i18n/index.js';
import { getLocale, msg, t } from '../i18n/index.js';
/**
* Pure presentation of the voice card nested in the voice control: captions,
* the current plan, the latest result and its numbered referents.
@@ -17,6 +17,16 @@ const MAX_STEPS = 6;
const MAX_LINES = 6;
const MAX_NOTES = 6;
const MAX_CAPTION = 220;
// Titles and lines wrap in the card; the caps only stop a runaway value. A
// translated headline runs longer than the English one ("Al menos 12 aeronaves
// militares en un radio de 40 km de Los Ángeles"), so other languages cut it at
// 120; English keeps 64. A failed result's lines (its error) are never cut.
const MAX_TITLE = 64;
const MAX_TRANSLATED_TITLE = 120;
const MAX_LINE = 80;
const titleMax = () =>
getLocale() === 'en' ? MAX_TITLE : MAX_TRANSLATED_TITLE;
const STATUS_TEXT = Object.freeze({
running: msg('in progress'),
@@ -205,12 +215,14 @@ export function reduceVoiceCard(state, event) {
...next,
result: {
display: shown.display,
// A failed result's lines are its error: shown whole, wrapped.
wholeLines: result.ok === false,
referents: normalizeReferents(
shown.referents,
MAX_DISPLAYED_REFERENTS,
),
},
announce: spokenLabel(shown.display.title, 64),
announce: spokenLabel(shown.display.title, titleMax()),
});
}
case 'outline': {
@@ -293,10 +305,12 @@ export function voiceCardView(state) {
})),
result: {
visible: Boolean(display?.title),
title: spokenLabel(display?.title || '', 64),
title: spokenLabel(display?.title || '', titleMax()),
chips: (display?.chips || []).map((chip) => spokenLabel(chip.label, 24)),
lines: (display?.lines || [])
.map((line) => spokenLabel(line, 80))
.map((line) =>
spokenLabel(line, state.result?.wholeLines ? Infinity : MAX_LINE),
)
.slice(0, MAX_LINES),
referents: (state.result?.referents || [])
.slice(0, MAX_DISPLAYED_REFERENTS)