Merge branch 'main' into upstream/wind-stale

This commit is contained in:
quanticsoul4772 authored and GitHub committed 2026-10-22 19:18:02 -07:00
commit ba2dda5beb
54 files changed
+4084 -263

No files matched your search

+7 -3
View File
@@ -412,14 +412,18 @@ const IMAGERY_LABEL_NAMES = Object.freeze([
[/^(\d{4}) POST-EVENT$/, msg('{year} POST-EVENT')],
]);
/** Day, short month and year in the active language: 16 OCT 2021, 16 ОКТ. 2021. */
/** Day, short month and year in the active language: 16 OCT 2021, 16 ОКТ. 2021, 2021年10月16日. */
function imageryDateText(observedAt) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(observedAt || ''));
if (!match) return '';
const date = new Date(Date.UTC(+match[1], +match[2] - 1, +match[3]));
if (Number.isNaN(date.getTime())) return '';
const part = (options) => formatDate(date, { timeZone: 'UTC', ...options });
return `${part({ day: 'numeric' })} ${part({ month: 'short' })} ${part({ year: 'numeric' })}`.toUpperCase();
const year = part({ year: 'numeric' });
// Year-first languages (2021年10月16日) keep their own order and units.
const whole = part({ day: 'numeric', month: 'short', year: 'numeric' });
if (whole.startsWith(year)) return whole;
return `${part({ day: 'numeric' })} ${part({ month: 'short' })} ${year}`.toUpperCase();
}
/**
@@ -1029,7 +1033,7 @@ function createPanel(event, handlers) {
<span>${escapeMarkup(t('GEOLOCATIONS · {credit}', { credit: 'GEO GEORGE SHADRACH' }))}</span>
<button type="button" data-action="geolocation-map">${escapeMarkup(t('↗ OPEN PUBLIC MAP'))}</button>
</div>
<p class="bhote-event-caveat"><span data-role="caveat"></span><span data-role="imagery-cloud-note"> ${escapeMarkup(t('Clouds are preserved from the source imagery.'))}</span></p>
<p class="bhote-event-caveat"><span data-role="caveat"></span><span data-role="imagery-cloud-note">${escapeMarkup(t(' Clouds are preserved from the source imagery.'))}</span></p>
`;
const refs = collectPanelReferences(panel);
+2 -1
View File
@@ -72,6 +72,7 @@ test('the Docker install path keeps its loopback-only, launcher-owned contract',
const compose = readFileSync(new URL('../compose.yaml', import.meta.url), 'utf8');
assert.match(compose, /^\s+- "127\.0\.0\.1:4173:4173"$/m, 'the port is published on the host loopback only');
assert.doesNotMatch(compose, /GEV_KEY_SETUP_TRUSTED_PEERS/, 'trust is derived at launch, never hard-coded');
assert.match(compose, /^\s+- GEV_KEY_SETUP_TRUSTED_PEERS$/m, 'the operator policy reaches the launcher without a hard-coded address');
assert.match(compose, /^\s+- GEV_ALLOWED_HOSTS$/m, 'the host allowlist reaches Vite');
assert.doesNotMatch(compose, /network_mode/, 'the container keeps its own network namespace');
});
+179
View File
@@ -0,0 +1,179 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import test from 'node:test';
import { resolveTrustedPeers } from '../scripts/docker-start.mjs';
const ROUTES = 'Iface Destination Gateway Flags\neth0 00000000 0100A8C0 0003\n';
const launcher = fileURLToPath(
new URL('../scripts/docker-start.mjs', import.meta.url),
);
const gateUrl = new URL('./keySetupCore.mjs', import.meta.url).href;
test('none resolves to an empty trust list even with a valid container gateway', () => {
for (const value of ['none', ' NONE ', 'None']) {
assert.equal(
resolveTrustedPeers({
env: { GEV_KEY_SETUP_TRUSTED_PEERS: value },
routeTable: ROUTES,
}),
'',
);
}
});
function launchWithPolicy(value, routeTable = ROUTES) {
// Run the real entry point and routing-table reader. Replace only the Linux
// route file and Vite's listening server so this works on macOS and CI too.
const fakeVite = `
import { admitKeySetupRequest } from ${JSON.stringify(gateUrl)};
const trustedAtImport = process.env.GEV_KEY_SETUP_TRUSTED_PEERS;
export async function createServer() {
const request = {
method: 'POST', hostHeader: 'localhost:4173',
origin: 'http://localhost:4173', contentType: 'application/json',
env: { GEV_KEY_SETUP_TRUSTED_PEERS: process.env.GEV_KEY_SETUP_TRUSTED_PEERS }
};
console.log('POLICY ' + JSON.stringify({
trustedAtImport,
allowedHosts: process.env.GEV_ALLOWED_HOSTS,
gateway: admitKeySetupRequest({ ...request, remoteAddress: '192.168.0.1' }),
loopback: admitKeySetupRequest({ ...request, remoteAddress: '127.0.0.1' }),
foreignOrigin: admitKeySetupRequest({ ...request, remoteAddress: '192.168.0.1', origin: 'https://foreign.example' })
}));
return { async listen() {}, printUrls() {}, async close() {} };
}
`;
const preload = `
import fs from 'node:fs';
import { registerHooks, syncBuiltinESMExports } from 'node:module';
const originalRead = fs.readFileSync;
fs.readFileSync = (file, ...args) => file === '/proc/net/route'
? ${JSON.stringify(routeTable)} : originalRead(file, ...args);
syncBuiltinESMExports();
registerHooks({ resolve(specifier, context, next) {
if (specifier === 'vite') return {
url: ${JSON.stringify(`data:text/javascript,${encodeURIComponent(fakeVite)}`)}, shortCircuit: true
};
return next(specifier, context);
}});
`;
const env = { ...process.env, GEV_ALLOWED_HOSTS: 'globe.example.lan' };
delete env.GEV_KEY_SETUP_TRUSTED_PEERS;
if (value !== undefined) env.GEV_KEY_SETUP_TRUSTED_PEERS = value;
const child = spawnSync(
process.execPath,
[
'--import',
`data:text/javascript,${encodeURIComponent(preload)}`,
launcher,
],
{ env, encoding: 'utf8', timeout: 10000 },
);
assert.ifError(child.error);
assert.equal(child.status, 0, child.stderr);
const line = child.stdout
.split('\n')
.find((line) => line.startsWith('POLICY '));
assert.ok(line, child.stdout);
return { ...JSON.parse(line.slice(7)), output: child.stdout };
}
test('the real Docker entry point seals none before importing Vite and the setup gate refuses the gateway', () => {
const result = launchWithPolicy('none');
assert.equal(result.trustedAtImport, '');
assert.equal(result.gateway.status, 403);
assert.equal(result.gateway.code, 'not-loopback');
assert.equal(result.loopback.ok, true);
assert.equal(result.allowedHosts, 'globe.example.lan');
assert.match(result.output, /trusts no additional peers/);
assert.doesNotMatch(result.output, /No default gateway/);
});
test('unset, blank and explicit peers retain gateway admission without weakening Origin checks', () => {
for (const value of [undefined, '', ' ', '192.168.0.1']) {
const result = launchWithPolicy(value);
assert.equal(result.trustedAtImport, '192.168.0.1');
assert.equal(result.gateway.ok, true);
assert.equal(result.foreignOrigin.ok, false);
}
const result = launchWithPolicy(undefined, '');
assert.equal(result.trustedAtImport, '');
assert.equal(result.gateway.status, 403);
assert.match(result.output, /No default gateway/);
});
test('Compose resolves shell and project dotenv policy before the launcher starts', (t) => {
const version = spawnSync('docker', ['compose', 'version'], {
encoding: 'utf8',
timeout: 10000,
});
if (version.error || version.status !== 0)
return t.skip('Docker Compose CLI is not installed');
const root = mkdtempSync(path.join(tmpdir(), 'gev-compose-policy-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
writeFileSync(
path.join(root, 'compose.yaml'),
readFileSync(new URL('../compose.yaml', import.meta.url)),
);
const baseEnv = { ...process.env };
for (const key of Object.keys(baseEnv)) {
if (
key.startsWith('COMPOSE_') ||
['GEV_KEY_SETUP_TRUSTED_PEERS', 'GEV_ALLOWED_HOSTS'].includes(key)
)
delete baseEnv[key];
}
function config(dotenv = '', overrides = {}) {
writeFileSync(path.join(root, '.env'), dotenv);
const child = spawnSync(
'docker',
[
'compose',
'-f',
'compose.yaml',
'--env-file',
'.env',
'config',
'--format',
'json',
],
{
cwd: root,
env: { ...baseEnv, ...overrides },
encoding: 'utf8',
timeout: 10000,
},
);
assert.ifError(child.error);
assert.equal(child.status, 0, child.stderr);
return JSON.parse(child.stdout).services['gods-eye-view'].environment || {};
}
const unset = config();
assert.ok(unset.GEV_KEY_SETUP_TRUSTED_PEERS == null);
assert.ok(unset.GEV_ALLOWED_HOSTS == null);
const dotenv =
'GEV_KEY_SETUP_TRUSTED_PEERS=none\nGEV_ALLOWED_HOSTS=globe.example.lan\n';
assert.deepEqual(config(dotenv), {
GEV_KEY_SETUP_TRUSTED_PEERS: 'none',
GEV_ALLOWED_HOSTS: 'globe.example.lan',
});
assert.equal(
config(dotenv, { GEV_KEY_SETUP_TRUSTED_PEERS: '10.9.8.7' })
.GEV_KEY_SETUP_TRUSTED_PEERS,
'10.9.8.7',
);
assert.equal(
config('', { GEV_KEY_SETUP_TRUSTED_PEERS: 'none' })
.GEV_KEY_SETUP_TRUSTED_PEERS,
'none',
);
assert.equal(
config(dotenv, { GEV_KEY_SETUP_TRUSTED_PEERS: '' })
.GEV_KEY_SETUP_TRUSTED_PEERS,
'',
);
});
+14 -3
View File
@@ -15,7 +15,9 @@ export function hudQuantity(value, unit, digits = 0) {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
return `${number}${unitLabel(unit).toUpperCase()}`;
const label = unitLabel(unit);
if (getLocale() === 'zh-CN') return label ? `${number} ${label}` : number;
return `${number}${label.toUpperCase()}`;
}
/** One hemisphere-tagged coordinate, e.g. `21.33N` / `21,33С`. */
@@ -35,7 +37,16 @@ export const LOCALIZED_LOCALITY = Object.freeze({
}),
sector: ({ latDeg, lonDeg }) =>
t('SECTOR {lat} {lon}', {
lat: hudCoordinate(latDeg, t('N'), t('S')),
lon: hudCoordinate(lonDeg, t('E'), t('W')),
// Hemisphere letters, not compass words: 21.33N, never 21.33北.
lat: hudCoordinate(
latDeg,
t('N', { context: 'hemisphere' }),
t('S', { context: 'hemisphere' }),
),
lon: hudCoordinate(
lonDeg,
t('E', { context: 'hemisphere' }),
t('W', { context: 'hemisphere' }),
),
}),
});
+43
View File
@@ -0,0 +1,43 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { getLocale, setLocale } from './index.js';
import { createModel } from '../layers/firms/model.js';
import { createQueries } from '../layers/firms/queries.js';
const cases = [
[0, '不到 1 分钟前', '<1m ago'],
[120000, '2 分钟前', '2m ago'],
[7200000, '2 小时前', '2h ago'],
];
for (const [age, zh, en] of cases) {
test(`FIRMS live age ${age} ms has one relative-time suffix`, (context) => {
const now = 1800000000000;
context.mock.method(Date, 'now', () => now);
const previousLocale = getLocale();
const layerState = {
_loading: false,
_stale: false,
_keyRequired: false,
_error: null,
_lastUpdate: now - age,
_count: 1,
_cellCount: 0,
_fires: [],
};
const model = createModel({ services: { anchors: {} } });
const { methods } = createQueries({
layerState,
components: { model },
config: {},
});
try {
setLocale('en');
assert.equal(methods.getStats().loadingLabel, `LIVE · updated ${en}`);
setLocale('zh-CN');
assert.equal(methods.getStats().loadingLabel, `实时 · 更新于 ${zh}`);
} finally {
setLocale(previousLocale);
}
});
}
+6
View File
@@ -35,6 +35,12 @@ test('normalizeLocale folds regional variants and rejects unshipped tags', () =>
assert.equal(normalizeLocale(' RU '), 'ru');
assert.equal(normalizeLocale('en_GB'), 'en');
assert.equal(normalizeLocale('xx'), null);
// Simplified Chinese by default; Traditional tags wait for zh-TW.
assert.equal(normalizeLocale('zh'), 'zh-CN');
assert.equal(normalizeLocale('zh-CN'), 'zh-CN');
assert.equal(normalizeLocale('zh-Hans-SG'), 'zh-CN');
assert.equal(normalizeLocale('zh-TW'), null);
assert.equal(normalizeLocale('zh-Hant-HK'), null);
assert.equal(normalizeLocale(undefined), null);
});
+2 -1
View File
@@ -18,8 +18,9 @@ import {
} from './locale.js';
import es from './locales/es.json' with { type: 'json' };
import ru from './locales/ru.json' with { type: 'json' };
import zhCN from './locales/zh-CN.json' with { type: 'json' };
const CATALOGS = Object.freeze({ es, ru });
const CATALOGS = Object.freeze({ es, ru, 'zh-CN': zhCN });
let currentLocale = DEFAULT_LOCALE;
+10 -3
View File
@@ -8,25 +8,32 @@ export const LOCALE_STORAGE_KEY = 'gev:locale:v1';
export const DEFAULT_LOCALE = 'en';
/** Shipped locales; order does not matter. Adding one: docs/TRANSLATORS.md. */
export const LOCALES = Object.freeze(['en', 'es', 'ru']);
export const LOCALES = Object.freeze(['en', 'es', 'ru', 'zh-CN']);
/** Each locale named in its own language, for the selector. */
export const LOCALE_NAMES = Object.freeze({
en: 'English',
es: 'Español',
ru: 'Русский',
'zh-CN': '简体中文',
});
/** Right-to-left locales. */
const RTL_LOCALES = Object.freeze([]);
/**
* Map any language tag to a shipped locale: 'ru-RU' and 'RU' → 'ru'.
* Map any language tag to a shipped locale: 'ru-RU' and 'RU' → 'ru',
* 'zh', 'zh-CN' and 'zh-Hans-SG' → 'zh-CN'.
* @returns {string|null}
*/
export function normalizeLocale(candidate) {
if (typeof candidate !== 'string') return null;
const primary = candidate.trim().toLowerCase().split(/[-_]/)[0];
const [primary, ...rest] = candidate.trim().toLowerCase().split(/[-_]/);
// Traditional Chinese readers are not given Simplified: no zh-TW yet.
if (primary === 'zh')
return rest.some((part) => ['hant', 'tw', 'hk', 'mo'].includes(part))
? null
: 'zh-CN';
return LOCALES.includes(primary) ? primary : null;
}
+8 -2
View File
@@ -1086,7 +1086,6 @@
"FIELD REPORTS · {count}": "INFORMES DE CAMPO · {count}",
"GEOLOCATIONS · {credit}": "GEOLOCALIZACIONES · {credit}",
"↗ OPEN PUBLIC MAP": "↗ ABRIR MAPA PÚBLICO",
"Clouds are preserved from the source imagery.": "Las nubes provienen de las imágenes de origen.",
"Show the Nepal context callout without a border or country fill": "Muestra la etiqueta de contexto de Nepal sin frontera ni relleno del país",
"Introduce Nepal, draw its border, then reveal the Bhote Koshi incident": "Presenta Nepal, dibuja su frontera y luego revela el incidente del Bhote Koshi",
"CITIES": "CIUDADES",
@@ -2937,5 +2936,12 @@
"Gemini access was refused. Check the API key and model access.": "Se denegó el acceso a Gemini. Comprueba la clave de API y el acceso al modelo.",
"Gemini Live is unavailable for the configured model.": "Gemini Live no está disponible para el modelo configurado.",
"Token request is too large": "La solicitud de credencial es demasiado grande",
"Use an empty JSON token request": "Usa una solicitud de credencial con un objeto JSON vacío"
"Use an empty JSON token request": "Usa una solicitud de credencial con un objeto JSON vacío",
" Clouds are preserved from the source imagery.": " Las nubes provienen de las imágenes de origen.",
"hemisphere|N": "N",
"hemisphere|S": "S",
"hemisphere|E": "E",
"hemisphere|W": "O",
"OK": "OK",
"{category} ({count})": "{category} ({count})"
}
+8 -2
View File
@@ -1100,7 +1100,6 @@
"FIELD REPORTS · {count}": "СООБЩЕНИЯ С МЕСТА · {count}",
"GEOLOCATIONS · {credit}": "ГЕОЛОКАЦИИ · {credit}",
"↗ OPEN PUBLIC MAP": "↗ ОТКРЫТЬ ПУБЛИЧНУЮ КАРТУ",
"Clouds are preserved from the source imagery.": "Облака оставлены такими, как на исходных снимках.",
"Show the Nepal context callout without a border or country fill": "Показать выноску с контекстом Непала без границы и заливки страны",
"Introduce Nepal, draw its border, then reveal the Bhote Koshi incident": "Представить Непал, нарисовать его границу, затем показать инцидент на Bhote Koshi",
"CITIES": "ГОРОДА",
@@ -3004,5 +3003,12 @@
"Gemini access was refused. Check the API key and model access.": "Доступ к Gemini запрещён. Проверьте ключ API и доступ к модели.",
"Gemini Live is unavailable for the configured model.": "Gemini Live недоступен для выбранной модели.",
"Token request is too large": "Запрос токена слишком велик",
"Use an empty JSON token request": "Используйте запрос токена с пустым объектом JSON"
"Use an empty JSON token request": "Используйте запрос токена с пустым объектом JSON",
" Clouds are preserved from the source imagery.": " Облака оставлены такими, как на исходных снимках.",
"hemisphere|N": "С",
"hemisphere|S": "Ю",
"hemisphere|E": "В",
"hemisphere|W": "З",
"OK": "НОРМА",
"{category} ({count})": "{category} ({count})"
}
File diff suppressed because it is too large. Load diff
+59
View File
@@ -63,6 +63,7 @@
"NVG",
"Noir",
"Normal",
"OK",
"POS: {time}Z",
"Plan",
"Pop",
@@ -98,6 +99,9 @@
"fire confidence|nominal",
"ft",
"hPa",
"hemisphere|E",
"hemisphere|N",
"hemisphere|S",
"installation class|BASE",
"km",
"km/h",
@@ -133,5 +137,60 @@
"REC",
"VS",
"WHOT/BHOT"
],
"zh-CN": [
"1090 MHz",
"978 MHz UAT",
"AIS: --",
"AIS: {name}",
"BRG {value}°",
"BRG —",
"CAL {value}",
"CAL · {badge}",
"CCTV",
"CRS {value}°",
"CRT",
"FLIR",
"FOV",
"FOV {value}°",
"FT",
"GEO",
"HDG",
"HDG {value}°",
"House",
"Ironbow",
"KTS",
"Landsat 8/9",
"MM",
"NVG",
"REC",
"SRC {value}",
"Sentinel-2",
"Techno",
"Trance",
"USB {rate} msg/s",
"WHOT/BHOT",
"dB",
"ft",
"hPa",
"hemisphere|E",
"hemisphere|N",
"hemisphere|S",
"hemisphere|W",
"km",
"km/h",
"kt",
"kts",
"mph",
"{city} CCTV",
"{rate} msg/s",
"{source} HTTP {status}",
"{speed} kn",
"{type} SPD: {speed} HDG: {heading}",
"{value} KM",
"{value} KM/S",
"ΔE",
"ΔN",
"▲ {frp} MW"
]
}
+106
View File
@@ -0,0 +1,106 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale, t } from './index.js';
import { hudQuantity, LOCALIZED_LOCALITY } from '../hudLocalityFormat.js';
import { formatCockpitDistance } from '../ui/cockpitPresentation.js';
import { cctvSourceStatusLabel } from '../ui/cctvFrames.js';
import { READOUT_WORDS } from '../layers/recentImagery/display.js';
import { createModel } from '../layers/firms/model.js';
import { createQueries } from '../layers/firms/queries.js';
import { renderRadioState } from '../ui/radioPresentation.js';
test('Chinese HUD and cockpit units keep their case without spacing coordinates', () => {
try {
setLocale('zh-CN');
assert.equal(hudQuantity(0, 'km'), '0 km');
assert.equal(hudQuantity(2069, 'km', 1), '2069.0 km');
assert.equal(formatCockpitDistance(14000), '14\u00a0km');
assert.equal(LOCALIZED_LOCALITY.sector({ latDeg: 21.33, lonDeg: -77.5 }),
'扇区 21.33N 77.50W');
setLocale('en');
assert.equal(hudQuantity(0, 'km'), '0KM');
assert.equal(hudQuantity(2069, 'km', 1), '2069.0KM');
assert.equal(formatCockpitDistance(14000), '14\u00a0KM');
} finally {
setLocale('en');
}
});
test('FIRMS stale cache age uses localized durations while English stays compact', () => {
const now = Date.now();
const layerState = { _stale: true };
const components = {
model: createModel({ services: { anchors: {} } }),
};
const { methods } = createQueries({ layerState, components, config: {} });
try {
for (const [hours, zh, en] of [[0.5, '不到 1 小时', '<1h'], [2, '2 小时', '2h'], [72, '3 天', '3d']]) {
layerState._lastUpdate = now - hours * 3600000;
setLocale('zh-CN');
const expected = t('STALE · cached {age}', { age: zh });
assert.equal(methods.getStats().loadingLabel, expected);
assert.equal(methods.getStats().error, expected);
setLocale('en');
assert.equal(methods.getStats().loadingLabel, `STALE · cached ${en}`);
}
} finally {
setLocale('en');
}
});
test('Recent Imagery spaces numeric Chinese ages and retains natural relative dates', () => {
try {
setLocale('zh-CN');
assert.equal(READOUT_WORDS.daysAgo(9), '9 天前');
assert.equal(READOUT_WORDS.daysAgo(0), '今天');
assert.equal(READOUT_WORDS.daysAgo(1), '昨天');
setLocale('en');
assert.equal(READOUT_WORDS.daysAgo(9), '9 days ago');
assert.equal(READOUT_WORDS.daysAgo(0), 'today');
} finally {
setLocale('en');
}
});
test('CCTV translates the healthy status and preserves unknown provider codes', () => {
try {
for (const [locale, expected] of [['zh-CN', '正常'], ['ru', 'НОРМА'], ['es', 'OK'], ['en', 'OK']]) {
setLocale(locale);
assert.equal(cctvSourceStatusLabel('ok'), expected);
assert.equal(cctvSourceStatusLabel('CUSTOM'), 'CUSTOM');
}
} finally {
setLocale('en');
}
});
test('radio option text and accessible name use the same localized parentheses', () => {
const originalDocument = globalThis.document;
const element = () => ({
style: {}, dataset: {}, attributes: {},
classList: { contains: () => true, toggle() {} },
setAttribute(name, value) { this.attributes[name] = value; },
replaceChildren(...children) { this.children = children; },
});
globalThis.document = { createElement: element, getElementById: () => null };
try {
for (const [locale, label, expected] of [['zh-CN', '全部', '全部(750)'], ['en', 'All', 'All (750)']]) {
setLocale(locale);
const host = {
_radioPanel: element(), _radioFilter: element(),
_syncContextRadioLauncherState() {},
actions: { getLifecycle: () => null, scheduleLayout() {} },
};
renderRadioState.call(host, {
enabled: true, categories: [{ id: 'all', label, count: 750, color: '' }],
filter: 'all', filteredCount: 750, stationCount: 750, volume: 1,
});
const option = host._radioFilter.children[0];
assert.equal(option.textContent, `● ${expected}`);
assert.equal(option.attributes['aria-label'], expected);
}
} finally {
globalThis.document = originalDocument;
setLocale('en');
}
});
+1
View File
@@ -591,6 +591,7 @@ export function createModel({
formatLatLon,
formatFrp,
formatAge,
formatAgeLabel,
formatAgoMinutes,
confidenceBucket,
};
+2 -1
View File
@@ -64,7 +64,8 @@ export function createQueries({
const staleText = layerState._lastUpdate
? t('STALE · cached {age}', {
age:
components.model.formatAge(now - layerState._lastUpdate) || '<1h',
components.model.formatAgeLabel(now - layerState._lastUpdate) ||
t('<1h', { context: 'age' }),
})
: t('STALE');
let loadingLabel = '';
+10 -2
View File
@@ -3,7 +3,12 @@
* day's readout. The model (`model.js`) stays English and i18n-free because AI
* tool services import it outside the browser; this module is page-only.
*/
import { formatDate, formatRelativeTime, t } from '../../i18n/index.js';
import {
formatDate,
formatRelativeTime,
getLocale,
t,
} from '../../i18n/index.js';
import { formatQuantity } from '../../ui/unitLabels.js';
import { dayTime } from './model.js';
@@ -26,7 +31,10 @@ export const displayMetres = (value) => formatQuantity(value, 'm');
/** The readout's words in the page's language; the model keeps them English. */
export const READOUT_WORDS = Object.freeze({
date: (day) => displayDay(day, LONG_DAY),
daysAgo: (delta) => formatRelativeTime(delta > 0 ? -delta : 0, 'day'),
daysAgo: (delta) => {
const text = formatRelativeTime(delta > 0 ? -delta : 0, 'day');
return getLocale() === 'zh-CN' ? text.replace(/(\d)(?=天)/g, '$1 ') : text;
},
// "Sentinel-2" never breaks at its hyphen: a word joiner follows it
// (JetBrains Mono has no U+2011 non-breaking hyphen).
sensor: (sensor) =>
+19 -8
View File
@@ -296,10 +296,6 @@ export function createWeatherLayer({
motion?.addEventListener?.('change', onVisibility);
documentRef?.addEventListener?.('visibilitychange', onVisibility);
registerClock();
removeCamera = viewer.camera.moveEnd?.addEventListener(() => {
checkHost();
if (enabled) notify();
});
},
attachShellServices(services) {
imageryHost =
@@ -315,10 +311,18 @@ export function createWeatherLayer({
},
enable() {
enabled = true;
if (!removeCamera) {
removeCamera = viewer.camera.moveEnd?.addEventListener(() => {
checkHost();
notify();
});
}
registerClock();
},
disable() {
enabled = false;
removeCamera?.();
removeCamera = null;
unregisterClock?.();
unregisterClock = null;
++generation;
@@ -357,7 +361,7 @@ export function createWeatherLayer({
signal: controller.signal,
});
if (!enabled || controller.signal.aborted || request !== controller)
return false;
return enabled && !signal?.aborted && request !== controller;
if (snapshot.unavailable) {
error = msg(
'Weather source unavailable; previous observation retained',
@@ -376,7 +380,9 @@ export function createWeatherLayer({
)
await show(snapshot.latest, controller.signal);
return (
!controller.signal.aborted && request === controller && enabled
enabled &&
!signal?.aborted &&
(!controller.signal.aborted || request !== controller)
);
}
const time =
@@ -388,9 +394,14 @@ export function createWeatherLayer({
(satellite && rendering.getDiagnostics().infrared !== infrared)
)
await show(time, controller.signal);
return !controller.signal.aborted && request === controller && enabled;
return (
enabled &&
!signal?.aborted &&
(!controller.signal.aborted || request !== controller)
);
} catch (cause) {
if (controller.signal.aborted || request !== controller) return false;
if (controller.signal.aborted || request !== controller)
return enabled && !signal?.aborted && request !== controller;
error = cause?.message || msg('Weather unavailable');
stop();
return true;
+46 -1
View File
@@ -694,7 +694,11 @@ test('superseding a pending stage preserves the newer manifest loading state', a
await flush();
const newer = h.layer.update();
await flush();
assert.equal(await old, false);
assert.equal(
await old,
true,
'replacement is accepted by the enable lifecycle',
);
assert.equal(
h.layer.getStats().loading,
true,
@@ -2311,3 +2315,44 @@ for (const product of ['radar', 'clouds-regional', 'lightning']) {
assert.equal(h.viewer.camera.moveEnd.size, 0);
});
}
test('a product replacement accepts supersession but never paints the obsolete first response', async () => {
const pending = [];
const h = layerHarness({
id: 'weather-satellite',
feed: {
getSnapshot: (args) => {
const task = deferred();
pending.push({ ...args, ...task });
return task.promise;
},
},
});
const first = h.layer.update();
h.layer.setParams({ product: 'clouds' });
assert.equal(pending.length, 2);
pending[0].resolve({ ...snapshot, product: 'clouds-regional' });
assert.equal(await first, true);
assert.equal(h.stages.length, 0);
pending[1].resolve({ ...snapshot, product: 'clouds' });
await flush();
h.stages.at(-1).finish();
await flush();
assert.equal(h.layer.getParams().product, 'clouds');
h.layer.destroy();
});
test('disabled weather releases its camera listener and re-enabling binds only once', () => {
const h = layerHarness();
h.layer.disable();
assert.equal(h.moveEnd.size, 0, 'disabled layers own no active camera work');
h.layer.enable();
h.layer.enable();
assert.equal(h.moveEnd.size, 1);
h.layer.disable();
assert.equal(h.moveEnd.size, 0);
h.layer.enable();
assert.equal(h.moveEnd.size, 1);
h.layer.destroy();
assert.equal(h.moveEnd.size, 0);
});
+3 -2
View File
@@ -180,7 +180,7 @@ export function createWindLayer({
overlay: requestedScalar(),
});
if (!enabled || controller.signal.aborted || request !== controller)
return false;
return enabled && !signal?.aborted && request !== controller;
manifest = snapshot;
error = null;
if (sampledPosition) sample(sampledPosition);
@@ -194,7 +194,8 @@ export function createWindLayer({
}
return true;
} catch (cause) {
if (controller.signal.aborted || request !== controller) return false;
if (controller.signal.aborted || request !== controller)
return enabled && !signal?.aborted && request !== controller;
error = cause?.message || msg('Wind source unavailable');
return true;
} finally {
+33
View File
@@ -412,3 +412,36 @@ test('wind unit chips appear only alongside a speed legend, including canvas tra
for (const overlay of ['pressure', 'temperature', 'none']) { layer.setParams({ overlay }); assert.deepEqual(unitChips(), []); }
layer.destroy();
});
test('a parameter replacement during manager enable does not roll the layer back', async () => {
const { LayerLifecycle } = await import('../../data/lifecycle.js');
const pending = [];
const rendering = Object.fromEntries(
['attach', 'start', 'stop', 'clear', 'destroy', 'setField'].map((name) => [
name,
() => {},
]),
);
const layer = createWindLayer({
feed: {
getSnapshot: (args) =>
new Promise((resolve) => pending.push({ ...args, resolve })),
},
createRendering: () => rendering,
});
const manager = new LayerLifecycle({ container: {} });
manager.register(layer);
const enabled = manager.setEnabled('wind', true);
for (let i = 0; i < 20 && !pending.length; i++) await Promise.resolve();
layer.setParams({ model: 'ifs' });
await Promise.resolve();
pending[0].resolve(snapshot('gfs'));
assert.equal(await enabled, true);
assert.equal(manager.isEnabled('wind'), true);
assert.equal(layer.getStats().loading, true);
pending[1].resolve(snapshot('ifs'));
for (let i = 0; i < 10; i++) await Promise.resolve();
assert.equal(layer.getStats().model, 'IFS');
assert.equal(layer.getStats().count, 4);
await manager.destroyAll();
});
+17
View File
@@ -778,3 +778,20 @@ test('visual input listeners are revoked before asynchronous UI teardown', () =>
assert.match(VisualSettings.prototype.stop.toString(), /this\._styleParameters\?\.destroy\(\)/);
assert.match(VisualSettings.prototype.stop.toString(), /this\._visualEffects\.stop\(\)/);
});
test('share restore rejects inherited styles while continuing later fields', () => {
for (const style of ['constructor', '__proto__', 'toString', 'hasOwnProperty', 'unknown']) {
const restored = makeManager(`#lat=10&lon=20&style=${style}&cr=1`).parseInitialHash();
assert.equal(restored.style, 'normal');
assert.equal(restored.celestialRing, true);
}
});
test('programmatic invalid styles are ignored before changing visual state', () => {
const target = { activeStyle: 'normal' };
for (const value of ['constructor', '__proto__', 'toString', 'hasOwnProperty', 'unknown', {}, null, () => {}]) {
assert.doesNotThrow(() => VisualSettings.prototype.setStyle.call(target, value));
assert.equal(target.activeStyle, 'normal');
}
});
+3 -1
View File
@@ -175,7 +175,9 @@ export class ShareLinkManager {
parseOr(params.get('dd'), 50),
50,
);
const style = URL_TO_STYLE[params.get('style')] || 'normal';
const style = Object.hasOwn(URL_TO_STYLE, params.get('style'))
? URL_TO_STYLE[params.get('style')]
: 'normal';
const decodedLayerState = decodeLayerStateParams(params);
const state = {
lat,
+21
View File
@@ -366,3 +366,24 @@ test('a second client keeps its own quota', async (t) => {
});
assert.notEqual(neighbour.statusCode, 429);
});
test('one 64-point chunk is accepted; larger batches fail before cache or upstream work', async (t) => {
const root = await mkdtemp(path.join(tmpdir(), 'terrain-batch-limit-'));
t.after(() => rm(root, { recursive: true, force: true }));
const asked = stubTerrainUpstream(t);
const route = install(
terrainHeightsProxy({ cachePath: path.join(root, 'cache.json') }),
).get('/api/terrain/heights');
const points = (count) =>
Array.from({ length: count }, (_, i) => `${i / 1000},30`).join(';');
for (const count of [65, 2001]) {
const response = await request(route, { url: `/?points=${points(count)}` });
assert.equal(response.statusCode, 413);
assert.match(response.body.error, /max 64/);
assert.equal(asked.length, 0);
}
const response = await request(route, { url: `/?points=${points(64)}` });
assert.equal(response.statusCode, 200);
assert.equal(response.body.results.length, 64);
assert.equal(asked.length, 64);
});
+22 -19
View File
@@ -98,7 +98,7 @@ test('standalone composition mounts every extracted provider exactly once withou
assert.equal(plugins.filter((p) => p.name === factory().name).length, 1);
});
test('terrain middleware chunks missing points and reconstructs repeated/reordered requests from cache', async (t) => {
test('terrain middleware bounds missing points and reconstructs repeated/reordered requests from cache', async (t) => {
isolate(t);
let calls = 0;
const sizes = [];
@@ -116,25 +116,23 @@ test('terrain middleware chunks missing points and reconstructs repeated/reorder
});
});
const request = install(terrainHeightsProxy());
const points = Array.from({ length: 257 }, (_, i) => `${i / 100},1`);
const points = Array.from({ length: 64 }, (_, i) => `${i / 100},1`);
const res = await request('/?points=' + points.join(';'));
assert.equal(res.status, 200);
// Tracks UPSTREAM_CHUNK in server/providers/terrain.js (64): 257 points
// split into four full chunks and a remainder.
assert.deepEqual(sizes, [64, 64, 64, 64, 1]);
const reordered = await request('/?points=2.56,1;0,1;2.56,1');
assert.deepEqual(sizes, [64]);
const reordered = await request('/?points=0.63,1;0,1;0.63,1');
assert.deepEqual(json(reordered), {
results: [{ ellipsoid: 102.56 }, { ellipsoid: 100 }, { ellipsoid: 102.56 }],
results: [{ ellipsoid: 100.63 }, { ellipsoid: 100 }, { ellipsoid: 100.63 }],
});
// Five chunks for the first batch; the reordered request is fully cached.
assert.equal(calls, 5);
// One bounded batch; the reordered request is fully cached.
assert.equal(calls, 1);
assert.equal((await request('/?points=invalid')).status, 400);
assert.equal(
(await request('/?points=' + Array(2001).fill('0,1').join(';'))).status,
500,
413,
);
// Rejected requests add no upstream calls: still the five from the first batch.
assert.equal(calls, 5);
// Rejected requests add no upstream calls.
assert.equal(calls, 1);
});
test('terrain middleware migrates valid legacy disk points without fabricating omitted heights', async (t) => {
@@ -388,21 +386,26 @@ test('terrain middleware retains successful chunks around a failure and retries
});
const request = install(terrainHeightsProxy());
const points = Array.from({ length: 130 }, (_, i) => `${i / 100},1`);
const url = '/?points=' + points.join(';');
assert.equal((await request(url)).status, 502);
const urls = [0, 64, 128].map(
(start) => '/?points=' + points.slice(start, start + 64).join(';'),
);
assert.deepEqual(
requests.map((p) => p.length),
[64, 64, 2],
await Promise.all(urls.map(async (url) => (await request(url)).status)),
[200, 502, 200],
);
assert.deepEqual(
requests.map((p) => p.length).sort((a, b) => a - b),
[2, 64, 64],
);
fail = false;
const recovered = await request(url);
assert.equal(recovered.status, 200);
const recovered = await Promise.all(urls.map((url) => request(url)));
assert.ok(recovered.every((response) => response.status === 200));
assert.deepEqual(
requests[3],
Array.from({ length: 64 }, (_, i) => [(i + 64) / 100, 1]),
);
assert.deepEqual(
json(recovered).results,
recovered.flatMap((response) => json(response).results),
Array.from({ length: 130 }, (_, i) => ({ ellipsoid: i / 100 + 100 })),
);
assert.equal(requests.length, 4);
+121
View File
@@ -583,3 +583,124 @@ test('track backfill proxy returns 502 on an oversized upstream body and caches
assert.equal(res2.statusCode, 502);
assert.equal(callCount, 1);
});
test('global aircraft snapshots coalesce cold and expired callers and clear rejected flights', async (t) => {
environment(t, { OPENSKY_AUTH_MODE: 'anon' });
t.mock.method(console, 'warn', () => {});
t.mock.method(console, 'error', () => {});
let now = Date.now();
t.mock.method(Date, 'now', () => now);
for (const kind of ['opensky', 'military']) {
const module =
kind === 'opensky'
? await import(
`../../server/providers/aircraft/opensky.js?coalesce=${Math.random()}`
)
: await import('../../server/providers/aircraft/adsb-lol.js');
const request = install(
kind === 'opensky' ? module.openSkyProxy() : module.adsbLolProxy(),
);
const route = kind === 'opensky' ? '/api/flights' : '/api/military';
let calls = 0;
let release;
let blocked;
let fail = false;
t.mock.method(globalThis, 'fetch', async () => {
calls++;
await blocked;
if (fail) throw new Error('fixture connection failed');
return Response.json(
{ time: now / 1000, states: [], ac: [] },
{ headers: { 'x-rate-limit-remaining': '5000' } },
);
});
for (const phase of ['cold', 'expired', 'failed', 'recovered']) {
now += 13000;
fail = phase === 'failed';
blocked = new Promise((resolve) => {
release = resolve;
});
const before = calls;
const pending = Array.from({ length: 5 }, () => request(route));
await new Promise(setImmediate);
assert.equal(calls - before, 1, `${kind} ${phase}: one refresh starts`);
release();
const responses = await Promise.all(pending);
assert.equal(calls - before, fail && kind === 'opensky' ? 2 : 1);
assert.ok(responses.every((res) => res.statusCode === 200));
assert.ok(responses.every((res) => res.body === responses[0].body));
}
}
});
test('OpenSky 5xx retains dated last-good data, cancels error bodies and cools down before recovery', async (t) => {
environment(t, { OPENSKY_AUTH_MODE: 'anon' });
let now = Date.now();
t.mock.method(Date, 'now', () => now);
for (const status of [502, 503, 504]) {
const module = await import(
`../../server/providers/aircraft/opensky.js?server-error=${status}`
);
const request = install(module.openSkyProxy());
let fail = false;
let calls = 0;
let cancelled = false;
t.mock.method(globalThis, 'fetch', async () => {
calls++;
if (fail)
return new Response(
new ReadableStream({
cancel() {
cancelled = true;
},
}),
{ status },
);
return Response.json(
{ time: now / 1000, states: [] },
{ headers: { 'x-rate-limit-remaining': '5000' } },
);
});
const good = await request('/api/flights');
now += 10000;
fail = true;
const stale = await request('/api/flights');
assert.equal(stale.statusCode, 200);
assert.equal(stale.body, good.body);
assert.equal(stale.headers['x-opensky-cache'], 'STALE');
assert.equal(
stale.headers['x-opensky-auth-reason'],
`upstream_http_${status}_serving_stale`,
);
assert.equal(stale.headers['x-opensky-stale-seconds'], '10');
assert.equal(cancelled, true);
const cooling = await request('/api/flights');
assert.equal(cooling.body, good.body);
assert.equal(
cooling.headers['x-opensky-auth-reason'],
`upstream_http_${status}_serving_stale`,
);
assert.equal(calls, 2);
now += 16000;
fail = false;
assert.equal(
(await request('/api/flights')).headers['x-opensky-cache'],
'MISS',
);
assert.equal(calls, 3);
}
const coldModule =
await import('../../server/providers/aircraft/opensky.js?cold-server-error');
let calls = 0;
t.mock.method(globalThis, 'fetch', async () => {
calls++;
return new Response('<html>failure</html>', { status: 503 });
});
const cold = install(coldModule.openSkyProxy());
for (let i = 0; i < 2; i++) {
const response = await cold('/api/flights');
assert.equal(response.statusCode, 503);
assert.match(JSON.parse(response.body).error, /OpenSky/);
}
assert.equal(calls, 1);
});
+2 -1
View File
@@ -20,6 +20,7 @@ const SOURCE_KIND_LABELS = Object.freeze({
});
const SOURCE_STATUS_LABELS = Object.freeze({
ok: msg('OK'),
degraded: msg('DEGRADED'),
error: msg('ERROR'),
unknown: msg('UNKNOWN'),
@@ -52,7 +53,7 @@ export function cctvSourceKindLabel(kind) {
: key.toUpperCase();
}
/** "DEGRADED" / "ПЕРЕБОИ"; OK and unknown codes stay as uppercase codes. */
/** "DEGRADED" / "ПЕРЕБОИ"; unknown codes stay as uppercase codes. */
export function cctvSourceStatusLabel(status) {
const key = String(status || 'unknown').toLowerCase();
return SOURCE_STATUS_LABELS[key]
+2 -2
View File
@@ -1,6 +1,6 @@
/** Contact readouts and nearby-signal presentation for the Cockpit controller. */
import * as Cesium from 'cesium';
import { formatNumber, t, tn } from '../i18n/index.js';
import { formatNumber, getLocale, t, tn } from '../i18n/index.js';
import { unitLabel } from './unitLabels.js';
import {
bearingBetweenCoordinates,
@@ -15,7 +15,7 @@ const LOCALIZED_CONTEXT_SCOPE = Object.freeze({
window: ({ label, radiusKm }) =>
t('{label} · {radius} AIR/SEA WINDOW', {
label,
radius: `${radiusKm === null ? '—' : formatNumber(radiusKm)}\u00a0${unitLabel('km').toUpperCase()}`,
radius: `${radiusKm === null ? '—' : formatNumber(radiusKm)}\u00a0${getLocale() === 'zh-CN' ? unitLabel('km') : unitLabel('km').toUpperCase()}`,
}),
installations: ({ scope, coverage }) =>
t('{scope} · INSTALLATIONS {coverage}', { scope, coverage }),
+4 -3
View File
@@ -1,5 +1,5 @@
/** Pure formatting and fixed presentation constants used by Cockpit components. */
import { formatNumber, t, msg } from '../i18n/index.js';
import { formatNumber, getLocale, t, msg } from '../i18n/index.js';
import { formatQuantity } from './unitLabels.js';
export const COCKPIT_HEADING_SLEW_DPS = 28;
@@ -145,10 +145,11 @@ export function formatCockpitCoordinate(value, positive, negative) {
*/
export function formatCockpitDistance(distanceM) {
const digits = distanceM < 10000 ? 1 : 0;
return formatQuantity(distanceM / 1000, 'km', {
const distance = formatQuantity(distanceM / 1000, 'km', {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
}).toUpperCase();
});
return getLocale() === 'zh-CN' ? distance : distance.toUpperCase();
}
export function formatCockpitWindDirection(value) {
+4 -4
View File
@@ -40,7 +40,7 @@ test('the current language comes first, the rest by their own names', () => {
nativeNames: LOCALE_NAMES,
}),
),
['ru', 'en', 'es'],
['ru', 'en', 'es', 'zh-CN'],
);
assert.deepEqual(
codes(
@@ -50,7 +50,7 @@ test('the current language comes first, the rest by their own names', () => {
nativeNames: LOCALE_NAMES,
}),
),
['es', 'en', 'ru'],
['es', 'en', 'ru', 'zh-CN'],
);
assert.deepEqual(
codes(
@@ -60,7 +60,7 @@ test('the current language comes first, the rest by their own names', () => {
nativeNames: LOCALE_NAMES,
}),
),
['en', 'es', 'ru'],
['en', 'es', 'ru', 'zh-CN'],
);
const rows = buildLocaleOptions({
locales: MANY,
@@ -263,7 +263,7 @@ test('keyboard model: open on the current language, move, choose, close', () =>
assert.deepEqual(model.keydown({ key: 'ArrowDown' }), { handled: true });
assert.equal(model.activeOption.code, 'en');
model.keydown({ key: 'End' });
assert.equal(model.activeOption.code, 'es');
assert.equal(model.activeOption.code, 'zh-CN');
model.keydown({ key: 'Home' });
assert.equal(model.activeOption.code, 'ru');
model.keydown({ key: 'ArrowUp' });
+6 -5
View File
@@ -114,13 +114,14 @@ export function renderRadioState(state) {
...state.categories.map((category) => {
const option = document.createElement('option');
option.value = category.id;
option.textContent = `● ${category.label} (${category.count})`;
const label = t('{category} ({count})', {
category: category.label,
count: category.count,
});
option.textContent = `● ${label}`;
option.dataset.radioColor = category.color;
option.style.color = category.color;
option.setAttribute(
'aria-label',
`${category.label} (${category.count})`,
);
option.setAttribute('aria-label', label);
return option;
}),
);
+51
View File
@@ -0,0 +1,51 @@
/* Tight tracking keeps Chinese labels readable; preserve Latin tracking for
intel HUD readouts and content marked translate="no", including its containers. */
html:lang(zh)
body
:not(
#intel-hud,
#intel-hud *,
[translate='no'],
[translate='no'] *,
:has([translate='no'])
) {
letter-spacing: 0.02em !important;
}
/* Keep small Chinese labels legible at a minimum of 9px. */
html:lang(zh) #command-dock .gev-mic-label,
html:lang(zh) #command-dock #gev-voice-detail,
html:lang(zh) .gev-voice-tier-btn,
html:lang(zh) .panel-layer-toggle {
font-size: 9px;
}
/* A two-glyph button label must not break between its glyphs. */
html:lang(zh) .pp-mode-btn {
white-space: nowrap;
}
/* Keep unit symbols in their catalog case, including the cockpit readouts. */
html:lang(zh) #intel-hud *,
html:lang(zh) #cockpit-hud * {
text-transform: none;
}
/* Move short travel modes (步行 / 骑行) to the next line as whole words. */
html:lang(zh) [data-layer-id='directions'] .data-toggle-meta {
word-break: keep-all;
overflow-wrap: break-word;
}
/* Leave room for the complete sensor name and resolution on two lines. */
html:lang(zh) .recent-imagery-readout .ri-strip {
height: 131px;
}
html:lang(zh) .recent-imagery-readout .ri-card {
height: 123px;
grid-template-rows: 42px 13px 22px 11px 18px;
}
html:lang(zh) .recent-imagery-readout .ri-card-sensor {
white-space: normal;
text-overflow: clip;
}
+5
View File
@@ -1538,6 +1538,11 @@ export class VisualSettings {
preserveCyberRestore = false,
} = {},
) {
if (
typeof styleName !== 'string' ||
(styleName !== 'normal' && !Object.hasOwn(STYLES, styleName))
)
return;
const { setDetectionStyle } = this.services;
if (!restore) this.shareLinkManager?.claimRestoreLane?.('visual');
if (this.hud.getVariant() === 'cyber') {
+3 -1
View File
@@ -370,7 +370,9 @@ export function viewFromParams(params) {
pitch_deg: number('pitch'),
},
layers: state?.enabledLayerIds ?? [],
style: STYLES_BY_URL_NAME[params.get('style')] ?? null,
style: Object.hasOwn(STYLES_BY_URL_NAME, params.get('style'))
? STYLES_BY_URL_NAME[params.get('style')]
: null,
map: VIEW_MAPS.includes(map) ? map : null,
follow,
annotations: annotationsFromParams(params),
+17
View File
@@ -126,3 +126,20 @@ test('annotations travel in the link, keeping only what the app draws', async ()
// Too long for a link: the view keeps them, the link leaves them out.
assert.equal(viewToParams(many).has('an'), false);
});
test('inherited and unknown style keys do not corrupt a restored view', () => {
for (const style of [
'constructor',
'__proto__',
'toString',
'hasOwnProperty',
'unknown',
]) {
const view = viewFromParams(
new URLSearchParams({ lat: '10', lon: '20', style, map: 'osm' }),
);
assert.equal(view.style, null);
assert.equal(view.map, 'osm');
assert.equal(view.camera.lat, 10);
}
});