Merge main into the voice tilt branch

This commit is contained in:
Manjunath committed 2026-11-03 19:40:13 +05:30
commit 2aef67197c
53 files changed
+2139 -281

No files matched your search

+10 -7
View File
@@ -89,7 +89,7 @@ Start with the included data sources, then add your own. Each layer is a separat
## ⚡ Quick Start ## ⚡ Quick Start
**Start without an account or API keys.** Every path opens the same app with **Local setup needs no account or API keys.** The app opens with
Esri satellite imagery and keyless terrain. OSM is the fallback if Esri is Esri satellite imagery and keyless terrain. OSM is the fallback if Esri is
unreachable. Flights, military traffic, satellites, earthquakes, public unreachable. Flights, military traffic, satellites, earthquakes, public
cameras, radio, and launches are available without keys. cameras, radio, and launches are available without keys.
@@ -236,7 +236,11 @@ the container.
### Path 4 — GitHub Codespaces (cloud, no local install) ### Path 4 — GitHub Codespaces (cloud, no local install)
Runs the same dev server in a cloud workspace, so Node and the dependencies Codespaces requires a GitHub account. Overpass queries and OpenAI voice token
requests return HTTP 403 through its forwarded HTTPS URL, even with API keys
in `.env` and `GEV_ALLOWED_HOSTS` configured. Use a local setup for these features.
The codespace runs the dev server in a cloud workspace, so Node and the dependencies
never touch your machine. The globe still renders in your own browser, so it never touch your machine. The globe still renders in your own browser, so it
needs the same graphics there as any other path. GitHub Free includes 120 needs the same graphics there as any other path. GitHub Free includes 120
core-hours a month with no card on file — about 60 hours on the default 2-core core-hours a month with no card on file — about 60 hours on the default 2-core
@@ -265,14 +269,13 @@ ignored on purpose, so pass that exact name.
⚠️ **A codespace is reachable from the internet.** Treat it as the ⚠️ **A codespace is reachable from the internet.** Treat it as the
wider-publish case in [Sharing an instance](#-sharing-an-instance). Provider wider-publish case in [Sharing an instance](#-sharing-an-instance). Provider
Settings does not open on the forwarded URL, so put keys in the workspace Settings is unavailable on the forwarded URL. Never commit your workspace
`.env` by hand. `.env` is gitignored — never commit it. [SECURITY.md](SECURITY.md) has the `.env`. [SECURITY.md](SECURITY.md) has the threat model.
threat model.
### Then power it up — in the app, not in a file ### Then power it up — in the app, not in a file
Keys are upgrades, not prerequisites. When you want one, click the **POWER UP** For a local setup, keys are upgrades, not prerequisites. When you want one,
chip in the bottom-right corner: Provider Settings lists every supported key, click the **POWER UP** chip in the bottom-right corner: Provider Settings lists every supported key,
what it switches on, and where to get it. Paste, hit **SAVE KEYS**, and the app what it switches on, and where to get it. Paste, hit **SAVE KEYS**, and the app
restarts itself with the new capability on. Once everything is configured the restarts itself with the new capability on. Once everything is configured the
chip reads **POWERED UP** — and if a compact layout hides it, `?setup=1` chip reads **POWERED UP** — and if a compact layout hides it, `?setup=1`
@@ -0,0 +1,3 @@
- A terrain request the proxy can only partly answer (a 429 pause, or a point
the upstream returns without a height) now keeps the real heights it has
instead of putting the whole chunk on the geoid (quanticsoul4772, #1456).
@@ -0,0 +1,5 @@
- Radio Next, Previous and the tuning dial no longer start a broadcaster stream
before Play is pressed; while a stream plays, it follows the new station.
Play reads Pause while a stream connects, and a tuner release onto a station
the directory removed or changed no longer leaves a Resume for another
station (gekh, #1474).
+3
View File
@@ -0,0 +1,3 @@
- Write the AI HUD summary in the interface language, led by the HUD's own
translated feed-state warning. A partial layer no longer reads as off or
nominal in the HUD, voice and analyst answers (German, @gekh, #1488).
+22 -11
View File
@@ -3092,9 +3092,10 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> take over under the Cockpit cap of 60. Loading/capped contacts remain 2D, > take over under the Cockpit cap of 60. Loading/capped contacts remain 2D,
> out-of-range contacts use rotation-free dots, and exit restores map treatment. > out-of-range contacts use rotation-free dots, and exit restores map treatment.
> Its Radio popover exposes compact power, > Its Radio popover exposes compact power,
> transport, station, and volume controls. Cockpit Previous/Next preserves > transport, station, and volume controls. Cockpit Previous/Next changes the
> selection, autoplay, and broadcaster fallback audio without starting the > selection without starting the map-view station flights that compete with
> map-view station flights that compete with the first-person camera. These > the first-person camera; like the Context Previous/Next buttons, it plays
> (with broadcaster fallback) only while a stream is already active. These
> are a Cockpit-only accordion: > are a Cockpit-only accordion:
> each static header uses the left accordion's label and divider with a dedicated > each static header uses the left accordion's label and divider with a dedicated
> directional-chevron disclosure button and no full-row hover treatment. Only one > directional-chevron disclosure button and no full-row hover treatment. Only one
@@ -3257,7 +3258,10 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> outlasts its retries, the proxy pauses upstream calls for the `Retry-After` > outlasts its retries, the proxy pauses upstream calls for the `Retry-After`
> (60 seconds when none, at most 10 minutes). A request whose points are all > (60 seconds when none, at most 10 minutes). A request whose points are all
> cached, fresh or stale, is still served; a request with any uncached point > cached, fresh or stale, is still served; a request with any uncached point
> gets 502 and the page uses its geoid fallback for that chunk. Each request > gets 502, whose `partial` lists the real heights it has (null for the rest)
> when it has any, and the page keeps those and uses its geoid fallback only
> for the points without one. The same holds when the upstream answers a
> point with a null height. Each request
> that was waiting on the upstream when the 429 came back logs one warning; > that was waiting on the upstream when the 429 came back logs one warning;
> requests refused during the pause do not log. Client geoid > requests refused during the pause do not log. Client geoid
> fallbacks wait 60 seconds before retrying and self-heal to Re:Earth on the > fallbacks wait 60 seconds before retrying and self-heal to Re:Earth on the
@@ -4736,10 +4740,12 @@ accepted snapshot, and feed telemetry before re-initialization; monotonic
ownership tokens plus the session boundary keep late callbacks from a retired ownership tokens plus the session boundary keep late callbacks from a retired
session inert. session inert.
A tuner drag resolves previews against the single accepted snapshot captured at A tuner drag resolves previews against the single accepted snapshot captured at
pointer-down, even while a newer catalog is admitted. Release starts playback pointer-down, even while a newer catalog is admitted. Like Previous/Next,
only when the current same-ID record still exactly matches the frozen release starts playback only if a stream was active when the drag began;
presentation and stream metadata; removal or replacement reports the channel as otherwise it just selects the station. It commits only when the current same-ID
unavailable and never silently retargets the drag. A cold degraded fallback may record still exactly matches the frozen presentation and stream metadata.
Removal or replacement reports the channel as unavailable and never silently
retargets the drag. A cold degraded fallback may
still populate the directory and globe, but its null accepted generation cannot still populate the directory and globe, but its null accepted generation cannot
populate or begin the tuner. populate or begin the tuner.
@@ -4916,7 +4922,9 @@ before the later control succeeds, only its playback handoff is suppressed; its
result reads enabled state from the manager and audio state from the Radio result reads enabled state from the manager and audio state from the Radio
module. Voice Pause is a playback-only no-op while the layer is disabled; it module. Voice Pause is a playback-only no-op while the layer is disabled; it
never enables Radio and reports a fulfilled-false pause as failure. A never enables Radio and reports a fulfilled-false pause as failure. A
newer-response or user-origin Stop still cancels stale handoff work. newer-response or user-origin Stop still cancels stale handoff work, as do a
user Pause, a user Previous/Next that only selects another station, and a tuner
release onto a station the directory removed or changed mid-drag.
Only a newer user turn or session teardown aborts the complete active-tool set. Only a newer user turn or session teardown aborts the complete active-tool set.
A cancelled turn cannot publish a late Radio playback request. Dedicated Radio A cancelled turn cannot publish a late Radio playback request. Dedicated Radio
controls and generic layer-visibility commands both forward cancellation. controls and generic layer-visibility commands both forward cancellation.
@@ -5405,8 +5413,11 @@ and unreachable upstream (502/504) separately from road geometry.
### AI HUD Summary (June 2026) ### AI HUD Summary (June 2026)
- HUD `SUMMARY` readout requests a five-word intelligence-style summary from `/api/openai/hud-summary` (model `OPENAI_HUD_SUMMARY_MODEL`, default `gpt-5-nano`, minimal reasoning). - HUD `SUMMARY` readout requests a five-word intelligence-style summary from `/api/openai/hud-summary` (model `OPENAI_HUD_SUMMARY_MODEL`, default `gpt-5-nano`, minimal reasoning).
- Input is the live basemap label context (place/street/nearby-place labels + enabled layers) — the model is instructed not to infer from coordinates. - Input: place/street/nearby-place labels, enabled layers and `feedProvenance`, with layer names as the user sees them; outside English the narration note and per-layer state ids are left out (`overall` carries the state). No model call without a place, street or nearby-place label: the deterministic line is shown.
- Output is sanitized to exactly five words; falls back to the deterministic telemetry summary on error/timeout (5s abort); typewriter animation on update. - Language: the HUD sends `locale` (part of the dedupe signature); the proxy keeps a shipped locale, English otherwise. Switching language in place drops the reply and aborts the request in flight, so the next tick asks again.
- Reply: `{summary, feedState}` (`text.format` JSON schema, `feedState` a snapshot state or null). A view that is not nominal needs the matching `feedState`. The HUD adds its own translated tag, state first and layer names last (`STALE | … | LIVE FLIGHTS`), so the warning is never clipped. Outside English a reply falls back when it carries a feed-state id, a translated layer's English name or id, only Latin letters in a non-Latin language, or, with every feed healthy, the UNAVAILABLE word. `get_hud_caption` uses the same check and tag order, in English, and returns `feedState`.
- The model is asked for at most five words; the proxy keeps up to 80 characters, or 20 for Japanese and Chinese, cut at a word boundary unless the first word is longer. It does not count words, since the model reads a name like Rue de Rivoli as one. Errors and the 5 s timeout fall back to the deterministic line; a camera move during typing shows that line instead of the fragment.
- `partial` is a feed state of its own, ranked between `fallback` and `nominal`, so one partial layer, or partial plus nominal, reads partial, not off or nominal (HUD, voice view state, `analyst_query`, `brief`, the agent world packet). Its narration note says coverage is incomplete rather than not live.
- A summary asked for before the camera starts moving never paints over the next view: `moveStart` marks the summary dirty and bumps its revision, as `moveEnd` does, aborts the request in flight and stops a reply still typing; neither a reply nor a fallback line (on an error, the 5 s timeout, the abort, or a failed context build) paints once the revision has moved. A dropped request forgets its signature and a timeout counts as a failure, so the next 15 s tick asks again even over the same place. An interrupted typewriter reply also forgets its signature; fully displayed answers remain reusable. - A summary asked for before the camera starts moving never paints over the next view: `moveStart` marks the summary dirty and bumps its revision, as `moveEnd` does, aborts the request in flight and stops a reply still typing; neither a reply nor a fallback line (on an error, the 5 s timeout, the abort, or a failed context build) paints once the revision has moved. A dropped request forgets its signature and a timeout counts as a failure, so the next 15 s tick asks again even over the same place. An interrupted typewriter reply also forgets its signature; fully displayed answers remain reusable.
### Place-search providers ### Place-search providers
+3
View File
@@ -1112,6 +1112,9 @@
"src/voice/globeResetConfirmation.test.mjs", "src/voice/globeResetConfirmation.test.mjs",
"src/hudDms.test.mjs", "src/hudDms.test.mjs",
"src/hudSummaryGate.test.mjs", "src/hudSummaryGate.test.mjs",
"src/hudSummaryLine.test.mjs",
"src/testSupport/stubMgrs.mjs",
"src/tools/queries/hud-caption-feed-state.test.mjs",
"src/voice/hudPlaceLabels.test.mjs", "src/voice/hudPlaceLabels.test.mjs",
"src/voice/panel/preferences.test.mjs", "src/voice/panel/preferences.test.mjs",
"src/voice/panel/preferencesView.test.mjs", "src/voice/panel/preferencesView.test.mjs",
+1
View File
@@ -485,6 +485,7 @@
"exports": ["./server/providers/openai"], "exports": ["./server/providers/openai"],
"modules": [ "modules": [
"src/i18n/marker.js", "src/i18n/marker.js",
"src/i18n/locale.js",
"src/hudLayoutPolicy.js", "src/hudLayoutPolicy.js",
"server/providers/common/rate-limit.js", "server/providers/common/rate-limit.js",
"server/providers/common/request.js", "server/providers/common/request.js",
+20 -3
View File
@@ -3073,20 +3073,27 @@ async function main() {
expectedSlot: targetIndex, expectedSlot: targetIndex,
previewPlayDelta: previewPlays - playsBefore, previewPlayDelta: previewPlays - playsBefore,
commitPlayDelta: window.__qaRadioPlayCalls.length - playsBefore, commitPlayDelta: window.__qaRadioPlayCalls.length - playsBefore,
audioState: state.audioState,
tuningStatic: state.tuningStatic,
samples, samples,
spread: Math.max(...samples) - Math.min(...samples), spread: Math.max(...samples) - Math.min(...samples),
}; };
}); });
check( check(
'pointer preview never plays and release commits the nearest station once without a needle sweep', 'from a stopped Radio, pointer release only selects the nearest station, silently and without a needle sweep',
tunerDirectRelease.targetId tunerDirectRelease.targetId
&& tunerDirectRelease.selectedId === tunerDirectRelease.targetId && tunerDirectRelease.selectedId === tunerDirectRelease.targetId
&& tunerDirectRelease.sliderSlot === tunerDirectRelease.expectedSlot && tunerDirectRelease.sliderSlot === tunerDirectRelease.expectedSlot
&& tunerDirectRelease.previewPlayDelta === 0 && tunerDirectRelease.previewPlayDelta === 0
&& tunerDirectRelease.commitPlayDelta === 1 && tunerDirectRelease.commitPlayDelta === 0
&& tunerDirectRelease.audioState === 'stopped'
&& !tunerDirectRelease.tuningStatic
&& tunerDirectRelease.spread < 0.5, && tunerDirectRelease.spread < 0.5,
JSON.stringify(tunerDirectRelease), JSON.stringify(tunerDirectRelease),
); );
// The release plays only if a stream was active when the drag began.
await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('radio').module.togglePlayback({ origin: 'user' }));
await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing');
// Earlier cases scroll and resize the directory. Bring the physical drag // Earlier cases scroll and resize the directory. Bring the physical drag
// target back into its scroller before deriving viewport mouse coordinates. // target back into its scroller before deriving viewport mouse coordinates.
await page.$eval('#radio-tuner-slider', (slider) => slider.scrollIntoView({ block: 'center', inline: 'nearest' })); await page.$eval('#radio-tuner-slider', (slider) => slider.scrollIntoView({ block: 'center', inline: 'nearest' }));
@@ -3494,6 +3501,9 @@ async function main() {
}); });
}); });
await sleep(500); await sleep(500);
// Next left the Radio stopped; a release plays only from an active stream.
await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('radio').module.togglePlayback({ origin: 'user' }));
await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing');
const failedTuner = await page.evaluate(async () => { const failedTuner = await page.evaluate(async () => {
const gev = window.__godsEyeView; const gev = window.__godsEyeView;
const slider = document.getElementById('radio-tuner-slider'); const slider = document.getElementById('radio-tuner-slider');
@@ -3692,7 +3702,14 @@ async function main() {
radio.stopPlayback(); radio.stopPlayback();
Array.from({ length: gev.viewer.dataSources.length }, (_, index) => gev.viewer.dataSources.get(index)) Array.from({ length: gev.viewer.dataSources.length }, (_, index) => gev.viewer.dataSources.get(index))
.find((item) => item.name === 'Radio stations').clustering.enabled = true; .find((item) => item.name === 'Radio stations').clustering.enabled = true;
radio.selectStation('00000000-0000-4000-8000-000000000001', { autoplay: false, focus: false }); // Next plays only while a stream is active, so start from a playing station.
radio.selectStation('00000000-0000-4000-8000-000000000001', { autoplay: true, focus: false });
});
await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing');
await page.evaluate(() => {
const gev = window.__godsEyeView;
const camera = gev.viewer.camera;
const radio = gev.dataManager.layers.get('radio').module;
const pool = gev.styleManager._radioControls._radioTunerPool; const pool = gev.styleManager._radioControls._radioTunerPool;
const selectedIndex = pool.findIndex((station) => station.id === radio.getUIState().selected?.id); const selectedIndex = pool.findIndex((station) => station.id === radio.getUIState().selected?.id);
window.__qaRadioExpectedPrimary = pool[(selectedIndex + 1) % pool.length]; window.__qaRadioExpectedPrimary = pool[(selectedIndex + 1) % pool.length];
+69 -13
View File
@@ -1,5 +1,12 @@
import { SNAPSHOT_FEED_STATES } from '../../../src/data/layerSnapshot.js';
import { import {
HUD_SUMMARY_INSTRUCTIONS, HUD_SUMMARY_MAX_CHARS,
HUD_SUMMARY_MAX_UNSPACED_CHARS,
HUD_SUMMARY_RESPONSE_FORMAT,
hudSummaryInput,
hudSummaryInstructions,
hudSummaryIsUnspaced,
hudSummaryLocale,
keylessHudSummaryResponse, keylessHudSummaryResponse,
} from '../../../src/hudSummaryResponse.js'; } from '../../../src/hudSummaryResponse.js';
import { enforceRateLimit, openAiRateLimiter } from './rate-limit.js'; import { enforceRateLimit, openAiRateLimiter } from './rate-limit.js';
@@ -18,14 +25,58 @@ function extractOpenAiResponseText(data) {
.trim(); .trim();
} }
function toFiveWordHudSummary(value) { /** Parse the summary and normalize the canonical feed state. */
return String(value || '') function parseHudSummaryReply(text, locale) {
.replace(/[^\p{L}\p{N}\s-]/gu, ' ') let reply = null;
.trim() try {
reply = JSON.parse(text);
} catch {
/* not JSON: no summary */
}
const feedState = SNAPSHOT_FEED_STATES.includes(reply?.feedState)
? reply.feedState
: null;
return {
summary: toHudSummary(reply?.summary, locale),
feedState,
};
}
function toHudSummary(value, requested) {
const locale = hudSummaryLocale(requested);
const words = String(value || '')
.normalize('NFC')
// Marks, and apostrophes and dots inside a word, belong to it: ar vowel
// signs, fr d'Orléans, ja サンフランシスコ・ベイエリア.
.replace(/[^\p{L}\p{M}\p{N}\s'’·・-]/gu, ' ')
.split(/\s+/) .split(/\s+/)
.filter(Boolean) .map((word) => word.replace(/^['’·・-]+|['’·・-]+$/gu, ''))
.slice(0, 5) .filter(Boolean);
.join(' '); // Characters, not words: the model counts Rue de Rivoli as one word.
const max = hudSummaryIsUnspaced(locale)
? HUD_SUMMARY_MAX_UNSPACED_CHARS
: HUD_SUMMARY_MAX_CHARS;
return capAtWord(words.join(' '), max, locale);
}
/**
* Cut `text` to `max` characters at a word boundary; a first word longer
* than that is cut where the cap falls.
*/
function capAtWord(text, max, locale) {
const graphemes = [
...new Intl.Segmenter(locale, { granularity: 'grapheme' }).segment(text),
];
if (graphemes.length <= max) return text;
const cut = graphemes[max].index;
let end = 0;
for (const { index, segment } of new Intl.Segmenter(locale, {
granularity: 'word',
}).segment(text)) {
if (index + segment.length > cut) break;
end = index + segment.length;
}
return text.slice(0, end).trim() || text.slice(0, cut);
} }
async function handleHudSummary(req, res) { async function handleHudSummary(req, res) {
@@ -77,14 +128,18 @@ async function handleHudSummary(req, res) {
model: model:
process.env.OPENAI_HUD_SUMMARY_MODEL || process.env.OPENAI_HUD_SUMMARY_MODEL ||
OPENAI_HUD_SUMMARY_MODEL_DEFAULT, OPENAI_HUD_SUMMARY_MODEL_DEFAULT,
instructions: HUD_SUMMARY_INSTRUCTIONS, instructions: hudSummaryInstructions(context?.locale),
input: JSON.stringify(context), input: hudSummaryInput(context),
text: { format: HUD_SUMMARY_RESPONSE_FORMAT },
reasoning: { effort: 'minimal' }, reasoning: { effort: 'minimal' },
max_output_tokens: 100, max_output_tokens: 200,
}), }),
}); });
const data = await response.json().catch(() => ({})); const data = await response.json().catch(() => ({}));
const summary = toFiveWordHudSummary(extractOpenAiResponseText(data)); const { summary, feedState } = parseHudSummaryReply(
extractOpenAiResponseText(data),
context?.locale,
);
res.statusCode = response.ok && summary ? 200 : response.status || 502; res.statusCode = response.ok && summary ? 200 : response.status || 502;
res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-store'); res.setHeader('Cache-Control', 'no-store');
@@ -93,6 +148,7 @@ async function handleHudSummary(req, res) {
res.end( res.end(
JSON.stringify({ JSON.stringify({
summary: summary || null, summary: summary || null,
feedState,
// Never relay `data.error.message`: that is OpenAI's own wording, and // Never relay `data.error.message`: that is OpenAI's own wording, and
// it carries request ids, organization hints and quota phrasing. // it carries request ids, organization hints and quota phrasing.
error: response.ok ? null : 'OpenAI HUD summary request failed', error: response.ok ? null : 'OpenAI HUD summary request failed',
@@ -110,4 +166,4 @@ async function handleHudSummary(req, res) {
} }
} }
export { handleHudSummary }; export { handleHudSummary, parseHudSummaryReply };
+68 -3
View File
@@ -17,6 +17,7 @@ export const LAYER_FEED_STATES = Object.freeze([
'loading', 'loading',
'degraded', 'degraded',
'stale', 'stale',
'partial',
'fallback', 'fallback',
'unavailable', 'unavailable',
]); ]);
@@ -27,6 +28,7 @@ export const LAYER_FEED_STATE_LABELS = Object.freeze({
loading: 'LOADING', loading: 'LOADING',
degraded: 'DEGRADED', degraded: 'DEGRADED',
stale: 'STALE', stale: 'STALE',
partial: 'PARTIAL',
fallback: 'FALLBACK', fallback: 'FALLBACK',
unavailable: 'UNAVAILABLE', unavailable: 'UNAVAILABLE',
}); });
@@ -48,8 +50,9 @@ export const FEED_STATE_SEVERITY = Object.freeze({
degraded: 2, degraded: 2,
stale: 3, stale: 3,
fallback: 4, fallback: 4,
nominal: 5, partial: 5,
off: 6, nominal: 6,
off: 7,
}); });
export { layerFeedState } from './feedState.js'; 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. * Pick the most severe feed-state from a list.
* @param {Iterable<string|null|undefined>} states Feed-state strings. * @param {Iterable<string|null|undefined>} 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) { export function worstFeedState(states) {
let worst = null; let worst = null;
@@ -205,6 +208,8 @@ export function feedProvenanceNote(layers = [], overall = null) {
return `${s.name || s.id} is ${String(s.feedState).toUpperCase()}${extras ? ` (${extras})` : ''}`; return `${s.name || s.id} is ${String(s.feedState).toUpperCase()}${extras ? ` (${extras})` : ''}`;
}); });
const body = bits.join('; ') || `${String(overall).toUpperCase()} feed`; const body = bits.join('; ') || `${String(overall).toUpperCase()} feed`;
if (overall === 'partial')
return `${body}. Narrate that coverage is incomplete with any count; do not claim full coverage.`;
return `${body}. Narrate that provenance with any count; do not describe this as live.`; return `${body}. Narrate that provenance with any count; do not describe this as live.`;
} }
@@ -251,3 +256,63 @@ export function viewStateLayerRow(layer, options) {
lastUpdate: snap.lastUpdate, lastUpdate: snap.lastUpdate,
}; };
} }
/**
* The feed state and up to two layer names of a provenance envelope that is
* 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}
*/
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',
)
.sort(
(a, b) =>
FEED_STATE_SEVERITY[a.feedState] - FEED_STATE_SEVERITY[b.feedState],
)
.map((s) => String(s.name || s.id || 'LAYER'))
.slice(0, 2);
return { state: overall, names };
}
const upperCase = (word) => word.toUpperCase();
/**
* `STATE | text | LAYERS`: the state leads so a narrow readout never clips
* it; the layer names trail so they never push out the text.
* @param {string} text
* @param {{ state: string, names: string[] }|null} tag From `provenanceTag`.
* @param {(state: string) => string} [stateLabel]
* @param {(name: string) => string} [layerLabel]
* @returns {string}
*/
export function provenanceLine(
text,
tag,
stateLabel = upperCase,
layerLabel = upperCase,
) {
if (!tag) return text;
return [stateLabel(tag.state), text, tag.names.map(layerLabel).join('/')]
.filter(Boolean)
.join(' | ');
}
/**
* Require a matching feedState for warning states: 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 response?.feedState === state;
}
+52 -1
View File
@@ -11,6 +11,7 @@ import {
layerFeedState, layerFeedState,
layerSnapshot, layerSnapshot,
layerSnapshots, layerSnapshots,
provenanceTag,
snapshotAgeLabel, snapshotAgeLabel,
snapshotAgeSec, snapshotAgeSec,
viewStateLayerRow, viewStateLayerRow,
@@ -90,7 +91,15 @@ test('layerFeedState matches the chip classifier for every honesty class', () =>
test('chip labels and severity cover every feed-state the envelope can emit', () => { test('chip labels and severity cover every feed-state the envelope can emit', () => {
assert.deepEqual( assert.deepEqual(
[...LAYER_FEED_STATES], [...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']); assert.deepEqual([...SNAPSHOT_FEED_STATES], [...LAYER_FEED_STATES, 'off']);
for (const state of LAYER_FEED_STATES) { for (const state of LAYER_FEED_STATES) {
@@ -107,6 +116,33 @@ test('chip labels and severity cover every feed-state the envelope can emit', ()
assert.equal(worstFeedState([]), null); 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');
const note = feedProvenanceEnvelope([partial], { now: NOW }).note;
assert.match(note, /coverage is incomplete/);
assert.doesNotMatch(note, /do not describe this as live/);
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', () => { test('a disabled layer is off even when leftover stats would read stale', () => {
const snap = layerSnapshot( const snap = layerSnapshot(
{ {
@@ -258,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 - 240_000, NOW), '4m ago');
assert.equal(snapshotAgeLabel(NOW - 7_200_000, NOW), '2h 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'],
});
});
+15 -74
View File
@@ -51,6 +51,7 @@ import {
radioViewIsGlobal, radioViewIsGlobal,
stationMatchesRadioCategory, stationMatchesRadioCategory,
} from './radio.js'; } from './radio.js';
import { radioStationRow, withRadioLayer } from '../testSupport/radioLayerHarness.mjs';
const stations = [ const stations = [
{ id: 'news', tags: ['News', 'Weather Radio', 'air traffic'] }, { id: 'news', tags: ['News', 'Weather Radio', 'air traffic'] },
@@ -883,88 +884,33 @@ test('tuner drag keeps one immutable catalog resolution through refresh and rele
}); });
test('failed exact tuner release cannot consume a stale playback fallback', async () => { test('failed exact tuner release cannot consume a stale playback fallback', async () => {
const originalFetch = globalThis.fetch;
const originalAudio = globalThis.Audio;
const playUrls = [];
const stationRows = [ const stationRows = [
{ radioStationRow({
id: '00000000-0000-4000-8000-000000000071', id: '00000000-0000-4000-8000-000000000071',
name: 'Exact target', name: 'Exact target',
lat: 30,
lon: -97,
streamUrl: 'https://radio.example.com/exact.mp3', streamUrl: 'https://radio.example.com/exact.mp3',
homepage: null, }),
tags: ['news'], radioStationRow({
languages: ['English'],
state: 'Texas',
country: 'United States',
countryCode: 'US',
metadataTrust: 'untrusted-community',
codec: 'MP3',
bitrate: 128,
},
{
id: '00000000-0000-4000-8000-000000000072', id: '00000000-0000-4000-8000-000000000072',
name: 'Stale fallback', name: 'Stale fallback',
lat: 31, lat: 31,
lon: -98, lon: -98,
streamUrl: 'https://radio.example.com/fallback.mp3', streamUrl: 'https://radio.example.com/fallback.mp3',
homepage: null, }),
tags: ['news'],
languages: ['English'],
state: 'Texas',
country: 'United States',
countryCode: 'US',
metadataTrust: 'untrusted-community',
codec: 'MP3',
bitrate: 128,
},
]; ];
globalThis.Audio = class FakeAudio { await withRadioLayer({
constructor() {
this.volume = 0.8;
this.src = '';
}
addEventListener() {}
pause() {}
play() {
playUrls.push(this.src);
if (this.src === stationRows[0].streamUrl) {
return Promise.reject(new DOMException('Exact target unavailable', 'NotSupportedError'));
}
return Promise.resolve();
}
removeAttribute() { this.src = ''; }
load() {}
};
globalThis.fetch = async (url) => String(url).startsWith('/api/radio/click/')
? { ok: true }
: new Response(JSON.stringify({
stations: stationRows, stations: stationRows,
stale: false, failingUrls: [stationRows[0].streamUrl],
degraded: false, }, async ({ playUrls }) => {
acceptedGeneration: 1, // A playing stream, so the release plays.
catalogInstance: 'qa-instance-a', assert.equal(radioLayer.selectStation(stationRows[1].id, { autoplay: true }), true);
updatedAt: new Date().toISOString(), await new Promise((resolve) => setTimeout(resolve));
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
const viewer = {
camera: { positionWC: { x: 7_000_000, y: 0, z: 0 } },
scene: { canvas: { disableRootEvents: true, onwheel: null, addEventListener() {}, removeEventListener() {} } },
dataSources: { add() {}, remove() {} },
entities: { add(entity) { return entity; }, remove() {} },
};
radioLayer.destroy();
try {
radioLayer.init(viewer);
radioLayer.enable();
await radioLayer.update();
assert.equal(radioLayer.selectStation(stationRows[0].id, { autoplay: false }), true);
assert.equal(radioLayer.cycleStation(1, { assert.equal(radioLayer.cycleStation(1, {
stationIds: stationRows.map(({ id }) => id), stationIds: stationRows.map(({ id }) => id),
autoplay: false, autoplay: false,
}), true, 'non-playing cycle creates the stale-fallback precondition'); fallback: true,
}), true, 'a voice-style prepared cycle creates the stale-fallback precondition');
playUrls.length = 0;
assert.equal(radioLayer.beginTuning(), true); assert.equal(radioLayer.beginTuning(), true);
assert.equal(radioLayer.previewTuningStation(stationRows[0].id, { rotate: false }), true); assert.equal(radioLayer.previewTuningStation(stationRows[0].id, { rotate: false }), true);
assert.equal(radioLayer.commitTuningStation(stationRows[0].id, { origin: 'user' }).ok, true); assert.equal(radioLayer.commitTuningStation(stationRows[0].id, { origin: 'user' }).ok, true);
@@ -975,12 +921,7 @@ test('failed exact tuner release cannot consume a stale playback fallback', asyn
assert.equal(state.audioState, 'error'); assert.equal(state.audioState, 'error');
assert.equal(state.tuningStatic, true); assert.equal(state.tuningStatic, true);
assert.equal(state.tuningAwaitingStationId, stationRows[0].id); assert.equal(state.tuningAwaitingStationId, stationRows[0].id);
} finally { });
globalThis.fetch = originalFetch;
if (originalAudio === undefined) delete globalThis.Audio;
else globalThis.Audio = originalAudio;
radioLayer.destroy();
}
}); });
test('tuner cancellation restores the frozen start marker after catalog removal', async () => { test('tuner cancellation restores the frozen start marker after catalog removal', async () => {
+417
View File
@@ -0,0 +1,417 @@
// Radio selection, fallbacks and voice handoffs on the real layer and its published view.
import assert from 'node:assert/strict';
import test from 'node:test';
import { radioLayer } from './radio.js';
import { setLocale } from '../i18n/index.js';
import { renderRadioState } from '../ui/radioPresentation.js';
import { radioStationRow, withRadioLayer } from '../testSupport/radioLayerHarness.mjs';
const READY = 'Ready — playback starts only from your action';
const ids = [1, 2, 3].map((n) => `00000000-0000-4000-8000-00000000009${n}`);
const stations = ids.map((id, index) => radioStationRow({
id,
name: `Station ${'ABC'[index]}`,
lat: 30 + index,
lon: -97 - index,
streamUrl: `https://radio.example.com/${'abc'[index]}.mp3`,
}));
const withLayer = (failingUrls, body) => withRadioLayer({ stations, failingUrls }, body);
const settle = () => new Promise((resolve) => setTimeout(resolve));
// What the Radio panel and the Cockpit player show for a state.
function render(state) {
const previousDocument = globalThis.document;
globalThis.document = { getElementById: () => null, activeElement: null };
try {
let view = null;
const host = {
destroyed: false,
_radioPanel: {},
store: { publish: (published) => (view = published), publishTuner() {} },
actions: {
getLifecycle: () => null,
preservePanelStateDuringClear: () => false,
setPanelCollapsed() {},
scheduleLayout() {},
},
};
renderRadioState.call(host, state);
return {
status: view.playback.text,
error: Boolean(view.playback.error),
button: view.transport.play.text,
stopDisabled: view.transport.stopDisabled,
cockpit: view.cockpit.play.glyph,
};
} finally {
if (previousDocument === undefined) delete globalThis.document;
else globalThis.document = previousDocument;
}
}
const readyToPlay = { status: READY, error: false, button: 'PLAY', stopDisabled: true, cockpit: '▶' };
test('Next before Play only selects, and Play tries just that station', async () => {
await withLayer([stations[1].streamUrl], async ({ playUrls }) => {
radioLayer.selectStation(ids[0], { autoplay: false });
assert.equal(radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, origin: 'user' }), true);
await settle();
assert.deepEqual(playUrls, []);
assert.equal(radioLayer.getUIState().audioState, 'stopped');
await radioLayer.togglePlayback({ origin: 'user' });
await settle();
const state = radioLayer.getUIState();
assert.deepEqual(playUrls, [stations[1].streamUrl], 'no fallback picked by the silent step');
assert.equal(state.selected?.id, ids[1]);
assert.equal(state.audioState, 'error');
});
});
test('Next after a failed stream leaves the new station ready to play, without the old error', async () => {
setLocale('en');
await withLayer([stations[0].streamUrl], async ({ playUrls }) => {
radioLayer.selectStation(ids[0], { autoplay: true, origin: 'user' });
await settle();
assert.equal(radioLayer.getUIState().audioState, 'error');
assert.equal(radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, origin: 'user' }), true);
const state = radioLayer.getUIState();
assert.equal(state.selected?.id, ids[1]);
assert.equal(state.audioState, 'stopped');
assert.equal(state.playingStationId, null);
assert.deepEqual(render(state), readyToPlay);
await radioLayer.togglePlayback({ origin: 'user' });
assert.deepEqual(playUrls, [stations[0].streamUrl, stations[1].streamUrl]);
assert.equal(radioLayer.getUIState().playingStationId, ids[1]);
});
});
test('Next while paused releases the paused stream, and Play starts the new station', async () => {
setLocale('en');
await withLayer([], async ({ playUrls }) => {
radioLayer.selectStation(ids[0], { autoplay: true, origin: 'user' });
await settle();
await radioLayer.togglePlayback({ origin: 'user' });
assert.equal(radioLayer.getUIState().audioState, 'paused');
radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, origin: 'user' });
const state = radioLayer.getUIState();
assert.equal(state.selected?.id, ids[1]);
assert.equal(state.audioState, 'stopped');
assert.equal(state.playingStationId, null);
assert.deepEqual(render(state), readyToPlay);
await radioLayer.togglePlayback({ origin: 'user' });
assert.deepEqual(playUrls, [stations[0].streamUrl, stations[1].streamUrl]);
assert.equal(radioLayer.getUIState().audioState, 'playing');
assert.equal(radioLayer.getUIState().playingStationId, ids[1]);
});
});
test('a catalog refresh after a paused Next cannot bring back Resume for an unnamed station', async () => {
setLocale('en');
await withLayer([], async ({ catalog }) => {
radioLayer.selectStation(ids[0], { autoplay: true, origin: 'user' });
await settle();
await radioLayer.togglePlayback({ origin: 'user' });
radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, origin: 'user' });
catalog.rows = stations.filter(({ id }) => id !== ids[1]);
catalog.generation = 2;
await radioLayer.update();
const state = radioLayer.getUIState();
assert.equal(state.selected, null);
assert.equal(state.audioState, 'stopped');
const shown = render(state);
assert.equal(shown.status, READY);
assert.equal(shown.button, 'PLAY');
});
});
test('a voice station request after a voice pause keeps the fallback of that request', async () => {
const failing = [];
await withLayer(failing, async ({ playUrls }) => {
radioLayer.selectStation(ids[0], { autoplay: true, origin: 'user' });
await settle();
radioLayer.pause({ origin: 'voice-duck' });
playUrls.length = 0;
// Anchor on the far station so the paused one is not the first match.
const first = radioLayer.selectRequestedStation(
{ categoryId: 'all', anchor: { lat: 32, lon: -99 } },
{ autoplay: false },
);
failing.push(first.streamUrl);
await radioLayer.play({ origin: 'voice' });
await settle();
await settle();
assert.equal(playUrls.length, 2);
assert.equal(playUrls[0], first.streamUrl);
assert.equal(radioLayer.getUIState().audioState, 'playing');
});
});
test('a cancelled voice step leaves no fallback for a later Play from the panel', async () => {
await withLayer([stations[1].streamUrl], async ({ playUrls }) => {
radioLayer.selectStation(ids[0], { autoplay: false });
radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, fallback: true, origin: 'voice' });
// The handoff is cancelled (barge-in), so voice never starts playback.
await settle();
await radioLayer.togglePlayback({ origin: 'user' });
await settle();
await settle();
const state = radioLayer.getUIState();
assert.deepEqual(playUrls, [stations[1].streamUrl]);
assert.equal(state.selected?.id, ids[1]);
assert.equal(state.audioState, 'error');
});
});
test('only a user step to another station tells voice to drop its prepared playback', async () => {
await withLayer([], async () => {
const controls = [];
const unsubscribe = radioLayer.subscribePlaybackControls((event) => controls.push(event));
try {
radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, fallback: true, origin: 'voice' });
radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, origin: 'user' });
radioLayer.cycleStation(1, { stationIds: [ids[1]], autoplay: false, origin: 'user' });
} finally {
unsubscribe();
}
assert.equal(radioLayer.getUIState().selected?.id, ids[1]);
assert.deepEqual(controls.map(({ action, origin }) => [action, origin]), [['select', 'user']]);
});
});
test('an unavailable tuner release stops the paused stream once, and Play still falls back', async () => {
setLocale('en');
const failing = [];
await withLayer(failing, async ({ playUrls, catalog }) => {
radioLayer.selectStation(ids[2], { autoplay: true, origin: 'user' });
await settle();
assert.equal(radioLayer.beginTuning(), true);
radioLayer.previewTuningStation(ids[1], { rotate: false });
// A directory refresh changes the station under the needle mid-drag.
catalog.rows = stations.map((row) => row.id === ids[1]
? { ...row, streamUrl: 'https://radio.example.com/b2.mp3' }
: row);
catalog.generation = 2;
await radioLayer.update();
const states = [];
const controls = [];
const unsubscribe = radioLayer.subscribe((state) => states.push(state));
const unsubscribeControls = radioLayer.subscribePlaybackControls((event) => controls.push(event));
states.length = 0;
try {
assert.equal(radioLayer.commitTuningStation(ids[1], { origin: 'user' }).reason, 'station-unavailable');
} finally {
unsubscribe();
unsubscribeControls();
}
assert.equal(states.length, 1);
assert.equal(states[0].tuningUnavailableStationId, ids[1]);
assert.deepEqual(controls.map(({ action, origin }) => [action, origin]), [['stop', 'user']]);
const state = radioLayer.getUIState();
assert.equal(state.selected, null);
assert.equal(state.audioState, 'stopped');
assert.equal(state.playingStationId, null);
assert.equal(render(state).button, 'PLAY');
playUrls.length = 0;
// Play picks the nearest station; its broadcaster fails.
const play = globalThis.Audio.prototype.play;
globalThis.Audio.prototype.play = function playFirstFails() {
if (!failing.length) failing.push(this.src);
return play.call(this);
};
try {
await radioLayer.togglePlayback({ origin: 'user' });
await settle();
await settle();
} finally {
globalThis.Audio.prototype.play = play;
}
assert.equal(playUrls.length, 2);
assert.equal(radioLayer.getUIState().audioState, 'playing');
});
});
test('while a stream connects the transport buttons offer Pause, because a click pauses', () => {
setLocale('en');
const shown = render({
enabled: true,
audioState: 'loading',
audioError: null,
selected: { id: 'a', name: 'Station A' },
selectedIndex: 0,
filteredCount: 1,
stationCount: 1,
volume: 1,
});
assert.equal(shown.status, 'Connecting directly to broadcaster…');
assert.equal(shown.button, 'PAUSE');
assert.equal(shown.cockpit, 'Ⅱ');
});
test('a dial release plays only if a stream was active when the drag began', async () => {
await withLayer([], async ({ playUrls }) => {
radioLayer.selectStation(ids[0], { autoplay: false });
assert.equal(radioLayer.beginTuning(), true);
radioLayer.previewTuningStation(ids[1], { rotate: false });
assert.equal(radioLayer.commitTuningStation(ids[1], { origin: 'user' }).ok, true);
await settle();
let state = radioLayer.getUIState();
assert.deepEqual(playUrls, []);
assert.equal(state.selected?.id, ids[1]);
assert.equal(state.audioState, 'stopped');
assert.equal(state.tuningStatic, false);
await radioLayer.togglePlayback({ origin: 'user' });
assert.equal(radioLayer.beginTuning(), true);
radioLayer.previewTuningStation(ids[2], { rotate: false });
radioLayer.commitTuningStation(ids[2], { origin: 'user' });
await settle();
state = radioLayer.getUIState();
assert.deepEqual(playUrls, [stations[1].streamUrl, stations[2].streamUrl]);
assert.equal(state.audioState, 'playing');
assert.equal(state.playingStationId, ids[2]);
});
});
// Like Chrome, resume() flips `state` only once the device starts, and suspend()
// on a context that is still suspended resolves at once without effect.
const RESUME_MS = 5;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function withFakeAudioContext(body) {
const log = { contexts: [], sources: [] };
const node = () => ({ connect() {}, disconnect() {} });
class FakeAudioContext {
constructor() {
Object.assign(this, { state: 'suspended', sampleRate: 8000, currentTime: 0, destination: {} });
log.contexts.push(this);
}
resume() {
if (this.state === 'running') return Promise.resolve();
return new Promise((resolve) => setTimeout(() => {
if (this.state !== 'closed') this.state = 'running';
resolve();
}, RESUME_MS));
}
suspend() {
if (this.state === 'running') this.state = 'suspended';
return Promise.resolve();
}
close() { this.state = 'closed'; return Promise.resolve(); }
createBuffer(_channels, length) { return { getChannelData: () => new Float32Array(length) }; }
createBufferSource() {
const source = { ...node(), started: false, stopped: false, start() { this.started = true; }, stop() { this.stopped = true; } };
log.sources.push(source);
return source;
}
createBiquadFilter() { return { ...node(), frequency: { value: 0 }, Q: { value: 0 } }; }
createGain() {
return { ...node(), gain: { value: 0, cancelScheduledValues() {}, setValueAtTime() {}, linearRampToValueAtTime() {} } };
}
}
const original = globalThis.AudioContext;
globalThis.AudioContext = FakeAudioContext;
return Promise.resolve(body(log)).finally(() => {
if (original === undefined) delete globalThis.AudioContext;
else globalThis.AudioContext = original;
});
}
test('a silent dial release stops the tuner noise and suspends its audio context, even before resume() settles', async () => {
await withFakeAudioContext(async ({ contexts, sources }) => {
await withLayer([], async ({ playUrls }) => {
radioLayer.selectStation(ids[0], { autoplay: false });
assert.equal(radioLayer.beginTuning(), true);
radioLayer.previewTuningStation(ids[1], { rotate: false });
assert.equal(radioLayer.commitTuningStation(ids[1], { origin: 'user' }).ok, true);
await sleep(RESUME_MS * 4);
assert.deepEqual(playUrls, []);
assert.equal(sources[0].started, true);
assert.equal(sources[0].stopped, true, 'noise source still looping');
assert.equal(contexts[0].state, 'suspended', 'audio context still running');
});
});
});
test('a second gesture before the first resume() settles keeps its noise graph', async () => {
await withFakeAudioContext(async ({ contexts, sources }) => {
await withLayer([], async () => {
radioLayer.selectStation(ids[0], { autoplay: false });
radioLayer.beginTuning();
radioLayer.commitTuningStation(ids[0], { origin: 'user' });
assert.equal(radioLayer.beginTuning(), true);
await sleep(RESUME_MS * 4);
assert.equal(contexts[0].state, 'running', 'second gesture lost its context');
assert.equal(sources.at(-1).stopped, false, 'second gesture lost its source');
radioLayer.commitTuningStation(ids[1], { origin: 'user' });
await sleep(RESUME_MS * 4);
assert.equal(sources.at(-1).stopped, true);
assert.equal(contexts[0].state, 'suspended');
});
});
});
test('a dial release from a paused stream only selects, without static', async () => {
await withFakeAudioContext(async ({ contexts }) => {
await withLayer([], async ({ playUrls }) => {
radioLayer.selectStation(ids[0], { autoplay: true, origin: 'user' });
await settle();
await radioLayer.togglePlayback({ origin: 'user' });
assert.equal(radioLayer.getUIState().audioState, 'paused');
assert.equal(radioLayer.beginTuning(), true);
await sleep(RESUME_MS * 4);
radioLayer.previewTuningStation(ids[1], { rotate: false });
radioLayer.commitTuningStation(ids[1], { origin: 'user' });
await sleep(RESUME_MS * 4);
const state = radioLayer.getUIState();
assert.deepEqual(playUrls, [stations[0].streamUrl]);
assert.equal(state.selected?.id, ids[1]);
assert.equal(state.audioState, 'stopped');
assert.equal(state.tuningStatic, false);
assert.equal(state.tuningAwaitingStationId, null);
assert.equal(contexts[0].state, 'suspended');
});
});
});
test('a dial release while a stream is still connecting plays the new station', async () => {
await withLayer([], async ({ playUrls }) => {
radioLayer.selectStation(ids[0], { autoplay: true, origin: 'user' });
assert.equal(radioLayer.getUIState().audioState, 'loading');
assert.equal(radioLayer.beginTuning(), true);
radioLayer.previewTuningStation(ids[1], { rotate: false });
radioLayer.commitTuningStation(ids[1], { origin: 'user' });
await settle();
const state = radioLayer.getUIState();
assert.equal(playUrls.at(-1), stations[1].streamUrl);
assert.equal(state.audioState, 'playing');
assert.equal(state.playingStationId, ids[1]);
});
});
test('a silent user step drops the fallback a voice step left behind', async () => {
await withLayer(stations.map(({ streamUrl }) => streamUrl), async ({ playUrls }) => {
radioLayer.selectStation(ids[0], { autoplay: false });
// Voice steps to B and keeps C as its fallback; the user steps back to A.
radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, fallback: true, origin: 'voice' });
radioLayer.cycleStation(-1, { stationIds: ids, autoplay: false, origin: 'user' });
await radioLayer.play({ origin: 'voice' });
await settle();
await settle();
assert.deepEqual(playUrls, [stations[0].streamUrl]);
assert.equal(radioLayer.getUIState().audioState, 'error');
});
});
test('a silent dial release drops the fallback a voice step left behind', async () => {
await withLayer(stations.map(({ streamUrl }) => streamUrl), async ({ playUrls }) => {
radioLayer.selectStation(ids[0], { autoplay: false });
radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, fallback: true, origin: 'voice' });
assert.equal(radioLayer.beginTuning(), true);
radioLayer.previewTuningStation(ids[0], { rotate: false });
radioLayer.commitTuningStation(ids[0], { origin: 'user' });
await radioLayer.play({ origin: 'voice' });
await settle();
await settle();
assert.deepEqual(playUrls, [stations[0].streamUrl]);
assert.equal(radioLayer.getUIState().audioState, 'error');
});
});
+45
View File
@@ -19,6 +19,7 @@ import assert from 'node:assert/strict';
import { import {
resolveEllipsoidalGround, resolveEllipsoidalGround,
cachedEllipsoidalGround, cachedEllipsoidalGround,
cachedRealEllipsoidalGround,
} from './terrainHeights.js'; } from './terrainHeights.js';
const AUSTIN = { lat: 30.2672, lon: -97.7431 }; const AUSTIN = { lat: 30.2672, lon: -97.7431 };
@@ -362,3 +363,47 @@ test('resolveEllipsoidalGround: null and string heights cannot become real zeroe
); );
} }
}); });
test('resolveEllipsoidalGround: a 502 keeps the real heights it carries and falls back only for the rest', async () => {
const coords = [
{ lat: 62.001, lon: -150.001, sourceOrthometricM: 10 },
{ lat: 62.002, lon: -150.002, sourceOrthometricM: 10 },
{ lat: 62.003, lon: -150.003, sourceOrthometricM: 10 },
];
await withFakeFetch(
async () => ({
ok: false,
status: 502,
json: async () => ({
error: 'terrain heights fetch failed and no cache available for every point',
partial: [{ ellipsoid: 412.5 }, null, { ellipsoid: 'x' }],
}),
}),
async () => {
const out = await resolveEllipsoidalGround(coords);
assert.deepEqual(out[0], { ellipsoid: 412.5, source: 'reearth' });
assert.equal(out[1].source, 'geoid-fallback');
assert.equal(out[2].source, 'geoid-fallback', 'a non-numeric height is not real');
}
);
assert.equal(cachedRealEllipsoidalGround(62.001, -150.001), 412.5);
assert.equal(cachedRealEllipsoidalGround(62.002, -150.002), null);
});
test('resolveEllipsoidalGround: a partial list of the wrong length is ignored', async () => {
const coords = [
{ lat: 63.001, lon: -151.001 },
{ lat: 63.002, lon: -151.002 },
];
await withFakeFetch(
async () => ({
ok: false,
status: 502,
json: async () => ({ error: 'x', partial: [{ ellipsoid: 7 }] }),
}),
async () => {
const out = await resolveEllipsoidalGround(coords);
assert.deepEqual(out.map((r) => r.source), ['geoid-fallback', 'geoid-fallback']);
}
);
});
+10 -1
View File
@@ -190,7 +190,8 @@ export async function fetchTerrainChunkWithRetry(
* Resolve a requested point batch against a per-point cache and a missing-only * Resolve a requested point batch against a per-point cache and a missing-only
* fetcher. Results are reconstructed by canonical key in exact request order, * fetcher. Results are reconstructed by canonical key in exact request order,
* including duplicates. Stale values remain eligible only when refresh fails. * including duplicates. Stale values remain eligible only when refresh fails.
* If any position has no real height, return 502 with no fabricated result. * If any position has no real height, return 502 with no fabricated result;
* the body's `partial` lists the real heights it has, null for the rest.
* *
* @param {object} options * @param {object} options
* @param {Array<[number, number]>} options.points * @param {Array<[number, number]>} options.points
@@ -269,11 +270,19 @@ export async function resolveTerrainHeightRequest({
return entry && validTerrainResult(entry.result) ? entry.result : null; return entry && validTerrainResult(entry.result) ? entry.result : null;
}); });
if (results.some((result) => result == null)) { if (results.some((result) => result == null)) {
// The real heights this request does have, null elsewhere, so the page
// keeps them and uses its geoid only for the rest. Named `partial`, not
// `results`, so no reader mistakes it for a complete answer; left out when
// no point has a height.
const partial = results.some((result) => result != null)
? { partial: results }
: {};
return { return {
status: 502, status: 502,
body: { body: {
error: error:
'terrain heights fetch failed and no cache available for every point', 'terrain heights fetch failed and no cache available for every point',
...partial,
}, },
cacheChanged, cacheChanged,
upstreamError, upstreamError,
+28
View File
@@ -268,3 +268,31 @@ test('terrain parser rejects out-of-range WGS84 points and accepts inclusive bou
[0, 0], [0, 0],
]); ]);
}); });
test('a 502 lists the real heights it has, null for the rest', async () => {
const cache = new Map([
[terrainPointKey([1, 1]), { at: 0, result: result('stale-a', 11) }],
]);
const response = await resolveTerrainHeightRequest({
points: [
[1, 1],
[2, 2],
[1, 1],
],
cache,
ttlMs: 100,
now: () => 1000,
fetchMissing: async () => {
const error = new Error('HTTP 429');
error.status = 429;
error.coolingDown = true;
throw error;
},
});
assert.equal(response.status, 502);
assert.equal('results' in response.body, false);
assert.deepEqual(
response.body.partial.map((item) => item?.id ?? null),
['stale-a', null, 'stale-a'],
);
});
+152 -40
View File
@@ -32,17 +32,31 @@ import {
} from './data/geoid.js'; } from './data/geoid.js';
import { getBasemapLabelContext } from './voice/gevActions.js'; import { getBasemapLabelContext } from './voice/gevActions.js';
import { import {
hudSummaryMatchesProvenance, hasWord,
hudSummaryHasEnglish,
hudSummaryIsLatin,
hudSummaryLayerContext, hudSummaryLayerContext,
hudTelemetryProvenance, hudTelemetryProvenance,
isHudSummaryUnconfigured, isHudSummaryUnconfigured,
} from './hudSummaryResponse.js'; } from './hudSummaryResponse.js';
import {
hudSummaryMatchesProvenance,
provenanceLine,
provenanceTag,
} from './data/layerSnapshot.js';
import { import {
applyHudUiTheme, applyHudUiTheme,
DEFAULT_HUD_LAYOUT, DEFAULT_HUD_LAYOUT,
normalizeHudLayout, normalizeHudLayout,
} from './hudLayouts.js'; } from './hudLayouts.js';
import { formatNumber, msg, onLocaleChange, t } from './i18n/index.js'; import {
formatNumber,
getLocale,
msg,
onLocaleChange,
t,
} from './i18n/index.js';
import { isRtlLocale } from './i18n/locale.js';
import { localizeLayerName } from './ui/layerPanel.js'; import { localizeLayerName } from './ui/layerPanel.js';
import { isEmbeddedInline } from './app/embed.js'; import { isEmbeddedInline } from './app/embed.js';
import { subscribeSelectedVessel } from './hudReadouts.js'; import { subscribeSelectedVessel } from './hudReadouts.js';
@@ -108,23 +122,58 @@ const FEED_STATE_LABELS = Object.freeze({
unavailable: msg('UNAVAILABLE'), unavailable: msg('UNAVAILABLE'),
}); });
/** A summary line with its warning in the active language. */
function withProvenance(text, provenance) {
return provenanceLine(
text,
provenance,
(state) =>
FEED_STATE_LABELS[state]
? t(FEED_STATE_LABELS[state])
: state.toUpperCase(),
(name) => localizeLayerName(name).toUpperCase(),
);
}
/** /**
* The summary's feed-state suffix in the active language, e.g. * The AI line. In an RTL locale a leading right-to-left mark keeps a reply
* `UNAVAILABLE: LIVE AIS VESSELS`. State ids and layer names stay English in * that opens with a Latin name (Times Square …) reading right to left.
* layer data; only this line translates them.
*/ */
function localizedProvenanceTag(provenance) { function aiSummaryLine(summary, warning) {
if (!provenance) return null; const line = withProvenance(summary, warning);
const state = FEED_STATE_LABELS[provenance.state] return isRtlLocale(getLocale()) ? `\u200f${line}` : line;
? t(FEED_STATE_LABELS[provenance.state]) }
: provenance.state.toUpperCase();
if (!provenance.names.length) return state; /**
return t('{state} {layers}', { * What a summary outside English must not carry: feed-state ids, translated
state, * layers' English names and ids, and with every feed healthy the
layers: provenance.names * UNAVAILABLE word. A state word the locale keeps in English (de FALLBACK)
.map((name) => localizeLayerName(name).toUpperCase()) * and words of a place label (Radio City, Loading Dock) are fine; so is a
.join('/'), * 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(' ');
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 &&
!hasWord(places, state),
),
...(context.englishLayerWords || []).filter(
(word) =>
!hasWord(places, word) && (!latin || /[^\p{L}\p{N}]/u.test(word)),
),
...(!overall || overall === 'nominal'
? [t(FEED_STATE_LABELS.unavailable)]
: []),
];
} }
/** The name shown for a _viewBand() id. */ /** The name shown for a _viewBand() id. */
@@ -221,7 +270,7 @@ export class IntelHUD {
this._placeLabels = null; this._placeLabels = null;
this._lastSummarySignature = ''; this._lastSummarySignature = '';
/** The reply painted for `_lastSummarySignature`, when it was accepted. */ /** The reply painted for `_lastSummarySignature`, when it was accepted. */
this._lastSummaryText = null; this._lastSummaryReply = null;
this._summaryRevision = 0; this._summaryRevision = 0;
// One-shot guards so the very first summary lands immediately instead of // One-shot guards so the very first summary lands immediately instead of
// waiting for the 15s interval tick: B) swap the "Awaiting telemetry..." // waiting for the 15s interval tick: B) swap the "Awaiting telemetry..."
@@ -250,12 +299,17 @@ export class IntelHUD {
// the camera has moved to. // the camera has moved to.
this._onCameraMoveStart = () => { this._onCameraMoveStart = () => {
this._markSummaryDirty(); this._markSummaryDirty();
if (this._summaryRequest || this._summaryTypingInterval) { const typing = Boolean(this._summaryTypingInterval);
if (this._summaryRequest || typing) {
// Cancelled requests and partially typed replies are not reusable answers. // Cancelled requests and partially typed replies are not reusable answers.
this._lastSummarySignature = null; this._lastSummarySignature = null;
} }
this._summaryRequest?.abort(); this._summaryRequest?.abort();
this._stopSummaryTyping(); this._stopSummaryTyping();
// A half-typed reply would stay until moveEnd, which a camera that
// keeps moving may not send for a long time: show the plain line.
if (typing && this._latestMetrics)
this._setSummaryText(this._composeSummary(), false);
}; };
this._onCameraMoveEnd = () => { this._onCameraMoveEnd = () => {
this._markSummaryDirty(); this._markSummaryDirty();
@@ -295,12 +349,12 @@ export class IntelHUD {
this._startTimers(); this._startTimers();
} }
/** /** Show the HUD's words in the new locale; drop AI text in the old one. */
* Show the HUD's own words in the new locale. The deterministic summary
* is composed again; the AI reply does not depend on the locale, so the
* next tick shows the one already received rather than asking again.
*/
_relocalize() { _relocalize() {
this._markSummaryDirty();
this._lastSummarySignature = null;
this._lastSummaryReply = null;
this._summaryRequest?.abort();
this._present({ this._present({
mode: MODE_LABELS[this._currentStyle] mode: MODE_LABELS[this._currentStyle]
? t(MODE_LABELS[this._currentStyle]) ? t(MODE_LABELS[this._currentStyle])
@@ -763,8 +817,8 @@ export class IntelHUD {
LOCALIZED_LOCALITY, LOCALIZED_LOCALITY,
); );
const provenance = localizedProvenanceTag( const provenance = hudTelemetryProvenance(
hudTelemetryProvenance(this._dataManager?.getAll?.() || []), this._dataManager?.getAll?.() || [],
); );
// ALT, SUN and ONA are sensor codes, as in the corner readouts. // ALT, SUN and ONA are sensor codes, as in the corner readouts.
const line = t( const line = t(
@@ -781,7 +835,7 @@ export class IntelHUD {
zone: localTag, zone: localTag,
}, },
); );
return provenance ? `${line} | ${provenance}` : line; return withProvenance(line, provenance);
} }
/** Stop a summary still typing out, leaving what it has typed so far. */ /** Stop a summary still typing out, leaving what it has typed so far. */
@@ -854,12 +908,28 @@ export class IntelHUD {
return; return;
} }
if (revision !== this._summaryRevision) return; if (revision !== this._summaryRevision) return;
// Without a place label the model invents a place: show the plain line.
const described = [
context.placeLabels,
context.streetLabels,
context.nearbyPlaceLabels,
].some((list) => list?.length);
if (!described) {
this._summaryDirty = false;
this._setSummaryText(fallbackText, false);
return;
}
const signature = JSON.stringify(context); const signature = JSON.stringify(context);
if (!force && signature === this._lastSummarySignature) { if (!force && signature === this._lastSummarySignature) {
this._summaryDirty = false; this._summaryDirty = false;
// A settle painted the fallback line over the reply this context // A settle painted the fallback line over the reply this context
// already has; reused place labels make that common, so restore it. // 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; return;
} }
if (this._summaryRequest) return; if (this._summaryRequest) return;
@@ -867,7 +937,7 @@ export class IntelHUD {
if (force) this._setSummaryText(fallbackText, false); if (force) this._setSummaryText(fallbackText, false);
this._summaryDirty = false; this._summaryDirty = false;
this._lastSummarySignature = signature; this._lastSummarySignature = signature;
this._lastSummaryText = null; this._lastSummaryReply = null;
const controller = new AbortController(); const controller = new AbortController();
let timedOut = false; let timedOut = false;
const timeout = window.setTimeout(() => { const timeout = window.setTimeout(() => {
@@ -890,9 +960,23 @@ export class IntelHUD {
if (!response.ok || !data?.summary) { if (!response.ok || !data?.summary) {
throw new Error(data?.error || `HTTP ${response.status}`); throw new Error(data?.error || `HTTP ${response.status}`);
} }
if (hudSummaryMatchesProvenance(data.summary, context.feedProvenance)) if (
this._lastSummaryText = data.summary; hudSummaryMatchesProvenance(data, context.feedProvenance) &&
this._setSummaryText(this._lastSummaryText || fallbackText, animate); !hudSummaryHasEnglish(
data.summary,
context.locale,
summaryForbiddenWords(context),
)
) {
this._lastSummaryReply = data.summary;
}
// 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) { } catch (error) {
// The 5 s timeout is a failure like any other; a move's abort (or // The 5 s timeout is a failure like any other; a move's abort (or
// destroy's) is not. // destroy's) is not.
@@ -950,18 +1034,46 @@ export class IntelHUD {
// A slower build for a view the camera has left must not replace the // A slower build for a view the camera has left must not replace the
// newer view's lookup. // newer view's lookup.
if (revision === this._summaryRevision) this._placeLabels = labels; if (revision === this._summaryRevision) this._placeLabels = labels;
const enabledLayers = const locale = getLocale();
this._dataManager const layerContext = hudSummaryLayerContext(
?.getAll?.() this._dataManager?.getAll?.() || [],
?.filter((layer) => layer.enabled) );
.map((layer) => layer.name) || []; // Outside English a layer's state id is left out: the model echoes it,
return { // and `overall` already carries the state.
const named = ({ feedState, ...layer }) => ({
...layer,
name: localizeLayerName(layer.name),
...(locale === 'en' && { feedState }),
});
const { feedProvenance } = layerContext;
const context = {
locale,
placeLabels: labels.placeLabels, placeLabels: labels.placeLabels,
streetLabels: labels.streetLabels, streetLabels: labels.streetLabels,
nearbyPlaceLabels: labels.nearbyPlaceLabels, nearbyPlaceLabels: labels.nearbyPlaceLabels,
enabledLayerLabels: enabledLayers, // The model sees only the layer names the user sees. The English note
...hudSummaryLayerContext(this._dataManager?.getAll?.() || []), // goes too: a small model copies its words. `overall` stays the id.
enabledLayerLabels:
layerContext.enabledLayerLabels.map(localizeLayerName),
enabledLayers: layerContext.enabledLayers.map(named),
feedProvenance: {
...feedProvenance,
layers: feedProvenance.layers.map(named),
note: locale === 'en' ? feedProvenance.note : undefined,
},
}; };
// 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 and ids of
// translated layers, which a reply in another language must not echo.
Object.defineProperties(context, {
warning: { value: provenanceTag(feedProvenance) },
englishLayerWords: {
value: layerContext.enabledLayers
.filter(({ name }) => localizeLayerName(name) !== name)
.flatMap(({ name, id }) => [name, id].filter(Boolean)),
},
});
return context;
} }
_markSummaryDirty() { _markSummaryDirty() {
+11 -9
View File
@@ -295,7 +295,7 @@ test('a summary asked for one view is dropped once the camera moves', async () =
try { try {
hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish }); hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish });
let place = 'Yellowstone'; let place = 'Yellowstone';
hud._summaryContext = async () => ({ place }); hud._summaryContext = async () => ({ placeLabels: [place] });
const summary = () => env.elements.get('hud-summary').textContent; const summary = () => env.elements.get('hud-summary').textContent;
const settle = () => new Promise((resolve) => setImmediate(resolve)); const settle = () => new Promise((resolve) => setImmediate(resolve));
hud._updateCameraData(); hud._updateCameraData();
@@ -321,7 +321,7 @@ test('a summary asked for one view is dropped once the camera moves', async () =
const moving = hud._updateSummary(false); const moving = hud._updateSummary(false);
await settle(); await settle();
assert.equal(pending.length, 2, 'the move marked the summary dirty'); assert.equal(pending.length, 2, 'the move marked the summary dirty');
assert.deepEqual(pending[1].context, { place: 'Atlantic' }); assert.deepEqual(pending[1].context, { placeLabels: ['Atlantic'] });
pending[1].reply('North Atlantic Ocean'); pending[1].reply('North Atlantic Ocean');
await moving; await moving;
assert.equal(summary(), 'North Atlantic Ocean'); assert.equal(summary(), 'North Atlantic Ocean');
@@ -354,7 +354,7 @@ test('a request the camera leaves paints nothing: aborted, failed, or its contex
let hud; let hud;
try { try {
hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish }); hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish });
hud._summaryContext = async () => ({ place: 'Yellowstone' }); hud._summaryContext = async () => ({ placeLabels: ['Yellowstone'] });
const element = env.elements.get('hud-summary'); const element = env.elements.get('hud-summary');
const settle = () => new Promise((resolve) => setImmediate(resolve)); const settle = () => new Promise((resolve) => setImmediate(resolve));
hud._updateCameraData(); hud._updateCameraData();
@@ -416,7 +416,7 @@ test('a cancelled summary retries the same context but completed answers stay re
let hud; let hud;
try { try {
hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish }); hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish });
hud._summaryContext = async () => ({ place: 'San Francisco' }); hud._summaryContext = async () => ({ placeLabels: ['San Francisco'] });
hud._updateCameraData(); hud._updateCameraData();
const settle = () => new Promise((resolve) => setImmediate(resolve)); const settle = () => new Promise((resolve) => setImmediate(resolve));
const first = hud._updateSummary(false); const first = hud._updateSummary(false);
@@ -474,7 +474,7 @@ test('after a dropped or timed-out request the next tick asks again, and a move
try { try {
hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish }); hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish });
// The same place before and after: a slow follow camera, a small pan. // The same place before and after: a slow follow camera, a small pan.
hud._summaryContext = async () => ({ place: 'Lisboa' }); hud._summaryContext = async () => ({ placeLabels: ['Lisboa'] });
const element = env.elements.get('hud-summary'); const element = env.elements.get('hud-summary');
const settle = () => new Promise((resolve) => setImmediate(resolve)); const settle = () => new Promise((resolve) => setImmediate(resolve));
hud._updateCameraData(); hud._updateCameraData();
@@ -501,15 +501,17 @@ test('after a dropped or timed-out request the next tick asks again, and a move
}); });
await update; await update;
// A reply still typing stops when the camera starts moving. // A reply still typing stops when the camera starts moving, and the
// plain line replaces the fragment.
hud._typeSummary('Canyon Junction Wyoming Yellowstone National Park'); hud._typeSummary('Canyon Junction Wyoming Yellowstone National Park');
t.mock.timers.tick(48); t.mock.timers.tick(48);
const typed = element.textContent;
env.cameraListeners.moveStart(); env.cameraListeners.moveStart();
const plain = element.textContent;
assert.equal(plain, hud._composeSummary());
t.mock.timers.tick(2_000); t.mock.timers.tick(2_000);
assert.equal( assert.equal(
element.textContent, element.textContent,
typed, plain,
'no more characters after the move', 'no more characters after the move',
); );
} finally { } finally {
@@ -536,7 +538,7 @@ test('a summary interrupted while typing can retry unchanged context', async (t)
let hud; let hud;
try { try {
hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish }); hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish });
hud._summaryContext = async () => ({ place: 'San Francisco' }); hud._summaryContext = async () => ({ placeLabels: ['San Francisco'] });
hud._updateCameraData(); hud._updateCameraData();
await hud._updateSummary(true); await hud._updateSummary(true);
t.mock.timers.tick(48); t.mock.timers.tick(48);
+300
View File
@@ -0,0 +1,300 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import './testSupport/stubMgrs.mjs';
import { hudReadouts } from './testSupport/hudReadouts.mjs';
const { IntelHUD } = await import('./hud.js');
const { setLocale } = await import('./i18n/index.js');
const { hudSummaryHasEnglish, hudSummaryInput } =
await import('./hudSummaryResponse.js');
const PARTIAL_FLIGHTS = {
id: 'flights',
name: 'Live Flights',
enabled: true,
stats: { count: 4, partial: true, source: 'opensky' },
};
function createHud(
t,
reply,
{ places = ['Остин'], locale = 'ru', layers = () => [PARTIAL_FLIGHTS] } = {},
) {
const { elements, publish } = hudReadouts();
const previous = { document: globalThis.document, window: globalThis.window };
globalThis.document = {
hidden: false,
visibilityState: 'visible',
getElementById: (id) => elements.get(id) ?? null,
querySelectorAll: () => [],
addEventListener() {},
removeEventListener() {},
};
globalThis.window ??= {
setTimeout,
clearTimeout,
setInterval,
clearInterval,
};
t.mock.timers.enable({ apis: ['setInterval'] });
const viewer = {
scene: { canvas: { width: 0, height: 0 }, pickPositionSupported: false },
camera: {
pitch: -Math.PI / 2,
heading: 0,
positionCartographic: { latitude: 0.53, longitude: -1.7, height: 600 },
computeViewRectangle: () => undefined,
moveStart: { addEventListener() {}, removeEventListener() {} },
moveEnd: { addEventListener() {}, removeEventListener() {} },
},
};
const sent = [];
const hud = new IntelHUD(viewer, {
publish,
summaryService: {
configured: async () => true,
async summarize(context) {
sent.push(context);
return { ok: true, status: 200, data: { error: null, ...reply } };
},
},
});
t.after(() => {
hud.destroy();
setLocale('en');
for (const [key, value] of Object.entries(previous)) {
if (value === undefined) delete globalThis[key];
else globalThis[key] = value;
}
});
// The test viewer has no view target, so no place lookup runs.
const buildContext = hud._summaryContext.bind(hud);
hud._summaryContext = async () =>
Object.assign(await buildContext(), { placeLabels: places });
hud._dataManager = { getAll: layers };
hud._latestMetrics = { altM: 600 };
setLocale(locale);
return {
hud,
sent,
shown: () => elements.get('hud-summary').textContent,
};
}
test('a ru HUD request carries no English layer name', async (t) => {
const { hud } = createHud(t, {});
const context = await hud._summaryContext();
assert.equal(context.locale, 'ru');
assert.deepEqual(context.enabledLayerLabels, ['Рейсы онлайн']);
const input = hudSummaryInput(context);
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), /englishLayerWords|warning/);
});
test('a ru summary shows the state word first and the layer names last', async (t) => {
const { hud, shown } = createHud(t, {
summary: 'Аэропорт задерживает рейсы',
feedState: 'partial',
});
await hud._updateSummary(false, true);
assert.equal(shown(), 'ЧАСТИЧНО | Аэропорт задерживает рейсы | РЕЙСЫ ОНЛАЙН');
});
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, {
summary: words,
feedState: 'partial',
});
await hud._updateSummary(false, true);
assert.ok(shown().startsWith('ЧАСТИЧНО | '), shown());
assert.ok(!shown().includes(words), shown());
});
}
test('a view with layers but no place label does not ask the model', async (t) => {
const { hud, sent, shown } = createHud(
t,
{ summary: 'Москва Рейсы онлайн частично', feedState: 'partial' },
{ places: [] },
);
await hud._updateSummary(false, true);
assert.equal(sent.length, 0);
assert.equal(shown(), hud._composeSummary());
});
test('a ja request leaves the layer state ids to `overall`', async (t) => {
const { hud } = createHud(t, {}, { locale: 'ja' });
const context = await hud._summaryContext();
assert.equal(context.feedProvenance.overall, 'partial');
assert.ok(!('feedState' in context.enabledLayers[0]));
assert.ok(!('feedState' in context.feedProvenance.layers[0]));
});
for (const [locale, summary] of [
['ja', '東京上空のstale航空情報'],
['ja', '東京上空のLive Flights'],
['zh-CN', '北京航班stale数据'],
['ru', 'Остин Live-Flights частично'],
]) {
test(`English flush against other text is an echo: ${summary}`, () => {
assert.ok(hudSummaryHasEnglish(summary, locale, ['stale', 'Live Flights']));
});
}
test('English inside a longer Latin word is not an echo', () => {
assert.ok(!hudSummaryHasEnglish('Datos stalesque', 'es', ['stale']));
});
for (const [locale, summary, places] of [
['es', 'Tráfico aéreo nominal sobre Madrid', ['Madrid']],
['de', 'Fallback Daten über Berlin', ['Berlin']],
['ja', 'Live Flights Plaza周辺', ['Live Flights Plaza']],
['es', 'Estación Loading Dock Madrid', ['Estación Loading Dock']],
]) {
test(`a ${locale} word that is not an echo is shown: ${summary}`, async (t) => {
const { hud, shown } = createHud(
t,
{ summary, feedState: 'partial' },
{ places, locale },
);
await hud._updateSummary(false, true);
assert.ok(shown().includes(summary), shown());
});
}
test('a language switch drops the reply in flight and asks again', async (t) => {
const { hud, sent, shown } = createHud(t, {});
let answer;
hud.summaryService.summarize = (context, { signal }) => {
sent.push(context);
return new Promise((resolve, reject) => {
answer = () =>
resolve({
ok: true,
status: 200,
data: {
error: null,
summary: 'Остин рейсы частично',
feedState: 'partial',
},
});
signal.addEventListener('abort', () =>
reject(Object.assign(new Error('aborted'), { name: 'AbortError' })),
);
});
};
const pending = hud._updateSummary(false, true);
while (!answer) await new Promise((resolve) => setImmediate(resolve));
setLocale('es');
answer();
await pending;
assert.ok(!shown().includes('Остин'), shown());
assert.equal(shown(), hud._composeSummary());
hud.summaryService.summarize = async (context) => {
sent.push(context);
return {
ok: true,
status: 200,
data: {
error: null,
summary: 'Austin vuelos parciales',
feedState: 'partial',
},
};
};
await hud._updateSummary(false);
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());
});
for (const [locale, place, summary] of [
['zh-CN', '北京', '北京航班不可用'],
['zh-Hant', '北京', '北京航班不可用'],
['ja', '東京', '東京航空便利用不可'],
]) {
test(`a ${locale} reply calling a feed unavailable flush against Han falls back`, async (t) => {
const { hud, shown } = createHud(
t,
{ summary, feedState: null },
{ locale, places: [place], 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);
});
+181 -27
View File
@@ -1,7 +1,10 @@
import { import {
SNAPSHOT_FEED_STATES,
feedProvenanceEnvelope, feedProvenanceEnvelope,
layerSnapshots, layerSnapshots,
provenanceTag,
} from './data/layerSnapshot.js'; } from './data/layerSnapshot.js';
import { DEFAULT_LOCALE, normalizeLocale } from './i18n/locale.js';
export const HUD_SUMMARY_UNCONFIGURED_CODE = 'OPENAI_NOT_CONFIGURED'; export const HUD_SUMMARY_UNCONFIGURED_CODE = 'OPENAI_NOT_CONFIGURED';
@@ -43,19 +46,189 @@ export function isHudSummaryUnconfigured(status, data) {
} }
/** /**
* HUD summary model instructions. The five-word line may only describe * HUD summary model instructions. The five words may only describe supplied
* supplied labels, and must carry feed-state when any enabled layer is not * labels; the feed state comes back as its own canonical field, so the check
* nominal. * does not depend on the summary's language or wording.
*/ */
export const HUD_SUMMARY_INSTRUCTIONS = [ export const HUD_SUMMARY_INSTRUCTIONS = [
"Write one concise intelligence-HUD summary for God's Eye View.", "Write one concise intelligence-HUD summary for God's Eye View.",
'Use only the supplied place, street, nearby-place, enabled-layer labels, and feedProvenance.', '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.', '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.', '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 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.', 'Do not infer from coordinates or invent a place or feed state.',
'Output 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(' '); ].join(' ');
/** Longest summary, in characters, for a script written without spaces. */
export const HUD_SUMMARY_MAX_UNSPACED_CHARS = 20;
/** Longest summary, in characters; the cut falls between words. */
export const HUD_SUMMARY_MAX_CHARS = 80;
/** Responses API structured output: the summary 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' },
// Nullable rather than a `none` token, which the model wrote into the
// summary of a view without layers.
feedState: {
anyOf: [
{ type: 'string', enum: [...SNAPSHOT_FEED_STATES] },
{ type: 'null' },
],
},
},
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;
}
// Names Intl.DisplayNames gets wrong for the shipped catalogs: pt is the
// Brazilian variant, and zh-Hant is the zh-CN wording in Traditional script.
const SUMMARY_LANGUAGES = Object.freeze({
pt: 'Brazilian Portuguese',
'zh-CN': 'Simplified Chinese',
'zh-Hant': 'Chinese in Traditional characters (mainland wording)',
});
/** Scripts written without spaces, capped in characters instead of words. */
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) => `\\p{Script=${script}}`);
/** 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();
}
const UNSPACED_WORD =
/^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}ー]+$/u;
function wordScript(char) {
return WORD_SCRIPTS.find((script) => new RegExp(script, 'u').test(char));
}
/**
* 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.
* Han and kana have no word edges: a word in them counts anywhere.
* @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;
if (UNSPACED_WORD.test(needle)) return haystack.includes(needle);
const chars = [...needle];
const before = wordScript(chars[0]);
const after = wordScript(chars.at(-1));
// flatWords leaves no regex syntax in the needle.
return new RegExp(
`${before ? `(?<!${before})` : ''}${needle}${after ? `(?!${after})` : ''}`,
'u',
).test(haystack);
}
/**
* 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] Words the summary must not carry.
* @returns {boolean}
*/
export function hudSummaryHasEnglish(summary, locale, words = []) {
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));
}
export function hudSummaryIsUnspaced(locale) {
return UNSPACED_LOCALES.test(hudSummaryLocale(locale));
}
/** The language a request's summary is written in, named in English. */
function summaryLanguage(tag) {
if (SUMMARY_LANGUAGES[tag]) return SUMMARY_LANGUAGES[tag];
try {
return new Intl.DisplayNames(['en'], { type: 'language' }).of(tag) || tag;
} catch {
return tag;
}
}
/**
* @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);
const length = hudSummaryIsUnspaced(tag)
? ` Instead of five words, write one phrase of at most ${HUD_SUMMARY_MAX_UNSPACED_CHARS} characters.`
: '';
return `${HUD_SUMMARY_INSTRUCTIONS} Write the summary in ${language} (${tag}), not English. Give places their usual names in that language and layers the names in enabledLayerLabels; feedState stays the English value.${length}`;
}
/**
* 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. * Stamp HUD summary context with the same layer snapshots voice tools use.
* @param {Array<object>} [layers] `DataLayerManager.getAll()` rows. * @param {Array<object>} [layers] `DataLayerManager.getAll()` rows.
@@ -83,32 +256,13 @@ export function hudSummaryLayerContext(layers = [], options) {
} }
/** /**
* The feed state and up to two layer names for the HUD telemetry suffix when * The warning for the HUD telemetry line when an enabled feed is not
* an enabled feed is not nominal. Values stay English (`state` is a feed-state * nominal.
* id, `names` are `layer.name`); the HUD translates them for display.
* @param {Array<object>} [layers] * @param {Array<object>} [layers]
* @param {{ now?: number }} [options] * @param {{ now?: number }} [options]
* @returns {{ state: string, names: string[] }|null} * @returns {{ state: string, names: string[] }|null}
*/ */
export function hudTelemetryProvenance(layers = [], options) { export function hudTelemetryProvenance(layers = [], options) {
const snapshots = layerSnapshots(layers, options).filter((s) => s.enabled); const snapshots = layerSnapshots(layers, options).filter((s) => s.enabled);
const envelope = feedProvenanceEnvelope(snapshots, options); return provenanceTag(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 };
}
/** Require the supplied non-nominal state before showing an AI summary. */
export function hudSummaryMatchesProvenance(summary, provenance) {
const state = provenance?.overall;
if (!state || state === 'nominal' || state === 'off') return true;
return String(summary || '')
.toUpperCase()
.split(/\W+/)
.includes(String(state).toUpperCase());
} }
+273 -24
View File
@@ -1,14 +1,25 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { readFileSync } from 'node:fs'; import { Readable } from 'node:stream';
import { import {
HUD_SUMMARY_INSTRUCTIONS, HUD_SUMMARY_INSTRUCTIONS,
HUD_SUMMARY_MAX_UNSPACED_CHARS,
HUD_SUMMARY_RESPONSE_FORMAT,
HUD_SUMMARY_UNCONFIGURED_CODE, HUD_SUMMARY_UNCONFIGURED_CODE,
hudSummaryInput,
hudSummaryInstructions,
hudSummaryLayerContext, hudSummaryLayerContext,
hudSummaryLocale,
hudTelemetryProvenance, hudTelemetryProvenance,
isHudSummaryUnconfigured, isHudSummaryUnconfigured,
keylessHudSummaryResponse, keylessHudSummaryResponse,
} from './hudSummaryResponse.js'; } from './hudSummaryResponse.js';
import {
SNAPSHOT_FEED_STATES,
hudSummaryMatchesProvenance,
provenanceTag,
} from './data/layerSnapshot.js';
import { parseHudSummaryReply } from '../server/providers/openai/hud-summary.js';
import { openAiRealtimeProxy } from '../vite.config.js'; import { openAiRealtimeProxy } from '../vite.config.js';
const UNCONFIGURED_PAYLOAD = { const UNCONFIGURED_PAYLOAD = {
@@ -206,46 +217,284 @@ 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( assert.match(
HUD_SUMMARY_INSTRUCTIONS, HUD_SUMMARY_INSTRUCTIONS,
/five words MUST include that feedState token/, /Set feedState to feedProvenance\.overall exactly/,
); );
assert.match( assert.match(
HUD_SUMMARY_INSTRUCTIONS, HUD_SUMMARY_INSTRUCTIONS,
/STALE, DEGRADED, FALLBACK, LOADING, or UNAVAILABLE/, /do not present the view as live or healthy/,
); );
assert.match(HUD_SUMMARY_INSTRUCTIONS, /feedProvenance/); assert.match(
HUD_SUMMARY_INSTRUCTIONS,
/partial, do not present the coverage as complete/,
);
assert.deepEqual(HUD_SUMMARY_RESPONSE_FORMAT.schema.required, [
'summary',
'feedState',
]);
assert.deepEqual(
HUD_SUMMARY_RESPONSE_FORMAT.schema.properties.feedState.anyOf,
[{ type: 'string', enum: [...SNAPSHOT_FEED_STATES] }, { type: 'null' }],
);
assert.ok(!SNAPSHOT_FEED_STATES.includes('none'));
assert.ok(SNAPSHOT_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', () => {
const local = readFileSync( assert.match(hudSummaryInstructions('es'), /in Spanish \(es\)/);
new URL('../server/providers/openai/hud-summary.js', import.meta.url), assert.match(hudSummaryInstructions('ru-RU'), /in Russian \(ru\)/);
'utf8', 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.match(local, /HUD_SUMMARY_INSTRUCTIONS/); assert.equal(hudSummaryLocale(42), 'en');
assert.doesNotMatch(local, /enabled-layer text labels/);
}); });
test('AI summaries missing a non-nominal provenance token fall back deterministically', async () => { test('shipped catalogs get their exact language, and CJK a character limit', () => {
const { hudSummaryMatchesProvenance } = assert.match(hudSummaryInstructions('pt'), /in Brazilian Portuguese \(pt\)/);
await import('./hudSummaryResponse.js'); assert.match(
assert.equal( hudSummaryInstructions('zh-Hant'),
hudSummaryMatchesProvenance('Austin flights operating normally today', { /in Chinese in Traditional characters \(mainland wording\) \(zh-Hant\)/,
overall: 'stale', );
assert.match(hudSummaryInstructions('zh-CN'), /in Simplified Chinese/);
assert.match(
hudSummaryInstructions('ja'),
new RegExp(`at most ${HUD_SUMMARY_MAX_UNSPACED_CHARS} characters`),
);
assert.doesNotMatch(hudSummaryInstructions('es'), /characters/);
assert.match(hudSummaryInstructions('es'), /names in enabledLayerLabels/);
});
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 previousFetch = globalThis.fetch;
process.env.OPENAI_API_KEY = 'test-key';
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;
}
}
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 Japanese summary is capped in characters, not words', async () => {
const long = 'テキサス州議会議事堂周辺の航空機データは古くなっています';
const { response } = await postHudSummary(
{ locale: 'ja', feedProvenance: { overall: 'stale' } },
JSON.stringify({ summary: long, feedState: 'stale' }),
);
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);
});
// Within the cap a reply comes back whole, in NFC: marks, in-word
// apostrophes and dots, multi-word names and Han names included.
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')],
['fr', 'Rue de Rivoli Vols en direct'],
['es', 'Times Square Nueva York Vuelos en vivo'],
['ru', 'Times Square Рейсы онлайн и суда'],
['en', 'Shibuya 渋谷 live flights overhead'],
['es', 'Vuelos sobre 東京駅 datos antiguos'],
['ru', 'Башня Тайбэй 101 台北101 рейсы'],
['ja', 'サンフランシスコ・ベイエリア'],
['ja', '東京 上空 の 航空 情報 は 古い'],
['zh-CN', 'SFO 附近 4 架 航班 数据 过期'],
['zh-Hant', 'SFO 附近 4 架 航班 數據 過期'],
]) {
test(`a ${locale} summary within the cap is kept whole: ${summary}`, () => {
const reply = JSON.stringify({ summary, feedState: null });
assert.equal(
parseHudSummaryReply(reply, locale).summary,
summary.normalize('NFC'),
);
});
}
test('a summary is capped in characters', 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');
});
test('a ja cap steps back to a space instead of cutting a Latin word', async () => {
const { response } = await postHudSummary(
{ locale: 'ja', feedProvenance: { overall: 'stale' } },
JSON.stringify({
summary: 'San Francisco Bay Area flights',
feedState: 'stale',
}),
);
assert.equal(response.body.summary, 'San Francisco Bay');
});
test('a ja cap keeps the text before a Latin word it would split', async () => {
const { response } = await postHudSummary(
{ locale: 'ja', feedProvenance: { overall: 'stale' } },
JSON.stringify({
summary: '東京 羽田空港周辺のFlightAware航空情報',
feedState: 'stale',
}),
);
assert.equal(response.body.summary, '東京 羽田空港周辺の');
});
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("a localized summary is shown only with the HUD's own canonical state", () => {
const stale = { overall: 'stale' };
assert.equal(
hudSummaryMatchesProvenance(
{ summary: 'Остин рейсы данные устарели сейчас', feedState: 'stale' },
stale,
),
true,
);
assert.equal(
hudSummaryMatchesProvenance(
{ summary: 'Austin vuelos en vivo ahora', feedState: 'nominal' },
stale,
),
false, false,
); );
assert.equal( assert.equal(
hudSummaryMatchesProvenance('Austin stale flights over downtown', { hudSummaryMatchesProvenance(
overall: 'stale', { summary: 'Austin stale flights over downtown', feedState: null },
}), stale,
true, ),
false,
); );
assert.equal( assert.equal(
hudSummaryMatchesProvenance('Austin flights operating normally today', { hudSummaryMatchesProvenance(
overall: 'nominal', { summary: 'Austin flights' },
}), { overall: 'partial' },
),
false,
);
assert.equal(
hudSummaryMatchesProvenance(
{ summary: 'Austin flights operating normally today' },
{ overall: 'nominal' },
),
true, true,
); );
assert.equal(hudSummaryMatchesProvenance({ summary: 'x' }, null), true);
});
test('a partial view keeps its warning tag', () => {
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'],
});
}); });
+1 -2
View File
@@ -562,7 +562,7 @@
"Radio volume": "مستوى صوت الراديو", "Radio volume": "مستوى صوت الراديو",
"Radio off": "الراديو متوقف", "Radio off": "الراديو متوقف",
"STATION SITE": "موقع المحطة", "STATION SITE": "موقع المحطة",
"Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "يتصل التطبيق مباشرةً بمحطة البث بعد الضغط على «تشغيل»، فيظهر لها عنوان IP الخاص بك.", "Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "يتصل التطبيق مباشرةً بمحطة البث عند الضغط على «تشغيل» أو اختيار محطة على الكرة الأرضية أو طلب التشغيل بالصوت، فيظهر لها عنوان IP الخاص بك.",
"LOADING": "جارٍ التحميل", "LOADING": "جارٍ التحميل",
"DEGRADED": "متدهور", "DEGRADED": "متدهور",
"STALE": "متقادم", "STALE": "متقادم",
@@ -1146,7 +1146,6 @@
"OCEANIA": "أوقيانوسيا", "OCEANIA": "أوقيانوسيا",
"NORTHERN OCEANIC GRID": "الشبكة المحيطية الشمالية", "NORTHERN OCEANIC GRID": "الشبكة المحيطية الشمالية",
"SOUTHERN OCEANIC GRID": "الشبكة المحيطية الجنوبية", "SOUTHERN OCEANIC GRID": "الشبكة المحيطية الجنوبية",
"{state} {layers}": "{state}: {layers}",
"view band|STREET": "شارع", "view band|STREET": "شارع",
"view band|CITY": "مدينة", "view band|CITY": "مدينة",
"view band|METRO": "حضري", "view band|METRO": "حضري",
+1 -2
View File
@@ -777,7 +777,7 @@
"Stop radio playback": "Radiowiedergabe stoppen", "Stop radio playback": "Radiowiedergabe stoppen",
"Radio volume": "Radio-Lautstärke", "Radio volume": "Radio-Lautstärke",
"STATION SITE": "SENDER-WEBSITE", "STATION SITE": "SENDER-WEBSITE",
"Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "Beim Starten der Wiedergabe wird eine direkte Verbindung zum Sender hergestellt. Die IP-Adresse ist für diesen Sender sichtbar.", "Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "Beim Starten der Wiedergabe, bei der Wahl eines Senders auf dem Globus oder auf Wunsch per Sprachsteuerung wird eine direkte Verbindung zum Sender hergestellt. Die IP-Adresse ist für diesen Sender sichtbar.",
"Intelligence HUD (H)": "Lagebild-HUD (H)", "Intelligence HUD (H)": "Lagebild-HUD (H)",
"Layout": "Layout", "Layout": "Layout",
"HUD layout": "HUD-Layout", "HUD layout": "HUD-Layout",
@@ -1114,7 +1114,6 @@
"OCEANIA": "OZEANIEN", "OCEANIA": "OZEANIEN",
"NORTHERN OCEANIC GRID": "NÖRDLICHES OZEANRASTER", "NORTHERN OCEANIC GRID": "NÖRDLICHES OZEANRASTER",
"SOUTHERN OCEANIC GRID": "SÜDLICHES OZEANRASTER", "SOUTHERN OCEANIC GRID": "SÜDLICHES OZEANRASTER",
"{state} {layers}": "{state} {layers}",
"view band|STREET": "STRASSE", "view band|STREET": "STRASSE",
"view band|CITY": "STADT", "view band|CITY": "STADT",
"view band|METRO": "GROSSRAUM", "view band|METRO": "GROSSRAUM",
+1 -2
View File
@@ -450,7 +450,7 @@
"Radio volume": "Volumen de Radio", "Radio volume": "Volumen de Radio",
"Radio off": "Radio desactivada", "Radio off": "Radio desactivada",
"STATION SITE": "SITIO DE LA EMISORA", "STATION SITE": "SITIO DE LA EMISORA",
"Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "El audio se conecta directamente con la emisora después de pulsar ESCUCHAR. Tu IP es visible para esa emisora.", "Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "El audio se conecta directamente con la emisora al pulsar ESCUCHAR, elegir una emisora en el globo o pedirlo por voz. Tu IP es visible para esa emisora.",
"LOADING": "CARGANDO", "LOADING": "CARGANDO",
"DEGRADED": "DEGRADADO", "DEGRADED": "DEGRADADO",
"STALE": "DESACTUALIZADO", "STALE": "DESACTUALIZADO",
@@ -2021,7 +2021,6 @@
"Globe field image unavailable": "Imagen del campo en el globo no disponible", "Globe field image unavailable": "Imagen del campo en el globo no disponible",
"status chip|UNAVAILABLE": "NO DISP.", "status chip|UNAVAILABLE": "NO DISP.",
"Uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.": "Contiene referencias a contenido registrado o medios vinculados. Esos archivos externos no se incluyen en un paquete de escena. Sus avisos originales siguen vigentes.", "Uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.": "Contiene referencias a contenido registrado o medios vinculados. Esos archivos externos no se incluyen en un paquete de escena. Sus avisos originales siguen vigentes.",
"{state} {layers}": "{state}: {layers}",
"Selected Space Mission": "Misión espacial seleccionada", "Selected Space Mission": "Misión espacial seleccionada",
"{duration}s + {hold}s": "{duration} s + {hold} s", "{duration}s + {hold}s": "{duration} s + {hold} s",
"Configured Source": "Fuente configurada", "Configured Source": "Fuente configurada",
+1 -2
View File
@@ -331,7 +331,6 @@
"STALE": "OBSOLÈTE", "STALE": "OBSOLÈTE",
"PARTIAL": "PARTIEL", "PARTIAL": "PARTIEL",
"FALLBACK": "SECOURS", "FALLBACK": "SECOURS",
"{state} {layers}": "{state} : {layers}",
"view band|STREET": "RUE", "view band|STREET": "RUE",
"view band|CITY": "VILLE", "view band|CITY": "VILLE",
"view band|METRO": "MÉTROPOLE", "view band|METRO": "MÉTROPOLE",
@@ -2819,7 +2818,7 @@
"Radio volume": "Volume de la radio", "Radio volume": "Volume de la radio",
"STATION SITE": "SITE DE LA STATION", "STATION SITE": "SITE DE LA STATION",
"DIRECTORY: RADIO BROWSER": "ANNUAIRE : RADIO BROWSER", "DIRECTORY: RADIO BROWSER": "ANNUAIRE : RADIO BROWSER",
"Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "Le flux audio se connecte directement au diffuseur dès que vous lancez la lecture. Votre IP est visible par ce diffuseur.", "Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "Le flux audio se connecte directement au diffuseur dès que vous lancez la lecture, choisissez une station sur le globe ou la demandez par la voix. Votre IP est visible par ce diffuseur.",
"Local RTL-SDR receiver": "Récepteur RTL-SDR local", "Local RTL-SDR receiver": "Récepteur RTL-SDR local",
"LOCAL RTL-SDR": "RTL-SDR LOCAL", "LOCAL RTL-SDR": "RTL-SDR LOCAL",
"Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · un seul tuner local partagé entre la FM et la couche ADS-B local.", "Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · un seul tuner local partagé entre la FM et la couche ADS-B local.",
+1 -2
View File
@@ -331,7 +331,6 @@
"STALE": "OBSOLETO", "STALE": "OBSOLETO",
"PARTIAL": "PARZIALE", "PARTIAL": "PARZIALE",
"FALLBACK": "RISERVA", "FALLBACK": "RISERVA",
"{state} {layers}": "{state} {layers}",
"view band|STREET": "STRADA", "view band|STREET": "STRADA",
"view band|CITY": "CITTÀ", "view band|CITY": "CITTÀ",
"view band|METRO": "METRO", "view band|METRO": "METRO",
@@ -2819,7 +2818,7 @@
"Radio volume": "Volume radio", "Radio volume": "Volume radio",
"STATION SITE": "SITO STAZIONE", "STATION SITE": "SITO STAZIONE",
"DIRECTORY: RADIO BROWSER": "ELENCO: RADIO BROWSER", "DIRECTORY: RADIO BROWSER": "ELENCO: RADIO BROWSER",
"Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "L'audio si collega direttamente all'emittente dopo aver premuto play. Il tuo IP è visibile a quell'emittente.", "Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "L'audio si collega direttamente all'emittente dopo aver premuto play, scelto una stazione sul globo o chiesto la riproduzione a voce. Il tuo IP è visibile a quell'emittente.",
"Local RTL-SDR receiver": "Ricevitore RTL-SDR locale", "Local RTL-SDR receiver": "Ricevitore RTL-SDR locale",
"LOCAL RTL-SDR": "RTL-SDR LOCALE", "LOCAL RTL-SDR": "RTL-SDR LOCALE",
"Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · un sintonizzatore locale condiviso tra FM e il livello ADS-B locale.", "Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · un sintonizzatore locale condiviso tra FM e il livello ADS-B locale.",
+1 -2
View File
@@ -325,7 +325,6 @@
"STALE": "更新遅延", "STALE": "更新遅延",
"PARTIAL": "部分的", "PARTIAL": "部分的",
"FALLBACK": "フォールバック", "FALLBACK": "フォールバック",
"{state} {layers}": "{state} {layers}",
"view band|STREET": "街路", "view band|STREET": "街路",
"view band|CITY": "市街", "view band|CITY": "市街",
"view band|METRO": "都市圏", "view band|METRO": "都市圏",
@@ -2677,7 +2676,7 @@
"Radio volume": "ラジオの音量", "Radio volume": "ラジオの音量",
"STATION SITE": "放送局サイト", "STATION SITE": "放送局サイト",
"DIRECTORY: RADIO BROWSER": "ディレクトリ:Radio Browser", "DIRECTORY: RADIO BROWSER": "ディレクトリ:Radio Browser",
"Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "再生ボタンを押すと、音声は放送局に直接接続されます。お使いのIPアドレスは放送局側から見えます。", "Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "再生ボタンを押す、地球儀で局を選ぶ、または音声コントロールで再生を指示すると、音声は放送局に直接接続されます。お使いのIPアドレスは放送局側から見えます。",
"Local RTL-SDR receiver": "ローカルRTL-SDR受信機", "Local RTL-SDR receiver": "ローカルRTL-SDR受信機",
"LOCAL RTL-SDR": "ローカルRTL-SDR", "LOCAL RTL-SDR": "ローカルRTL-SDR",
"Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · FMとローカルADS-Bレイヤーで1台のローカルチューナーを共有します。", "Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · FMとローカルADS-Bレイヤーで1台のローカルチューナーを共有します。",
+1 -2
View File
@@ -331,7 +331,6 @@
"STALE": "DESATUALIZADO", "STALE": "DESATUALIZADO",
"PARTIAL": "PARCIAL", "PARTIAL": "PARCIAL",
"FALLBACK": "ALTERNATIVO", "FALLBACK": "ALTERNATIVO",
"{state} {layers}": "{state}: {layers}",
"view band|STREET": "RUA", "view band|STREET": "RUA",
"view band|CITY": "CIDADE", "view band|CITY": "CIDADE",
"view band|METRO": "METRÓPOLE", "view band|METRO": "METRÓPOLE",
@@ -2821,7 +2820,7 @@
"Radio volume": "Volume do rádio", "Radio volume": "Volume do rádio",
"STATION SITE": "SITE DA ESTAÇÃO", "STATION SITE": "SITE DA ESTAÇÃO",
"DIRECTORY: RADIO BROWSER": "DIRETÓRIO: RADIO BROWSER", "DIRECTORY: RADIO BROWSER": "DIRETÓRIO: RADIO BROWSER",
"Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "O áudio conecta direto à emissora depois que você aperta \"Ouvir\". Seu IP fica visível para essa emissora.", "Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "O áudio conecta direto à emissora quando você aperta \"Ouvir\", escolhe uma estação no globo ou pede por voz. Seu IP fica visível para essa emissora.",
"Local RTL-SDR receiver": "Receptor RTL-SDR local", "Local RTL-SDR receiver": "Receptor RTL-SDR local",
"LOCAL RTL-SDR": "RTL-SDR LOCAL", "LOCAL RTL-SDR": "RTL-SDR LOCAL",
"Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · um sintonizador local compartilhado entre o FM e a camada ADS-B local.", "Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · um sintonizador local compartilhado entre o FM e a camada ADS-B local.",
+1 -2
View File
@@ -727,7 +727,7 @@
"STATION TAG": "ТЕГ СТАНЦИИ", "STATION TAG": "ТЕГ СТАНЦИИ",
"DRAG TO TUNE": "ТЯНИТЕ ДЛЯ НАСТРОЙКИ", "DRAG TO TUNE": "ТЯНИТЕ ДЛЯ НАСТРОЙКИ",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB — ПРОСМОТР · ENTER / SPACE — ВЫБОР", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB — ПРОСМОТР · ENTER / SPACE — ВЫБОР",
"Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "После нажатия «Пуск» звук идёт напрямую от вещателя, и ему виден ваш IP-адрес.", "Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "Звук идёт напрямую от вещателя после нажатия «Пуск», выбора станции на глобусе или голосовой команды. Вещателю виден ваш IP-адрес.",
"Internet radio and local SDR": "Интернет-радио и локальный SDR", "Internet radio and local SDR": "Интернет-радио и локальный SDR",
"Filter stations by station tag": "Фильтровать станции по тегу", "Filter stations by station tag": "Фильтровать станции по тегу",
"CONTEXT": "КОНТЕКСТ", "CONTEXT": "КОНТЕКСТ",
@@ -2055,7 +2055,6 @@
"Globe field image unavailable": "Изображение поля на глобусе недоступно", "Globe field image unavailable": "Изображение поля на глобусе недоступно",
"status chip|UNAVAILABLE": "НЕДОСТ.", "status chip|UNAVAILABLE": "НЕДОСТ.",
"Uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.": "Используется зарегистрированный контент или связанные медиафайлы. Эти внешние файлы не входят в архив сцены. Их исходные условия использования по-прежнему действуют.", "Uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.": "Используется зарегистрированный контент или связанные медиафайлы. Эти внешние файлы не входят в архив сцены. Их исходные условия использования по-прежнему действуют.",
"{state} {layers}": "{state}: {layers}",
"Selected Space Mission": "Выбранная космическая миссия", "Selected Space Mission": "Выбранная космическая миссия",
"{duration}s + {hold}s": "{duration} с + {hold} с", "{duration}s + {hold}s": "{duration} с + {hold} с",
"Configured Source": "Настроенный источник", "Configured Source": "Настроенный источник",
+1 -2
View File
@@ -334,7 +334,6 @@
"STALE": "ЗАСТАРІЛО", "STALE": "ЗАСТАРІЛО",
"PARTIAL": "ЧАСТКОВО", "PARTIAL": "ЧАСТКОВО",
"FALLBACK": "РЕЗЕРВ", "FALLBACK": "РЕЗЕРВ",
"{state} {layers}": "{state}: {layers}",
"view band|STREET": "ВУЛИЦЯ", "view band|STREET": "ВУЛИЦЯ",
"view band|CITY": "МІСТО", "view band|CITY": "МІСТО",
"view band|METRO": "АГЛОМЕРАЦІЯ", "view band|METRO": "АГЛОМЕРАЦІЯ",
@@ -2889,7 +2888,7 @@
"Radio volume": "Гучність радіо", "Radio volume": "Гучність радіо",
"STATION SITE": "САЙТ СТАНЦІЇ", "STATION SITE": "САЙТ СТАНЦІЇ",
"DIRECTORY: RADIO BROWSER": "КАТАЛОГ: RADIO BROWSER", "DIRECTORY: RADIO BROWSER": "КАТАЛОГ: RADIO BROWSER",
"Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "Після натискання «Відтворити» звук надходить безпосередньо від мовника. Мовник бачить вашу IP-адресу.", "Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "Звук надходить безпосередньо від мовника після натискання «Відтворити», вибору станції на глобусі або голосової команди. Мовник бачить вашу IP-адресу.",
"Local RTL-SDR receiver": "Локальний приймач RTL-SDR", "Local RTL-SDR receiver": "Локальний приймач RTL-SDR",
"LOCAL RTL-SDR": "ЛОКАЛЬНИЙ RTL-SDR", "LOCAL RTL-SDR": "ЛОКАЛЬНИЙ RTL-SDR",
"Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · один локальний тюнер для FM і шару «Локальний ADS-B».", "Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · один локальний тюнер для FM і шару «Локальний ADS-B».",
+1 -2
View File
@@ -325,7 +325,6 @@
"STALE": "过期", "STALE": "过期",
"PARTIAL": "部分", "PARTIAL": "部分",
"FALLBACK": "备用", "FALLBACK": "备用",
"{state} {layers}": "{state} {layers}",
"view band|STREET": "街道", "view band|STREET": "街道",
"view band|CITY": "城市", "view band|CITY": "城市",
"view band|METRO": "都市圈", "view band|METRO": "都市圈",
@@ -2576,7 +2575,7 @@
"Radio volume": "电台音量", "Radio volume": "电台音量",
"STATION SITE": "电台网站", "STATION SITE": "电台网站",
"DIRECTORY: RADIO BROWSER": "目录:RADIO BROWSER", "DIRECTORY: RADIO BROWSER": "目录:RADIO BROWSER",
"Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "点击播放后,音频将直接连接到广播方。广播方可以看到你的 IP。", "Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "点击播放、在地球上选择电台或通过语音要求播放后,音频将直接连接到广播方。广播方可以看到你的 IP。",
"Local RTL-SDR receiver": "本地 RTL-SDR 接收机", "Local RTL-SDR receiver": "本地 RTL-SDR 接收机",
"LOCAL RTL-SDR": "本地 RTL-SDR", "LOCAL RTL-SDR": "本地 RTL-SDR",
"Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · 一个本地调谐器由 FM 与本地 ADS-B 图层共享。", "Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · 一个本地调谐器由 FM 与本地 ADS-B 图层共享。",
+1 -2
View File
@@ -325,7 +325,6 @@
"STALE": "過期", "STALE": "過期",
"PARTIAL": "部分", "PARTIAL": "部分",
"FALLBACK": "備用", "FALLBACK": "備用",
"{state} {layers}": "{state} {layers}",
"view band|STREET": "街道", "view band|STREET": "街道",
"view band|CITY": "城市", "view band|CITY": "城市",
"view band|METRO": "都市圈", "view band|METRO": "都市圈",
@@ -2564,7 +2563,7 @@
"Radio volume": "電臺音量", "Radio volume": "電臺音量",
"STATION SITE": "電臺網站", "STATION SITE": "電臺網站",
"DIRECTORY: RADIO BROWSER": "目錄:RADIO BROWSER", "DIRECTORY: RADIO BROWSER": "目錄:RADIO BROWSER",
"Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "點擊播放後,音頻將直接連接到廣播方。廣播方可以看到你的 IP。", "Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "點擊播放、在地球上選擇電臺或通過語音要求播放後,音頻將直接連接到廣播方。廣播方可以看到你的 IP。",
"Local RTL-SDR receiver": "本地 RTL-SDR 接收機", "Local RTL-SDR receiver": "本地 RTL-SDR 接收機",
"LOCAL RTL-SDR": "本地 RTL-SDR", "LOCAL RTL-SDR": "本地 RTL-SDR",
"Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · 一個本地調諧器由 FM 與本地 ADS-B 圖層共享。", "Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · 一個本地調諧器由 FM 與本地 ADS-B 圖層共享。",
+6 -3
View File
@@ -344,14 +344,17 @@ export function createPlayback({ state: layerState, services, parts, source }) {
} }
if (!parts.interaction.radioPresentationAllowed()) if (!parts.interaction.radioPresentationAllowed())
return Promise.resolve(false); return Promise.resolve(false);
if (!layerState._selectedId) { if (layerState._selectedId) {
// Drop a fallback left by a voice step whose playback never started.
layerState._playFallbackId = null;
layerState._playFallbackFocus = null;
} else {
const ranked = parts.queries.rankedVisibleStations(); const ranked = parts.queries.rankedVisibleStations();
if (!ranked.length) return Promise.resolve(false); if (!ranked.length) return Promise.resolve(false);
layerState._playFallbackId = ranked[1]?.id || null;
layerState._playFallbackFocus = false;
parts.selection.selectRadioStation(ranked[0].id, { parts.selection.selectRadioStation(ranked[0].id, {
autoplay: false, autoplay: false,
focus: false, focus: false,
playFallback: { id: ranked[1]?.id || null, focus: false },
}); });
} }
return playSelectedRadio({ origin }); return playSelectedRadio({ origin });
+36 -6
View File
@@ -73,6 +73,16 @@ export function createSelection({
return { filter: layerState._filter, volume: layerState._userVolume }; return { filter: layerState._filter, volume: layerState._userVolume };
} }
// A paused or failed stream of another station would outlive its selection.
function releaseStaleRadioStream(stationId) {
if (
layerState._audioStationId &&
layerState._audioStationId !== stationId &&
['paused', 'error'].includes(layerState._audioState)
)
parts.playback.stopRadioPlayback();
}
/** Select a station. Playback occurs only when autoplay is explicitly true. */ /** Select a station. Playback occurs only when autoplay is explicitly true. */
function selectRadioStation( function selectRadioStation(
@@ -83,11 +93,18 @@ export function createSelection({
origin = 'programmatic', origin = 'programmatic',
attemptId = null, attemptId = null,
cameraNavigation = null, cameraNavigation = null,
playFallback = null,
} = {}, } = {},
) { ) {
if (!parts.interaction.radioPresentationAllowed()) return false; if (!parts.interaction.radioPresentationAllowed()) return false;
const station = layerState._stationById.get(String(id)); const station = layerState._stationById.get(String(id));
if (!station) return false; if (!station) return false;
if (!autoplay) releaseStaleRadioStream(station.id);
// Set after the release, which clears fallbacks.
if (playFallback) {
layerState._playFallbackId = playFallback.id;
layerState._playFallbackFocus = playFallback.focus;
}
layerState._tuningUnavailableStationId = null; layerState._tuningUnavailableStationId = null;
if ( if (
!parts.navigation.radioCameraNavigationOwnsSelection( !parts.navigation.radioCameraNavigationOwnsSelection(
@@ -104,6 +121,7 @@ export function createSelection({
) )
parts.tuning.endRadioTuning(); parts.tuning.endRadioTuning();
layerState._cancelledTuningPresentationStation = null; layerState._cancelledTuningPresentationStation = null;
const changed = layerState._selectedId !== station.id;
layerState._selectedId = station.id; layerState._selectedId = station.id;
const generation = ++layerState._selectionGeneration; const generation = ++layerState._selectionGeneration;
const sessionGeneration = layerState._sessionGeneration; const sessionGeneration = layerState._sessionGeneration;
@@ -122,6 +140,9 @@ export function createSelection({
if (focus) parts.navigation.focusStation(station); if (focus) parts.navigation.focusStation(station);
parts.presentation.emitState(); parts.presentation.emitState();
if (autoplay) void parts.playback.playSelectedRadio({ origin, attemptId }); if (autoplay) void parts.playback.playSelectedRadio({ origin, attemptId });
// A user step to another station cancels a playback voice prepared.
else if (origin === 'user' && changed)
parts.presentation.emitPlaybackControl('select', origin);
return true; return true;
} }
@@ -133,6 +154,7 @@ export function createSelection({
rotate = false, rotate = false,
stationIds = null, stationIds = null,
autoplay = true, autoplay = true,
fallback = autoplay,
origin = 'programmatic', origin = 'programmatic',
} = {}, } = {},
) { ) {
@@ -159,15 +181,19 @@ export function createSelection({
current < 0 current < 0
? 0 ? 0
: (current + (direction < 0 ? -1 : 1) + ranked.length) % ranked.length; : (current + (direction < 0 ? -1 : 1) + ranked.length) % ranked.length;
layerState._playFallbackId = // Voice defers playback, so its silent step keeps a fallback; silent UI steps do not.
ranked.length > 1 ? ranked[(nextIndex + 1) % ranked.length].id : null; const fallbackId =
fallback && ranked.length > 1
? ranked[(nextIndex + 1) % ranked.length].id
: null;
const rotationCameraState = rotate const rotationCameraState = rotate
? parts.navigation.radioCameraState() ? parts.navigation.radioCameraState()
: null; : null;
const rotationNavigation = rotate const rotationNavigation = rotate
? parts.navigation.beginRadioCameraNavigation(rotationCameraState) ? parts.navigation.beginRadioCameraNavigation(rotationCameraState)
: null; : null;
layerState._playFallbackFocus = rotate const fallbackFocus =
fallback && rotate
? (fallbackStation) => { ? (fallbackStation) => {
parts.navigation.rotateRadioStationIntoView( parts.navigation.rotateRadioStationIntoView(
fallbackStation, fallbackStation,
@@ -191,6 +217,7 @@ export function createSelection({
focus: false, focus: false,
origin, origin,
cameraNavigation: rotationNavigation, cameraNavigation: rotationNavigation,
playFallback: { id: fallbackId, focus: fallbackFocus },
}); });
} }
@@ -217,9 +244,12 @@ export function createSelection({
}, },
); );
if (!ranked.length) return null; if (!ranked.length) return null;
layerState._playFallbackId = ranked[1]?.id || null; selectRadioStation(ranked[0].id, {
layerState._playFallbackFocus = false; autoplay,
selectRadioStation(ranked[0].id, { autoplay, focus: false, origin }); focus: false,
origin,
playFallback: { id: ranked[1]?.id || null, focus: false },
});
return ranked[0]; return ranked[0];
} }
+2
View File
@@ -78,6 +78,8 @@ export function createState({ services }) {
layerState._tuningStartStationId = null; layerState._tuningStartStationId = null;
layerState._tuningResumesPlayback = false;
layerState._tuningResolutionSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT; layerState._tuningResolutionSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT;
layerState._tuningStationById = new Map(); layerState._tuningStationById = new Map();
+17 -5
View File
@@ -66,6 +66,12 @@ export function createTuning({ state: layerState, services, parts, source }) {
layerState._cancelledTuningPresentationStation = null; layerState._cancelledTuningPresentationStation = null;
layerState._tuningStartStationId = layerState._selectedId; layerState._tuningStartStationId = layerState._selectedId;
layerState._tuningPreviewId = layerState._selectedId; layerState._tuningPreviewId = layerState._selectedId;
// Like Previous/Next, the release plays only if a stream was active.
layerState._tuningResumesPlayback = [
'loading',
'buffering',
'playing',
].includes(layerState._audioState);
parts.tuningNoise.installTuningNoise(); parts.tuningNoise.installTuningNoise();
parts.playback.pauseRadioPlayback({ origin: 'user' }); parts.playback.pauseRadioPlayback({ origin: 'user' });
parts.tuningNoise.syncTuningNoiseGain(); parts.tuningNoise.syncTuningNoiseGain();
@@ -179,7 +185,9 @@ export function createTuning({ state: layerState, services, parts, source }) {
layerState._selectionGeneration += 1; layerState._selectionGeneration += 1;
parts.tuningNoise.clearRadioTuningNoise({ emit: false }); parts.tuningNoise.clearRadioTuningNoise({ emit: false });
layerState._tuningUnavailableStationId = stationId; layerState._tuningUnavailableStationId = stationId;
parts.presentation.emitState(); // The drag paused the stream; with no selection, Resume would start the
// nearest station. Stop also publishes the state and cancels a voice handoff.
parts.playback.stopRadioPlayback({ origin });
return Object.freeze({ return Object.freeze({
ok: false, ok: false,
reason: 'station-unavailable', reason: 'station-unavailable',
@@ -187,9 +195,11 @@ export function createTuning({ state: layerState, services, parts, source }) {
generation, generation,
}); });
} }
const autoplay = layerState._tuningResumesPlayback;
layerState._tuningResumesPlayback = false;
layerState._tuningActive = false; layerState._tuningActive = false;
layerState._tuningStatic = true; layerState._tuningStatic = autoplay;
layerState._tuningAwaitingStationId = frozenStation.id; layerState._tuningAwaitingStationId = autoplay ? frozenStation.id : null;
layerState._tuningPreviewId = null; layerState._tuningPreviewId = null;
layerState._tuningStartStationId = null; layerState._tuningStartStationId = null;
layerState._tuningUnavailableStationId = null; layerState._tuningUnavailableStationId = null;
@@ -204,10 +214,12 @@ export function createTuning({ state: layerState, services, parts, source }) {
layerState._playFallbackFocus = null; layerState._playFallbackFocus = null;
layerState._playFallbackOrigin = 'programmatic'; layerState._playFallbackOrigin = 'programmatic';
layerState._playFallbackAttemptId = null; layerState._playFallbackAttemptId = null;
parts.tuningNoise.syncTuningNoiseGain(); // Muting alone would leave the noise graph running after a silent release.
if (autoplay) parts.tuningNoise.syncTuningNoiseGain();
else parts.tuningNoise.clearRadioTuningNoise({ emit: false });
parts.presentation.emitState(); parts.presentation.emitState();
const selected = parts.selection.selectRadioStation(frozenStation.id, { const selected = parts.selection.selectRadioStation(frozenStation.id, {
autoplay: true, autoplay,
focus: false, focus: false,
origin, origin,
cameraNavigation, cameraNavigation,
+22 -4
View File
@@ -50,6 +50,11 @@ export function createTuningNoise({
layerState._tuningNoiseGain = null; layerState._tuningNoiseGain = null;
} }
function suspendTuningNoiseContext() {
const suspended = layerState._tuningNoiseContext?.suspend?.();
if (suspended?.catch) void suspended.catch(() => {});
}
function syncTuningNoiseGain() { function syncTuningNoiseGain() {
if (!layerState._tuningNoiseContext || !layerState._tuningNoiseGain) return; if (!layerState._tuningNoiseContext || !layerState._tuningNoiseGain) return;
const audible = radioTuningStaticShouldPlay({ const audible = radioTuningStaticShouldPlay({
@@ -79,8 +84,21 @@ export function createTuningNoise({
if (!AudioContextClass) return false; if (!AudioContextClass) return false;
if (!layerState._tuningNoiseContext) if (!layerState._tuningNoiseContext)
layerState._tuningNoiseContext = new AudioContextClass(); layerState._tuningNoiseContext = new AudioContextClass();
const resumed = layerState._tuningNoiseContext.resume?.(); const context = layerState._tuningNoiseContext;
if (resumed?.catch) void resumed.catch(() => {}); const resumed = context.resume?.();
// A release before the device starts finds the context still suspended,
// so its suspend() is a no-op; suspend again once resume() settles.
if (resumed?.then)
void resumed.then(
() => {
if (
layerState._tuningNoiseContext === context &&
!layerState._tuningNoiseSource
)
suspendTuningNoiseContext();
},
() => {},
);
if (layerState._tuningNoiseSource) return true; if (layerState._tuningNoiseSource) return true;
const frameCount = Math.max( const frameCount = Math.max(
@@ -120,6 +138,7 @@ export function createTuningNoise({
if (layerState._tuningCameraNavigation) if (layerState._tuningCameraNavigation)
parts.navigation.invalidateRadioCameraNavigation(); parts.navigation.invalidateRadioCameraNavigation();
layerState._tuningActive = false; layerState._tuningActive = false;
layerState._tuningResumesPlayback = false;
layerState._tuningStatic = false; layerState._tuningStatic = false;
layerState._tuningAwaitingStationId = null; layerState._tuningAwaitingStationId = null;
layerState._tuningPreviewId = null; layerState._tuningPreviewId = null;
@@ -131,8 +150,7 @@ export function createTuningNoise({
parts.rendering.updateSelectionEntity(); parts.rendering.updateSelectionEntity();
syncTuningNoiseGain(); syncTuningNoiseGain();
stopTuningNoiseSource(); stopTuningNoiseSource();
const suspended = layerState._tuningNoiseContext?.suspend?.(); suspendTuningNoiseContext();
if (suspended?.catch) void suspended.catch(() => {});
if (emit) parts.presentation.emitState(); if (emit) parts.presentation.emitState();
} }
return { return {
+19 -6
View File
@@ -151,12 +151,25 @@ export function createApplicationRequestServices({
const query = points const query = points
.map(({ lat, lon }) => `${lon.toFixed(5)},${lat.toFixed(5)}`) .map(({ lat, lon }) => `${lon.toFixed(5)},${lat.toFixed(5)}`)
.join(';'); .join(';');
return requireOk( const response = await request(
await request(`${urls.terrain}?points=${encodeURIComponent(query)}`, { `${urls.terrain}?points=${encodeURIComponent(query)}`,
signal, { signal },
}), );
'Terrain heights', // A failed request still carries the real heights the server had, so
)?.results; // the caller can keep them; null marks a point without one.
const partial = response.data?.partial;
if (
!response.ok &&
Array.isArray(partial) &&
partial.length === points.length
) {
const error = new Error(
`Terrain heights unavailable (${response.status})`,
);
error.partial = partial;
throw error;
}
return requireOk(response, 'Terrain heights')?.results;
}, },
}, },
regional: { regional: {
+17
View File
@@ -95,6 +95,23 @@ test('a failed probe is retried after its backoff, not on every query', async ()
]); ]);
}); });
test('a failed terrain request carries the real heights the server had', async () => {
const partial = [{ ellipsoid: 412.5 }, null];
const services = createApplicationRequestServices({
fetchImpl: async () => Response.json({ error: 'x', partial }, { status: 502 }),
});
const points = [{ lat: 30, lon: -97 }, { lat: 31, lon: -97 }];
await assert.rejects(services.terrain.getHeights(points), (error) => {
assert.match(error.message, /Terrain heights unavailable \(502\)/);
assert.deepEqual(error.partial, partial);
return true;
});
await assert.rejects(services.terrain.getHeights(points.slice(0, 1)), (error) => {
assert.equal(error.partial, undefined, 'a list of the wrong length is not attached');
return true;
});
});
test('the summary capability probe is asked once and unknown when unanswered', async () => { test('the summary capability probe is asked once and unknown when unanswered', async () => {
for (const [answer, expected] of [ for (const [answer, expected] of [
[() => Response.json({ configured: false }), false], [() => Response.json({ configured: false }), false],
+26 -6
View File
@@ -226,7 +226,8 @@ export function createTerrainHeights({
* - Remaining (deduplicated) coordinates are sent to `/api/terrain/heights` * - Remaining (deduplicated) coordinates are sent to `/api/terrain/heights`
* in sequential chunks of <=64 points. * in sequential chunks of <=64 points.
* - If a chunk request fails (network error, non-ok HTTP, malformed body), * - If a chunk request fails (network error, non-ok HTTP, malformed body),
* every point in THAT chunk falls back to geoid math * every point in THAT chunk without a real height in the failure's
* `partial` falls back to geoid math
* (`sourceOrthometricM + geoidHeight` or `geoidHeight` alone) rather than * (`sourceOrthometricM + geoidHeight` or `geoidHeight` alone) rather than
* failing the whole batch — a transient proxy outage in the middle of a * failing the whole batch — a transient proxy outage in the middle of a
* large batch shouldn't blank out entities whose chunk happened to land * large batch shouldn't blank out entities whose chunk happened to land
@@ -314,15 +315,34 @@ export function createTerrainHeights({
cacheStore(item.key, entry); cacheStore(item.key, entry);
} }
} }
} catch { } catch (error) {
requestSignal?.throwIfAborted(); requestSignal?.throwIfAborted();
// Proxy down (or cold cache had nothing to serve-stale) — fall back to // Proxy down (or cold cache had nothing to serve-stale) — fall back to
// geoid math for every point in this chunk. `ensureGeoidReady()` is // geoid math for the points in this chunk. A failure that still
// awaited lazily, only on the fallback path, so the common (proxy // carries real heights (the proxy's `partial`, e.g. cached points
// healthy) case never pays for the geoid grid's dynamic import. // during an upstream 429 pause) keeps them; only the rest use the
// geoid. `ensureGeoidReady()` is awaited lazily, only on the fallback
// path, so the common (proxy healthy) case never pays for the geoid
// grid's dynamic import.
const partial =
Array.isArray(error?.partial) && error.partial.length === chunk.length
? error.partial
: [];
const fallback = [];
chunk.forEach((item, index) => {
const ellipsoid = partial[index]?.ellipsoid;
if (!Number.isFinite(ellipsoid)) {
fallback.push(item);
return;
}
const entry = { ellipsoid, source: 'reearth' };
resolvedThisCall.set(item.key, entry);
cacheStore(item.key, entry);
});
if (fallback.length === 0) continue;
await ensureGeoidReady(); await ensureGeoidReady();
requestSignal?.throwIfAborted(); requestSignal?.throwIfAborted();
for (const item of chunk) { for (const item of fallback) {
const ellipsoid = geoidFallback( const ellipsoid = geoidFallback(
item.lat, item.lat,
item.lon, item.lon,
+99
View File
@@ -0,0 +1,99 @@
import { radioLayer } from '../data/radio.js';
export function radioStationRow(overrides = {}) {
return {
id: '00000000-0000-4000-8000-000000000001',
name: 'Station',
lat: 30,
lon: -97,
streamUrl: 'https://radio.example.com/station.mp3',
homepage: null,
tags: ['news'],
languages: ['English'],
state: 'Texas',
country: 'United States',
countryCode: 'US',
metadataTrust: 'untrusted-community',
codec: 'MP3',
bitrate: 128,
...overrides,
};
}
/**
* Run `body` against an enabled `radioLayer` that serves `stations`.
* Streams listed in `failingUrls` (mutable) reject play(); `body` receives the
* played URLs and the catalog, whose rows and generation the next update() serves.
*/
export async function withRadioLayer({ stations, failingUrls = [] }, body) {
const originalFetch = globalThis.fetch;
const originalAudio = globalThis.Audio;
const playUrls = [];
const catalog = { rows: stations, generation: 1 };
globalThis.Audio = class FakeAudio {
constructor() {
this.volume = 0.8;
this.src = '';
}
addEventListener() {}
pause() {}
play() {
playUrls.push(this.src);
if (failingUrls.includes(this.src)) {
return Promise.reject(
new DOMException('unavailable', 'NotSupportedError'),
);
}
return Promise.resolve();
}
removeAttribute() {
this.src = '';
}
load() {}
};
globalThis.fetch = async (url) =>
String(url).startsWith('/api/radio/click/')
? { ok: true }
: new Response(
JSON.stringify({
stations: catalog.rows,
stale: false,
degraded: false,
acceptedGeneration: catalog.generation,
catalogInstance: 'qa-instance-a',
updatedAt: new Date().toISOString(),
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
const viewer = {
camera: { positionWC: { x: 7_000_000, y: 0, z: 0 } },
scene: {
canvas: {
disableRootEvents: true,
onwheel: null,
addEventListener() {},
removeEventListener() {},
},
},
dataSources: { add() {}, remove() {} },
entities: {
add(entity) {
return entity;
},
remove() {},
},
};
radioLayer.destroy();
try {
radioLayer.init(viewer);
radioLayer.enable();
await radioLayer.update();
await body({ playUrls, catalog });
} finally {
globalThis.fetch = originalFetch;
if (originalAudio === undefined) delete globalThis.Audio;
else globalThis.Audio = originalAudio;
radioLayer.destroy();
}
}
+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);
},
});
+23 -5
View File
@@ -1,7 +1,12 @@
/** Composite queries that combine other tools' answers for one area. */ /** Composite queries that combine other tools' answers for one area. */
import { suggestView } from '../views.js'; import { suggestView } from '../views.js';
import { feedProvenanceEnvelope } from '../../data/layerSnapshot.js'; import {
feedProvenanceEnvelope,
hudSummaryMatchesProvenance,
provenanceLine,
provenanceTag,
} from '../../data/layerSnapshot.js';
import { defineTool, ToolError } from '../catalog.js'; import { defineTool, ToolError } from '../catalog.js';
import { import {
AREA_SCHEMA, AREA_SCHEMA,
@@ -232,6 +237,7 @@ export const getHudCaption = defineTool({
feedState: sectionFeedState(brief[key]), feedState: sectionFeedState(brief[key]),
}), }),
); );
const feedProvenance = feedProvenanceEnvelope(layers);
const response = await context.services.summary.summarize( const response = await context.services.summary.summarize(
{ {
placeLabels: [area.label], placeLabels: [area.label],
@@ -244,16 +250,28 @@ export const getHudCaption = defineTool({
name, name,
feedState, feedState,
})), })),
feedProvenance: feedProvenanceEnvelope(layers), feedProvenance,
}, },
{ signal: context.signal }, { signal: context.signal },
); );
const caption = response?.data?.summary; const words = response?.data?.summary;
if (!response?.ok || typeof caption !== 'string' || !caption) if (!response?.ok || typeof words !== 'string' || !words)
throw new ToolError('unavailable', 'The caption service did not answer'); throw new ToolError('unavailable', 'The caption service did not answer');
// As on the HUD: the app's own state leads and its layers trail, and a
// reply that names another state than the app's keeps only the place.
const caption = provenanceLine(
hudSummaryMatchesProvenance(response.data, feedProvenance)
? words
: area.label,
provenanceTag(feedProvenance),
);
return { return {
summary: caption, summary: caption,
data: { area: area.label, caption }, data: {
area: area.label,
caption,
feedState: feedProvenance.overall,
},
}; };
}, },
}); });
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { composeCatalog, coreTools } from '../index.js';
// get_hud_caption shows the app's own feed state, not the model's words.
async function staleCaption(reply) {
const conditions = {
observedAt: '2026-10-01T19:15:00.000Z',
temperatureC: 26.3,
apparentTemperatureC: 32,
precipitationMm: 0,
cloudCoverPct: 100,
windKph: 5.4,
windDirectionDeg: 318,
visibilityM: 15400,
weatherCode: 3,
};
const places = {
resolve: async (name) =>
name === 'Austin'
? {
name: 'Austin, Texas',
bounds: { west: -97.94, south: 30.1, east: -97.56, north: 30.52 },
}
: null,
};
const staleWeather = {
getConditions: async () => ({ status: 'stale', weather: conditions }),
};
let sent = null;
const summary = {
async summarize(context) {
sent = context;
return { ok: true, status: 200, data: { error: null, ...reply } };
},
};
const catalog = composeCatalog({
tools: coreTools,
services: { weather: staleWeather, summary, places },
});
const result = await catalog.call('get_hud_caption', {
area: { place: 'Austin' },
});
assert.equal(sent.feedProvenance.overall, 'stale');
return result.data;
}
test('get_hud_caption keeps the canonical feed state of a stale view', async () => {
const data = await staleCaption({
summary: 'Austin weather across the area',
feedState: 'stale',
});
assert.equal(data.feedState, 'stale');
assert.equal(
data.caption,
'STALE | Austin weather across the area | WEATHER',
);
});
test('get_hud_caption drops words whose feed state is not the app one', async () => {
const data = await staleCaption({
summary: 'Austin live weather looks fine',
feedState: 'nominal',
});
assert.equal(data.feedState, 'stale');
assert.equal(data.caption, 'STALE | Austin, Texas | WEATHER');
});
+3
View File
@@ -241,9 +241,12 @@ export function bindRadioControls() {
const pool = this._radioTunerPool.length const pool = this._radioTunerPool.length
? this._radioTunerPool ? this._radioTunerPool
: this._radioTunerStations; : this._radioTunerStations;
// Silent unless a stream is active; an active stream follows the selection.
const { audioState } = this.radio.getUIState();
const cycled = this.radio.cycleStation(direction, { const cycled = this.radio.cycleStation(direction, {
rotate, rotate,
stationIds: pool.map((station) => station.id), stationIds: pool.map((station) => station.id),
autoplay: ['loading', 'buffering', 'playing'].includes(audioState),
origin: 'user', origin: 'user',
}); });
if (!cycled) { if (!cycled) {
+35 -1
View File
@@ -47,7 +47,9 @@ function fixture() {
publishTuner() {}, publishTuner() {},
}; };
const calls = []; const calls = [];
const cycleOptions = [];
const subscribers = []; const subscribers = [];
const uiState = { audioState: 'stopped' };
const radio = { const radio = {
subscribe(callback) { subscribe(callback) {
subscribers.push(callback); subscribers.push(callback);
@@ -57,10 +59,12 @@ function fixture() {
endTuning() { endTuning() {
calls.push('endTuning'); calls.push('endTuning');
}, },
cycleStation(direction) { cycleStation(direction, options) {
calls.push(['cycle', direction]); calls.push(['cycle', direction]);
cycleOptions.push(options);
return true; return true;
}, },
getUIState: () => uiState,
}; };
const actions = { const actions = {
isRegistered: () => true, isRegistered: () => true,
@@ -90,6 +94,8 @@ function fixture() {
store, store,
actions, actions,
calls, calls,
cycleOptions,
uiState,
subscribers, subscribers,
document, document,
cleanup() { cleanup() {
@@ -177,3 +183,31 @@ test('destruction releases an active tuner pointer after revoking listeners', ()
f.cleanup(); f.cleanup();
} }
}); });
test('Next and Previous play only while a stream is active', () => {
const f = fixture();
try {
f.controls.connect();
for (const audioState of ['stopped', 'paused', 'error']) {
f.uiState.audioState = audioState;
f.store.act('cycleRadio', 1);
f.store.act('cycleRadio', -1);
}
assert.deepEqual(
f.cycleOptions.map((options) => options.autoplay),
[false, false, false, false, false, false],
);
f.cycleOptions.length = 0;
for (const audioState of ['loading', 'buffering', 'playing']) {
f.uiState.audioState = audioState;
f.store.act('cycleRadio', 1);
f.store.act('cycleRadio', -1);
}
assert.deepEqual(
f.cycleOptions.map((options) => options.autoplay),
[true, true, true, true, true, true],
);
} finally {
f.cleanup();
}
});
+3 -1
View File
@@ -25,7 +25,9 @@ export function renderRadioState(state) {
const interactive = enabled && !transitioning && !uncertain; const interactive = enabled && !transitioning && !uncertain;
const selected = state.selected || null; const selected = state.selected || null;
const hasStations = state.filteredCount > 0; const hasStations = state.filteredCount > 0;
const activePlayback = ['playing', 'buffering'].includes(state.audioState); const activePlayback = ['loading', 'buffering', 'playing'].includes(
state.audioState,
);
document document
.getElementById('title-bar') .getElementById('title-bar')
?.classList.toggle('radio-broadcasting', state.audioState === 'playing'); ?.classList.toggle('radio-broadcasting', state.audioState === 'playing');
+1 -1
View File
@@ -67,7 +67,7 @@ const radioWords = computed(() => ({
<a id="radio-station-homepage" :href="radio ? radio.station.homepage || undefined : '#'" target="_blank" rel="noopener noreferrer" :hidden="radio ? !radio.station.homepage : true">{{ t('STATION SITE') }}</a> <a id="radio-station-homepage" :href="radio ? radio.station.homepage || undefined : '#'" target="_blank" rel="noopener noreferrer" :hidden="radio ? !radio.station.homepage : true">{{ t('STATION SITE') }}</a>
<a href="https://www.radio-browser.info/" target="_blank" rel="noopener noreferrer">{{ t('DIRECTORY: RADIO BROWSER') }}</a> <a href="https://www.radio-browser.info/" target="_blank" rel="noopener noreferrer">{{ t('DIRECTORY: RADIO BROWSER') }}</a>
</div> </div>
<p class="radio-privacy">{{ t('Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.') }}</p> <p class="radio-privacy">{{ t('Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.') }}</p>
<SdrCard /> <SdrCard />
</RailPanel> </RailPanel>
</template> </template>
+6 -1
View File
@@ -2273,7 +2273,11 @@ export async function controlRadio(
if (!radioActionIsCurrent(options)) return cancelled(summarize); if (!radioActionIsCurrent(options)) return cancelled(summarize);
const prepared = state.selected const prepared = state.selected
? true ? true
: radio.cycleStation?.(1, { focus: false, autoplay: false }); : radio.cycleStation?.(1, {
focus: false,
autoplay: false,
fallback: true,
});
return { return {
ok: Boolean(prepared), ok: Boolean(prepared),
action: 'control_radio', action: 'control_radio',
@@ -2297,6 +2301,7 @@ export async function controlRadio(
const selected = radio.cycleStation?.(action === 'next' ? 1 : -1, { const selected = radio.cycleStation?.(action === 'next' ? 1 : -1, {
focus: false, focus: false,
autoplay: false, autoplay: false,
fallback: true,
}); });
return { return {
ok: Boolean(selected), ok: Boolean(selected),
+18 -1
View File
@@ -2190,7 +2190,7 @@ test('voice Radio resolves Austin and exposes semantic selection, volume, pause,
assert.equal(result.radioAction, 'play'); assert.equal(result.radioAction, 'play');
assert.equal(result.stationId, 'nearest'); assert.equal(result.stationId, 'nearest');
assert.equal('station' in result, false); assert.equal('station' in result, false);
assert.deepEqual(calls.at(-1), ['cycle', 1, { focus: false, autoplay: false }]); assert.deepEqual(calls.at(-1), ['cycle', 1, { focus: false, autoplay: false, fallback: true }]);
for (const invalidCoordinates of [ for (const invalidCoordinates of [
{ latitude: null, longitude: null }, { latitude: null, longitude: null },
@@ -4317,3 +4317,20 @@ test('set_panel_open reaches the Utilities panel by its name, Draw or Import', a
} }
assert.deepEqual(calls.at(-1), ['utilities-panel', false, { explicit: true }]); assert.deepEqual(calls.at(-1), ['utilities-panel', false, { explicit: true }]);
}); });
test('a partial analyst card shows one partial chip', () => {
const card = presentResult('analyst_query', {
ok: true,
action: 'analyst_query',
count: 3,
feedState: 'partial',
partial: true,
unanswered: ['vessels'],
summary: '3 contacts',
scopeLabel: 'within 5 km of X',
display: { scope: 'within 5 km of X' },
coverage: {},
});
const labels = card.display.chips.map((chip) => chip.label);
assert.equal(new Set(labels).size, labels.length, JSON.stringify(labels));
});
+13 -4
View File
@@ -591,7 +591,15 @@ test('replacement input invalidates an in-flight Radio preflight immediately', (
assert.equal(controller.radioHandoffEpoch, priorEpoch + 1); assert.equal(controller.radioHandoffEpoch, priorEpoch + 1);
}); });
test('direct Radio pause or stop cancels a pending voice handoff', async () => { for (const [name, control, message] of [
['direct Radio pause or stop cancels a pending voice handoff', 'stop', 'the explicit stop remains authoritative'],
[
'a direct Radio Next that does not play cancels a pending voice handoff',
{ action: 'select', origin: 'user', attemptId: null },
'the user selection is not overridden by voice playback',
],
]) {
test(name, async () => {
const order = []; const order = [];
let playbackControl = null; let playbackControl = null;
const ui = createTestVoiceUi(); const ui = createTestVoiceUi();
@@ -618,14 +626,15 @@ test('direct Radio pause or stop cancels a pending voice handoff', async () => {
}; };
controller.pendingRadioPlaybackResult = { ok: true, radioPlaybackRequested: true }; controller.pendingRadioPlaybackResult = { ok: true, radioPlaybackRequested: true };
playbackControl('stop'); playbackControl(control);
await controller.handleRealtimeEvent({ await controller.handleRealtimeEvent({
data: JSON.stringify({ type: 'response.done', response: { status: 'completed' } }), data: JSON.stringify({ type: 'response.done', response: { status: 'completed' } }),
}); });
assert.equal(controller.pendingRadioPlaybackResult, null); assert.equal(controller.pendingRadioPlaybackResult, null);
assert.deepEqual(order, [], 'the explicit stop remains authoritative'); assert.deepEqual(order, [], message);
}); });
}
test('confirmed manual Radio playback closes active voice without stopping Radio', () => { test('confirmed manual Radio playback closes active voice without stopping Radio', () => {
const order = []; const order = [];
+1 -1
View File
@@ -65,7 +65,7 @@ export class RealtimeRadio {
: control || {}; : control || {};
if ( if (
event.origin === 'user' && event.origin === 'user' &&
(event.action === 'pause' || event.action === 'stop') ['pause', 'stop', 'select'].includes(event.action)
) { ) {
this.cancelRadioHandoff(); this.cancelRadioHandoff();
} else if ( } else if (
+4 -1
View File
@@ -422,7 +422,10 @@ function analystCard(result) {
...(result.feedState && result.feedState !== 'nominal' ...(result.feedState && result.feedState !== 'nominal'
? [{ label: shownFeedState(result.feedState) }] ? [{ 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 // Coverage qualifications lead the feed detail. The bounded card groups
// overflow in its existing Notes disclosure rather than dropping them. // overflow in its existing Notes disclosure rather than dropping them.