mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
Merge remote-tracking branch 'origin/main' into HEAD
This commit is contained in:
commit
3748e9c23a
85 files changed
+3409
-195
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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), []);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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() {
|
||||
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 (
|
||||
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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) } });
|
||||
|
||||
@@ -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 ----------------------------------------------------------
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user