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 fix-1056
# Conflicts: # src/tools/queries/aviation.test.mjs
This commit is contained in:
commit
d604b97a4d
36 files changed
+794
-62
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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
+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', () => {
|
||||
|
||||
@@ -134,6 +134,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 ||
|
||||
|
||||
@@ -445,3 +445,62 @@ test('aircraft answers carry no breakdown or stats unless asked', async () => {
|
||||
/must be one of/,
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
Reference in new issue
Block a user