mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
i18n: fix the repeat-review findings
- Status pieces translate in one place (src/ui/statusText.js): a Local
ADS-B error with a single piece now translates, and the analyst card
splits empty-layer notes into pieces (Transit, Local ADS-B).
- Translate the ALPR retry chip text, the ECCC error on the Air quality
card, "Upstream response too large" and the "entry {n}" feed label.
- The unmarked-text check also sees error/loadingLabel/statusMessage/
status assignments, `status:` properties and showToast?.( /
this._showToast( calls; the scene import preview marks its pack
statuses, data and model-facing text carry i18n-ignore with a reason.
- Tests check the real code: the HUD locality formatter moves to
src/hudLocalityFormat.js, the unsaved-keys test pins the teardown.
- One copy each: city names come from src/cityPois.js, the GNSS HTTP
template and wind "field unavailable" texts; dead fallbacks removed.
- Leftovers of the reverted vessel-label change and the reformatting of
detectionHost.test.mjs / keySetup.test.mjs are back to main.
Catalogs: es/ru 2617 messages each, complete.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
a2463ca2d2
commit
3157888780
60 files changed
+1577
-1606
No files matched your search
+515
@@ -0,0 +1,515 @@
|
||||
/**
|
||||
* @module cityPois
|
||||
* @description The preset cities and their landmarks — plain data with no
|
||||
* Cesium, so modules that only need the names (voice cards) can import it.
|
||||
* `locations.js` re-exports it.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Points of Interest per city.
|
||||
* Each city has 5 POIs; the first is the default fly-to landmark.
|
||||
*
|
||||
* Field reference:
|
||||
* alt — RANGE (distance from target in meters), NOT absolute altitude
|
||||
* heading — optimal camera heading in degrees (0=N, 90=E, 180=S, 270=W)
|
||||
* pitch — camera tilt in degrees (negative = looking down)
|
||||
* buildingHeight — estimated height of landmark center above ground (meters)
|
||||
*/
|
||||
export const CITY_POIS = {
|
||||
austin: {
|
||||
name: 'Austin',
|
||||
groundElevation: 150, // meters above WGS84 ellipsoid
|
||||
viewBounds: {
|
||||
southwest: { lat: 30.1, lng: -97.95 },
|
||||
northeast: { lat: 30.52, lng: -97.55 },
|
||||
},
|
||||
pois: [
|
||||
{
|
||||
name: 'Texas State Capitol',
|
||||
lat: 30.2747,
|
||||
lon: -97.7403,
|
||||
alt: 550,
|
||||
pitch: -28,
|
||||
heading: 180,
|
||||
buildingHeight: 35,
|
||||
},
|
||||
{
|
||||
name: 'Frost Bank Tower',
|
||||
lat: 30.2674,
|
||||
lon: -97.7434,
|
||||
alt: 550,
|
||||
pitch: -22,
|
||||
heading: 30,
|
||||
buildingHeight: 80,
|
||||
},
|
||||
{
|
||||
name: 'Pennybacker Bridge',
|
||||
lat: 30.3451,
|
||||
lon: -97.7951,
|
||||
alt: 500,
|
||||
pitch: -25,
|
||||
heading: 90,
|
||||
buildingHeight: 40,
|
||||
},
|
||||
{
|
||||
name: 'The Jenga Tower',
|
||||
lat: 30.2642,
|
||||
lon: -97.75,
|
||||
alt: 500,
|
||||
pitch: -18,
|
||||
heading: 45,
|
||||
buildingHeight: 60,
|
||||
},
|
||||
{
|
||||
name: 'UT Tower',
|
||||
lat: 30.2862,
|
||||
lon: -97.7394,
|
||||
alt: 500,
|
||||
pitch: -22,
|
||||
heading: 180,
|
||||
buildingHeight: 50,
|
||||
},
|
||||
],
|
||||
},
|
||||
sf: {
|
||||
name: 'San Francisco',
|
||||
groundElevation: 15,
|
||||
viewBounds: {
|
||||
southwest: { lat: 37.7, lng: -122.53 },
|
||||
northeast: { lat: 37.84, lng: -122.35 },
|
||||
},
|
||||
pois: [
|
||||
{
|
||||
name: 'Golden Gate Bridge',
|
||||
lat: 37.8199,
|
||||
lon: -122.4783,
|
||||
alt: 1400,
|
||||
pitch: -20,
|
||||
heading: 45,
|
||||
buildingHeight: 100,
|
||||
},
|
||||
{
|
||||
name: 'Transamerica Pyramid',
|
||||
lat: 37.7952,
|
||||
lon: -122.4028,
|
||||
alt: 500,
|
||||
pitch: -25,
|
||||
heading: 30,
|
||||
buildingHeight: 85,
|
||||
},
|
||||
{
|
||||
name: 'Salesforce Tower',
|
||||
lat: 37.7897,
|
||||
lon: -122.3972,
|
||||
alt: 680,
|
||||
pitch: -25,
|
||||
heading: 330,
|
||||
buildingHeight: 100,
|
||||
},
|
||||
{
|
||||
name: 'Alcatraz Island',
|
||||
lat: 37.8267,
|
||||
lon: -122.423,
|
||||
alt: 800,
|
||||
pitch: -30,
|
||||
heading: 0,
|
||||
buildingHeight: 20,
|
||||
},
|
||||
{
|
||||
name: 'Coit Tower',
|
||||
lat: 37.8024,
|
||||
lon: -122.4058,
|
||||
alt: 420,
|
||||
pitch: -30,
|
||||
heading: 45,
|
||||
buildingHeight: 30,
|
||||
},
|
||||
],
|
||||
},
|
||||
nyc: {
|
||||
name: 'New York',
|
||||
groundElevation: 10,
|
||||
viewBounds: {
|
||||
southwest: { lat: 40.477, lng: -74.259 },
|
||||
northeast: { lat: 40.918, lng: -73.7 },
|
||||
},
|
||||
pois: [
|
||||
{
|
||||
name: 'Statue of Liberty',
|
||||
lat: 40.6892,
|
||||
lon: -74.0445,
|
||||
alt: 450,
|
||||
pitch: -25,
|
||||
heading: 315,
|
||||
buildingHeight: 45,
|
||||
},
|
||||
{
|
||||
name: 'Empire State Building',
|
||||
lat: 40.7484,
|
||||
lon: -73.9857,
|
||||
alt: 850,
|
||||
pitch: -12,
|
||||
heading: 30,
|
||||
buildingHeight: 130,
|
||||
buildingBounds: { height: 443, width: 130, depth: 75 },
|
||||
},
|
||||
{
|
||||
name: 'One World Trade Center',
|
||||
lat: 40.7127,
|
||||
lon: -74.0134,
|
||||
alt: 850,
|
||||
pitch: -25,
|
||||
heading: 0,
|
||||
buildingHeight: 170,
|
||||
},
|
||||
{
|
||||
name: 'Brooklyn Bridge',
|
||||
lat: 40.7061,
|
||||
lon: -73.9969,
|
||||
alt: 850,
|
||||
pitch: -25,
|
||||
heading: 45,
|
||||
buildingHeight: 40,
|
||||
},
|
||||
{
|
||||
name: 'Chrysler Building',
|
||||
lat: 40.7516,
|
||||
lon: -73.9755,
|
||||
alt: 700,
|
||||
pitch: -20,
|
||||
heading: 225,
|
||||
buildingHeight: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
tokyo: {
|
||||
name: 'Tokyo',
|
||||
groundElevation: 40,
|
||||
viewBounds: {
|
||||
southwest: { lat: 35.52, lng: 139.55 },
|
||||
northeast: { lat: 35.9, lng: 139.92 },
|
||||
},
|
||||
pois: [
|
||||
{
|
||||
name: 'Tokyo Tower',
|
||||
lat: 35.6586,
|
||||
lon: 139.7454,
|
||||
alt: 850,
|
||||
pitch: -25,
|
||||
heading: 0,
|
||||
buildingHeight: 110,
|
||||
},
|
||||
{
|
||||
name: 'Tokyo Skytree',
|
||||
lat: 35.7101,
|
||||
lon: 139.8107,
|
||||
alt: 900,
|
||||
pitch: -25,
|
||||
heading: 30,
|
||||
buildingHeight: 200,
|
||||
},
|
||||
{
|
||||
name: 'Imperial Palace',
|
||||
lat: 35.6852,
|
||||
lon: 139.7528,
|
||||
alt: 900,
|
||||
pitch: -35,
|
||||
heading: 0,
|
||||
buildingHeight: 20,
|
||||
},
|
||||
{
|
||||
name: 'Senso-ji Temple',
|
||||
lat: 35.7148,
|
||||
lon: 139.7967,
|
||||
alt: 400,
|
||||
pitch: -30,
|
||||
heading: 180,
|
||||
buildingHeight: 25,
|
||||
},
|
||||
{
|
||||
name: 'Mode Gakuen Cocoon Tower',
|
||||
lat: 35.6929,
|
||||
lon: 139.6925,
|
||||
alt: 350,
|
||||
pitch: -20,
|
||||
heading: 30,
|
||||
buildingHeight: 70,
|
||||
},
|
||||
],
|
||||
},
|
||||
london: {
|
||||
name: 'London',
|
||||
groundElevation: 15,
|
||||
viewBounds: {
|
||||
southwest: { lat: 51.28, lng: -0.51 },
|
||||
northeast: { lat: 51.7, lng: 0.33 },
|
||||
},
|
||||
pois: [
|
||||
{
|
||||
name: 'Tower Bridge',
|
||||
lat: 51.5055,
|
||||
lon: -0.0754,
|
||||
alt: 400,
|
||||
pitch: -25,
|
||||
heading: 270,
|
||||
buildingHeight: 65,
|
||||
},
|
||||
{
|
||||
name: 'The Shard',
|
||||
lat: 51.5045,
|
||||
lon: -0.0865,
|
||||
alt: 850,
|
||||
pitch: -20,
|
||||
heading: 0,
|
||||
buildingHeight: 100,
|
||||
},
|
||||
{
|
||||
name: 'Big Ben / Parliament',
|
||||
lat: 51.5007,
|
||||
lon: -0.1246,
|
||||
alt: 600,
|
||||
pitch: -25,
|
||||
heading: 180,
|
||||
buildingHeight: 50,
|
||||
},
|
||||
{
|
||||
name: "St. Paul's Cathedral",
|
||||
lat: 51.5138,
|
||||
lon: -0.0984,
|
||||
alt: 400,
|
||||
pitch: -30,
|
||||
heading: 270,
|
||||
buildingHeight: 55,
|
||||
},
|
||||
{
|
||||
name: 'The Gherkin',
|
||||
lat: 51.5145,
|
||||
lon: -0.0803,
|
||||
alt: 350,
|
||||
pitch: -20,
|
||||
heading: 30,
|
||||
buildingHeight: 60,
|
||||
},
|
||||
],
|
||||
},
|
||||
paris: {
|
||||
name: 'Paris',
|
||||
groundElevation: 35,
|
||||
viewBounds: {
|
||||
southwest: { lat: 48.815, lng: 2.224 },
|
||||
northeast: { lat: 48.902, lng: 2.47 },
|
||||
},
|
||||
pois: [
|
||||
{
|
||||
name: 'Eiffel Tower',
|
||||
lat: 48.8584,
|
||||
lon: 2.2945,
|
||||
alt: 750,
|
||||
pitch: -25,
|
||||
heading: 315,
|
||||
buildingHeight: 150,
|
||||
},
|
||||
{
|
||||
name: 'Arc de Triomphe',
|
||||
lat: 48.8738,
|
||||
lon: 2.295,
|
||||
alt: 400,
|
||||
pitch: -28,
|
||||
heading: 45,
|
||||
buildingHeight: 25,
|
||||
},
|
||||
{
|
||||
name: 'Notre-Dame',
|
||||
lat: 48.853,
|
||||
lon: 2.3499,
|
||||
alt: 400,
|
||||
pitch: -25,
|
||||
heading: 225,
|
||||
buildingHeight: 35,
|
||||
},
|
||||
{
|
||||
name: 'Sacré-Cœur',
|
||||
lat: 48.8867,
|
||||
lon: 2.3431,
|
||||
alt: 400,
|
||||
pitch: -30,
|
||||
heading: 180,
|
||||
buildingHeight: 40,
|
||||
},
|
||||
{
|
||||
name: 'Louvre Pyramid',
|
||||
lat: 48.8606,
|
||||
lon: 2.3376,
|
||||
alt: 500,
|
||||
pitch: -35,
|
||||
heading: 0,
|
||||
buildingHeight: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
dubai: {
|
||||
name: 'Dubai',
|
||||
groundElevation: 5,
|
||||
viewBounds: {
|
||||
southwest: { lat: 24.95, lng: 54.9 },
|
||||
northeast: { lat: 25.35, lng: 55.55 },
|
||||
},
|
||||
pois: [
|
||||
{
|
||||
name: 'Burj Khalifa',
|
||||
lat: 25.1972,
|
||||
lon: 55.2744,
|
||||
alt: 600,
|
||||
pitch: -20,
|
||||
heading: 200,
|
||||
buildingHeight: 270,
|
||||
},
|
||||
{
|
||||
name: 'Burj Al Arab',
|
||||
lat: 25.1412,
|
||||
lon: 55.1853,
|
||||
alt: 500,
|
||||
pitch: -25,
|
||||
heading: 90,
|
||||
buildingHeight: 100,
|
||||
},
|
||||
{
|
||||
name: 'Palm Jumeirah',
|
||||
lat: 25.1124,
|
||||
lon: 55.139,
|
||||
alt: 1200,
|
||||
pitch: -40,
|
||||
heading: 0,
|
||||
buildingHeight: 20,
|
||||
},
|
||||
{
|
||||
name: 'Dubai Frame',
|
||||
lat: 25.235,
|
||||
lon: 55.3003,
|
||||
alt: 400,
|
||||
pitch: -25,
|
||||
heading: 270,
|
||||
buildingHeight: 75,
|
||||
},
|
||||
{
|
||||
name: 'Museum of the Future',
|
||||
lat: 25.2197,
|
||||
lon: 55.2806,
|
||||
alt: 350,
|
||||
pitch: -20,
|
||||
heading: 30,
|
||||
buildingHeight: 35,
|
||||
},
|
||||
],
|
||||
},
|
||||
dc: {
|
||||
name: 'Washington DC',
|
||||
groundElevation: 10,
|
||||
viewBounds: {
|
||||
southwest: { lat: 38.79, lng: -77.12 },
|
||||
northeast: { lat: 38.995, lng: -76.91 },
|
||||
},
|
||||
pois: [
|
||||
{
|
||||
name: 'US Capitol',
|
||||
lat: 38.8897,
|
||||
lon: -77.0091,
|
||||
alt: 550,
|
||||
pitch: -25,
|
||||
heading: 270,
|
||||
buildingHeight: 45,
|
||||
},
|
||||
{
|
||||
name: 'Washington Monument',
|
||||
lat: 38.8895,
|
||||
lon: -77.0353,
|
||||
alt: 500,
|
||||
pitch: -30,
|
||||
heading: 0,
|
||||
buildingHeight: 85,
|
||||
},
|
||||
{
|
||||
name: 'Lincoln Memorial',
|
||||
lat: 38.8893,
|
||||
lon: -77.0502,
|
||||
alt: 400,
|
||||
pitch: -25,
|
||||
heading: 90,
|
||||
buildingHeight: 20,
|
||||
},
|
||||
{
|
||||
name: 'Pentagon',
|
||||
lat: 38.8711,
|
||||
lon: -77.0559,
|
||||
alt: 800,
|
||||
pitch: -40,
|
||||
heading: 0,
|
||||
buildingHeight: 20,
|
||||
},
|
||||
{
|
||||
name: 'Jefferson Memorial',
|
||||
lat: 38.8814,
|
||||
lon: -77.0365,
|
||||
alt: 400,
|
||||
pitch: -30,
|
||||
heading: 0,
|
||||
buildingHeight: 25,
|
||||
},
|
||||
],
|
||||
},
|
||||
tallinn: {
|
||||
name: 'Tallinn',
|
||||
groundElevation: 15,
|
||||
viewBounds: {
|
||||
southwest: { lat: 59.36, lng: 24.6 },
|
||||
northeast: { lat: 59.52, lng: 24.92 },
|
||||
},
|
||||
pois: [
|
||||
{
|
||||
name: 'Viru Square',
|
||||
lat: 59.4366,
|
||||
lon: 24.7527,
|
||||
alt: 450,
|
||||
pitch: -28,
|
||||
heading: 60,
|
||||
buildingHeight: 25,
|
||||
},
|
||||
{
|
||||
name: 'Old Town / Raekoja plats',
|
||||
lat: 59.4372,
|
||||
lon: 24.7452,
|
||||
alt: 400,
|
||||
pitch: -30,
|
||||
heading: 180,
|
||||
buildingHeight: 20,
|
||||
},
|
||||
{
|
||||
name: 'Teatri väljak',
|
||||
lat: 59.4344,
|
||||
lon: 24.7514,
|
||||
alt: 400,
|
||||
pitch: -25,
|
||||
heading: 220,
|
||||
buildingHeight: 25,
|
||||
},
|
||||
{
|
||||
name: 'Port of Tallinn',
|
||||
lat: 59.4445,
|
||||
lon: 24.7675,
|
||||
alt: 700,
|
||||
pitch: -30,
|
||||
heading: 90,
|
||||
buildingHeight: 20,
|
||||
},
|
||||
{
|
||||
name: 'Ülemiste',
|
||||
lat: 59.421,
|
||||
lon: 24.792,
|
||||
alt: 600,
|
||||
pitch: -28,
|
||||
heading: 45,
|
||||
buildingHeight: 30,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -249,6 +249,7 @@ export function createAisWatchdog(options = {}) {
|
||||
if (!env.hasKey) {
|
||||
const actions = terminateOwned('key-absent');
|
||||
status = 'missing-key';
|
||||
// i18n-ignore: server detail; the chip shows a translated reason (vessels/queries.js)
|
||||
error = 'AISSTREAM_API_KEY is not set';
|
||||
reconnectAttempt = 0;
|
||||
keyFingerprint = null;
|
||||
@@ -257,6 +258,7 @@ export function createAisWatchdog(options = {}) {
|
||||
if (env.hasTransport === false) {
|
||||
const actions = terminateOwned('no-transport');
|
||||
status = 'unsupported';
|
||||
// i18n-ignore: server detail; the chip shows a translated reason (vessels/queries.js)
|
||||
error = 'Node WebSocket transport is unavailable';
|
||||
return actions;
|
||||
}
|
||||
@@ -329,6 +331,7 @@ export function createAisWatchdog(options = {}) {
|
||||
if (!silenceWatchArmed) return [];
|
||||
const silentFor = monoNow - silenceSinceMono;
|
||||
if (silentFor >= recycleAfterMs) {
|
||||
// i18n-ignore: server detail; the chip shows a translated reason (vessels/queries.js)
|
||||
error = `AISStream delivered no data for ${Math.round(silentFor / 1000)}s`;
|
||||
const actions = terminateOwned('silent');
|
||||
scheduleRetry('transport');
|
||||
@@ -401,6 +404,7 @@ export function createAisWatchdog(options = {}) {
|
||||
function onClose(eventGeneration) {
|
||||
if (!ownsGeneration(eventGeneration)) return [];
|
||||
release();
|
||||
// i18n-ignore: server detail; the chip shows a translated reason (vessels/queries.js)
|
||||
if (!error) error = 'AISStream websocket closed';
|
||||
scheduleRetry('transport');
|
||||
return [];
|
||||
|
||||
+154
-431
@@ -27,25 +27,17 @@ import { DETECTION_THEME_MAP } from '../overlays/worldOverlayTokens.js';
|
||||
import { composeLabel, resolveTier } from './detectionDraw.js';
|
||||
|
||||
test('detection diagnostics count rendered fading rows instead of absent selected identities', () => {
|
||||
assert.equal(
|
||||
countFadingRenderEntries([
|
||||
{ selected: true },
|
||||
{ selected: false },
|
||||
{ selected: true },
|
||||
{ selected: false },
|
||||
]),
|
||||
2,
|
||||
);
|
||||
assert.equal(
|
||||
countFadingRenderEntries([{ selected: true }, { selected: true }]),
|
||||
0,
|
||||
);
|
||||
assert.equal(countFadingRenderEntries([
|
||||
{ selected: true },
|
||||
{ selected: false },
|
||||
{ selected: true },
|
||||
{ selected: false },
|
||||
]), 2);
|
||||
assert.equal(countFadingRenderEntries([{ selected: true }, { selected: true }]), 0);
|
||||
});
|
||||
|
||||
class MockEvent {
|
||||
constructor() {
|
||||
this.listeners = new Set();
|
||||
}
|
||||
constructor() { this.listeners = new Set(); }
|
||||
|
||||
addEventListener(listener) {
|
||||
this.listeners.add(listener);
|
||||
@@ -77,127 +69,62 @@ function mockContext(target, trace) {
|
||||
return {
|
||||
calls,
|
||||
font: '',
|
||||
get filter() {
|
||||
return filter;
|
||||
},
|
||||
set filter(value) {
|
||||
filter = value;
|
||||
record('filter', value);
|
||||
},
|
||||
get filter() { return filter; },
|
||||
set filter(value) { filter = value; record('filter', value); },
|
||||
fillStyle: '',
|
||||
get strokeStyle() {
|
||||
return strokeStyle;
|
||||
},
|
||||
set strokeStyle(value) {
|
||||
strokeStyle = value;
|
||||
record('strokeStyle', value);
|
||||
},
|
||||
get strokeStyle() { return strokeStyle; },
|
||||
set strokeStyle(value) { strokeStyle = value; record('strokeStyle', value); },
|
||||
globalAlpha: 1,
|
||||
get globalCompositeOperation() {
|
||||
return globalCompositeOperation;
|
||||
},
|
||||
set globalCompositeOperation(value) {
|
||||
globalCompositeOperation = value;
|
||||
record('globalCompositeOperation', value);
|
||||
},
|
||||
get globalCompositeOperation() { return globalCompositeOperation; },
|
||||
set globalCompositeOperation(value) { globalCompositeOperation = value; record('globalCompositeOperation', value); },
|
||||
lineWidth: 1,
|
||||
measureText(text) {
|
||||
return { width: String(text).length * 6 };
|
||||
},
|
||||
setTransform(...args) {
|
||||
record('setTransform', ...args);
|
||||
},
|
||||
clearRect(...args) {
|
||||
record('clearRect', ...args);
|
||||
},
|
||||
save() {
|
||||
record('save');
|
||||
},
|
||||
restore() {
|
||||
record('restore');
|
||||
},
|
||||
translate(...args) {
|
||||
record('translate', ...args);
|
||||
},
|
||||
scale(...args) {
|
||||
record('scale', ...args);
|
||||
},
|
||||
beginPath() {
|
||||
record('beginPath');
|
||||
},
|
||||
rect(...args) {
|
||||
record('rect', ...args);
|
||||
},
|
||||
clip(...args) {
|
||||
record('clip', ...args);
|
||||
},
|
||||
moveTo(...args) {
|
||||
record('moveTo', ...args);
|
||||
},
|
||||
lineTo(...args) {
|
||||
record('lineTo', ...args);
|
||||
},
|
||||
arc(...args) {
|
||||
record('arc', ...args);
|
||||
},
|
||||
arcTo(...args) {
|
||||
record('arcTo', ...args);
|
||||
},
|
||||
closePath() {
|
||||
record('closePath');
|
||||
},
|
||||
measureText(text) { return { width: String(text).length * 6 }; },
|
||||
setTransform(...args) { record('setTransform', ...args); },
|
||||
clearRect(...args) { record('clearRect', ...args); },
|
||||
save() { record('save'); },
|
||||
restore() { record('restore'); },
|
||||
translate(...args) { record('translate', ...args); },
|
||||
scale(...args) { record('scale', ...args); },
|
||||
beginPath() { record('beginPath'); },
|
||||
rect(...args) { record('rect', ...args); },
|
||||
clip(...args) { record('clip', ...args); },
|
||||
moveTo(...args) { record('moveTo', ...args); },
|
||||
lineTo(...args) { record('lineTo', ...args); },
|
||||
arc(...args) { record('arc', ...args); },
|
||||
arcTo(...args) { record('arcTo', ...args); },
|
||||
closePath() { record('closePath'); },
|
||||
// The style is recorded AT fill time, not on assignment, so a pin can prove
|
||||
// which plate token actually reached the canvas rather than which one was
|
||||
// set at some point during the frame. globalAlpha rides along for the same
|
||||
// reason: the backdrop feather is an alpha, not a colour.
|
||||
fill(path) {
|
||||
record('fill', path, this.fillStyle, this.globalAlpha);
|
||||
},
|
||||
stroke(path) {
|
||||
record('stroke', path, this.strokeStyle, this.lineWidth);
|
||||
},
|
||||
fillRect(...args) {
|
||||
record('fillRect', ...args);
|
||||
},
|
||||
fillText(...args) {
|
||||
record('fillText', ...args);
|
||||
},
|
||||
drawImage(...args) {
|
||||
record('drawImage', ...args);
|
||||
},
|
||||
fill(path) { record('fill', path, this.fillStyle, this.globalAlpha); },
|
||||
stroke(path) { record('stroke', path, this.strokeStyle, this.lineWidth); },
|
||||
fillRect(...args) { record('fillRect', ...args); },
|
||||
fillText(...args) { record('fillText', ...args); },
|
||||
drawImage(...args) { record('drawImage', ...args); },
|
||||
};
|
||||
}
|
||||
|
||||
// `search` seeds window.location.search. The detection mode banner is developer
|
||||
// telemetry gated behind ?detectDebug=1, so any test that uses the banner as its
|
||||
// "did this frame repaint" probe must opt in explicitly.
|
||||
function installEnvironment({
|
||||
width = 800,
|
||||
height = 600,
|
||||
dpr = 2,
|
||||
search = '',
|
||||
} = {}) {
|
||||
function installEnvironment({ width = 800, height = 600, dpr = 2, search = '' } = {}) {
|
||||
const byId = new Map();
|
||||
const paintTrace = [];
|
||||
const ctx = mockContext('shared', paintTrace);
|
||||
const detectionCtx = mockContext('detection', paintTrace);
|
||||
const originalPerformance = Object.getOwnPropertyDescriptor(
|
||||
globalThis,
|
||||
'performance',
|
||||
);
|
||||
const originalPerformance = Object.getOwnPropertyDescriptor(globalThis, 'performance');
|
||||
const originalDateNow = Date.now;
|
||||
const originalPath2D = globalThis.Path2D;
|
||||
let currentTime = 1_000;
|
||||
let performanceStep = 0;
|
||||
Object.defineProperty(globalThis, 'performance', {
|
||||
configurable: true,
|
||||
value: {
|
||||
now: () => {
|
||||
const value = currentTime;
|
||||
currentTime += performanceStep;
|
||||
return value;
|
||||
},
|
||||
},
|
||||
value: { now: () => {
|
||||
const value = currentTime;
|
||||
currentTime += performanceStep;
|
||||
return value;
|
||||
} },
|
||||
});
|
||||
Date.now = () => currentTime;
|
||||
globalThis.Path2D = MockPath2D;
|
||||
@@ -240,9 +167,7 @@ function installEnvironment({
|
||||
register(this);
|
||||
}
|
||||
|
||||
getBoundingClientRect() {
|
||||
return this._rect;
|
||||
}
|
||||
getBoundingClientRect() { return this._rect; }
|
||||
getContext() {
|
||||
if (this.tagName !== 'CANVAS') return null;
|
||||
return this.id === 'world-overlay-detection-surface' ? detectionCtx : ctx;
|
||||
@@ -255,17 +180,13 @@ function installEnvironment({
|
||||
|
||||
remove() {
|
||||
if (this.parentElement) {
|
||||
this.parentElement.children = this.parentElement.children.filter(
|
||||
(child) => child !== this,
|
||||
);
|
||||
this.parentElement.children = this.parentElement.children.filter((child) => child !== this);
|
||||
}
|
||||
unregister(this);
|
||||
this.parentElement = null;
|
||||
}
|
||||
|
||||
get nextSibling() {
|
||||
return null;
|
||||
}
|
||||
get nextSibling() { return null; }
|
||||
}
|
||||
|
||||
function register(element) {
|
||||
@@ -279,26 +200,14 @@ function installEnvironment({
|
||||
}
|
||||
|
||||
const body = new MockElement('body');
|
||||
body.classList = {
|
||||
contains() {
|
||||
return false;
|
||||
},
|
||||
};
|
||||
body.classList = { contains() { return false; } };
|
||||
const document = {
|
||||
body,
|
||||
createElement(tagName) {
|
||||
return new MockElement(tagName);
|
||||
},
|
||||
getElementById(id) {
|
||||
return byId.get(id) || null;
|
||||
},
|
||||
querySelector(selector) {
|
||||
return byId.get(selector.slice(1)) || null;
|
||||
},
|
||||
createElement(tagName) { return new MockElement(tagName); },
|
||||
getElementById(id) { return byId.get(id) || null; },
|
||||
querySelector(selector) { return byId.get(selector.slice(1)) || null; },
|
||||
querySelectorAll(selector) {
|
||||
const element = selector.startsWith('#')
|
||||
? byId.get(selector.slice(1))
|
||||
: null;
|
||||
const element = selector.startsWith('#') ? byId.get(selector.slice(1)) : null;
|
||||
return element ? [element] : [];
|
||||
},
|
||||
};
|
||||
@@ -307,18 +216,10 @@ function installEnvironment({
|
||||
location: { search },
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
getComputedStyle() {
|
||||
return { display: 'block', visibility: 'visible', opacity: '1' };
|
||||
},
|
||||
};
|
||||
globalThis.ResizeObserver = class {
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
};
|
||||
globalThis.MutationObserver = class {
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
getComputedStyle() { return { display: 'block', visibility: 'visible', opacity: '1' }; },
|
||||
};
|
||||
globalThis.ResizeObserver = class { observe() {} disconnect() {} };
|
||||
globalThis.MutationObserver = class { observe() {} disconnect() {} };
|
||||
globalThis.document = document;
|
||||
globalThis.window = window;
|
||||
|
||||
@@ -340,9 +241,7 @@ function installEnvironment({
|
||||
positionWC: new Cesium.Cartesian3(0, 0, 10_000_000),
|
||||
positionCartographic: { height: 1_000_000 },
|
||||
viewMatrix: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY),
|
||||
frustum: {
|
||||
projectionMatrix: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY),
|
||||
},
|
||||
frustum: { projectionMatrix: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY) },
|
||||
moveEnd: new MockEvent(),
|
||||
},
|
||||
scene: { postRender, requestRender() {} },
|
||||
@@ -355,12 +254,8 @@ function installEnvironment({
|
||||
ctx,
|
||||
detectionCtx,
|
||||
paintTrace,
|
||||
advance(ms) {
|
||||
currentTime += ms;
|
||||
},
|
||||
setPerformanceStep(ms) {
|
||||
performanceStep = ms;
|
||||
},
|
||||
advance(ms) { currentTime += ms; },
|
||||
setPerformanceStep(ms) { performanceStep = ms; },
|
||||
cleanup() {
|
||||
destroyDetection();
|
||||
destroyWorldOverlay();
|
||||
@@ -371,8 +266,7 @@ function installEnvironment({
|
||||
delete globalThis.window;
|
||||
delete globalThis.ResizeObserver;
|
||||
delete globalThis.MutationObserver;
|
||||
if (originalPerformance)
|
||||
Object.defineProperty(globalThis, 'performance', originalPerformance);
|
||||
if (originalPerformance) Object.defineProperty(globalThis, 'performance', originalPerformance);
|
||||
else delete globalThis.performance;
|
||||
},
|
||||
};
|
||||
@@ -401,16 +295,14 @@ function militaryLayer() {
|
||||
return {
|
||||
id: 'military',
|
||||
getDetectableObjects() {
|
||||
return [
|
||||
{
|
||||
position: new Cesium.Cartesian3(0, 0, 6_356_752),
|
||||
sourceId: 'military-pin',
|
||||
id: 'MIL-PIN',
|
||||
metric: 'FL450',
|
||||
type: 'AIR',
|
||||
tier: 'military',
|
||||
},
|
||||
];
|
||||
return [{
|
||||
position: new Cesium.Cartesian3(0, 0, 6_356_752),
|
||||
sourceId: 'military-pin',
|
||||
id: 'MIL-PIN',
|
||||
metric: 'FL450',
|
||||
type: 'AIR',
|
||||
tier: 'military',
|
||||
}];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -466,9 +358,7 @@ test('detection lifecycle re-hosts unchanged painters behind the sole host liste
|
||||
assert.equal(env.postRender.listeners.size, 1);
|
||||
assert.equal(env.document.getElementById('detection-overlay'), null);
|
||||
assert.ok(env.document.getElementById('world-overlay-canvas'));
|
||||
const surface = env.document.getElementById(
|
||||
'world-overlay-detection-surface',
|
||||
);
|
||||
const surface = env.document.getElementById('world-overlay-detection-surface');
|
||||
const root = env.document.getElementById('world-overlay-root');
|
||||
const mainCanvas = env.document.getElementById('world-overlay-canvas');
|
||||
assert.ok(surface);
|
||||
@@ -491,36 +381,19 @@ test('detection lifecycle re-hosts unchanged painters behind the sole host liste
|
||||
assert.equal(first.profile, 'DENSE');
|
||||
assert.ok(Array.isArray(first.calloutRects));
|
||||
if (first.calloutRects.length) {
|
||||
const x = first.calloutRects[0].x;
|
||||
first.calloutRects[0].x = NaN;
|
||||
assert.equal(
|
||||
getDetectionDiagnostics().calloutRects[0].x,
|
||||
x,
|
||||
'QA owns a copy, never a live plate',
|
||||
);
|
||||
const x=first.calloutRects[0].x;
|
||||
first.calloutRects[0].x=NaN;
|
||||
assert.equal(getDetectionDiagnostics().calloutRects[0].x,x,'QA owns a copy, never a live plate');
|
||||
}
|
||||
assert.equal(first.observationCount, 2);
|
||||
assert.equal(first.didSolve, true);
|
||||
assert.ok(first.solveRevision > 0);
|
||||
assert.ok(first.collectiveLabelBudget > 0);
|
||||
assert.ok(
|
||||
first.bracketOpacityCounts.full + first.bracketOpacityCounts.partial > 0,
|
||||
);
|
||||
assert.ok(
|
||||
env.detectionCtx.calls.some(
|
||||
([name, text]) =>
|
||||
name === 'fillText' && String(text).startsWith('DENSE VIS:'),
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
env.detectionCtx.calls.some(
|
||||
([name, path]) => name === 'stroke' && path instanceof MockPath2D,
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
env.detectionCtx.calls.some(([name]) => name === 'fillRect'),
|
||||
'scanlines and banner still paint',
|
||||
);
|
||||
assert.ok(first.bracketOpacityCounts.full + first.bracketOpacityCounts.partial > 0);
|
||||
assert.ok(env.detectionCtx.calls.some(([name, text]) => name === 'fillText'
|
||||
&& String(text).startsWith('DENSE VIS:')));
|
||||
assert.ok(env.detectionCtx.calls.some(([name, path]) => name === 'stroke' && path instanceof MockPath2D));
|
||||
assert.ok(env.detectionCtx.calls.some(([name]) => name === 'fillRect'), 'scanlines and banner still paint');
|
||||
assert.equal(env.detectionCtx.globalCompositeOperation, 'source-over');
|
||||
assert.equal(env.detectionCtx.filter, 'none');
|
||||
assert.equal(surface.style.display, 'block');
|
||||
@@ -536,16 +409,8 @@ test('detection lifecycle re-hosts unchanged painters behind the sole host liste
|
||||
// screen-blended sensor surface: `screen` can only lighten, so a callout's
|
||||
// dark backing plate is a no-op over sunlit ground and the text dissolves
|
||||
// into it. Brackets, banner and scanlines stay on the sensor surface.
|
||||
assert.ok(
|
||||
env.ctx.calls.some(
|
||||
([name, text]) => name === 'fillText' && text === 'TEST0',
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
!env.detectionCtx.calls.some(
|
||||
([name, text]) => name === 'fillText' && text === 'TEST0',
|
||||
),
|
||||
);
|
||||
assert.ok(env.ctx.calls.some(([name, text]) => name === 'fillText' && text === 'TEST0'));
|
||||
assert.ok(!env.detectionCtx.calls.some(([name, text]) => name === 'fillText' && text === 'TEST0'));
|
||||
env.advance(1);
|
||||
env.postRender.raise();
|
||||
assert.ok(getDetectionDiagnostics().solveRevision > first.solveRevision);
|
||||
@@ -555,10 +420,7 @@ test('detection lifecycle re-hosts unchanged painters behind the sole host liste
|
||||
assert.equal(getMode(), 'SPARSE');
|
||||
assert.equal(getDetectionDiagnostics().profile, 'SPARSE');
|
||||
assert.equal(getDetectionDiagnostics().allocationStrategy, 'WEIGHTED');
|
||||
assert.ok(
|
||||
env.detectionCtx.calls.some(([name]) => name === 'arc'),
|
||||
'sparse focus ring remains available',
|
||||
);
|
||||
assert.ok(env.detectionCtx.calls.some(([name]) => name === 'arc'), 'sparse focus ring remains available');
|
||||
|
||||
setMode('BALANCED');
|
||||
env.postRender.raise();
|
||||
@@ -567,12 +429,9 @@ test('detection lifecycle re-hosts unchanged painters behind the sole host liste
|
||||
env.postRender.raise();
|
||||
assert.equal(getDetectionDiagnostics().profile, 'DENSE');
|
||||
|
||||
const bannerCount = () =>
|
||||
env.detectionCtx.calls.filter(
|
||||
([name, text]) =>
|
||||
name === 'fillText' &&
|
||||
/^(SPARSE|BALANCED|DENSE) VIS:/.test(String(text)),
|
||||
).length;
|
||||
const bannerCount = () => env.detectionCtx.calls.filter(([name, text]) => (
|
||||
name === 'fillText' && /^(SPARSE|BALANCED|DENSE) VIS:/.test(String(text))
|
||||
)).length;
|
||||
const beforeSuspend = bannerCount();
|
||||
suspendDetection('intercity');
|
||||
assert.equal(isDetectionSuspended(), true);
|
||||
@@ -589,12 +448,9 @@ test('detection lifecycle re-hosts unchanged painters behind the sole host liste
|
||||
// These are the exact engine calls made by military style presets after
|
||||
// their production UI gate chooses CRT/NVG/FLIR defaults.
|
||||
const expectedThemes = {
|
||||
retro:
|
||||
'contrast(1.08) saturate(1.04) drop-shadow(0 0 3px rgba(255, 176, 56, 0.45))',
|
||||
surveillance:
|
||||
'contrast(1.12) saturate(1.12) drop-shadow(0 0 3px rgba(120, 255, 120, 0.42))',
|
||||
thermal:
|
||||
'contrast(1.1) saturate(1.08) drop-shadow(0 0 3px rgba(255, 224, 170, 0.42))',
|
||||
retro: 'contrast(1.08) saturate(1.04) drop-shadow(0 0 3px rgba(255, 176, 56, 0.45))',
|
||||
surveillance: 'contrast(1.12) saturate(1.12) drop-shadow(0 0 3px rgba(120, 255, 120, 0.42))',
|
||||
thermal: 'contrast(1.1) saturate(1.08) drop-shadow(0 0 3px rgba(255, 224, 170, 0.42))',
|
||||
};
|
||||
for (const style of ['retro', 'surveillance', 'thermal']) {
|
||||
setMode('OFF');
|
||||
@@ -620,28 +476,17 @@ test('detection lifecycle re-hosts unchanged painters behind the sole host liste
|
||||
assert.equal(getMode(), 'OFF');
|
||||
assert.equal(surface.style.display, 'none');
|
||||
assert.equal(env.postRender.listeners.size, 1);
|
||||
assert.ok(
|
||||
modes.includes('SPARSE') &&
|
||||
modes.includes('BALANCED') &&
|
||||
modes.includes('DENSE'),
|
||||
);
|
||||
assert.ok(modes.includes('SPARSE') && modes.includes('BALANCED') && modes.includes('DENSE'));
|
||||
|
||||
destroyDetection();
|
||||
assert.equal(
|
||||
env.postRender.listeners.size,
|
||||
1,
|
||||
'detection never owns the listener',
|
||||
);
|
||||
assert.equal(env.postRender.listeners.size, 1, 'detection never owns the listener');
|
||||
setMode('DENSE');
|
||||
env.postRender.raise();
|
||||
assert.equal(env.document.getElementById('detection-overlay'), null);
|
||||
destroyWorldOverlay();
|
||||
assert.equal(env.postRender.listeners.size, 0);
|
||||
assert.equal(env.document.getElementById('world-overlay-canvas'), null);
|
||||
assert.equal(
|
||||
env.document.getElementById('world-overlay-detection-surface'),
|
||||
null,
|
||||
);
|
||||
assert.equal(env.document.getElementById('world-overlay-detection-surface'), null);
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
@@ -654,22 +499,16 @@ test('callouts stop painting the moment the last detectable object goes away', (
|
||||
// clear the replay buffer, or the final callsigns stay stranded on the
|
||||
// shared canvas after the last data layer is switched off.
|
||||
const env = installEnvironment();
|
||||
let objects = [
|
||||
{
|
||||
position: new Cesium.Cartesian3(0, 0, 6_356_752),
|
||||
sourceId: 'lonely-1',
|
||||
id: 'LASTONE',
|
||||
metric: 'FL120',
|
||||
type: 'AIR',
|
||||
},
|
||||
];
|
||||
let objects = [{
|
||||
position: new Cesium.Cartesian3(0, 0, 6_356_752),
|
||||
sourceId: 'lonely-1',
|
||||
id: 'LASTONE',
|
||||
metric: 'FL120',
|
||||
type: 'AIR',
|
||||
}];
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(
|
||||
env.viewer,
|
||||
[{ id: 'flights', getDetectableObjects: () => objects }],
|
||||
() => {},
|
||||
);
|
||||
initDetection(env.viewer, [{ id: 'flights', getDetectableObjects: () => objects }], () => {});
|
||||
setMode('DENSE');
|
||||
env.advance(250);
|
||||
env.postRender.raise();
|
||||
@@ -678,9 +517,7 @@ test('callouts stop painting the moment the last detectable object goes away', (
|
||||
env.advance(250);
|
||||
env.postRender.raise();
|
||||
assert.ok(
|
||||
env.ctx.calls.some(
|
||||
([name, text]) => name === 'fillText' && text === 'LASTONE',
|
||||
),
|
||||
env.ctx.calls.some(([name, text]) => name === 'fillText' && text === 'LASTONE'),
|
||||
'the callout paints on the shared canvas while the contact exists',
|
||||
);
|
||||
|
||||
@@ -691,9 +528,7 @@ test('callouts stop painting the moment the last detectable object goes away', (
|
||||
env.advance(250);
|
||||
env.postRender.raise();
|
||||
assert.ok(
|
||||
!env.ctx.calls.some(
|
||||
([name, text]) => name === 'fillText' && text === 'LASTONE',
|
||||
),
|
||||
!env.ctx.calls.some(([name, text]) => name === 'fillText' && text === 'LASTONE'),
|
||||
'an empty field must drop the replay buffer, not strand the last callsign',
|
||||
);
|
||||
} finally {
|
||||
@@ -760,24 +595,12 @@ test('space-tier callouts take the heavier plate while air contacts keep the lig
|
||||
theme.calloutPlateSpace,
|
||||
'the fixture is only meaningful while the two tiers differ',
|
||||
);
|
||||
const painted = (text) =>
|
||||
env.ctx.calls.some(
|
||||
([name, value]) => name === 'fillText' && value === text,
|
||||
);
|
||||
assert.ok(
|
||||
painted('AIRONE') && painted('SATONE'),
|
||||
'both tiers reached the frame under test',
|
||||
);
|
||||
const painted = (text) => env.ctx.calls.some(([name, value]) => name === 'fillText' && value === text);
|
||||
assert.ok(painted('AIRONE') && painted('SATONE'), 'both tiers reached the frame under test');
|
||||
|
||||
const fills = sharedFillStyles(env);
|
||||
assert.ok(
|
||||
fills.includes(theme.calloutPlate),
|
||||
'the air contact keeps the light plate',
|
||||
);
|
||||
assert.ok(
|
||||
fills.includes(theme.calloutPlateSpace),
|
||||
'the space contact takes the heavier plate',
|
||||
);
|
||||
assert.ok(fills.includes(theme.calloutPlate), 'the air contact keeps the light plate');
|
||||
assert.ok(fills.includes(theme.calloutPlateSpace), 'the space contact takes the heavier plate');
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
@@ -794,16 +617,11 @@ test('surveillance military tier reaches the dedicated render target as shipped
|
||||
env.postRender.raise();
|
||||
|
||||
assert.ok(
|
||||
env.detectionCtx.calls.some(
|
||||
([name, value]) => name === 'strokeStyle' && value === '#ff5a47',
|
||||
),
|
||||
env.detectionCtx.calls.some(([name, value]) => name === 'strokeStyle' && value === '#ff5a47'),
|
||||
'the production projection/tier/batched-bracket paint path emits surveillance military red',
|
||||
);
|
||||
assert.ok(
|
||||
env.detectionCtx.calls.some(
|
||||
([name, path]) => name === 'stroke' && path instanceof MockPath2D,
|
||||
),
|
||||
);
|
||||
assert.ok(env.detectionCtx.calls.some(([name, path]) => name === 'stroke'
|
||||
&& path instanceof MockPath2D));
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
@@ -815,71 +633,44 @@ test('pathological detection paint holds alternate frames without freezing share
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, [detectableLayer()], () => {});
|
||||
setOverlayEntries('valve-card', [
|
||||
{
|
||||
id: 'ambient-card',
|
||||
position: new Cesium.Cartesian3(0, 0, 0),
|
||||
variant: 'label',
|
||||
title: 'AMBIENT-CONTINUES',
|
||||
selected: true,
|
||||
protected: true,
|
||||
horizonCull: false,
|
||||
edgeFade: 'none',
|
||||
},
|
||||
]);
|
||||
setOverlayEntries('valve-card', [{
|
||||
id: 'ambient-card',
|
||||
position: new Cesium.Cartesian3(0, 0, 0),
|
||||
variant: 'label',
|
||||
title: 'AMBIENT-CONTINUES',
|
||||
selected: true,
|
||||
protected: true,
|
||||
horizonCull: false,
|
||||
edgeFade: 'none',
|
||||
}]);
|
||||
setMode('DENSE');
|
||||
env.advance(250);
|
||||
env.setPerformanceStep(30);
|
||||
|
||||
env.postRender.raise();
|
||||
env.postRender.raise();
|
||||
const detectionClears = env.detectionCtx.calls.filter(
|
||||
([name]) => name === 'clearRect',
|
||||
).length;
|
||||
const detectionBanners = env.detectionCtx.calls.filter(
|
||||
([name, text]) =>
|
||||
name === 'fillText' && String(text).startsWith('DENSE VIS:'),
|
||||
).length;
|
||||
const sharedClears = env.ctx.calls.filter(
|
||||
([name]) => name === 'clearRect',
|
||||
).length;
|
||||
const sharedCards = env.ctx.calls.filter(
|
||||
([name, text]) => name === 'fillText' && text === 'AMBIENT-CONTINUES',
|
||||
).length;
|
||||
const detectionClears = env.detectionCtx.calls.filter(([name]) => name === 'clearRect').length;
|
||||
const detectionBanners = env.detectionCtx.calls.filter(([name, text]) => name === 'fillText'
|
||||
&& String(text).startsWith('DENSE VIS:')).length;
|
||||
const sharedClears = env.ctx.calls.filter(([name]) => name === 'clearRect').length;
|
||||
const sharedCards = env.ctx.calls.filter(([name, text]) => name === 'fillText'
|
||||
&& text === 'AMBIENT-CONTINUES').length;
|
||||
|
||||
env.postRender.raise();
|
||||
assert.equal(
|
||||
env.detectionCtx.calls.filter(([name]) => name === 'clearRect').length,
|
||||
detectionClears,
|
||||
'the host preserves detection pixels on the held odd frame',
|
||||
);
|
||||
assert.equal(
|
||||
env.detectionCtx.calls.filter(
|
||||
([name, text]) =>
|
||||
name === 'fillText' && String(text).startsWith('DENSE VIS:'),
|
||||
).length,
|
||||
detectionBanners,
|
||||
);
|
||||
assert.equal(
|
||||
env.ctx.calls.filter(([name]) => name === 'clearRect').length,
|
||||
sharedClears + 1,
|
||||
'the shared surface still clears',
|
||||
);
|
||||
assert.equal(
|
||||
env.ctx.calls.filter(
|
||||
([name, text]) => name === 'fillText' && text === 'AMBIENT-CONTINUES',
|
||||
).length,
|
||||
sharedCards + 1,
|
||||
'the shared lane still repaints',
|
||||
);
|
||||
assert.equal(env.detectionCtx.calls.filter(([name]) => name === 'clearRect').length, detectionClears,
|
||||
'the host preserves detection pixels on the held odd frame');
|
||||
assert.equal(env.detectionCtx.calls.filter(([name, text]) => name === 'fillText'
|
||||
&& String(text).startsWith('DENSE VIS:')).length, detectionBanners);
|
||||
assert.equal(env.ctx.calls.filter(([name]) => name === 'clearRect').length, sharedClears + 1,
|
||||
'the shared surface still clears');
|
||||
assert.equal(env.ctx.calls.filter(([name, text]) => name === 'fillText'
|
||||
&& text === 'AMBIENT-CONTINUES').length, sharedCards + 1,
|
||||
'the shared lane still repaints');
|
||||
assert.equal(getDetectionDiagnostics().throttleSkipCount, 1);
|
||||
|
||||
env.postRender.raise();
|
||||
assert.equal(
|
||||
env.detectionCtx.calls.filter(([name]) => name === 'clearRect').length,
|
||||
detectionClears + 1,
|
||||
'the next even frame repaints detection',
|
||||
);
|
||||
assert.equal(env.detectionCtx.calls.filter(([name]) => name === 'clearRect').length,
|
||||
detectionClears + 1, 'the next even frame repaints detection');
|
||||
assert.equal(getDetectionDiagnostics().throttleSkipCount, 1);
|
||||
} finally {
|
||||
env.cleanup();
|
||||
@@ -887,14 +678,9 @@ test('pathological detection paint holds alternate frames without freezing share
|
||||
});
|
||||
|
||||
test('detection cannot resurrect a private canvas, listener, matrix, resize, clear, or UI inventory', () => {
|
||||
const source = readFileSync(
|
||||
new URL('./detection.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const uiSource =
|
||||
readShellSource() +
|
||||
'\n' +
|
||||
readFileSync(new URL('../ui/visualPresets.js', import.meta.url), 'utf8');
|
||||
const source = readFileSync(new URL('./detection.js', import.meta.url), 'utf8');
|
||||
const uiSource = readShellSource()
|
||||
+ '\n' + readFileSync(new URL('../ui/visualPresets.js', import.meta.url), 'utf8');
|
||||
assert.doesNotMatch(source, /createElement\(\s*['"]canvas['"]\s*\)/);
|
||||
assert.doesNotMatch(source, /postRender\.addEventListener/);
|
||||
assert.doesNotMatch(source, /['"]detection-overlay['"]/);
|
||||
@@ -904,20 +690,11 @@ test('detection cannot resurrect a private canvas, listener, matrix, resize, cle
|
||||
assert.doesNotMatch(source, /CALLOUT_OCCLUSION_SELECTORS/);
|
||||
assert.doesNotMatch(source, /_ctx\.(?:filter|globalCompositeOperation)\s*=/);
|
||||
assert.match(source, /_hostSurface\.style\.mixBlendMode = _theme\.blend;/);
|
||||
assert.match(
|
||||
source,
|
||||
/_hostSurface\.style\.filter = `\$\{_theme\.filter\} drop-shadow\(0 0 \$\{GLOW_PX\}px \$\{_theme\.glow\}\)`;/,
|
||||
);
|
||||
assert.match(source, /_hostSurface\.style\.filter = `\$\{_theme\.filter\} drop-shadow\(0 0 \$\{GLOW_PX\}px \$\{_theme\.glow\}\)`;/);
|
||||
assert.match(source, /const LABEL_SOLVE_INTERVAL_MS = 125;/);
|
||||
assert.match(source, /const _labelArbiter = new LabelArbiter\(\);/);
|
||||
assert.match(
|
||||
source,
|
||||
/const clipW = vp3 \* px \+ vp7 \* py \+ vp11 \* pz \+ vp15;/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/registerWorldOverlayPaintLane\('detection', _paintDetectionLane/,
|
||||
);
|
||||
assert.match(source, /const clipW = vp3 \* px \+ vp7 \* py \+ vp11 \* pz \+ vp15;/);
|
||||
assert.match(source, /registerWorldOverlayPaintLane\('detection', _paintDetectionLane/);
|
||||
assert.match(source, /target: 'detection'/);
|
||||
assert.match(source, /shouldPaint: _shouldPaintDetectionLane/);
|
||||
// The three military styles used to carry three copies of these numbers.
|
||||
@@ -934,10 +711,7 @@ test('detection cannot resurrect a private canvas, listener, matrix, resize, cle
|
||||
3,
|
||||
'CRT, NVG, and FLIR retain their Dense auto-enable defaults',
|
||||
);
|
||||
assert.match(
|
||||
uiSource,
|
||||
/preset\.detection && !this\._detectionUserOverridden/,
|
||||
);
|
||||
assert.match(uiSource, /preset\.detection && !this\._detectionUserOverridden/);
|
||||
});
|
||||
|
||||
// ── Developer telemetry gate (2026-08-20 QA hunt) ───────────────────────────
|
||||
@@ -951,22 +725,15 @@ test('detectionDebugRequested parses the query-string gate and nothing else', ()
|
||||
assert.equal(detectionDebugRequested(''), false);
|
||||
assert.equal(detectionDebugRequested('?detectDebug=0'), false);
|
||||
assert.equal(detectionDebugRequested('?detectDebug'), false);
|
||||
assert.equal(
|
||||
detectionDebugRequested('?detectdebug=1'),
|
||||
false,
|
||||
'the flag is case-sensitive',
|
||||
);
|
||||
assert.equal(detectionDebugRequested('?detectdebug=1'), false, 'the flag is case-sensitive');
|
||||
assert.equal(detectionDebugRequested(undefined), false);
|
||||
assert.equal(detectionDebugRequested(null), false);
|
||||
});
|
||||
|
||||
test('the mode banner is absent by default and present behind the flag', () => {
|
||||
const bannerPaints = (env) =>
|
||||
env.detectionCtx.calls.filter(
|
||||
([name, text]) =>
|
||||
name === 'fillText' &&
|
||||
/^(SPARSE|BALANCED|DENSE) VIS:/.test(String(text)),
|
||||
).length;
|
||||
const bannerPaints = (env) => env.detectionCtx.calls.filter(([name, text]) => (
|
||||
name === 'fillText' && /^(SPARSE|BALANCED|DENSE) VIS:/.test(String(text))
|
||||
)).length;
|
||||
|
||||
const painted = (search) => {
|
||||
const env = installEnvironment({ search });
|
||||
@@ -978,9 +745,7 @@ test('the mode banner is absent by default and present behind the flag', () => {
|
||||
env.postRender.raise();
|
||||
// The overlay is alive either way — brackets still stroke.
|
||||
assert.ok(
|
||||
env.detectionCtx.calls.some(
|
||||
([name, path]) => name === 'stroke' && path instanceof MockPath2D,
|
||||
),
|
||||
env.detectionCtx.calls.some(([name, path]) => name === 'stroke' && path instanceof MockPath2D),
|
||||
'detection still paints its contacts regardless of the debug gate',
|
||||
);
|
||||
return bannerPaints(env);
|
||||
@@ -991,15 +756,8 @@ test('the mode banner is absent by default and present behind the flag', () => {
|
||||
}
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
painted(''),
|
||||
0,
|
||||
'users must never see the engine telemetry banner',
|
||||
);
|
||||
assert.ok(
|
||||
painted('?detectDebug=1') > 0,
|
||||
'the flag brings the telemetry back',
|
||||
);
|
||||
assert.equal(painted(''), 0, 'users must never see the engine telemetry banner');
|
||||
assert.ok(painted('?detectDebug=1') > 0, 'the flag brings the telemetry back');
|
||||
});
|
||||
|
||||
test('detection telemetry stays reachable programmatically with the banner hidden', () => {
|
||||
@@ -1036,11 +794,7 @@ test('civilian and military AIR brackets cover front, left, and right at Sparse
|
||||
type: 'AIR',
|
||||
}));
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(
|
||||
env.viewer,
|
||||
[{ id: layerId, getDetectableObjects: () => objects }],
|
||||
() => {},
|
||||
);
|
||||
initDetection(env.viewer, [{ id: layerId, getDetectableObjects: () => objects }], () => {});
|
||||
setDetectionTuning({ densityPct: 25 });
|
||||
setMode('SPARSE');
|
||||
settleFrame(env);
|
||||
@@ -1053,10 +807,8 @@ test('civilian and military AIR brackets cover front, left, and right at Sparse
|
||||
assert.equal(diagnostics.visibleCount, 3);
|
||||
assert.equal(diagnostics.densityPct, 25);
|
||||
assert.equal(diagnostics.profile, 'SPARSE');
|
||||
assert.ok(
|
||||
diagnostics.selectedCount <= diagnostics.collectiveLabelBudget,
|
||||
'Sparse keeps its documented budget instead of silently becoming Dense',
|
||||
);
|
||||
assert.ok(diagnostics.selectedCount <= diagnostics.collectiveLabelBudget,
|
||||
'Sparse keeps its documented budget instead of silently becoming Dense');
|
||||
} finally {
|
||||
destroyDetection();
|
||||
destroyWorldOverlay();
|
||||
@@ -1081,17 +833,11 @@ function backdropLayer() {
|
||||
return [
|
||||
{
|
||||
position: new Cesium.Cartesian3(-0.4, 0.2, 6_356_752),
|
||||
sourceId: 'ground-1',
|
||||
id: 'GROUNDED',
|
||||
metric: 'FL100',
|
||||
type: 'AIR',
|
||||
sourceId: 'ground-1', id: 'GROUNDED', metric: 'FL100', type: 'AIR',
|
||||
},
|
||||
{
|
||||
position: new Cesium.Cartesian3(0.4, -0.2, 20_000_000),
|
||||
sourceId: 'sky-1',
|
||||
id: 'SKYBACK',
|
||||
metric: 'FL400',
|
||||
type: 'AIR',
|
||||
sourceId: 'sky-1', id: 'SKYBACK', metric: 'FL400', type: 'AIR',
|
||||
},
|
||||
];
|
||||
},
|
||||
@@ -1113,14 +859,8 @@ test('the backdrop feather reaches the canvas as a lighter plate against sky', (
|
||||
env.ctx.calls.length = 0;
|
||||
settleFrame(env);
|
||||
|
||||
const painted = (text) =>
|
||||
env.ctx.calls.some(
|
||||
([name, value]) => name === 'fillText' && value === text,
|
||||
);
|
||||
assert.ok(
|
||||
painted('GROUNDED') && painted('SKYBACK'),
|
||||
'both backdrops reached the frame under test',
|
||||
);
|
||||
const painted = (text) => env.ctx.calls.some(([name, value]) => name === 'fillText' && value === text);
|
||||
assert.ok(painted('GROUNDED') && painted('SKYBACK'), 'both backdrops reached the frame under test');
|
||||
|
||||
const plate = DETECTION_THEME_MAP._default.calloutPlate;
|
||||
const plateAlphas = env.ctx.calls
|
||||
@@ -1131,10 +871,7 @@ test('the backdrop feather reaches the canvas as a lighter plate against sky', (
|
||||
const heaviest = Math.max(...plateAlphas);
|
||||
const lightest = Math.min(...plateAlphas);
|
||||
assert.ok(heaviest > 0, 'the grounded contact still gets a plate');
|
||||
assert.ok(
|
||||
lightest > 0,
|
||||
'the sky contact keeps a whisper rather than vanishing',
|
||||
);
|
||||
assert.ok(lightest > 0, 'the sky contact keeps a whisper rather than vanishing');
|
||||
// Reverting the feature makes both plates equal, which fails here first.
|
||||
assert.ok(
|
||||
lightest < heaviest * 0.5,
|
||||
@@ -1200,23 +937,9 @@ test('a transit vehicle is a contact: box, route, mode and operator on the share
|
||||
painted.some((text) => String(text).includes('34 KM/H')),
|
||||
'and so does what the vehicle is doing',
|
||||
);
|
||||
assert.ok(
|
||||
env.ctx.calls.some(
|
||||
([name, , color, width]) =>
|
||||
name === 'stroke' && color === '#5EF08A' && width === 1.25,
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
env.ctx.calls.some(
|
||||
([name, , color, width]) =>
|
||||
name === 'stroke' && color === '#05080C' && width === 3.25,
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
!env.detectionCtx.calls.some(
|
||||
([name, , color]) => name === 'stroke' && color === '#5EF08A',
|
||||
),
|
||||
);
|
||||
assert.ok(env.ctx.calls.some(([name, , color, width]) => name === 'stroke' && color === '#5EF08A' && width === 1.25));
|
||||
assert.ok(env.ctx.calls.some(([name, , color, width]) => name === 'stroke' && color === '#05080C' && width === 3.25));
|
||||
assert.ok(!env.detectionCtx.calls.some(([name, , color]) => name === 'stroke' && color === '#5EF08A'));
|
||||
// The operator travels on the object for the two-tier card form, which is
|
||||
// where `klass` is read; the host's track label does not paint it.
|
||||
assert.equal(composeLabel(objects[0]).secondary, 'MBTA · BUS 34 KM/H');
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { BUNDLE_SOURCE } from './bundle.js';
|
||||
|
||||
// Pack statuses stay English in the report; the import dialog shows them
|
||||
// through t() (scenes/sharing.js).
|
||||
const msg = (text) => text;
|
||||
|
||||
/** Describe an import without fetching assets, applying state or exposing source configuration. */
|
||||
export function describeSceneShare(
|
||||
{ project, assets },
|
||||
@@ -16,11 +20,11 @@ export function describeSceneShare(
|
||||
status:
|
||||
pack.source.adapter === BUNDLE_SOURCE
|
||||
? assets.has(pack.source.path)
|
||||
? 'Included in bundle'
|
||||
: 'Missing bundle file — reimport its bundle'
|
||||
? msg('Included in bundle')
|
||||
: msg('Missing bundle file — reimport its bundle')
|
||||
: sources.has(pack.source.adapter)
|
||||
? 'Source configured; file checked when loaded'
|
||||
: 'Source unavailable',
|
||||
? msg('Source configured; file checked when loaded')
|
||||
: msg('Source unavailable'),
|
||||
})),
|
||||
);
|
||||
return {
|
||||
|
||||
+2
-35
@@ -18,6 +18,7 @@ import * as Cesium from 'cesium';
|
||||
import { forward as toMGRS } from 'mgrs';
|
||||
import { CITY_POIS } from './locations.js';
|
||||
import { composeLocalityTag } from './hudLocality.js';
|
||||
import { hudQuantity, LOCALIZED_LOCALITY } from './hudLocalityFormat.js';
|
||||
import {
|
||||
ellipsoidalToMslDisplayM,
|
||||
ensureGeoidReady,
|
||||
@@ -35,10 +36,8 @@ import {
|
||||
DEFAULT_HUD_LAYOUT,
|
||||
normalizeHudLayout,
|
||||
} from './hudLayouts.js';
|
||||
import { formatNumber, getLocale, msg, t } from './i18n/index.js';
|
||||
import { unitLabel } from './ui/unitLabels.js';
|
||||
import { formatNumber, msg, t } from './i18n/index.js';
|
||||
import { localizeLayerName } from './ui/layerPanel.js';
|
||||
import { localizePlaceName } from './ui/placeNames.js';
|
||||
|
||||
/** Color palettes keyed by shader mode; applied as CSS custom properties. */
|
||||
const HUD_COLORS = {
|
||||
@@ -138,38 +137,6 @@ function viewBandLabel(band) {
|
||||
}
|
||||
}
|
||||
|
||||
/** An upper-case HUD quantity such as `13KM` / `13КМ`, in the active language. */
|
||||
function hudQuantity(value, unit, digits = 0) {
|
||||
const number = formatNumber(value, {
|
||||
useGrouping: false,
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
});
|
||||
return `${number}${unitLabel(unit).toUpperCase()}`;
|
||||
}
|
||||
|
||||
/** One hemisphere-tagged coordinate, e.g. `21.33N` / `21,33С`. */
|
||||
function hudCoordinate(value, positive, negative) {
|
||||
return `${hudQuantity(Math.abs(value), '', 2)}${value >= 0 ? positive : negative}`;
|
||||
}
|
||||
|
||||
/** The summary's locality line in the active language (see hudLocality.js). */
|
||||
const LOCALIZED_LOCALITY = Object.freeze({
|
||||
// Translate the catalogued name (`Tokyo`), then upper-case it in the page's
|
||||
// language: the place-name table knows `Tokyo`, not `TOKYO`.
|
||||
near: ({ poi, city, distKm }) =>
|
||||
t('NEAR {poi} ({city}) {distance}', {
|
||||
poi: poi.toLocaleUpperCase(getLocale()),
|
||||
city: localizePlaceName(city).toLocaleUpperCase(getLocale()),
|
||||
distance: hudQuantity(Math.round(distKm), 'km'),
|
||||
}),
|
||||
sector: ({ latDeg, lonDeg }) =>
|
||||
t('SECTOR {lat} {lon}', {
|
||||
lat: hudCoordinate(latDeg, t('N'), t('S')),
|
||||
lon: hudCoordinate(lonDeg, t('E'), t('W')),
|
||||
}),
|
||||
});
|
||||
|
||||
/** Shader modes that automatically show the HUD overlay. */
|
||||
const MILITARY_STYLES = new Set(['retro', 'surveillance', 'thermal']);
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { composeLocalityTag, NEAR_POI_MAX_KM } from './hudLocality.js';
|
||||
import { LOCALIZED_LOCALITY } from './hudLocalityFormat.js';
|
||||
import { setLocale } from './i18n/index.js';
|
||||
|
||||
const ALCATRAZ = { poi: 'Alcatraz Island', city: 'San Francisco' };
|
||||
const LINCOLN = { poi: 'Lincoln Memorial', city: 'Washington DC' };
|
||||
@@ -123,7 +125,7 @@ test('a missing or malformed nearest POI never crashes the summary', () => {
|
||||
});
|
||||
|
||||
// The names reach a formatter as catalogued, so the HUD can translate the
|
||||
// city before upper-casing it (N-04: `TOKYO` never matched the `Tokyo` key).
|
||||
// city before upper-casing it: the place-name table knows `Tokyo`, not `TOKYO`.
|
||||
test('a formatter builds the line from the same parts', () => {
|
||||
const format = {
|
||||
near: ({ poi, city, distKm }) => `near:${poi}|${city}|${distKm}`,
|
||||
@@ -138,3 +140,20 @@ test('a formatter builds the line from the same parts', () => {
|
||||
'sector:21.3|-157.8',
|
||||
);
|
||||
});
|
||||
|
||||
test('the HUD formatter translates the catalogued city, then upper-cases it', () => {
|
||||
const tokyo = { poi: 'Tokyo Tower', city: 'Tokyo', distKm: 2 };
|
||||
assert.equal(
|
||||
composeLocalityTag(tokyo, 35.66, 139.75, LOCALIZED_LOCALITY),
|
||||
'NEAR TOKYO TOWER (TOKYO) 2KM',
|
||||
);
|
||||
setLocale('ru');
|
||||
try {
|
||||
assert.match(
|
||||
composeLocalityTag(tokyo, 35.66, 139.75, LOCALIZED_LOCALITY),
|
||||
/\(ТОКИО\)/,
|
||||
);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
// N-04: the HUD locality line passes the city upper-cased ("TOKYO") to the
|
||||
// place-name lookup, which knows only "Tokyo", so the city never translates.
|
||||
// The formatter below is the HUD's own (src/hud.js LOCALIZED_LOCALITY.near);
|
||||
// hud.js itself does not load under node (mgrs CommonJS import).
|
||||
// Place: src/hudLocalityCity.test.mjs
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { setLocale } from './i18n/index.js';
|
||||
import { composeLocalityTag } from './hudLocality.js';
|
||||
import { localizePlaceName } from './ui/placeNames.js';
|
||||
|
||||
const nearest = { poi: 'Tokyo Tower', city: 'Tokyo', distKm: 2 };
|
||||
const hudFormat = {
|
||||
near: ({ city }) => localizePlaceName(city),
|
||||
sector: () => '',
|
||||
};
|
||||
|
||||
test('ru: the HUD locality names Tokyo in Russian', () => {
|
||||
setLocale('ru');
|
||||
try {
|
||||
const shown = composeLocalityTag(nearest, 35.66, 139.75, hudFormat);
|
||||
assert.match(shown, /токио/i);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @module hudLocalityFormat
|
||||
* @description The HUD summary's locality line in the active language — the
|
||||
* formatter `hud.js` hands to `composeLocalityTag`. Kept apart from `hud.js`
|
||||
* (which imports `mgrs`, not loadable under plain Node) so it is unit-testable.
|
||||
*/
|
||||
import { formatNumber, getLocale, t } from './i18n/index.js';
|
||||
import { unitLabel } from './ui/unitLabels.js';
|
||||
import { localizePlaceName } from './ui/placeNames.js';
|
||||
|
||||
/** An upper-case HUD quantity such as `13KM` / `13КМ`, in the active language. */
|
||||
export function hudQuantity(value, unit, digits = 0) {
|
||||
const number = formatNumber(value, {
|
||||
useGrouping: false,
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
});
|
||||
return `${number}${unitLabel(unit).toUpperCase()}`;
|
||||
}
|
||||
|
||||
/** One hemisphere-tagged coordinate, e.g. `21.33N` / `21,33С`. */
|
||||
function hudCoordinate(value, positive, negative) {
|
||||
return `${hudQuantity(Math.abs(value), '', 2)}${value >= 0 ? positive : negative}`;
|
||||
}
|
||||
|
||||
/** The summary's locality line in the active language (see hudLocality.js). */
|
||||
export const LOCALIZED_LOCALITY = Object.freeze({
|
||||
// Translate the catalogued name (`Tokyo`), then upper-case it in the page's
|
||||
// language: the place-name table knows `Tokyo`, not `TOKYO`.
|
||||
near: ({ poi, city, distKm }) =>
|
||||
t('NEAR {poi} ({city}) {distance}', {
|
||||
poi: poi.toLocaleUpperCase(getLocale()),
|
||||
city: localizePlaceName(city).toLocaleUpperCase(getLocale()),
|
||||
distance: hudQuantity(Math.round(distKm), 'km'),
|
||||
}),
|
||||
sector: ({ latDeg, lonDeg }) =>
|
||||
t('SECTOR {lat} {lon}', {
|
||||
lat: hudCoordinate(latDeg, t('N'), t('S')),
|
||||
lon: hudCoordinate(lonDeg, t('E'), t('W')),
|
||||
}),
|
||||
});
|
||||
@@ -136,3 +136,78 @@ test('unmarkedScript finds literals that reach the page without t/tn/msg', () =>
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('unmarkedScript sees toasts behind optional calls and private helpers', () => {
|
||||
const texts = (js) => unmarkedScript('f.js', js).map((f) => f.text);
|
||||
assert.deepEqual(texts("showToast?.('North up');"), ['North up']);
|
||||
assert.deepEqual(texts("this._showToast('Search failed');"), [
|
||||
'Search failed',
|
||||
]);
|
||||
assert.deepEqual(texts("this.showToast('Link copied');"), ['Link copied']);
|
||||
assert.deepEqual(texts("this.actions.showToast?.('Tilted view');"), [
|
||||
'Tilted view',
|
||||
]);
|
||||
assert.deepEqual(texts("showToast?.(t('North up'));"), []);
|
||||
assert.deepEqual(texts("this._showToast(t('Search failed'));"), []);
|
||||
});
|
||||
|
||||
test('unmarkedScript sees status text assigned to layer errors and labels', () => {
|
||||
const texts = (js) => unmarkedScript('f.js', js).map((f) => f.text);
|
||||
assert.deepEqual(texts("layerState._error = 'live feed unavailable';"), [
|
||||
'live feed unavailable',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
texts("this._error = err?.message || 'TeleGeography load failed';"),
|
||||
['TeleGeography load failed'],
|
||||
);
|
||||
assert.deepEqual(texts("_error = 'Route request failed';"), [
|
||||
'Route request failed',
|
||||
]);
|
||||
assert.deepEqual(texts("error ??= 'Feed closed';"), ['Feed closed']);
|
||||
assert.deepEqual(texts('stats.error = `${name} fetch error`;'), [
|
||||
'fetch error',
|
||||
]);
|
||||
assert.deepEqual(texts("loadingLabel = 'loading coverage...';"), [
|
||||
'loading coverage...',
|
||||
]);
|
||||
assert.deepEqual(texts("state._loadingLabel = 'Loading cables';"), [
|
||||
'Loading cables',
|
||||
]);
|
||||
assert.deepEqual(texts("stats.statusMessage = 'Add a key in Settings';"), [
|
||||
'Add a key in Settings',
|
||||
]);
|
||||
assert.deepEqual(texts("this.status = 'Scene not saved';"), [
|
||||
'Scene not saved',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
texts("layerState._error = msg('live feed unavailable');"),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(texts("stats.error = t('GBFS fetch error');"), []);
|
||||
// Machine values, comparisons and local scratch variables are not UI text.
|
||||
assert.deepEqual(texts("status = 'missing-key';"), []);
|
||||
assert.deepEqual(texts("if (error === 'Feed closed') stop();"), []);
|
||||
assert.deepEqual(texts("const error = log() || 'Unknown shader error';"), []);
|
||||
assert.deepEqual(texts('stats.error = new Error(`HTTP ${code}`);'), []);
|
||||
});
|
||||
|
||||
test('unmarkedScript sees human-readable status, loadingLabel and statusMessage fields', () => {
|
||||
const texts = (js) => unmarkedScript('f.js', js).map((f) => f.text);
|
||||
assert.deepEqual(
|
||||
texts("return { status: 'Scene not saved — storage unavailable' };"),
|
||||
['Scene not saved — storage unavailable'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
texts("return { status: ok ? 'Ready' : 'Source unavailable' };"),
|
||||
['Ready', 'Source unavailable'],
|
||||
);
|
||||
assert.deepEqual(texts("return { loadingLabel: 'Loading feeds' };"), [
|
||||
'Loading feeds',
|
||||
]);
|
||||
assert.deepEqual(texts("return { statusMessage: 'connect a receiver' };"), [
|
||||
'connect a receiver',
|
||||
]);
|
||||
assert.deepEqual(texts("return { status: 'streaming', code: 'ok' };"), []);
|
||||
assert.deepEqual(texts("return { status: 'auth-failed' };"), []);
|
||||
assert.deepEqual(texts("return { status: msg('Source unavailable') };"), []);
|
||||
});
|
||||
@@ -243,6 +243,10 @@ test('tn() shows a caller-formatted count and still picks the form by number', (
|
||||
assert.equal(label(12345), '12,345 map objects');
|
||||
assert.equal(tn(12345, '{count} file', '{count} files'), '12345 files');
|
||||
setLocale('ru');
|
||||
assert.equal(label(12345), '12 345 объектов на карте');
|
||||
assert.equal(label(1), '1 объект на карте');
|
||||
try {
|
||||
assert.equal(label(12345), '12 345 объектов на карте');
|
||||
assert.equal(label(1), '1 объект на карте');
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
@@ -2440,7 +2440,6 @@
|
||||
"Exit cockpit to fly to a route": "Sal de la cabina para volar a una ruta",
|
||||
"Exit cockpit to fly to a file": "Sal de la cabina para volar a un archivo",
|
||||
"Exit cockpit to fly to a scene": "Sal de la cabina para volar a una escena",
|
||||
"Exit cockpit to fly to a {noun}": "Sal de la cabina para volar a {noun}",
|
||||
"Go to expanded Radio section": "Ir a la sección de Radio expandida",
|
||||
"Expand Radio section in Context": "Expandir la sección de Radio en Contexto",
|
||||
"Double-click the header to snap the panel back": "Haz doble clic en el encabezado para devolver el panel a su sitio",
|
||||
@@ -2877,5 +2876,12 @@
|
||||
"unsaved provider keys": "las claves de proveedores sin guardar",
|
||||
"Exit cockpit to fly to an aircraft": "Sal de la cabina para volar a una aeronave",
|
||||
"Exit cockpit to fly to a satellite": "Sal de la cabina para volar a un satélite",
|
||||
"Exit cockpit to frame contacts": "Sal de la cabina para encuadrar contactos"
|
||||
"Exit cockpit to frame contacts": "Sal de la cabina para encuadrar contactos",
|
||||
"{feed} {count} ({seconds}s old)": "{feed} {count} (hace {seconds} s)",
|
||||
"entry {n}": "n.º {n}",
|
||||
"Upstream response too large": "Respuesta del origen demasiado grande",
|
||||
"Included in bundle": "Incluido en el paquete",
|
||||
"Missing bundle file — reimport its bundle": "Falta el archivo en el paquete — vuelve a importar su paquete",
|
||||
"Source configured; file checked when loaded": "Fuente configurada; el archivo se comprobará al cargarlo",
|
||||
"Source unavailable": "Fuente no disponible"
|
||||
}
|
||||
@@ -2481,7 +2481,6 @@
|
||||
"Exit cockpit to fly to a route": "Выйдите из кабины, чтобы перелететь к маршруту",
|
||||
"Exit cockpit to fly to a file": "Выйдите из кабины, чтобы перелететь к файлу",
|
||||
"Exit cockpit to fly to a scene": "Выйдите из кабины, чтобы перелететь к сцене",
|
||||
"Exit cockpit to fly to a {noun}": "Выйдите из кабины, чтобы перелететь: {noun}",
|
||||
"Go to expanded Radio section": "Перейти к развёрнутому разделу радио",
|
||||
"Expand Radio section in Context": "Развернуть раздел «Радио» в панели «Контекст»",
|
||||
"Double-click the header to snap the panel back": "Двойной щелчок по заголовку — вернуть панель на место",
|
||||
@@ -2944,5 +2943,12 @@
|
||||
"unsaved provider keys": "несохранённые ключи провайдеров",
|
||||
"Exit cockpit to fly to an aircraft": "Выйдите из кабины, чтобы перелететь к самолёту",
|
||||
"Exit cockpit to fly to a satellite": "Выйдите из кабины, чтобы перелететь к спутнику",
|
||||
"Exit cockpit to frame contacts": "Выйдите из кабины, чтобы показать контакты в кадре"
|
||||
"Exit cockpit to frame contacts": "Выйдите из кабины, чтобы показать контакты в кадре",
|
||||
"{feed} {count} ({seconds}s old)": "{feed} {count} ({seconds} с назад)",
|
||||
"entry {n}": "№ {n}",
|
||||
"Upstream response too large": "Слишком большой ответ источника",
|
||||
"Included in bundle": "Входит в архив",
|
||||
"Missing bundle file — reimport its bundle": "Файла нет в архиве — импортируйте этот архив снова",
|
||||
"Source configured; file checked when loaded": "Источник настроен; файл будет проверен при загрузке",
|
||||
"Source unavailable": "Источник недоступен"
|
||||
}
|
||||
+3
-8
@@ -389,14 +389,9 @@ export async function initKeySetup({
|
||||
// A pasted key lives only in its field until Save: switching language (a
|
||||
// reload) asks first while any field holds one.
|
||||
const unregisterUnsavedWork = registerUnsavedWork(() =>
|
||||
!disposed &&
|
||||
Object.keys(
|
||||
collectKeyUpdates(
|
||||
[...(root.querySelectorAll?.('input[data-env-var]') || [])].map(
|
||||
(input) => ({ envVar: input.dataset?.envVar, value: input.value }),
|
||||
),
|
||||
),
|
||||
).length
|
||||
[...(root.querySelectorAll?.('input[data-env-var]') || [])].some((input) =>
|
||||
String(input.value ?? '').trim(),
|
||||
)
|
||||
? msg('unsaved provider keys')
|
||||
: null,
|
||||
);
|
||||
|
||||
+19
-80
@@ -6,7 +6,9 @@ import {
|
||||
stripKeylessBasemapFromHash,
|
||||
bindKeySetupPlacement,
|
||||
waitForChatGptOAuth,
|
||||
initKeySetup,
|
||||
} from './keySetup.js';
|
||||
import { describeUnsavedWork } from './i18n/index.js';
|
||||
|
||||
test('setup placement moves the same button only in Cyber and disconnects on teardown', () => {
|
||||
const home = {
|
||||
@@ -59,20 +61,10 @@ test('setup placement moves the same button only in Cyber and disconnects on tea
|
||||
});
|
||||
|
||||
test('the chip counts what is missing, and retires the count at zero', () => {
|
||||
assert.equal(
|
||||
keySetupChipLabel({ setCount: 0, total: 8 }),
|
||||
'POWER UP · 8 KEYS WAITING',
|
||||
);
|
||||
assert.equal(
|
||||
keySetupChipLabel({ setCount: 7, total: 8 }),
|
||||
'POWER UP · 1 KEY WAITING',
|
||||
);
|
||||
assert.equal(keySetupChipLabel({ setCount: 0, total: 8 }), 'POWER UP · 8 KEYS WAITING');
|
||||
assert.equal(keySetupChipLabel({ setCount: 7, total: 8 }), 'POWER UP · 1 KEY WAITING');
|
||||
assert.equal(keySetupChipLabel({ setCount: 8, total: 8 }), 'POWERED UP');
|
||||
assert.equal(
|
||||
keySetupChipLabel(null),
|
||||
'POWERED UP',
|
||||
'no status is not a broken label',
|
||||
);
|
||||
assert.equal(keySetupChipLabel(null), 'POWERED UP', 'no status is not a broken label');
|
||||
});
|
||||
|
||||
test('collectKeyUpdates keeps only non-empty trimmed values', () => {
|
||||
@@ -112,27 +104,14 @@ test('OAuth login polling stops as soon as local ChatGPT auth becomes available'
|
||||
|
||||
test('OAuth login polling stops on login or HTTP failure instead of waiting for timeout', async () => {
|
||||
for (const result of [
|
||||
{
|
||||
ok: true,
|
||||
payload: {
|
||||
available: false,
|
||||
loginFailed: true,
|
||||
error: 'Sign-in failed.',
|
||||
},
|
||||
},
|
||||
{ ok: true, payload: { available: false, loginFailed: true, error: 'Sign-in failed.' } },
|
||||
{ ok: false, payload: { error: 'Sign-in is local only.' } },
|
||||
]) {
|
||||
let checks = 0;
|
||||
await assert.rejects(
|
||||
waitForChatGptOAuth({
|
||||
fetchImpl: async () => {
|
||||
checks += 1;
|
||||
return { ok: result.ok, json: async () => result.payload };
|
||||
},
|
||||
sleep: () => assert.fail('failed login must stop polling'),
|
||||
}),
|
||||
{ message: result.payload.error },
|
||||
);
|
||||
await assert.rejects(waitForChatGptOAuth({
|
||||
fetchImpl: async () => { checks += 1; return { ok: result.ok, json: async () => result.payload }; },
|
||||
sleep: () => assert.fail('failed login must stop polling'),
|
||||
}), { message: result.payload.error });
|
||||
assert.equal(checks, 1);
|
||||
}
|
||||
});
|
||||
@@ -140,34 +119,12 @@ test('OAuth login polling stops on login or HTTP failure instead of waiting for
|
||||
test('OAuth login polling respects timeout and an aborted setup surface', async () => {
|
||||
let clock = 0;
|
||||
let checks = 0;
|
||||
const poll = {
|
||||
fetchImpl: async () => {
|
||||
checks += 1;
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ available: false, pending: true }),
|
||||
};
|
||||
},
|
||||
};
|
||||
assert.equal(
|
||||
await waitForChatGptOAuth({
|
||||
...poll,
|
||||
timeoutMs: 100,
|
||||
pollMs: 100,
|
||||
now: () => clock,
|
||||
sleep: async (ms) => {
|
||||
clock += ms;
|
||||
},
|
||||
}),
|
||||
false,
|
||||
);
|
||||
const poll = { fetchImpl: async () => { checks += 1; return { ok: true, json: async () => ({ available: false, pending: true }) }; } };
|
||||
assert.equal(await waitForChatGptOAuth({ ...poll, timeoutMs: 100, pollMs: 100, now: () => clock, sleep: async (ms) => { clock += ms; } }), false);
|
||||
assert.equal(checks, 2);
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
assert.equal(
|
||||
await waitForChatGptOAuth({ ...poll, signal: controller.signal }),
|
||||
false,
|
||||
);
|
||||
assert.equal(await waitForChatGptOAuth({ ...poll, signal: controller.signal }), false);
|
||||
assert.equal(checks, 2, 'aborted polling does not fetch');
|
||||
});
|
||||
|
||||
@@ -175,22 +132,14 @@ test('OAuth polling ignores a ready response received after the surface was abor
|
||||
const controller = new AbortController();
|
||||
const available = await waitForChatGptOAuth({
|
||||
signal: controller.signal,
|
||||
fetchImpl: async () => ({
|
||||
ok: true,
|
||||
json: async () => {
|
||||
controller.abort();
|
||||
return { available: true };
|
||||
},
|
||||
}),
|
||||
fetchImpl: async () => ({ ok: true, json: async () => { controller.abort(); return { available: true }; } }),
|
||||
sleep: () => assert.fail('aborted polling must not continue'),
|
||||
});
|
||||
assert.equal(available, false);
|
||||
});
|
||||
|
||||
test('the first Google key strips ONLY the keyless OSM basemap from the share hash', () => {
|
||||
const stripped = stripKeylessBasemapFromHash(
|
||||
'lat=30.2&lon=-97.7&map=osm&style=normal',
|
||||
);
|
||||
const stripped = stripKeylessBasemapFromHash('lat=30.2&lon=-97.7&map=osm&style=normal');
|
||||
assert.ok(stripped !== null);
|
||||
const params = new URLSearchParams(stripped);
|
||||
assert.equal(params.get('map'), null, 'osm basemap removed');
|
||||
@@ -198,11 +147,7 @@ test('the first Google key strips ONLY the keyless OSM basemap from the share ha
|
||||
assert.equal(params.get('style'), 'normal', 'style survives');
|
||||
// A stack under any other name was chosen or shared on purpose.
|
||||
assert.equal(stripKeylessBasemapFromHash('map=bing-aerial&lat=1'), null);
|
||||
assert.equal(
|
||||
stripKeylessBasemapFromHash('lat=1&lon=2'),
|
||||
null,
|
||||
'no stack, nothing to do',
|
||||
);
|
||||
assert.equal(stripKeylessBasemapFromHash('lat=1&lon=2'), null, 'no stack, nothing to do');
|
||||
assert.equal(stripKeylessBasemapFromHash(''), null);
|
||||
assert.equal(stripKeylessBasemapFromHash(undefined), null);
|
||||
});
|
||||
@@ -216,15 +161,11 @@ test('aborting pending setup removes its surface and ignores a late response', a
|
||||
let requestSignal;
|
||||
const controller = new AbortController();
|
||||
const pending = initKeySetup({
|
||||
documentRef: {
|
||||
getElementById: (id) => (id === 'key-setup-chip' ? chip : root),
|
||||
},
|
||||
documentRef: { getElementById: (id) => id === 'key-setup-chip' ? chip : root },
|
||||
signal: controller.signal,
|
||||
fetchImpl: (_url, { signal }) => {
|
||||
requestSignal = signal;
|
||||
return new Promise((resolve) => {
|
||||
resolveResponse = resolve;
|
||||
});
|
||||
return new Promise((resolve) => { resolveResponse = resolve; });
|
||||
},
|
||||
});
|
||||
controller.abort();
|
||||
@@ -233,10 +174,7 @@ test('aborting pending setup removes its surface and ignores a late response', a
|
||||
resolveResponse({ ok: true, json: async () => ({ keys: [] }) });
|
||||
assert.equal(await pending, null);
|
||||
});
|
||||
|
||||
test('a pasted, unsaved key makes the language switch ask first until teardown', async () => {
|
||||
const { initKeySetup } = await import('./keySetup.js');
|
||||
const { describeUnsavedWork } = await import('./i18n/index.js');
|
||||
const node = () => ({
|
||||
dataset: {},
|
||||
addEventListener() {},
|
||||
@@ -271,6 +209,7 @@ test('a pasted, unsaved key makes the language switch ask first until teardown',
|
||||
assert.deepEqual(describeUnsavedWork(), []);
|
||||
input.value = 'AIza-pasted';
|
||||
assert.deepEqual(describeUnsavedWork(), ['unsaved provider keys']);
|
||||
// The field still holds the key, so only teardown dropping the check clears it.
|
||||
controller.abort();
|
||||
assert.deepEqual(describeUnsavedWork(), []);
|
||||
});
|
||||
@@ -1,11 +1,11 @@
|
||||
// N-02: the Air quality layer (merged from main, AQHI) is fully English in
|
||||
// es/ru: layer name, Weather card (title, coverage, status, legend, risk) and
|
||||
// its own failure text. Place: src/layers/airQuality/airQualityLocale.test.mjs
|
||||
// The Air quality layer (AQHI) reads in the UI language: layer name, Weather
|
||||
// card (title, coverage, status, legend, risk) and its own failure text.
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { setLocale, t } from '../../i18n/index.js';
|
||||
import { layerNameForId, localizeLayerName } from '../../ui/layerNames.js';
|
||||
import { LayerPanel } from '../../ui/layerPanel.js';
|
||||
import { translateFeedError } from '../../ui/statusText.js';
|
||||
import { createAirQualityLayer } from './index.js';
|
||||
|
||||
function inLocale(locale, run) {
|
||||
@@ -89,3 +89,20 @@ for (const locale of ['es', 'ru']) {
|
||||
assert.ok(!row.includes('Air quality request failed'), row);
|
||||
});
|
||||
}
|
||||
|
||||
for (const locale of ['en', 'es', 'ru']) {
|
||||
test(`${locale}: the Weather card shows an HTTP failure as the row does`, async () => {
|
||||
const layer = harness({
|
||||
getSnapshot: async () => {
|
||||
throw new Error('ECCC HTTP 503');
|
||||
},
|
||||
});
|
||||
await layer.update();
|
||||
const card = inLocale(locale, () => layer.getRowControls().summary.status);
|
||||
assert.equal(
|
||||
card,
|
||||
inLocale(locale, () => translateFeedError('ECCC HTTP 503')),
|
||||
);
|
||||
assert.equal(card, locale === 'en' ? 'ECCC HTTP 503' : 'ECCC: HTTP 503');
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { msg, t, tn, translateKnown } from '../../i18n/index.js';
|
||||
import { msg, t, tn } from '../../i18n/index.js';
|
||||
import { translateFeedError } from '../../ui/statusText.js';
|
||||
import {
|
||||
AIR_QUALITY_BANDS,
|
||||
AIR_QUALITY_OVERLAY_SOURCE_ID,
|
||||
@@ -216,8 +217,9 @@ export function createAirQualityLayer({ source, overlayHost } = {}) {
|
||||
0,
|
||||
);
|
||||
// Readings and errors stay English data (analyst records, stats); the
|
||||
// card shows them in the UI language. A provider's own error text has
|
||||
// no catalog entry and shows as it came.
|
||||
// card shows them in the UI language, errors as the Layers panel row
|
||||
// does. A provider's own error text has no catalog entry and shows as
|
||||
// it came.
|
||||
return {
|
||||
readout: true,
|
||||
summary: {
|
||||
@@ -226,7 +228,7 @@ export function createAirQualityLayer({ source, overlayHost } = {}) {
|
||||
validTime: newest ? new Date(newest).toISOString() : undefined,
|
||||
detail: `${provider.network} · ${provider.scale}`,
|
||||
status: _lastError
|
||||
? translateKnown(_lastError)
|
||||
? translateFeedError(_lastError)
|
||||
: _count === 0
|
||||
? _lastUpdate === null
|
||||
? t('No readings available')
|
||||
@@ -249,7 +251,7 @@ export function createAirQualityLayer({ source, overlayHost } = {}) {
|
||||
id: reading.id,
|
||||
ordinal: index + 1,
|
||||
lead: `${reading.scale} ${reading.label}`,
|
||||
text: `${reading.name} · ${reading.risk ? t(reading.risk) : reading.risk}`,
|
||||
text: `${reading.name} · ${t(reading.risk)}`,
|
||||
accent: reading.color,
|
||||
})),
|
||||
},
|
||||
|
||||
@@ -132,6 +132,7 @@ export function createTracking({
|
||||
icao24,
|
||||
// Honesty cue: the contact is coasting on dead reckoning, so the
|
||||
// narrated position/velocity are last-known rather than live.
|
||||
// i18n-ignore: voice-context data, English for the model
|
||||
status: described.stale ? 'stale (missed polls)' : 'live',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { formatNumber, msg, t, translateKnown } from '../../i18n/index.js';
|
||||
import { formatNumber, msg, t } from '../../i18n/index.js';
|
||||
import { translateFeedError } from '../../ui/statusText.js';
|
||||
import {
|
||||
GNSS_LEVEL_COLORS,
|
||||
GNSS_MIN_AIRCRAFT,
|
||||
@@ -31,14 +32,6 @@ const LEVEL_LABELS = Object.freeze({
|
||||
high: msg('Low-accuracy score > 10%'),
|
||||
});
|
||||
|
||||
/** A source failure (English data, source.js) in the UI language. */
|
||||
function shownSourceError(text) {
|
||||
const http = /^GNSS feed unavailable \(HTTP (\d{3})\)$/.exec(text);
|
||||
return http
|
||||
? t('GNSS feed unavailable (HTTP {status})', { status: http[1] })
|
||||
: translateKnown(text);
|
||||
}
|
||||
|
||||
/** Degrees at the centre of the current view, or null before the camera settles. */
|
||||
export function defaultGnssViewAnchor(viewer) {
|
||||
const canvas = viewer?.scene?.canvas;
|
||||
@@ -385,7 +378,7 @@ export function createGnssIntegrityLayer({
|
||||
'Regional sample · 250 nm radius · cells from visited views over 30 min',
|
||||
) +
|
||||
(_lastError && _retryAt
|
||||
? ` · ${shownSourceError(_lastError)} · ${
|
||||
? ` · ${translateFeedError(_lastError)} · ${
|
||||
_retryAt <= 8640000000000000
|
||||
? t('Retrying automatically at {time} UTC', {
|
||||
time: new Date(_retryAt).toISOString().slice(11, 19),
|
||||
|
||||
@@ -42,7 +42,7 @@ export function createAdsbGnssSource({
|
||||
throw Object.assign(
|
||||
new Error(
|
||||
// The status code form is matched by FEED_ERROR_PATTERNS
|
||||
// (ui/layerPanel.js) and gnss/index.js.
|
||||
// (ui/statusText.js) and gnss/index.js.
|
||||
limited
|
||||
? msg('GNSS feed rate limited (HTTP 429)')
|
||||
: `GNSS feed unavailable (HTTP ${response.status})`,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// N-03: the GNSS source's own failures are English data (source.js); the
|
||||
// The GNSS source's own failures are English data (source.js); the
|
||||
// card's info line shows them in the UI language, the HTTP form included.
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
@@ -192,6 +192,7 @@ export function createModel({ state: layerState, services, parts, source }) {
|
||||
launch.id || launch.slug || launch.name || `launch-${date}`,
|
||||
),
|
||||
name: launch.name || 'Unnamed launch',
|
||||
// i18n-ignore: Launch Library status names are provider data, shown as sent
|
||||
status: launch.status?.name || 'Unknown',
|
||||
launchTime: Number.isFinite(date)
|
||||
? new Date(date).toISOString()
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// A Local ADS-B feed problem can be the whole error (nothing heard, WebUSB
|
||||
// not producing); the row shows it in the UI language like any other piece.
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { setLocale } from '../../i18n/index.js';
|
||||
import { LayerPanel } from '../../ui/layerPanel.js';
|
||||
import { localAdsbStatus } from './status.js';
|
||||
const receiver = {
|
||||
webUsbSupported: true,
|
||||
connected: false,
|
||||
mode: 'adsb',
|
||||
status: 'idle',
|
||||
};
|
||||
function rowFor(status) {
|
||||
return LayerPanel.prototype._buildMetaText({
|
||||
id: 'local-adsb',
|
||||
enabled: true,
|
||||
stats: { count: 0, lastUpdate: Date.now(), ...status },
|
||||
});
|
||||
}
|
||||
for (const locale of ['es', 'ru']) {
|
||||
for (const status of ['unreachable', 'invalid']) {
|
||||
test(`${locale}: a lone ${status} decoder feed with nothing heard is translated`, () => {
|
||||
setLocale(locale);
|
||||
try {
|
||||
const st = localAdsbStatus({
|
||||
receiver,
|
||||
feedState: {
|
||||
configured: true,
|
||||
feeds: [{ id: 'a', label: '1090', band: '1090', status }],
|
||||
},
|
||||
heard: 0,
|
||||
});
|
||||
const row = rowFor(st);
|
||||
assert.doesNotMatch(row, /\b(unreachable|invalid)\b/, row);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { LAYER_SOURCE } from './policy.js';
|
||||
|
||||
// Marks English that the Layers panel translates when it shows the status.
|
||||
// Texts with a number or a feed name stay English data too; the panel shows
|
||||
// them through STATUS_PIECE_PATTERNS (ui/layerPanel.js), piece by piece.
|
||||
// them through STATUS_PIECE_PATTERNS (ui/statusText.js), piece by piece.
|
||||
const msg = (text) => text;
|
||||
|
||||
/**
|
||||
@@ -75,6 +75,7 @@ function webUsbStatus(receiver, heard, feedState) {
|
||||
const rate = Number.isFinite(receiver.messagesPerSecond)
|
||||
? `${receiver.messagesPerSecond} msg/s`
|
||||
: msg('listening');
|
||||
// i18n-ignore: English data, shown through STATUS_PIECE_PATTERNS
|
||||
return { status: 'streaming', loadingLabel: `${heard} heard · ${rate}` };
|
||||
}
|
||||
|
||||
@@ -117,6 +118,7 @@ export function localAdsbStatus({ receiver, feedState, heard }) {
|
||||
return {
|
||||
source,
|
||||
status: 'streaming',
|
||||
// i18n-ignore: English data, shown through STATUS_PIECE_PATTERNS
|
||||
loadingLabel: `${live.length} ${plural(live.length, 'feed', 'feeds')} live · ${heardText}${rate}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// N-10: the Local ADS-B row's working texts ("3 heard · 5.8 msg/s",
|
||||
// "2 feeds live · 14 heard", "listening", "USB error", feed problems) have no
|
||||
// catalog entries and stay English in es/ru. Place: src/layers/localAdsb/statusLocale.test.mjs
|
||||
// The Local ADS-B row's working texts ("3 heard · 5.8 msg/s",
|
||||
// "2 feeds live · 14 heard", "listening", "USB error", feed problems) read in
|
||||
// the UI language.
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { setLocale } from '../../i18n/index.js';
|
||||
|
||||
@@ -125,6 +125,7 @@ export function createTracking({
|
||||
icao24,
|
||||
// Honesty cue: the contact is coasting on dead reckoning, so the
|
||||
// narrated position/velocity are last-known rather than live.
|
||||
// i18n-ignore: voice-context data, English for the model
|
||||
status: described.stale ? 'stale (missed polls)' : 'live',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -72,7 +72,7 @@ export function createIngestion({
|
||||
// At least one group loaded — proceed with a fresh rebuild, but keep the
|
||||
// partial outage visible at the layer control instead of presenting the
|
||||
// reduced catalog as a fully healthy refresh. English data; the panel
|
||||
// shows it through FEED_ERROR_PATTERNS (ui/layerPanel.js).
|
||||
// shows it through FEED_ERROR_PATTERNS (ui/statusText.js).
|
||||
layerState._lastError = failed.length
|
||||
? `${failed.length} CelesTrak group${failed.length === 1 ? '' : 's'} unavailable`
|
||||
: null;
|
||||
|
||||
@@ -477,7 +477,7 @@ export function createIngestion({ state, services, parts, source }) {
|
||||
error?.message || error,
|
||||
);
|
||||
// English data; the panel shows it through FEED_ERROR_PATTERNS
|
||||
// (ui/layerPanel.js).
|
||||
// i18n-ignore: translated on display (ui/statusText.js)
|
||||
status.error = `${feed.name} feed unavailable`;
|
||||
status.retryInSec = Number.isFinite(error?.retryInSec)
|
||||
? error.retryInSec
|
||||
|
||||
@@ -278,9 +278,10 @@ export function createQueries({ state, parts }) {
|
||||
: {}),
|
||||
degraded: count > 0 && health.degraded,
|
||||
loading: health.loading && count === 0,
|
||||
// English data; the panel shows it through LOADING_LABEL_PATTERNS
|
||||
// (ui/layerPanel.js).
|
||||
// English data; the panel shows it through STATUS_PIECE_PATTERNS
|
||||
// (ui/statusText.js).
|
||||
loadingLabel:
|
||||
// i18n-ignore: translated on display
|
||||
health.loading && count === 0
|
||||
? `Loading ${active.map((feed) => feed.name).join(', ')}`
|
||||
: undefined,
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
// The speed unit of a vessel's detection callout follows the UI language.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import aisLiveVesselsLayer, {
|
||||
_setVesselOverlayHostForTest,
|
||||
_setVesselStateForTest,
|
||||
_updateVesselCardsForTest,
|
||||
} from '../../data/aisLiveVessels.js';
|
||||
import { setLocale } from '../../i18n/index.js';
|
||||
|
||||
const record = (mmsi, name, screen, z = 0) => ({
|
||||
name,
|
||||
mmsi,
|
||||
type: 'Cargo',
|
||||
destination: '',
|
||||
speed: 9.5,
|
||||
course: 80,
|
||||
heading: 80,
|
||||
lastPositionUtc: '',
|
||||
missedRefreshes: 0,
|
||||
position: { x: 0, y: 0, z, screen },
|
||||
billboard: null,
|
||||
});
|
||||
|
||||
function detectable(cameraZ) {
|
||||
const carded = record('200', 'GOLDEN BEAR', { x: 40, y: 30 });
|
||||
const plain = record('300', 'BAY PILOT', { x: 41, y: 31 });
|
||||
const originalProjection = Cesium.SceneTransforms.worldToWindowCoordinates;
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = (_scene, position) =>
|
||||
position.screen;
|
||||
_setVesselOverlayHostForTest({
|
||||
setEntries() {},
|
||||
setVisible() {},
|
||||
clearSource() {},
|
||||
});
|
||||
_setVesselStateForTest({
|
||||
viewer: {
|
||||
scene: {
|
||||
canvas: { clientWidth: 1600, clientHeight: 900 },
|
||||
camera: { positionWC: new Cesium.Cartesian3(0, 0, cameraZ) },
|
||||
},
|
||||
},
|
||||
records: [carded, plain],
|
||||
billboardCollection: { show: true, remove() {} },
|
||||
});
|
||||
try {
|
||||
// Both share one 118 px grid cell, so only one of them gets a card.
|
||||
_updateVesselCardsForTest([carded, plain]);
|
||||
return Object.fromEntries(
|
||||
aisLiveVesselsLayer
|
||||
.getDetectableObjects()
|
||||
.map((object) => [object.id, object]),
|
||||
);
|
||||
} finally {
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = originalProjection;
|
||||
_setVesselStateForTest({ enabled: false });
|
||||
_setVesselOverlayHostForTest();
|
||||
}
|
||||
}
|
||||
|
||||
test('ru: the callout speed unit is translated', () => {
|
||||
setLocale('ru');
|
||||
try {
|
||||
const objects = detectable(4_000_000);
|
||||
assert.equal(objects['GOLDEN BEAR'].metric, '10 уз');
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
assert.equal(detectable(4_000_000)['GOLDEN BEAR'].metric, '10 kn');
|
||||
});
|
||||
@@ -69,7 +69,6 @@ export function createVesselState({ source, services }) {
|
||||
/** Sprites whose animated emphasis remains outside the 1.0 deadband. */
|
||||
activeFocusCount: 0,
|
||||
activeLabelCount: 0,
|
||||
/** @type {Set<Object>} Records that have an ambient card this pass. */
|
||||
selectedRecord: null,
|
||||
/** @type {{setPositions: Function, clear: Function, destroy: Function}|null} Selected-vessel fading trail */
|
||||
trail: null,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// N-09: a scalar overlay that cannot be drawn reports a marked English status
|
||||
// A scalar overlay that cannot be drawn reports a marked English status
|
||||
// (rendering.js), which the Wind row shows through t() in the UI language.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale, t } from '../../i18n/index.js';
|
||||
import { WIND_FIELDS } from './fields.js';
|
||||
import { fieldUnavailableText } from './rendering.js';
|
||||
import { SCALAR_UNAVAILABLE } from './source.js';
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
test(`${locale}: every field's unavailable status has a translation`, () => {
|
||||
@@ -19,3 +20,10 @@ for (const locale of ['es', 'ru']) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('only a known overlay has an unavailable status', () => {
|
||||
assert.equal(fieldUnavailableText('constructor'), null);
|
||||
assert.equal(fieldUnavailableText('none'), null);
|
||||
for (const [overlay, text] of Object.entries(SCALAR_UNAVAILABLE))
|
||||
assert.equal(fieldUnavailableText(overlay), text);
|
||||
});
|
||||
@@ -205,6 +205,7 @@ export function createWindGpuRendering({ cesium: C, getViewer }) {
|
||||
} catch (error) {
|
||||
clear();
|
||||
diagnostics.error =
|
||||
// i18n-ignore: GPU diagnostics only; the panel never shows this field
|
||||
error instanceof Error ? error.message : 'Wind geometry unavailable';
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ export function createWindLayer({
|
||||
model: model === 'ifs' ? 'ECMWF' : 'GFS',
|
||||
validTime:
|
||||
formatWindValidTime(manifest?.cycle?.validIso) || 'Unavailable',
|
||||
// i18n-ignore: English reading for AI tools; the card status uses t()
|
||||
status: loading
|
||||
? 'Loading forecast'
|
||||
: error ||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { orderWeatherImagery } from '../weather/imageryOrder.js';
|
||||
import { NO_IMAGERY_HOST } from '../weather/imageryHost.js';
|
||||
import { msg } from '../../i18n/index.js';
|
||||
import { createWindGpuRendering } from './gpuRendering.js';
|
||||
import { SCALAR_UNAVAILABLE } from './source.js';
|
||||
import { advectParticle, sampleWind } from './model.js';
|
||||
import {
|
||||
createFieldRaster,
|
||||
@@ -16,16 +17,17 @@ import {
|
||||
|
||||
// A scalar overlay that cannot be drawn, in English for diagnostics; the
|
||||
// panel status shows it translated (index.js). The scalar texts are the ones
|
||||
// the source reports (source.js).
|
||||
// the source reports. setOptions only accepts these overlays.
|
||||
const FIELD_UNAVAILABLE = Object.freeze({
|
||||
speed: msg('Wind speed field unavailable'),
|
||||
temperature: msg('Temperature field unavailable'),
|
||||
pressure: msg('Mean sea level pressure field unavailable'),
|
||||
...SCALAR_UNAVAILABLE,
|
||||
});
|
||||
|
||||
/** English status for a scalar overlay with no drawable field. */
|
||||
export function fieldUnavailableText(overlay) {
|
||||
return FIELD_UNAVAILABLE[overlay] || `${overlay} field unavailable`;
|
||||
return Object.hasOwn(FIELD_UNAVAILABLE, overlay)
|
||||
? FIELD_UNAVAILABLE[overlay]
|
||||
: null;
|
||||
}
|
||||
const WIND_FIELD_FADE_HIGH_METERS = 1_200_000;
|
||||
const WIND_FIELD_FADE_LOW_METERS = 200_000;
|
||||
|
||||
@@ -4,6 +4,12 @@ import { readWindBody } from '../../sources/windBody.js';
|
||||
// wind layer and layer panel show them translated.
|
||||
const msg = (text) => text;
|
||||
|
||||
/** Status of a scalar overlay the provider could not supply, by overlay. */
|
||||
export const SCALAR_UNAVAILABLE = Object.freeze({
|
||||
temperature: msg('Temperature field unavailable'),
|
||||
pressure: msg('Mean sea level pressure field unavailable'),
|
||||
});
|
||||
|
||||
/** Create a bounded, cancellable source for the same-origin wind provider. */
|
||||
export function createWindSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
@@ -44,10 +50,7 @@ export function createWindSource({
|
||||
const optionalUnavailable =
|
||||
overlay !== 'none' &&
|
||||
scalar === undefined &&
|
||||
scalarError ===
|
||||
(overlay === 'temperature'
|
||||
? msg('Temperature field unavailable')
|
||||
: msg('Mean sea level pressure field unavailable'));
|
||||
scalarError === SCALAR_UNAVAILABLE[overlay];
|
||||
const scalarValid =
|
||||
overlay === 'none'
|
||||
? scalar === undefined && scalarError === undefined
|
||||
|
||||
+3
-509
@@ -10,519 +10,13 @@ import {
|
||||
import { unavailablePlaceSearch } from './search/placeSearch.js';
|
||||
import { cancelCameraArrival } from './data/cameraArrival.js';
|
||||
import { guardCameraAboveGround } from './cameraGroundGuard.js';
|
||||
import { CITY_POIS } from './cityPois.js';
|
||||
|
||||
export { CITY_POIS };
|
||||
|
||||
/** Newest landmark arrival; an older post-arrival ground guard yields to it. */
|
||||
let arrivalGeneration = 0;
|
||||
|
||||
/**
|
||||
* Points of Interest per city.
|
||||
* Each city has 5 POIs; the first is the default fly-to landmark.
|
||||
*
|
||||
* Field reference:
|
||||
* alt — RANGE (distance from target in meters), NOT absolute altitude
|
||||
* heading — optimal camera heading in degrees (0=N, 90=E, 180=S, 270=W)
|
||||
* pitch — camera tilt in degrees (negative = looking down)
|
||||
* buildingHeight — estimated height of landmark center above ground (meters)
|
||||
*/
|
||||
export const CITY_POIS = {
|
||||
austin: {
|
||||
name: 'Austin',
|
||||
groundElevation: 150, // meters above WGS84 ellipsoid
|
||||
viewBounds: {
|
||||
southwest: { lat: 30.1, lng: -97.95 },
|
||||
northeast: { lat: 30.52, lng: -97.55 },
|
||||
},
|
||||
pois: [
|
||||
{
|
||||
name: 'Texas State Capitol',
|
||||
lat: 30.2747,
|
||||
lon: -97.7403,
|
||||
alt: 550,
|
||||
pitch: -28,
|
||||
heading: 180,
|
||||
buildingHeight: 35,
|
||||
},
|
||||
{
|
||||
name: 'Frost Bank Tower',
|
||||
lat: 30.2674,
|
||||
lon: -97.7434,
|
||||
alt: 550,
|
||||
pitch: -22,
|
||||
heading: 30,
|
||||
buildingHeight: 80,
|
||||
},
|
||||
{
|
||||
name: 'Pennybacker Bridge',
|
||||
lat: 30.3451,
|
||||
lon: -97.7951,
|
||||
alt: 500,
|
||||
pitch: -25,
|
||||
heading: 90,
|
||||
buildingHeight: 40,
|
||||
},
|
||||
{
|
||||
name: 'The Jenga Tower',
|
||||
lat: 30.2642,
|
||||
lon: -97.75,
|
||||
alt: 500,
|
||||
pitch: -18,
|
||||
heading: 45,
|
||||
buildingHeight: 60,
|
||||
},
|
||||
{
|
||||
name: 'UT Tower',
|
||||
lat: 30.2862,
|
||||
lon: -97.7394,
|
||||
alt: 500,
|
||||
pitch: -22,
|
||||
heading: 180,
|
||||
buildingHeight: 50,
|
||||
},
|
||||
],
|
||||
},
|
||||
sf: {
|
||||
name: 'San Francisco',
|
||||
groundElevation: 15,
|
||||
viewBounds: {
|
||||
southwest: { lat: 37.7, lng: -122.53 },
|
||||
northeast: { lat: 37.84, lng: -122.35 },
|
||||
},
|
||||
pois: [
|
||||
{
|
||||
name: 'Golden Gate Bridge',
|
||||
lat: 37.8199,
|
||||
lon: -122.4783,
|
||||
alt: 1400,
|
||||
pitch: -20,
|
||||
heading: 45,
|
||||
buildingHeight: 100,
|
||||
},
|
||||
{
|
||||
name: 'Transamerica Pyramid',
|
||||
lat: 37.7952,
|
||||
lon: -122.4028,
|
||||
alt: 500,
|
||||
pitch: -25,
|
||||
heading: 30,
|
||||
buildingHeight: 85,
|
||||
},
|
||||
{
|
||||
name: 'Salesforce Tower',
|
||||
lat: 37.7897,
|
||||
lon: -122.3972,
|
||||
alt: 680,
|
||||
pitch: -25,
|
||||
heading: 330,
|
||||
buildingHeight: 100,
|
||||
},
|
||||
{
|
||||
name: 'Alcatraz Island',
|
||||
lat: 37.8267,
|
||||
lon: -122.423,
|
||||
alt: 800,
|
||||
pitch: -30,
|
||||
heading: 0,
|
||||
buildingHeight: 20,
|
||||
},
|
||||
{
|
||||
name: 'Coit Tower',
|
||||
lat: 37.8024,
|
||||
lon: -122.4058,
|
||||
alt: 420,
|
||||
pitch: -30,
|
||||
heading: 45,
|
||||
buildingHeight: 30,
|
||||
},
|
||||
],
|
||||
},
|
||||
nyc: {
|
||||
name: 'New York',
|
||||
groundElevation: 10,
|
||||
viewBounds: {
|
||||
southwest: { lat: 40.477, lng: -74.259 },
|
||||
northeast: { lat: 40.918, lng: -73.7 },
|
||||
},
|
||||
pois: [
|
||||
{
|
||||
name: 'Statue of Liberty',
|
||||
lat: 40.6892,
|
||||
lon: -74.0445,
|
||||
alt: 450,
|
||||
pitch: -25,
|
||||
heading: 315,
|
||||
buildingHeight: 45,
|
||||
},
|
||||
{
|
||||
name: 'Empire State Building',
|
||||
lat: 40.7484,
|
||||
lon: -73.9857,
|
||||
alt: 850,
|
||||
pitch: -12,
|
||||
heading: 30,
|
||||
buildingHeight: 130,
|
||||
buildingBounds: { height: 443, width: 130, depth: 75 },
|
||||
},
|
||||
{
|
||||
name: 'One World Trade Center',
|
||||
lat: 40.7127,
|
||||
lon: -74.0134,
|
||||
alt: 850,
|
||||
pitch: -25,
|
||||
heading: 0,
|
||||
buildingHeight: 170,
|
||||
},
|
||||
{
|
||||
name: 'Brooklyn Bridge',
|
||||
lat: 40.7061,
|
||||
lon: -73.9969,
|
||||
alt: 850,
|
||||
pitch: -25,
|
||||
heading: 45,
|
||||
buildingHeight: 40,
|
||||
},
|
||||
{
|
||||
name: 'Chrysler Building',
|
||||
lat: 40.7516,
|
||||
lon: -73.9755,
|
||||
alt: 700,
|
||||
pitch: -20,
|
||||
heading: 225,
|
||||
buildingHeight: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
tokyo: {
|
||||
name: 'Tokyo',
|
||||
groundElevation: 40,
|
||||
viewBounds: {
|
||||
southwest: { lat: 35.52, lng: 139.55 },
|
||||
northeast: { lat: 35.9, lng: 139.92 },
|
||||
},
|
||||
pois: [
|
||||
{
|
||||
name: 'Tokyo Tower',
|
||||
lat: 35.6586,
|
||||
lon: 139.7454,
|
||||
alt: 850,
|
||||
pitch: -25,
|
||||
heading: 0,
|
||||
buildingHeight: 110,
|
||||
},
|
||||
{
|
||||
name: 'Tokyo Skytree',
|
||||
lat: 35.7101,
|
||||
lon: 139.8107,
|
||||
alt: 900,
|
||||
pitch: -25,
|
||||
heading: 30,
|
||||
buildingHeight: 200,
|
||||
},
|
||||
{
|
||||
name: 'Imperial Palace',
|
||||
lat: 35.6852,
|
||||
lon: 139.7528,
|
||||
alt: 900,
|
||||
pitch: -35,
|
||||
heading: 0,
|
||||
buildingHeight: 20,
|
||||
},
|
||||
{
|
||||
name: 'Senso-ji Temple',
|
||||
lat: 35.7148,
|
||||
lon: 139.7967,
|
||||
alt: 400,
|
||||
pitch: -30,
|
||||
heading: 180,
|
||||
buildingHeight: 25,
|
||||
},
|
||||
{
|
||||
name: 'Mode Gakuen Cocoon Tower',
|
||||
lat: 35.6929,
|
||||
lon: 139.6925,
|
||||
alt: 350,
|
||||
pitch: -20,
|
||||
heading: 30,
|
||||
buildingHeight: 70,
|
||||
},
|
||||
],
|
||||
},
|
||||
london: {
|
||||
name: 'London',
|
||||
groundElevation: 15,
|
||||
viewBounds: {
|
||||
southwest: { lat: 51.28, lng: -0.51 },
|
||||
northeast: { lat: 51.7, lng: 0.33 },
|
||||
},
|
||||
pois: [
|
||||
{
|
||||
name: 'Tower Bridge',
|
||||
lat: 51.5055,
|
||||
lon: -0.0754,
|
||||
alt: 400,
|
||||
pitch: -25,
|
||||
heading: 270,
|
||||
buildingHeight: 65,
|
||||
},
|
||||
{
|
||||
name: 'The Shard',
|
||||
lat: 51.5045,
|
||||
lon: -0.0865,
|
||||
alt: 850,
|
||||
pitch: -20,
|
||||
heading: 0,
|
||||
buildingHeight: 100,
|
||||
},
|
||||
{
|
||||
name: 'Big Ben / Parliament',
|
||||
lat: 51.5007,
|
||||
lon: -0.1246,
|
||||
alt: 600,
|
||||
pitch: -25,
|
||||
heading: 180,
|
||||
buildingHeight: 50,
|
||||
},
|
||||
{
|
||||
name: "St. Paul's Cathedral",
|
||||
lat: 51.5138,
|
||||
lon: -0.0984,
|
||||
alt: 400,
|
||||
pitch: -30,
|
||||
heading: 270,
|
||||
buildingHeight: 55,
|
||||
},
|
||||
{
|
||||
name: 'The Gherkin',
|
||||
lat: 51.5145,
|
||||
lon: -0.0803,
|
||||
alt: 350,
|
||||
pitch: -20,
|
||||
heading: 30,
|
||||
buildingHeight: 60,
|
||||
},
|
||||
],
|
||||
},
|
||||
paris: {
|
||||
name: 'Paris',
|
||||
groundElevation: 35,
|
||||
viewBounds: {
|
||||
southwest: { lat: 48.815, lng: 2.224 },
|
||||
northeast: { lat: 48.902, lng: 2.47 },
|
||||
},
|
||||
pois: [
|
||||
{
|
||||
name: 'Eiffel Tower',
|
||||
lat: 48.8584,
|
||||
lon: 2.2945,
|
||||
alt: 750,
|
||||
pitch: -25,
|
||||
heading: 315,
|
||||
buildingHeight: 150,
|
||||
},
|
||||
{
|
||||
name: 'Arc de Triomphe',
|
||||
lat: 48.8738,
|
||||
lon: 2.295,
|
||||
alt: 400,
|
||||
pitch: -28,
|
||||
heading: 45,
|
||||
buildingHeight: 25,
|
||||
},
|
||||
{
|
||||
name: 'Notre-Dame',
|
||||
lat: 48.853,
|
||||
lon: 2.3499,
|
||||
alt: 400,
|
||||
pitch: -25,
|
||||
heading: 225,
|
||||
buildingHeight: 35,
|
||||
},
|
||||
{
|
||||
name: 'Sacré-Cœur',
|
||||
lat: 48.8867,
|
||||
lon: 2.3431,
|
||||
alt: 400,
|
||||
pitch: -30,
|
||||
heading: 180,
|
||||
buildingHeight: 40,
|
||||
},
|
||||
{
|
||||
name: 'Louvre Pyramid',
|
||||
lat: 48.8606,
|
||||
lon: 2.3376,
|
||||
alt: 500,
|
||||
pitch: -35,
|
||||
heading: 0,
|
||||
buildingHeight: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
dubai: {
|
||||
name: 'Dubai',
|
||||
groundElevation: 5,
|
||||
viewBounds: {
|
||||
southwest: { lat: 24.95, lng: 54.9 },
|
||||
northeast: { lat: 25.35, lng: 55.55 },
|
||||
},
|
||||
pois: [
|
||||
{
|
||||
name: 'Burj Khalifa',
|
||||
lat: 25.1972,
|
||||
lon: 55.2744,
|
||||
alt: 600,
|
||||
pitch: -20,
|
||||
heading: 200,
|
||||
buildingHeight: 270,
|
||||
},
|
||||
{
|
||||
name: 'Burj Al Arab',
|
||||
lat: 25.1412,
|
||||
lon: 55.1853,
|
||||
alt: 500,
|
||||
pitch: -25,
|
||||
heading: 90,
|
||||
buildingHeight: 100,
|
||||
},
|
||||
{
|
||||
name: 'Palm Jumeirah',
|
||||
lat: 25.1124,
|
||||
lon: 55.139,
|
||||
alt: 1200,
|
||||
pitch: -40,
|
||||
heading: 0,
|
||||
buildingHeight: 20,
|
||||
},
|
||||
{
|
||||
name: 'Dubai Frame',
|
||||
lat: 25.235,
|
||||
lon: 55.3003,
|
||||
alt: 400,
|
||||
pitch: -25,
|
||||
heading: 270,
|
||||
buildingHeight: 75,
|
||||
},
|
||||
{
|
||||
name: 'Museum of the Future',
|
||||
lat: 25.2197,
|
||||
lon: 55.2806,
|
||||
alt: 350,
|
||||
pitch: -20,
|
||||
heading: 30,
|
||||
buildingHeight: 35,
|
||||
},
|
||||
],
|
||||
},
|
||||
dc: {
|
||||
name: 'Washington DC',
|
||||
groundElevation: 10,
|
||||
viewBounds: {
|
||||
southwest: { lat: 38.79, lng: -77.12 },
|
||||
northeast: { lat: 38.995, lng: -76.91 },
|
||||
},
|
||||
pois: [
|
||||
{
|
||||
name: 'US Capitol',
|
||||
lat: 38.8897,
|
||||
lon: -77.0091,
|
||||
alt: 550,
|
||||
pitch: -25,
|
||||
heading: 270,
|
||||
buildingHeight: 45,
|
||||
},
|
||||
{
|
||||
name: 'Washington Monument',
|
||||
lat: 38.8895,
|
||||
lon: -77.0353,
|
||||
alt: 500,
|
||||
pitch: -30,
|
||||
heading: 0,
|
||||
buildingHeight: 85,
|
||||
},
|
||||
{
|
||||
name: 'Lincoln Memorial',
|
||||
lat: 38.8893,
|
||||
lon: -77.0502,
|
||||
alt: 400,
|
||||
pitch: -25,
|
||||
heading: 90,
|
||||
buildingHeight: 20,
|
||||
},
|
||||
{
|
||||
name: 'Pentagon',
|
||||
lat: 38.8711,
|
||||
lon: -77.0559,
|
||||
alt: 800,
|
||||
pitch: -40,
|
||||
heading: 0,
|
||||
buildingHeight: 20,
|
||||
},
|
||||
{
|
||||
name: 'Jefferson Memorial',
|
||||
lat: 38.8814,
|
||||
lon: -77.0365,
|
||||
alt: 400,
|
||||
pitch: -30,
|
||||
heading: 0,
|
||||
buildingHeight: 25,
|
||||
},
|
||||
],
|
||||
},
|
||||
tallinn: {
|
||||
name: 'Tallinn',
|
||||
groundElevation: 15,
|
||||
viewBounds: {
|
||||
southwest: { lat: 59.36, lng: 24.6 },
|
||||
northeast: { lat: 59.52, lng: 24.92 },
|
||||
},
|
||||
pois: [
|
||||
{
|
||||
name: 'Viru Square',
|
||||
lat: 59.4366,
|
||||
lon: 24.7527,
|
||||
alt: 450,
|
||||
pitch: -28,
|
||||
heading: 60,
|
||||
buildingHeight: 25,
|
||||
},
|
||||
{
|
||||
name: 'Old Town / Raekoja plats',
|
||||
lat: 59.4372,
|
||||
lon: 24.7452,
|
||||
alt: 400,
|
||||
pitch: -30,
|
||||
heading: 180,
|
||||
buildingHeight: 20,
|
||||
},
|
||||
{
|
||||
name: 'Teatri väljak',
|
||||
lat: 59.4344,
|
||||
lon: 24.7514,
|
||||
alt: 400,
|
||||
pitch: -25,
|
||||
heading: 220,
|
||||
buildingHeight: 25,
|
||||
},
|
||||
{
|
||||
name: 'Port of Tallinn',
|
||||
lat: 59.4445,
|
||||
lon: 24.7675,
|
||||
alt: 700,
|
||||
pitch: -30,
|
||||
heading: 90,
|
||||
buildingHeight: 20,
|
||||
},
|
||||
{
|
||||
name: 'Ülemiste',
|
||||
lat: 59.421,
|
||||
lon: 24.792,
|
||||
alt: 600,
|
||||
pitch: -28,
|
||||
heading: 45,
|
||||
buildingHeight: 30,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Absolute full-earth camera preset for the zoom_to_globe voice tool. The height
|
||||
* must stay inside the app's 'global' view-scale band (>12,000 km — classifyViewScale
|
||||
|
||||
@@ -12,8 +12,7 @@ import { t, msg } from './i18n/index.js';
|
||||
export const NAVIGATION_AUTHORITY_EVENT = 'gev:navigation-authority-taken';
|
||||
|
||||
// The Cockpit refusal per destination noun. Nouns are English identifiers
|
||||
// from the callers ('camera', 'location', 'photo'…); a noun without its own
|
||||
// sentence falls back to the template with the noun as given.
|
||||
// from the callers ('camera', 'location', 'photo'…);
|
||||
// navigationPolicyFrame.test.mjs collects every noun the callers pass and
|
||||
// fails when one has no sentence here.
|
||||
const COCKPIT_REFUSALS = Object.freeze({
|
||||
@@ -44,8 +43,7 @@ export const COCKPIT_REFUSAL_NOUNS = Object.freeze(
|
||||
);
|
||||
|
||||
function cockpitRefusal(noun) {
|
||||
const ready = COCKPIT_REFUSALS[noun];
|
||||
return ready ? t(ready) : t('Exit cockpit to fly to a {noun}', { noun });
|
||||
return t(COCKPIT_REFUSALS[noun]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
// N-06: voice frame_overhead under Cockpit passed the noun 'frame', which had
|
||||
// no entry in COCKPIT_REFUSALS, so the toast fell back to the template with
|
||||
// the English id («Sal de la cabina para volar a frame»). The second test
|
||||
// collects every noun the callers hand to the navigation policy, so the next
|
||||
// The Cockpit refusal names its destination in the UI language. The scan
|
||||
// below collects every noun the callers hand to the navigation policy, so a
|
||||
// noun without its own sentence fails here instead of reaching the page.
|
||||
import assert from 'node:assert/strict';
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
@@ -59,17 +57,15 @@ function sourceFiles(dir) {
|
||||
});
|
||||
}
|
||||
|
||||
const lineOf = (text, index) => text.slice(0, index).split('\n').length;
|
||||
|
||||
// Calls whose first argument is the destination noun.
|
||||
const NOUN_FIRST =
|
||||
/\b(runImmediateNavigation|_runExplicitNavigation|runOrientation|_beginDeferredNavigation|cockpitRefusal|runNavigation|navigation\??\.(?:run|begin))\s*\(\s*([^,)]*)/g;
|
||||
// runManagedVoiceNavigation(styleManager, noun, action, …) in gevActions.js.
|
||||
const VOICE_NOUN = /\brunManagedVoiceNavigation\s*\(\s*[\w.?]+\s*,\s*([^,)]*)/g;
|
||||
// Parameter defaults: `noun = 'location'`.
|
||||
const NOUN_DEFAULT = /\bnoun\s*=\s*'([^']+)'/g;
|
||||
// A local noun picked by a condition: `const noun = … ? 'cyclone' : 'weather'`.
|
||||
const NOUN_CHOICE = /\bconst noun\s*=\s*[^?;]*\?\s*'([^']+)'\s*:\s*'([^']+)'/g;
|
||||
// The destination noun: the first argument of a navigation call, or the
|
||||
// second of runManagedVoiceNavigation(styleManager, noun, …) in gevActions.js.
|
||||
// `runNavigation(fly)` layer seams take only the flight (group 1).
|
||||
const NOUN_ARGUMENT =
|
||||
/\b(?:(runNavigation)\s*\(|(?:runImmediateNavigation|_runExplicitNavigation|runOrientation|_beginDeferredNavigation|cockpitRefusal|navigation\??\.(?:run|begin))\s*\(|runManagedVoiceNavigation\s*\(\s*[\w.?]+\s*,)\s*([^,)]*)/g;
|
||||
// Literal nouns elsewhere: a parameter default (`noun = 'location'`) or both
|
||||
// branches of `const noun = … ? 'cyclone' : 'weather'`.
|
||||
const NOUN_LITERAL =
|
||||
/\bnoun\s*=\s*'([^']+)'|\bconst noun\s*=\s*[^?;]*\?\s*'([^']+)'\s*:\s*'([^']+)'/g;
|
||||
|
||||
function trackableKinds() {
|
||||
const source = readFileSync(join(SRC, 'voice/gevActions.js'), 'utf8');
|
||||
@@ -78,54 +74,36 @@ function trackableKinds() {
|
||||
return [...block[1].matchAll(/kind:\s*'([^']+)'/g)].map((m) => m[1]);
|
||||
}
|
||||
|
||||
// Non-literal first arguments the test knows how to expand. Anything else is
|
||||
// a new dynamic noun: list its values here.
|
||||
// Non-literal arguments: what each one can be. A wrapper's own `noun` (or all
|
||||
// its arguments) passes on; its default is a NOUN_LITERAL. Anything else is a
|
||||
// new dynamic noun: list its values here.
|
||||
const DYNAMIC_NOUNS = new Map([
|
||||
['family.kind', trackableKinds],
|
||||
[
|
||||
"detail?.kind || 'target'",
|
||||
() => [...Object.keys(WORLD_FOCUS_FRAMING), 'target'],
|
||||
],
|
||||
['noun', () => []],
|
||||
['...args', () => []],
|
||||
['...arguments', () => []],
|
||||
]);
|
||||
// Wrappers that pass their own `noun` parameter (or all arguments) on.
|
||||
const PASS_THROUGH = new Set(['noun', '...args', '...arguments']);
|
||||
|
||||
function collectNouns() {
|
||||
const nouns = new Map();
|
||||
const nouns = new Set();
|
||||
const unknown = [];
|
||||
const add = (noun, where) => {
|
||||
if (!nouns.has(noun)) nouns.set(noun, where);
|
||||
};
|
||||
for (const file of sourceFiles(SRC)) {
|
||||
const text = readFileSync(file, 'utf8');
|
||||
const name = relative(SRC, file);
|
||||
const take = (raw, where, { closures = false } = {}) => {
|
||||
for (const [, seam, raw] of text.matchAll(NOUN_ARGUMENT)) {
|
||||
const arg = raw.trim();
|
||||
const literal = arg.match(/^'([^']*)'$/);
|
||||
if (literal) return add(literal[1], where);
|
||||
// A wrapper's own parameter; its default is collected by NOUN_DEFAULT.
|
||||
if (PASS_THROUGH.has(arg) || /^noun\s*=\s*'[^']+'$/.test(arg)) return;
|
||||
if (DYNAMIC_NOUNS.has(arg)) {
|
||||
for (const noun of DYNAMIC_NOUNS.get(arg)()) add(noun, where);
|
||||
return;
|
||||
}
|
||||
// The layer-side `runNavigation(fly)` seams take only the flight; their
|
||||
// noun is bound where the seam is built.
|
||||
if (closures) return;
|
||||
unknown.push(`${where}: ${arg}`);
|
||||
};
|
||||
for (const m of text.matchAll(NOUN_FIRST))
|
||||
take(m[2], `${name}:${lineOf(text, m.index)}`, {
|
||||
closures: m[1] === 'runNavigation',
|
||||
});
|
||||
for (const m of text.matchAll(VOICE_NOUN))
|
||||
take(m[1], `${name}:${lineOf(text, m.index)}`);
|
||||
for (const m of text.matchAll(NOUN_DEFAULT))
|
||||
add(m[1], `${name}:${lineOf(text, m.index)}`);
|
||||
for (const m of text.matchAll(NOUN_CHOICE)) {
|
||||
add(m[1], `${name}:${lineOf(text, m.index)}`);
|
||||
add(m[2], `${name}:${lineOf(text, m.index)}`);
|
||||
const literal = arg.match(/^'([^']*)'$/)?.[1];
|
||||
if (literal) nouns.add(literal);
|
||||
else if (DYNAMIC_NOUNS.has(arg))
|
||||
DYNAMIC_NOUNS.get(arg)().forEach((noun) => nouns.add(noun));
|
||||
else if (!seam && !/^noun\s*=/.test(arg))
|
||||
unknown.push(`${relative(SRC, file)}: ${arg}`);
|
||||
}
|
||||
for (const m of text.matchAll(NOUN_LITERAL))
|
||||
m.slice(1).forEach((noun) => noun && nouns.add(noun));
|
||||
}
|
||||
return { nouns, unknown };
|
||||
}
|
||||
@@ -134,25 +112,26 @@ test('every noun the callers pass has its own Cockpit refusal', () => {
|
||||
const { nouns, unknown } = collectNouns();
|
||||
assert.deepEqual(unknown, [], 'a new dynamic noun: add it to DYNAMIC_NOUNS');
|
||||
// The scan still sees the known callers (a guard against a broken regex).
|
||||
for (const noun of ['location', 'camera', 'photo', 'shared view', 'file'])
|
||||
for (const noun of [
|
||||
'location',
|
||||
'camera',
|
||||
'photo',
|
||||
'shared view',
|
||||
'file',
|
||||
'scene',
|
||||
'route',
|
||||
'weather',
|
||||
'cyclone',
|
||||
'vessel',
|
||||
'fire',
|
||||
'frame',
|
||||
'aircraft',
|
||||
'satellite',
|
||||
'target',
|
||||
])
|
||||
assert.ok(nouns.has(noun), `found ${noun}`);
|
||||
for (const noun of ['scene', 'route', 'weather', 'cyclone', 'vessel', 'fire'])
|
||||
assert.ok(nouns.has(noun), `found ${noun}`);
|
||||
for (const noun of ['frame', 'aircraft', 'satellite', 'target'])
|
||||
assert.ok(nouns.has(noun), `found ${noun}`);
|
||||
|
||||
const missing = [...nouns]
|
||||
.filter(([noun]) => !COCKPIT_REFUSAL_NOUNS.includes(noun))
|
||||
.map(([noun, where]) => `${noun} (${where})`);
|
||||
const missing = [...nouns].filter(
|
||||
(noun) => !COCKPIT_REFUSAL_NOUNS.includes(noun),
|
||||
);
|
||||
assert.deepEqual(missing, [], 'add these nouns to COCKPIT_REFUSALS');
|
||||
|
||||
setLocale('es');
|
||||
for (const noun of nouns.keys()) {
|
||||
const shown = cockpitToast(noun);
|
||||
assert.notEqual(
|
||||
shown,
|
||||
`Sal de la cabina para volar a ${noun}`,
|
||||
`${noun}: the template with the English id`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -119,7 +119,7 @@ export function createSceneSharing(director) {
|
||||
{
|
||||
scene: pack.scene,
|
||||
pack: pack.id,
|
||||
status: pack.status,
|
||||
status: t(pack.status),
|
||||
path: pack.path,
|
||||
attribution: pack.attribution.text,
|
||||
license: pack.attribution.license,
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
// Imported by the server: no i18n here. The text stays English data; layers
|
||||
// show it translated (translateKnown).
|
||||
const msg = (text) => text;
|
||||
|
||||
const tooLarge = () =>
|
||||
Object.assign(new Error(msg('Upstream response too large')), {
|
||||
code: 'RESPONSE_TOO_LARGE',
|
||||
});
|
||||
|
||||
/**
|
||||
* Read a fetch() Response body as text with a hard byte cap. Rejects early on an
|
||||
* oversized Content-Length, then streams with a running cap so a chunked or
|
||||
* length-omitted response cannot blow past the limit. Throws { code:'RESPONSE_TOO_LARGE' }.
|
||||
*/
|
||||
export async function readResponseTextCapped(response, maxBytes, signal) {
|
||||
const tooLarge = () =>
|
||||
Object.assign(new Error('Upstream response too large'), {
|
||||
code: 'RESPONSE_TOO_LARGE',
|
||||
});
|
||||
const declared = Number(response.headers.get('content-length'));
|
||||
if (Number.isFinite(declared) && declared > maxBytes) {
|
||||
void response.body?.cancel().catch(() => {});
|
||||
@@ -61,10 +66,6 @@ export async function readResponseJsonCapped(response, maxBytes, signal) {
|
||||
* Throws { code:'RESPONSE_TOO_LARGE' }.
|
||||
*/
|
||||
export async function readResponseBytesCapped(response, maxBytes, signal) {
|
||||
const tooLarge = () =>
|
||||
Object.assign(new Error('Upstream response too large'), {
|
||||
code: 'RESPONSE_TOO_LARGE',
|
||||
});
|
||||
const declared = Number(response.headers.get('content-length'));
|
||||
if (Number.isFinite(declared) && declared > maxBytes) {
|
||||
await response.body?.cancel().catch(() => {});
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* Records contain observation data only, with no scene objects or transport data.
|
||||
*/
|
||||
// Messages are English data (AI tools read them too); the Layers panel shows
|
||||
// them translated by their shape (translateFeedError in src/ui/layerPanel.js),
|
||||
// them translated by their shape (translateFeedError in src/ui/statusText.js),
|
||||
// so a new or reworded message is changed there as well.
|
||||
export class LiveSourceError extends Error {
|
||||
constructor(
|
||||
|
||||
@@ -714,10 +714,6 @@ test('KML and KMZ rows say they keep their own styles instead of showing a color
|
||||
assert.ok(gpx.children[0].style.background);
|
||||
});
|
||||
|
||||
// N-08: tn() replaces values.count with the raw number, so the
|
||||
// formatNumber(...) passed by the import list is lost: "12345 map objects"
|
||||
// (main: "12,345"). Append to src/ui/geoFileImport.test.mjs (uses its setup(),
|
||||
// file(), rows() helpers).
|
||||
test('the map-object count in a row title keeps digit grouping', async () => {
|
||||
const t = setup({
|
||||
load: async (f) => ({
|
||||
|
||||
@@ -117,8 +117,6 @@ const SOURCE_LABEL_KEYS = new Map(
|
||||
msg('OSM routing'),
|
||||
msg('Mapped camera locations'),
|
||||
msg('OpenStreetMap · community mapped'),
|
||||
msg('Decoder feeds'),
|
||||
msg('WebUSB + decoder feeds'),
|
||||
].map((key) => [key, key]),
|
||||
);
|
||||
|
||||
|
||||
+1
-190
@@ -14,12 +14,12 @@ import {
|
||||
} from './layerNames.js';
|
||||
export { localizeLayerName } from './layerNames.js';
|
||||
import { createWeatherPanel } from './weatherPanel.js';
|
||||
import { translateFeedError, translateLoadingLabel } from './statusText.js';
|
||||
import {
|
||||
formatNumber,
|
||||
getLocale,
|
||||
msg,
|
||||
t,
|
||||
tn,
|
||||
translateKnown,
|
||||
} from '../i18n/index.js';
|
||||
|
||||
@@ -146,197 +146,8 @@ export function layerKeyRequirementTooltip(layer = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
// Live-source failures arrive as English data (src/sources/live/contract.js,
|
||||
// standalone.js, flights and military ingestion), some with a source name or a
|
||||
// number in them; the row shows them in the UI language. Keep these in step
|
||||
// with the producers. Anything else shows its catalog entry, or as it came.
|
||||
const FEED_ERRORS = new Set([
|
||||
msg('OpenSky OAuth client missing/invalid'),
|
||||
msg('OpenSky OAuth rejected credentials'),
|
||||
msg('OpenSky username/password rejected'),
|
||||
msg('OpenSky auth missing'),
|
||||
msg('OpenSky auth required'),
|
||||
msg('OpenSky auth invalid'),
|
||||
msg('OpenSky auth failed'),
|
||||
]);
|
||||
const FEED_ERROR_PATTERNS = [
|
||||
// Street Level coverage errors (streetLevel/providers/kartaview/source.js),
|
||||
// ahead of the generic "<source> HTTP <status>".
|
||||
[
|
||||
/^KartaView answered HTTP (\d+)$/,
|
||||
([, status]) => t('KartaView answered HTTP {status}', { status }),
|
||||
],
|
||||
[
|
||||
/^Panoramax answered HTTP (\d+)$/,
|
||||
([, status]) => t('Panoramax answered HTTP {status}', { status }),
|
||||
],
|
||||
[
|
||||
/^KartaView refused the request \(code (-?\d+)\)$/,
|
||||
([, code]) => t('KartaView refused the request (code {code})', { code }),
|
||||
],
|
||||
// Weather imagery (weather/source.js, infraredImage.js) and vector tile
|
||||
// (sources/vectorTiles.js) failures carry their own wording; the generic
|
||||
// pattern below would take "Weather image" for a source name.
|
||||
[
|
||||
/^Weather (image )?HTTP (\d+)$/,
|
||||
([, image, status]) =>
|
||||
image
|
||||
? t('Weather image HTTP {status}', { status })
|
||||
: t('Weather HTTP {status}', { status }),
|
||||
],
|
||||
[
|
||||
/^Vector tiles unavailable \(HTTP (\d+)\)$/,
|
||||
([, status]) => t('Vector tiles unavailable (HTTP {status})', { status }),
|
||||
],
|
||||
// Layer statuses with a number or a feed name in them: GNSS
|
||||
// (gnss/source.js), CelesTrak groups (satellites/ingestion.js) and transit
|
||||
// feeds (transit/ingestion.js).
|
||||
[
|
||||
/^GNSS feed unavailable \(HTTP (\d{3})\)$/,
|
||||
([, status]) => t('GNSS feed unavailable (HTTP {status})', { status }),
|
||||
],
|
||||
[
|
||||
/^(\d+) CelesTrak groups? unavailable$/,
|
||||
([, groups]) =>
|
||||
tn(
|
||||
Number(groups),
|
||||
'{count} CelesTrak group unavailable',
|
||||
'{count} CelesTrak groups unavailable',
|
||||
{ count: formatNumber(Number(groups)) },
|
||||
),
|
||||
],
|
||||
[
|
||||
/^(.+) feed unavailable$/,
|
||||
([, feed]) => t('{feed} feed unavailable', { feed: translateKnown(feed) }),
|
||||
],
|
||||
[
|
||||
/^Source snapshot (\d+) min old$/,
|
||||
([, minutes]) => t('Source snapshot {minutes} min old', { minutes }),
|
||||
],
|
||||
[
|
||||
/^(.+) rate limited \(anonymous\)$/,
|
||||
([, source]) =>
|
||||
t('{source} rate limited (anonymous)', { source: sourceLabel(source) }),
|
||||
],
|
||||
[
|
||||
/^(.+) rate limited$/,
|
||||
([, source]) => t('{source} rate limited', { source: sourceLabel(source) }),
|
||||
],
|
||||
[
|
||||
/^(.+) HTTP (\d{3})$/,
|
||||
([, source, status]) =>
|
||||
t('{source} HTTP {status}', { source: sourceLabel(source), status }),
|
||||
],
|
||||
[
|
||||
/^(.+) network error$/,
|
||||
([, source]) =>
|
||||
t('{source} network error', { source: sourceLabel(source) }),
|
||||
],
|
||||
[
|
||||
/^Malformed (.+) aircraft rows$/,
|
||||
([, source]) =>
|
||||
t('Malformed {source} aircraft rows', { source: sourceLabel(source) }),
|
||||
],
|
||||
[
|
||||
/^Malformed (.+) response$/,
|
||||
([, source]) =>
|
||||
t('Malformed {source} response', { source: sourceLabel(source) }),
|
||||
],
|
||||
];
|
||||
|
||||
/** One error text in the UI language, or null when nothing matches. */
|
||||
function feedErrorText(text) {
|
||||
if (FEED_ERRORS.has(text)) return t(text);
|
||||
const known = translateKnown(text);
|
||||
if (known !== text) return known;
|
||||
for (const [pattern, format] of FEED_ERROR_PATTERNS) {
|
||||
const match = pattern.exec(text);
|
||||
if (match) return format(match);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A layer's error text in the UI language (see FEED_ERRORS). An error made of
|
||||
* several statuses joined by " · " (Local ADS-B) translates piece by piece.
|
||||
*/
|
||||
export function translateFeedError(text) {
|
||||
if (typeof text !== 'string') return text;
|
||||
const whole = feedErrorText(text);
|
||||
if (whole !== null) return whole;
|
||||
if (!text.includes(ROW_TEXT_SEPARATOR)) return text;
|
||||
return text
|
||||
.split(ROW_TEXT_SEPARATOR)
|
||||
.map((piece) => feedErrorText(piece) ?? translateStatusPiece(piece))
|
||||
.join(ROW_TEXT_SEPARATOR);
|
||||
}
|
||||
|
||||
const ROW_TEXT_SEPARATOR = ' · ';
|
||||
|
||||
// Feed states named in a Local ADS-B feed problem ("feeds 1090, 978 stale").
|
||||
const FEED_PROBLEM_FORMS = Object.freeze({
|
||||
stale: (count, names) =>
|
||||
tn(count, 'feed {names} stale', 'feeds {names} stale', { names }),
|
||||
unreachable: (count, names) =>
|
||||
tn(count, 'feed {names} unreachable', 'feeds {names} unreachable', {
|
||||
names,
|
||||
}),
|
||||
invalid: (count, names) =>
|
||||
tn(count, 'feed {names} invalid', 'feeds {names} invalid', { names }),
|
||||
});
|
||||
|
||||
// Working texts with a number or a name in them arrive as English data, one
|
||||
// piece of a " · " joined line each: transit (transit/queries.js) and Local
|
||||
// ADS-B (localAdsb/status.js). Keep these in step with the producers.
|
||||
const STATUS_PIECE_PATTERNS = [
|
||||
[/^Loading (.+)$/, ([, feeds]) => t('Loading {feeds}', { feeds })],
|
||||
[
|
||||
/^(\d+) heard$/,
|
||||
([, heard]) =>
|
||||
tn(Number(heard), '{count} heard', '{count} heard', {
|
||||
count: formatNumber(Number(heard)),
|
||||
}),
|
||||
],
|
||||
[
|
||||
/^(USB )?(\d+(?:\.\d+)?) msg\/s$/,
|
||||
([, usb, rate]) =>
|
||||
usb
|
||||
? t('USB {rate} msg/s', { rate: formatNumber(Number(rate)) })
|
||||
: t('{rate} msg/s', { rate: formatNumber(Number(rate)) }),
|
||||
],
|
||||
[
|
||||
/^(\d+) feeds? live$/,
|
||||
([, live]) =>
|
||||
tn(Number(live), '{count} feed live', '{count} feeds live', {
|
||||
count: formatNumber(Number(live)),
|
||||
}),
|
||||
],
|
||||
[
|
||||
/^feeds? (.+) (stale|unreachable|invalid)$/,
|
||||
([, names, status]) =>
|
||||
FEED_PROBLEM_FORMS[status](names.split(', ').length, names),
|
||||
],
|
||||
];
|
||||
|
||||
/** One piece of a status line in the UI language, or as it came. */
|
||||
function translateStatusPiece(piece) {
|
||||
const known = translateKnown(piece);
|
||||
if (known !== piece) return known;
|
||||
for (const [pattern, format] of STATUS_PIECE_PATTERNS) {
|
||||
const match = pattern.exec(piece);
|
||||
if (match) return format(match);
|
||||
}
|
||||
return piece;
|
||||
}
|
||||
|
||||
/** A loading label is English data, at times several joined by " · ". */
|
||||
function translateLoadingLabel(label) {
|
||||
return label
|
||||
.trim()
|
||||
.split(ROW_TEXT_SEPARATOR)
|
||||
.map(translateStatusPiece)
|
||||
.join(ROW_TEXT_SEPARATOR);
|
||||
}
|
||||
// A short Latin code joined by hyphens. Longer runs (a URL, a token in an
|
||||
// error) keep their break opportunities so they cannot overflow the rail.
|
||||
const HYPHENATED_CODE = /[A-Za-z0-9]+(?:-[A-Za-z0-9]+)+/g;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// N-03: layer status texts produced without msg()/catalog entries (GNSS,
|
||||
// Transit, CelesTrak, AQHI) stay English in the es/ru DATA LAYERS row.
|
||||
// Place: src/ui/layerStatusLocale.test.mjs
|
||||
// The DATA LAYERS row shows the English status texts that GNSS, Transit,
|
||||
// CelesTrak and AQHI produce in the UI language.
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
} from '../loadingFeedback.js';
|
||||
import { setSplitFlapText, disposeSplitFlap } from '../splitFlap.js';
|
||||
import { getLocale, t, msg } from '../i18n/index.js';
|
||||
import { localizeLayerName, translateFeedError } from './layerPanel.js';
|
||||
import { localizeLayerName } from './layerPanel.js';
|
||||
import { translateFeedError } from './statusText.js';
|
||||
|
||||
// loadingFeedback.js composes the shared status copy inside the portable
|
||||
// node/browser graph (the server-side HUD summary reads the same modules), so
|
||||
@@ -56,6 +57,7 @@ const CHIP_ERROR_KEYS = new Map(
|
||||
msg('Vector tile request timed out'),
|
||||
msg('Camera coverage unavailable'),
|
||||
msg('Camera source temporarily unavailable'),
|
||||
msg('Overpass returned an incomplete camera response'),
|
||||
].map((key) => [key.toUpperCase(), key]),
|
||||
);
|
||||
const CHIP_TILE_HTTP_RE = /^VECTOR TILES UNAVAILABLE \(HTTP (\d+)\)$/;
|
||||
|
||||
@@ -5,7 +5,13 @@
|
||||
// catalog entry, not only the default one.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { setLocale, t } from '../i18n/index.js';
|
||||
import {
|
||||
aggregateLayerLoading,
|
||||
createLoadingFeedbackState,
|
||||
presentLoadingFeedback,
|
||||
reduceLoadingFeedback,
|
||||
} from '../loadingFeedback.js';
|
||||
|
||||
const elements = {
|
||||
'global-loading-status': { hidden: true, dataset: {} },
|
||||
@@ -107,4 +113,30 @@ for (const locale of ['es', 'ru']) {
|
||||
assert.equal(localized.label, 'HTTP 503 FROM OVERPASS-API.DE');
|
||||
assert.notEqual(localized.detail, 'retry pending');
|
||||
});
|
||||
|
||||
// An incomplete Overpass answer (alpr/source.js) on the ALPR retry chip,
|
||||
// which shows the layer's error upper-cased.
|
||||
test(`${locale}: the ALPR retry chip translates an incomplete Overpass answer`, () => {
|
||||
setLocale(locale);
|
||||
const error = 'Overpass returned an incomplete camera response';
|
||||
assert.notEqual(t(error), error, 'the catalog has the entry');
|
||||
const now = Date.now();
|
||||
const layer = {
|
||||
id: 'alpr-cameras',
|
||||
name: 'ALPR cameras',
|
||||
enabled: true,
|
||||
stats: { status: 'unavailable', error, retryAt: now + 12000 },
|
||||
};
|
||||
const summary = aggregateLayerLoading([layer]);
|
||||
const state = reduceLoadingFeedback(
|
||||
createLoadingFeedbackState(),
|
||||
summary,
|
||||
now,
|
||||
);
|
||||
const shown = localizeLoadingPresentation(
|
||||
presentLoadingFeedback(state, summary, now),
|
||||
);
|
||||
assert.equal(shown.state, 'retry', 'the ALPR retry chip is up');
|
||||
assert.equal(shown.label, t(error).toLocaleUpperCase(locale));
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
// Shell toasts, prompts and launcher labels that reach the page through
|
||||
// `showToast?.(`, `this._showToast(`, `window.prompt/confirm` or a constant
|
||||
// are invisible to the unmarked-text checker, so pin them here: marked in the
|
||||
// code, present in every shipped catalog, and no bare literal left at the
|
||||
// call sites.
|
||||
// Shell toasts, prompts and launcher labels: pin that they are marked in the
|
||||
// code, present in every shipped catalog, and that no bare literal is left at
|
||||
// the call sites (`window.prompt/confirm` and constants are invisible to the
|
||||
// unmarked-text checker).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
@@ -27,7 +26,6 @@ const KEYS = [
|
||||
'Exit cockpit to fly to a location',
|
||||
'Exit cockpit to fly to a photo',
|
||||
'Exit cockpit to fly to a shared view',
|
||||
'Exit cockpit to fly to a {noun}',
|
||||
'Go to expanded Radio section',
|
||||
'Expand Radio section in Context',
|
||||
'Double-click the header to snap the panel back',
|
||||
@@ -84,12 +82,10 @@ test('the Cockpit refusal keeps its English wording per noun', () => {
|
||||
cockpitActive: true,
|
||||
showToast,
|
||||
});
|
||||
runExplicitNavigation({ cockpitActive: true, noun: 'contact', showToast });
|
||||
assert.deepEqual(log, [
|
||||
'Exit cockpit to fly to a camera',
|
||||
'Exit cockpit to fly to a location',
|
||||
'Exit cockpit to fly to a location',
|
||||
'Exit cockpit to fly to a contact',
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* Layer status texts in the UI language.
|
||||
*
|
||||
* Layers report errors, loading labels and status notes as English data
|
||||
* (stats, analyst records, what the model receives); the Layers panel, the
|
||||
* loading chip, layer cards and the voice card show them translated through
|
||||
* here, by their shape.
|
||||
*
|
||||
* Pure: imports only i18n and layer names, no DOM.
|
||||
* @module ui/statusText
|
||||
*/
|
||||
import { sourceLabel } from './layerNames.js';
|
||||
import { formatNumber, msg, t, tn, translateKnown } from '../i18n/index.js';
|
||||
|
||||
const STATUS_SEPARATOR = ' · ';
|
||||
|
||||
// Live-source failures arrive as English data (src/sources/live/contract.js,
|
||||
// standalone.js, flights and military ingestion), some with a source name or a
|
||||
// number in them; the row shows them in the UI language. Keep these in step
|
||||
// with the producers. Anything else shows its catalog entry, or as it came.
|
||||
const FEED_ERRORS = new Set([
|
||||
msg('OpenSky OAuth client missing/invalid'),
|
||||
msg('OpenSky OAuth rejected credentials'),
|
||||
msg('OpenSky username/password rejected'),
|
||||
msg('OpenSky auth missing'),
|
||||
msg('OpenSky auth required'),
|
||||
msg('OpenSky auth invalid'),
|
||||
msg('OpenSky auth failed'),
|
||||
]);
|
||||
const FEED_ERROR_PATTERNS = [
|
||||
// Street Level coverage errors (streetLevel/providers/kartaview/source.js),
|
||||
// ahead of the generic "<source> HTTP <status>".
|
||||
[
|
||||
/^KartaView answered HTTP (\d+)$/,
|
||||
([, status]) => t('KartaView answered HTTP {status}', { status }),
|
||||
],
|
||||
[
|
||||
/^Panoramax answered HTTP (\d+)$/,
|
||||
([, status]) => t('Panoramax answered HTTP {status}', { status }),
|
||||
],
|
||||
[
|
||||
/^KartaView refused the request \(code (-?\d+)\)$/,
|
||||
([, code]) => t('KartaView refused the request (code {code})', { code }),
|
||||
],
|
||||
// Weather imagery (weather/source.js, infraredImage.js) and vector tile
|
||||
// (sources/vectorTiles.js) failures carry their own wording; the generic
|
||||
// pattern below would take "Weather image" for a source name.
|
||||
[
|
||||
/^Weather (image )?HTTP (\d+)$/,
|
||||
([, image, status]) =>
|
||||
image
|
||||
? t('Weather image HTTP {status}', { status })
|
||||
: t('Weather HTTP {status}', { status }),
|
||||
],
|
||||
[
|
||||
/^Vector tiles unavailable \(HTTP (\d+)\)$/,
|
||||
([, status]) => t('Vector tiles unavailable (HTTP {status})', { status }),
|
||||
],
|
||||
// Layer statuses with a number or a feed name in them: GNSS
|
||||
// (gnss/source.js), CelesTrak groups (satellites/ingestion.js) and transit
|
||||
// feeds (transit/ingestion.js).
|
||||
[
|
||||
/^GNSS feed unavailable \(HTTP (\d{3})\)$/,
|
||||
([, status]) => t('GNSS feed unavailable (HTTP {status})', { status }),
|
||||
],
|
||||
[
|
||||
/^(\d+) CelesTrak groups? unavailable$/,
|
||||
([, groups]) =>
|
||||
tn(
|
||||
Number(groups),
|
||||
'{count} CelesTrak group unavailable',
|
||||
'{count} CelesTrak groups unavailable',
|
||||
{ count: formatNumber(Number(groups)) },
|
||||
),
|
||||
],
|
||||
[
|
||||
/^(.+) feed unavailable$/,
|
||||
([, feed]) => t('{feed} feed unavailable', { feed: translateKnown(feed) }),
|
||||
],
|
||||
[
|
||||
/^Source snapshot (\d+) min old$/,
|
||||
([, minutes]) => t('Source snapshot {minutes} min old', { minutes }),
|
||||
],
|
||||
[
|
||||
/^(.+) rate limited \(anonymous\)$/,
|
||||
([, source]) =>
|
||||
t('{source} rate limited (anonymous)', { source: sourceLabel(source) }),
|
||||
],
|
||||
[
|
||||
/^(.+) rate limited$/,
|
||||
([, source]) => t('{source} rate limited', { source: sourceLabel(source) }),
|
||||
],
|
||||
[
|
||||
/^(.+) HTTP (\d{3})$/,
|
||||
([, source, status]) =>
|
||||
t('{source} HTTP {status}', { source: sourceLabel(source), status }),
|
||||
],
|
||||
[
|
||||
/^(.+) network error$/,
|
||||
([, source]) =>
|
||||
t('{source} network error', { source: sourceLabel(source) }),
|
||||
],
|
||||
[
|
||||
/^Malformed (.+) aircraft rows$/,
|
||||
([, source]) =>
|
||||
t('Malformed {source} aircraft rows', { source: sourceLabel(source) }),
|
||||
],
|
||||
[
|
||||
/^Malformed (.+) response$/,
|
||||
([, source]) =>
|
||||
t('Malformed {source} response', { source: sourceLabel(source) }),
|
||||
],
|
||||
];
|
||||
|
||||
/** One error text in the UI language, or null when nothing matches. */
|
||||
function feedErrorText(text) {
|
||||
if (FEED_ERRORS.has(text)) return t(text);
|
||||
const known = translateKnown(text);
|
||||
if (known !== text) return known;
|
||||
for (const [pattern, format] of FEED_ERROR_PATTERNS) {
|
||||
const match = pattern.exec(text);
|
||||
if (match) return format(match);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A layer's error text in the UI language (see FEED_ERRORS). An error made of
|
||||
* several statuses joined by " · " (Local ADS-B) translates piece by piece,
|
||||
* and so does a lone status piece ("feed 1090 unreachable").
|
||||
*/
|
||||
export function translateFeedError(text) {
|
||||
if (typeof text !== 'string') return text;
|
||||
const whole = feedErrorText(text);
|
||||
if (whole !== null) return whole;
|
||||
return text
|
||||
.split(STATUS_SEPARATOR)
|
||||
.map((piece) => feedErrorText(piece) ?? translateStatusPiece(piece))
|
||||
.join(STATUS_SEPARATOR);
|
||||
}
|
||||
|
||||
// A decoder feed with no band is named by the server's "entry N" (its place
|
||||
// in LOCAL_RECEIVER_FEEDS, server/providers/local-receivers.js).
|
||||
const FEED_ENTRY = /^entry (\d+)$/;
|
||||
|
||||
function shownFeedName(name) {
|
||||
const entry = FEED_ENTRY.exec(name);
|
||||
return entry ? t('entry {n}', { n: entry[1] }) : name;
|
||||
}
|
||||
|
||||
// Feed states named in a Local ADS-B feed problem ("feeds 1090, 978 stale").
|
||||
const FEED_PROBLEM_FORMS = Object.freeze({
|
||||
stale: (count, names) =>
|
||||
tn(count, 'feed {names} stale', 'feeds {names} stale', { names }),
|
||||
unreachable: (count, names) =>
|
||||
tn(count, 'feed {names} unreachable', 'feeds {names} unreachable', {
|
||||
names,
|
||||
}),
|
||||
invalid: (count, names) =>
|
||||
tn(count, 'feed {names} invalid', 'feeds {names} invalid', { names }),
|
||||
});
|
||||
|
||||
// Working texts with a number or a name in them arrive as English data, one
|
||||
// piece of a " · " joined line each: transit (transit/queries.js) and Local
|
||||
// ADS-B (localAdsb/status.js). The Layers panel and the voice card's analyst
|
||||
// notes show them through here. Keep these in step with the producers.
|
||||
const STATUS_PIECE_PATTERNS = [
|
||||
[/^Loading (.+)$/, ([, feeds]) => t('Loading {feeds}', { feeds })],
|
||||
[
|
||||
/^(\d+) heard$/,
|
||||
([, heard]) =>
|
||||
tn(Number(heard), '{count} heard', '{count} heard', {
|
||||
count: formatNumber(Number(heard)),
|
||||
}),
|
||||
],
|
||||
[
|
||||
/^(USB )?(\d+(?:\.\d+)?) msg\/s$/,
|
||||
([, usb, rate]) =>
|
||||
usb
|
||||
? t('USB {rate} msg/s', { rate: formatNumber(Number(rate)) })
|
||||
: t('{rate} msg/s', { rate: formatNumber(Number(rate)) }),
|
||||
],
|
||||
[
|
||||
/^(\d+) feeds? live$/,
|
||||
([, live]) =>
|
||||
tn(Number(live), '{count} feed live', '{count} feeds live', {
|
||||
count: formatNumber(Number(live)),
|
||||
}),
|
||||
],
|
||||
[
|
||||
/^feeds? (.+) (stale|unreachable|invalid)$/,
|
||||
([, names, status]) => {
|
||||
const list = names.split(', ');
|
||||
return FEED_PROBLEM_FORMS[status](
|
||||
list.length,
|
||||
list.map(shownFeedName).join(', '),
|
||||
);
|
||||
},
|
||||
],
|
||||
// A quiet transit feed in the coverage line, "TTC 0 (95s old)"
|
||||
// (transit/queries.js); the feed name and the numbers stay as they came.
|
||||
[
|
||||
/^(.+) (\d+) \((\d+)s old\)$/,
|
||||
([, feed, count, seconds]) =>
|
||||
t('{feed} {count} ({seconds}s old)', { feed, count, seconds }),
|
||||
],
|
||||
];
|
||||
|
||||
/** One piece of a status line in the UI language, or as it came. */
|
||||
export function translateStatusPiece(piece) {
|
||||
const known = translateKnown(piece);
|
||||
if (known !== piece) return known;
|
||||
for (const [pattern, format] of STATUS_PIECE_PATTERNS) {
|
||||
const match = pattern.exec(piece);
|
||||
if (match) return format(match);
|
||||
}
|
||||
return piece;
|
||||
}
|
||||
|
||||
/** A status line of " · " joined pieces in the UI language, piece by piece. */
|
||||
export function translateStatusLine(text) {
|
||||
return text
|
||||
.split(STATUS_SEPARATOR)
|
||||
.map(translateStatusPiece)
|
||||
.join(STATUS_SEPARATOR);
|
||||
}
|
||||
|
||||
/** A loading label is English data, at times several joined by " · ". */
|
||||
export function translateLoadingLabel(label) {
|
||||
return translateStatusLine(label.trim());
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Layer status texts arrive as English data and show in the UI language by
|
||||
// their shape; English shows exactly as it came.
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { setLocale, t, translateKnown } from '../i18n/index.js';
|
||||
import { sourceLabel } from './layerNames.js';
|
||||
import {
|
||||
translateFeedError,
|
||||
translateLoadingLabel,
|
||||
translateStatusLine,
|
||||
} from './statusText.js';
|
||||
import { FEED_SOURCE, COMBINED_SOURCE } from '../layers/localAdsb/status.js';
|
||||
import { readResponseTextCapped } from '../sources/httpBody.js';
|
||||
|
||||
function inLocale(locale, run) {
|
||||
setLocale(locale);
|
||||
try {
|
||||
return run();
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
}
|
||||
|
||||
const STATUS_LINES = [
|
||||
'feed 1090 unreachable',
|
||||
'feeds 1090, entry 2 invalid',
|
||||
'3 heard · USB 5.8 msg/s · feed 1090 stale',
|
||||
'2 feeds live · 14 heard',
|
||||
'TTC 0 (95s old)',
|
||||
'TTC 0 · GO Transit 4 (95s old)',
|
||||
'Loading TTC, MiWay',
|
||||
];
|
||||
|
||||
test('en: status lines and errors show as they came', () => {
|
||||
setLocale('en');
|
||||
for (const text of [
|
||||
...STATUS_LINES,
|
||||
'ECCC HTTP 503',
|
||||
'GNSS feed unavailable (HTTP 503)',
|
||||
'Upstream response too large',
|
||||
]) {
|
||||
assert.equal(translateFeedError(text), text);
|
||||
assert.equal(translateStatusLine(text), text);
|
||||
assert.equal(translateLoadingLabel(` ${text} `), text);
|
||||
}
|
||||
});
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
test(`${locale}: a lone feed problem translates like a joined one`, () => {
|
||||
inLocale(locale, () => {
|
||||
const lone = translateFeedError('feed 1090 unreachable');
|
||||
assert.doesNotMatch(lone, /unreachable/, lone);
|
||||
assert.equal(
|
||||
translateFeedError('feed 1090 unreachable · 2 heard').split(' · ')[0],
|
||||
lone,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test(`${locale}: a feed with no band is named in the UI language`, () => {
|
||||
inLocale(locale, () => {
|
||||
const shown = translateFeedError('feeds 1090, entry 2 invalid');
|
||||
assert.doesNotMatch(shown, /\b(entry|invalid)\b/, shown);
|
||||
assert.ok(shown.includes(t('entry {n}', { n: 2 })), shown);
|
||||
assert.ok(shown.includes('1090'), shown);
|
||||
});
|
||||
});
|
||||
|
||||
test(`${locale}: a quiet transit feed keeps its name and numbers`, () => {
|
||||
inLocale(locale, () => {
|
||||
const shown = translateStatusLine('TTC 0 · GO Transit 4 (95s old)');
|
||||
assert.doesNotMatch(shown, /\bold\b/, shown);
|
||||
assert.ok(shown.startsWith('TTC 0 · GO Transit 4 '), shown);
|
||||
assert.ok(shown.includes('95'), shown);
|
||||
});
|
||||
});
|
||||
|
||||
test(`${locale}: an oversized upstream response is translated`, async () => {
|
||||
const response = new Response('x'.repeat(10), {
|
||||
headers: { 'content-length': '10' },
|
||||
});
|
||||
const error = await readResponseTextCapped(response, 4).catch((e) => e);
|
||||
assert.equal(error.message, 'Upstream response too large');
|
||||
inLocale(locale, () => {
|
||||
assert.notEqual(translateKnown(error.message), error.message);
|
||||
assert.equal(translateFeedError(error.message), t(error.message));
|
||||
});
|
||||
});
|
||||
|
||||
test(`${locale}: Local ADS-B source names translate without their own entry`, () => {
|
||||
inLocale(locale, () => {
|
||||
for (const source of [FEED_SOURCE, COMBINED_SOURCE]) {
|
||||
assert.notEqual(t(source), source, 'the catalog has the entry');
|
||||
assert.equal(sourceLabel(source), t(source));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -79,8 +79,8 @@ html[lang='es'] .cctv-controls {
|
||||
}
|
||||
|
||||
/* A translated label longer than its button wraps inside the button instead
|
||||
of running past the panel edge. English keeps its one-line buttons (unguarded,
|
||||
this rule broke AUTO HOP OFF onto two lines). */
|
||||
of running past the panel edge. English is excluded so its labels (AUTO HOP
|
||||
OFF) stay on one line. */
|
||||
html:not([lang='en']) .cctv-controls .scene-btn {
|
||||
white-space: normal;
|
||||
overflow-wrap: break-word;
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// The analyst card's empty notes can be a layer's status line: Local ADS-B
|
||||
// with every decoder feed stale, Transit with a quiet feed ("(95s old)"). The
|
||||
// card shows them in the UI language; the model gets the English.
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { attachVoiceResult } from './speech.js';
|
||||
import {
|
||||
initialVoiceCardState,
|
||||
reduceVoiceCard,
|
||||
voiceCardView,
|
||||
} from './voiceCardPresentation.js';
|
||||
|
||||
function notesFor(layerKey, caveat) {
|
||||
const shaped = attachVoiceResult(
|
||||
'analyst_query',
|
||||
{
|
||||
ok: true,
|
||||
count: 0,
|
||||
complete: true,
|
||||
scopeLabel: 'in view',
|
||||
feedState: 'nominal',
|
||||
unanswered: [],
|
||||
coverage: { layersQueried: [{ layerKey, status: 'empty' }] },
|
||||
display: { scope: 'in view', caveat },
|
||||
items: [],
|
||||
},
|
||||
{},
|
||||
);
|
||||
return voiceCardView(
|
||||
[
|
||||
{
|
||||
type: 'action-call',
|
||||
callId: 'c1',
|
||||
name: 'analyst_query',
|
||||
arguments: {},
|
||||
},
|
||||
{
|
||||
type: 'action-result',
|
||||
callId: 'c1',
|
||||
name: 'analyst_query',
|
||||
result: shaped,
|
||||
},
|
||||
].reduce(reduceVoiceCard, initialVoiceCardState()),
|
||||
).result.notes;
|
||||
}
|
||||
|
||||
// Caveat = `${layerKey}: ${getEmptyNote(layerKey)}` (analystEngine.js:1026,
|
||||
// gevActions.js getEmptyNote → stats.coverage || stats.statusMessage).
|
||||
const cases = [
|
||||
['local-adsb', 'local-adsb: feeds 1090, 978 stale · 0 heard'],
|
||||
['local-adsb', 'local-adsb: feed 1090 stale · 0 heard'],
|
||||
['transit', 'transit: TTC 0 (95s old)'],
|
||||
['transit', 'transit: TTC 0 · GO Transit 0 (95s old)'],
|
||||
];
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
for (const [layerKey, caveat] of cases) {
|
||||
test(`${locale}: analyst note "${caveat}" is translated`, () => {
|
||||
setLocale(locale);
|
||||
try {
|
||||
const notes = notesFor(layerKey, caveat);
|
||||
assert.ok(notes.length > 0, 'the note is shown');
|
||||
assert.doesNotMatch(notes.join(' | '), /\b(stale|heard|old)\b/);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const [layerKey, caveat] of cases) {
|
||||
test(`en: analyst note "${caveat}" keeps its status line`, () => {
|
||||
setLocale('en');
|
||||
const line = caveat.slice(caveat.indexOf(': ') + 2);
|
||||
assert.ok(
|
||||
notesFor(layerKey, caveat).some((note) => note.endsWith(`: ${line}`)),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
// N-07: the analyst card shows a layer's "empty" note from getStats().coverage
|
||||
// as is; Transit's notes are English template strings with no catalog entry,
|
||||
// so the es/ru card note stays English. Place: src/voice/analystTransitNote.test.mjs
|
||||
// The analyst card shows a layer's "empty" note (Transit's English
|
||||
// getStats().coverage text) in the UI language.
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
// N-05: plan steps for preset ids from the tool schema (sf, nyc, dc) read
|
||||
// "Sf" / "Nyc" / "Dc" in every language; 'tokyo' translates.
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { CITY_POIS } from '../locations.js';
|
||||
import { GEV_ACTION_SCHEMAS } from './actionSchemas.js';
|
||||
import { narrationLabel, planStepLabel } from './speech.js';
|
||||
|
||||
test.afterEach(() => setLocale('en'));
|
||||
|
||||
test('es: preset location ids show the city name', () => {
|
||||
setLocale('es');
|
||||
assert.equal(
|
||||
planStepLabel('fly_to_location', { locationId: 'tokyo' }),
|
||||
'Volar a Tokio',
|
||||
);
|
||||
for (const id of ['sf', 'nyc', 'dc']) {
|
||||
const label = planStepLabel('fly_to_location', { locationId: id });
|
||||
assert.doesNotMatch(label, /\b(Sf|Nyc|Dc)$/, label);
|
||||
}
|
||||
assert.equal(
|
||||
planStepLabel('fly_to_location', { locationId: 'nyc' }),
|
||||
'Volar a Nueva York',
|
||||
);
|
||||
});
|
||||
|
||||
test('ru: a preset id in the nearest-aircraft step names the city', () => {
|
||||
setLocale('ru');
|
||||
assert.match(
|
||||
planStepLabel('select_nearest_aircraft', { locationId: 'dc' }),
|
||||
/Вашингтон/,
|
||||
);
|
||||
});
|
||||
|
||||
test('en: every preset id reads as its CITY_POIS name', () => {
|
||||
setLocale('en');
|
||||
for (const [id, city] of Object.entries(CITY_POIS)) {
|
||||
assert.equal(
|
||||
planStepLabel('fly_to_location', { locationId: id }),
|
||||
`Fly to ${city.name}`,
|
||||
id,
|
||||
);
|
||||
assert.equal(
|
||||
narrationLabel('fly_to_location', { locationId: id }),
|
||||
city.name,
|
||||
);
|
||||
}
|
||||
// Other places are shown as given.
|
||||
assert.equal(
|
||||
planStepLabel('fly_to_location', { query: 'Ulaanbaatar' }),
|
||||
'Fly to Ulaanbaatar',
|
||||
);
|
||||
});
|
||||
|
||||
test('every locationId the voice tools accept is a preset city', () => {
|
||||
const ids = new Set();
|
||||
const visit = (node) => {
|
||||
if (!node || typeof node !== 'object') return;
|
||||
if (Array.isArray(node.locationId?.enum))
|
||||
node.locationId.enum.forEach((id) => ids.add(id));
|
||||
Object.values(node).forEach(visit);
|
||||
};
|
||||
visit(GEV_ACTION_SCHEMAS);
|
||||
assert.ok(ids.size > 0, 'found the locationId enums');
|
||||
for (const id of ids) assert.ok(Object.hasOwn(CITY_POIS, id), id);
|
||||
});
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from '../i18n/index.js';
|
||||
import { layerNameForId, sourceLabel } from '../ui/layerNames.js';
|
||||
import { localizePlaceName } from '../ui/placeNames.js';
|
||||
import { translateStatusLine } from '../ui/statusText.js';
|
||||
import { formatQuantity } from '../ui/unitLabels.js';
|
||||
|
||||
const LAYER_NOUNS = Object.freeze({
|
||||
@@ -372,7 +373,10 @@ function shownNote(text) {
|
||||
const shown = match ? show(match) : null;
|
||||
if (shown) return shown;
|
||||
}
|
||||
return translateKnown(text);
|
||||
// A layer's empty note can be its status line, English pieces joined by
|
||||
// " · " ("feeds 1090, 978 stale · 0 heard", "TTC 0 (95s old)").
|
||||
const known = translateKnown(text);
|
||||
return known !== text ? known : translateStatusLine(text);
|
||||
}
|
||||
|
||||
/** Feed states as the card's chips show them; the values stay English. */
|
||||
|
||||
+3
-21
@@ -39,6 +39,7 @@ import {
|
||||
sourceLabel,
|
||||
} from '../ui/layerNames.js';
|
||||
import { localizePlaceName } from '../ui/placeNames.js';
|
||||
import { CITY_POIS } from '../cityPois.js';
|
||||
import { formatQuantity } from '../ui/unitLabels.js';
|
||||
|
||||
const LAYER_NOUNS = Object.freeze({
|
||||
@@ -344,28 +345,10 @@ function shownPair(items) {
|
||||
: items[0] || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* The preset city ids the voice tools take (the `locationId` enum in
|
||||
* actionSchemas.js) and the CITY_POIS names they stand for, so `sf` reads
|
||||
* "San Francisco", not "Sf". A copy, not an import: src/locations.js pulls in
|
||||
* Cesium. planStepPresetIds.i18n.test.mjs keeps it in step with CITY_POIS.
|
||||
*/
|
||||
const PRESET_PLACE_NAMES = Object.freeze({
|
||||
austin: 'Austin',
|
||||
sf: 'San Francisco',
|
||||
nyc: 'New York',
|
||||
tokyo: 'Tokyo',
|
||||
london: 'London',
|
||||
paris: 'Paris',
|
||||
dubai: 'Dubai',
|
||||
dc: 'Washington DC',
|
||||
tallinn: 'Tallinn',
|
||||
});
|
||||
|
||||
/** A preset city id as its English name; any other value unchanged. */
|
||||
/** A preset city id (`sf`, `nyc`) as its English name; any other value unchanged. */
|
||||
function presetPlaceName(value) {
|
||||
const id = typeof value === 'string' ? value.trim() : '';
|
||||
return Object.hasOwn(PRESET_PLACE_NAMES, id) ? PRESET_PLACE_NAMES[id] : value;
|
||||
return Object.hasOwn(CITY_POIS, id) ? CITY_POIS[id].name : value;
|
||||
}
|
||||
|
||||
// A place as the card shows it: a preset city in the interface language,
|
||||
@@ -375,7 +358,6 @@ function shownPlace(value, max) {
|
||||
}
|
||||
|
||||
/** Ids such as `austin` or `local-firms` read as names on screen. */
|
||||
|
||||
function displayName(value, max) {
|
||||
const label = spokenLabel(value, max);
|
||||
return /^[a-z0-9-]+$/.test(label)
|
||||
|
||||
@@ -7,7 +7,8 @@ import test from 'node:test';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { GEV_ACTION_SCHEMAS } from './actionSchemas.js';
|
||||
import { presentResult, shownSnapshotTime } from './resultDisplay.js';
|
||||
import { attachVoiceResult, planStepLabel } from './speech.js';
|
||||
import { CITY_POIS } from '../cityPois.js';
|
||||
import { attachVoiceResult, narrationLabel, planStepLabel } from './speech.js';
|
||||
import {
|
||||
initialVoiceCardState,
|
||||
reduceVoiceCard,
|
||||
@@ -244,3 +245,41 @@ test('C12: the analyst scope names Los Angeles in the UI language', () => {
|
||||
assert.match(title, /Los Ángeles$/, 'the headline is not cut at 64');
|
||||
assert.equal(result.scopeLabel, 'within 40 km of Los Angeles');
|
||||
});
|
||||
|
||||
// A preset city id from the tool schema (sf, nyc, dc) reads as its city name.
|
||||
test('a preset city id names the city in plan steps and narration', () => {
|
||||
const step = (id, action = 'fly_to_location') =>
|
||||
planStepLabel(action, { locationId: id });
|
||||
assert.equal(step('sf'), 'Fly to San Francisco');
|
||||
assert.equal(
|
||||
narrationLabel('fly_to_location', { locationId: 'dc' }),
|
||||
'Washington DC',
|
||||
);
|
||||
assert.equal(
|
||||
planStepLabel('fly_to_location', { query: 'Ulaanbaatar' }),
|
||||
'Fly to Ulaanbaatar',
|
||||
'other places are shown as given',
|
||||
);
|
||||
inLocale('es', () => {
|
||||
assert.equal(step('tokyo'), 'Volar a Tokio');
|
||||
assert.equal(step('nyc'), 'Volar a Nueva York');
|
||||
for (const id of ['sf', 'nyc', 'dc'])
|
||||
assert.doesNotMatch(step(id), /\b(Sf|Nyc|Dc)$/, id);
|
||||
});
|
||||
inLocale('ru', () => {
|
||||
assert.match(step('dc', 'select_nearest_aircraft'), /Вашингтон/);
|
||||
});
|
||||
});
|
||||
|
||||
test('every locationId the voice tools accept is a preset city', () => {
|
||||
const ids = new Set();
|
||||
const visit = (node) => {
|
||||
if (!node || typeof node !== 'object') return;
|
||||
if (Array.isArray(node.locationId?.enum))
|
||||
node.locationId.enum.forEach((id) => ids.add(id));
|
||||
Object.values(node).forEach(visit);
|
||||
};
|
||||
visit(GEV_ACTION_SCHEMAS);
|
||||
assert.ok(ids.size > 0, 'found the locationId enums');
|
||||
for (const id of ids) assert.ok(Object.hasOwn(CITY_POIS, id), id);
|
||||
});
|
||||
Reference in new issue
Block a user