mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
The fire and camera labels in the context store, getStrongestFire() and getSelectedInfo() were translated at the source, so the voice model got "Пожар · FRP 12 МВт" or "Cámara ALPR" in track_entity, get_entity_context and pointer_context while every other entity name in those payloads is English. Keep them English, as before the interface was translated; the voice card translates them where it shows them.
1516 lines
46 KiB
JavaScript
1516 lines
46 KiB
JavaScript
// src/data/alprCameras.test.mjs
|
|
// Pure helpers and actual layer lifecycle with a minimal viewer/input harness.
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import * as Cesium from 'cesium';
|
|
import alprCamerasLayer, {
|
|
createAlprCamerasLayer,
|
|
configureAlprSource,
|
|
} from './alprCameras.js';
|
|
import { createAlprTileSource } from '../layers/alpr/source.js';
|
|
import { DATA_CREDITS } from './dataCredits.js';
|
|
import { setLocale } from '../i18n/index.js';
|
|
import {
|
|
clearSelectedEntityContextForLayer,
|
|
getContextStore,
|
|
getSelectedEntityContext,
|
|
registerEntityContext,
|
|
selectEntityContext,
|
|
} from './contextStore.js';
|
|
import {
|
|
MAX_RENDERED,
|
|
QUERY_LIMIT,
|
|
QUERY_SNAP_DEGREES,
|
|
QUERY_REUSE_MS,
|
|
alprRetryDelayMs,
|
|
boxContains,
|
|
buildOverpassQuery,
|
|
snapAlprBox,
|
|
destinationPointDeg,
|
|
isAlprSurveillanceType,
|
|
normalizeAlprNode,
|
|
} from './alprCameras.js';
|
|
|
|
test('normalizeAlprNode: maps a full OSM node to a plain record', () => {
|
|
const record = normalizeAlprNode({
|
|
type: 'node',
|
|
id: 12345,
|
|
lat: 30.2672,
|
|
lon: -97.7431,
|
|
tags: {
|
|
man_made: 'surveillance',
|
|
'surveillance:type': 'ALPR',
|
|
operator: 'Flock Safety',
|
|
'camera:type': 'fixed',
|
|
'surveillance:zone': 'traffic',
|
|
'camera:direction': '270',
|
|
ref: 'ATX-001',
|
|
check_date: '2026-06-01',
|
|
},
|
|
});
|
|
assert.deepEqual(record, {
|
|
id: 'alpr:12345',
|
|
osmId: 12345,
|
|
latitude: 30.2672,
|
|
longitude: -97.7431,
|
|
operator: 'Flock Safety',
|
|
manufacturer: null,
|
|
cameraType: 'fixed',
|
|
zone: 'traffic',
|
|
directionDeg: 270,
|
|
ref: 'ATX-001',
|
|
lastVerified: '2026-06-01',
|
|
source: null,
|
|
});
|
|
});
|
|
|
|
test('normalizeAlprNode: rejects non-node elements and missing coordinates', () => {
|
|
assert.equal(normalizeAlprNode(null), null);
|
|
assert.equal(normalizeAlprNode({ type: 'way', id: 1, tags: {} }), null);
|
|
assert.equal(
|
|
normalizeAlprNode({ type: 'node', id: 1, lat: NaN, lon: 1, tags: {} }),
|
|
null,
|
|
);
|
|
});
|
|
|
|
test('normalizeAlprNode: a present-but-blank direction tag reads as missing, not zero', () => {
|
|
const record = normalizeAlprNode({
|
|
type: 'node',
|
|
id: 1,
|
|
lat: 30,
|
|
lon: -97,
|
|
tags: {
|
|
man_made: 'surveillance',
|
|
'surveillance:type': 'ALPR',
|
|
'camera:direction': '',
|
|
},
|
|
});
|
|
assert.equal(record.directionDeg, null);
|
|
});
|
|
|
|
test('destinationPointDeg: due-north offset increases latitude, keeps longitude', () => {
|
|
const dest = destinationPointDeg(30, -97, 0, 100);
|
|
assert.ok(dest.latitude > 30);
|
|
assert.ok(Math.abs(dest.longitude - -97) < 1e-6);
|
|
});
|
|
|
|
test('alprRetryDelayMs: doubles from a 30s floor to a 240s ceiling', () => {
|
|
assert.equal(alprRetryDelayMs(0), 30000);
|
|
assert.equal(alprRetryDelayMs(30000), 60000);
|
|
assert.equal(alprRetryDelayMs(200000), 240000);
|
|
assert.equal(alprRetryDelayMs(240000), 240000);
|
|
});
|
|
|
|
test('isAlprSurveillanceType: accepts semicolon multi-values and mixed case, rejects other cameras', () => {
|
|
assert.equal(isAlprSurveillanceType('ALPR'), true);
|
|
assert.equal(isAlprSurveillanceType('alpr'), true);
|
|
assert.equal(isAlprSurveillanceType('camera;ALPR'), true);
|
|
assert.equal(isAlprSurveillanceType('ALPR; camera'), true);
|
|
assert.equal(isAlprSurveillanceType('camera'), false);
|
|
assert.equal(isAlprSurveillanceType('ALPRX'), false);
|
|
assert.equal(isAlprSurveillanceType(undefined), false);
|
|
});
|
|
|
|
test('normalizeAlprNode: keeps a camera;ALPR multi-value node and drops a plain camera', () => {
|
|
const multi = normalizeAlprNode({
|
|
type: 'node',
|
|
id: 2,
|
|
lat: 30,
|
|
lon: -97,
|
|
tags: { man_made: 'surveillance', 'surveillance:type': 'camera;ALPR' },
|
|
});
|
|
assert.equal(multi?.id, 'alpr:2');
|
|
const plain = normalizeAlprNode({
|
|
type: 'node',
|
|
id: 3,
|
|
lat: 30,
|
|
lon: -97,
|
|
tags: { man_made: 'surveillance', 'surveillance:type': 'camera' },
|
|
});
|
|
assert.equal(plain, null);
|
|
});
|
|
|
|
test('buildOverpassQuery: matches multi-value tags case-insensitively and asks for the full cap', () => {
|
|
const query = buildOverpassQuery(30, -98, 31, -97);
|
|
assert.match(
|
|
query,
|
|
/\["surveillance:type"~"[^"]*ALPR[^"]*",i\]/,
|
|
'regex, case-insensitive match',
|
|
);
|
|
assert.doesNotMatch(
|
|
query,
|
|
/"surveillance:type"="ALPR"/,
|
|
'no exact-only match',
|
|
);
|
|
assert.ok(query.endsWith(`out body ${QUERY_LIMIT};`));
|
|
assert.ok(
|
|
QUERY_LIMIT > 0 && QUERY_LIMIT <= 1500,
|
|
'bounded query, not a global camera catalog',
|
|
);
|
|
assert.ok(
|
|
MAX_RENDERED >= QUERY_LIMIT,
|
|
'a render cap below the query cap silently hides cameras while count reports them',
|
|
);
|
|
});
|
|
|
|
test('normalizeAlprNode: a whitespace-only direction tag reads as missing, not due north', () => {
|
|
const record = normalizeAlprNode({
|
|
type: 'node',
|
|
id: 9,
|
|
lat: 30,
|
|
lon: -97,
|
|
tags: {
|
|
man_made: 'surveillance',
|
|
'surveillance:type': 'ALPR',
|
|
'camera:direction': ' ',
|
|
},
|
|
});
|
|
assert.equal(record.directionDeg, null);
|
|
});
|
|
|
|
test('snapAlprBox: snaps outward to the query grid and clamps to valid ranges', () => {
|
|
const snapped = snapAlprBox({
|
|
south: 30.2671,
|
|
west: -97.7431,
|
|
north: 30.3012,
|
|
east: -97.7002,
|
|
});
|
|
assert.deepEqual(snapped, {
|
|
south: 30.25,
|
|
west: -97.75,
|
|
north: 30.35,
|
|
east: -97.7,
|
|
});
|
|
assert.ok(
|
|
snapped.south <= 30.2671 &&
|
|
snapped.north >= 30.3012 &&
|
|
snapped.west <= -97.7431 &&
|
|
snapped.east >= -97.7002,
|
|
);
|
|
assert.equal(
|
|
snapAlprBox({ south: 89.99, west: 179.99, north: 90, east: 180 }).north,
|
|
90,
|
|
);
|
|
assert.equal(
|
|
snapAlprBox({ south: -90, west: -180, north: -89.99, east: -179.99 }).west,
|
|
-180,
|
|
);
|
|
assert.ok(
|
|
QUERY_SNAP_DEGREES > 0 && QUERY_SNAP_DEGREES <= 0.1,
|
|
'grid stays a fraction of a metro so reuse cannot over-fetch',
|
|
);
|
|
});
|
|
|
|
test('boxContains: a nudged view stays inside its snapped box; a real pan leaves it', () => {
|
|
const outer = snapAlprBox({
|
|
south: 30.2671,
|
|
west: -97.7431,
|
|
north: 30.3012,
|
|
east: -97.7002,
|
|
});
|
|
assert.equal(
|
|
boxContains(outer, {
|
|
south: 30.27,
|
|
west: -97.74,
|
|
north: 30.3,
|
|
east: -97.71,
|
|
}),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
boxContains(outer, {
|
|
south: 30.36,
|
|
west: -97.74,
|
|
north: 30.4,
|
|
east: -97.71,
|
|
}),
|
|
false,
|
|
);
|
|
assert.equal(boxContains(null, outer), false);
|
|
});
|
|
|
|
const cameraNode = (id = 42, extra = {}) => ({
|
|
type: 'node',
|
|
id,
|
|
lat: 30.2672,
|
|
lon: -97.7431,
|
|
tags: {
|
|
man_made: 'surveillance',
|
|
'surveillance:type': 'ALPR',
|
|
manufacturer: 'Flock Safety',
|
|
},
|
|
...extra,
|
|
});
|
|
const cameraResponse = (elements = [cameraNode()], stale = false) => ({
|
|
ok: true,
|
|
status: 200,
|
|
headers: {
|
|
get: (name) => (name === 'x-overpass-cache' && stale ? 'STALE' : null),
|
|
},
|
|
json: async () => ({ elements }),
|
|
});
|
|
|
|
// Real layer, Cesium entities and context store. Only the DOM, upstream fetch,
|
|
// camera rectangle and input event delivery are substituted (no GL in Node).
|
|
function cameraHarness(layer = alprCamerasLayer) {
|
|
const originals = {
|
|
document: globalThis.document,
|
|
window: globalThis.window,
|
|
fetch: globalThis.fetch,
|
|
now: Date.now,
|
|
};
|
|
const events = new EventTarget();
|
|
globalThis.document = { addEventListener() {}, removeEventListener() {} };
|
|
globalThis.window = events;
|
|
let now = originals.now();
|
|
Date.now = () => now;
|
|
let response = async () => cameraResponse();
|
|
const requests = [];
|
|
globalThis.fetch = (...args) => {
|
|
// Marker floors resolve through the shared terrain service; these tests
|
|
// count camera requests only.
|
|
if (String(args[0]).includes('/api/terrain/heights'))
|
|
return Promise.resolve(Response.json({ results: [] }));
|
|
requests.push(args);
|
|
return response(...args);
|
|
};
|
|
// Rendering tests use normalized records; tile transport has its own fixture suite.
|
|
if (layer === alprCamerasLayer)
|
|
configureAlprSource({
|
|
...createAlprTileSource(),
|
|
async fetch(box, signal) {
|
|
const response = await globalThis.fetch('/test/cameras', {
|
|
signal,
|
|
body: `data=${encodeURIComponent(buildOverpassQuery(box.south, box.west, box.north, box.east))}`,
|
|
});
|
|
if (!response.ok)
|
|
throw new Error('Camera source temporarily unavailable');
|
|
const body = await response.json();
|
|
if (!Array.isArray(body.elements) || body.remark)
|
|
throw new Error('Incomplete fixture snapshot');
|
|
signal?.throwIfAborted();
|
|
return {
|
|
records: [
|
|
...new Map(
|
|
body.elements
|
|
.slice(0, QUERY_LIMIT)
|
|
.map(normalizeAlprNode)
|
|
.filter(Boolean)
|
|
.map((r) => [r.id, r]),
|
|
).values(),
|
|
],
|
|
stale: response.headers.get('x-overpass-cache') === 'STALE',
|
|
saturated: body.elements.length >= QUERY_LIMIT,
|
|
noCoverage: body.noCoverage,
|
|
zoomIn: body.zoomIn,
|
|
};
|
|
},
|
|
});
|
|
let box = { south: 30.26, west: -97.75, north: 30.28, east: -97.73 };
|
|
let source, click, picked;
|
|
const credits = new Set();
|
|
const viewer = {
|
|
creditDisplay: {
|
|
addStaticCredit: (credit) => credits.add(credit),
|
|
removeStaticCredit: (credit) => credits.delete(credit),
|
|
},
|
|
camera: {
|
|
moveEnd: new Cesium.Event(),
|
|
computeViewRectangle: () =>
|
|
box
|
|
? Cesium.Rectangle.fromDegrees(
|
|
box.west,
|
|
box.south,
|
|
box.east,
|
|
box.north,
|
|
)
|
|
: null,
|
|
},
|
|
scene: {
|
|
canvas: { addEventListener() {}, removeEventListener() {} },
|
|
globe: { ellipsoid: Cesium.Ellipsoid.WGS84 },
|
|
postRender: new Cesium.Event(),
|
|
pick: () => picked,
|
|
},
|
|
dataSources: {
|
|
add: (value) => {
|
|
source = value;
|
|
},
|
|
remove() {},
|
|
},
|
|
};
|
|
const setInput = Cesium.ScreenSpaceEventHandler.prototype.setInputAction;
|
|
Cesium.ScreenSpaceEventHandler.prototype.setInputAction = function (
|
|
fn,
|
|
type,
|
|
modifier,
|
|
) {
|
|
if (type === Cesium.ScreenSpaceEventType.LEFT_CLICK) click = fn;
|
|
return setInput.call(this, fn, type, modifier);
|
|
};
|
|
try {
|
|
layer.init(viewer);
|
|
} finally {
|
|
Cesium.ScreenSpaceEventHandler.prototype.setInputAction = setInput;
|
|
}
|
|
layer.enable();
|
|
return {
|
|
viewer,
|
|
requests,
|
|
credits,
|
|
get source() {
|
|
return source;
|
|
},
|
|
setFetch(fn) {
|
|
response = fn;
|
|
},
|
|
setBox(value) {
|
|
box = value;
|
|
},
|
|
expire() {
|
|
now += QUERY_REUSE_MS + 1;
|
|
},
|
|
advance(ms) {
|
|
now += ms;
|
|
},
|
|
click(id) {
|
|
const entity = typeof id === 'string' ? source.entities.getById(id) : id;
|
|
picked = entity ? { id: entity } : undefined;
|
|
click({ position: new Cesium.Cartesian2() });
|
|
},
|
|
restore() {
|
|
layer.destroy(viewer);
|
|
Date.now = originals.now;
|
|
for (const name of ['window', 'document', 'fetch']) {
|
|
if (originals[name] === undefined) delete globalThis[name];
|
|
else globalThis[name] = originals[name];
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
test('OSM attribution stays inline while ALPR displays and follows toggles', async (t) => {
|
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
|
const h = cameraHarness();
|
|
try {
|
|
assert.equal(h.credits.size, 0);
|
|
await alprCamerasLayer.update();
|
|
assert.equal(h.credits.size, 1);
|
|
const [credit] = h.credits;
|
|
assert.equal(credit.showOnScreen, true);
|
|
assert.match(credit.html, />© OpenStreetMap</);
|
|
assert.doesNotMatch(credit.html, /OpenMapTiles|Map and place data/);
|
|
t.mock.timers.tick(6000);
|
|
assert.equal(h.credits.size, 1);
|
|
alprCamerasLayer.disable();
|
|
assert.equal(h.credits.size, 0);
|
|
alprCamerasLayer.enable();
|
|
await alprCamerasLayer.update();
|
|
assert.equal(h.credits.size, 1);
|
|
assert.match(
|
|
DATA_CREDITS.find((entry) => entry.key === 'openstreetmap').html,
|
|
/OpenStreetMap contributors.*ODbL/,
|
|
);
|
|
} finally {
|
|
h.restore();
|
|
}
|
|
});
|
|
|
|
test('all manufacturers share one ALPR title and color; only supplied metadata appears underneath', async () => {
|
|
const h = cameraHarness();
|
|
try {
|
|
h.setFetch(async () =>
|
|
cameraResponse([
|
|
cameraNode(42, {
|
|
tags: {
|
|
'surveillance:type': 'ALPR',
|
|
manufacturer: 'Flock Safety',
|
|
operator: 'City Police',
|
|
'camera:type': 'fixed',
|
|
},
|
|
}),
|
|
cameraNode(43, {
|
|
tags: {
|
|
'surveillance:type': 'ALPR',
|
|
manufacturer: 'Motorola Solutions',
|
|
},
|
|
}),
|
|
cameraNode(44, { tags: { 'surveillance:type': 'ALPR' } }),
|
|
]),
|
|
);
|
|
await alprCamerasLayer.update();
|
|
const entities = h.source.entities.values;
|
|
assert.equal(alprCamerasLayer.name, 'ALPR Cameras');
|
|
assert.deepEqual(
|
|
entities.map((entity) => entity.gevLabelModel.title),
|
|
['ALPR-0042', 'ALPR-0043', 'ALPR-0044'],
|
|
);
|
|
assert.equal(
|
|
new Set(entities.map((entity) => entity.billboard.image.getValue())).size,
|
|
1,
|
|
);
|
|
assert.deepEqual(entities[0].gevLabelModel.details, [
|
|
'OSM MAPPED',
|
|
'FLOCK SAFETY · CITY POLICE · FIXED',
|
|
'PUBLIC MAP DATA',
|
|
]);
|
|
assert.deepEqual(entities[1].gevLabelModel.details, [
|
|
'OSM MAPPED',
|
|
'MOTOROLA SOLUTIONS',
|
|
'PUBLIC MAP DATA',
|
|
]);
|
|
assert.deepEqual(
|
|
entities[2].gevLabelModel.details,
|
|
['OSM MAPPED', 'PUBLIC MAP DATA'],
|
|
'unknown metadata is not guessed, but the source is always named',
|
|
);
|
|
for (const entity of entities) {
|
|
h.click(entity.id);
|
|
assert.equal(getSelectedEntityContext().label, 'ALPR camera');
|
|
assert.equal(getSelectedEntityContext().layerName, 'ALPR Cameras');
|
|
}
|
|
} finally {
|
|
h.restore();
|
|
}
|
|
});
|
|
|
|
// The voice model reads the label as data; the voice card translates it.
|
|
test('the camera label stays English data in a translated interface', async () => {
|
|
const h = cameraHarness();
|
|
setLocale('ru');
|
|
try {
|
|
h.setFetch(async () =>
|
|
cameraResponse([
|
|
cameraNode(42, { tags: { 'surveillance:type': 'ALPR' } }),
|
|
]),
|
|
);
|
|
await alprCamerasLayer.update();
|
|
h.click(h.source.entities.values[0].id);
|
|
assert.equal(getSelectedEntityContext().label, 'ALPR camera');
|
|
} finally {
|
|
setLocale('en');
|
|
h.restore();
|
|
}
|
|
});
|
|
|
|
test('invalid OSM ids and out-of-range coordinates never become camera entities', () => {
|
|
for (const id of [undefined, null, 0, -1, 1.5, '42', NaN])
|
|
assert.equal(normalizeAlprNode(cameraNode(id, { id })), null);
|
|
for (const coords of [
|
|
{ lat: 95 },
|
|
{ lat: -95 },
|
|
{ lon: 181 },
|
|
{ lon: -181 },
|
|
]) {
|
|
assert.equal(normalizeAlprNode(cameraNode(42, coords)), null);
|
|
}
|
|
});
|
|
|
|
test('clicking the selected camera or empty map deselects, including after refresh', async () => {
|
|
const h = cameraHarness();
|
|
try {
|
|
await alprCamerasLayer.update();
|
|
for (const next of ['alpr:42', null]) {
|
|
h.click('alpr:42');
|
|
assert.equal(getSelectedEntityContext()?.id, 'alpr:42');
|
|
h.click(next);
|
|
assert.equal(getSelectedEntityContext(), null);
|
|
h.expire();
|
|
await alprCamerasLayer.update();
|
|
assert.equal(getSelectedEntityContext(), null);
|
|
assert.equal(
|
|
h.source.entities.getById('alpr:42').billboard.width.getValue(),
|
|
38,
|
|
);
|
|
}
|
|
} finally {
|
|
h.restore();
|
|
}
|
|
});
|
|
|
|
test('a refresh cannot reclaim selection from another layer or an explicit clear', async () => {
|
|
const h = cameraHarness();
|
|
try {
|
|
await alprCamerasLayer.update();
|
|
h.click('alpr:42');
|
|
const aircraft = { id: 'flight:TEST' };
|
|
registerEntityContext(aircraft, { id: aircraft.id, layerId: 'flights' });
|
|
selectEntityContext(aircraft);
|
|
h.expire();
|
|
await alprCamerasLayer.update();
|
|
assert.equal(getSelectedEntityContext()?.id, aircraft.id);
|
|
h.click('alpr:42');
|
|
clearSelectedEntityContextForLayer('alpr-cameras');
|
|
h.expire();
|
|
await alprCamerasLayer.update();
|
|
assert.equal(getSelectedEntityContext(), null);
|
|
} finally {
|
|
h.restore();
|
|
}
|
|
});
|
|
|
|
test('only a surviving selected camera keeps selection through a dataset refresh', async () => {
|
|
const h = cameraHarness();
|
|
try {
|
|
await alprCamerasLayer.update();
|
|
h.click('alpr:42');
|
|
h.expire();
|
|
await alprCamerasLayer.update();
|
|
assert.equal(getSelectedEntityContext()?.id, 'alpr:42');
|
|
h.setFetch(async () => cameraResponse([cameraNode(43)]));
|
|
h.expire();
|
|
await alprCamerasLayer.update();
|
|
assert.equal(getSelectedEntityContext(), null);
|
|
} finally {
|
|
h.restore();
|
|
}
|
|
});
|
|
|
|
test('disable removes contexts and highlight; cache reuse after re-enable does not reselect', async () => {
|
|
const h = cameraHarness();
|
|
try {
|
|
await alprCamerasLayer.update();
|
|
h.click('alpr:42');
|
|
alprCamerasLayer.disable();
|
|
assert.equal(getSelectedEntityContext(), null);
|
|
assert.equal(getContextStore().entities.size, 0);
|
|
alprCamerasLayer.enable();
|
|
await alprCamerasLayer.update();
|
|
assert.equal(getSelectedEntityContext(), null);
|
|
assert.equal(
|
|
h.source.entities.getById('alpr:42').billboard.width.getValue(),
|
|
38,
|
|
);
|
|
} finally {
|
|
h.restore();
|
|
}
|
|
});
|
|
|
|
test('retry clears the old terminal error while the new request is loading', async () => {
|
|
const h = cameraHarness();
|
|
try {
|
|
h.setFetch(async () => ({ ...cameraResponse(), ok: false, status: 429 }));
|
|
await alprCamerasLayer.update();
|
|
assert.equal(alprCamerasLayer.getStats().status, 'unavailable');
|
|
let resolve;
|
|
h.setFetch(
|
|
() =>
|
|
new Promise((r) => {
|
|
resolve = r;
|
|
}),
|
|
);
|
|
const pending = alprCamerasLayer.update();
|
|
assert.equal(alprCamerasLayer.getStats().loading, true);
|
|
assert.equal(alprCamerasLayer.getStats().error, null);
|
|
assert.equal(alprCamerasLayer.getStats().status, 'loading');
|
|
resolve(cameraResponse());
|
|
await pending;
|
|
assert.equal(alprCamerasLayer.getStats().status, 'ready');
|
|
} finally {
|
|
h.restore();
|
|
}
|
|
});
|
|
|
|
test('a cancelled older request cannot overwrite a newer request with a late network error', async () => {
|
|
const h = cameraHarness();
|
|
try {
|
|
let rejectOld, resolveNew;
|
|
h.setFetch(
|
|
() =>
|
|
new Promise((resolve, reject) => {
|
|
rejectOld = reject;
|
|
}),
|
|
);
|
|
const old = alprCamerasLayer.update();
|
|
h.setBox({ south: 31, west: -98, north: 31.02, east: -97.98 });
|
|
h.setFetch(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
resolveNew = resolve;
|
|
}),
|
|
);
|
|
const fresh = alprCamerasLayer.update();
|
|
rejectOld(new TypeError('Network error on a cancelled request'));
|
|
await old;
|
|
assert.equal(alprCamerasLayer.getStats().status, 'loading');
|
|
assert.equal(alprCamerasLayer.getStats().error, null);
|
|
resolveNew(cameraResponse([]));
|
|
await fresh;
|
|
assert.equal(alprCamerasLayer.getStats().status, 'empty');
|
|
} finally {
|
|
h.restore();
|
|
}
|
|
});
|
|
|
|
test('invalid or partial successful payloads are failures, not an empty all-clear', async () => {
|
|
const h = cameraHarness();
|
|
try {
|
|
for (const payload of [
|
|
{},
|
|
{ elements: null },
|
|
{ elements: [], remark: 'runtime error: Query timed out' },
|
|
]) {
|
|
h.setFetch(async () => ({
|
|
...cameraResponse(),
|
|
json: async () => payload,
|
|
}));
|
|
await alprCamerasLayer.update();
|
|
assert.equal(alprCamerasLayer.getStats().status, 'unavailable');
|
|
}
|
|
} finally {
|
|
h.restore();
|
|
}
|
|
});
|
|
|
|
test('duplicate nodes render once with a count that agrees with the entity set', async () => {
|
|
const h = cameraHarness();
|
|
try {
|
|
h.setFetch(async () => cameraResponse([cameraNode(), cameraNode()]));
|
|
await alprCamerasLayer.update();
|
|
assert.equal(alprCamerasLayer.getStats().status, 'ready');
|
|
assert.equal(alprCamerasLayer.getStats().count, 1);
|
|
assert.equal(h.source.entities.values.length, 1);
|
|
} finally {
|
|
h.restore();
|
|
}
|
|
});
|
|
|
|
test('zooming out hides local markers, releases selection and makes no unbounded request', async () => {
|
|
const h = cameraHarness();
|
|
try {
|
|
await alprCamerasLayer.update();
|
|
h.click('alpr:42');
|
|
h.setBox(null);
|
|
await alprCamerasLayer.update();
|
|
assert.equal(alprCamerasLayer.getStats().status, 'zoom-in');
|
|
assert.equal(alprCamerasLayer.getStats().count, 0);
|
|
assert.equal(getSelectedEntityContext(), null);
|
|
assert.equal(h.source.entities.values.length, 0);
|
|
assert.equal(h.requests.length, 1);
|
|
} finally {
|
|
h.restore();
|
|
}
|
|
});
|
|
|
|
test('state, country and dateline views never request data; returning to the city reuses its cache', async () => {
|
|
const h = cameraHarness();
|
|
const city = { south: 30.26, west: -97.75, north: 30.28, east: -97.73 };
|
|
const wideViews = [
|
|
{ south: 26, west: -107, north: 36, east: -93 },
|
|
{ south: 24, west: -125, north: 50, east: -66 },
|
|
{ south: 30, west: -99, north: 31, east: -95 },
|
|
{ south: -1, west: 179, north: 1, east: -179 },
|
|
];
|
|
try {
|
|
for (const box of wideViews) {
|
|
h.setBox(box);
|
|
await alprCamerasLayer.update();
|
|
assert.equal(h.requests.length, 0, 'fresh wide view must not query');
|
|
assert.equal(alprCamerasLayer.getStats().status, 'zoom-in');
|
|
assert.equal(h.credits.size, 0, 'no data presentation has occurred');
|
|
}
|
|
h.setBox(city);
|
|
await alprCamerasLayer.update();
|
|
h.click('alpr:42');
|
|
for (const box of wideViews) {
|
|
h.setBox(box);
|
|
await alprCamerasLayer.update();
|
|
const stats = alprCamerasLayer.getStats();
|
|
assert.equal(stats.status, 'zoom-in');
|
|
assert.equal(stats.loading, false);
|
|
assert.equal(stats.error, null);
|
|
assert.equal(stats.retryAt, 0);
|
|
assert.equal(stats.count, 0);
|
|
assert.equal(getSelectedEntityContext(), null);
|
|
assert.equal(h.requests.length, 1, 'zoom out must not query');
|
|
}
|
|
h.setBox(city);
|
|
await alprCamerasLayer.update();
|
|
assert.equal(alprCamerasLayer.getStats().count, 1);
|
|
assert.equal(h.requests.length, 1, 'return uses fresh cached city data');
|
|
} finally {
|
|
h.restore();
|
|
}
|
|
});
|
|
|
|
test('nearby camera moves reuse the snapped query but count and register only in-view nodes', async () => {
|
|
const h = cameraHarness();
|
|
try {
|
|
h.setFetch(async () =>
|
|
cameraResponse([cameraNode(), cameraNode(43, { lon: -97.71 })]),
|
|
);
|
|
await alprCamerasLayer.update();
|
|
assert.equal(alprCamerasLayer.getStats().count, 1);
|
|
h.setBox({ south: 30.26, west: -97.72, north: 30.28, east: -97.7 });
|
|
await alprCamerasLayer.update();
|
|
assert.equal(h.requests.length, 1);
|
|
assert.deepEqual(
|
|
h.source.entities.values.map((e) => e.id),
|
|
['alpr:43'],
|
|
);
|
|
assert.deepEqual([...getContextStore().entities.keys()], ['alpr:43']);
|
|
h.expire();
|
|
await alprCamerasLayer.update();
|
|
assert.equal(h.requests.length, 2);
|
|
} finally {
|
|
h.restore();
|
|
}
|
|
});
|
|
|
|
test('empty mapped coverage, stale data and a saturated response remain distinct', async () => {
|
|
const h = cameraHarness();
|
|
try {
|
|
h.setFetch(async () => cameraResponse([]));
|
|
await alprCamerasLayer.update();
|
|
assert.equal(alprCamerasLayer.getStats().status, 'empty');
|
|
assert.match(
|
|
alprCamerasLayer.getStats().loadingLabel,
|
|
/No ALPR data for this area/,
|
|
);
|
|
h.expire();
|
|
h.setFetch(async () =>
|
|
cameraResponse(
|
|
Array.from({ length: QUERY_LIMIT }, (_, i) => cameraNode(i + 1)),
|
|
true,
|
|
),
|
|
);
|
|
await alprCamerasLayer.update();
|
|
assert.equal(alprCamerasLayer.getStats().status, 'stale');
|
|
assert.equal(alprCamerasLayer.getStats().error, null);
|
|
assert.equal(alprCamerasLayer.getStats().saturated, true);
|
|
assert.match(
|
|
alprCamerasLayer.getStats().loadingLabel,
|
|
/cached.*Coverage limited/,
|
|
);
|
|
} finally {
|
|
h.restore();
|
|
}
|
|
});
|
|
|
|
test('selected readout samples the rendered ground, not zero elevation or the direction line midpoint', async () => {
|
|
const h = cameraHarness();
|
|
try {
|
|
let samples = 0;
|
|
h.viewer.scene.sampleHeightSupported = true;
|
|
h.viewer.scene.sampleHeight = (point, excluded) => {
|
|
samples += 1;
|
|
assert.equal(excluded[0].id, 'alpr:42');
|
|
return 187;
|
|
};
|
|
h.setFetch(async () =>
|
|
cameraResponse([
|
|
cameraNode(42, {
|
|
tags: { 'surveillance:type': 'ALPR', 'camera:direction': '90' },
|
|
}),
|
|
]),
|
|
);
|
|
await alprCamerasLayer.update();
|
|
h.click('alpr:42');
|
|
const entity = h.source.entities.getById('alpr:42');
|
|
const anchor = Cesium.Cartographic.fromCartesian(
|
|
entity.gevDisplayPosition(),
|
|
);
|
|
assert.ok(Math.abs(anchor.height - 187) < 0.001);
|
|
assert.ok(
|
|
Math.abs(Cesium.Math.toDegrees(anchor.longitude) + 97.7431) < 1e-7,
|
|
);
|
|
for (let i = 0; i < 60; i++) {
|
|
h.viewer.scene.postRender.raiseEvent();
|
|
entity.gevDisplayPosition();
|
|
}
|
|
assert.equal(samples, 1, 'no per-frame raycasts');
|
|
h.advance(1000);
|
|
h.viewer.scene.postRender.raiseEvent();
|
|
assert.equal(samples, 2);
|
|
h.click(null);
|
|
h.advance(1000);
|
|
h.viewer.scene.postRender.raiseEvent();
|
|
assert.equal(samples, 2, 'no terrain sampling without a selection');
|
|
} finally {
|
|
h.restore();
|
|
}
|
|
});
|
|
|
|
test('a late network failure after disable cannot resurrect retries or an error state', async () => {
|
|
const h = cameraHarness();
|
|
try {
|
|
let reject;
|
|
h.setFetch(
|
|
() =>
|
|
new Promise((resolve, rejectRequest) => {
|
|
reject = rejectRequest;
|
|
}),
|
|
);
|
|
const pending = alprCamerasLayer.update();
|
|
alprCamerasLayer.disable();
|
|
reject(new TypeError('cancelled connection'));
|
|
await pending;
|
|
assert.equal(alprCamerasLayer.getStats().status, 'idle');
|
|
assert.equal(alprCamerasLayer.getStats().error, null);
|
|
assert.equal(alprCamerasLayer.getStats().retryAt, 0);
|
|
} finally {
|
|
h.restore();
|
|
}
|
|
});
|
|
|
|
test('two factories keep their requests, records and destruction independent', async () => {
|
|
const services = {
|
|
render: { governorRequestRender() {} },
|
|
picking: { registerPickOwner() {}, unregisterPickOwner() {} },
|
|
groundFloor: {
|
|
cachedGroundFloor() {
|
|
return null;
|
|
},
|
|
},
|
|
context: {
|
|
clearSelectedEntityContextForLayer() {},
|
|
getSelectedEntityContext() {
|
|
return null;
|
|
},
|
|
registerEntityContext() {},
|
|
removeEntityContextsForLayer() {},
|
|
selectEntityContext() {},
|
|
},
|
|
};
|
|
let resolveFirst, firstSignal;
|
|
const first = createAlprCamerasLayer({
|
|
services,
|
|
source: {
|
|
fetch: (_box, signal) => {
|
|
firstSignal = signal;
|
|
return new Promise((resolve) => {
|
|
resolveFirst = resolve;
|
|
});
|
|
},
|
|
},
|
|
});
|
|
const second = createAlprCamerasLayer({
|
|
services,
|
|
source: {
|
|
label: 'Test camera directory',
|
|
attribution: { name: 'Test directory' },
|
|
fetch: async () => ({
|
|
records: [
|
|
{ id: 'directory:99', latitude: 30.2672, longitude: -97.7431 },
|
|
],
|
|
stale: false,
|
|
saturated: false,
|
|
}),
|
|
},
|
|
});
|
|
const a = cameraHarness(first),
|
|
b = cameraHarness(second);
|
|
try {
|
|
const pending = first.update();
|
|
await second.update();
|
|
first.destroy();
|
|
assert.equal(firstSignal.aborted, true);
|
|
resolveFirst({
|
|
records: [normalizeAlprNode(cameraNode(42))],
|
|
stale: false,
|
|
saturated: false,
|
|
});
|
|
await pending;
|
|
assert.equal(first.getStats().count, 0);
|
|
assert.equal(second.getStats().count, 1);
|
|
assert.equal(b.source.entities.values[0].id, 'directory:99');
|
|
assert.equal(second.source, 'Test camera directory');
|
|
assert.ok(
|
|
b.source.entities.values[0].gevLabelModel.details.includes(
|
|
'Source: Test directory',
|
|
),
|
|
);
|
|
assert.equal(a.viewer.camera.moveEnd.numberOfListeners, 0);
|
|
assert.equal(a.viewer.scene.postRender.numberOfListeners, 0);
|
|
} finally {
|
|
b.restore();
|
|
a.restore();
|
|
}
|
|
});
|
|
|
|
test('markers sit on the cached floor; unknown floors clamp only until they resolve', async () => {
|
|
// Ground clamping re-samples every marker whenever a photoreal tile streams
|
|
// in; a whole-city view (about 1,000 cameras) stuttered while zooming.
|
|
const floors = new Map([['30.2672', 150]]);
|
|
let resolveCalls = 0;
|
|
let releaseResolve;
|
|
const services = {
|
|
render: { governorRequestRender() {} },
|
|
picking: { registerPickOwner() {}, unregisterPickOwner() {} },
|
|
groundFloor: {
|
|
cachedGroundFloor: (lat) => floors.get(lat.toFixed(4)) ?? null,
|
|
resolveGroundFloorCells: async () => {
|
|
resolveCalls++;
|
|
await new Promise((resolve) => {
|
|
releaseResolve = resolve;
|
|
});
|
|
floors.set('30.2682', 160);
|
|
},
|
|
},
|
|
context: {
|
|
clearSelectedEntityContextForLayer() {},
|
|
getSelectedEntityContext() {
|
|
return null;
|
|
},
|
|
registerEntityContext() {},
|
|
removeEntityContextsForLayer() {},
|
|
selectEntityContext() {},
|
|
},
|
|
};
|
|
const layer = createAlprCamerasLayer({
|
|
services,
|
|
source: {
|
|
fetch: async () => ({
|
|
records: [
|
|
{ id: 'directory:1', latitude: 30.2672, longitude: -97.7431 },
|
|
{ id: 'directory:2', latitude: 30.2682, longitude: -97.7431 },
|
|
],
|
|
stale: false,
|
|
saturated: false,
|
|
}),
|
|
},
|
|
});
|
|
const h = cameraHarness(layer);
|
|
try {
|
|
await layer.update();
|
|
const known = h.source.entities.getById('directory:1');
|
|
const unknown = h.source.entities.getById('directory:2');
|
|
assert.equal(
|
|
known.billboard.heightReference.getValue(),
|
|
Cesium.HeightReference.NONE,
|
|
);
|
|
assert.ok(
|
|
Math.abs(
|
|
Cesium.Cartographic.fromCartesian(known.position.getValue()).height -
|
|
151.5,
|
|
) < 0.01,
|
|
);
|
|
assert.equal(
|
|
unknown.billboard.heightReference.getValue(),
|
|
Cesium.HeightReference.CLAMP_TO_GROUND,
|
|
);
|
|
assert.equal(resolveCalls, 1);
|
|
releaseResolve();
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
assert.equal(
|
|
unknown.billboard.heightReference.getValue(),
|
|
Cesium.HeightReference.NONE,
|
|
);
|
|
assert.ok(
|
|
Math.abs(
|
|
Cesium.Cartographic.fromCartesian(unknown.position.getValue()).height -
|
|
161.5,
|
|
) < 0.01,
|
|
);
|
|
assert.equal(
|
|
h.source.entities.getById('directory:2'),
|
|
unknown,
|
|
'placed in place, not rebuilt',
|
|
);
|
|
} finally {
|
|
h.restore();
|
|
}
|
|
});
|
|
|
|
test('loaded cameras outside the view say so instead of looking unloaded', async () => {
|
|
// Field review: "nearby" spans twice the camera-to-ground range, so a view
|
|
// could hold cameras with none on screen and read as a failed load.
|
|
const h = cameraHarness();
|
|
const project = Cesium.SceneTransforms.worldToWindowCoordinates;
|
|
let inView = false;
|
|
Cesium.SceneTransforms.worldToWindowCoordinates = () =>
|
|
inView ? new Cesium.Cartesian2(100, 100) : new Cesium.Cartesian2(-500, 100);
|
|
try {
|
|
h.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(
|
|
-97.7431,
|
|
30.2672,
|
|
2000,
|
|
);
|
|
h.viewer.scene.canvas.clientWidth = 800;
|
|
h.viewer.scene.canvas.clientHeight = 600;
|
|
await alprCamerasLayer.update();
|
|
let stats = alprCamerasLayer.getStats();
|
|
assert.equal(stats.onScreen, 0);
|
|
assert.match(stats.countLabel, /1 nearby · 0 on screen/);
|
|
assert.match(stats.loadingLabel, /None on screen/);
|
|
inView = true;
|
|
h.viewer.camera.moveEnd.raiseEvent();
|
|
stats = alprCamerasLayer.getStats();
|
|
assert.equal(stats.onScreen, 1);
|
|
assert.doesNotMatch(stats.loadingLabel, /None on screen/);
|
|
} finally {
|
|
Cesium.SceneTransforms.worldToWindowCoordinates = project;
|
|
h.restore();
|
|
}
|
|
});
|
|
|
|
test('orbit cache hits and metadata refreshes preserve marker geometry and selection events', async () => {
|
|
const h = cameraHarness();
|
|
try {
|
|
h.setFetch(async () =>
|
|
cameraResponse([
|
|
cameraNode(42, {
|
|
tags: { 'surveillance:type': 'ALPR', 'camera:direction': '90' },
|
|
}),
|
|
]),
|
|
);
|
|
await alprCamerasLayer.update();
|
|
h.click('alpr:42');
|
|
const entity = h.source.entities.getById('alpr:42');
|
|
const position = entity.position,
|
|
line = entity.polyline;
|
|
// Ground-clamped native wedges cost ~40 ms per frame for a downtown view;
|
|
// bearings are painted by the overlay for the nearest cameras instead.
|
|
assert.equal(line, undefined);
|
|
assert.equal(entity.polygon, undefined);
|
|
let selections = 0,
|
|
clears = 0,
|
|
collectionChanges = 0;
|
|
window.addEventListener('gev:entity-selected', () => selections++);
|
|
window.addEventListener('gev:entity-selection-cleared', () => clears++);
|
|
h.source.entities.collectionChanged.addEventListener(
|
|
(_collection, added, removed) => {
|
|
collectionChanges += added.length + removed.length;
|
|
},
|
|
);
|
|
h.setBox({ south: 30.261, west: -97.749, north: 30.279, east: -97.731 });
|
|
await alprCamerasLayer.update();
|
|
h.expire();
|
|
h.setFetch(async () =>
|
|
cameraResponse([
|
|
cameraNode(42, {
|
|
tags: {
|
|
'surveillance:type': 'ALPR',
|
|
'camera:direction': '90',
|
|
operator: 'Updated directory',
|
|
},
|
|
}),
|
|
]),
|
|
);
|
|
await alprCamerasLayer.update();
|
|
assert.equal(h.source.entities.getById('alpr:42'), entity);
|
|
assert.equal(entity.position, position);
|
|
assert.equal(entity.polyline, line);
|
|
assert.equal(
|
|
getSelectedEntityContext().properties.operator,
|
|
'Updated directory',
|
|
);
|
|
assert.equal(selections, 0);
|
|
assert.equal(clears, 0);
|
|
assert.equal(collectionChanges, 0);
|
|
assert.equal(h.requests.length, 2, 'only expiry needs another request');
|
|
h.expire();
|
|
h.setFetch(async () => cameraResponse([cameraNode(42, { lon: -97.742 })]));
|
|
await alprCamerasLayer.update();
|
|
assert.equal(h.source.entities.getById('alpr:42'), entity);
|
|
assert.equal(
|
|
entity.polyline,
|
|
undefined,
|
|
'removed direction must not persist',
|
|
);
|
|
assert.ok(
|
|
Cesium.Cartesian3.equals(
|
|
entity.position.getValue(),
|
|
Cesium.Cartesian3.fromDegrees(-97.742, 30.2672),
|
|
),
|
|
);
|
|
} finally {
|
|
h.restore();
|
|
}
|
|
});
|
|
|
|
test('moves inside a pending query do not restart it; moving to another city cancels it', async () => {
|
|
const h = cameraHarness();
|
|
try {
|
|
let resolve;
|
|
h.setFetch(
|
|
() =>
|
|
new Promise((r) => {
|
|
resolve = r;
|
|
}),
|
|
);
|
|
const first = alprCamerasLayer.update();
|
|
const signal = h.requests[0][1].signal;
|
|
h.setBox({ south: 30.261, west: -97.749, north: 30.279, east: -97.731 });
|
|
await alprCamerasLayer.update();
|
|
assert.equal(h.requests.length, 1);
|
|
assert.equal(signal.aborted, false);
|
|
assert.equal(alprCamerasLayer.getStats().loading, true);
|
|
const finishFirst = resolve;
|
|
h.setBox({ south: 51.49, west: -0.14, north: 51.5, east: -0.12 });
|
|
const second = alprCamerasLayer.update();
|
|
assert.equal(signal.aborted, true);
|
|
assert.equal(h.requests.length, 2);
|
|
finishFirst(cameraResponse());
|
|
await first;
|
|
assert.equal(
|
|
alprCamerasLayer.getStats().loading,
|
|
true,
|
|
'late old response cannot finish current request',
|
|
);
|
|
resolve(cameraResponse([cameraNode(43, { lat: 51.495, lon: -0.13 })]));
|
|
await second;
|
|
assert.deepEqual(
|
|
h.source.entities.values.map((e) => e.id),
|
|
['alpr:43'],
|
|
);
|
|
} finally {
|
|
h.restore();
|
|
}
|
|
});
|
|
|
|
test('ground-centered orbits ignore horizon rectangles while sky, distant and dateline views stay bounded', async () => {
|
|
const h = cameraHarness();
|
|
const camera = h.viewer.camera;
|
|
const focus = Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672);
|
|
h.viewer.scene.canvas.clientWidth = 1440;
|
|
h.viewer.scene.canvas.clientHeight = 900;
|
|
camera.pickEllipsoid = () => focus;
|
|
camera.positionWC = Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, 1200);
|
|
try {
|
|
await alprCamerasLayer.update();
|
|
const entity = h.source.entities.getById('alpr:42');
|
|
h.click('alpr:42');
|
|
for (const box of [null, { south: 31, west: -99, north: 36, east: -95 }]) {
|
|
h.setBox(box);
|
|
await alprCamerasLayer.update();
|
|
assert.equal(h.source.entities.getById('alpr:42'), entity);
|
|
assert.equal(getSelectedEntityContext().id, 'alpr:42');
|
|
assert.equal(h.requests.length, 1);
|
|
}
|
|
camera.positionWC = Cesium.Cartesian3.fromDegrees(
|
|
-97.7431,
|
|
30.2672,
|
|
800000,
|
|
);
|
|
await alprCamerasLayer.update();
|
|
assert.equal(alprCamerasLayer.getStats().status, 'zoom-in');
|
|
assert.equal(h.source.entities.values.length, 0);
|
|
camera.pickEllipsoid = () => undefined;
|
|
await alprCamerasLayer.update();
|
|
assert.equal(alprCamerasLayer.getStats().status, 'zoom-in');
|
|
camera.pickEllipsoid = () => Cesium.Cartesian3.fromDegrees(179.999, 0);
|
|
camera.positionWC = Cesium.Cartesian3.fromDegrees(179.999, 0, 1200);
|
|
await alprCamerasLayer.update();
|
|
assert.equal(alprCamerasLayer.getStats().status, 'zoom-in');
|
|
assert.equal(h.requests.length, 1);
|
|
} finally {
|
|
h.restore();
|
|
}
|
|
});
|
|
|
|
test('nearby count and discovery control frame a real loaded camera without fetching or stealing tracking', async () => {
|
|
const h = cameraHarness();
|
|
const flights = [];
|
|
h.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(
|
|
-97.7431,
|
|
30.2672,
|
|
600,
|
|
);
|
|
h.viewer.camera.flyToBoundingSphere = (sphere, options) =>
|
|
flights.push({ sphere, options });
|
|
h.viewer.scene.globe.show = true;
|
|
h.viewer.scene.globe.getHeight = () => 312;
|
|
try {
|
|
assert.equal(alprCamerasLayer.getRowControls().chips[0].disabled, true);
|
|
await alprCamerasLayer.update();
|
|
assert.equal(alprCamerasLayer.getStats().countLabel, '1 nearby');
|
|
const controls = alprCamerasLayer.getRowControls();
|
|
assert.equal(controls.legend[0].label, 'Camera badges');
|
|
assert.equal(controls.chips[0].onClick(), true);
|
|
assert.equal(flights.length, 1);
|
|
assert.equal(getSelectedEntityContext().id, 'alpr:42');
|
|
const center = Cesium.Cartographic.fromCartesian(flights[0].sphere.center);
|
|
assert.ok(
|
|
Math.abs(Cesium.Math.toDegrees(center.latitude) - 30.2672) < 1e-9,
|
|
);
|
|
assert.ok(
|
|
Math.abs(center.height - 312) < 1e-6,
|
|
'use the rendered globe floor on keyless basemaps',
|
|
);
|
|
assert.equal(flights[0].options.offset.range, 800);
|
|
assert.equal(h.requests.length, 1);
|
|
h.viewer.trackedEntity = {};
|
|
assert.equal(alprCamerasLayer.getRowControls().chips[0].disabled, true);
|
|
assert.equal(
|
|
controls.chips[0].onClick(),
|
|
false,
|
|
'a stale button cannot steal the follow camera',
|
|
);
|
|
h.viewer.trackedEntity = undefined;
|
|
alprCamerasLayer.disable();
|
|
assert.equal(alprCamerasLayer.getStats().countLabel, '');
|
|
assert.equal(controls.chips[0].onClick(), false);
|
|
assert.equal(flights.length, 1);
|
|
} finally {
|
|
h.restore();
|
|
}
|
|
});
|
|
|
|
test('a non-retryable capability error does not arm the ALPR retry timer', async (t) => {
|
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
|
const h = cameraHarness();
|
|
try {
|
|
h.setFetch(async () => {
|
|
throw Object.assign(new Error('Unavailable'), {
|
|
code: 'OVERPASS_NOT_CONFIGURED',
|
|
retryable: false,
|
|
});
|
|
});
|
|
await alprCamerasLayer.update();
|
|
assert.equal(alprCamerasLayer.getStats().retryAt, 0);
|
|
const requests = h.requests.length;
|
|
t.mock.timers.tick(300_000);
|
|
await Promise.resolve();
|
|
assert.equal(h.requests.length, requests);
|
|
} finally {
|
|
h.restore();
|
|
}
|
|
});
|
|
|
|
test('unsupported ALPR coverage suppresses the nearby count and names the extract region', async () => {
|
|
const h = cameraHarness();
|
|
try {
|
|
h.setFetch(async () => ({
|
|
...cameraResponse([]),
|
|
json: async () => ({ elements: [], noCoverage: true }),
|
|
}));
|
|
await alprCamerasLayer.update();
|
|
const stats = alprCamerasLayer.getStats();
|
|
assert.equal(stats.noCoverage, true);
|
|
assert.equal(stats.countLabel, '');
|
|
assert.equal(
|
|
stats.loadingLabel,
|
|
'No ALPR data for this area — US and Canada only',
|
|
);
|
|
} finally {
|
|
h.restore();
|
|
}
|
|
});
|
|
|
|
import { createGroundFloor } from '../services/groundFloor.js';
|
|
import { createTerrainHeights } from '../services/terrainHeights.js';
|
|
import { createApplicationRequestServices } from '../services/requests.js';
|
|
|
|
test('slow ALPR floors share real terrain requests across preparation, render and pans; disable aborts', async () => {
|
|
const requests = [];
|
|
const sourceServices = createApplicationRequestServices({
|
|
fetchImpl: (url, { signal }) =>
|
|
new Promise((resolve, reject) => {
|
|
assert.match(url, /^\/api\/terrain\/heights\?/);
|
|
const points = new URL(url, 'http://localhost').searchParams
|
|
.get('points')
|
|
.split(';');
|
|
requests.push({
|
|
signal,
|
|
points,
|
|
release: () =>
|
|
resolve(
|
|
Response.json({
|
|
results: points.map(() => ({ ellipsoid: 160 })),
|
|
}),
|
|
),
|
|
});
|
|
signal.addEventListener('abort', () => reject(signal.reason), {
|
|
once: true,
|
|
});
|
|
}),
|
|
});
|
|
const terrain = createTerrainHeights({ source: sourceServices.terrain });
|
|
const ground = createGroundFloor({ terrain });
|
|
ground.FLOOR_RESOLVE_DEADLINE_MS = 1;
|
|
const records = Array.from({ length: 130 }, (_, i) => ({
|
|
id: `slow:${i}`,
|
|
latitude: 30.261 + Math.floor(i / 15) * 0.001,
|
|
longitude: -97.749 + (i % 15) * 0.001,
|
|
}));
|
|
const layer = createAlprCamerasLayer({
|
|
source: {
|
|
fetch: async () => ({ records, stale: false, saturated: false }),
|
|
},
|
|
services: {
|
|
groundFloor: ground,
|
|
render: { governorRequestRender() {} },
|
|
picking: { registerPickOwner() {}, unregisterPickOwner() {} },
|
|
context: {
|
|
clearSelectedEntityContextForLayer() {},
|
|
getSelectedEntityContext() {},
|
|
registerEntityContext() {},
|
|
removeEntityContextsForLayer() {},
|
|
selectEntityContext() {},
|
|
},
|
|
},
|
|
});
|
|
const h = cameraHarness(layer);
|
|
try {
|
|
await layer.update();
|
|
assert.equal(
|
|
requests.length,
|
|
1,
|
|
'render joins the outstanding preparation batch',
|
|
);
|
|
assert.equal(
|
|
requests[0].points.length,
|
|
64,
|
|
'one bounded batch runs at a time',
|
|
);
|
|
h.expire();
|
|
await layer.update();
|
|
await layer.update();
|
|
assert.equal(
|
|
requests.length,
|
|
1,
|
|
'further preparation and render reuse cold cell promises',
|
|
);
|
|
requests[0].release();
|
|
await new Promise((done) => setTimeout(done, 0));
|
|
assert.equal(requests.length, 2);
|
|
const requested = requests.flatMap((request) => request.points);
|
|
assert.equal(
|
|
new Set(requested).size,
|
|
requested.length,
|
|
'no duplicate cells over HTTP',
|
|
);
|
|
layer.disable();
|
|
assert.equal(
|
|
requests[1].signal.aborted,
|
|
true,
|
|
'disable cancels the active HTTP request',
|
|
);
|
|
requests[1].release();
|
|
await new Promise((done) => setTimeout(done, 0));
|
|
assert.equal(requests.length, 2, 'queued final cells are discarded');
|
|
assert.equal(h.source.entities.values.length, 0);
|
|
layer.enable();
|
|
await layer.update();
|
|
assert.equal(
|
|
requests.length,
|
|
3,
|
|
'a fresh consumer can resume unresolved cells',
|
|
);
|
|
layer.destroy();
|
|
assert.equal(
|
|
requests[2].signal.aborted,
|
|
true,
|
|
'destroy also aborts its consumer',
|
|
);
|
|
} finally {
|
|
h.restore();
|
|
}
|
|
});
|
|
|
|
test('map-source changes reposition all ALPR entities and saved native appearances in place', async () => {
|
|
let floor = 180;
|
|
let listener;
|
|
let removed = 0;
|
|
const records = Array.from({ length: 70 }, (_, i) => ({
|
|
id: `surface:${i}`,
|
|
latitude: 30.2672 + i * 0.00001,
|
|
longitude: -97.7431,
|
|
directionDeg: i === 0 ? null : 90,
|
|
}));
|
|
const layer = createAlprCamerasLayer({
|
|
source: {
|
|
fetch: async () => ({ records, stale: false, saturated: false }),
|
|
},
|
|
services: {
|
|
groundFloor: { cachedGroundFloor: () => floor },
|
|
overlays: {
|
|
subscribeMapStack(fn) {
|
|
listener = fn;
|
|
return () => {
|
|
removed++;
|
|
};
|
|
},
|
|
},
|
|
render: { governorRequestRender() {} },
|
|
picking: { registerPickOwner() {}, unregisterPickOwner() {} },
|
|
context: {
|
|
clearSelectedEntityContextForLayer() {},
|
|
getSelectedEntityContext() {},
|
|
registerEntityContext() {},
|
|
removeEntityContextsForLayer() {},
|
|
selectEntityContext() {},
|
|
},
|
|
},
|
|
});
|
|
const h = cameraHarness(layer);
|
|
try {
|
|
await layer.update();
|
|
const entities = [...h.source.entities.values];
|
|
const saved = entities[1];
|
|
saved.gevAlprNativeAppearance = {
|
|
position: saved.position,
|
|
heightReference: saved.billboard.heightReference,
|
|
};
|
|
saved.position = Cesium.Cartesian3.fromDegrees(-97.7431, 30.26721, 190);
|
|
saved.gevAlprPickPosition = saved.position;
|
|
for (const next of [150, 120, null, 180]) {
|
|
floor = next;
|
|
// Includes a provider switch with the same globe.show value.
|
|
h.viewer.scene.globe.show = next !== 180;
|
|
listener({ detail: { status: 'ready' } });
|
|
for (const entity of entities) {
|
|
assert.equal(h.source.entities.getById(entity.id), entity);
|
|
assert.ok(
|
|
Math.abs(
|
|
Cesium.Cartographic.fromCartesian(entity.position.getValue())
|
|
.height - (next == null ? 0 : next + 1.5),
|
|
) < 0.01,
|
|
`${entity.id} refreshes at ${next}`,
|
|
);
|
|
assert.equal(
|
|
entity.billboard.heightReference.getValue(),
|
|
next == null
|
|
? Cesium.HeightReference.CLAMP_TO_GROUND
|
|
: Cesium.HeightReference.NONE,
|
|
);
|
|
assert.equal(entity.gevAlprCanvasPosition, null);
|
|
}
|
|
assert.equal(
|
|
saved.gevAlprNativeAppearance,
|
|
null,
|
|
'saved appearance restores at the new floor',
|
|
);
|
|
}
|
|
} finally {
|
|
h.restore();
|
|
}
|
|
assert.equal(removed, 1, 'map listener is disposed');
|
|
});
|
|
|
|
test('a repeated move inside an outstanding query keeps the displayed marker set until commit', async () => {
|
|
const h = cameraHarness();
|
|
try {
|
|
await alprCamerasLayer.update();
|
|
const previous = h.source.entities.getById('alpr:42');
|
|
const revision = alprCamerasLayer.getStats().renderRevision;
|
|
let release;
|
|
h.setFetch(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
release = resolve;
|
|
}),
|
|
);
|
|
h.setBox({ south: 31, west: -98, north: 31.02, east: -97.98 });
|
|
const pending = alprCamerasLayer.update();
|
|
await alprCamerasLayer.update();
|
|
assert.equal(
|
|
h.source.entities.getById('alpr:42'),
|
|
previous,
|
|
'do not show an intermediate filtered snapshot while the new query prepares',
|
|
);
|
|
assert.equal(alprCamerasLayer.getStats().renderRevision, revision);
|
|
assert.equal(h.requests.length, 2, 'both moves share one new query');
|
|
release(cameraResponse([cameraNode(43, { lat: 31.01, lon: -97.99 })]));
|
|
await pending;
|
|
assert.equal(alprCamerasLayer.getStats().renderRevision, revision + 1);
|
|
assert.deepEqual(
|
|
h.source.entities.values.map((e) => e.id),
|
|
['alpr:43'],
|
|
);
|
|
} finally {
|
|
h.restore();
|
|
}
|
|
});
|