Merge branch 'main' into sv-locale

This commit is contained in:
German Khokhlov authored and GitHub committed 2026-11-09 18:45:01 +04:00
commit 8b0b975e18
27 files changed
+593 -69

No files matched your search

+23 -23
View File
@@ -820,7 +820,7 @@
"FLIGHT {count}": "VOL {count}",
"REUSED": "RÉUTILISÉ",
"NO STAGE RE-ENTRY / RECOVERY DATA": "AUCUNE DONNÉE DE RENTRÉE / RÉCUPÉRATION D'ÉTAGE",
"{count} / 30D": "{count} / 30J",
"{count} / 30D": "{count} / 30 J",
"NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW": "AUCUNE MISSION DISPONIBLE DANS LA FENÊTRE ACTUELLE DE 30 JOURS",
"DATE UNAVAILABLE": "DATE INDISPONIBLE",
"UNSPECIFIED OPERATOR": "OPÉRATEUR NON PRÉCISÉ",
@@ -1052,11 +1052,11 @@
"Longitudes must be within ±180°": "Les longitudes doivent être comprises entre −180° et +180°",
"Select one side of the dateline": "Ne traversez pas la ligne de changement de date",
"Box must enclose an area": "La zone doit délimiter une surface",
"Add the full Starlink broadband shell (thousands of extra points)": "Ajouter toute la constellation Starlink (des milliers de points en plus)",
"Loading the Starlink shell…": "Chargement de la constellation Starlink…",
"Add the full Starlink broadband shell (thousands of extra points)": "Ajouter la couche complète de la constellation Starlink (des milliers de points en plus)",
"Loading the Starlink shell…": "Chargement de la couche de la constellation Starlink…",
"Starlink {detail} — click to retry": "Starlink {detail} — cliquer pour réessayer",
"load failed": "échec du chargement",
"Showing the full Starlink shell — click for the core catalog only": "Constellation Starlink complète affichée — cliquer pour le catalogue de base seulement",
"Showing the full Starlink shell — click for the core catalog only": "Couche complète de la constellation Starlink affichée — cliquer pour le catalogue de base seulement",
"DENSE": "DENSE",
"CelesTrak unreachable": "CelesTrak injoignable",
"DOCKED · {name} · +{extra}": "AMARRÉ · {name} · +{extra}",
@@ -1137,7 +1137,7 @@
"Hybrid": "Hybride",
"Lines": "Lignes",
"Dots": "Points",
"Road lines colored by live flow: green to red": "Routes colorées selon le trafic en direct : du vert au rouge",
"Road lines colored by live flow: green to red": "Routes colorées selon le trafic en direct : du vert au rouge",
"Moving dots colored by live flow": "Points mobiles colorés selon le trafic en direct",
"Moving dots": "Points mobiles",
"TomTom key unavailable": "Clé TomTom indisponible",
@@ -1153,7 +1153,7 @@
"LIVE · Roads: {source} · No flow roads in view": "DIRECT · Routes : {source} · Aucune route avec flux dans la vue",
"LIVE · Roads: {source} · Flow {percent}%": "DIRECT · Routes : {source} · Flux {percent} %",
"SIMULATED · Roads: {source} · Flow {percent}%": "SIMULÉ · Routes : {source} · Flux {percent} %",
"STALE · Roads: {source} · Flow: cached TomTom tiles": "OBSOLÈTE · Routes : {source} · Flux : tuiles TomTom en cache",
"STALE · Roads: {source} · Flow: cached TomTom tiles": "OBSOLÈTE · Routes : {source} · Flux : tuiles TomTom en cache",
"Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "Synchro du flux · Routes : OpenStreetMap · Flux : TomTom · Non appariées : simulées",
"LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "DIRECT · Routes : OpenStreetMap · Flux : TomTom · couv. {percent} % · Non appariées : simulées",
"LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "DIRECT · Routes : OpenStreetMap · Flux : TomTom · couv. {percent} %",
@@ -1250,7 +1250,7 @@
"Contiguous US · gaps ≠ no rain": "États-Unis contigus · lacunes ≠ absence de pluie",
"Americas + Pacific · not individual strikes": "Amériques + Pacifique · pas des impacts individuels",
"Color: strikes/km²/min ×10³": "Couleur : impacts/km²/min ×10³",
"Color: {units}": "Couleur : {units}",
"Color: {units}": "Couleur : {units}",
"RADAR · {units}": "RADAR · {units}",
"60°S–60°N · typically 2–3 h delayed": "60°S–60°N · retard habituel de 2 à 3 h",
"North America · infrared imagery": "Amérique du Nord · imagerie infrarouge",
@@ -1382,7 +1382,7 @@
"{label} is unavailable": "{label} est indisponible",
"This map stack": "Ce fond de carte",
"{label} unavailable: {hint}": "{label} indisponible : {hint}",
"Google 3D tiles unavailable — check the key's API restrictions, quota, or network": "Tuiles Google 3D indisponibles — vérifiez les restrictions d'API de la clé, le quota ou le réseau",
"Google 3D tiles unavailable — check the key's API restrictions, quota, or network": "Google 3D Tiles indisponible — vérifiez les restrictions d'API de la clé, le quota ou le réseau",
"Needs {envVars} — add it in Provider Settings — or a Cesium ion token for the ion-hosted route": "Nécessite {envVars} — à ajouter dans les paramètres des fournisseurs — ou un jeton Cesium ion pour la variante hébergée par ion",
"Exit cockpit to fly to a camera": "Quittez le cockpit pour rejoindre une caméra",
"Exit cockpit to fly to a location": "Quittez le cockpit pour rejoindre un lieu",
@@ -1521,7 +1521,7 @@
"Apply details": "Appliquer les détails",
"Select a scene first": "Sélectionnez d'abord une scène",
"Share selected scene": "Partager la scène sélectionnée",
"Scene JSON preserves authored settings and attribution. It does not include asset files. Bundles include only pack files you choose here or previously imported bundle files.": "Le JSON de scène conserve les réglages de l'auteur et l'attribution. Il n'inclut pas les fichiers de ressources. Les archives n'incluent que les fichiers de paquets choisis ici ou ceux d'archives déjà importées.",
"Scene JSON preserves authored settings and attribution. It does not include asset files. Bundles include only pack files you choose here or previously imported bundle files.": "Le JSON de scène conserve les paramètres de l'auteur et l'attribution. Il n'inclut pas les fichiers de ressources. Les archives n'incluent que les fichiers de paquets choisis ici ou ceux d'archives déjà importées.",
"Download scene JSON": "Télécharger le JSON de scène",
"Choose data-pack files": "Choisir des fichiers de paquet de données",
"Or choose a data-pack folder": "Ou choisir un dossier de paquet de données",
@@ -2891,7 +2891,7 @@
"AVAILABLE MISSIONS": "MISSIONS DISPONIBLES",
"SELECT A MISSION TO INSPECT": "CHOISIR UNE MISSION À EXAMINER",
"LOADING 30-DAY MISSION INDEX": "CHARGEMENT DE L'INDEX DES MISSIONS À 30 JOURS",
"Space Missions unavailable: the mission index could not load. Try again later.": "Missions spatiales indisponibles : impossible de charger l’index des missions. Réessayez plus tard.",
"Space Missions unavailable: the mission index could not load. Try again later.": "Missions spatiales indisponibles : impossible de charger l'index des missions. Réessayez plus tard.",
"30-DAY MISSION INDEX UNAVAILABLE": "INDEX DES MISSIONS À 30 JOURS INDISPONIBLE",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB APERÇU · ENTER / SPACE SÉLECTIONNE",
"Internet radio and local SDR": "Radio Internet et SDR local",
@@ -2912,7 +2912,7 @@
"Radio volume": "Volume de la radio",
"STATION SITE": "SITE DE LA STATION",
"DIRECTORY: RADIO BROWSER": "ANNUAIRE : RADIO BROWSER",
"Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "Le flux audio se connecte directement au diffuseur dès que vous lancez la lecture, choisissez une station sur le globe ou la demandez par la voix. Votre IP est visible par ce diffuseur.",
"Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "Le flux audio se connecte directement au diffuseur lorsque vous lancez la lecture, que vous choisissez une station sur le globe ou que vous la demandez par la voix. Votre adresse IP est visible par ce diffuseur.",
"Local RTL-SDR receiver": "Récepteur RTL-SDR local",
"LOCAL RTL-SDR": "RTL-SDR LOCAL",
"Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · un seul tuner local partagé entre la FM et la couche ADS-B local.",
@@ -3275,7 +3275,7 @@
"The provider refused access for this key. Check the key's project, region or permissions.": "Le fournisseur a refusé l'accès pour cette clé. Vérifiez le projet, la région ou les autorisations de la clé.",
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Le compte du fournisseur n'a plus de crédit. Vérifiez la facturation ou choisissez un autre fournisseur d'IA dans les paramètres.",
"SIGHTING": "OBSERVATION",
"Show {name} sighting": "Afficher l’observation de {name}",
"Show {name} sighting": "Afficher l'observation : {name}",
"seen {age} ago": "vu il y a {age}",
"observed {age} ago": "observé il y a {age}",
"Be Whale Wise": "Respectez les cétacés",
@@ -3313,7 +3313,7 @@
"Cetacean": "Cétacé",
"Wildlife Sightings": "Observations naturalistes",
"Exit cockpit to fly to a sighting": "Quittez le cockpit pour rejoindre une observation",
"Exit cockpit to fly to a sighting area": "Quittez le cockpit pour rejoindre une zone d’observation",
"Exit cockpit to fly to a sighting area": "Quittez le cockpit pour rejoindre une zone d'observation",
"Wildlife": "Faune et flore",
"Zoom in for local sightings": "Zoomez pour les observations locales",
"Loading photo...": "Chargement de la photo…",
@@ -3323,26 +3323,26 @@
"Acartia": "Acartia",
"CC BY 4.0": "CC BY 4.0",
"{count} sightings in view": "{count} observations dans la vue",
"Close sighting": "Fermer l’observation",
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Faune observée au cours des dernières 48 h (cétacés 7 jours). La couleur et le pictogramme du badge indiquent le groupe taxonomique. Les espèces sensibles sont limitées à des cellules de 0,2° ; les signalements de mammifères marins Acartia en mer des Salish sont arrondis à 0,01°. Pas un recensement.",
"Close sighting": "Fermer l'observation",
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Faune observée au cours des dernières 48 h (cétacés 7 jours). La couleur et le pictogramme du badge indiquent le groupe taxonomique. Les espèces sensibles sont limitées à des cellules de 0,2° ; les signalements de mammifères marins en mer des Salish provenant d'Acartia sont arrondis à 0,01°. Pas un recensement.",
"Open {name} on iNaturalist": "Ouvrir {name} sur iNaturalist",
"Open {name} on Acartia": "Ouvrir {name} sur Acartia",
"iNaturalist": "iNaturalist",
"iNaturalist contributors · licences per record": "Contributeurs iNaturalist · licences par observation",
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Contributeurs iNaturalist · Acartia {count} (mer des Salish) · licences par observation",
"{days}d": "{days}j",
"{days}d": "{days} j",
"All markers": "Tous les marqueurs",
"Show markers of every age": "Afficher les marqueurs de tout âge",
"Maximum event age: {days}d; undated markers are kept": "Ancienneté maximale : {days} j ; marqueurs sans date conservés",
"Maximum event age: {days}d; undated markers are kept": "Ancienneté maximale : {days} j ; marqueurs sans date conservés",
"event age filter|All": "Tous",
"Event age": "Ancienneté",
"Event age: {days}d · undated kept": "Ancienneté : {days} j · marqueurs sans date conservés",
"Close camera preview": "Fermer l’aperçu de la caméra",
"Refresh map still": "Actualiser l’image fixe sur la carte",
"The map still uses a separate request and may show a different moment.": "L’image fixe de la carte utilise une requête distincte et peut montrer un autre moment.",
"Event age: {days}d · undated kept": "Ancienneté : {days} j · marqueurs sans date conservés",
"Close camera preview": "Fermer l'aperçu de la caméra",
"Refresh map still": "Actualiser l'image fixe sur la carte",
"The map still uses a separate request and may show a different moment.": "L'image fixe de la carte utilise une requête distincte et peut montrer un autre moment.",
"Video plays from Québec 511, which sees your IP address.": "La vidéo est lue depuis Québec 511, qui voit votre adresse IP.",
"STATIONS": "STATIONS",
"Crewed stations and the craft that visit them": "Stations habitées et les vaisseaux qui s’y rendent",
"Crewed stations and the craft that visit them": "Stations habitées et les vaisseaux qui s'y rendent",
"IMAGING": "IMAGERIE",
"Earth observation: optical, radar, weather and science": "Observation de la Terre : optique, radar, météo et science",
"TELECOM": "TÉLÉCOM",
@@ -3355,7 +3355,7 @@
"Rocket bodies, fragments and dead satellites": "Étages de fusée, fragments et satellites hors service",
"Classes may lag: SATCAT is a cached copy": "Classes peut-être en retard : SATCAT est une copie en cache",
"Classes may lag: SATCAT unavailable": "Classes peut-être en retard : SATCAT indisponible",
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "SATCAT de CelesTrak donne le type et l’état de chaque objet. Sans copie à jour, les classes reposent sur la base UCS, les noms et les groupes CelesTrak : un satellite hors service depuis peut encore apparaître comme actif.",
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "SATCAT de CelesTrak donne le type et l'état de chaque objet. Sans copie à jour, les classes reposent sur la base UCS, les noms et les groupes CelesTrak : un satellite hors service depuis peut encore apparaître comme actif.",
"STARLINK ···": "STARLINK ···",
"STARLINK ✕": "STARLINK ✕",
"SENSOR SWATH {value} KM": "FAUCHÉE CAPTEUR {value} KM",
+2 -1
View File
@@ -159,7 +159,8 @@ export function buildIncidentCard(row, nowMs, { link = null } = {}) {
// the known cause, behavior and complexity values and the wording around them.
const facts = [];
if (Number.isFinite(row.acres)) {
const acres = Math.round(row.acres);
const acres =
row.acres < 1 ? Math.round(row.acres * 100) / 100 : Math.round(row.acres);
facts.push(
tn(acres, '{acres} ac', '{acres} ac', { acres: formatNumber(acres) }),
);
+31
View File
@@ -302,3 +302,34 @@ test('partial incident details render only the facts that exist', () => {
'Catron County · $85K to date',
]);
});
test('a fire under one acre keeps its WFIGS size instead of reading 0 ac', () => {
const row = {
stableId: 'x',
name: 'Grey',
acres: 0.25,
containedPct: null,
state: 'US-WA',
category: 'WF',
discoveredTime: null,
updatedTime: null,
cause: null,
behavior: null,
personnel: null,
county: null,
costToDate: null,
complexity: null,
};
assert.equal(
buildIncidentCard(row, 1758000000000).details[0],
'0.25 ac · containment unknown · US-WA',
);
assert.equal(
buildIncidentCard({ ...row, acres: 0.01 }, 1758000000000).details[0],
'0.01 ac · containment unknown · US-WA',
);
assert.equal(
buildIncidentCard({ ...row, acres: 1.4 }, 1758000000000).details[0],
'1 ac · containment unknown · US-WA',
);
});
+108 -2
View File
@@ -1,6 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { once } from 'node:events';
import { gunzipSync } from 'node:zlib';
import { WebSocketServer } from 'ws';
import { setTimeout as delay } from 'node:timers/promises';
import * as providers from '../../server/providers/live.js';
@@ -15,7 +16,7 @@ function install(plugin, preview = false) {
},
},
});
return async (route, url = '/', method = 'GET') => {
return async (route, url = '/', method = 'GET', headers = {}) => {
assert.ok(routes.has(route), `registered route: ${route}`);
const response = {
statusCode: 200,
@@ -32,7 +33,7 @@ function install(plugin, preview = false) {
this.body = body;
},
};
await routes.get(route)({ url, method }, response);
await routes.get(route)({ url, method, headers }, response);
return response;
};
}
@@ -584,6 +585,111 @@ test('track backfill proxy returns 502 on an oversized upstream body and caches
assert.equal(callCount, 1);
});
test('the OpenSky snapshot is compressed for clients that ask, and an error body never inherits the cached frame', async (t) => {
environment(t, {
OPENSKY_AUTH_MODE: 'anon',
OPENSKY_CLIENT_ID: undefined,
OPENSKY_CLIENT_SECRET: undefined,
OPENSKY_USERNAME: undefined,
OPENSKY_PASSWORD: undefined,
});
let now = Date.now();
t.mock.method(Date, 'now', () => now);
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'error', () => {});
let upstreamStatus = 200;
let snapshotAgeMs = 0;
t.mock.method(globalThis, 'fetch', async (url) => {
if (url.includes('/lat/'))
return Response.json({
now: now / 1000,
ac: Array.from({ length: 200 }, (_, index) => ({
hex: `abc${index}`,
lat: 30,
lon: -97,
alt_baro: 10000,
})),
});
assert.ok(url.includes('/states/'), `unexpected upstream call: ${url}`);
if (upstreamStatus !== 200)
return new Response('{"error":"upstream"}', { status: upstreamStatus });
const sourceSeconds = Math.floor((now - snapshotAgeMs) / 1000);
// Healthy credits keep the 9 s base TTL, so the clock jumps below clear it.
return Response.json(
{
time: sourceSeconds,
states: Array.from({ length: 200 }, (_, index) => [
`abc${index}`,
'CALLSIGN',
'United States',
sourceSeconds,
-97.7,
30.1,
10000,
]),
},
{ headers: { 'x-rate-limit-remaining': '4000' } },
);
});
// A fresh module: the snapshot cache, its frame and the TTL are module state.
const isolated = await import(
`../../server/providers/aircraft/opensky.js?compression=${now}`
);
const states = install(isolated.openSkyProxy());
const compressed = await states('/api/flights', '/', 'GET', {
'accept-encoding': 'gzip, deflate, br',
});
assert.equal(compressed.statusCode, 200);
assert.equal(compressed.headers['content-encoding'], 'gzip');
assert.equal(compressed.headers.vary, 'Accept-Encoding');
assert.ok(Buffer.isBuffer(compressed.body));
// Same cached snapshot, no Accept-Encoding: identity bytes, which the frame
// above must decode to.
const identity = await states('/api/flights');
assert.equal(identity.headers['x-opensky-cache'], 'HIT');
assert.equal(identity.headers['content-encoding'], undefined);
assert.equal(identity.headers.vary, 'Accept-Encoding');
assert.equal(gunzipSync(compressed.body).toString(), identity.body);
// `gzip;q=0` refuses gzip.
const refused = await states('/api/flights', '/', 'GET', {
'accept-encoding': 'gzip;q=0',
});
assert.equal(refused.headers['content-encoding'], undefined);
assert.equal(refused.body, identity.body);
// An upstream rejection is not cached, so it must not carry the previous
// snapshot's Content-Encoding.
upstreamStatus = 403;
now += 60000; // past the 9 s base TTL
const rejected = await states('/api/flights', '/', 'GET', {
'accept-encoding': 'gzip',
});
assert.equal(rejected.statusCode, 403);
assert.equal(rejected.headers['content-encoding'], undefined);
assert.ok(JSON.parse(rejected.body).error, 'a readable error body');
// A snapshot too old to serve falls back to adsb.lol, which negotiates the
// same way.
upstreamStatus = 200;
snapshotAgeMs = 3600000;
now += 60000;
const regional = await states('/api/flights', '?lat=30&lon=-97', 'GET', {
'accept-encoding': 'gzip',
});
assert.equal(regional.statusCode, 200);
assert.equal(regional.headers['x-flight-source'], 'adsb.lol');
assert.equal(regional.headers['content-encoding'], 'gzip');
assert.equal(regional.headers.vary, 'Accept-Encoding');
assert.equal(
JSON.parse(gunzipSync(regional.body).toString()).states.length,
200,
);
});
test('OpenSky Basic mode no longer sends username and password: it asks anonymously and warns once', async (t) => {
environment(t, {
OPENSKY_CLIENT_ID: undefined,
+22 -13
View File
@@ -684,25 +684,34 @@ test('the disk cache survives a restart and stays within its bounds', async () =
await createNominatimCache({ dir, now: time.now }).get('k0'),
null,
);
assert.equal(
fs.statSync(dir).mode & 0o777,
0o700,
'the query cache directory is private',
);
// The cache is private only through POSIX mode bits. Windows has none
// (directories stat as 0o666) and the cache keeps the checkout's ACLs,
// so these checks run, and privacy holds, only on POSIX systems.
const posixModes = process.platform !== 'win32';
if (posixModes) {
assert.equal(
fs.statSync(dir).mode & 0o777,
0o700,
'the query cache directory is private (POSIX modes)',
);
}
const text = fs
.readdirSync(dir)
.filter((name) => name.endsWith('.json'))
.map((name) => fs.readFileSync(path.join(dir, name), 'utf8'))
.join('\n');
assert.equal(text.includes('k4'), false, 'disk entries never store raw keys');
for (const name of fs
.readdirSync(dir)
.filter((entry) => entry.endsWith('.json')))
assert.equal(
fs.statSync(path.join(dir, name)).mode & 0o777,
0o600,
`${name} is private`,
);
if (posixModes) {
for (const name of fs
.readdirSync(dir)
.filter((entry) => entry.endsWith('.json'))) {
assert.equal(
fs.statSync(path.join(dir, name)).mode & 0o777,
0o600,
`${name} is private (POSIX modes)`,
);
}
}
fs.rmSync(dir, { recursive: true, force: true });
});
+131
View File
@@ -0,0 +1,131 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { gunzipSync } from 'node:zlib';
import {
acceptsGzip,
encodedBody,
gzipBody,
} from '../../server/providers/common/compress.js';
/** A request shaped like the one Node hands a middleware. */
function request(acceptEncoding) {
return {
url: '/api/flights',
method: 'GET',
headers:
acceptEncoding === undefined ? {} : { 'accept-encoding': acceptEncoding },
};
}
/** Repetitive JSON shaped like a snapshot body. */
function snapshotBody(states = 200) {
return JSON.stringify({
time: 1700000000,
states: Array.from({ length: states }, (_, index) => [
`abc${index}`,
'CALLSIGN',
'United States',
1700000000,
30.1,
-97.7,
10000,
]),
});
}
/** Seeded high-entropy bytes, so the "gzip made it bigger" check is stable. */
function incompressibleBytes(length) {
const bytes = Buffer.alloc(length);
let state = 123456789;
for (let index = 0; index < length; index += 1) {
state = (state * 1103515245 + 12345) % 2147483648;
bytes[index] = (state >>> 16) & 0xff;
}
return bytes;
}
test('accept-encoding is read the way clients actually write it', () => {
assert.equal(acceptsGzip(request('gzip')), true);
assert.equal(acceptsGzip(request('gzip, deflate, br')), true);
assert.equal(acceptsGzip(request('br;q=1.0, gzip;q=0.8')), true);
assert.equal(acceptsGzip(request('GZIP')), true);
assert.equal(acceptsGzip(request(' gzip ')), true);
assert.equal(acceptsGzip(request('br, deflate')), false);
assert.equal(acceptsGzip(request('identity')), false);
assert.equal(acceptsGzip(request('')), false);
assert.equal(acceptsGzip(request(undefined)), false);
assert.equal(acceptsGzip({}), false);
assert.equal(acceptsGzip(undefined), false);
// `q=0` names gzip and refuses it (RFC 9110 section 12.4.2).
assert.equal(acceptsGzip(request('gzip;q=0')), false);
assert.equal(acceptsGzip(request('gzip;q=0.000')), false);
assert.equal(acceptsGzip(request('gzip;q=0.001')), true);
assert.equal(acceptsGzip(request('gzip;q=1')), true);
assert.equal(acceptsGzip(request('gzip;q=1.000')), true);
// A q that is not a whole `qvalue` is unreadable and must not round into
// acceptance.
assert.equal(acceptsGzip(request('gzip;q=bogus')), false);
assert.equal(acceptsGzip(request('gzip;q=1oops')), false);
assert.equal(acceptsGzip(request('gzip;q=0.5abc')), false);
assert.equal(acceptsGzip(request('gzip;q=2')), false);
assert.equal(acceptsGzip(request('gzip;q=1.5')), false);
assert.equal(acceptsGzip(request('gzip;q=0.0001')), false);
assert.equal(acceptsGzip(request('gzip;q=')), false);
// `gzip` is not matched as a substring of a neighbouring token.
assert.equal(acceptsGzip(request('x-gzip')), false);
assert.equal(acceptsGzip(request('gzipped')), false);
});
test('a body is only compressed when compression pays for itself', async () => {
assert.equal(await gzipBody(''), null);
assert.equal(
await gzipBody('{"states":[]}'),
null,
'below the frame threshold',
);
assert.equal(await gzipBody(Buffer.alloc(1023, 'a')), null, 'one byte short');
const identity = snapshotBody();
assert.ok(identity.length > 1024, 'fixture is worth compressing');
const compressed = await gzipBody(identity);
assert.ok(Buffer.isBuffer(compressed));
assert.ok(compressed.byteLength < Buffer.byteLength(identity));
assert.equal(gunzipSync(compressed).toString(), identity);
// Buffers are accepted as-is; data gzip cannot shrink stays uncompressed.
assert.ok((await gzipBody(Buffer.from(identity))) !== null);
assert.equal(await gzipBody(incompressibleBytes(4096)), null);
});
test('the encoded body and the header that names it are chosen together', async () => {
const identity = snapshotBody();
const compressed = await gzipBody(identity);
const negotiated = encodedBody(request('gzip'), identity, compressed);
assert.equal(negotiated.body, compressed);
assert.equal(negotiated.headers['Content-Encoding'], 'gzip');
assert.equal(negotiated.headers.Vary, 'Accept-Encoding');
assert.equal(gunzipSync(negotiated.body).toString(), identity);
// Offered but refused: identity bytes and no Content-Encoding.
const refused = encodedBody(request('br'), identity, compressed);
assert.equal(refused.body, identity);
assert.equal(refused.headers['Content-Encoding'], undefined);
assert.equal(refused.headers.Vary, 'Accept-Encoding');
// Wanted but not available: same answer.
const unavailable = encodedBody(request('gzip'), identity, null);
assert.equal(unavailable.body, identity);
assert.equal(unavailable.headers['Content-Encoding'], undefined);
// Vary is on both branches, so a shared cache cannot serve the wrong encoding.
assert.equal(unavailable.headers.Vary, 'Accept-Encoding');
assert.equal(
encodedBody(request(undefined), identity, compressed).headers.Vary,
'Accept-Encoding',
);
});
+2 -1
View File
@@ -19,6 +19,7 @@ import {
numericSummary,
thinEvenly,
} from '../results.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
const ICAO24_SCHEMA = Object.freeze({
type: 'string',
@@ -75,7 +76,7 @@ function aircraftRow(record, from) {
altitude_m: round(record.baroAltitudeM ?? record.ellipsoidAltitudeM, 0),
on_ground: record.onGround === true,
speed_mps: round(record.speedMps, 1),
course_deg: round(record.courseDeg, 0),
course_deg: roundHeadingDegrees(record.courseDeg),
vertical_rate_mps: round(record.verticalRateMps, 1),
type_code: record.typeCode || null,
registration: record.registration || null,
+13
View File
@@ -146,6 +146,19 @@ test('the military feed is used on request and reported when missing', async ()
);
});
test('an aircraft course that rounds to 360 reads as 0', async () => {
const catalog = composeCatalog({
tools: coreTools,
services: {
aircraft: feed([
record('ddd444', 37.6, -122.3, { callsign: 'N360', courseDeg: 359.6 }),
]),
},
});
const result = await catalog.call('find_aircraft', { callsign: 'N360' });
assert.equal(result.data.rows[0].course_deg, 0);
});
test('aircraft are found by exactly one identifier across feeds', async () => {
const catalog = composeCatalog({
tools: coreTools,
+1
View File
@@ -71,6 +71,7 @@ function weatherRow(weather) {
precipitation_mm: weather.precipitationMm ?? null,
cloud_cover_pct: weather.cloudCoverPct ?? null,
wind_kph: weather.windKph ?? null,
// Raw Open-Meteo value, so north reads 360.
wind_direction_deg: weather.windDirectionDeg ?? null,
visibility_m: weather.visibilityM ?? null,
};
+3 -2
View File
@@ -20,6 +20,7 @@ import {
numericSummary,
thinEvenly,
} from '../results.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
import { normalizeVesselType } from '../../data/vesselTypes.js';
const MMSI_SCHEMA = Object.freeze({
@@ -57,8 +58,8 @@ function vesselRow(record, from) {
lat: round(record.latitude, 5),
lon: round(record.longitude, 5),
speed_mps: round(record.speedMps, 1),
course_deg: round(record.courseDeg, 0),
heading_deg: round(record.headingDeg, 0),
course_deg: roundHeadingDegrees(record.courseDeg),
heading_deg: roundHeadingDegrees(record.headingDeg),
observed_at: isoTime(record.observedAtMs),
...(from ? { distance_km: round(distanceKm(from, point), 1) } : {}),
};
+19
View File
@@ -351,6 +351,25 @@ test('find_vessel answers carry normalized types and destinations', async () =>
);
});
test('a vessel course or heading that rounds to 360 reads as 0', async () => {
const catalog = composeCatalog({
tools: coreTools,
services: {
vessels: vessels({
records: [
vessel('366000003', 37.78, -122.4, {
courseDeg: 359.7,
headingDeg: 359.6,
}),
],
}),
},
});
const [row] = (await catalog.call('find_vessel', { mmsi: '366000003' })).data
.rows;
assert.deepEqual([row.course_deg, row.heading_deg], [0, 0]);
});
test('a destination with no letters counts as unknown', async () => {
const catalog = composeCatalog({
tools: coreTools,
+2 -1
View File
@@ -10,6 +10,7 @@ import {
resolveArea,
} from '../area.js';
import { LIMIT_SCHEMA, capRows, countNoun, toBase64 } from '../results.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
const FRAME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']);
const MAX_FRAME_BYTES = 3 * 1024 * 1024;
@@ -85,7 +86,7 @@ export const findCctvCameras = defineTool({
provider: text(camera.provider),
lat: round(camera.lat, 6),
lon: round(camera.lon, 6),
heading_deg: round(camera.headingDeg, 0),
heading_deg: roundHeadingDegrees(camera.headingDeg),
feed_type: text(camera.feedType),
credit: text(camera.credit),
distance_km: round(distanceKm(center, camera), 2),
+14
View File
@@ -77,6 +77,20 @@ test('cameras in an area are listed nearest first', async () => {
assert.equal(result.data.rows[1].credit, null);
});
test('a camera heading that rounds to 360 reads as 0', async () => {
const source = {
async getCatalog() {
return { sources: [{ ...cameras[0], headingDeg: 359.6 }] };
},
};
const catalog = composeCatalog({
tools: coreTools,
services: { cctv: source },
});
const result = await catalog.call('find_cctv_cameras', { area: downtown });
assert.equal(result.data.rows[0].heading_deg, 0);
});
test('a snapshot returns the frame as an image with its credit', async () => {
const source = cctv();
const catalog = composeCatalog({
+2 -1
View File
@@ -19,6 +19,7 @@ import {
resolveArea,
} from '../area.js';
import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
const MAX_SYSTEMS = 3;
@@ -244,7 +245,7 @@ export const getTransitVehicles = defineTool({
trip_id: vehicle.tripId ?? null,
lat: vehicle.lat,
lon: vehicle.lon,
bearing_deg: round(vehicle.bearing, 0),
bearing_deg: roundHeadingDegrees(vehicle.bearing),
speed_mps: round(vehicle.speedMps, 1),
status: vehicle.status ?? null,
occupancy: vehicle.occupancy ?? null,
+17
View File
@@ -311,6 +311,23 @@ test('transit vehicles come from nearby feeds and can be limited to a route', as
assert.equal(rapid.data.rows[0].id, 'v2');
});
test('a transit bearing that rounds to 360 reads as 0', async () => {
const north = [{ ...vehicles[0], bearing: 359.6 }];
const catalog = composeCatalog({
tools: coreTools,
services: {
transit: transit({
ok: true,
status: 200,
json: async () => ({ vehicles: north }),
}),
clock,
},
});
const result = await catalog.call('get_transit_vehicles', { area: downtown });
assert.equal(result.data.rows[0].bearing_deg, 0);
});
test('transit reports uncovered areas and unavailable feeds', async () => {
const catalog = composeCatalog({
tools: coreTools,
+3 -2
View File
@@ -10,6 +10,7 @@ import { parseTleText, tleCatalogNumber } from '../../sources/tle.js';
import { POINT_SCHEMA, resolvePoint } from '../area.js';
import { defineTool, ToolError } from '../catalog.js';
import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
const text = (value) => (typeof value === 'string' && value ? value : null);
@@ -241,7 +242,7 @@ export const nextSatellitePass = defineTool({
peak: isoTime(pass.maxElevMs),
set: isoTime(pass.setMs),
max_elevation_deg: round(pass.maxElevDeg, 1),
rise_azimuth_deg: round(pass.riseAzDeg, 0),
rise_azimuth_deg: roundHeadingDegrees(pass.riseAzDeg),
rise_direction: compass(pass.riseAzDeg),
visible: pass.visible,
},
@@ -283,7 +284,7 @@ export const satellitesOverhead = defineTool({
name: entry.name,
norad: entry.norad,
elevation_deg: round(look.elevDeg, 1),
azimuth_deg: round(look.azDeg, 0),
azimuth_deg: roundHeadingDegrees(look.azDeg),
direction: compass(look.azDeg),
},
]
+35
View File
@@ -2,6 +2,10 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { twoline2satrec } from 'satellite.js';
import { composeCatalog, coreTools } from '../index.js';
import {
findNextSatellitePass,
lookAnglesAt,
} from '../../data/satellitePass.js';
import {
noradIdFromSatnum,
parseTleText,
@@ -296,6 +300,37 @@ test('satellites overhead are those above the elevation, highest first', async (
);
});
test('satellite azimuths that round to 360 read as 0', async () => {
const catalog = composeCatalog({
tools: coreTools,
services: { satellites: satellites(), clock: CLOCK },
});
// At these points the ISS's raw azimuths fall just below 360°; check that
// first so the test cannot pass on a raw value near 0.
const [, line1, line2] = TLE.split('\n');
const iss = twoline2satrec(line1, line2);
const nearNorth = (deg) => deg > 359.5 && deg < 360;
const rawPass = findNextSatellitePass({
satrec: iss,
latDeg: 10,
lonDeg: 30,
fromMs: CLOCK.now(),
});
assert.ok(nearNorth(rawPass.riseAzDeg), `rise ${rawPass.riseAzDeg}°`);
const rawLook = lookAnglesAt(iss, CLOCK.now(), 39.25, 65.75);
assert.ok(nearNorth(rawLook.azDeg), `azimuth ${rawLook.azDeg}°`);
const pass = await catalog.call('next_satellite_pass', {
location: { lat: 10, lon: 30 },
});
assert.equal(pass.data.pass.rise_azimuth_deg, 0);
assert.equal(pass.data.pass.rise_direction, 'N');
const overhead = await catalog.call('satellites_overhead', {
location: { lat: 39.25, lon: 65.75 },
});
assert.equal(overhead.data.rows[0].name, 'ISS (ZARYA)');
assert.equal(overhead.data.rows[0].azimuth_deg, 0);
});
test('stale orbit and launch data are reported', async () => {
const stale = {
readGroup: async () => ({ ok: true, status: 200, text: TLE, stale: true }),