Merge branch 'main' into feat/cctv-model-spec-enrichment

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Sameh Khamis committed 2026-10-15 19:08:55 -07:00
commit a4888d7846
24 files changed
+623 -14

No files matched your search

+38
View File
@@ -162,6 +162,44 @@ test('malformed, duplicate, oversized, future and expired status is rejected rat
assert.deepEqual(parseCycloneStatus({ activeStorms: [] }, NOW), []);
});
test('advisories published before their nominal time are accepted, far-future ones are not', () => {
// NHC wrote the 03:00Z advisory at 02:34Z.
const early = Date.UTC(2026, 8, 16, 2, 34);
assert.equal(parseCycloneStatus(status(), early)[0].issuedAt, TIME);
assert.throws(
() => parseCycloneStatus(status(), Date.UTC(2026, 8, 16, 1, 25)),
/invalid_cyclone_data/,
);
});
test('advisory and position times are bounded at 90 minutes ahead and 12 hours old', () => {
const MINUTE = 60_000;
const at = (offsetMs) => new Date(NOW + offsetMs).toISOString();
const fields = {
issuedAt: (value) =>
status({
forecastAdvisory: {
advNum: '010',
issuance: value,
url: 'https://www.nhc.noaa.gov/text/MIATCMEP5.shtml',
},
}),
positionAt: (value) => status({ lastUpdate: value }),
};
for (const [field, payload] of Object.entries(fields)) {
for (const offset of [90 * MINUTE, -12 * 60 * MINUTE]) {
const [storm] = parseCycloneStatus(payload(at(offset)), NOW);
assert.equal(storm[field], at(offset), `${field} ${offset}`);
}
for (const offset of [90 * MINUTE + 1, -12 * 60 * MINUTE - 1])
assert.throws(
() => parseCycloneStatus(payload(at(offset)), NOW),
/invalid_cyclone_data/,
`${field} ${offset}`,
);
}
});
test('coherent geometry is retained with holes/dateline while older advisory geometry is omitted', () => {
const storms = parseCycloneStatus(status(), NOW);
const parts = collections();
+1 -1
View File
@@ -304,7 +304,7 @@ export const DATA_CREDITS = [
key: 'gbfs',
html:
'Bikeshare availability: GBFS operator feeds ' +
'(e.g. Austin BCycle; Bike Share Toronto, operated by the Toronto Parking Authority)',
'(e.g. Austin BCycle; Bike Share Toronto, operated by the Toronto Parking Authority; Bike Itaú, operated by Tembici)',
},
{
key: 'urbansharing-gbfs',
@@ -0,0 +1,73 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { CITY_BY_ID } from './registry.js';
import { createModel } from './model.js';
import { createViewport } from './viewport.js';
import { isAllowedGbfsHost, isAllowedGbfsPath } from '../../data/gbfsSource.js';
// Each Bike Itaú city's PBSC host, and the station farthest from its
// registered centre in the live feed (checked 2026-10-15).
const CITIES = {
'sao-paulo-bike-itau': {
host: 'saopaulo.publicbikesystem.net',
stations: [{ name: '260 - P3', lat: -23.568235, lon: -46.740715 }],
},
'rio-de-janeiro-bike-itau': {
host: 'riodejaneiro.publicbikesystem.net',
stations: [{ name: '293 - Praia da Macumba', lat: -23.03135, lon: -43.479584 }],
},
'salvador-bike-itau': {
host: 'salvador.publicbikesystem.net',
stations: [{ name: '49 - Stella Maris', lat: -12.93645, lon: -38.32701 }],
},
'recife-bike-itau': {
host: 'recife.publicbikesystem.net',
stations: [{ name: '69 - Praia de Piedade', lat: -8.186205, lon: -34.918371 }],
},
'porto-alegre-bike-itau': {
host: 'portoalegre.publicbikesystem.net',
stations: [{ name: '72 - Avenida Tramandaí', lat: -30.135915, lon: -51.22684 }],
},
'brasilia-bike-itau': {
host: 'brasilia.publicbikesystem.net',
stations: [{ name: "60 - Parque Olhos d'Água", lat: -15.7431, lon: -47.88667 }],
},
'curitiba-bike-itau': {
host: 'curitiba.publicbikesystem.net',
stations: [{ name: '42 - Terminal Portão', lat: -25.476885, lon: -49.292608 }],
},
};
// The production range check, with no viewer or fetches behind it.
const parts = { model: createModel({ state: {}, services: {}, parts: {}, source: null }) };
const { computeInRangeCities } = createViewport({
state: {},
services: { render: { governorRequestRender() {} } },
parts,
source: null,
});
test('the Bike Itaú systems are registered and readable through the proxy', () => {
for (const [id, expected] of Object.entries(CITIES)) {
const city = CITY_BY_ID.get(id);
assert.ok(city, `${id} is registered`);
assert.equal(city.provider, 'Bike Itaú');
assert.deepEqual(city.hosts, [expected.host]);
for (const feed of [city.stationInformationUrl, city.stationStatusUrl]) {
const url = new URL(feed);
assert.ok(isAllowedGbfsHost(url.hostname), `${id} host ${url.hostname}`);
assert.ok(isAllowedGbfsPath(url.pathname), `${id} path ${url.pathname}`);
}
}
});
test('a camera over each outermost station activates its system', () => {
for (const [id, expected] of Object.entries(CITIES)) {
for (const station of expected.stations) {
assert.ok(
computeInRangeCities({ lat: station.lat, lon: station.lon }).has(id),
`${station.name} activates ${id}`,
);
}
}
});
+84
View File
@@ -246,6 +246,90 @@ const RAW_GBFS_CITY_REGISTRY = [
'https://chat.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'Bike Chattanooga',
},
{
id: 'sao-paulo-bike-itau',
city: 'São Paulo, SP',
centerLat: -23.5505,
centerLon: -46.6333,
loadRadiusKm: 100,
stationInformationUrl:
'https://saopaulo.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
stationStatusUrl:
'https://saopaulo.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'Bike Itaú',
},
{
id: 'rio-de-janeiro-bike-itau',
city: 'Rio de Janeiro, RJ',
centerLat: -22.9068,
centerLon: -43.1729,
loadRadiusKm: 100,
stationInformationUrl:
'https://riodejaneiro.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
stationStatusUrl:
'https://riodejaneiro.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'Bike Itaú',
},
{
id: 'salvador-bike-itau',
city: 'Salvador, BA',
centerLat: -12.9777,
centerLon: -38.5016,
loadRadiusKm: 90,
stationInformationUrl:
'https://salvador.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
stationStatusUrl:
'https://salvador.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'Bike Itaú',
},
{
id: 'recife-bike-itau',
city: 'Recife, PE',
centerLat: -8.0476,
centerLon: -34.877,
loadRadiusKm: 90,
stationInformationUrl:
'https://recife.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
stationStatusUrl:
'https://recife.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'Bike Itaú',
},
{
id: 'porto-alegre-bike-itau',
city: 'Porto Alegre, RS',
centerLat: -30.0346,
centerLon: -51.2177,
loadRadiusKm: 90,
stationInformationUrl:
'https://portoalegre.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
stationStatusUrl:
'https://portoalegre.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'Bike Itaú',
},
{
id: 'brasilia-bike-itau',
city: 'Brasília, DF',
centerLat: -15.7939,
centerLon: -47.8828,
loadRadiusKm: 90,
stationInformationUrl:
'https://brasilia.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
stationStatusUrl:
'https://brasilia.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'Bike Itaú',
},
{
id: 'curitiba-bike-itau',
city: 'Curitiba, PR',
centerLat: -25.4284,
centerLon: -49.2733,
loadRadiusKm: 90,
stationInformationUrl:
'https://curitiba.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
stationStatusUrl:
'https://curitiba.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'Bike Itaú',
},
{
id: 'toronto-bike-share',
city: 'Toronto, ON',
+5 -3
View File
@@ -275,10 +275,12 @@ export class FlightRecords {
return { icao24, prevMeta, meta, groundFlipped, fixEpochMs };
}
absence(id, { complete, likelyLanded }) {
// Partial admissions do not prove absence, but retention remains bounded.
absence(id, { complete, retainIds, likelyLanded }) {
// Partial admissions and position-less rows do not prove absence, but
// retention remains bounded; a likely-landed position-less contact
// still takes the landed limit.
if (
!complete &&
(!complete || (retainIds?.has(id) && !likelyLanded)) &&
Date.now() - (this.data.get(id)?.observedReceiptMs ?? 0) < 300000
)
return 'retain';
+24
View File
@@ -107,6 +107,30 @@ test('ground transitions preserve last render height while a surface cell is col
assert.equal(next.meta.wasAirborne, true);
});
test('complete snapshots age out vanished aircraft and retain position-less ones unless landed', () => {
const store = records();
for (const id of ['abc123', 'def456', 'ghi789'])
store.receive(observation({ id, reference: id }), view());
// abc123 and ghi789 are still listed without a position; def456 vanished.
const absent = (id, likelyLanded = false) =>
store.absence(id, {
complete: true,
retainIds: new Set(['abc123', 'ghi789']),
likelyLanded,
});
assert.deepEqual(
[absent('def456'), absent('def456'), absent('def456')],
['stale', 'stale', 'remove'],
);
assert.equal(absent('abc123'), 'retain');
assert.equal(store.missingPolls.has('abc123'), false);
// A landed contact that lost its position takes the landed fast-cull.
assert.equal(absent('ghi789', true), 'remove');
// Position-less retention stays bounded like partial admission.
store.data.get('abc123').observedReceiptMs = Date.now() - 300001;
assert.equal(absent('abc123'), 'stale');
});
test('civil emergency status follows the latest poll instead of sticking', () => {
const store = records();
const first = store.receive(observation({ squawk: '7700' }), view());
+1
View File
@@ -243,6 +243,7 @@ export function createFlightSnapshotRenderer({
if (currentIcaos.has(icao24)) continue;
const absence = records.absence(icao24, {
complete: snapshot.complete,
retainIds: snapshot.retainIds,
likelyLanded: queries._likelyLanded(icao24),
});
if (absence === 'retain') continue;
+15 -2
View File
@@ -7,10 +7,12 @@ import {
finite,
} from './contract.js';
const openSkyId = (row) =>
Array.isArray(row) ? cleanText(row[0]).toLowerCase() : '';
/** Normalize the OpenSky state-vector route, including its regional fallback. */
export function normalizeOpenSkyAircraft(row) {
if (!Array.isArray(row)) return null;
const id = cleanText(row[0]).toLowerCase();
const id = openSkyId(row);
if (!id || !coordinates(row[6], row[5])) return null;
return {
id,
@@ -77,6 +79,16 @@ export function normalizeReadsbAircraft(row, snapshotTimeMs) {
};
}
/**
* OpenSky allows null longitude/latitude for a contact that is still
* broadcasting. Strict nulls also reject rows truncated before the position.
*/
function openSkyUnpositionedId(row) {
return Array.isArray(row) && row[5] === null && row[6] === null
? openSkyId(row)
: '';
}
export function openSkySnapshot(
payload,
{
@@ -96,6 +108,7 @@ export function openSkySnapshot(
return record;
},
'OpenSky',
openSkyUnpositionedId,
);
const observedAtMs = epoch(payload?.time, 1000);
const ageMs = observedAtMs == null ? null : Math.max(0, now - observedAtMs);
+17 -3
View File
@@ -53,17 +53,30 @@ export function coordinates(latitude, longitude) {
);
}
/** Admit a snapshot atomically; an all-invalid nonempty feed is unavailable. */
export function admitRecords(rows, normalize, label) {
/**
* Admit a snapshot atomically; an all-invalid nonempty feed is unavailable.
* `unpositionedId(row)` names a rejected row that is a valid contact without
* a position: it is returned in `retainIds` and does not make the snapshot
* incomplete.
*/
export function admitRecords(rows, normalize, label, unpositionedId) {
if (!Array.isArray(rows))
throw new LiveSourceError('malformed', `Malformed ${label} response`);
const records = [];
const ids = new Set();
const retainIds = new Set();
let unpositioned = 0;
for (const row of rows) {
const record = normalize(row);
if (record && !ids.has(record.id)) {
records.push(record);
ids.add(record.id);
} else if (!record) {
const id = unpositionedId?.(row);
if (id) {
retainIds.add(id);
unpositioned += 1;
}
}
}
if (rows.length && !records.length) {
@@ -71,7 +84,8 @@ export function admitRecords(rows, normalize, label) {
}
return {
records,
complete: records.length === rows.length,
retainIds,
complete: records.length + unpositioned === rows.length,
rejectedCount: rows.length - records.length,
};
}
+44
View File
@@ -67,6 +67,50 @@ test('mixed-invalid and duplicate admissions are explicitly incomplete; all-inva
assert.equal(openSkySnapshot({ states: [] }).complete, true);
});
test('position-less OpenSky rows are seen, not missing; malformed rows stay incomplete', () => {
// OpenSky's spec allows null longitude/latitude; every live /states/all
// carries such rows.
const unpositioned = ['DEF456', ...aircraft.slice(1)];
unpositioned[5] = null;
unpositioned[6] = null;
const snapshot = openSkySnapshot({ states: [aircraft, unpositioned] });
assert.equal(snapshot.records.length, 1);
assert.equal(snapshot.complete, true);
assert.deepEqual([...snapshot.retainIds], ['def456']);
assert.equal(snapshot.rejectedCount, 1);
const malformed = [...aircraft];
malformed[5] = 181;
const garbled = openSkySnapshot({
states: [aircraft, unpositioned, malformed],
});
assert.equal(garbled.complete, false);
assert.deepEqual([...garbled.retainIds], ['def456']);
// No usable id, only half a position, a truncated row, or no row at all.
for (const row of [
[' ', ...unpositioned.slice(1)],
Object.assign([...unpositioned], { 5: -97 }),
['DEF456'],
null,
])
assert.equal(openSkySnapshot({ states: [aircraft, row] }).complete, false);
// Repeated position-less rows stay complete.
const repeated = openSkySnapshot({
states: [aircraft, unpositioned, unpositioned],
});
assert.equal(repeated.complete, true);
// A feed with no positions at all still cannot prove absence.
assert.throws(() => openSkySnapshot({ states: [unpositioned] }), {
code: 'malformed',
});
// Military readsb admission keeps its existing semantics.
const military = readsbSnapshot(
{ ac: [{ hex: 'abc123', lat: 30, lon: -97 }, { hex: 'def456' }] },
{ observedAtMs: null },
);
assert.equal(military.complete, false);
assert.equal(military.retainIds.size, 0);
});
test('readsb units and position/contact ages are normalized once against the source epoch', () => {
const row = normalizeReadsbAircraft(
{
+149
View File
@@ -0,0 +1,149 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import sharp from 'sharp';
import { build } from 'vite';
import { makeFixtureRoot } from './fixtureRoot.mjs';
const REPO_ROOT = path.resolve(
fileURLToPath(new URL('.', import.meta.url)),
'../..',
);
const PUBLIC_ROOT = path.join(REPO_ROOT, 'public');
const INDEX_HTML = readFileSync(path.join(REPO_ROOT, 'index.html'), 'utf8');
/** `public/` is served at the site root, so an index.html href maps to a file by dropping the leading slash. */
function publicFile(href) {
assert.ok(href.startsWith('/'), `${href} must be a root-relative URL`);
return path.join(PUBLIC_ROOT, href.slice(1));
}
const manifestHref = INDEX_HTML.match(
/<link[^>]+rel="manifest"[^>]+href="([^"]+)"/,
)?.[1];
/**
* Manifest URLs resolve against the manifest, not the page. Keeping them
* relative is what lets a build served under a path prefix install inside it.
*/
function manifestFile(src) {
assert.ok(
!src.startsWith('/') && !/^[a-z][a-z\d+.-]*:/i.test(src),
`${src} must be relative to the manifest`,
);
return path.join(path.dirname(publicFile(manifestHref)), src);
}
test('index.html links a web app manifest', () => {
assert.ok(manifestHref, 'no <link rel="manifest"> in index.html');
});
test('iOS gets a raster home-screen icon', () => {
// Safari ignores SVG for apple-touch-icon and falls back to a screenshot of
// the page, which is the blank tile this whole file exists to prevent. The
// existing `rel="icon"` SVG covers the browser tab and nothing else.
const href = INDEX_HTML.match(
/<link[^>]+rel="apple-touch-icon"[^>]+href="([^"]+)"/,
)?.[1];
assert.ok(href, 'no <link rel="apple-touch-icon"> in index.html');
assert.match(href, /\.png$/, 'apple-touch-icon must be a PNG');
assert.doesNotThrow(() => readFileSync(publicFile(href)));
});
test('the manifest declares a standalone installable app', () => {
const manifest = JSON.parse(readFileSync(publicFile(manifestHref), 'utf8'));
assert.equal(manifest.display, 'standalone');
assert.equal(manifest.start_url, '.');
assert.equal(manifest.scope, '.');
for (const field of ['name', 'short_name', 'background_color', 'theme_color'])
assert.ok(manifest[field], `manifest is missing ${field}`);
// Android's install prompt needs both, and uses the 512 for the splash screen.
const sizes = new Set(manifest.icons.map((icon) => icon.sizes));
for (const required of ['192x192', '512x512'])
assert.ok(sizes.has(required), `manifest declares no ${required} icon`);
// Without a maskable icon Android pads the square into a circle and clips
// the mark; with one it crops to the safe zone instead.
assert.ok(
manifest.icons.some((icon) => icon.purpose?.includes('maskable')),
'manifest declares no maskable icon',
);
});
test('every declared icon is a square PNG at its declared size', async () => {
const manifest = JSON.parse(readFileSync(publicFile(manifestHref), 'utf8'));
for (const icon of manifest.icons) {
const meta = await sharp(manifestFile(icon.src)).metadata();
const [width, height] = icon.sizes.split('x').map(Number);
assert.equal(meta.format, 'png', `${icon.src} is not a PNG`);
// A size the manifest overstates is worse than a missing one: the platform
// trusts the declaration and scales whatever it finds.
assert.equal(meta.width, width, `${icon.src} width`);
assert.equal(meta.height, height, `${icon.src} height`);
}
});
test('install assets stay inside a non-root Vite base', async (t) => {
// The real index.html pulls the whole Cesium app into a build, so only its
// install links are copied, verbatim, into a fixture page.
const tags = INDEX_HTML.match(
/<link[^>]+rel="(?:icon|apple-touch-icon|manifest)"[^>]*>/g,
);
assert.ok(tags?.length >= 3, 'index.html lost its install links');
const root = await makeFixtureRoot('gev-webapp-icons-');
t.after(() => rm(root, { recursive: true, force: true }));
await writeFile(
path.join(root, 'index.html'),
`<!doctype html><html><head>${tags.join('')}</head><body></body></html>`,
);
const base = '/example/';
const origin = 'https://host.test';
const result = await build({
root,
configFile: false,
envFile: false,
logLevel: 'silent',
base,
publicDir: PUBLIC_ROOT,
build: { write: false },
});
const html = result.output.find(
(item) => item.fileName === 'index.html',
).source;
const hrefs = [...html.matchAll(/<link[^>]+href="([^"]+)"/g)].map(
(match) => match[1],
);
assert.equal(hrefs.length, tags.length);
for (const href of hrefs) {
assert.ok(href.startsWith(base), `${href} escapes ${base}`);
assert.doesNotThrow(() =>
readFileSync(path.join(PUBLIC_ROOT, href.slice(base.length))),
);
}
// Vite copies public/ only when it writes, and copies it verbatim, so the
// deployed manifest is the committed one, served at the emitted href.
const manifestUrl = new URL(
html.match(/<link[^>]+rel="manifest"[^>]+href="([^"]+)"/)[1],
origin,
);
const manifest = JSON.parse(readFileSync(publicFile(manifestHref), 'utf8'));
const inBase = (value) => {
const url = new URL(value, manifestUrl);
assert.ok(
url.href.startsWith(origin + base),
`${value} resolves to ${url.href}, outside ${base}`,
);
return url;
};
assert.equal(inBase(manifest.start_url).href, origin + base);
assert.equal(inBase(manifest.scope).href, origin + base);
for (const icon of manifest.icons) {
const url = inBase(icon.src);
assert.doesNotThrow(() =>
readFileSync(path.join(PUBLIC_ROOT, url.pathname.slice(base.length))),
);
}
});