Merge remote-tracking branch 'origin/main' into HEAD

This commit is contained in:
Rudy Celekli committed 2026-10-16 23:19:12 -04:00
commit 3748e9c23a
85 files changed
+3409 -195

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
/**
* Local equirectangular distance in km, for viewer-proximity gates. Takes the
* short way round, so points either side of 180° stay close.
*/
export function approxDistanceKm(lat1, lon1, lat2, lon2) {
const dLat = (lat2 - lat1) * 111.32;
let dLonDeg = lon2 - lon1;
dLonDeg -= 360 * Math.round(dLonDeg / 360);
const dLon =
dLonDeg * 111.32 * Math.cos((((lat1 + lat2) / 2) * Math.PI) / 180);
return Math.hypot(dLat, dLon);
}
+252
View File
@@ -0,0 +1,252 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import {
fallbackHeadingFromId,
isLikelyCataloniaCoordinate,
toFiniteNumber,
} from '../../server/providers/cctv/normalize.js';
import {
loadCaltransSourcesFromOpenData,
loadTallinnSourcesFromCatalog,
loadTflSourcesFromOpenData,
parseCataloniaXml,
} from '../../server/providers/cctv/sources.js';
import { cctvProxy } from '../../server/providers/cctv.js';
import { createCatalog } from '../layers/cctv/catalog.js';
import { createModel } from '../layers/cctv/model.js';
/** Values `Number` reads as 0 (or 1) although they carry no number. */
const ABSENT = [null, undefined, '', ' ', false, true];
/** Set env vars for one test and restore them afterwards. */
function withEnv(t, env) {
for (const [name, value] of Object.entries(env)) {
const previous = process.env[name];
t.after(() => {
if (previous === undefined) delete process.env[name];
else process.env[name] = previous;
});
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
}
/** Silence the loaders' progress and failure logging. */
function quiet(t) {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
}
/** The production browser model; only the catalog's pose pass is stubbed. */
function browserModel() {
const parts = {};
parts.model = createModel({
state: {},
services: { focus: {} },
parts,
source: {},
});
return parts.model;
}
/** The browser catalog over the production model. */
function browserCatalog(model, cityPois = {}) {
return createCatalog({
state: {},
services: { locations: { CITY_POIS: cityPois } },
parts: { model: { ...model, ensureCameraPose: () => {} } },
source: {},
});
}
/** GET /api/cctv/sources through the real proxy middleware. */
async function requestSources(plugin) {
let handler;
plugin.configureServer({
middlewares: {
use(_route, fn) {
handler = fn;
},
},
});
const res = {
writeHead(status, headers) {
Object.assign(this, { status, headers });
},
end(body) {
this.body = body;
},
};
await handler({ url: '/sources', method: 'GET' }, res);
assert.equal(res.status, 200);
return JSON.parse(res.body);
}
test('absent, blank and boolean values take the fallback on both sides of the source list', () => {
const { safeNumber } = browserModel();
for (const value of ABSENT) {
assert.ok(Number.isNaN(toFiniteNumber(value)), `server ${String(value)}`);
assert.equal(toFiniteNumber(value, 15), 15, `server ${String(value)}`);
assert.ok(Number.isNaN(safeNumber(value)), `browser ${String(value)}`);
assert.equal(safeNumber(value, 74), 74, `browser ${String(value)}`);
}
for (const [value, expected] of [
[0, 0],
['0', 0],
[-17, -17],
[' 12.5 ', 12.5],
]) {
assert.equal(toFiniteNumber(value, 99), expected);
assert.equal(safeNumber(value, 99), expected);
}
});
test('a source-pack camera that omits or nulls its pose gets the browser defaults, not the clamp floors', async (t) => {
quiet(t);
const pose = {
headingDeg: null,
pitchDeg: null,
fovDeg: null,
rangeM: null,
mountHeightM: null,
groundElevationM: null,
};
const at = { cityId: 'austin', lat: 30.2672, lon: -97.7431 };
withEnv(t, {
CCTV_SOURCES_FILE: path.join(tmpdir(), 'gev-no-such-pack.json'),
CCTV_SOURCES_JSON: JSON.stringify([
{ id: 'pack-pose-omitted', ...at, feedType: 'image' },
{ id: 'pack-pose-null', ...at, ...pose, feedType: 'image' },
{ id: 'pack-coords-null', lat: null, lon: null, feedType: 'image' },
{ id: 'pack-coords-blank', lat: '', lon: ' ', feedType: 'image' },
]),
});
const body = await requestSources(cctvProxy({ sourceRoot: tmpdir() }));
const model = browserModel();
const cameras = browserCatalog(model, {
austin: { name: 'Austin', groundElevation: 150 },
}).buildCatalogFromSources(body.sources);
const byId = new Map(cameras.map((camera) => [camera.id, camera]));
for (const id of ['pack-pose-omitted', 'pack-pose-null']) {
const camera = byId.get(id);
assert.ok(camera, id);
assert.equal(camera.headingDeg, model.headingFromId(id), id);
assert.equal(camera.pitchDeg, -17, id);
assert.equal(camera.fovDeg, 74, id);
assert.equal(camera.rangeM, 700, id);
assert.equal(camera.mountHeightM, 24, id);
assert.equal(camera.groundElevationM, 150, id);
}
// No position is not a camera in the Gulf of Guinea.
assert.equal(byId.has('pack-coords-null'), false);
assert.equal(byId.has('pack-coords-blank'), false);
});
test('Tallinn rows with a null heading or ground keep the low-confidence prior', (t) => {
quiet(t);
const root = mkdtempSync(path.join(tmpdir(), 'gev-tallinn-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const file = path.join(root, 'tallinn.json');
writeFileSync(
file,
JSON.stringify([
{
id: 'tln-null-heading',
lat: 59.437,
lon: 24.753,
headingDeg: null,
groundElevationM: null,
url: 'https://ristmikud.tallinn.ee/last/cam001.jpg',
},
]),
);
withEnv(t, { CCTV_TALLINN_SOURCES_FILE: file });
const [camera] = loadTallinnSourcesFromCatalog({ sourceRoot: root });
assert.equal(camera.headingConfidence, 'low');
assert.equal(camera.headingDeg, fallbackHeadingFromId('tln-null-heading'));
assert.equal(camera.groundElevationM, 15);
});
test('TfL JamCams with null coordinates are dropped instead of placed at 0,0', async (t) => {
quiet(t);
const jamCam = (id, lat, lon) => ({
id: `JamCams_${id}`,
commonName: `JamCam ${id}`,
lat,
lon,
additionalProperties: [
{ key: 'available', value: 'true' },
{
key: 'imageUrl',
value: `https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk/${id}.jpg`,
},
],
});
t.mock.method(globalThis, 'fetch', async () =>
Response.json([
jamCam('00001.00001', 51.5072, -0.1276),
jamCam('00001.00002', null, null),
jamCam('00001.00003', 51.5072, ''),
]),
);
const cameras = await loadTflSourcesFromOpenData();
assert.deepEqual(
cameras.map((camera) => camera.id),
['tfl-00001.00001'],
);
});
test('a Catalonia feature with a blank longitude is not read as the zero meridian', () => {
const feature = (coordinates) => `<gml:featureMember>
<cite:cameres fid="cameres.1">
<gml:coordinates>${coordinates}</gml:coordinates>
<cite:link>https://example.org/cam.jpg</cite:link>
</cite:cameres>
</gml:featureMember>`;
const [blank, valid] = parseCataloniaXml(
feature(',41.5') + feature('2.17,41.39'),
);
// 0 °E at 41.5 °N is inside the Catalonia box, so a zero would pass it.
assert.ok(Number.isNaN(blank.lon));
assert.equal(isLikelyCataloniaCoordinate(blank.lat, blank.lon), false);
assert.equal(isLikelyCataloniaCoordinate(valid.lat, valid.lon), true);
});
test('a Caltrans camera with a blank elevation keeps the 150 m ground prior', async (t) => {
quiet(t);
withEnv(t, { CCTV_CALTRANS_DISTRICTS: '4' });
t.mock.method(globalThis, 'fetch', async () =>
Response.json({
data: [
{
cctv: {
inService: 'true',
location: {
latitude: '37.8044',
longitude: '-122.2712',
elevation: '',
locationName: 'TV101 -- I-880 : Test Ave',
},
imageData: {
static: {
currentImageURL:
'https://cwwp2.dot.ca.gov/data/d4/cctv/image/tv101/tv101.jpg',
},
},
},
},
],
}),
);
const [camera] = await loadCaltransSourcesFromOpenData();
assert.ok(camera);
assert.equal(camera.groundElevationM, 150);
});
+48 -1
View File
@@ -726,7 +726,15 @@ const LABEL_ICAO = 'ae1fa4';
const LABEL_POSITION = Cesium.Cartesian3.fromDegrees(-97.71, 30.21, 10_668);
/** Seed one civil contact through the production refresh seam. */
function seedLabelContact({ callsign, registration, tracked = false }) {
function seedLabelContact({
callsign,
registration,
tracked = false,
// Per-test metadata overrides, spread last so a test can set a field to
// `undefined` to model "the feed never sent it" — a default parameter
// could not express that, it would just re-supply the default.
meta: metaOverrides = {},
}) {
_setTrackedFlightRefreshStateForTest({
icao24: LABEL_ICAO,
entity: null,
@@ -752,6 +760,7 @@ function seedLabelContact({ callsign, registration, tracked = false }) {
turnRateDps: 0,
rawLat: 30.21,
rawLon: -97.71,
...metaOverrides,
},
});
}
@@ -825,6 +834,44 @@ test('civil label chain: the cockpit descriptor exposes a trimmed registration',
assert.equal(flightsLayer.getTrackedInfo()?.registration, null);
});
test('the tracked descriptor publishes the reported vertical rate', () => {
// `mapAnalystRecord` has always published `verticalRateMps`; the tracked
// seam did not, so a consumer that needs climb/descent (a flight-phase
// classifier, the cockpit) had to reach past getTrackedInfo() into the
// raw record. Both seams now answer with the same field name and unit.
seedLabelContact({
callsign: 'SWA696',
registration: 'N123AB',
tracked: true,
meta: { verticalRate: -6.4 },
});
assert.equal(flightsLayer.getTrackedInfo()?.verticalRateMps, -6.4);
assert.equal(
mapAnalystRecord(LABEL_ICAO, { verticalRate: -6.4 }).verticalRateMps,
-6.4,
'the analyst seam and the tracked seam must agree',
);
});
test('an unreported vertical rate stays null, never a level-flight zero', () => {
// Absent is not level. `stickyNumber` seeds this field with null, so a
// contact that has never transmitted a rate must not read as 0 m/s — a
// phase classifier would call that cruise while the aircraft climbs.
for (const missing of [undefined, null, Number.NaN]) {
seedLabelContact({
callsign: 'SWA696',
registration: 'N123AB',
tracked: true,
meta: { verticalRate: missing },
});
assert.equal(
flightsLayer.getTrackedInfo()?.verticalRateMps,
null,
`vertical rate ${String(missing)} must publish as null`,
);
}
});
test('civil label chain: identity stays icao24 while the label moves', () => {
for (const fixture of [
{ callsign: 'SWA696', registration: 'N123AB' },
+26
View File
@@ -257,6 +257,32 @@ test('stickyFloorCell does not stick to a far-away stale cell', () => {
assert.deepEqual(stickyFloorCell(30.2004, -97.6604, prev), { lat: 30.2, lon: -97.66 });
});
// --- Antimeridian ------------------------------------------------------------
// Raw longitude arithmetic walks a segment across 180° the long way round and
// reads a contact on the seam as half a world from its previous cell.
test('corridorFloorCells walks across the antimeridian, not the long way round', () => {
for (const [from, to, across, behind] of [
[179.999, -179.998, -179.999, 179.998],
[-179.999, 179.998, 179.999, -179.998],
]) {
const cells = corridorFloorCells([{ lat: -16.69, lon: from }, { lat: -16.69, lon: to }]);
const lons = cells.map((c) => c.lon);
assert.equal(lons[0], from);
assert.equal(lons.at(-1), to);
assert.ok(lons.includes(across), `crosses through ${across}: ${lons}`);
assert.ok(!lons.includes(behind), `never walks back through ${behind}: ${lons}`);
assert.ok(lons.every((lon) => Math.abs(lon) <= 180), `normalised: ${lons}`);
}
});
test('stickyFloorCell holds across the antimeridian', () => {
for (const [prevLon, lon] of [[180, -179.9998], [-180, 179.9998]]) {
const prev = { lat: -16.69, lon: prevLon };
assert.deepEqual(stickyFloorCell(-16.69, lon, prev), prev);
}
});
// --- F2: corridor budget fairness ------------------------------------------
// The first cut spent the budget in Map insertion order and decremented it
// BEFORE deduping, so parked contacts (whose corridor is the fix cell the poll
+9
View File
@@ -128,3 +128,12 @@ test('sampler: outside the google-3d regime nothing is sampled at all', () => {
setMeshFloorPreferred(true);
assert.equal(cachedMeshFloor(cell.lat, cell.lon), null);
});
test('sampler: a cell just across the antimeridian is still near the viewer', () => {
reset();
let calls = 0;
const scene = fakeScene(() => { calls += 1; return undefined; });
sampleMeshFloorCells(scene, [{ lat: -16.8, lon: -179.999 }], { viewerLat: -16.8, viewerLon: 179.999 });
sampleMeshFloorCells(scene, [{ lat: -16.81, lon: 179.999 }], { viewerLat: -16.81, viewerLon: -179.999 });
assert.equal(calls, 2, 'about 200 m apart, so both cells are probed');
});
+45
View File
@@ -834,3 +834,48 @@ test('two contacts matching at the same strength resolve deterministically', ()
'and the same query resolves the same way every time',
);
});
test('the military tracked descriptor publishes the reported vertical rate', () => {
// Shape parity with the civil layer's getTrackedInfo(). The two layers store
// the rate under different record keys (`verticalRateMps` here,
// `verticalRate` there), so `resolveTrackedAircraftInfo` only hands back one
// shape if BOTH descriptors map it — a consumer that reads the field off the
// civil seam and finds it missing on the military one would silently treat
// every military contact as level flight.
const seedRate = (verticalRateMps) =>
_setTrackedMilitaryRefreshStateForTest({
icao24: 'ae7f01',
entity: { gevLabelModel: { title: 'TEST', details: [] } },
billboard: {
position: Cesium.Cartesian3.fromDegrees(-97.0, 31.0, 8_000),
color: Cesium.Color.WHITE,
show: true,
},
billboardCollection: { show: true, remove() {} },
viewer: { camera: { positionCartographic: null }, scene: {} },
meta: {
callsign: 'RCH512',
rawLat: 31,
rawLon: -97,
altitudeFt: 26_000,
speedMps: 220,
track: 95,
onGround: false,
verticalRateMps,
},
});
seedRate(12.7);
assert.equal(militaryFlightsLayer.getTrackedInfo()?.verticalRateMps, 12.7);
// Absent is not level: a contact that has never transmitted a rate must read
// as null, not as a 0 m/s a phase classifier would call cruise.
for (const missing of [undefined, null, Number.NaN]) {
seedRate(missing);
assert.equal(
militaryFlightsLayer.getTrackedInfo()?.verticalRateMps,
null,
`vertical rate ${String(missing)} must publish as null`,
);
}
});
+121
View File
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { gstime } from 'satellite.js';
import { createModel } from '../layers/launches/model.js';
import rocketLaunchesLayer, {
_setRocketMissionOverlayHostForTest,
_setSelectedRocketMissionForTest,
@@ -759,6 +760,126 @@ test('normalizes detailed payload and stage recovery records', () => {
assert.equal(launches[0].recoveryStages[0].lon, -76.2);
});
test('finiteCoordinate normalizes finite numbers and rejects non-numeric or non-finite inputs', () => {
const model = createModel({});
assert.equal(model.finiteCoordinate(42.5), 42.5);
assert.equal(model.finiteCoordinate('28.5619'), 28.5619);
assert.equal(model.finiteCoordinate('-80.5772'), -80.5772);
assert.equal(model.finiteCoordinate(0), 0);
assert.equal(model.finiteCoordinate('0'), 0);
assert.equal(model.finiteCoordinate(undefined), null);
assert.equal(model.finiteCoordinate('invalid'), null);
assert.equal(model.finiteCoordinate('TBD'), null);
assert.equal(model.finiteCoordinate(Number.NaN), null);
assert.equal(model.finiteCoordinate(Number.POSITIVE_INFINITY), null);
assert.equal(model.finiteCoordinate(Number.NEGATIVE_INFINITY), null);
});
test('normalizes recovery stage coordinates, downrange distance, and flight numbers with non-finite fallbacks', () => {
const launches = normalizeRocketLaunches(
{
results: [
{
id: 'recovery-fallbacks',
name: 'Falcon 9 | Edge Case Stage Normalization',
net: '2026-07-20T10:00:00Z',
pad: { latitude: '28.608', longitude: '-80.604' },
rocket: {
launcher_stage: [
{
id: 88,
type: { name: 'Booster' },
launcher_flight_number: 'invalid-flight',
landing: {
attempt: true,
success: false,
downrange_distance: 'TBD',
landing_location: {
name: 'Ocean Impact',
latitude: 'N/A',
longitude: undefined,
},
},
},
{
id: 89,
type: { name: 'Expended Stage' },
landing: {
attempt: false,
type: { name: 'Expendable' },
},
},
],
},
},
],
},
NOW,
);
assert.equal(launches[0].recoveryStages.length, 2);
const booster = launches[0].recoveryStages[0];
assert.equal(booster.status, 'LOST');
assert.equal(booster.success, false);
assert.equal(booster.attempted, true);
assert.equal(booster.flightNumber, null);
assert.equal(booster.downrangeKm, null);
assert.equal(booster.lat, null);
assert.equal(booster.lon, null);
const expendable = launches[0].recoveryStages[1];
assert.equal(expendable.status, 'EXPENDABLE');
assert.equal(expendable.attempted, false);
assert.equal(expendable.success, null);
});
test('normalizes payload amounts and fractional string masses', () => {
const launches = normalizeRocketLaunches(
{
results: [
{
id: 'payload-edge-cases',
name: 'Electron | Mixed Payloads',
net: '2026-07-20T10:00:00Z',
pad: { latitude: '-39.26', longitude: '177.86' },
rocket: {
payloads: [
{
id: 601,
amount: 'not-a-number',
payload: { id: 11, name: 'Unknown Amount Payload' },
},
{
id: 602,
amount: 3,
payload: { id: 12, name: 'Triple Payload' },
},
{
id: 603,
payload: {
id: 13,
name: 'Valid Fractional Mass Payload',
mass: '12.75',
},
},
],
},
},
],
},
NOW,
);
// Blank, negative and non-numeric masses are pinned by "missing payload
// details stay unknown without inventing mass or classification".
const payloads = launches[0].payloads;
assert.equal(payloads.length, 3);
assert.equal(payloads[0].amount, 1);
assert.equal(payloads[1].amount, 3);
assert.equal(payloads[2].massKg, 12.75);
});
test('keeps payload and recovery collections empty when LL2 does not disclose them', () => {
const launches = normalizeRocketLaunches({ results: [{
id: 'undisclosed',
+12 -4
View File
@@ -72,19 +72,27 @@ export function sceneSeekState(scene, progress, durationForShot, holdForShot) {
let shotIndex = scene.shots.length - 1;
for (let index = 0; index < scene.shots.length; index += 1) {
const endElapsedSec = startElapsedSec + durations[index];
if (targetSec < endElapsedSec || index === scene.shots.length - 1) {
// Compare in progress units: a shot's startProgress (start / total)
// times total can round below its start and select the previous shot.
if (
normalized < endElapsedSec / totalSec ||
index === scene.shots.length - 1
) {
shotIndex = index;
break;
}
startElapsedSec = endElapsedSec;
}
// Keep the elapsed time inside the selected shot, so it agrees with
// shotIndex when targetSec rounded below the shot's start.
const elapsedSec = Math.max(targetSec, startElapsedSec);
const shot = scene.shots[shotIndex];
const flightDurationSec = shot.durationSec || DEFAULT_SHOT_DURATION_SEC;
const holdDurationSec = holdForShot(scene, shot);
const shotDurationSec = Math.max(0.001, flightDurationSec + holdDurationSec);
const shotElapsedSec = Math.max(
0,
Math.min(shotDurationSec, targetSec - startElapsedSec),
Math.min(shotDurationSec, elapsedSec - startElapsedSec),
);
const cameraProgress = clamp01(
shotElapsedSec / Math.max(0.001, flightDurationSec),
@@ -99,8 +107,8 @@ export function sceneSeekState(scene, progress, durationForShot, holdForShot) {
);
const move = resolveCameraMove(scene, shot);
return {
sceneProgress: totalSec > 0 ? targetSec / totalSec : 0,
sceneElapsedSec: targetSec,
sceneProgress: totalSec > 0 ? elapsedSec / totalSec : 0,
sceneElapsedSec: elapsedSec,
sceneDurationSec: totalSec,
shotIndex,
shot,
+36
View File
@@ -49,3 +49,39 @@ test('camera seeking preserves cubic easing and shortest-angle orientation', ()
);
assert.deepEqual(cameraAtProgress(null, camera(20, 350), 0), camera(20, 350));
});
test('seeking to a shot start lands in that shot for every shot count and duration', () => {
const misses = [];
for (let count = 2; count <= 12; count += 1) {
for (let flightTenths = 1; flightTenths <= 100; flightTenths += 1) {
for (let holdTenths = 0; holdTenths <= 50; holdTenths += 1) {
const shots = Array.from({ length: count }, (_, index) => ({
id: `s${index}`,
// Every third shot is longer, so boundaries are not all equal steps.
durationSec: flightTenths / 10 + (index % 3 === 2 ? 1.7 : 0),
holdSec: holdTenths / 10,
camera: camera(20, 0),
}));
const mixed = { shots };
for (const [index, shot] of shots.entries()) {
const { startProgress, startElapsedSec } = sceneTimingForShot(
mixed,
shot,
duration,
);
const seek = sceneSeekState(mixed, startProgress, duration, hold);
// One or two ulps earlier still belongs to the previous shot.
const before = startProgress * (1 - Number.EPSILON);
const previous = sceneSeekState(mixed, before, duration, hold);
if (
seek.shotIndex !== index ||
seek.sceneElapsedSec < startElapsedSec ||
previous.shotIndex !== Math.max(0, index - 1)
)
misses.push(`${count}x${flightTenths}/${holdTenths}#${index}`);
}
}
}
}
assert.deepEqual(misses.slice(0, 5), []);
});
+11
View File
@@ -97,12 +97,23 @@ export function createModel({ state: layerState, services, parts, source }) {
/**
* Coerces a value to a finite number or returns the fallback.
*
* Absent, blank and boolean values return the fallback as well. The source
* list serializes an unknown number (NaN) as JSON null, and `Number(null)`
* is 0, so without this an unknown pose field arrived as zero and was
* clamped to the field's floor instead of taking its default.
* @param {*} value
* @param {number} [fallback=NaN]
* @returns {number}
*/
function safeNumber(value, fallback = NaN) {
if (
value == null ||
typeof value === 'boolean' ||
(typeof value === 'string' && !value.trim())
)
return fallback;
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
+13 -2
View File
@@ -3,6 +3,12 @@ import {
FRAME_ENDPOINT,
MEDIA_ENDPOINT,
} from './sourcePolicy.js';
const FRAME_SOURCE_KINDS = new Set([
'upstream-image',
'hls-pull',
'streetview',
'synthetic',
]);
function safeNumber(value, fallback = NaN) {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
@@ -51,7 +57,7 @@ export function createCctvSource({
},
getFrameUrl: frameUrlFor,
getMediaUrl: mediaUrlFor,
/** Read one current frame through the registered frame endpoint. */
/** Read a registered frame and its provenance, including fallback frames. */
async getFrame(camera, { signal } = {}) {
signal?.throwIfAborted();
const response = await fetchImpl(frameUrlFor(camera), {
@@ -62,7 +68,12 @@ export function createCctvSource({
const bytes = new Uint8Array(await response.arrayBuffer());
signal?.throwIfAborted();
const type = response.headers.get('content-type') || '';
return { contentType: type.split(';')[0].trim().toLowerCase(), bytes };
const sourceKind = response.headers.get('x-cctv-source');
return {
contentType: type.split(';')[0].trim().toLowerCase(),
bytes,
sourceKind: FRAME_SOURCE_KINDS.has(sourceKind) ? sourceKind : null,
};
},
};
}
+23
View File
@@ -173,3 +173,26 @@ test('frames are read through the registered frame endpoint', async () => {
});
await assert.rejects(failing.getFrame(camera), /Camera frame HTTP 502/);
});
test('frame provenance admits the proxy kinds and leaves missing or unknown headers unknown', async () => {
for (const kind of [
'upstream-image',
'hls-pull',
'streetview',
'synthetic',
'other',
null,
]) {
const source = createCctvSource({
fetchImpl: async () =>
new Response(new Uint8Array([1]), {
headers: {
'Content-Type': 'image/png',
...(kind ? { 'X-CCTV-Source': kind } : {}),
},
}),
});
const frame = await source.getFrame(camera);
assert.equal(frame.sourceKind, kind === 'other' ? null : kind);
}
});
+11 -2
View File
@@ -114,7 +114,7 @@ export function createQueries({
* Build a public descriptor for one aircraft using its best current position
* (dead-reckoned when history exists, billboard position otherwise).
* @param {string} icao24 - ICAO 24-bit transponder address.
* @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null}
* @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null, verticalRateMps: number|null}|null}
* Descriptor with a cloned position, or null if the aircraft is unknown.
*/
@@ -164,6 +164,15 @@ export function createQueries({
onGround: info?.onGround === true,
velocityMps: displayed.speedMps,
track: displayed.trackDeg,
// Climb/descent rate, the one kinematic field `mapAnalystRecord` already
// published (as `verticalRateMps`) but this descriptor did not, so a
// consumer reading the tracked seam had to reach back into `_flightData`
// for it. Reported, never derived: `stickyNumber` in records.js keeps the
// last value across a blank poll, and an absent rate stays null rather
// than becoming a level-flight 0 the aircraft never transmitted.
verticalRateMps: Number.isFinite(info?.verticalRate)
? info.verticalRate
: null,
stale: Boolean(
flightState.records.missingPolls.get(icao24) ||
flightState.feed._backoff,
@@ -856,7 +865,7 @@ export function createQueries({
/**
* Describe the currently tracked aircraft at its dead-reckoned position.
* @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null}
* @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null, verticalRateMps: number|null}|null}
* Tracked aircraft info, or null when nothing is tracked.
*/
getTrackedInfo() {
+1 -6
View File
@@ -13,9 +13,4 @@ export const MISSING_POLL_LIMIT = 3;
export const ERROR_BACKOFF_INTERVAL = 20000;
/** Local equirectangular distance used for viewer-proximate floor sampling. */
export function approxDistanceKm(lat1, lon1, lat2, lon2) {
const dLat = (lat2 - lat1) * 111.32;
const dLon =
(lon2 - lon1) * 111.32 * Math.cos((((lat1 + lat2) / 2) * Math.PI) / 180);
return Math.hypot(dLat, dLon);
}
export { approxDistanceKm } from '../../data/approxDistance.js';
+18
View File
@@ -0,0 +1,18 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
approxDistanceKm,
GROUND_FLOOR_CLAMP_RADIUS_KM,
} from './recordPolicy.js';
test('approximate distance takes the short way across the antimeridian', () => {
// 0.2° of longitude at Taveuni, Fiji (16.8° S) is about 21 km.
for (const [lon1, lon2] of [
[179.9, -179.9],
[-179.9, 179.9],
]) {
const km = approxDistanceKm(-16.8, lon1, -16.8, lon2);
assert.ok(Math.abs(km - 21.31) < 0.01, `${lon1} → ${lon2}: ${km} km`);
assert.ok(km <= GROUND_FLOOR_CLAMP_RADIUS_KM);
}
});
+5 -4
View File
@@ -134,15 +134,16 @@ export function createIngestion({
let placesError = null;
if (layerState.googleSearchRequested) {
layerState.googleSearchRequested = false;
// A view across 180° has west > east; its width runs through 180°.
const lonSpan = box.east - box.west + (box.east < box.west ? 360 : 0);
const latitude = (box.south + box.north) / 2;
const longitude = (box.west + box.east) / 2;
let longitude = box.west + lonSpan / 2;
if (longitude > 180) longitude -= 360;
const radiusM = Math.min(
50000,
Math.max(
1000,
Math.round(
Math.max(box.north - box.south, box.east - box.west) * 55_000,
),
Math.round(Math.max(box.north - box.south, lonSpan) * 55_000),
),
);
try {
@@ -0,0 +1,51 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createIngestion } from './ingestion.js';
// Taveuni, Fiji: Cesium reports a view across 180° with west > east. Each view
// is 0.4° wide and 0.2° tall; the last one does not cross 180°.
for (const [west, east, centre] of [
[179.7, -179.9, 179.9],
[179.9, -179.7, -179.9],
[179.5, 179.9, 179.7],
])
test(`a nearby search is centred on the view at ${centre}`, async () => {
const box = { south: -17, west, north: -16.8, east };
const searches = [];
const state = {
enabled: true,
viewer: {},
records: [],
googleSearchRequested: true,
};
const ingestion = createIngestion({
state,
source: {
getMappedSites: async () => ({ records: [], wide: true }),
searchNearby: async (query) => {
searches.push(query);
return { places: [] };
},
},
services: {
render: { governorRequestRender() {} },
ground: { resolveGroundFloorCellsBounded: async () => {} },
},
parts: {
viewport: {
loadArea: () => ({ box, coverage: {} }),
clearUnavailableRetry() {},
scheduleUnavailableRetry() {},
},
model: { installationWithinViewport: () => true },
rendering: { renderRecords() {}, warmInstallationFloors() {} },
},
});
await ingestion.loadInstallations();
assert.equal(searches.length, 1);
const { latitude, longitude, radiusM } = searches[0];
assert.ok(Math.abs(latitude + 16.9) < 1e-9);
assert.ok(Math.abs(longitude - centre) < 1e-9, `longitude ${longitude}`);
// The width, not the height, sets the radius.
assert.equal(radiusM, 22000);
});
+9 -2
View File
@@ -88,7 +88,7 @@ export function createQueries({
* Build a public descriptor for one aircraft using its best current position
* (dead-reckoned when history exists, billboard position otherwise).
* @param {string} icao24 - ICAO hex identifier of the aircraft.
* @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null}
* @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null, verticalRateMps: number|null}|null}
* Descriptor with a cloned position, or null if the aircraft is unknown.
*/
@@ -131,6 +131,13 @@ export function createQueries({
onGround: info?.onGround === true,
velocityMps: displayed.speedMps,
track: displayed.trackDeg,
// Same field, same name, same null-when-absent rule as the civil
// descriptor — the two layers store the record under different keys
// (`verticalRateMps` here, `verticalRate` there), so only the mapping
// makes `resolveTrackedAircraftInfo` hand back one shape for both.
verticalRateMps: Number.isFinite(info?.verticalRateMps)
? info.verticalRateMps
: null,
stale: Boolean(
flightState.records.missingPolls.get(icao24) ||
flightState.feed._backoff,
@@ -777,7 +784,7 @@ export function createQueries({
/**
* Describe the currently tracked aircraft at its dead-reckoned position.
* @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null}
* @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null, verticalRateMps: number|null}|null}
* Tracked aircraft info, or null when nothing is tracked.
*/
getTrackedInfo() {
+9 -4
View File
@@ -39,8 +39,6 @@ export class MilitaryFlightRecords {
? prevMeta.altitudeFt * 0.3048
: 0
: 3048);
const track = aircraft.courseDeg || 0;
const speedMps = aircraft.speedMps ?? 0;
const verticalRateMps = aircraft.verticalRateMps;
const callsign = aircraft.callsign;
const type = aircraft.typeCode;
@@ -148,8 +146,15 @@ export class MilitaryFlightRecords {
// altitudeFt/baro).
geoAltitudeM,
renderAltitudeM,
speedMps: stickyNumber(speedMps, prevMeta?.speedMps, null),
track: stickyNumber(track, prevMeta?.track, null),
// A poll that omits track/speed holds the last values, like the civil
// records; 0 only until one is known. A grounded row without a speed
// reads 0, so a parked plane never coasts at its landing speed.
speedMps: stickyNumber(
aircraft.speedMps,
onGround ? 0 : prevMeta?.speedMps,
0,
),
track: stickyNumber(aircraft.courseDeg, prevMeta?.track, 0),
// Analyst seam (additive): sticky like the other kinematics.
verticalRateMps: stickyNumber(
verticalRateMps,
+36
View File
@@ -114,3 +114,39 @@ test('military emergency status follows the latest poll instead of sticking', ()
);
assert.equal(next.meta.emergency, null);
});
test('military records hold the last track and speed when a poll omits them', () => {
const records = store();
records.receive(observation({ courseDeg: 270, speedMps: 200 }), context());
const missing = records.receive(
observation({ courseDeg: null, speedMps: null }),
context(),
);
assert.equal(missing.meta.track, 270);
assert.equal(missing.meta.speedMps, 200);
const north = records.receive(
observation({ courseDeg: 0, speedMps: 0 }),
context(),
);
assert.equal(north.meta.track, 0);
assert.equal(north.meta.speedMps, 0);
const unseen = store().receive(
observation({ courseDeg: undefined, speedMps: undefined }),
context(),
);
assert.equal(unseen.meta.track, 0);
assert.equal(unseen.meta.speedMps, 0);
const landing = store();
landing.receive(observation({ courseDeg: 90, speedMps: 70 }), context());
const parked = landing.receive(
observation({
onGround: true,
baroAltitudeM: null,
courseDeg: null,
speedMps: null,
}),
context(),
);
assert.equal(parked.meta.speedMps, 0);
assert.equal(parked.meta.track, 90);
});
+18 -9
View File
@@ -11,20 +11,26 @@ export function containmentAccent(containedPct) {
return '#8bc34a';
}
/** Shoelace area (degree², sign dropped) — relative sizes only. */
/** Shoelace area along short longitude edges (degree², relative sizes only). */
function ringArea(ring) {
let sum = 0;
for (let i = 0; i < ring.length - 1; i++) {
const [x1, y1] = ring[i];
const [x2, y2] = ring[i + 1];
let [x1, y1] = ring[0];
for (let i = 1; i < ring.length; i++) {
let delta = ring[i][0] - ring[i - 1][0];
if (delta > 180) delta -= 360;
else if (delta < -180) delta += 360;
const x2 = x1 + delta;
const y2 = ring[i][1];
sum += x1 * y2 - x2 * y1;
x1 = x2;
y1 = y2;
}
return Math.abs(sum / 2);
}
/**
* Anchor point for the incident card: the centroid of the largest polygon's
* outer ring. Degrees, so callers own the Cartesian conversion.
* Anchor point for the incident card: the vertex mean of the largest polygon's
* outer ring, with circular longitude. Callers own the Cartesian conversion.
* @param {Array<Array<Array<[number, number]>>>} polygons - Normalized rows' polygons.
* @returns {{lon: number, lat: number}}
*/
@@ -41,14 +47,17 @@ export function perimeterAnchorDegrees(polygons) {
}
// Vertex mean over the closed ring (first == last point dropped): perimeters
// are compact enough that this stays inside or near the polygon.
let lon = 0;
let sinLon = 0;
let cosLon = 0;
let lat = 0;
const count = best.length - 1;
const toRad = Math.PI / 180;
for (let i = 0; i < count; i++) {
lon += best[i][0];
sinLon += Math.sin(best[i][0] * toRad);
cosLon += Math.cos(best[i][0] * toRad);
lat += best[i][1];
}
return { lon: lon / count, lat: lat / count };
return { lon: Math.atan2(sinLon, cosLon) / toRad, lat: lat / count };
}
/**
+68 -1
View File
@@ -16,12 +16,79 @@ const tiny = [
[-107.01, 34.0],
];
test('the card anchors at the centroid of the largest polygon', () => {
test('the card anchors at the vertex mean of the largest polygon', () => {
const anchor = perimeterAnchorDegrees([[tiny], [square]]);
assert.ok(Math.abs(anchor.lon - -108.1) < 1e-9);
assert.ok(Math.abs(anchor.lat - 35.1) < 1e-9);
});
test('incident cards stay at the dateline for closed, reversed and rotated outlines', () => {
const vertices = [
[179, 10],
[-179, 10],
[-179, 12],
[179, 12],
];
for (let start = 0; start < vertices.length; start++) {
const rotated = [...vertices.slice(start), ...vertices.slice(0, start)];
for (const points of [rotated, [...rotated].reverse()]) {
const anchor = perimeterAnchorDegrees([[points.concat([points[0]])]]);
assert.ok(Math.abs(Math.abs(anchor.lon) - 180) < 1e-9);
assert.ok(Math.abs(anchor.lat - 11) < 1e-9);
}
}
});
test('incident card longitude treats vertices at either sign of 180 as the same meridian', () => {
for (const boundary of [-180, 180]) {
const ring = [
[179, 10],
[boundary, 10],
[-179, 10],
[-179, 12],
[179, 12],
[179, 10],
];
const anchor = perimeterAnchorDegrees([[ring]]);
assert.ok(Math.abs(Math.abs(anchor.lon) - 180) < 1e-9);
assert.ok(Math.abs(anchor.lat - 10.8) < 1e-9);
}
});
test('multipart cards compare polygon areas along short dateline edges', () => {
const ordinary = [
[10, 20],
[11, 20],
[11, 21],
[10, 21],
[10, 20],
];
for (const width of [0.2, 2]) {
const crossing = [
[180 - width / 2, 10],
[-180 + width / 2, 10],
[-180 + width / 2, 10 + width],
[180 - width / 2, 10 + width],
[180 - width / 2, 10],
];
for (const ring of [crossing, [...crossing].reverse()]) {
for (const polygons of [
[[ring], [ordinary]],
[[ordinary], [ring]],
]) {
const anchor = perimeterAnchorDegrees(polygons);
if (width < 1) {
assert.ok(Math.abs(anchor.lon - 10.5) < 1e-9);
assert.ok(Math.abs(anchor.lat - 20.5) < 1e-9);
} else {
assert.ok(Math.abs(Math.abs(anchor.lon) - 180) < 1e-9);
assert.ok(Math.abs(anchor.lat - 11) < 1e-9);
}
}
}
}
});
test('a full incident row renders name, size, containment, and ages', () => {
const now = 1758000000000;
const card = buildIncidentCard(
@@ -97,6 +97,8 @@ export function createGoogleViewer({ loader, getService, render } = {}) {
service ||= getService
? await getService()
: new (await ensureLibrary()).StreetViewService();
if (!current())
throw new Error('Street View details lookup was released');
return service.getPanorama({ pano: panoId });
})().then(resolve, reject);
});
@@ -236,6 +238,7 @@ export function createGoogleViewer({ loader, getService, render } = {}) {
const panoId = String(imageId);
if (!host) throw new Error('Street View is not mounted');
if (loader.authFailed()) throw new Error(KEY_REJECTED_MESSAGE);
cancelShown?.();
pendingOpen = panoId;
const instance = await ensurePanorama(host);
if (pendingOpen !== panoId) return;
@@ -403,3 +403,69 @@ test('a remount looks up again a panorama whose lookup hung', async () => {
await settle();
assert.equal(poses.length, count);
});
test('superseded panorama waits cannot steal the current close cleanup', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { OPEN_TIMEOUT_MS } = await import('./policy.js');
const flush = async () => {
for (let i = 0; i < 10; i++) await Promise.resolve();
};
const { fake, loader, viewer, host } = setup();
await viewer.mount(host);
const [panorama] = fake.built;
panorama.setPano = (id) => {
panorama.pano = id;
};
const first = viewer.open('pano-a');
await flush();
const second = viewer.open('pano-b');
await flush();
panorama.fire('position_changed'); // new panorama has not reached OK
await flush();
viewer.close();
const remaining = loader.listenerCount();
t.mock.timers.tick(OPEN_TIMEOUT_MS);
await Promise.allSettled([first, second]);
assert.equal(
remaining,
0,
'close disposes every pending open without waiting 20 seconds',
);
assert.equal(panorama.visible, false);
});
test('unmount during details-service acquisition makes no later billed lookup', async () => {
const fake = fakeStreetViewLibrary({ panoramas: PANORAMAS });
const loader = fakeMapsLoader(fake.library);
const { createGoogleProvider } = await import('./index.js');
let release;
const acquiring = new Promise((resolve) => {
release = resolve;
});
const importLibrary = loader.importLibrary.bind(loader);
let imports = 0;
loader.importLibrary = async (name) => {
if (++imports > 1) await acquiring;
return importLibrary(name);
};
const provider = createGoogleProvider({
getApiKey: () => 'fixture-key',
loader,
});
const instance = provider.create({
notify() {},
actions: { reportError() {} },
});
const viewer = instance.viewer;
await viewer.mount(fakeHost());
await viewer.open('pano-a');
viewer.unmount();
release();
for (let i = 0; i < 10; i++) await Promise.resolve();
assert.equal(
fake.lookups.length,
0,
'teardown invalidates the request before calling getPanorama',
);
instance.destroy();
});
+191 -8
View File
@@ -9,6 +9,18 @@ import { acquireImageryComparison } from './imageryComparison.js';
*/
const AUTOMATIC = Symbol('automatic switch');
/** Background retries of a fallback in use back off from 30 s to 10 min. */
const FALLBACK_RETRY_MS = 30_000;
const FALLBACK_RETRY_MAX_MS = 600_000;
/** A retry attempt still unsettled after a minute counts as failed. */
const FALLBACK_ATTEMPT_MS = 60_000;
const resolved = (provider, effectiveStackId) => ({
provider,
effectiveStackId,
fallbackMessage: null,
});
/** Coordinate source lifetimes and scene changes; the registry owns provider choices. */
export class MapSourceController {
constructor(
@@ -46,8 +58,18 @@ export class MapSourceController {
this._lastError = null;
this._imageryLayer = null;
this._activeImageryProvider = null;
this._activeResolution = null;
this._removeImageryErrorListener = null;
this._terrainMode = null;
this._terrainFallback = null;
this._retryTimer = null;
this._retrying = false;
this._retryRequested = false;
this._retryDelay = FALLBACK_RETRY_MS;
/** Expire callbacks of retry attempts in flight; destroy() runs them. */
this._retryDeadlines = new Set();
/** A fallback terrain a retry replaced while it stayed installed. */
this._replacedTerrain = null;
this._subscribers = new Set();
this._destroyed = false;
}
@@ -285,21 +307,171 @@ export class MapSourceController {
this._showTileset(null);
this.viewer.scene.globe.show = true;
this._activeId = resolution.effectiveStackId;
this._activeResolution = resolution;
if (source?.terrain && source.terrain.id !== this._terrainMode) {
const terrain = source.terrain;
const result = await this._cached(
this._terrainProviders,
terrain.id,
() => terrain.create({ signal: this._abort.signal }),
const pending = this._cached(this._terrainProviders, terrain.id, () =>
terrain.create({ signal: this._abort.signal }),
);
let result = await pending;
if (gen !== this._switchGen) return;
if (result.terrain) this.viewer.scene.setTerrain(result.terrain);
else this.viewer.terrainProvider = result.provider;
// A background retry may have replaced a fallback while this waited.
const latest = this._terrainProviders.get(terrain.id);
if (latest !== pending) result = await latest;
if (gen !== this._switchGen) return;
this._applyTerrain(result);
this._terrainMode = terrain.id;
}
if (resolution.fallbackFor || this._terrainFallback) this._scheduleRetry();
return resolution;
}
_applyTerrain(result) {
if (result.terrain) this.viewer.scene.setTerrain(result.terrain);
else this.viewer.terrainProvider = result.provider;
this._terrainFallback = result.fallback ? result : null;
if (this._replacedTerrain && this._replacedTerrain !== result) {
this._dispose(this._replacedTerrain.provider);
this._replacedTerrain = null;
}
}
/**
* One timer retries the real provider behind whichever fallback is on
* screen: Esri's OSM stand-in, or flat terrain on a shown globe. Fallbacks
* stay cached and shown meanwhile, so no switch waits on a retry; a
* fallback out of use is left alone until an activation shows it again.
* One retry runs at a time; a request made meanwhile re-arms once it ends.
*/
_scheduleRetry() {
if (this._retryTimer || this._destroyed) return;
if (this._retrying) {
this._retryRequested = true;
return;
}
this._retryTimer = setTimeout(() => {
this._retryTimer = null;
void this._retryFallbacks();
}, this._retryDelay);
}
async _retryFallbacks() {
const resolution = this._activeResolution;
const flat = this.viewer.scene.globe.show ? this._terrainFallback : null;
const id = resolution?.fallbackFor;
let terrain = flat && this._sources.get(this._activeId)?.terrain;
if (terrain?.id !== this._terrainMode) terrain = null;
if (!id && !terrain) return;
this._retrying = true;
this._retryRequested = false;
// An attempt that outlives its deadline counts as a failure. Its signal is
// aborted, but the Esri and keyless factories ignore it and Cesium's
// requests have no timeout, so a late result is released, never used.
const attempt = async (create, release) => {
const expiry = new AbortController();
let expire;
const deadline = new Promise((resolve) => {
expire = () => {
clearTimeout(timer);
resolve(expiry);
};
});
const timer = setTimeout(expire, FALLBACK_ATTEMPT_MS);
this._retryDeadlines.add(expire);
const pending = Promise.resolve().then(() =>
create?.({
signal: AbortSignal.any([this._abort.signal, expiry.signal]),
}),
);
try {
const result = await Promise.race([pending, deadline]);
if (result !== expiry) return result;
expiry.abort();
pending.then(release, () => {});
return null;
} catch {
return null; // Still unavailable: keep the fallback.
} finally {
clearTimeout(timer);
this._retryDeadlines.delete(expire);
}
};
// Each recovers as soon as it loads, never waiting on the other.
const retryImagery = async () => {
const cached = this._imageryProviders.get(id);
const provider = await attempt(this._sources.get(id).imagery, (late) =>
this._dispose(late),
);
// Obsolete: destroyed, or the cache moved on while this was in flight.
if (this._destroyed || this._imageryProviders.get(id) !== cached) {
this._dispose(provider);
return true;
}
if (!provider) return false;
this._imageryProviders.set(id, Promise.resolve(resolved(provider, id)));
// Still showing the fallback once any switch settles: use the real one.
const recover = () => {
if (this._activeResolution === resolution)
void this.setStack(id, { [AUTOMATIC]: true });
};
if (!this._isSwitching) recover();
else {
const stop = this.subscribe(() => {
stop();
recover();
});
}
return true;
};
const retryTerrain = async () => {
const cached = this._terrainProviders.get(terrain.id);
const real = await attempt(terrain.create, (late) =>
this._dispose(late?.provider),
);
if (
this._destroyed ||
this._terrainProviders.get(terrain.id) !== cached ||
!real ||
real.fallback
) {
this._dispose(real?.provider);
return Boolean(real && !real.fallback);
}
this._terrainProviders.set(terrain.id, Promise.resolve(real));
if (this._terrainFallback === flat && this.viewer.scene.globe.show) {
this._applyTerrain(real);
this._dispose(flat.provider);
this._requestRender('map-stack');
} else {
this._terrainMode = null; // The next globe activation installs it.
// Release the replaced fallback once nothing shows it.
if (this._terrainFallback === flat) this._replacedTerrain = flat;
else this._dispose(flat.provider);
}
return true;
};
const settled = (retry) =>
retry().catch((error) => {
console.warn('[MapSourceController] fallback recovery failed:', error);
return false;
});
let failed = false;
try {
const results = await Promise.all([
id && settled(retryImagery),
terrain && settled(retryTerrain),
]);
failed = results.includes(false);
} finally {
this._retrying = false;
}
if (this._destroyed) return;
this._retryDelay = failed
? Math.min(this._retryDelay * 2, FALLBACK_RETRY_MAX_MS)
: FALLBACK_RETRY_MS;
if (failed || this._retryRequested) this._scheduleRetry();
}
_cached(cache, id, create) {
if (cache.has(id)) return cache.get(id);
const promise = Promise.resolve()
@@ -323,7 +495,7 @@ export class MapSourceController {
try {
const provider = await source.imagery({ signal: this._abort.signal });
if (this._destroyed) this._dispose(provider);
return { provider, effectiveStackId: stack.id, fallbackMessage: null };
return resolved(provider, stack.id);
} catch (error) {
const fallback = source.constructionFallback;
if (this._destroyed || !fallback || !this.isStackAvailable(fallback.id))
@@ -333,7 +505,11 @@ export class MapSourceController {
this.getStack(fallback.id),
next,
);
return { ...resolution, fallbackMessage: fallback.message };
return {
...resolution,
fallbackMessage: fallback.message,
fallbackFor: stack.id,
};
}
});
}
@@ -388,6 +564,7 @@ export class MapSourceController {
this.viewer.imageryLayers.remove(this._imageryLayer, true);
this._imageryLayer = null;
this._activeImageryProvider = null;
this._activeResolution = null;
}
_dispose(value) {
if (!value || this._disposed.has(value)) return;
@@ -401,6 +578,12 @@ export class MapSourceController {
this._subscribers.clear();
this._switchGen++;
this._abort.abort();
clearTimeout(this._retryTimer);
for (const expire of this._retryDeadlines) expire();
this._retryDeadlines.clear();
this._retrying = false;
this._dispose(this._replacedTerrain?.provider);
this._replacedTerrain = null;
this._isSwitching = false;
this._removeImageryLayer();
this._credits.destroy();
+545 -2
View File
@@ -70,7 +70,7 @@ const settle = async () => {
for (let i = 0; i < 20; i++) await Promise.resolve();
};
const descriptor = (id) => ({ id, label: id, kind: 'imagery' });
function publicFixture() {
function publicFixture(options) {
const tileset = { show: true };
const registry = createDefaultMapSources({ googleTileset: tileset });
const providers = new Map();
@@ -84,7 +84,7 @@ function publicFixture() {
create: async () => ({ provider: { id: 'terrain' } }),
};
}
return { ...fixture(registry), registry, providers, tileset };
return { ...fixture(registry, options), registry, providers, tileset };
}
test('an additional imagery source needs no controller branch and owns its cached resources', async () => {
@@ -839,3 +839,546 @@ test('each switch generation reports its origin: an outside setStack is manual,
]);
recovering.controller.destroy();
});
function failingEsri(env) {
const esri = { failing: true, attempts: 0, next: null };
env.registry.sources.find(
(source) => source.descriptor.id === 'esri-imagery',
).imagery = async () => {
esri.attempts++;
if (esri.next) await esri.next;
if (esri.failing) throw new Error('Esri unreachable');
return env.providers.get('esri-imagery');
};
return esri;
}
const deferred = () => {
let resolve;
const promise = new Promise((done) => (resolve = done));
return { promise, resolve };
};
test('a failed Esri construction shows OSM at once, backs off while retries fail, and recovers in place', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const env = publicFixture();
const esri = failingEsri(env);
await env.controller.setStack('esri-imagery');
assert.equal(env.controller.getActiveId(), 'osm');
const osmLayer = env.imagery.at(-1);
t.mock.timers.tick(30_000);
await settle();
assert.equal(esri.attempts, 2, 'one background retry');
assert.equal(env.imagery.at(-1), osmLayer, 'a failed retry keeps the layer');
await env.controller.setStack('osm');
await env.controller.setStack('esri-imagery');
assert.equal(esri.attempts, 2, 'a reselection does not retry at once');
t.mock.timers.tick(30_000);
await settle();
assert.equal(esri.attempts, 2, 'the second retry waits twice as long');
t.mock.timers.tick(30_000);
await settle();
assert.equal(esri.attempts, 3);
esri.failing = false;
t.mock.timers.tick(120_000);
await settle();
assert.equal(esri.attempts, 4);
assert.equal(env.controller.getActiveId(), 'esri-imagery');
assert.equal(env.controller.getSwitchOrigin(), 'automatic');
assert.equal(env.controller.getState().lastError, null);
assert.equal(env.imagery.at(-1).provider, env.providers.get('esri-imagery'));
t.mock.timers.tick(600_000);
await settle();
assert.equal(esri.attempts, 4, 'the real provider ends the retries');
env.controller.destroy();
});
test('retries of a fallback that keeps failing back off to a 10 minute cap', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const env = publicFixture();
const esri = failingEsri(env);
await env.controller.setStack('esri-imagery');
for (const delay of [30, 60, 120, 240, 480, 600, 600]) {
t.mock.timers.tick(delay * 1000);
await settle();
}
assert.equal(esri.attempts, 8);
env.controller.destroy();
});
test('Esri is retried only while its fallback is shown', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const env = publicFixture();
const esri = failingEsri(env);
await env.controller.setStack('esri-imagery');
await env.controller.setStack('osm');
t.mock.timers.tick(30_000);
await settle();
assert.equal(esri.attempts, 1, 'no retry while OSM was chosen');
await env.controller.setStack('esri-imagery');
assert.equal(esri.attempts, 1, 'the cached fallback shows at once');
assert.equal(env.controller.getActiveId(), 'osm');
esri.failing = false;
t.mock.timers.tick(30_000);
await settle();
assert.equal(esri.attempts, 2);
assert.equal(env.controller.getActiveId(), 'esri-imagery');
env.controller.destroy();
});
test('an Esri recovery that lands mid-switch applies once the switch settles', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const env = publicFixture();
const esri = failingEsri(env);
await env.controller.setStack('esri-imagery');
const retry = deferred();
esri.next = retry.promise;
esri.failing = false;
t.mock.timers.tick(30_000);
await settle();
// The fallback activates OSM's terrain; make that switch slow.
const slow = deferred();
env.registry.sources.find(
(source) => source.descriptor.id === 'osm',
).terrain = { id: 'slow', create: () => slow.promise };
const switching = env.controller.setStack('esri-imagery');
retry.resolve();
await settle();
assert.equal(env.controller.getActiveId(), 'osm', 'the switch is still open');
slow.resolve({ provider: { id: 'slow' } });
await switching;
await settle();
assert.equal(env.controller.getActiveId(), 'esri-imagery');
assert.equal(env.imagery.at(-1).provider, env.providers.get('esri-imagery'));
env.controller.destroy();
});
function terrainFixture(t, create) {
t.mock.timers.enable({ apis: ['setTimeout'] });
let renders = 0;
const env = publicFixture({ requestRender: () => renders++ });
const installed = [];
Object.defineProperty(env.viewer, 'terrainProvider', {
get: () => installed.at(-1),
set: (provider) => installed.push(provider),
});
const terrain = { id: 'keyless', calls: 0 };
terrain.create = () => create(++terrain.calls);
for (const source of env.registry.sources)
if (source.imagery) source.terrain = terrain;
return { env, installed, terrain, renders: () => renders };
}
const flatTerrain = (n) => ({ provider: { id: `flat-${n}` }, fallback: true });
test('flat fallback terrain stays installed while retries run, and real terrain replaces it live', async (t) => {
const real = { id: 'real' };
let working = false;
const { env, installed, terrain, renders } = terrainFixture(t, async (n) =>
working ? { provider: real } : flatTerrain(n),
);
await env.controller.setStack('esri-imagery');
const [flat] = installed;
t.mock.timers.tick(30_000);
await settle();
assert.equal(terrain.calls, 2, 'one background retry');
await env.controller.setStack('osm');
assert.deepEqual(installed, [flat], 'failed retries and switches keep it');
working = true;
const before = renders();
t.mock.timers.tick(60_000);
await settle();
assert.equal(terrain.calls, 3);
assert.deepEqual(installed, [flat, real]);
assert.equal(renders(), before + 1, 'the live swap requests a render');
t.mock.timers.tick(600_000);
await settle();
assert.equal(terrain.calls, 3, 'the real terrain ends the retries');
env.controller.destroy();
});
test('terrain is not retried or installed while a 3D tileset hides the globe', async (t) => {
const real = { id: 'real' };
let working = false;
const { env, installed, terrain } = terrainFixture(t, async (n) =>
working ? { provider: real } : flatTerrain(n),
);
await env.controller.setStack('esri-imagery');
await env.controller.setStack('photoreal');
t.mock.timers.tick(30_000);
await settle();
assert.equal(terrain.calls, 1, 'no retry behind a hidden globe');
await env.controller.setStack('esri-imagery');
const hidden = deferred();
terrain.create = async () => {
terrain.calls++;
await hidden.promise;
return { provider: real };
};
t.mock.timers.tick(30_000);
await settle();
assert.equal(terrain.calls, 2, 'the globe activation re-arms the retry');
await env.controller.setStack('photoreal');
hidden.resolve();
await settle();
assert.equal(installed.length, 1, 'nothing is installed behind a tileset');
await env.controller.setStack('osm');
assert.equal(installed.at(-1), real, 'the next globe activation installs it');
assert.equal(terrain.calls, 2);
env.controller.destroy();
});
test('a switch that read the flat fallback installs the terrain a retry just cached', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
// Sweep the microtask distance between the retry landing and the switch.
for (let hops = 0; hops < 12; hops++) {
const real = { id: 'real' },
gate = deferred();
const keyless = {
id: 'keyless',
create: async () => {
if (!keyless.retried) return flatTerrain(1);
await gate.promise;
return { provider: real };
},
};
const other = { id: 'other', create: async () => ({ provider: {} }) };
const source = (id, terrain) => ({
descriptor: descriptor(id),
imagery: async () => ({ id }),
terrain,
});
const env = fixture({
defaultId: 'a',
sources: [source('a', keyless), source('c', other)],
});
const installed = [];
Object.defineProperty(env.viewer, 'terrainProvider', {
get: () => installed.at(-1),
set: (provider) => installed.push(provider),
});
await env.controller.setStack('a');
keyless.retried = true;
t.mock.timers.tick(30_000);
await settle();
await env.controller.setStack('c');
gate.resolve();
for (let i = 0; i < hops; i++) await null;
await env.controller.setStack('a');
await settle();
assert.equal(installed.at(-1), real, `hops ${hops}`);
env.controller.destroy();
}
});
test('destroy cancels a pending retry and releases one in flight', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const env = publicFixture();
const esri = failingEsri(env);
await env.controller.setStack('esri-imagery');
env.controller.destroy();
t.mock.timers.tick(30_000);
await settle();
assert.equal(esri.attempts, 1, 'a pending retry is cancelled');
const late = publicFixture();
const lateEsri = failingEsri(late);
await late.controller.setStack('esri-imagery');
const retry = deferred();
lateEsri.next = retry.promise;
lateEsri.failing = false;
let destroyed = 0;
late.providers.get('esri-imagery').destroy = () => destroyed++;
t.mock.timers.tick(30_000);
await settle();
late.controller.destroy();
retry.resolve();
await settle();
assert.equal(destroyed, 1, 'a late provider is released');
assert.equal(late.imagery.length, 0);
t.mock.timers.tick(600_000);
await settle();
assert.equal(lateEsri.attempts, 2);
});
test('retries stay single-flight when an activation re-shows the fallback mid-retry', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const env = publicFixture();
const created = [],
pending = [];
let attempts = 0;
env.registry.sources.find(
(source) => source.descriptor.id === 'esri-imagery',
).imagery = async () => {
if (++attempts === 1) throw new Error('Esri unreachable');
const gate = deferred();
pending.push(gate);
await gate.promise;
const provider = { id: `esri-${attempts}`, errorEvent: event() };
provider.destroy = () => (provider.destroyed = true);
created.push(provider);
return provider;
};
await env.controller.setStack('esri-imagery');
t.mock.timers.tick(30_000);
await settle();
assert.equal(attempts, 2, 'retry #1 is in flight');
await env.controller.setStack('osm');
await env.controller.setStack('esri-imagery');
assert.equal(env.controller.getActiveId(), 'osm', 'the fallback is shown');
t.mock.timers.tick(30_000);
await settle();
assert.equal(attempts, 2, 'no second retry while one is in flight');
for (const gate of pending.reverse()) {
gate.resolve();
await settle();
}
const shown = env.imagery.at(-1).provider;
assert.equal(env.controller.getActiveId(), 'esri-imagery');
await env.controller.setStack('osm');
await env.controller.setStack('esri-imagery');
assert.equal(env.imagery.at(-1).provider, shown, 'the cached one is shown');
env.controller.destroy();
await settle();
assert.ok(created.length > 0);
for (const provider of created)
assert.equal(provider.destroyed, true, `${provider.id} is disposed`);
});
test('a stalled terrain retry does not hold back recovered imagery', async (t) => {
const stalled = deferred();
const { env, terrain } = terrainFixture(t, async (n) => {
if (n > 1) await stalled.promise;
return flatTerrain(n);
});
const esri = failingEsri(env);
await env.controller.setStack('esri-imagery');
assert.equal(env.controller.getActiveId(), 'osm');
esri.failing = false;
t.mock.timers.tick(30_000);
await settle();
assert.equal(terrain.calls, 2, 'the terrain retry is in flight');
assert.equal(env.controller.getActiveId(), 'esri-imagery');
assert.equal(env.imagery.at(-1).provider, env.providers.get('esri-imagery'));
stalled.resolve();
await settle();
env.controller.destroy();
});
test('a stalled imagery retry does not hold back recovered terrain', async (t) => {
const real = { id: 'real' };
const { env, installed } = terrainFixture(t, async (n) =>
n > 1 ? { provider: real } : flatTerrain(n),
);
const esri = failingEsri(env);
await env.controller.setStack('esri-imagery');
const stalled = deferred();
esri.next = stalled.promise;
t.mock.timers.tick(30_000);
await settle();
assert.equal(esri.attempts, 2, 'the imagery retry is in flight');
assert.equal(installed.at(-1), real, 'real terrain is installed');
stalled.resolve();
await settle();
env.controller.destroy();
});
test('a retry attempt that never settles is abandoned at its deadline and released if it lands late', async (t) => {
const hung = deferred();
const late = { id: 'late', destroy: () => (late.destroyed = true) };
const { env, installed, terrain } = terrainFixture(t, async (n) => {
if (n === 2) await hung.promise;
return n === 2 ? { provider: late } : flatTerrain(n);
});
const esri = failingEsri(env);
await env.controller.setStack('esri-imagery');
t.mock.timers.tick(30_000);
await settle();
assert.equal(esri.attempts, 2, 'imagery failed');
assert.equal(terrain.calls, 2, 'terrain hangs');
t.mock.timers.tick(60_000); // The hung attempt's deadline.
await settle();
t.mock.timers.tick(60_000); // The doubled backoff.
await settle();
assert.equal(esri.attempts, 3, 'imagery is retried again');
assert.equal(terrain.calls, 3, 'terrain is retried again');
hung.resolve();
await settle();
assert.equal(late.destroyed, true, 'the late provider is released');
assert.ok(!installed.includes(late), 'and never installed');
env.controller.destroy();
});
test('a failed retry after a mid-retry re-show re-arms once, after the doubled delay', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const env = publicFixture();
const esri = failingEsri(env);
await env.controller.setStack('esri-imagery');
const gate = deferred();
esri.next = gate.promise;
t.mock.timers.tick(30_000);
await settle();
await env.controller.setStack('osm');
await env.controller.setStack('esri-imagery');
gate.resolve();
await settle();
assert.equal(esri.attempts, 2);
t.mock.timers.tick(59_999);
await settle();
assert.equal(esri.attempts, 2, 'not before the doubled delay');
t.mock.timers.tick(1);
await settle();
assert.equal(esri.attempts, 3, 'one retry after it');
t.mock.timers.tick(119_999);
await settle();
assert.equal(esri.attempts, 3, 'no second timer was armed');
env.controller.destroy();
});
test('destroy releases recovered imagery and terrain that lands afterwards', async (t) => {
const stalled = deferred();
const real = { id: 'real', destroy: () => (real.destroyed = true) };
const { env, installed } = terrainFixture(t, async (n) => {
if (n === 1) return flatTerrain(n);
await stalled.promise;
return { provider: real };
});
const esri = failingEsri(env);
const imagery = env.providers.get('esri-imagery');
imagery.destroy = () => (imagery.destroyed = true);
await env.controller.setStack('esri-imagery');
esri.failing = false;
t.mock.timers.tick(30_000);
await settle();
assert.equal(env.imagery.at(-1).provider, imagery, 'imagery has landed');
env.controller.destroy();
stalled.resolve();
await settle();
assert.equal(imagery.destroyed, true);
assert.equal(real.destroyed, true);
assert.ok(!installed.includes(real));
});
function liveTimers(t) {
const live = new Set();
const { setTimeout: set, clearTimeout: clear } = globalThis;
globalThis.setTimeout = (fn, ms, ...args) => {
const id = set(
(...values) => {
live.delete(id);
fn(...values);
},
ms,
...args,
);
live.add(id);
return id;
};
globalThis.clearTimeout = (id) => {
live.delete(id);
clear(id);
};
t.after(() =>
Object.assign(globalThis, { setTimeout: set, clearTimeout: clear }),
);
return live;
}
test('destroy during a hung retry attempt clears its deadline at once', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const live = liveTimers(t);
const env = publicFixture();
const esri = failingEsri(env);
await env.controller.setStack('esri-imagery');
esri.next = new Promise(() => {});
t.mock.timers.tick(30_000);
await settle();
assert.equal(esri.attempts, 2, 'the attempt hangs');
assert.equal(live.size, 1, 'only its deadline is armed');
env.controller.destroy();
assert.equal(live.size, 0, 'destroy clears the deadline');
assert.equal(env.controller._retrying, false);
await settle();
assert.equal(live.size, 0, 'and nothing re-arms');
});
function destroyableFlat(n) {
const provider = { id: `flat-${n}` };
provider.destroy = () => (provider.destroyed = true);
return { provider, fallback: true };
}
test('terrain recovered on screen releases the fallback it replaced', async (t) => {
const real = { id: 'real' };
let flat;
const { env, installed } = terrainFixture(t, async (n) =>
n === 1 ? (flat = destroyableFlat(n)) : { provider: real },
);
await env.controller.setStack('esri-imagery');
t.mock.timers.tick(30_000);
await settle();
assert.equal(installed.at(-1), real);
assert.equal(flat.provider.destroyed, true);
env.controller.destroy();
});
test('terrain recovered behind a tileset releases the fallback once a globe replaces it', async (t) => {
const real = { id: 'real' };
const gate = deferred();
let flat;
const { env, installed } = terrainFixture(t, async (n) => {
if (n === 1) return (flat = destroyableFlat(n));
await gate.promise;
return { provider: real };
});
await env.controller.setStack('esri-imagery');
t.mock.timers.tick(30_000);
await settle();
await env.controller.setStack('photoreal');
gate.resolve();
await settle();
assert.equal(installed.at(-1), flat.provider);
assert.ok(!flat.provider.destroyed, 'still installed behind the tileset');
await env.controller.setStack('osm');
assert.equal(installed.at(-1), real);
assert.equal(flat.provider.destroyed, true);
env.controller.destroy();
});
test('a hung imagery attempt that lands after its deadline is released, never cached or shown', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const env = publicFixture();
const hung = deferred();
const late = { id: 'late', errorEvent: event() };
const good = { id: 'good', errorEvent: event() };
for (const provider of [late, good])
provider.destroy = () => (provider.destroyed = true);
let attempts = 0;
env.registry.sources.find(
(source) => source.descriptor.id === 'esri-imagery',
).imagery = async () => {
if (++attempts === 1) throw new Error('Esri unreachable');
if (attempts === 2) {
await hung.promise;
return late;
}
return good;
};
await env.controller.setStack('esri-imagery');
t.mock.timers.tick(30_000);
await settle();
t.mock.timers.tick(60_000); // The deadline.
await settle();
hung.resolve();
await settle();
assert.equal(late.destroyed, true, 'the late provider is released');
assert.equal(env.controller.getActiveId(), 'osm', 'and not shown');
t.mock.timers.tick(60_000);
await settle();
assert.equal(attempts, 3);
assert.equal(env.imagery.at(-1).provider, good, 'the next retry installs');
await env.controller.setStack('osm');
await env.controller.setStack('esri-imagery');
assert.equal(env.imagery.at(-1).provider, good, 'only its own is cached');
env.controller.destroy();
await settle();
assert.equal(good.destroyed, true);
});
+10
View File
@@ -317,3 +317,13 @@ test('both Google 3D routes keep tiles drawing while draped imagery loads', asyn
assert.equal(calls[1][1].asynchronouslyLoadImagery, true);
assert.equal(calls[1][1].enableCollision, true);
});
test('unreachable keyless terrain falls back to flat terrain marked as a fallback', async (t) => {
t.mock.method(Cesium.CesiumTerrainProvider, 'fromUrl', async () => {
throw new Error('layer.json unreachable');
});
t.mock.method(console, 'warn', () => {});
const result = await createKeylessTerrain();
assert.ok(result.provider instanceof Cesium.EllipsoidTerrainProvider);
assert.equal(result.fallback, true);
});
+1 -1
View File
@@ -59,6 +59,6 @@ export async function createKeylessTerrain() {
'[MapStack] Re:Earth terrain unavailable, falling back to flat ellipsoid terrain:',
error,
);
return { provider: new Cesium.EllipsoidTerrainProvider() };
return { provider: new Cesium.EllipsoidTerrainProvider(), fallback: true };
}
}
+26
View File
@@ -126,6 +126,32 @@ test('production reader rejects oversized, malformed and runtime-failure bodies'
}
});
test('element data that mentions a failure phrase is data, and sets no cooldown', async () => {
const names = ['Timed Out Escape Rooms', 'Runtime Error Bar', 'Out of Memory Lane', 'Too Many Requests Cafe', 'rate_limited'];
for (const [i, name] of names.entries()) {
const body = JSON.stringify({ elements: [{ type: 'node', id: 1, lat: 1, lon: 2, tags: { name } }] });
let count = 0;
const options = { endpoints: [`https://phrase-${i}.example/`], fetchImpl: async () => {
count++; return new Response(body);
} };
const payload = await fetchOverpassPayload('first', 1024, options);
assert.equal(payload.status, 200, name);
assert.ok(overpassPayloadIsData(payload), name);
assert.ok(payload.body.includes(name), name);
await fetchOverpassPayload('different', 1024, options);
assert.equal(count, 2, name);
}
const remark = JSON.stringify({ elements: [], remark: 'runtime error: rate_limited' });
const limited = await fetchOverpassPayload('x', 1024, { endpoints: ['https://remark.example/'],
fetchImpl: async () => new Response(remark, { headers: { 'Retry-After': '90' } }) });
assert.equal(limited.status, 429);
assert.equal(limited.retryAfterMs, 90_000);
const error = await fetchOverpassPayload('x', 1024, { endpoints: ['https://error.example/'],
fetchImpl: async () => new Response('{"error":"rate_limited"}', { headers: { 'Retry-After': '120' } }) });
assert.equal(error.status, 429);
assert.equal(error.retryAfterMs, 120_000);
});
function proxyHandler() {
const plugin = createViteConfig({ mode: 'test' }).plugins.find(p => p.name === 'overpass-proxy');
const routes = new Map();
+50 -39
View File
@@ -189,20 +189,22 @@ export class SceneDirector {
...this._presentation,
hasRun: !!this._lastRunJson,
}));
this._bootstrapLegacyShotPacks();
// Upgrade only already-installed packs; unrelated Scenes are untouched.
for (const scene of this._project.scenes) {
for (const marker of [...(scene.appliedShotPacks || [])]) {
const recipe = getSceneAppendRecipeById(marker.id);
if (
recipe?.expansionFromVersion &&
marker.version > 0 &&
marker.version <= recipe.expansionFromVersion
) {
this.appendShotPack(scene.id, marker.id, {
render: false,
announce: false,
});
if (!this._storageReadError) {
this._bootstrapLegacyShotPacks();
// Upgrade only already-installed packs; unrelated Scenes are untouched.
for (const scene of this._project.scenes) {
for (const marker of [...(scene.appliedShotPacks || [])]) {
const recipe = getSceneAppendRecipeById(marker.id);
if (
recipe?.expansionFromVersion &&
marker.version > 0 &&
marker.version <= recipe.expansionFromVersion
) {
this.appendShotPack(scene.id, marker.id, {
render: false,
announce: false,
});
}
}
}
}
@@ -324,13 +326,14 @@ export class SceneDirector {
'Scene not saved — existing saved project could not be read. Export edits or import a valid file.',
),
);
return;
return false;
}
this._project.updatedAt = new Date().toISOString();
try {
const payload = JSON.stringify(this._project);
parseSceneDocument(payload);
localStorage.setItem(STORAGE_KEY, payload);
return true;
} catch (e) {
// Private browsing / block-all-cookies / quota-exceeded throws here. The
// in-memory project stays usable this session, but persistence failed —
@@ -351,6 +354,7 @@ export class SceneDirector {
})
: undefined,
);
return false;
}
}
@@ -923,12 +927,12 @@ export class SceneDirector {
}
this._selectedSceneId = scene.id;
this._selectedShotId = appendedShots[0]?.id || this._selectedShotId;
this._saveProject();
const saved = this._saveProject();
if (render) {
this._renderSceneSelect();
this._renderShotList();
}
if (announce) {
if (announce && saved) {
this._updateStatus(
marker
? tn(
@@ -984,14 +988,16 @@ export class SceneDirector {
scene.shots.push(shot);
this._selectedShotId = shot.id;
this._saveProject();
const saved = this._saveProject();
this._shotOutcome('shot-captured', scene, shot);
this._updateStatus(
t('Captured: {scene} / {shot}', {
scene: displaySceneTitle(scene),
shot: displayShotTitle(shot.title, shot, scene),
}),
);
if (saved) {
this._updateStatus(
t('Captured: {scene} / {shot}', {
scene: displaySceneTitle(scene),
shot: displayShotTitle(shot.title, shot, scene),
}),
);
}
}
/**
@@ -1017,14 +1023,16 @@ export class SceneDirector {
shot.visual = this.styleManager.getVisualState();
shot.layers = this._captureLayerStates();
this._saveProject();
const saved = this._saveProject();
this._shotOutcome('shot-updated', scene, shot);
this._updateStatus(
t('Updated: {scene} / {shot}', {
scene: displaySceneTitle(scene),
shot: displayShotTitle(shot.title, shot, scene),
}),
);
if (saved) {
this._updateStatus(
t('Updated: {scene} / {shot}', {
scene: displaySceneTitle(scene),
shot: displayShotTitle(shot.title, shot, scene),
}),
);
}
}
/**
@@ -1755,13 +1763,13 @@ export class SceneDirector {
) {
if (this._running) return { started: false, reason: 'already-running' };
let queue = this._buildPlaybackQueue(
sceneId || this._selectedSceneId || this._project.scenes[0]?.id,
{ single },
);
const startSceneId =
sceneId || this._selectedSceneId || this._project.scenes[0]?.id;
let queue = this._buildPlaybackQueue(startSceneId, { single });
if (afterShotId !== null) {
const index = queue.findIndex(
({ scene, shot }) => scene.id === sceneId && shot.id === afterShotId,
({ scene, shot }) =>
scene.id === startSceneId && shot.id === afterShotId,
);
if (index < 0) return { started: false, reason: 'shot-not-found' };
queue = queue.slice(index + 1);
@@ -1834,7 +1842,7 @@ export class SceneDirector {
title: 'Editable Scene Run',
startedAt: new Date().toISOString(),
estimatedDurationSec,
scenesRun: queue.length,
scenesRun: new Set(queue.map(({ scene }) => scene.id)).size,
events: [],
};
@@ -1842,6 +1850,7 @@ export class SceneDirector {
this._logEvent('scene_run_start', { count: queue.length });
this._setPlaybackKeyboardEnabled(true);
let runResult = { started: true, shots: queue.length };
try {
await playSceneQueue(queue, {
token,
@@ -1860,9 +1869,11 @@ export class SceneDirector {
this._logEvent('scene_run_error', {
message: error.message || 'unknown error',
});
runResult = { started: false, reason: 'run-failed' };
} finally {
this._finishRun();
}
return runResult;
}
/**
@@ -2090,9 +2101,9 @@ export class SceneDirector {
this._selectedShotId =
selection?.shotId || project.scenes[0]?.shots[0]?.id || null;
this._loadedSceneId = null;
this._saveProject();
const saved = this._saveProject();
this._publish({ type: 'project-imported', project });
this._updateStatus(t('Imported {file}', { file: file.name }));
if (saved) this._updateStatus(t('Imported {file}', { file: file.name }));
return true;
} catch (error) {
if (
+80 -6
View File
@@ -601,8 +601,12 @@ const PROJECT_FIXTURE = {
}],
};
/** Stub the browser globals the director touches, headlessly. */
function installSceneRuntime(project = PROJECT_FIXTURE) {
/**
* Stub the browser globals the director touches, headlessly. `storage` replaces
* the seeded entries (key -> raw string); setting `localStorage.full` makes
* every write throw a quota error.
*/
function installSceneRuntime(project = PROJECT_FIXTURE, { storage } = {}) {
const originalDocument = globalThis.document;
const originalLocalStorage = globalThis.localStorage;
const noopClassList = { add() {}, remove() {}, toggle() {}, contains: () => false };
@@ -614,10 +618,17 @@ function installSceneRuntime(project = PROJECT_FIXTURE) {
removeEventListener() {},
body: { classList: noopClassList, appendChild() {} },
};
const stored = new Map([['godsEyeView.sceneProject.v2', JSON.stringify(project)]]);
const stored = new Map(storage
? Object.entries(storage)
: [['godsEyeView.sceneProject.v2', JSON.stringify(project)]]);
globalThis.localStorage = {
stored,
full: false,
getItem: (key) => stored.get(key) ?? null,
setItem: (key, value) => stored.set(key, value),
setItem(key, value) {
if (this.full) throw new DOMException('quota exceeded', 'QuotaExceededError');
stored.set(key, value);
},
removeItem: (key) => stored.delete(key),
};
@@ -718,7 +729,8 @@ async function settle(turns = 8) {
/** Build a director over doubles, with the fixture project loaded. */
function makeDirector(options = {}) {
const restore = installSceneRuntime(options.project);
const restore = installSceneRuntime(options.project, { storage: options.storage });
const storage = globalThis.localStorage;
const viewer = fakeViewer();
const styleManager = fakeStyleManager(options.style);
const dataManager = fakeDataManager(options.data);
@@ -728,7 +740,7 @@ function makeDirector(options = {}) {
// Telemetry is only accumulated during a run; observable-failure assertions
// need the accumulator without driving a whole run.
director._activeRun = { events: [] };
return { director, viewer, styleManager, dataManager, restore };
return { director, viewer, styleManager, dataManager, storage, restore };
}
test('only Play Shot or a scene run grants transient media authority; LOAD, Stop and replacement revoke it', async () => {
@@ -1584,3 +1596,65 @@ test('camera refusal starts no authored frame or playback clock', async () => {
assert.deepEqual(viewer.flights, []);
} finally { restore(); }
});
test('an unreadable saved project does not overwrite its recovery checkpoint (#971)', async () => {
const unreadable = '{"version":99,"scenes":[]}';
const warn = console.warn;
console.warn = () => {};
const { director, storage, restore } = makeDirector({ storage: {
'godsEyeView.sceneProject.v2': unreadable,
'godsEyeView.sceneProject.checkpoint.v1': 'EARLIER-CHECKPOINT',
} });
try {
assert.equal(storage.stored.get('godsEyeView.sceneProject.v2'), unreadable);
assert.equal(storage.stored.get('godsEyeView.sceneProject.checkpoint.v1'), 'EARLIER-CHECKPOINT');
} finally { console.warn = warn; await director.destroy(); restore(); }
});
test('a capture, update or import that cannot be saved keeps the "Scene not saved" status (#971)', async () => {
const warn = console.warn;
console.warn = () => {};
const { director, storage, restore } = makeDirector();
let status = '';
director.subscribe(({ state }) => { status = state.status; });
try {
storage.full = true;
director._selectedSceneId = 'scene-1';
director._selectedShotId = 'shot-a';
director.captureShot();
assert.match(status, /^Scene not saved/, 'failed capture keeps the save failure');
director.updateSelectedShot();
assert.match(status, /^Scene not saved/, 'failed update keeps the save failure');
assert.equal(await director.importProjectFile(
{ name: 'fixture.json' },
{ prepared: { project: structuredClone(PROJECT_FIXTURE), assets: [] } },
), true);
assert.match(status, /^Scene not saved/, 'failed import keeps the save failure');
} finally { console.warn = warn; await director.destroy(); restore(); }
});
test('startScene resolves its result, resumes after a shot without a scene id, and counts distinct scenes (#971)', async () => {
const { director, restore } = makeDirector();
try {
const options = { single: true, preview: false };
assert.deepEqual(await director.startScene('scene-1', options), { started: true, shots: 2 });
assert.equal(director._lastRun.scenesRun, 1, 'one scene with two shots is one scene run');
assert.deepEqual(
await director.startScene(undefined, { ...options, afterShotId: 'shot-a' }),
{ started: true, shots: 1 },
);
} finally { await director.destroy(); restore(); }
});
test('a caught run failure resolves as run-failed, not success (#971)', async () => {
const { director, styleManager, restore } = makeDirector();
const warn = console.warn;
console.warn = () => {};
styleManager.applyVisualState = async () => { throw new Error('visual failed'); };
try {
assert.deepEqual(
await director.startScene('scene-1', { single: true, preview: false }),
{ started: false, reason: 'run-failed' },
);
} finally { console.warn = warn; await director.destroy(); restore(); }
});
+1 -1
View File
@@ -22,7 +22,7 @@ function boundedBox(lat, lon, halfSpan) {
}
/**
* Answer a decimal-degree query without asking anyone.
* Answer a decimal-degree or geo-URI query without asking anyone.
*
* It sits ahead of the network geocoders, so a coordinate costs no request and
* works with no key. Anything that is not exactly a coordinate is passed on
+13 -3
View File
@@ -5,7 +5,8 @@
* as 12, 34 flies the operator somewhere they did not ask for and looks like a
* successful search. So every rule here is exact — a component is a decimal
* number, optionally signed, optionally marked with one hemisphere letter, and
* nothing else. Degrees/minutes/seconds and grid references are not handled;
* nothing else. Two-dimensional WGS84 geo URIs use their own strict grammar.
* Degrees/minutes/seconds and grid references are not handled;
* they fall through to the geocoders, which is what happens today.
*/
@@ -13,6 +14,10 @@
const COMPONENT =
/^([NSEW])?\s*([+-]?(?:\d+(?:\.\d+)?|\.\d+))\s*°?\s*([NSEW])?$/i;
/** RFC 5870's 2D WGS84 subset; never discard altitude or uncertainty. */
const GEO_URI =
/^geo:(-?\d{1,2}(?:\.\d+)?),(-?\d{1,3}(?:\.\d+)?)(?:;crs=wgs84)?$/i;
/** Hemisphere letters, by the axis each one can name. */
const LATITUDE_LETTERS = new Set(['N', 'S']);
@@ -61,20 +66,25 @@ export function formatCoordinateLabel(lat, lon) {
}
/**
* Parse a decimal-degree query, or return null.
* Parse a decimal-degree pair or a two-dimensional WGS84 geo URI, or return null.
*
* Both orders are accepted when the hemisphere letters say which is which
* (`W 40, N 74` is longitude then latitude); with no letters the conventional
* latitude-then-longitude order applies. Two letters naming the same axis, a
* letter that fixes nothing, an out-of-range latitude or longitude, and any
* trailing text all decline.
* Geo URIs accept only latitude/longitude and an optional `;crs=wgs84`;
* altitude, uncertainty, other parameters and query/fragment suffixes decline.
*
* @param {string} query
* @returns {{ lat: number, lon: number, label: string } | null}
*/
export function parseCoordinateQuery(query) {
if (typeof query !== 'string') return null;
const parts = splitComponents(query);
const text = query.trim();
const parts = /^geo:/i.test(text)
? GEO_URI.exec(text)?.slice(1, 3)
: splitComponents(text);
if (!parts) return null;
const first = readComponent(parts[0]);
+64
View File
@@ -132,3 +132,67 @@ test('the poles and the dateline are inside the accepted range', () => {
assert.deepEqual(parseCoordinateQuery('90, 180')?.lat, 90);
assert.deepEqual(parseCoordinateQuery('-90, -180')?.lon, -180);
});
test('two-dimensional WGS84 geo URIs resolve to the same decimal coordinate', () => {
for (const query of [
'geo:39.7392,-104.9903',
'geo:39.7392,-104.9903;crs=wgs84',
' GEO:39.7392,-104.9903;CRS=WGS84 ',
]) {
assert.deepEqual(parseCoordinateQuery(query), {
lat: 39.7392,
lon: -104.9903,
label: '39.7392° N, 104.9903° W',
});
}
for (const [query, decimal] of [
['geo:0,0', '0,0'],
['geo:-33.8568,151.2153', '-33.8568,151.2153'],
['geo:90,180', '90,180'],
['geo:-90,-180;crs=wgs84', '-90,-180'],
['geo:09,007', '9,7'],
['geo:-0,-0', '-0,-0'],
]) {
assert.deepEqual(
parseCoordinateQuery(query),
parseCoordinateQuery(decimal),
);
}
});
test('malformed or unsupported geo URIs are refused without losing their meaning', () => {
for (const query of [
'geo:',
'geo:39.7392',
'geo:,104.9903',
'geo:39.7392,,104.9903',
'geo:39.7392,-104.9903,',
'geo:91,0',
'geo:-90.1,0',
'geo:0,181',
'geo:0,-180.1',
'geo:000,0',
'geo:0,0000',
'geo:+39.7392,-104.9903',
'geo:.5,-.25',
'geo:1e1,2',
'geo:NaN,0',
'geo:Infinity,0',
'geo:39.7392, -104.9903',
'geo:39.7392;-104.9903',
'geo:39.7392N,104.9903W',
'geo:39.7392,-104.9903,1600',
'geo:39.7392,-104.9903;u=10',
'geo:39.7392,-104.9903;crs=unknown',
'geo:39.7392,-104.9903;crs=wgs84;crs=wgs84',
'geo:39.7392,-104.9903;crs=wgs84;u=0',
'geo:39.7392,-104.9903;custom=value',
'geo:0,0?q=39.7392,-104.9903(Denver)',
'geo:39.7392,-104.9903#Denver',
'geo:39.7392,-104.9903 trailing text',
'geo://39.7392,-104.9903',
'https://example.com/geo:39.7392,-104.9903',
]) {
assert.equal(parseCoordinateQuery(query), null, query);
}
});
+50
View File
@@ -9,6 +9,7 @@ import assert from 'node:assert/strict';
import { createCoordinateGeocoder } from './coordinateGeocoder.js';
import { createPresetGeocoder } from './presetGeocoder.js';
import { createPlaceSearch } from './placeSearch.js';
import { createDefaultPlaceSearch } from './defaults.js';
const PRESETS = {
austin: {
@@ -189,3 +190,52 @@ test('a cancelled search stops the offline providers too', async () => {
);
}
});
test('default search resolves a geo URI exactly without requesting a geocoder', async () => {
const requests = [];
const search = createDefaultPlaceSearch({
resolveApiKey: () => 'test-key',
fetchImpl: async (url) => {
requests.push(url);
throw new Error('No network request should be needed');
},
});
const { place, fallbackUsed } = await search.geocode(
'geo:39.7392,-104.9903;crs=wgs84',
);
assert.deepEqual(requests, []);
assert.equal(fallbackUsed, false);
assert.equal(place.lat, 39.7392);
assert.equal(place.lng, -104.9903);
assert.equal(place.exact, true);
assert.deepEqual(place.types, ['coordinate']);
assert.ok(place.viewport.southwest.lat < place.lat);
assert.ok(place.viewport.northeast.lat > place.lat);
assert.ok(place.viewport.southwest.lng < place.lng);
assert.ok(place.viewport.northeast.lng > place.lng);
});
test('unsupported geo URIs reach the next search provider unchanged', async () => {
const queries = [
'geo:39.7392,-104.9903,1600',
'geo:39.7392,-104.9903;u=10',
'geo:39.7392,-104.9903;crs=unknown',
'geo:0,0?q=Denver',
];
const received = [];
const search = createPlaceSearch({
providers: [
createCoordinateGeocoder(),
{
async geocode(query) {
received.push(query);
return { place: null, answered: true };
},
},
],
});
for (const query of queries) {
assert.equal((await search.geocode(query)).place, null);
}
assert.deepEqual(received, queries);
});
+18 -11
View File
@@ -204,12 +204,16 @@ export function createGroundFloor({ terrain, signal }) {
* @returns {{lat: number, lon: number}} The cell to read.
*/
function stickyFloorCell(lat, lon, previousCell) {
if (
previousCell &&
Math.abs(lat - previousCell.lat) <= 0.0005 + CELL_HYSTERESIS_DEG &&
Math.abs(lon - previousCell.lon) <= 0.0005 + CELL_HYSTERESIS_DEG
) {
return previousCell;
if (previousCell) {
// The short way round, so a contact on the 180° seam keeps its cell.
let dLon = lon - previousCell.lon;
dLon -= 360 * Math.round(dLon / 360);
if (
Math.abs(lat - previousCell.lat) <= 0.0005 + CELL_HYSTERESIS_DEG &&
Math.abs(dLon) <= 0.0005 + CELL_HYSTERESIS_DEG
) {
return previousCell;
}
}
return coarseFloorCoord(lat, lon);
}
@@ -269,17 +273,20 @@ export function createGroundFloor({ terrain, signal }) {
const a = points[s];
const b = points[s + 1];
if (![a?.lat, a?.lon, b?.lat, b?.lon].every(Number.isFinite)) continue;
// The short way round, so a segment across 180° walks across it.
let dLon = b.lon - a.lon;
dLon -= 360 * Math.round(dLon / 360);
const steps = Math.ceil(
Math.max(Math.abs(b.lat - a.lat), Math.abs(b.lon - a.lon)) /
Math.max(Math.abs(b.lat - a.lat), Math.abs(dLon)) /
CORRIDOR_WALK_STEP_DEG,
);
for (let i = 1; i <= steps; i++) {
if (out.length >= prefixLimit) break walk;
const t = i / steps;
const cell = coarseFloorCoord(
a.lat + (b.lat - a.lat) * t,
a.lon + (b.lon - a.lon) * t,
);
let lon = a.lon + dLon * t;
if (lon > 180) lon -= 360;
else if (lon < -180) lon += 360;
const cell = coarseFloorCoord(a.lat + (b.lat - a.lat) * t, lon);
const key = `${cell.lat},${cell.lon}`;
if (seen.has(key) || key === destKey) continue;
seen.add(key);
+3 -11
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { approxDistanceKm } from '../data/approxDistance.js';
/** Construct an instance-owned meshFloorSampler service with explicit dependencies. */
export function createMeshFloorSampler({
@@ -79,16 +80,6 @@ export function createMeshFloorSampler({
return false;
}
/**
* Equirectangular distance (km) — same approximation the flights clamp uses.
*/
function _approxKm(lat1, lon1, lat2, lon2) {
const dLat = (lat2 - lat1) * 111.32;
const dLon =
(lon2 - lon1) * 111.32 * Math.cos((((lat1 + lat2) / 2) * Math.PI) / 180);
return Math.hypot(dLat, dLon);
}
/**
* Samples the rendered mesh height for the coarse cells containing `points`,
* one-shot per cell, budget-capped, viewer-proximity-gated. Call ONCE per
@@ -133,7 +124,8 @@ export function createMeshFloorSampler({
if (
Number.isFinite(viewerLat) &&
Number.isFinite(viewerLon) &&
_approxKm(viewerLat, viewerLon, cell.lat, cell.lon) > MAX_SAMPLE_DIST_KM
approxDistanceKm(viewerLat, viewerLon, cell.lat, cell.lon) >
MAX_SAMPLE_DIST_KM
) {
continue; // too far: tiles not streamed there, probe would be a guaranteed miss
}
+14 -4
View File
@@ -69,12 +69,22 @@ test('placeholder values are never counted as configured credentials', () => {
});
test('doctor selects a Windows-safe npm process without changing Unix behavior', () => {
assert.deepEqual(npmProcessSpec('win32'), {
command: 'npm.cmd',
// Windows passes no argument array with shell: true (Node DEP0190).
assert.deepEqual(npmProcessSpec(['ci'], 'win32'), {
command: 'npm.cmd ci',
args: [],
shell: true,
});
assert.deepEqual(npmProcessSpec('darwin'), { command: 'npm', shell: false });
assert.deepEqual(npmProcessSpec('linux'), { command: 'npm', shell: false });
assert.deepEqual(npmProcessSpec(['--version'], 'darwin'), {
command: 'npm',
args: ['--version'],
shell: false,
});
assert.deepEqual(npmProcessSpec(['ci'], 'linux'), {
command: 'npm',
args: ['ci'],
shell: false,
});
});
test('doctor recognizes every OpenSky OAuth keychain alias used by dev-fresh', () => {
+319
View File
@@ -0,0 +1,319 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as fs from 'node:fs/promises';
import {
mkdtemp,
mkdir,
writeFile,
readFile,
readdir,
rm,
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
FRAGMENT_DIR,
STAGING_DIR,
assembleChangelog,
assembleFragments,
orderFragmentNames,
recoverInterruptedBatch,
} from '../../scripts/assemble-changelog.mjs';
const EXISTING =
'# Changelog\n\n- An entry that was already released (someone, #1).\n';
const repositoryRoot = fileURLToPath(new URL('../..', import.meta.url));
async function fixture(t, fragments = {}) {
const root = await mkdtemp(path.join(tmpdir(), 'gev-changelog-'));
t.after(() => rm(root, { recursive: true, force: true }));
await mkdir(path.join(root, FRAGMENT_DIR));
await writeFile(
path.join(root, FRAGMENT_DIR, 'README.md'),
'# How to add one\n',
);
await writeFile(path.join(root, 'CHANGELOG.md'), EXISTING);
for (const [name, body] of Object.entries(fragments)) {
await writeFile(path.join(root, FRAGMENT_DIR, name), body);
}
return root;
}
test('the batch reads newest pull request first', () => {
assert.deepEqual(orderFragmentNames(['7-b.md', '112-c.md', '19-a.md']), [
'112-c.md',
'19-a.md',
'7-b.md',
]);
});
test('the directory README is documentation, never an entry', () => {
assert.deepEqual(orderFragmentNames(['README.md', '5-a.md']), ['5-a.md']);
});
test('two fragments claiming one pull request fail loudly, not silently', () => {
assert.throws(
() => orderFragmentNames(['5-first.md', '5-second.md']),
/Two fragments claim pull request 5/,
);
});
test('a name that is not <pull request>-<slug>.md is refused', () => {
for (const name of ['notes.md', '5_underscore.md', '5-Caps.md', '5-a.txt']) {
assert.throws(() => orderFragmentNames([name]), /Unexpected file/, name);
}
});
test('fragment text is spliced in unchanged, above the existing entries', () => {
// Wrapped, indented and punctuated the way a real entry is: the continuation
// indent and the blank line inside the entry are the parts a reflow would eat.
const entry = [
'- A new thing, with `code` and a — dash. The second line is indented the',
' way the surrounding entries wrap, and it keeps its two spaces.',
'',
' A second paragraph, because some entries have one (someone, #9).',
].join('\n');
const result = assembleChangelog(EXISTING, [`${entry}\n\n\n`]);
assert.equal(
result,
`# Changelog\n\n${entry}\n\n- An entry that was already released (someone, #1).\n`,
);
// The entry survives byte for byte: drawTool.test.mjs reads this file as
// prose and slices a fixed window, so reflowing would move text across it.
assert.ok(result.includes(entry));
});
test('a CHANGELOG without the heading is refused rather than prepended to', () => {
assert.throws(
() => assembleChangelog('- orphan entry\n', ['- new\n']),
/must start with "# Changelog"/,
);
});
test('assembling consumes the fragments and leaves the README', async (t) => {
const root = await fixture(t, {
'20-second.md': '- Second (someone, #20).',
'100-first.md': '- First (someone, #100).',
});
const { consumed } = await assembleFragments(root, '--write');
assert.deepEqual(consumed, ['100-first.md', '20-second.md']);
const changelog = await readFile(path.join(root, 'CHANGELOG.md'), 'utf8');
assert.match(
changelog,
/# Changelog\n\n- First \(someone, #100\)\.\n\n- Second \(someone, #20\)\.\n\n- An entry that was already released/,
);
assert.deepEqual(await readdir(path.join(root, FRAGMENT_DIR)), ['README.md']);
});
test('a rejected batch leaves the directory and the file untouched', async (t) => {
const root = await fixture(t, {
'8-first.md': '- First (someone, #8).',
'8-again.md': '- Again (someone, #8).',
});
await assert.rejects(
assembleFragments(root, '--write'),
/Two fragments claim pull request 8/,
);
assert.equal(
await readFile(path.join(root, 'CHANGELOG.md'), 'utf8'),
EXISTING,
);
assert.deepEqual((await readdir(path.join(root, FRAGMENT_DIR))).sort(), [
'8-again.md',
'8-first.md',
'README.md',
]);
});
test('check mode changes nothing on disk', async (t) => {
const root = await fixture(t, { '3-a.md': '- Something (someone, #3).' });
const { consumed } = await assembleFragments(root, '--check');
assert.deepEqual(consumed, ['3-a.md']);
assert.equal(
await readFile(path.join(root, 'CHANGELOG.md'), 'utf8'),
EXISTING,
);
assert.deepEqual((await readdir(path.join(root, FRAGMENT_DIR))).sort(), [
'3-a.md',
'README.md',
]);
});
test('an empty directory is a no-op, so a release can always run it', async (t) => {
const root = await fixture(t);
const { consumed } = await assembleFragments(root, '--write');
assert.deepEqual(consumed, []);
assert.equal(
await readFile(path.join(root, 'CHANGELOG.md'), 'utf8'),
EXISTING,
);
});
test('a mode that is neither check nor write is refused', async (t) => {
const root = await fixture(t);
await assert.rejects(assembleFragments(root, '--force'), /--check\|--write/);
});
// Failure injection. Every filesystem call the write path makes is a point
// where a release can die: a full disk, a permissions error, a killed process.
const IO_CALLS = ['readdir', 'readFile', 'mkdir', 'writeFile', 'rename', 'rm'];
const BATCH = {
'20-second.md': '- Second (someone, #20).',
'100-first.md': '- First (someone, #100).',
'7-third.md': '- Third (someone, #7).',
};
/**
* The real filesystem, except that `call` throws the `nth` time it runs. With
* `crash`, every later call throws as well, the way a killed process makes no
* further calls: the in-process rollback cannot run either, so the next
* invocation is the only thing that can recover.
*/
function failing(call, nth, { crash = false } = {}) {
let seen = 0;
let dead = false;
const io = { ...fs };
for (const name of IO_CALLS) {
io[name] = async (...args) => {
if (dead) throw new Error('process already gone');
if (name === call && ++seen === nth) {
dead = crash;
throw new Error(`injected ${call} failure #${nth}`);
}
return fs[name](...args);
};
}
return io;
}
/** How many times one uninterrupted `--write` makes each filesystem call. */
async function countCalls(t) {
const root = await fixture(t, BATCH);
const counts = Object.fromEntries(IO_CALLS.map((name) => [name, 0]));
const io = { ...fs };
for (const name of IO_CALLS) {
io[name] = (...args) => {
counts[name] += 1;
return fs[name](...args);
};
}
await assembleFragments(root, '--write', io);
return {
counts,
expected: await readFile(path.join(root, 'CHANGELOG.md'), 'utf8'),
};
}
test('a write that fails at any call never lets a retry duplicate or lose an entry', async (t) => {
const { counts, expected } = await countCalls(t);
// The write path itself: one mkdir, one writeFile, a rename per fragment plus
// the commit rename, and the cleanup rm. Fewer would mean a step went missing.
assert.ok(counts.rename >= Object.keys(BATCH).length + 1);
for (const crash of [false, true]) {
for (const call of IO_CALLS) {
for (let nth = 1; nth <= counts[call]; nth += 1) {
const label = `${crash ? 'crash' : 'error'} at ${call} #${nth}`;
const root = await fixture(t, BATCH);
await assert.rejects(
assembleFragments(root, '--write', failing(call, nth, { crash })),
/injected|already gone/,
label,
);
await assembleFragments(root, '--write');
const changelog = await readFile(
path.join(root, 'CHANGELOG.md'),
'utf8',
);
assert.equal(changelog, expected, label);
for (const pr of [100, 20, 7, 1]) {
assert.equal(
changelog.split(`(someone, #${pr}).`).length - 1,
1,
`${label}: #${pr} appears exactly once`,
);
}
assert.deepEqual(
await readdir(path.join(root, FRAGMENT_DIR)),
['README.md'],
label,
);
}
}
}
});
test('an error before the commit rolls the batch back in the same run', async (t) => {
const root = await fixture(t, BATCH);
// The second rename has already moved one fragment into the staging directory.
await assert.rejects(
assembleFragments(root, '--write', failing('rename', 2)),
/injected rename failure #2/,
);
assert.equal(
await readFile(path.join(root, 'CHANGELOG.md'), 'utf8'),
EXISTING,
);
assert.deepEqual(
(await readdir(path.join(root, FRAGMENT_DIR))).sort(),
[...Object.keys(BATCH), 'README.md'].sort(),
);
});
test('check mode reports a batch an interrupted release left staged', async (t) => {
const root = await fixture(t, BATCH);
await assert.rejects(
assembleFragments(root, '--write', failing('rename', 2, { crash: true })),
);
await assert.rejects(
assembleFragments(root, '--check'),
new RegExp(`${STAGING_DIR}/ holds a batch an interrupted release`),
);
assert.equal(await recoverInterruptedBatch(root), 'rolled-back');
assert.equal(await recoverInterruptedBatch(root), 'none');
});
test('recovery refuses to roll a fragment back over a file of the same name', async (t) => {
const root = await fixture(t, BATCH);
await assert.rejects(
assembleFragments(root, '--write', failing('rename', 2, { crash: true })),
);
await writeFile(
path.join(root, FRAGMENT_DIR, '100-first.md'),
'- Rewritten.',
);
await assert.rejects(
assembleFragments(root, '--write'),
/Cannot roll back .*100-first\.md/,
);
assert.equal(
await readFile(path.join(root, 'CHANGELOG.md'), 'utf8'),
EXISTING,
);
});
// The two below run against the repository itself, so a fragment that the
// release could not assemble fails `npm test` in the pull request that adds it
// rather than at release time, when its author has moved on.
test('every pending fragment in the repository can be assembled', async () => {
const names = await readdir(path.join(repositoryRoot, FRAGMENT_DIR));
assert.ok(
names.includes('README.md'),
`${FRAGMENT_DIR}/README.md documents the convention and keeps the directory tracked`,
);
// Throws on a name that is not <pull request>-<slug>.md, and on two fragments
// claiming one pull request number. Every other file is an entry.
assert.equal(orderFragmentNames(names).length, names.length - 1);
});
test('CHANGELOG.md still carries the heading a release splices under', async () => {
const changelog = await readFile(
path.join(repositoryRoot, 'CHANGELOG.md'),
'utf8',
);
assert.ok(changelog.startsWith('# Changelog\n'));
});
+70 -4
View File
@@ -7,9 +7,10 @@ import { firmsProxy } from 'gods-eye-view/server/providers/firms';
import { gbfsProxy } from 'gods-eye-view/server/providers/gbfs';
import { localProviderPlugins } from '../../server/providers/local.js';
function install(plugin) {
function install(plugin, hook = 'configureServer') {
const routes = new Map();
plugin.configureServer({
assert.equal(typeof plugin[hook], 'function', `${hook} is available`);
plugin[hook]({
middlewares: {
use(route, handler) {
routes.set(route, handler);
@@ -17,9 +18,16 @@ function install(plugin) {
},
});
assert.equal(routes.size, 1);
return async (url = '/', method = 'GET') => {
return async (url = '/', method = 'GET', headers = {}) => {
const res = {
headersSent: false,
headers: {},
set statusCode(status) {
this.status = status;
},
setHeader(name, value) {
this.headers[name.toLowerCase()] = value;
},
writeHead(status, headers) {
Object.assign(this, { status, headers, headersSent: true });
},
@@ -27,7 +35,7 @@ function install(plugin) {
this.body = body;
},
};
await [...routes.values()][0]({ url, method }, res);
await [...routes.values()][0]({ url, method, headers, socket: {} }, res);
return res;
};
}
@@ -191,6 +199,64 @@ test('traffic middleware preserves keyless mode, caching, stale budget fallback
assert.equal(calls, 2);
});
test('traffic refuses cross-site requests in dev and preview before spending the TomTom budget', async (t) => {
isolate(t, { TOMTOM_API_KEY: 'fixture-key', TOMTOM_DAILY_TILE_BUDGET: '3' });
let calls = 0;
t.mock.method(globalThis, 'fetch', async () => {
calls++;
return new Response(new Uint8Array([1, 2, 3]));
});
const host = 'localhost:5173';
const refused = [
{ host, 'sec-fetch-site': 'cross-site' }, // <img src> from any web page
{ host, 'sec-fetch-site': 'same-site' },
{ host, origin: 'https://evil.example' }, // fetch from another page
{ host, origin: 'null' }, // sandboxed frame
{ host, 'x-forwarded-for': '203.0.113.9' }, // through a proxy
];
for (const [mode, hook] of [
['dev', 'configureServer'],
['preview', 'configurePreviewServer'],
]) {
const request = install(tomtomProxy(), hook);
// Each refused shape asks for distinct uncached tiles: without the gate
// every one would be a billed attempt counted against the daily budget.
let x = 0;
for (const headers of refused)
for (let i = 0; i < 2; i++) {
const res = await request(`/flow/16/${x++}/0.pbf`, 'GET', headers);
assert.equal(res.status, 403, `${mode}: ${JSON.stringify(headers)}`);
}
assert.equal(
(await request('/status', 'GET', refused[0])).status,
403,
`${mode}: status is gated too`,
);
assert.equal(
calls,
mode === 'dev' ? 0 : 2,
`${mode}: no refused upstream work`,
);
const app = { host, 'sec-fetch-site': 'same-origin' };
assert.equal(json(await request('/status', 'GET', app)).dailyCount, 0);
// The app itself and loopback tools (no browser headers) still get tiles.
const tile = await request('/flow/8/1/1.pbf', 'GET', app);
assert.equal(tile.status, 200);
assert.equal(tile.headers['x-tomtom-cache'], 'MISS');
assert.equal((await request('/flow/8/2/1.pbf')).status, 200);
assert.equal(
(
await request('/flow/8/1/1.pbf', 'GET', {
host,
origin: `http://${host}`,
})
).headers['x-tomtom-cache'],
'HIT',
);
assert.equal(calls, mode === 'dev' ? 2 : 4);
}
});
test('traffic middleware answers 502 to an oversized tile without caching it', async (t) => {
isolate(t, { TOMTOM_API_KEY: 'fixture-key', TOMTOM_DAILY_TILE_BUDGET: '' });
const bodies = [];
+21 -4
View File
@@ -323,13 +323,30 @@ export function lineTouchesArea(coords, area) {
/** Whether a point lies inside a ring of `[lon, lat]` vertices (ray casting). */
function ringContains(ring, point) {
if (ring.length < 3) return false;
// Keep adjacent vertices on the same longitude sheet, as the edge tests do.
// Wrapping each vertex around the query point would split a dateline ring
// when queried from Greenwich and mistake its complement for its interior.
let previous = ring[0][0];
let west = previous;
let east = previous;
const vertices = ring.map(([lon, lat]) => {
if (Math.abs(lon - previous) > 180)
lon += 360 * Math.round((previous - lon) / 360);
previous = lon;
west = Math.min(west, lon);
east = Math.max(east, lon);
return [lon, lat];
});
const middle = (west + east) / 2;
const lon = point.lon + 360 * Math.round((middle - point.lon) / 360);
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
const [xi, yi] = ring[i];
const [xj, yj] = ring[j];
for (let i = 0, j = vertices.length - 1; i < vertices.length; j = i, i += 1) {
const [xi, yi] = vertices[i];
const [xj, yj] = vertices[j];
if (
yi > point.lat !== yj > point.lat &&
point.lon < ((xj - xi) * (point.lat - yi)) / (yj - yi) + xi
lon < ((xj - xi) * (point.lat - yi)) / (yj - yi) + xi
)
inside = !inside;
}
+75
View File
@@ -262,3 +262,78 @@ test('a box radius reaches every point of the box, however large', async () => {
const small = await resolveArea({ bbox: [-97.94, 30.1, -97.56, 30.52] });
assert.ok(areaRadiusKm(small) < 30);
});
test('polygon containment follows the short edges across the antimeridian', async () => {
const ring = [
[179, -2],
[-179, -2],
[-179, 2],
[179, 2],
];
const areas = [
[{ bbox: [179.4, -0.1, 179.6, 0.1] }, true],
[{ bbox: [-179.6, -0.1, -179.4, 0.1] }, true],
[{ bbox: [179.8, -0.1, -179.8, 0.1] }, true],
[{ lat: 0, lon: 179.5, radius_km: 10 }, true],
[{ lat: 0, lon: -179.5, radius_km: 10 }, true],
[{ lat: 0, lon: 180, radius_km: 10 }, true],
[{ lat: 0, lon: -180, radius_km: 10 }, true],
[{ bbox: [-0.1, -0.1, 0.1, 0.1] }, false],
[{ lat: 0, lon: 0, radius_km: 10 }, false],
];
for (const vertices of [
ring,
[...ring, [...ring[0]]],
[...ring].reverse(),
[...ring.slice(2), ...ring.slice(0, 2)],
]) {
for (const [input, expected] of areas) {
assert.equal(
polygonsTouchArea([[vertices]], await resolveArea(input)),
expected,
JSON.stringify({ vertices, input }),
);
}
}
});
test('antimeridian polygons keep holes empty and include their boundaries', async () => {
const outer = [
[178, -3],
[-178, -3],
[-178, 3],
[178, 3],
];
const hole = [
[179, -1],
[-179, -1],
[-179, 1],
[179, 1],
];
for (const input of [
{ bbox: [179.4, -0.1, 179.6, 0.1] },
{ bbox: [-179.6, -0.1, -179.4, 0.1] },
{ lat: 0, lon: 180, radius_km: 10 },
]) {
const area = await resolveArea(input);
assert.ok(polygonsTouchArea([[outer]], area));
assert.ok(!polygonsTouchArea([[outer, hole]], area));
assert.ok(!polygonsTouchArea([[outer, [...hole].reverse()]], area));
}
const solid = await resolveArea({ bbox: [178.4, -0.1, 178.6, 0.1] });
assert.ok(polygonsTouchArea([[outer, hole]], solid));
const boundary = await resolveArea({ bbox: [178.9, -0.1, 179.1, 0.1] });
assert.ok(polygonsTouchArea([[outer, hole]], boundary));
});
test('polygon containment retains rings spanning more than half the globe', async () => {
const longitudes = [-170, -110, -50, 10, 70, 130];
const ring = [
...longitudes.map((lon) => [lon, -5]),
...[...longitudes].reverse().map((lon) => [lon, 5]),
];
const inside = await resolveArea({ bbox: [99, -1, 101, 1] });
const outside = await resolveArea({ bbox: [169, -1, 171, 1] });
assert.ok(polygonsTouchArea([[ring]], inside));
assert.ok(!polygonsTouchArea([[ring]], outside));
});
+2
View File
@@ -111,6 +111,8 @@ export const aircraftInArea = defineTool({
(record) =>
areaContains(area, { lat: record.latitude, lon: record.longitude }) &&
!(args.airborne_only && record.onGround) &&
((args.min_altitude_m == null && args.max_altitude_m == null) ||
Number.isFinite(altitude(record))) &&
(args.min_altitude_m == null ||
altitude(record) >= args.min_altitude_m) &&
(args.max_altitude_m == null ||
+59
View File
@@ -388,3 +388,62 @@ test('the real OpenSky source marks an hour-old snapshot stale for searches', as
assert.deepEqual(result.data.stale_feeds, ['civil']);
assert.match(result.summary, /may be stale\.$/);
});
test('altitude bands require a reported altitude from normalized OpenSky records', async () => {
const { normalizeOpenSkyAircraft } =
await import('../../sources/live/aircraft.js');
const state = (id, baro, geometric) =>
normalizeOpenSkyAircraft([
id,
'TEST',
'Test',
1767225600,
1767225600,
0,
0,
baro,
false,
10,
90,
0,
[],
geometric,
]);
const records = [
state('abc001', null, null),
state('abc002', 0, null),
state('abc003', null, 50),
state('abc004', 500, null),
];
const catalog = composeCatalog({
tools: coreTools,
services: {
aircraft: { getSnapshot: async () => ({ records }) },
},
});
const area = { bbox: [-1, -1, 1, 1] };
assert.equal(
(await catalog.call('aircraft_in_area', { area })).data.total,
4,
'unfiltered answers retain unknown altitude',
);
for (const band of [
{ max_altitude_m: 100 },
{ min_altitude_m: -500, max_altitude_m: 100 },
]) {
const result = await catalog.call('aircraft_in_area', { area, ...band });
assert.deepEqual(
result.data.rows.map((row) => row.id),
['abc002', 'abc003'],
);
assert.deepEqual(
result.data.rows.map((row) => row.altitude_m),
[0, 50],
);
}
const minimum = await catalog.call('aircraft_in_area', {
area,
min_altitude_m: -500,
});
assert.equal(minimum.data.total, 3);
});
+9 -4
View File
@@ -288,20 +288,25 @@ export const getFirePerimeters = defineTool({
},
});
/** The average vertex of a perimeter's outer rings, enough to place it in an area. */
/** Average outer-ring vertices, using a circular longitude mean at the dateline. */
function polygonCenter(polygons) {
let lat = 0;
let lon = 0;
let sinLon = 0;
let cosLon = 0;
let count = 0;
const toRad = Math.PI / 180;
for (const polygon of polygons || []) {
for (const [x, y] of polygon?.[0] || []) {
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
lon += x;
sinLon += Math.sin(x * toRad);
cosLon += Math.cos(x * toRad);
lat += y;
count += 1;
}
}
return count ? { lat: lat / count, lon: lon / count } : null;
return count
? { lat: lat / count, lon: Math.atan2(sinLon, cosLon) / toRad }
: null;
}
export const getTerrainHeight = defineTool({
+91
View File
@@ -227,6 +227,97 @@ test('fire perimeters are placed by their outline and sorted by size', async ()
});
});
test('fire perimeter queries include dateline interiors and exclude the opposite meridian', async () => {
const perimeters = {
getSnapshot: async () => [
{
stableId: 'dateline',
name: 'Dateline Fire',
acres: 100,
polygons: [
[
[
[179, -2],
[-179, -2],
[-179, 2],
[179, 2],
[179, -2],
],
],
],
},
],
};
const catalog = catalogWith({ perimeters });
for (const area of [
{ bbox: [179.4, -0.1, 179.6, 0.1] },
{ bbox: [-179.6, -0.1, -179.4, 0.1] },
{ lat: 0, lon: 180, radius_km: 10 },
]) {
const result = await catalog.call('get_fire_perimeters', { area });
assert.deepEqual(
result.data.rows.map((row) => row.id),
['dateline'],
);
}
const elsewhere = await catalog.call('get_fire_perimeters', {
area: { bbox: [-0.1, -0.1, 0.1, 0.1] },
});
assert.equal(elsewhere.data.total, 0);
});
test('fire perimeter coordinates stay at the dateline for closed and multipart outlines', async () => {
const perimeters = {
getSnapshot: async () => [
{
stableId: 'closed',
acres: 100,
polygons: [
[
[
[179, -2],
[-179, -2],
[-179, 2],
[179, 2],
[179, -2],
],
],
],
},
{
stableId: 'multipart',
acres: 50,
polygons: [
[
[
[179, -1],
[179.5, -1],
[179.5, 1],
[179, 1],
],
],
[
[
[-179.5, -1],
[-179, -1],
[-179, 1],
[-179.5, 1],
],
],
],
},
],
};
const result = await catalogWith({ perimeters }).call('get_fire_perimeters', {
area: { bbox: [178, -3, -178, 3] },
});
assert.equal(result.data.total, 2);
for (const row of result.data.rows) {
assert.ok(Math.abs(row.lon) > 179, `${row.id}: ${row.lon}`);
assert.ok(Math.abs(row.lon) <= 180);
}
});
test('terrain heights are returned in request order', async () => {
const terrain = {
async getHeights(points) {
+24 -6
View File
@@ -118,9 +118,11 @@ export const findCctvCameras = defineTool({
export const getCctvSnapshot = defineTool({
name: 'get_cctv_snapshot',
title: 'Current camera view',
title: 'Camera snapshot',
description:
'The current image from one public camera, by the id from find_cctv_cameras.',
'An image from one public camera, by the id from find_cctv_cameras. ' +
'Street View fallback imagery is historical, not the current camera view; ' +
'unknown frame provenance is explicit.',
inputSchema: {
type: 'object',
properties: { camera_id: { type: 'string', minLength: 1, maxLength: 200 } },
@@ -138,7 +140,7 @@ export const getCctvSnapshot = defineTool({
`No camera has id ${args.camera_id}; use find_cctv_cameras first`,
);
const frame = await services.cctv.getFrame(camera, { signal });
if (!FRAME_TYPES.has(frame.contentType))
if (frame.sourceKind === 'synthetic' || !FRAME_TYPES.has(frame.contentType))
throw new ToolError(
'unavailable',
`Camera ${camera.name || camera.id} has no still image right now`,
@@ -149,8 +151,22 @@ export const getCctvSnapshot = defineTool({
'The camera image is too large to return',
);
const name = text(camera.name) || camera.id;
const fallback = frame.sourceKind === 'streetview';
const current = ['upstream-image', 'hls-pull'].includes(frame.sourceKind);
const credit = fallback ? 'Google Street View' : text(camera.credit);
const label = fallback
? 'Street View fallback for'
: current
? 'Current view from'
: 'Image from';
return {
summary: `Current view from ${name}${camera.city ? ` in ${camera.city}` : ''}${camera.credit ? `, courtesy of ${camera.credit}` : ''}.`,
summary:
`${label} ${name}${camera.city ? ` in ${camera.city}` : ''}${credit ? `, courtesy of ${credit}` : ''}.` +
(fallback
? ' Historical imagery; the current camera frame is unavailable.'
: current
? ''
: ' Frame provenance is unknown.'),
data: {
view: suggestView(services, {
point: { lat: camera.lat, lon: camera.lon },
@@ -160,8 +176,10 @@ export const getCctvSnapshot = defineTool({
id: camera.id,
name,
city: text(camera.city),
provider: text(camera.provider),
credit: text(camera.credit),
provider: fallback ? 'Google Street View' : text(camera.provider),
credit,
source_kind: frame.sourceKind ?? null,
current_view: current ? true : fallback ? false : null,
mime_type: frame.contentType,
bytes: frame.bytes.byteLength,
},
+184 -1
View File
@@ -30,7 +30,13 @@ const cameras = [
{ id: 'no-position', name: 'Unplaced' },
];
const PNG = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const cctv = (frame = { contentType: 'image/png', bytes: PNG }) => ({
const cctv = (
frame = {
contentType: 'image/png',
bytes: PNG,
sourceKind: 'upstream-image',
},
) => ({
frames: [],
async getCatalog() {
return { sources: cameras };
@@ -104,6 +110,183 @@ test('a snapshot returns the frame as an image with its credit', async () => {
});
});
test('mounted camera fallback provenance survives the source and MCP image result', async (t) => {
const { cctvProxy } = await import('../../../server/providers/cctv.js');
const { createCctvSource } = await import('../../layers/cctv/source.js');
const camera = {
id: 'fixture-camera',
name: 'Fixture crossing',
city: 'Fixture City',
provider: 'Fixture transport agency',
credit: 'Fixture transport agency',
lat: 30.2685,
lon: -97.7425,
headingDeg: 180,
fovDeg: 60,
url: 'https://camera.example/current.png',
feedType: 'image',
};
const environment = {
CCTV_SOURCES_JSON: JSON.stringify([camera]),
CCTV_PREFER_AUSTIN: '0',
CCTV_FORCE_AUSTIN: '0',
CCTV_SOURCES_FILE: 'missing-cctv-provenance-fixture.json',
GOOGLE_MAPS_SERVER_API_KEY: 'fixture-key',
GOOGLE_MAPS_API_KEY: '',
};
for (const [name, value] of Object.entries(environment)) {
const previous = process.env[name];
t.after(() => {
if (previous === undefined) delete process.env[name];
else process.env[name] = previous;
});
process.env[name] = value;
}
t.mock.method(console, 'warn', () => {});
let upstreamAvailable = false;
const calls = [];
t.mock.method(globalThis, 'fetch', async (raw) => {
const url = new URL(raw);
calls.push(url.origin);
if (url.origin === 'https://camera.example')
return upstreamAvailable
? new Response(PNG, { headers: { 'Content-Type': 'image/png' } })
: new Response(null, { status: 503 });
assert.equal(
url.origin,
'https://maps.googleapis.com',
'no actual upstream is contacted',
);
assert.equal(url.searchParams.get('key'), 'fixture-key');
return new Response(PNG, { headers: { 'Content-Type': 'image/png' } });
});
let middleware;
cctvProxy().configureServer({
middlewares: {
use(route, handler) {
assert.equal(route, '/api/cctv');
middleware = handler;
},
},
});
const source = createCctvSource({
fetchImpl: async (path) => {
assert.ok(path.startsWith('/api/cctv/'));
const response = {
writeHead(status, headers) {
this.status = status;
this.headers = headers;
},
end(body) {
this.body = body;
},
};
await middleware(
{ url: path.slice('/api/cctv'.length), method: 'GET', headers: {} },
response,
);
return new Response(response.body, {
status: response.status,
headers: response.headers,
});
},
});
const catalog = composeCatalog({
tools: coreTools,
services: { cctv: source },
});
const fallback = await catalog.call('get_cctv_snapshot', {
camera_id: camera.id,
});
assert.equal(fallback.data.source_kind, 'streetview');
assert.equal(fallback.data.current_view, false);
assert.equal(fallback.data.credit, 'Google Street View');
assert.match(fallback.summary, /Street View fallback/);
assert.match(fallback.summary, /Historical imagery/);
assert.doesNotMatch(
fallback.summary,
/Current view|courtesy of Fixture transport agency/,
);
assert.equal(
fallback.images.length,
1,
'the historical image stays available with its caveat',
);
assert.deepEqual(calls, [
'https://camera.example',
'https://maps.googleapis.com',
]);
upstreamAvailable = true;
const current = await catalog.call('get_cctv_snapshot', {
camera_id: camera.id,
});
assert.equal(current.data.source_kind, 'upstream-image');
assert.equal(current.data.current_view, true);
assert.equal(current.data.credit, camera.credit);
assert.match(current.summary, /^Current view from/);
const server = createMcpServer({ catalog, name: 'test', version: '1' });
upstreamAvailable = false;
const result = await server.handle({
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: { name: 'get_cctv_snapshot', arguments: { camera_id: camera.id } },
});
assert.ok(
result.result.content.some(
(entry) => entry.type === 'text' && /Historical imagery/.test(entry.text),
),
);
assert.ok(result.result.content.some((entry) => entry.type === 'image'));
process.env.GOOGLE_MAPS_SERVER_API_KEY = '';
const placeholder = await source.getFrame(camera);
assert.equal(placeholder.sourceKind, 'synthetic');
assert.equal(placeholder.contentType, 'image/svg+xml');
await assert.rejects(
catalog.call('get_cctv_snapshot', { camera_id: camera.id }),
(error) => error.code === 'unavailable',
);
const unavailable = await server.handle({
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: { name: 'get_cctv_snapshot', arguments: { camera_id: camera.id } },
});
assert.equal(unavailable.result.isError, true);
assert.ok(
!unavailable.result.content.some((entry) => entry.type === 'image'),
);
});
test('snapshot placeholders are refused and unknown frame provenance is explicit', async () => {
const synthetic = composeCatalog({
tools: coreTools,
services: {
cctv: cctv({
contentType: 'image/png',
bytes: PNG,
sourceKind: 'synthetic',
}),
},
});
await assert.rejects(
synthetic.call('get_cctv_snapshot', { camera_id: 'austin-1' }),
(error) => error.code === 'unavailable',
);
const unknown = await composeCatalog({
tools: coreTools,
services: { cctv: cctv({ contentType: 'image/png', bytes: PNG }) },
}).call('get_cctv_snapshot', { camera_id: 'austin-1' });
assert.equal(unknown.data.current_view, null);
assert.equal(
unknown.data.credit,
cameras[0].credit,
'catalog attribution is preserved',
);
assert.match(unknown.summary, /provenance is unknown/);
assert.doesNotMatch(unknown.summary, /Current view/);
});
test('unknown cameras, video-only feeds and oversized frames are reported', async () => {
const catalog = (frame) =>
composeCatalog({ tools: coreTools, services: { cctv: cctv(frame) } });
+2 -4
View File
@@ -344,7 +344,6 @@ export function createRecentImageryPanel({
const detailsHost = el('div', 'ri-details', root);
detailsHost.id = 'ri-details';
let detailsOpen = false;
let detailsShownOpen = false;
const details = createRailCards({
container: detailsHost,
document,
@@ -352,6 +351,8 @@ export function createRecentImageryPanel({
onOpen: () => {
detailsOpen = !detailsOpen;
render();
// After the render: preserveScroll() puts the old position back.
if (detailsOpen) revealCard(container, detailsHost.children[0]);
},
});
@@ -875,9 +876,6 @@ export function createRecentImageryPanel({
],
},
]);
if (detailsOpen && !detailsShownOpen)
revealCard(container, detailsHost.children[0]);
detailsShownOpen = detailsOpen;
}
// ---- divider ----------------------------------------------------------
+23
View File
@@ -1147,6 +1147,29 @@ test('the body scroll position survives renders even when DOM mutations reset it
assert.equal(f.body.scrollTop, 120);
});
test('opening DETAILS scrolls its card into view, and closing it does not scroll', async () => {
const f = fixture();
await f.ready();
// The body is 100 px high and scrolled 40 px; the card opens 150 px down.
f.body.scrollTop = 40;
f.body.clientHeight = 100;
f.body.getBoundingClientRect = () => ({ top: 0 });
const header = f
.byId('ri-details')
.find((node) => node.classList.contains('rail-card-header'));
const card = header.parentNode;
card.getBoundingClientRect = () => ({
top: 150,
height: card.dataset.open === 'true' ? 60 : 0,
});
header.click();
assert.equal(card.dataset.open, 'true');
assert.equal(f.body.scrollTop, 150);
header.click();
assert.equal(card.dataset.open, 'false');
assert.equal(f.body.scrollTop, 150);
});
test('destroy returns every listener, the divider and the panel state', async () => {
const f = fixture();
await f.ready();