mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
Merge branch 'main' into codex/fix-kml-root-scanner
Resolve docs/CURRENT-STATE.md (and scripts/format-scope.json) by keeping both sides. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
2a0f78d169
190 files changed
+9967
-17519
No files matched your search
@@ -881,6 +881,7 @@ export function createAnalystEngine(providers) {
|
||||
followUp && !spec.scope ? lastResult.distanceOf || null : null;
|
||||
let resolvedScope = null;
|
||||
let scopeNote = rememberedScope?.coverage || 'anywhere';
|
||||
let scopeSource = rememberedScope?.source || null;
|
||||
// Human phrasing for the same scope, so every spoken count can name what it
|
||||
// measured ("8 in view", "about 30 within 250 km of Austin") instead of
|
||||
// arriving as a bare number that contradicts the panel.
|
||||
@@ -890,8 +891,16 @@ export function createAnalystEngine(providers) {
|
||||
// further when a new scope is given.
|
||||
const scope = spec.scope || { kind: followUp ? 'anywhere' : 'view' };
|
||||
if (scope.kind === 'region') {
|
||||
const region = await providers.resolveRegionRing(scope.name, signal);
|
||||
if (!isCurrent()) return cancelled();
|
||||
let region;
|
||||
try {
|
||||
region = await providers.resolveRegionRing(scope.name, signal);
|
||||
} catch (error) {
|
||||
// Cancellation belongs to the query owner, not an error's name: a
|
||||
// resource-local AbortError from a current lookup still propagates.
|
||||
if (signal?.aborted || !isCurrent()) return cancelled();
|
||||
throw error;
|
||||
}
|
||||
if (signal?.aborted || !isCurrent()) return cancelled();
|
||||
if (region?.error === 'region-timeout') {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -910,6 +919,7 @@ export function createAnalystEngine(providers) {
|
||||
}
|
||||
resolvedScope = region;
|
||||
scopeNote = `region:${region.name}`;
|
||||
scopeSource = region.source || null;
|
||||
scopeLabel = `over ${region.name}`;
|
||||
scopeDetail = scopeLabel;
|
||||
} else if (scope.kind === 'radius') {
|
||||
@@ -1049,6 +1059,7 @@ export function createAnalystEngine(providers) {
|
||||
layersQueried,
|
||||
records: totals,
|
||||
scope: scopeNote,
|
||||
...(scopeSource ? { scopeSource } : {}),
|
||||
...(rememberedScope?.note ? { note: rememberedScope.note } : {}),
|
||||
...(queriedSnapshots.length
|
||||
? { feedProvenance: feedProvenanceEnvelope(queriedSnapshots) }
|
||||
@@ -1081,6 +1092,7 @@ export function createAnalystEngine(providers) {
|
||||
label: memoryOverride?.scopeLabel || result.scopeLabel,
|
||||
detail: memoryOverride?.scopeDetail || result.display.scope,
|
||||
coverage: committedCoverage.scope,
|
||||
source: committedCoverage.scopeSource || null,
|
||||
note: committedCoverage.note || null,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createAnalystEngine, applyScope, haversineKm } from './analystEngine.js';
|
||||
import { createAnnotationResolver } from '../annotations/resolver.js';
|
||||
import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js';
|
||||
|
||||
// Stub world: a square "Texland" region, flights + ships + fires around it.
|
||||
const TEXLAND = { name: 'Texland', ring: [[-100, 28], [-94, 28], [-94, 33], [-100, 33]] };
|
||||
@@ -131,6 +133,175 @@ test('analyst: count flights over a region', async () => {
|
||||
assert.ok(r.coverage.scope.includes('Texland'));
|
||||
});
|
||||
|
||||
test('analyst: region coverage identifies the source of the boundary used for counting', async () => {
|
||||
for (const source of ['natural-earth', 'bundled-admin', 'remote-boundary']) {
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: async () => ({ ...TEXLAND, source }),
|
||||
});
|
||||
const result = await engine.query({
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'region', name: 'Texland' },
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.count, 3, 'the boundary excludes the Oregon flight');
|
||||
assert.equal(result.coverage.scope, 'region:Texland');
|
||||
assert.equal(result.coverage.scopeSource, source);
|
||||
}
|
||||
});
|
||||
|
||||
test('analyst: filter-only follow-ups retain the region source without resolving again', async () => {
|
||||
const lookups = [];
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: async (name) => {
|
||||
lookups.push(name);
|
||||
return { ...TEXLAND, source: 'natural-earth' };
|
||||
},
|
||||
});
|
||||
const first = await engine.query({
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'region', name: 'Texland' },
|
||||
});
|
||||
const airborne = await engine.query({
|
||||
followUp: true,
|
||||
filters: [{ field: 'onGround', op: 'eq', value: false }],
|
||||
});
|
||||
const military = await engine.query({
|
||||
followUp: true,
|
||||
filters: [{ field: 'military', op: 'eq', value: true }],
|
||||
});
|
||||
|
||||
assert.equal(first.count, 3);
|
||||
assert.equal(airborne.count, 2);
|
||||
assert.deepEqual(military.items.map((item) => item.id), ['RCH01']);
|
||||
for (const result of [first, airborne, military]) {
|
||||
assert.equal(result.coverage.scope, 'region:Texland');
|
||||
assert.equal(result.scopeLabel, 'over Texland');
|
||||
assert.equal(result.coverage.scopeSource, 'natural-earth');
|
||||
}
|
||||
assert.deepEqual(lookups, ['Texland']);
|
||||
});
|
||||
|
||||
test('analyst: an explicit replacement region replaces retained provenance', async () => {
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: async (name) => name === 'Texland'
|
||||
? { ...TEXLAND, source: 'natural-earth' }
|
||||
: {
|
||||
name,
|
||||
ring: [[-98, 30.5], [-96, 30.5], [-96, 31.5], [-98, 31.5]],
|
||||
source: 'remote-boundary',
|
||||
},
|
||||
});
|
||||
await engine.query({
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'region', name: 'Texland' },
|
||||
});
|
||||
const replacement = await engine.query({
|
||||
followUp: true,
|
||||
scope: { kind: 'region', name: 'Northern Texland' },
|
||||
});
|
||||
const followUp = await engine.query({ followUp: true });
|
||||
|
||||
for (const result of [replacement, followUp]) {
|
||||
assert.deepEqual(result.items.map((item) => item.id), ['RCH01']);
|
||||
assert.equal(result.coverage.scope, 'region:Northern Texland');
|
||||
assert.equal(result.coverage.scopeSource, 'remote-boundary');
|
||||
}
|
||||
});
|
||||
|
||||
test('analyst: an explicit scope without provenance clears the retained region source', async () => {
|
||||
for (const scope of [
|
||||
{ kind: 'anywhere' },
|
||||
{ kind: 'view' },
|
||||
{ kind: 'radius', km: 150 },
|
||||
{ kind: 'region', name: 'Uncredited Texland' },
|
||||
]) {
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: async (name) => ({
|
||||
...TEXLAND,
|
||||
name,
|
||||
...(name === 'Texland' ? { source: 'natural-earth' } : {}),
|
||||
}),
|
||||
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
|
||||
});
|
||||
const first = await engine.query({
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'region', name: 'Texland' },
|
||||
});
|
||||
const replacement = await engine.query({ followUp: true, scope });
|
||||
const followUp = await engine.query({ followUp: true });
|
||||
|
||||
assert.equal(first.coverage.scopeSource, 'natural-earth');
|
||||
for (const result of [replacement, followUp]) {
|
||||
assert.equal(result.ok, true, scope.kind);
|
||||
assert.equal(result.count, 3, scope.kind);
|
||||
assert.equal(Object.hasOwn(result.coverage, 'scopeSource'), false, scope.kind);
|
||||
assert.notEqual(result.coverage.scope, first.coverage.scope, scope.kind);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (const [label, committedScope, scopeSource] of [
|
||||
['replacement boundary source', 'region:Presented Texland', 'remote-boundary'],
|
||||
['window without a boundary source', 'window:presented', null],
|
||||
]) {
|
||||
test(`analyst: pre-commit displayed cohort retains ${label} and its feed provenance`, async () => {
|
||||
const committedSnapshot = {
|
||||
id: 'flights', enabled: true, feedState: 'stale',
|
||||
source: 'displayed-cohort-feed', lastUpdate: 1_800_000_000_000,
|
||||
};
|
||||
let reads = 0;
|
||||
let lookups = 0;
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => { reads++; return FLIGHTS; },
|
||||
getLayerSnapshot: () => ({
|
||||
id: 'flights', enabled: true, feedState: 'nominal', source: 'current-feed',
|
||||
}),
|
||||
resolveRegionRing: async () => {
|
||||
lookups++;
|
||||
return { ...TEXLAND, source: 'natural-earth' };
|
||||
},
|
||||
});
|
||||
await engine.query(
|
||||
{ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } },
|
||||
{
|
||||
beforeCommit: async (result) => {
|
||||
assert.equal(result.count, 3);
|
||||
assert.equal(result.coverage.scopeSource, 'natural-earth');
|
||||
const { scopeSource: _originalSource, ...coverage } = result.coverage;
|
||||
return {
|
||||
matched: [{ key: 'flights', rows: [FLIGHTS[1]] }],
|
||||
scopeLabel: 'the presented cohort',
|
||||
scopeDetail: 'the presented cohort window',
|
||||
coverage: {
|
||||
...coverage,
|
||||
scope: committedScope,
|
||||
...(scopeSource ? { scopeSource } : {}),
|
||||
feedProvenance: { layers: [committedSnapshot] },
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
const followUp = await engine.query({ followUp: true });
|
||||
assert.equal(followUp.count, 1);
|
||||
assert.deepEqual(followUp.items.map((item) => item.id), ['RCH01']);
|
||||
assert.equal(followUp.scopeLabel, 'the presented cohort');
|
||||
assert.equal(followUp.display.scope, 'the presented cohort window');
|
||||
assert.equal(followUp.coverage.scope, committedScope);
|
||||
if (scopeSource) assert.equal(followUp.coverage.scopeSource, scopeSource);
|
||||
else assert.equal(Object.hasOwn(followUp.coverage, 'scopeSource'), false);
|
||||
assert.equal(followUp.coverage.feedProvenance.overall, 'stale');
|
||||
assert.deepEqual(followUp.coverage.feedProvenance.layers, [committedSnapshot]);
|
||||
assert.equal(reads, 1, 'displayed cohort is not replaced by another feed read');
|
||||
assert.equal(lookups, 1, 'follow-up retains the committed scope without resolving again');
|
||||
});
|
||||
}
|
||||
|
||||
test('analyst: attribute filter — above 40,000 ft (~12,192 m)', async () => {
|
||||
const r = await makeEngine().query({
|
||||
layers: ['flights'], scope: { kind: 'anywhere' },
|
||||
@@ -317,6 +488,138 @@ test('analyst: region lookup receives and obeys the owning action signal', async
|
||||
assert.equal(result.cancelled, true);
|
||||
});
|
||||
|
||||
for (const [label, reason, trackAuthority] of [
|
||||
['default abort reason', undefined, false],
|
||||
['custom error reason', new Error('operator stopped'), false],
|
||||
['primitive reason and revoked authority', 'operator stopped', true],
|
||||
]) {
|
||||
test(`analyst: real region resolver cancellation preserves memory (${label})`, async () => {
|
||||
const started = Promise.withResolvers();
|
||||
const geocode = Promise.withResolvers();
|
||||
const controller = new AbortController();
|
||||
const lookups = [];
|
||||
const { resolveRegionRingForQuery } = createAnnotationResolver({
|
||||
featureSource: Object.fromEntries(FEATURE_SOURCE_METHODS.map((method) => [
|
||||
method,
|
||||
async () => { throw new Error('offline region query reached feature source'); },
|
||||
])),
|
||||
});
|
||||
const placeSearch = {
|
||||
geocode(name, { signal }) {
|
||||
lookups.push(name);
|
||||
assert.equal(signal, controller.signal);
|
||||
started.resolve();
|
||||
return geocode.promise;
|
||||
},
|
||||
};
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: (name, signal) => resolveRegionRingForQuery(
|
||||
name, signal, placeSearch, { budgetMs: Infinity },
|
||||
),
|
||||
});
|
||||
const first = await engine.query({
|
||||
layers: ['flights'], scope: { kind: 'region', name: 'Texas' },
|
||||
});
|
||||
assert.equal(first.ok, true);
|
||||
assert.equal(first.coverage.scopeSource, 'bundled-admin');
|
||||
assert.deepEqual(first.items.map((item) => item.id), ['SWA1', 'RCH01', 'GND1']);
|
||||
let commits = 0;
|
||||
const pending = engine.query(
|
||||
{ layers: ['flights'], scope: { kind: 'region', name: 'Cordillera Oriental' } },
|
||||
{
|
||||
signal: controller.signal,
|
||||
...(trackAuthority ? { isCurrent: () => !controller.signal.aborted } : {}),
|
||||
beforeCommit: async () => { commits++; },
|
||||
},
|
||||
);
|
||||
await started.promise;
|
||||
controller.abort(reason);
|
||||
// Let an actual late geocode result cross the resolver's post-await check.
|
||||
geocode.resolve({ place: {
|
||||
lat: 5, lng: -73, label: 'Cordillera Oriental',
|
||||
name: 'Cordillera Oriental', types: ['natural_feature'],
|
||||
} });
|
||||
const result = await pending;
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.code, 'CANCELLED');
|
||||
assert.equal(result.cancelled, true);
|
||||
assert.equal(commits, 0);
|
||||
const followUp = await engine.query({ followUp: true });
|
||||
assert.deepEqual(followUp.items.map((item) => item.id), first.items.map((item) => item.id));
|
||||
assert.equal(followUp.coverage.scope, first.coverage.scope);
|
||||
assert.equal(followUp.coverage.scopeSource, 'bundled-admin');
|
||||
assert.equal(followUp.scopeLabel, first.scopeLabel);
|
||||
assert.deepEqual(lookups, ['Cordillera Oriental'], 'follow-up does not resolve again');
|
||||
});
|
||||
}
|
||||
|
||||
test('analyst: a rejected superseded region lookup returns cancellation and retains its prior cohort', async () => {
|
||||
const lookup = Promise.withResolvers();
|
||||
let current = true;
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: (name) => name === 'Texland'
|
||||
? { ...TEXLAND, source: 'natural-earth' }
|
||||
: lookup.promise,
|
||||
});
|
||||
const first = await engine.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } });
|
||||
const pending = engine.query(
|
||||
{ layers: ['flights'], scope: { kind: 'region', name: 'Replacement' } },
|
||||
{ isCurrent: () => current },
|
||||
);
|
||||
current = false;
|
||||
lookup.reject(new Error('late lookup failed'));
|
||||
assert.equal((await pending).code, 'CANCELLED');
|
||||
const followUp = await engine.query({ followUp: true });
|
||||
assert.deepEqual(followUp.items.map((item) => item.id), first.items.map((item) => item.id));
|
||||
assert.equal(followUp.coverage.scopeSource, 'natural-earth');
|
||||
assert.equal(followUp.coverage.scope, first.coverage.scope);
|
||||
});
|
||||
|
||||
test('analyst: an aborted owner cannot commit a region provider that returns a late success', async () => {
|
||||
const controller = new AbortController();
|
||||
const lookup = Promise.withResolvers();
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: () => lookup.promise,
|
||||
});
|
||||
const pending = engine.query(
|
||||
{ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
controller.abort();
|
||||
lookup.resolve({ ...TEXLAND, source: 'natural-earth' });
|
||||
assert.equal((await pending).code, 'CANCELLED');
|
||||
assert.equal(engine.hasMemory(), false);
|
||||
});
|
||||
|
||||
for (const error of [new Error('live lookup failed'), new DOMException('resource stopped', 'AbortError')]) {
|
||||
test(`analyst: live region ${error.name} propagates without changing memory`, async () => {
|
||||
const controller = new AbortController();
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: async (name) => {
|
||||
if (name === 'Texland') return { ...TEXLAND, source: 'natural-earth' };
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
const first = await engine.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } });
|
||||
await assert.rejects(
|
||||
engine.query(
|
||||
{ layers: ['flights'], scope: { kind: 'region', name: 'Replacement' } },
|
||||
{ signal: controller.signal },
|
||||
),
|
||||
(thrown) => thrown === error,
|
||||
);
|
||||
assert.equal(controller.signal.aborted, false);
|
||||
const followUp = await engine.query({ followUp: true });
|
||||
assert.deepEqual(followUp.items.map((item) => item.id), first.items.map((item) => item.id));
|
||||
assert.equal(followUp.coverage.scope, first.coverage.scope);
|
||||
assert.equal(followUp.coverage.scopeSource, 'natural-earth');
|
||||
});
|
||||
}
|
||||
|
||||
test('analyst: route fields queryable from cached enrichment only', async () => {
|
||||
const r = await makeEngine().query({
|
||||
layers: ['flights'], scope: { kind: 'anywhere' },
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
hkCameraName,
|
||||
hkCameraToSource,
|
||||
hkImageUrlFromKey,
|
||||
loadHkSourcesFromOpenData,
|
||||
normalizeHkImageUrl,
|
||||
parseHkCameraLocationsXml,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
DEFAULT_HK_CAMERAS_URL,
|
||||
DEFAULT_HK_MAX_SOURCES,
|
||||
HK_ANCHORS,
|
||||
HK_IMAGE_ORIGIN,
|
||||
HK_MAX_CATALOG_BYTES,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import {
|
||||
CAMERA_CODE_MAX_CHARS,
|
||||
prioritizeSources,
|
||||
} from '../../server/providers/cctv/normalize.js';
|
||||
import { allocateSourceCap } from '../../server/providers/cctv/cap.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
|
||||
/**
|
||||
* A response whose body is a live stream, plus a flag that flips when the
|
||||
* stream is cancelled. A rejection path that returns without cancelling holds
|
||||
* the transport open, so the flag is what the refusal tests actually assert.
|
||||
*/
|
||||
const streamingResponse = (init = {}) => {
|
||||
const state = { cancelled: false };
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('<image-list>'));
|
||||
},
|
||||
cancel() {
|
||||
state.cancelled = true;
|
||||
},
|
||||
});
|
||||
return { response: new Response(body, init), state };
|
||||
};
|
||||
|
||||
/** One Transport Department `<image>` block as a parsed row. */
|
||||
const row = (overrides = {}) => ({
|
||||
key: 'BC101F',
|
||||
region: 'New Territories',
|
||||
district: 'North',
|
||||
description: 'San Tin Highway near Lok Ma Chau [BC101F]',
|
||||
latitude: '22.508',
|
||||
longitude: '114.078',
|
||||
url: 'https://tdcctv.data.one.gov.hk/BC101F.JPG',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/** Fixture XML matching the published English locations schema. */
|
||||
const fixtureXml = (...records) => {
|
||||
const images = records
|
||||
.map(
|
||||
(record) => `
|
||||
<image>
|
||||
<key>${record.key}</key>
|
||||
<region>${record.region}</region>
|
||||
<district>${record.district}</district>
|
||||
<description>${record.description}</description>
|
||||
<latitude>${record.latitude}</latitude>
|
||||
<longitude>${record.longitude}</longitude>
|
||||
<url>${record.url}</url>
|
||||
</image>`,
|
||||
)
|
||||
.join('');
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>\n<image-list>${images}\n</image-list>\n`;
|
||||
};
|
||||
|
||||
test('an HK row maps to a source on the pinned HTTPS frame host', () => {
|
||||
const source = hkCameraToSource(row());
|
||||
assert.equal(source.id, 'hk-bc101f');
|
||||
assert.equal(source.name, 'San Tin Highway near Lok Ma Chau [BC101F]');
|
||||
assert.equal(source.cityId, 'hk');
|
||||
assert.equal(source.provider, 'Transport Department');
|
||||
assert.equal(source.lat, 22.508);
|
||||
assert.equal(source.lon, 114.078);
|
||||
assert.equal(source.feedType, 'image');
|
||||
assert.equal(source.sourceKind, 'hk-td-open-data');
|
||||
assert.equal(
|
||||
source.license,
|
||||
'Hong Kong Transport Department / DATA.GOV.HK — attribution required',
|
||||
);
|
||||
assert.equal(source.headingConfidence, 'low');
|
||||
assert.ok(Number.isFinite(source.headingDeg));
|
||||
assert.equal(source.url, 'https://tdcctv.data.one.gov.hk/BC101F.JPG');
|
||||
assert.equal(source.url, source.snapshotUrl);
|
||||
assert.ok(source.url.startsWith(HK_IMAGE_ORIGIN));
|
||||
});
|
||||
|
||||
test('rows outside Hong Kong, off-host frames and unusable keys are dropped', () => {
|
||||
assert.equal(
|
||||
hkCameraToSource(row({ latitude: '43.6532', longitude: '-79.3832' })),
|
||||
null,
|
||||
);
|
||||
assert.equal(hkCameraToSource(row({ latitude: '0', longitude: '0' })), null);
|
||||
assert.equal(hkCameraToSource(row({ key: '' })), null);
|
||||
assert.equal(hkCameraToSource(row({ key: 'bad key!' })), null);
|
||||
assert.equal(
|
||||
hkCameraToSource(row({ url: 'https://evil.example/BC101F.JPG' })),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
hkCameraToSource(
|
||||
row({
|
||||
url: 'https://tdcctv.data.one.gov.hk.evil.test/BC101F.JPG',
|
||||
}),
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(hkCameraToSource(row({ url: 'file:///etc/passwd' })), null);
|
||||
assert.equal(hkCameraToSource(null), null);
|
||||
});
|
||||
|
||||
test('frame URLs are rebuilt from the key and pinned to the TD host', () => {
|
||||
assert.equal(
|
||||
hkImageUrlFromKey('bc101f'),
|
||||
'https://tdcctv.data.one.gov.hk/BC101F.JPG',
|
||||
);
|
||||
assert.equal(hkImageUrlFromKey(''), null);
|
||||
assert.equal(hkImageUrlFromKey('bad/key'), null);
|
||||
|
||||
assert.equal(
|
||||
normalizeHkImageUrl('http://tdcctv.data.one.gov.hk/H429F.JPG'),
|
||||
'https://tdcctv.data.one.gov.hk/H429F.JPG',
|
||||
);
|
||||
assert.equal(
|
||||
normalizeHkImageUrl('https://tdcctv.data.one.gov.hk/H429F.JPG'),
|
||||
'https://tdcctv.data.one.gov.hk/H429F.JPG',
|
||||
);
|
||||
assert.equal(normalizeHkImageUrl(''), null);
|
||||
assert.equal(normalizeHkImageUrl('not a url'), null);
|
||||
assert.equal(normalizeHkImageUrl('https://evil.example/H429F.JPG'), null);
|
||||
});
|
||||
|
||||
test('a nameless row still gets a label', () => {
|
||||
assert.equal(
|
||||
hkCameraName(
|
||||
{ description: ' Aberdeen Praya Road near Fish Market [H429F] ' },
|
||||
'hk-h429f',
|
||||
),
|
||||
'Aberdeen Praya Road near Fish Market [H429F]',
|
||||
);
|
||||
assert.equal(hkCameraName({ key: 'H429F' }, 'hk-h429f'), 'H429F');
|
||||
assert.equal(hkCameraName({}, 'hk-h429f'), 'HK Camera h429f');
|
||||
});
|
||||
|
||||
test('the XML parser reads each image block', () => {
|
||||
const rows = parseHkCameraLocationsXml(
|
||||
fixtureXml(
|
||||
row(),
|
||||
row({
|
||||
key: 'H429F',
|
||||
region: 'Hong Kong Island',
|
||||
district: 'Southern',
|
||||
description: 'Aberdeen Praya Road near Fish Market [H429F]',
|
||||
latitude: '22.24845',
|
||||
longitude: '114.1505',
|
||||
url: 'https://tdcctv.data.one.gov.hk/H429F.JPG',
|
||||
}),
|
||||
),
|
||||
);
|
||||
assert.equal(rows.length, 2);
|
||||
assert.equal(rows[0].key, 'BC101F');
|
||||
assert.equal(rows[1].key, 'H429F');
|
||||
assert.equal(rows[1].latitude, '22.24845');
|
||||
});
|
||||
|
||||
test('the loader reads the keyless catalog and collapses duplicate ids', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
return new Response(
|
||||
fixtureXml(
|
||||
row(),
|
||||
row({ description: 'San Tin Highway near Lok Ma Chau (duplicate)' }),
|
||||
row({
|
||||
key: 'H210F',
|
||||
region: 'Hong Kong Island',
|
||||
district: 'Wan Chai',
|
||||
description: 'Aberdeen Tunnel - Wan Chai Side [H210F]',
|
||||
latitude: '22.2747',
|
||||
longitude: '114.17936',
|
||||
url: 'https://tdcctv.data.one.gov.hk/H210F.JPG',
|
||||
}),
|
||||
row({ url: 'https://evil.example/x.jpg' }),
|
||||
),
|
||||
{ headers: { 'Content-Type': 'application/xml' } },
|
||||
);
|
||||
});
|
||||
const cameras = await loadHkSourcesFromOpenData();
|
||||
assert.deepEqual(requested, [DEFAULT_HK_CAMERAS_URL]);
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.id),
|
||||
// Nearest Central/TST first: Wan Chai before the northern New Territories.
|
||||
['hk-h210f', 'hk-bc101f'],
|
||||
);
|
||||
assert.equal(cameras[0].name, 'Aberdeen Tunnel - Wan Chai Side [H210F]');
|
||||
});
|
||||
|
||||
test('an upstream failure yields an empty pack and releases the response', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const failed = streamingResponse({ status: 503 });
|
||||
t.mock.method(globalThis, 'fetch', async () => failed.response);
|
||||
assert.deepEqual(await loadHkSourcesFromOpenData(), []);
|
||||
assert.equal(failed.state.cancelled, true, 'the failed body is cancelled');
|
||||
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
throw new Error('network down');
|
||||
});
|
||||
assert.deepEqual(await loadHkSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('the unselected label is the description, trimmed to the code width', () => {
|
||||
assert.equal(
|
||||
hkCameraToSource(row({ description: 'San Tin Highway [BC101F]' })).code,
|
||||
'SAN TIN HIGHWAY [BC101F]',
|
||||
);
|
||||
const long = hkCameraToSource(
|
||||
row({
|
||||
description:
|
||||
'Bow Trail & Old Banff Coach Rd SW / Strathcona Blvd SW [BC101F]',
|
||||
}),
|
||||
);
|
||||
assert.equal(long.code.length, CAMERA_CODE_MAX_CHARS);
|
||||
assert.ok(long.code.endsWith('…'));
|
||||
assert.ok(long.code.startsWith('BOW TRAIL & OLD BANFF'));
|
||||
});
|
||||
|
||||
test('the catalog fetch refuses redirects and oversized bodies', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const seen = [];
|
||||
const redirected = streamingResponse({
|
||||
status: 302,
|
||||
headers: { location: 'https://evil.example/cameras.xml' },
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
seen.push([String(url), init.redirect]);
|
||||
return redirected.response;
|
||||
});
|
||||
assert.deepEqual(await loadHkSourcesFromOpenData(), []);
|
||||
assert.deepEqual(seen, [[DEFAULT_HK_CAMERAS_URL, 'manual']]);
|
||||
assert.equal(
|
||||
redirected.state.cancelled,
|
||||
true,
|
||||
'the redirect body is cancelled',
|
||||
);
|
||||
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
const body = fixtureXml(row());
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
'Content-Type': 'application/xml',
|
||||
'content-length': String(HK_MAX_CATALOG_BYTES + 1),
|
||||
},
|
||||
});
|
||||
});
|
||||
assert.deepEqual(await loadHkSourcesFromOpenData(), []);
|
||||
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
const oversized = 'x'.repeat(HK_MAX_CATALOG_BYTES + 1024);
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(oversized));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/xml' } },
|
||||
);
|
||||
});
|
||||
assert.deepEqual(await loadHkSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('a reduced catalog cap thins every pack instead of dropping HK', () => {
|
||||
const lane = (name, count) => ({
|
||||
name,
|
||||
sources: Array.from({ length: count }, (_, i) => ({ id: `${name}-${i}` })),
|
||||
});
|
||||
const { sources, packs } = allocateSourceCap(
|
||||
[
|
||||
lane('austin', 250),
|
||||
lane('calgary', 215),
|
||||
lane('hk', DEFAULT_HK_MAX_SOURCES),
|
||||
],
|
||||
30,
|
||||
);
|
||||
assert.equal(sources.length, 30);
|
||||
assert.deepEqual(
|
||||
packs.map((p) => [p.name, p.kept]),
|
||||
[
|
||||
['austin', 10],
|
||||
['calgary', 10],
|
||||
['hk', 10],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.id.startsWith('hk-')).map((s) => s.id),
|
||||
Array.from({ length: 10 }, (_, i) => `hk-${i}`),
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Serve the HK catalog to the HK endpoint and an empty payload to every other
|
||||
* pack, so one catalog refresh exercises the registration without reaching the
|
||||
* network. Returns the URLs that were requested.
|
||||
*/
|
||||
const runCatalogWithMockedUpstreams = async (t) => {
|
||||
const requested = [];
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const href = String(url);
|
||||
requested.push(href);
|
||||
if (href.includes('Traffic_Camera_Locations_En.xml')) {
|
||||
return new Response(
|
||||
fixtureXml(
|
||||
row(),
|
||||
row({
|
||||
key: 'H210F',
|
||||
region: 'Hong Kong Island',
|
||||
district: 'Wan Chai',
|
||||
description: 'Aberdeen Tunnel - Wan Chai Side [H210F]',
|
||||
latitude: '22.2747',
|
||||
longitude: '114.17936',
|
||||
url: 'https://tdcctv.data.one.gov.hk/H210F.JPG',
|
||||
}),
|
||||
),
|
||||
{ headers: { 'Content-Type': 'application/xml' } },
|
||||
);
|
||||
}
|
||||
return Response.json([]);
|
||||
});
|
||||
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
|
||||
return { requested, sources };
|
||||
};
|
||||
|
||||
test('the HK lane is wired into the catalog and its loader runs', async (t) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
delete process.env.CCTV_SOURCES_FILE;
|
||||
delete process.env.CCTV_SOURCES_JSON;
|
||||
delete process.env.CCTV_HK_ENABLED;
|
||||
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
|
||||
assert.ok(
|
||||
requested.includes(DEFAULT_HK_CAMERAS_URL),
|
||||
'the catalog refresh invokes the HK loader',
|
||||
);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'hk').map((s) => s.id),
|
||||
['hk-h210f', 'hk-bc101f'],
|
||||
'HK cameras reach the served catalog through the registered lane',
|
||||
);
|
||||
} finally {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in saved)) delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, saved);
|
||||
}
|
||||
});
|
||||
|
||||
test('CCTV_HK_ENABLED=0 keeps the lane from being loaded at all', async (t) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
delete process.env.CCTV_SOURCES_FILE;
|
||||
delete process.env.CCTV_SOURCES_JSON;
|
||||
process.env.CCTV_HK_ENABLED = '0';
|
||||
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
|
||||
assert.equal(
|
||||
requested.includes(DEFAULT_HK_CAMERAS_URL),
|
||||
false,
|
||||
'the disabled lane never reaches its upstream',
|
||||
);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'hk'),
|
||||
[],
|
||||
);
|
||||
} finally {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in saved)) delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, saved);
|
||||
}
|
||||
});
|
||||
|
||||
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
|
||||
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
|
||||
});
|
||||
|
||||
test('the HK pack default sits above the full TD catalog and anchors the New Territories', () => {
|
||||
assert.ok(
|
||||
DEFAULT_HK_MAX_SOURCES >= 1013,
|
||||
'default cap should keep nearly all published TD cameras',
|
||||
);
|
||||
assert.ok(
|
||||
DEFAULT_HK_MAX_SOURCES <= DEFAULT_CCTV_MAX_SOURCES,
|
||||
'a single pack must not outgrow the catalog-wide ceiling alone',
|
||||
);
|
||||
const places = HK_ANCHORS.map(
|
||||
(a) => `${a.lat.toFixed(2)},${a.lon.toFixed(2)}`,
|
||||
);
|
||||
assert.ok(places.includes('22.38,114.19'), 'Sha Tin anchor');
|
||||
assert.ok(places.includes('22.37,114.11'), 'Tsuen Wan anchor');
|
||||
assert.ok(places.includes('22.45,114.17'), 'Tai Po anchor');
|
||||
assert.ok(places.includes('22.49,114.14'), 'Fanling anchor');
|
||||
assert.ok(places.includes('22.39,113.98'), 'Tuen Mun anchor');
|
||||
assert.ok(places.includes('22.44,114.02'), 'Yuen Long anchor');
|
||||
assert.ok(places.includes('22.31,114.26'), 'Tseung Kwan O anchor');
|
||||
assert.ok(places.includes('22.28,114.16'), 'Central anchor');
|
||||
assert.ok(places.includes('22.30,114.17'), 'Tsim Sha Tsui anchor');
|
||||
});
|
||||
|
||||
test('a lowered HK cap still keeps Sha Tin when New Territories anchors are set', () => {
|
||||
const cameras = [
|
||||
hkCameraToSource(
|
||||
row({
|
||||
key: 'H001F',
|
||||
region: 'Hong Kong Island',
|
||||
district: 'Central & Western',
|
||||
description: 'Central harbour [H001F]',
|
||||
latitude: '22.2819',
|
||||
longitude: '114.158',
|
||||
}),
|
||||
),
|
||||
hkCameraToSource(
|
||||
row({
|
||||
key: 'ST101F',
|
||||
region: 'New Territories',
|
||||
district: 'Sha Tin',
|
||||
description: 'Sha Tin Road [ST101F]',
|
||||
latitude: '22.3827',
|
||||
longitude: '114.19',
|
||||
}),
|
||||
),
|
||||
hkCameraToSource(
|
||||
row({
|
||||
key: 'K999F',
|
||||
region: 'Kowloon',
|
||||
district: 'Yau Tsim Mong',
|
||||
description: 'Far Kowloon filler [K999F]',
|
||||
latitude: '22.32',
|
||||
longitude: '114.18',
|
||||
}),
|
||||
),
|
||||
].filter(Boolean);
|
||||
const kept = prioritizeSources(cameras, 2, HK_ANCHORS);
|
||||
assert.deepEqual(kept.map((camera) => camera.id).sort(), [
|
||||
'hk-h001f',
|
||||
'hk-st101f',
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,342 @@
|
||||
import { afterEach, beforeEach, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
loadTaiwanSources,
|
||||
parseTaiwanCctvXml,
|
||||
resetTaiwanSourcesCache,
|
||||
taiwanCameraToSource,
|
||||
taiwanFrame,
|
||||
TAIWAN_FREEWAY_CCTV_URL,
|
||||
TAIWAN_MAX_CATALOG_BYTES,
|
||||
TAIWAN_RETRY_AFTER_FAILURE_MS,
|
||||
} from '../../server/providers/cctv/taiwan.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
|
||||
// Settings that would steer the loader or the catalog away from the mocks.
|
||||
const ISOLATED_ENV = [
|
||||
'CCTV_SOURCES_FILE',
|
||||
'CCTV_SOURCES_JSON',
|
||||
'CCTV_PREFER_AUSTIN',
|
||||
'CCTV_FORCE_AUSTIN',
|
||||
'CCTV_TAIWAN_ENABLED',
|
||||
'CCTV_TAIWAN_URL',
|
||||
'CCTV_TAIWAN_MAX_SOURCES',
|
||||
];
|
||||
|
||||
let savedEnv = {};
|
||||
|
||||
beforeEach(() => {
|
||||
resetTaiwanSourcesCache();
|
||||
savedEnv = Object.fromEntries(
|
||||
ISOLATED_ENV.map((key) => [key, process.env[key]]),
|
||||
);
|
||||
for (const key of ISOLATED_ENV) delete process.env[key];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const [key, value] of Object.entries(savedEnv)) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
function cctvXml(...blocks) {
|
||||
return `<?xml version='1.0' encoding='utf-8'?>
|
||||
<CCTVList xmlns="http://traffic.transportdata.tw/standard/traffic/schema/">
|
||||
<AuthorityCode>NFB</AuthorityCode>
|
||||
<CCTVs>${blocks.join('')}</CCTVs>
|
||||
</CCTVList>`;
|
||||
}
|
||||
|
||||
function block({
|
||||
id = 'CCTV-N1-S-0.000-M',
|
||||
stream = 'https://cctvn.freeway.gov.tw/abs2mjpg/bmjpg?camera=10000',
|
||||
lat = '25.1229931',
|
||||
lon = '121.735695',
|
||||
} = {}) {
|
||||
return `<CCTV>
|
||||
<CCTVID>${id}</CCTVID>
|
||||
<VideoStreamURL>${stream}</VideoStreamURL>
|
||||
<PositionLon>${lon}</PositionLon>
|
||||
<PositionLat>${lat}</PositionLat>
|
||||
<RoadName>國道1號</RoadName>
|
||||
<RoadDirection>S</RoadDirection>
|
||||
<RoadSection><Start>基隆端</Start><End>基隆交流道</End></RoadSection>
|
||||
<LocationMile>0K+000</LocationMile>
|
||||
</CCTV>`;
|
||||
}
|
||||
|
||||
test('parses the MOTC 2.0 table and registers the same camera still', () => {
|
||||
const [row] = parseTaiwanCctvXml(cctvXml(block()));
|
||||
const camera = taiwanCameraToSource(row);
|
||||
assert.equal(camera.id, 'tw-freeway-cctvn-10000');
|
||||
assert.equal(camera.lat, 25.1229931);
|
||||
assert.equal(camera.lon, 121.735695);
|
||||
assert.equal(camera.name, '國道1號 S 0K+000 基隆端→基隆交流道');
|
||||
assert.equal(camera.cityId, 'taiwan');
|
||||
assert.equal(camera.feedType, 'image');
|
||||
assert.equal(camera.headingConfidence, 'low');
|
||||
assert.equal(
|
||||
camera.url,
|
||||
'https://cctvn.freeway.gov.tw/abs2mjpg/jpg?camera=10000',
|
||||
);
|
||||
assert.equal(camera.snapshotUrl, camera.url);
|
||||
assert.match(camera.license, /Open Government Data License, Taiwan/);
|
||||
});
|
||||
|
||||
test('each camera server maps its stream path to its own still path only', () => {
|
||||
assert.equal(
|
||||
taiwanFrame(
|
||||
'https://cctvs.freeway.gov.tw/live-view/mjpg/video.cgi?camera=373',
|
||||
).frameUrl,
|
||||
'https://cctvs.freeway.gov.tw/live-view/jpg/image.cgi?camera=373',
|
||||
);
|
||||
assert.equal(
|
||||
taiwanFrame(
|
||||
'https://cctvn5.freeway.gov.tw/abs2mjpg/bmjpg?camera=c3a580c9-0232-46a1-bfc6-927590758ab4',
|
||||
).frameUrl,
|
||||
'https://cctvn5.freeway.gov.tw/abs2mjpg/jpg?camera=c3a580c9-0232-46a1-bfc6-927590758ab4',
|
||||
);
|
||||
assert.equal(
|
||||
taiwanFrame(
|
||||
'https://cctvc.freeway.gov.tw/abs2mjpg/bmjpg?camera=495&extra=1',
|
||||
).frameUrl,
|
||||
'https://cctvc.freeway.gov.tw/abs2mjpg/jpg?camera=495',
|
||||
);
|
||||
});
|
||||
|
||||
test('off-host, downgraded or malformed stream URLs never enter the proxy registry', () => {
|
||||
for (const stream of [
|
||||
'http://cctvn.freeway.gov.tw/abs2mjpg/bmjpg?camera=10000',
|
||||
'https://cctvn.freeway.gov.tw.evil.test/abs2mjpg/bmjpg?camera=10000',
|
||||
'https://user:[email protected]/abs2mjpg/bmjpg?camera=10000',
|
||||
'https://cctvn.freeway.gov.tw:8443/abs2mjpg/bmjpg?camera=10000',
|
||||
'https://cctvn.freeway.gov.tw/live-view/mjpg/video.cgi?camera=10000',
|
||||
'https://cctvs.freeway.gov.tw/abs2mjpg/bmjpg?camera=373',
|
||||
'https://cctvn.freeway.gov.tw/abs2mjpg/bmjpg?camera=../../admin',
|
||||
'https://cctvs.freeway.gov.tw/live-view/mjpg/video.cgi?cacamera=373',
|
||||
'http://127.0.0.1/private',
|
||||
'',
|
||||
]) {
|
||||
assert.equal(taiwanFrame(stream), null, stream);
|
||||
const [row] = parseTaiwanCctvXml(cctvXml(block({ stream })));
|
||||
assert.equal(taiwanCameraToSource(row), null, stream);
|
||||
}
|
||||
});
|
||||
|
||||
test('coordinates outside Taiwan, swapped or missing are rejected', () => {
|
||||
for (const [lat, lon] of [
|
||||
['121.735695', '25.1229931'],
|
||||
['35.68', '139.76'],
|
||||
['', '121.7'],
|
||||
['NaN', '121.7'],
|
||||
]) {
|
||||
const [row] = parseTaiwanCctvXml(cctvXml(block({ lat, lon })));
|
||||
assert.equal(taiwanCameraToSource(row), null, `${lat},${lon}`);
|
||||
}
|
||||
assert.equal(taiwanCameraToSource(null), null);
|
||||
});
|
||||
|
||||
test('Chinese place names in CCTVID stay in the record without shaping the key', () => {
|
||||
const [row] = parseTaiwanCctvXml(
|
||||
cctvXml(block({ id: 'CCTV-N1-N-55-R-中壢服務區&(北)' })),
|
||||
);
|
||||
const camera = taiwanCameraToSource(row);
|
||||
assert.equal(camera.id, 'tw-freeway-cctvn-10000');
|
||||
assert.equal(row.cctvId, 'CCTV-N1-N-55-R-中壢服務區&(北)');
|
||||
});
|
||||
|
||||
function mockCatalog(t, xml) {
|
||||
const requested = [];
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async (url, options) => {
|
||||
requested.push(String(url));
|
||||
if (String(url) === TAIWAN_FREEWAY_CCTV_URL) {
|
||||
assert.equal(options.redirect, 'manual');
|
||||
return new Response(xml, {
|
||||
headers: { 'content-type': 'application/xml' },
|
||||
});
|
||||
}
|
||||
return Response.json([]);
|
||||
});
|
||||
return requested;
|
||||
}
|
||||
|
||||
test('loader deduplicates cameras that share a camera number', async (t) => {
|
||||
mockCatalog(
|
||||
t,
|
||||
cctvXml(
|
||||
block(),
|
||||
block({ id: 'CCTV-N1-S-0.000-M-dup' }),
|
||||
block({
|
||||
stream: 'https://cctvc.freeway.gov.tw/abs2mjpg/bmjpg?camera=495',
|
||||
lat: '24.1',
|
||||
lon: '120.6',
|
||||
}),
|
||||
),
|
||||
);
|
||||
assert.deepEqual((await loadTaiwanSources()).map((c) => c.id).sort(), [
|
||||
'tw-freeway-cctvc-495',
|
||||
'tw-freeway-cctvn-10000',
|
||||
]);
|
||||
});
|
||||
|
||||
test('a redirected or failed catalog contributes an empty pack', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
for (const status of [302, 503]) {
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async () =>
|
||||
new Response(cctvXml(block()), {
|
||||
status,
|
||||
headers: status === 302 ? { location: 'https://evil.test/' } : {},
|
||||
}),
|
||||
);
|
||||
resetTaiwanSourcesCache();
|
||||
assert.deepEqual(await loadTaiwanSources(), []);
|
||||
}
|
||||
});
|
||||
|
||||
async function runCatalogWithTaiwanFlag(t, flag) {
|
||||
const requested = mockCatalog(t, cctvXml(block()));
|
||||
if (flag !== undefined) process.env.CCTV_TAIWAN_ENABLED = flag;
|
||||
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
|
||||
return { requested, sources };
|
||||
}
|
||||
|
||||
test('the Taiwan pack is in the catalog by default', async (t) => {
|
||||
const { sources } = await runCatalogWithTaiwanFlag(t, undefined);
|
||||
assert.equal(
|
||||
sources.find((c) => c.id === 'tw-freeway-cctvn-10000')?.cityId,
|
||||
'taiwan',
|
||||
);
|
||||
});
|
||||
|
||||
test('CCTV_TAIWAN_ENABLED=0 makes no Freeway Bureau request', async (t) => {
|
||||
const { requested, sources } = await runCatalogWithTaiwanFlag(t, '0');
|
||||
assert.ok(requested.length > 0, 'the other packs still refreshed');
|
||||
assert.equal(
|
||||
requested.some((href) => href.includes('freeway.gov.tw')),
|
||||
false,
|
||||
);
|
||||
assert.ok(!sources.some((c) => c.cityId === 'taiwan'));
|
||||
});
|
||||
|
||||
test('a catalog over the byte cap or a failed request contributes an empty pack', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const padding = `<!--${'x'.repeat(TAIWAN_MAX_CATALOG_BYTES)}-->`;
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async () => new Response(cctvXml(block()) + padding),
|
||||
);
|
||||
assert.deepEqual(await loadTaiwanSources(), []);
|
||||
resetTaiwanSourcesCache();
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
throw new TypeError('fetch failed');
|
||||
});
|
||||
assert.deepEqual(await loadTaiwanSources(), []);
|
||||
});
|
||||
|
||||
test('a failed download is not retried until the backoff ends', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
let calls = 0;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
calls += 1;
|
||||
throw new TypeError('fetch failed');
|
||||
});
|
||||
t.mock.method(Date, 'now', () => 1_000_000);
|
||||
assert.deepEqual(await loadTaiwanSources(), []);
|
||||
assert.deepEqual(await loadTaiwanSources(), []);
|
||||
assert.equal(calls, 1, 'the second refresh made no request');
|
||||
Date.now.mock.mockImplementation(
|
||||
() => 1_000_000 + TAIWAN_RETRY_AFTER_FAILURE_MS + 1,
|
||||
);
|
||||
await loadTaiwanSources();
|
||||
assert.equal(calls, 2, 'the backoff ends after an hour');
|
||||
});
|
||||
|
||||
test('a failed download serves the last good table', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const fetchMock = t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async () => new Response(cctvXml(block())),
|
||||
);
|
||||
const first = await loadTaiwanSources();
|
||||
assert.deepEqual(
|
||||
first.map((c) => c.id),
|
||||
['tw-freeway-cctvn-10000'],
|
||||
);
|
||||
fetchMock.mock.mockImplementation(
|
||||
async () => new Response('', { status: 503 }),
|
||||
);
|
||||
assert.deepEqual(
|
||||
(await loadTaiwanSources()).map((c) => c.id),
|
||||
['tw-freeway-cctvn-10000'],
|
||||
);
|
||||
});
|
||||
|
||||
test('CCTV_TAIWAN_MAX_SOURCES is clamped to 1-2000', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
const rows = Array.from({ length: 3 }, (_, i) =>
|
||||
block({
|
||||
stream: `https://cctvn.freeway.gov.tw/abs2mjpg/bmjpg?camera=${i + 1}`,
|
||||
}),
|
||||
);
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async () => new Response(cctvXml(...rows)),
|
||||
);
|
||||
for (const [value, expected] of [
|
||||
['0', 1],
|
||||
['2', 2],
|
||||
['5000', 3],
|
||||
['abc', 3],
|
||||
]) {
|
||||
resetTaiwanSourcesCache();
|
||||
process.env.CCTV_TAIWAN_MAX_SOURCES = value;
|
||||
const sources = await loadTaiwanSources();
|
||||
assert.equal(sources.length, expected, value);
|
||||
assert.equal(sources.available, 3, value);
|
||||
}
|
||||
});
|
||||
|
||||
test('names fall back to the published ID when the row has no road details', () => {
|
||||
const xml = cctvXml(block({ id: 'CCTV-N3-N-100-M' }))
|
||||
.replace('<RoadName>國道1號</RoadName>', '<RoadName></RoadName>')
|
||||
.replace('<LocationMile>0K+000</LocationMile>', '')
|
||||
.replace('<Start>基隆端</Start><End>基隆交流道</End>', '');
|
||||
const camera = taiwanCameraToSource(parseTaiwanCctvXml(xml)[0]);
|
||||
assert.equal(camera.name, 'CCTV-N3-N-100-M');
|
||||
});
|
||||
|
||||
test('numeric character references in the table are decoded', () => {
|
||||
const xml = cctvXml(block()).replace(
|
||||
'<RoadName>國道1號</RoadName>',
|
||||
'<RoadName>國道1號</RoadName>',
|
||||
);
|
||||
assert.equal(parseTaiwanCctvXml(xml)[0].roadName, '國道1號');
|
||||
});
|
||||
|
||||
test('camera numbers that are UUIDs keep their case in the frame URL only', () => {
|
||||
const camera = taiwanCameraToSource(
|
||||
parseTaiwanCctvXml(
|
||||
cctvXml(
|
||||
block({
|
||||
stream:
|
||||
'https://cctvn5.freeway.gov.tw/abs2mjpg/bmjpg?camera=C3A580C9-0232-46A1-BFC6-927590758AB4',
|
||||
}),
|
||||
),
|
||||
)[0],
|
||||
);
|
||||
assert.equal(
|
||||
camera.id,
|
||||
'tw-freeway-cctvn5-c3a580c9-0232-46a1-bfc6-927590758ab4',
|
||||
);
|
||||
assert.match(camera.url, /camera=C3A580C9-0232-46A1-BFC6-927590758AB4$/);
|
||||
});
|
||||
+33
-1
@@ -239,7 +239,8 @@ export const DATA_CREDITS = [
|
||||
html:
|
||||
'CCTV cameras & frames (Ontario): ' +
|
||||
'<a href="https://511on.ca/" target="_blank" rel="noopener">Ontario 511</a> ' +
|
||||
'(<a href="https://www.ontario.ca/page/open-government-licence-ontario" target="_blank" rel="noopener">Open Government Licence - Ontario</a>)',
|
||||
'(<a href="https://www.ontario.ca/page/open-government-licence-ontario" target="_blank" rel="noopener">Open Government Licence - Ontario</a>) ' +
|
||||
'· provided as is, without warranty; actual road conditions may differ',
|
||||
},
|
||||
{
|
||||
key: 'fintraffic-cctv',
|
||||
@@ -257,6 +258,14 @@ export const DATA_CREDITS = [
|
||||
'Road-camera stills (Lithuania, when enabled): ' +
|
||||
'<a href="https://eismoinfo.lt/" target="_blank" rel="noopener">Via Lietuva / Eismoinfo</a>; periodically updated images',
|
||||
},
|
||||
{
|
||||
key: 'taiwan-cctv',
|
||||
html:
|
||||
'Freeway camera stills (Taiwan): ' +
|
||||
'<a href="https://www.freeway.gov.tw/" target="_blank" rel="noopener">Freeway Bureau, MOTC</a>, ' +
|
||||
'CCTV static data (MOTC 2.0), released under the ' +
|
||||
'<a href="https://data.gov.tw/license" target="_blank" rel="noopener">Open Government Data License, Taiwan (Version 1.0)</a>',
|
||||
},
|
||||
{
|
||||
key: 'calgary-cctv',
|
||||
html:
|
||||
@@ -288,6 +297,13 @@ export const DATA_CREDITS = [
|
||||
'<a href="https://kingcounty.gov/en/dept/kcit/data-information-services/gis-center/about/terms-conditions-copyrights" target="_blank" rel="noopener">KCGIS Center terms</a>. ' +
|
||||
'Partner-city cameras hosted on the county image handler (e.g. Newcastle) name their owner in the CCTV panel.',
|
||||
},
|
||||
{
|
||||
key: 'hk-cctv',
|
||||
html:
|
||||
'Traffic snapshot cameras (Hong Kong): ' +
|
||||
'<a href="https://data.gov.hk/en-data/dataset/hk-td-tis_2-traffic-snapshot-images" target="_blank" rel="noopener">Transport Department / DATA.GOV.HK</a> ' +
|
||||
'(<a href="https://data.gov.hk/en/terms-and-conditions" target="_blank" rel="noopener">Terms of Use</a> — attribution required)',
|
||||
},
|
||||
{
|
||||
key: 'vegvesen-cctv',
|
||||
html:
|
||||
@@ -311,6 +327,22 @@ export const DATA_CREDITS = [
|
||||
'Traffic cameras (Toronto): contains information licensed under the ' +
|
||||
'<a href="https://www.toronto.ca/city-government/data-research-maps/open-data/open-data-licence/" target="_blank" rel="noopener">Open Government Licence – Toronto</a>',
|
||||
},
|
||||
{
|
||||
key: 'mapillary',
|
||||
html: 'Street Level: imagery © <a href="https://www.mapillary.com" target="_blank" rel="noopener">Mapillary</a> contributors, <a href="https://creativecommons.org/licenses/by-sa/4.0/" target="_blank" rel="noopener">CC BY-SA 4.0</a>',
|
||||
},
|
||||
{
|
||||
key: 'google-street-view',
|
||||
html: 'Street Level: Street View imagery © <a href="https://www.google.com/streetview/" target="_blank" rel="noopener">Google</a>',
|
||||
},
|
||||
{
|
||||
key: 'kartaview',
|
||||
html: 'Street Level: imagery © <a href="https://kartaview.org" target="_blank" rel="noopener">KartaView</a> contributors, <a href="https://creativecommons.org/licenses/by-sa/4.0/" target="_blank" rel="noopener">CC BY-SA 4.0</a>',
|
||||
},
|
||||
{
|
||||
key: 'panoramax',
|
||||
html: 'Street Level: imagery © <a href="https://panoramax.fr" target="_blank" rel="noopener">Panoramax</a> contributors, licence per picture',
|
||||
},
|
||||
{
|
||||
key: 'gbfs',
|
||||
html:
|
||||
|
||||
@@ -128,3 +128,16 @@ test('the NYC camera credit carries NYC DOT’s requested disclaimer', () => {
|
||||
assert.match(credit.html, /make no representation as to the accuracy/);
|
||||
assert.match(credit.html, /observe posted signage/);
|
||||
});
|
||||
|
||||
test('every Street Level imagery source is in the attribution lightbox', () => {
|
||||
for (const key of [
|
||||
'mapillary',
|
||||
'google-street-view',
|
||||
'kartaview',
|
||||
'panoramax',
|
||||
])
|
||||
assert.ok(
|
||||
DATA_CREDITS.some((entry) => entry.key === key),
|
||||
`${key} has no credit`,
|
||||
);
|
||||
});
|
||||
@@ -189,7 +189,7 @@ const loadIndex = createRetryableLoader(async () => {
|
||||
return index;
|
||||
});
|
||||
|
||||
function toResult(entry) {
|
||||
function toResult(entry, candidates = 1) {
|
||||
return {
|
||||
name: entry.name,
|
||||
featurecla: entry.featurecla,
|
||||
@@ -197,6 +197,7 @@ function toResult(entry) {
|
||||
polygons: entry.polygons,
|
||||
bboxDiagonalKm: entry.bboxDiagonalKm,
|
||||
areaKm2: entry.areaKm2,
|
||||
candidates,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -209,7 +210,9 @@ function toResult(entry) {
|
||||
* @param {string} query e.g. "the Alps", "Rockies", "Gulf of Mexico"
|
||||
* @returns {Promise<{name:string, featurecla:string, kind:'natural'|'marine',
|
||||
* polygons:Array<Array<[number,number]>>, bboxDiagonalKm:number,
|
||||
* areaKm2:number}|null>} largest-area match, or null when nothing matches.
|
||||
* areaKm2:number, candidates:number}|null>} largest-area match plus the
|
||||
* number of same-name entries, or null when nothing matches. Uniqueness in
|
||||
* this pack alone does not establish the user's intended place.
|
||||
*/
|
||||
export async function findNaturalRegion(query) {
|
||||
const norm = normalizeName(query);
|
||||
@@ -223,7 +226,7 @@ export async function findNaturalRegion(query) {
|
||||
for (const key of candidates) {
|
||||
if (!key) continue;
|
||||
const list = index.get(key);
|
||||
if (list && list.length) return toResult(list[0]);
|
||||
if (list && list.length) return toResult(list[0], list.length);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -270,9 +273,10 @@ export function pointInRing(ring, lat, lon) {
|
||||
/**
|
||||
* Resolve a natural-region OUTLINE ring for the annotation resolver's first
|
||||
* rung. Stricter than `findNaturalRegion`: walks ALL entries sharing the
|
||||
* matched name (duplicate names included — the US and Spanish "Sierra
|
||||
* Nevada" both exist upstream) and returns the single ring that CONTAINS the
|
||||
* geocoded anchor. Containment is simultaneously the disambiguator and the
|
||||
* matched name (duplicate names included), trying exact names, aliases and
|
||||
* broader suffix variants in that order. Returns a ring only when exactly one
|
||||
* entry under the matched name CONTAINS the anchor. Containment is the
|
||||
* disambiguator and the
|
||||
* wrong-place guard: when no ring contains the anchor this returns null and
|
||||
* the resolver's normal ladder continues unchanged.
|
||||
*
|
||||
@@ -291,23 +295,26 @@ export async function lookupNaturalRegionOutline(query, lat, lon) {
|
||||
ALIASES[norm],
|
||||
...suffixVariants(ALIASES[norm] || norm),
|
||||
];
|
||||
const seen = new Set();
|
||||
for (const key of candidates) {
|
||||
if (!key || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
for (const key of new Set(candidates.filter(Boolean))) {
|
||||
const containing = [];
|
||||
for (const entry of index.get(key) || []) {
|
||||
for (const ring of entry.polygons) {
|
||||
if (pointInRing(ring, lat, lon)) {
|
||||
return {
|
||||
name: entry.name,
|
||||
kind: entry.kind,
|
||||
featurecla: entry.featurecla,
|
||||
ring,
|
||||
areaKm2: entry.areaKm2,
|
||||
};
|
||||
}
|
||||
}
|
||||
const ring = entry.polygons.find((candidate) =>
|
||||
pointInRing(candidate, lat, lon),
|
||||
);
|
||||
if (ring) containing.push({ entry, ring });
|
||||
}
|
||||
// Specific names win over aliases and broader rewrites. Ambiguity belongs
|
||||
// to this name, not the union of every lower-priority candidate name.
|
||||
if (containing.length > 1) return null;
|
||||
if (containing.length === 0) continue;
|
||||
const [{ entry, ring }] = containing;
|
||||
return {
|
||||
name: entry.name,
|
||||
kind: entry.kind,
|
||||
featurecla: entry.featurecla,
|
||||
ring,
|
||||
areaKm2: entry.areaKm2,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -33,7 +33,10 @@ test('marquee ranges resolve with sane areas (owner acceptance: Alps + Rockies)'
|
||||
});
|
||||
|
||||
test('aliases and articles: "the Alps", "Rockies", "Sahara Desert", "Himalaya"', async () => {
|
||||
assert.equal((await findNaturalRegion('the Alps'))?.name, 'Alps');
|
||||
const alps = await findNaturalRegion('the Alps');
|
||||
assert.equal(alps?.name, 'Alps');
|
||||
assert.equal(alps?.candidates, 1);
|
||||
assert.equal((await findNaturalRegion('Alps mountains'))?.name, 'Alps');
|
||||
assert.equal((await findNaturalRegion('Rockies'))?.name, 'Rocky Mountains');
|
||||
assert.equal((await findNaturalRegion('the Rockies'))?.name, 'Rocky Mountains');
|
||||
assert.equal((await findNaturalRegion('Sahara Desert'))?.name, 'Sahara');
|
||||
@@ -41,6 +44,11 @@ test('aliases and articles: "the Alps", "Rockies", "Sahara Desert", "Himalaya"',
|
||||
assert.equal((await findNaturalRegion(' THE ALPS '))?.name, 'Alps');
|
||||
});
|
||||
|
||||
test('duplicate natural-region names expose ambiguity instead of claiming uniqueness', async () => {
|
||||
assert.ok((await findNaturalRegion('Cordillera Oriental'))?.candidates > 1);
|
||||
assert.ok((await findNaturalRegion('Canadian Shield'))?.candidates > 1);
|
||||
});
|
||||
|
||||
test('major deserts/ranges resolve with sane areas', async () => {
|
||||
const sahara = await findNaturalRegion('Sahara');
|
||||
assert.ok(sahara && sahara.areaKm2 > 8_000_000 && sahara.areaKm2 < 12_000_000,
|
||||
@@ -122,24 +130,55 @@ test('listRegions() enumerates both kinds for diagnostics', async () => {
|
||||
});
|
||||
|
||||
// ── lookupNaturalRegionOutline (resolver first-rung contract) ──────
|
||||
test('outline lookup: Alps ring via containment anchor', async () => {
|
||||
test('outline lookup: containing exact names win over broader suffix variants', async () => {
|
||||
const r = await lookupNaturalRegionOutline('the Alps', 46.5, 10.0);
|
||||
assert.ok(r, 'Alps must resolve with an inside anchor');
|
||||
assert.equal(r.name, 'Alps');
|
||||
assert.ok(r.ring.length >= 8, 'range-scale ring');
|
||||
assert.ok(r.areaKm2 > 100000 && r.areaKm2 < 400000, `sane Alps area, got ${r.areaKm2}`);
|
||||
const range = await lookupNaturalRegionOutline('Alaska Range', 63.14, -148.71);
|
||||
assert.equal(range?.name, 'Alaska Range');
|
||||
assert.ok(pointInRing(range.ring, 63.14, -148.71));
|
||||
assert.equal((await lookupNaturalRegionOutline('Alaska', 63.14, -148.71))?.name, 'Alaska');
|
||||
});
|
||||
|
||||
test('outline lookup: duplicate names disambiguate by anchor containment', async () => {
|
||||
const us = await lookupNaturalRegionOutline('Sierra Nevada', 37.2, -119.0);
|
||||
assert.ok(us, 'US anchor must match a Sierra Nevada');
|
||||
assert.ok(pointInRing(us.ring, 37.2, -119.0), 'returned ring contains the US anchor');
|
||||
const colombia = await lookupNaturalRegionOutline('Cordillera Oriental', 5, -73);
|
||||
assert.ok(colombia, 'a single containing candidate resolves a duplicate name');
|
||||
assert.ok(pointInRing(colombia.ring, 5, -73));
|
||||
});
|
||||
|
||||
test('outline lookup: overlapping duplicate candidates remain unresolved', async () => {
|
||||
assert.equal(await lookupNaturalRegionOutline('Canadian Shield', 45.6336, -74.86493), null);
|
||||
});
|
||||
|
||||
test('outline lookup: anchor outside every ring → null (wrong-place guard)', async () => {
|
||||
assert.equal(await lookupNaturalRegionOutline('the Alps', 30.26, -97.77), null);
|
||||
});
|
||||
|
||||
test('outline lookup: a unique physical-region name does not override an outside contemporary place', async () => {
|
||||
for (const [name, lat, lon] of [
|
||||
['Gold Coast', -28, 153.4],
|
||||
['Santa Cruz', 37, -122],
|
||||
['Isabela', 17, 122],
|
||||
['India', 28, 95], // Northeast India lies outside the physical peninsula.
|
||||
['Korea', 33.5, 126.5], // Jeju lies outside the physical peninsula.
|
||||
]) {
|
||||
assert.equal((await findNaturalRegion(name))?.candidates, 1, `${name} has a unique physical namesake`);
|
||||
assert.equal(await lookupNaturalRegionOutline(name, lat, lon), null, `${name} requires containment`);
|
||||
}
|
||||
});
|
||||
|
||||
test('outline lookup: aliases retain containment checks', async () => {
|
||||
const alps = await lookupNaturalRegionOutline('Alps mountains', 46.5, 10);
|
||||
assert.equal(alps?.name, 'Alps');
|
||||
assert.ok(pointInRing(alps.ring, 46.5, 10));
|
||||
assert.equal(await lookupNaturalRegionOutline('Alps mountains', -28, 153.4), null);
|
||||
});
|
||||
|
||||
test('outline lookup: non-region names never match', async () => {
|
||||
assert.equal(await lookupNaturalRegionOutline('Zilker Park', 30.26, -97.77), null);
|
||||
assert.equal(await lookupNaturalRegionOutline('Texas', 31.0, -99.0), null);
|
||||
|
||||
@@ -33,6 +33,18 @@ export function crossTrackKm(lat, lon, lat1, lon1, lat2, lon2) {
|
||||
return Math.asin(Math.sin(d13) * Math.sin(b13 - b12)) * EARTH_RADIUS_KM;
|
||||
}
|
||||
|
||||
/** Signed distance along the great circle from the origin toward the destination. */
|
||||
function alongTrackKm(position, origin, destination) {
|
||||
const distance = greatCircleKm(origin, position) / EARTH_RADIUS_KM;
|
||||
const direction =
|
||||
bearingRad(origin.lat, origin.lon, position.lat, position.lon) -
|
||||
bearingRad(origin.lat, origin.lon, destination.lat, destination.lon);
|
||||
return (
|
||||
Math.atan2(Math.sin(distance) * Math.cos(direction), Math.cos(distance)) *
|
||||
EARTH_RADIUS_KM
|
||||
);
|
||||
}
|
||||
|
||||
const NEAR_ENDPOINT_KM = 130;
|
||||
const CROSS_TRACK_KM = 200;
|
||||
const LOW_ALT_M = 3700; // ~12 000 ft
|
||||
@@ -66,6 +78,18 @@ export function routePlausible({
|
||||
const near = (point) => greatCircleKm(position, point) < NEAR_ENDPOINT_KM;
|
||||
let geomOk = (haveO && near(origin)) || (haveD && near(destination));
|
||||
if (!geomOk && haveO && haveD) {
|
||||
const routeKm = greatCircleKm(origin, destination);
|
||||
const alongKm = alongTrackKm(position, origin, destination);
|
||||
// atan2 returns the principal arc in [-πR, πR]. Near an antipodal
|
||||
// destination, its allowed forward extension can cross that branch cut.
|
||||
const segmentAlongKm =
|
||||
alongKm < -CROSS_TRACK_KM
|
||||
? alongKm + 2 * Math.PI * EARTH_RADIUS_KM
|
||||
: alongKm;
|
||||
// Coincident or antipodal endpoints do not define one minor arc. Keep the
|
||||
// existing loose check when the segment cannot be judged reliably.
|
||||
const segmentKnown =
|
||||
routeKm > 0.001 && routeKm < Math.PI * EARTH_RADIUS_KM - 0.001;
|
||||
geomOk =
|
||||
Math.abs(
|
||||
crossTrackKm(
|
||||
@@ -76,7 +100,10 @@ export function routePlausible({
|
||||
destination.lat,
|
||||
destination.lon,
|
||||
),
|
||||
) < CROSS_TRACK_KM;
|
||||
) < CROSS_TRACK_KM &&
|
||||
(!segmentKnown ||
|
||||
(segmentAlongKm >= -CROSS_TRACK_KM &&
|
||||
segmentAlongKm <= routeKm + CROSS_TRACK_KM));
|
||||
} else if (!geomOk) {
|
||||
geomOk = true; // only one endpoint known and not near — cannot judge, allow
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import { routePlausible } from './routePlausible.js';
|
||||
import { createTracking } from '../layers/flights/tracking.js';
|
||||
|
||||
const SFO = { lat: 37.6188, lon: -122.3754, code: 'SFO' };
|
||||
const LAX = { lat: 33.9416, lon: -118.4085, code: 'LAX' };
|
||||
const beyond = { lat: -35.818227816411174, lon: -66.16647396367095 };
|
||||
const behind = { lat: 35.818227816411174, lon: 113.83352603632906 };
|
||||
const antipode = { lat: -SFO.lat, lon: SFO.lon + 180 };
|
||||
const midpoint = { lat: 35.8, lon: -120.4 };
|
||||
|
||||
const plausible = (position, origin = SFO, destination = LAX) =>
|
||||
routePlausible({
|
||||
latDeg: position.lat,
|
||||
lonDeg: position.lon,
|
||||
altitudeM: 10000,
|
||||
verticalRateMps: 0,
|
||||
origin,
|
||||
destination,
|
||||
});
|
||||
|
||||
// Independent spherical oracle: native unit-vector angular distances, without
|
||||
// the production bearing/cross-track/along-track calculations.
|
||||
const unit = ({ lat, lon }) => {
|
||||
const latitude = Cesium.Math.toRadians(lat);
|
||||
const longitude = Cesium.Math.toRadians(lon);
|
||||
return new Cesium.Cartesian3(
|
||||
Math.cos(latitude) * Math.cos(longitude),
|
||||
Math.cos(latitude) * Math.sin(longitude),
|
||||
Math.sin(latitude),
|
||||
);
|
||||
};
|
||||
const angle = (a, b) => Cesium.Cartesian3.angleBetween(unit(a), unit(b));
|
||||
const excess = (position, origin, destination) =>
|
||||
angle(origin, position) +
|
||||
angle(position, destination) -
|
||||
angle(origin, destination);
|
||||
|
||||
test('a short airport route cannot describe a plane thousands of kilometres beyond its destination', () => {
|
||||
assert.ok(excess(beyond, SFO, LAX) > 2.9);
|
||||
assert.equal(plausible(beyond), false);
|
||||
});
|
||||
|
||||
test('backwards and antipodal extensions are not the scheduled airport leg', () => {
|
||||
for (const position of [behind, antipode]) {
|
||||
assert.ok(excess(position, SFO, LAX) > 3);
|
||||
assert.equal(plausible(position), false);
|
||||
}
|
||||
});
|
||||
|
||||
test('ordinary route offsets and generous endpoint extensions stay plausible', () => {
|
||||
assert.ok(excess(midpoint, SFO, LAX) < 0.001);
|
||||
for (const position of [midpoint, SFO, LAX, { lat: 36.5, lon: -119.5 }]) {
|
||||
assert.equal(plausible(position), true);
|
||||
}
|
||||
// ~167 km past either endpoint is outside the 130 km endpoint circle,
|
||||
// but remains inside the 200 km coarse-route tolerance.
|
||||
const origin = { lat: 0, lon: 0 };
|
||||
const destination = { lat: 0, lon: 10 };
|
||||
assert.equal(plausible({ lat: 0, lon: -1.5 }, origin, destination), true);
|
||||
assert.equal(plausible({ lat: 0, lon: 11.5 }, origin, destination), true);
|
||||
});
|
||||
|
||||
test('the generous endpoint tolerance still excludes a clearly different leg', () => {
|
||||
const origin = { lat: 0, lon: 0 };
|
||||
const destination = { lat: 0, lon: 10 };
|
||||
assert.equal(plausible({ lat: 0, lon: -2.5 }, origin, destination), false);
|
||||
assert.equal(plausible({ lat: 0, lon: 12.5 }, origin, destination), false);
|
||||
});
|
||||
|
||||
test('minor-arc admission works across the antimeridian and near the pole', () => {
|
||||
const dateline = [
|
||||
{ lat: 10, lon: 170 },
|
||||
{ lat: 10, lon: -170 },
|
||||
];
|
||||
assert.equal(plausible({ lat: 10.15, lon: 180 }, ...dateline), true);
|
||||
assert.equal(plausible({ lat: -10.15, lon: 0 }, ...dateline), false);
|
||||
const polar = [
|
||||
{ lat: 80, lon: -60 },
|
||||
{ lat: 80, lon: 60 },
|
||||
];
|
||||
const top = { lat: 84.96163122670251, lon: 0 };
|
||||
assert.ok(excess(top, ...polar) < 1e-8);
|
||||
assert.equal(plausible(top, ...polar), true);
|
||||
assert.equal(plausible({ lat: -top.lat, lon: 180 }, ...polar), false);
|
||||
});
|
||||
|
||||
test('the forward tolerance survives the signed-angle branch cut in both directions', () => {
|
||||
const origin = { lat: 0, lon: 0 };
|
||||
for (const direction of [-1, 1]) {
|
||||
const destination = { lat: 0, lon: direction * 179.9 };
|
||||
const within = { lat: 0, lon: direction * -178.8 };
|
||||
const endpointGapKm = angle(destination, within) * 6371.0088;
|
||||
assert.ok(endpointGapKm > 130 && endpointGapKm < 200);
|
||||
assert.equal(plausible(within, origin, destination), true);
|
||||
assert.equal(
|
||||
plausible({ lat: 0, lon: direction * -178 }, origin, destination),
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('unjudgeable endpoint geometry and missing coordinates keep the loose contract', () => {
|
||||
assert.equal(
|
||||
plausible({ lat: 0, lon: -90 }, { lat: 0, lon: 0 }, { lat: 0, lon: 180 }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
plausible({ lat: 45, lon: 0 }, { lat: 0, lon: 0 }, { lat: 0, lon: 0 }),
|
||||
true,
|
||||
);
|
||||
assert.equal(plausible(beyond, SFO, null), true);
|
||||
assert.equal(plausible(beyond, null, null), true);
|
||||
});
|
||||
|
||||
test('the actual native tracked label hides the wrong leg and keeps a healthy route', (t) => {
|
||||
const collection = new Cesium.BillboardCollection();
|
||||
t.after(() => collection.destroy());
|
||||
const flightState = {
|
||||
records: { data: new Map(), missingPolls: new Map() },
|
||||
feed: { _backoff: false },
|
||||
_billboards: new Map(),
|
||||
_scratchCarto: new Cesium.Cartographic(),
|
||||
};
|
||||
// Only application host services are supplied. Route admission, WGS84
|
||||
// billboard conversion and tracked-label formatting are the actual owners.
|
||||
const services = Object.fromEntries(
|
||||
[
|
||||
'context',
|
||||
'trails',
|
||||
'geoid',
|
||||
'groundFloor',
|
||||
'focus',
|
||||
'militaryRegistry',
|
||||
'readout',
|
||||
'camera',
|
||||
'picking',
|
||||
].map((name) => [name, {}]),
|
||||
);
|
||||
services.aircraftPresentation = { isTr3b: () => false };
|
||||
const parts = { queries: { _contactLabel: (_, info) => info.callsign } };
|
||||
const tracking = createTracking({ flightState, services, parts });
|
||||
for (const [position, expected] of [
|
||||
[beyond, false],
|
||||
[midpoint, true],
|
||||
]) {
|
||||
const id = 'abc001';
|
||||
const route = { origin: SFO, destination: LAX };
|
||||
flightState.records.data.set(id, {
|
||||
callsign: 'OWNED001',
|
||||
altitude: 10000,
|
||||
verticalRate: 0,
|
||||
route,
|
||||
});
|
||||
flightState._billboards.set(
|
||||
id,
|
||||
collection.add({
|
||||
id,
|
||||
position: Cesium.Cartesian3.fromDegrees(
|
||||
position.lon,
|
||||
position.lat,
|
||||
10000,
|
||||
),
|
||||
}),
|
||||
);
|
||||
assert.equal(
|
||||
tracking._trackedLabelText(id).includes('SFO → LAX'),
|
||||
expected,
|
||||
);
|
||||
assert.equal(tracking._routeIsPlausible(id, route), expected);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user