mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
Merge branch 'main' into feat/bikeshare-norway
This commit is contained in:
182 files changed
+21688
-1249
No files matched your search
+879
-144
File diff suppressed because it is too large.
Load diff
@@ -174,11 +174,95 @@ test('analyst: nearest sorting attaches distanceKm ascending', async () => {
|
||||
|
||||
test('analyst: follow-up re-filters the remembered set without re-snapshot', async () => {
|
||||
const eng = makeEngine();
|
||||
await eng.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } });
|
||||
const r = await eng.query({ followUp: true, filters: [{ field: 'onGround', op: 'eq', value: true }] });
|
||||
const first = await eng.query({
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'region', name: 'Texland' },
|
||||
});
|
||||
const r = await eng.query({
|
||||
followUp: true,
|
||||
filters: [{ field: 'onGround', op: 'eq', value: true }],
|
||||
});
|
||||
assert.equal(r.count, 1);
|
||||
assert.equal(r.items[0].id, 'GND1');
|
||||
assert.equal(r.coverage.followUp, true);
|
||||
assert.equal(r.scopeLabel, first.scopeLabel);
|
||||
assert.equal(r.display.scope, first.display.scope);
|
||||
assert.equal(r.coverage.scope, first.coverage.scope);
|
||||
});
|
||||
|
||||
test('analyst: follow-ups preserve partial and unanswered coverage', async () => {
|
||||
const eng = createAnalystEngine({
|
||||
getRecords: (key) => (key === 'flights' ? FLIGHTS : []),
|
||||
getLayerSnapshot: (key) => ({
|
||||
enabled: key === 'flights',
|
||||
feedState: key === 'flights' ? 'nominal' : 'off',
|
||||
}),
|
||||
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
|
||||
});
|
||||
const first = await eng.query({
|
||||
layers: ['flights', 'military'],
|
||||
scope: { kind: 'anywhere' },
|
||||
});
|
||||
assert.equal(first.partial, true);
|
||||
assert.deepEqual(first.unanswered, ['military']);
|
||||
|
||||
const followUp = await eng.query({ followUp: true, limit: 1 });
|
||||
assert.equal(followUp.partial, true);
|
||||
assert.deepEqual(followUp.unanswered, ['military']);
|
||||
assert.deepEqual(
|
||||
followUp.coverage.layersQueried.map(({ layerKey, status }) => [
|
||||
layerKey,
|
||||
status,
|
||||
]),
|
||||
first.coverage.layersQueried.map(({ layerKey, status }) => [
|
||||
layerKey,
|
||||
status,
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('analyst: cancelled pre-commit work cannot replace follow-up memory', async () => {
|
||||
const eng = makeEngine();
|
||||
await eng.query({
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'anywhere' },
|
||||
filters: [{ field: 'id', op: 'eq', value: 'SWA1' }],
|
||||
});
|
||||
let current = true;
|
||||
const stale = await eng.query(
|
||||
{ layers: ['local-firms'], scope: { kind: 'anywhere' } },
|
||||
{
|
||||
isCurrent: () => current,
|
||||
beforeCommit: async () => {
|
||||
current = false;
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(stale.code, 'CANCELLED');
|
||||
const followUp = await eng.query({ followUp: true });
|
||||
assert.deepEqual(followUp.items.map((item) => item.id), ['SWA1']);
|
||||
});
|
||||
|
||||
test('analyst: failed pre-commit work cannot replace follow-up memory', async () => {
|
||||
const eng = makeEngine();
|
||||
await eng.query({
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'anywhere' },
|
||||
filters: [{ field: 'id', op: 'eq', value: 'SWA1' }],
|
||||
});
|
||||
await assert.rejects(
|
||||
eng.query(
|
||||
{ layers: ['local-firms'], scope: { kind: 'anywhere' } },
|
||||
{
|
||||
beforeCommit: async () => {
|
||||
throw new Error('projection failed');
|
||||
},
|
||||
},
|
||||
),
|
||||
/projection failed/,
|
||||
);
|
||||
const followUp = await eng.query({ followUp: true });
|
||||
assert.deepEqual(followUp.items.map((item) => item.id), ['SWA1']);
|
||||
});
|
||||
|
||||
test('analyst: unresolved region is an honest failure, not empty success', async () => {
|
||||
@@ -200,6 +284,39 @@ test('analyst: a region lookup timeout is reported as region-timeout', async ()
|
||||
assert.equal(r.coverage.scope, 'region:Texas:timeout');
|
||||
});
|
||||
|
||||
test('analyst: region lookup receives and obeys the owning action signal', async () => {
|
||||
const controller = new AbortController();
|
||||
let receivedSignal = null;
|
||||
const eng = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: async (_name, signal) => {
|
||||
receivedSignal = signal;
|
||||
await new Promise((resolve) =>
|
||||
signal.addEventListener('abort', resolve, { once: true }),
|
||||
);
|
||||
return null;
|
||||
},
|
||||
getViewContext: () => ({
|
||||
lat: 30.27,
|
||||
lon: -97.74,
|
||||
viewRadiusKm: 150,
|
||||
}),
|
||||
});
|
||||
const pending = eng.query(
|
||||
{ layers: ['flights'], scope: { kind: 'region', name: 'Texas' } },
|
||||
{
|
||||
signal: controller.signal,
|
||||
isCurrent: () => !controller.signal.aborted,
|
||||
},
|
||||
);
|
||||
await Promise.resolve();
|
||||
controller.abort();
|
||||
const result = await pending;
|
||||
assert.equal(receivedSignal, controller.signal);
|
||||
assert.equal(result.code, 'CANCELLED');
|
||||
assert.equal(result.cancelled, true);
|
||||
});
|
||||
|
||||
test('analyst: route fields queryable from cached enrichment only', async () => {
|
||||
const r = await makeEngine().query({
|
||||
layers: ['flights'], scope: { kind: 'anywhere' },
|
||||
@@ -262,20 +379,132 @@ test('helpers: haversine sanity + scope radius', () => {
|
||||
assert.deepEqual(scoped.map((f) => f.id).sort(), ['GND1', 'SWA1']);
|
||||
});
|
||||
|
||||
test('bounded loaded cohorts disclose truncation before filtering and retain it on follow-up', async () => {
|
||||
test('capped cohorts report returned/total/truncated and keep it on follow-up', async () => {
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => [{ id: 'sample dam', lat: 0, lon: 0, name: 'sample' }],
|
||||
getRecordCoverage: () => ({ basis: 'bounded-loaded-records', recordsExamined: 1, loadedCount: 3000, sourceTruncated: true }),
|
||||
getRecordCoverage: () => ({ total: 3000, truncated: true }),
|
||||
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
|
||||
});
|
||||
const result = await engine.query({ layers: ['local-dams'], scope: { kind: 'anywhere' }, sortBy: 'distance' });
|
||||
assert.equal(result.count, 1);
|
||||
assert.equal(result.coverage.layersQueried[0].sourceTruncated, true);
|
||||
assert.match(result.coverage.note, /omitted records may change the nearest item or count/);
|
||||
const layer = result.coverage.layersQueried[0];
|
||||
assert.deepEqual({ returned: layer.returned, total: layer.total, truncated: layer.truncated }, { returned: 1, total: 3000, truncated: true });
|
||||
assert.deepEqual(result.coverage.records, { returned: 1, total: 3000, truncated: true });
|
||||
assert.match(result.display.caveat, /counted 1 of 3,000 loaded records/);
|
||||
const followUp = await engine.query({ followUp: true });
|
||||
assert.equal(followUp.coverage.layersQueried[0].loadedCount, 3000);
|
||||
assert.equal(followUp.coverage.layersQueried[0].total, 3000);
|
||||
});
|
||||
|
||||
test('an uncapped cohort is complete and carries no caveat', async () => {
|
||||
const result = await makeEngine().query({ layers: ['flights'], scope: { kind: 'anywhere' } });
|
||||
assert.deepEqual(result.coverage.records, { returned: 4, total: 4, truncated: false });
|
||||
assert.equal(result.display.caveat, undefined);
|
||||
assert.equal(result.display.scope, 'anywhere in the loaded data');
|
||||
});
|
||||
|
||||
test('unknown fields, operators, scopes and wrong-typed values are refused with the allowed values', async () => {
|
||||
const engine = makeEngine();
|
||||
const field = await engine.query({ layers: ['flights'], filters: [{ field: 'time', op: 'gte', value: 5 }] });
|
||||
assert.equal(field.ok, false);
|
||||
assert.equal(field.code, 'UNKNOWN_FIELD');
|
||||
assert.ok(field.allowed.includes('altitudeM'));
|
||||
const op = await engine.query({ layers: ['flights'], filters: [{ field: 'altitudeM', op: 'wat', value: 5 }] });
|
||||
assert.equal(op.code, 'BAD_OPERATOR');
|
||||
assert.deepEqual(op.allowed, ['gt', 'gte', 'lt', 'lte', 'eq', 'neq']);
|
||||
const flag = await engine.query({ layers: ['flights'], filters: [{ field: 'military', op: 'eq', value: 'false' }] });
|
||||
assert.equal(flag.code, 'BAD_VALUE', 'the string "false" must not match the TRUE record');
|
||||
const num = await engine.query({ layers: ['flights'], filters: [{ field: 'altitudeM', op: 'gt', value: 'high' }] });
|
||||
assert.equal(num.code, 'BAD_VALUE');
|
||||
const scope = await engine.query({ layers: ['flights'], scope: { kind: 'polygon' } });
|
||||
assert.equal(scope.code, 'UNKNOWN_SCOPE');
|
||||
assert.deepEqual(scope.allowed, ['view', 'region', 'radius', 'anywhere']);
|
||||
const sort = await engine.query({ layers: ['flights'], sortBy: 'loudness' });
|
||||
assert.equal(sort.code, 'UNKNOWN_FIELD');
|
||||
assert.equal(engine.hasMemory(), false, 'refusals never become follow-up memory');
|
||||
});
|
||||
|
||||
test('a typed boolean filter still matches exactly', async () => {
|
||||
const r = await makeEngine().query({ layers: ['flights'], scope: { kind: 'anywhere' }, filters: [{ field: 'military', op: 'eq', value: false }] });
|
||||
assert.deepEqual(r.items.map((i) => i.id).sort(), ['GND1', 'N123', 'SWA1']);
|
||||
});
|
||||
|
||||
test('distance honours sortDir: desc is farthest first', async () => {
|
||||
const far = await makeEngine().query({ layers: ['flights'], scope: { kind: 'anywhere' }, sortBy: 'distance', sortDir: 'desc', limit: 1 });
|
||||
assert.equal(far.items[0].id, 'N123');
|
||||
const near = await makeEngine().query({ layers: ['flights'], scope: { kind: 'anywhere' }, sortBy: 'distance', limit: 1 });
|
||||
assert.equal(near.items[0].id, 'SWA1');
|
||||
});
|
||||
|
||||
test('a follow-up without a previous answer is refused, not a fresh flights query', async () => {
|
||||
const r = await makeEngine().query({ followUp: true, filters: [] });
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.code, 'NO_RESULT_CONTEXT');
|
||||
});
|
||||
|
||||
test('follow-ups never rewrite the remembered rows', async () => {
|
||||
const engine = makeEngine();
|
||||
await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' } });
|
||||
await engine.query({ followUp: true, sortBy: 'distance' });
|
||||
const again = await engine.query({ followUp: true });
|
||||
assert.equal(again.count, 4, 'a follow-up keeps the remembered set, not the view');
|
||||
assert.ok(again.items.every((item) => item.distanceKm === undefined));
|
||||
});
|
||||
|
||||
test('a superseded slow region query cannot overwrite the newer follow-up memory', async () => {
|
||||
let releaseSlow;
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: (key) => ({ flights: FLIGHTS, 'ais-live-vessels': SHIPS }[key] || []),
|
||||
resolveRegionRing: () => new Promise((resolve) => { releaseSlow = () => resolve(TEXLAND); }),
|
||||
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
|
||||
});
|
||||
let generation = 0;
|
||||
const slowGeneration = ++generation;
|
||||
const slow = engine.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } }, { isCurrent: () => generation === slowGeneration });
|
||||
const newGeneration = ++generation;
|
||||
const fresh = await engine.query({ layers: ['ais-live-vessels'], scope: { kind: 'anywhere' } }, { isCurrent: () => generation === newGeneration });
|
||||
assert.equal(fresh.ok, true);
|
||||
releaseSlow();
|
||||
const stale = await slow;
|
||||
assert.equal(stale.code, 'CANCELLED');
|
||||
const followUp = await engine.query({ followUp: true });
|
||||
assert.deepEqual(followUp.items.map((i) => i.layerKey), ['ais-live-vessels', 'ais-live-vessels']);
|
||||
});
|
||||
|
||||
test('off, loading and unavailable layers are refused distinctly; a real zero stays ok', async () => {
|
||||
const engineFor = (snapshot, rows = [], warming = false) => createAnalystEngine({
|
||||
getRecords: () => rows,
|
||||
getLayerSnapshot: () => snapshot,
|
||||
isWarming: () => warming,
|
||||
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
|
||||
});
|
||||
assert.equal((await engineFor({ enabled: false, feedState: 'off' }).query({ layers: ['earthquakes'] })).code, 'LAYER_OFF');
|
||||
assert.equal((await engineFor({ enabled: true, feedState: 'loading' }).query({ layers: ['earthquakes'] })).code, 'NOT_READY');
|
||||
assert.equal((await engineFor({ enabled: true, feedState: 'nominal' }, [], true).query({ layers: ['earthquakes'] })).code, 'NOT_READY');
|
||||
assert.equal((await engineFor({ enabled: true, feedState: 'unavailable' }).query({ layers: ['earthquakes'] })).code, 'FEED_UNAVAILABLE');
|
||||
const zero = await engineFor({ enabled: true, feedState: 'nominal' }, [{ id: 'q', lat: 50, lon: 50, magnitude: 2 }]).query({ layers: ['earthquakes'], filters: [{ field: 'magnitude', op: 'gte', value: 5 }] });
|
||||
assert.equal(zero.ok, true);
|
||||
assert.equal(zero.count, 0);
|
||||
assert.equal(zero.coverage.layersQueried[0].status, 'ok');
|
||||
});
|
||||
|
||||
test('time fields filter by ISO and derive ageHours; the feed window is exposed', async () => {
|
||||
const now = Date.parse('2026-09-23T12:00:00Z');
|
||||
const engine = createAnalystEngine({
|
||||
now: () => now,
|
||||
getRecords: () => [
|
||||
{ id: 'recent', lat: 0, lon: 0, magnitude: 4, timeMs: now - 2 * 3_600_000 },
|
||||
{ id: 'older', lat: 0, lon: 0, magnitude: 5, timeMs: now - 20 * 3_600_000 },
|
||||
],
|
||||
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
|
||||
});
|
||||
const iso = await engine.query({ layers: ['earthquakes'], scope: { kind: 'anywhere' }, filters: [{ field: 'timeMs', op: 'gte', value: '2026-09-23T06:00:00Z' }] });
|
||||
assert.deepEqual(iso.items.map((i) => i.id), ['recent']);
|
||||
const age = await engine.query({ layers: ['earthquakes'], scope: { kind: 'anywhere' }, filters: [{ field: 'ageHours', op: 'lte', value: 6 }] });
|
||||
assert.deepEqual(age.items.map((i) => i.id), ['recent']);
|
||||
assert.equal(age.items[0].ageHours, 2);
|
||||
assert.equal(age.display.window, 'last 24 h');
|
||||
assert.equal(age.coverage.layersQueried[0].window, 'last 24 h');
|
||||
});
|
||||
|
||||
test('follow-up provenance stays attached to old rows after a feed recovers', async () => {
|
||||
let state = 'stale';
|
||||
@@ -293,3 +522,141 @@ test('follow-up provenance stays attached to old rows after a feed recovers', as
|
||||
assert.equal(engine.hasMemory(), false);
|
||||
assert.equal((await engine.query()).coverage.feedProvenance.overall, 'nominal');
|
||||
});
|
||||
|
||||
test('a large loaded set sorts and summarizes without overflowing the stack', async () => {
|
||||
const rows = Array.from({ length: 200_000 }, (_, i) => ({ id: `FIRE-${i}`, lat: 0, lon: 0, frp: i }));
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => rows,
|
||||
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
|
||||
});
|
||||
const result = await engine.query({ layers: ['local-firms'], scope: { kind: 'anywhere' }, sortBy: 'frp', limit: 1 });
|
||||
assert.equal(result.count, 200_000);
|
||||
assert.equal(result.items[0].frp, 199_999);
|
||||
assert.equal(result.summary.frpMin, 0);
|
||||
});
|
||||
|
||||
test('an enabled but empty layer says why on screen', async () => {
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => [],
|
||||
getLayerSnapshot: () => ({ enabled: true, feedState: 'nominal' }),
|
||||
getEmptyNote: () => 'Fly below 400 km to a covered region',
|
||||
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
|
||||
});
|
||||
const result = await engine.query({ layers: ['transit'] });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.count, 0);
|
||||
assert.equal(result.coverage.layersQueried[0].status, 'empty');
|
||||
assert.match(result.display.caveat, /transit: Fly below 400 km/);
|
||||
});
|
||||
|
||||
test('a radius around a named place without a centre is refused, not measured around the camera', async () => {
|
||||
const r = await makeEngine().query({ layers: ['ais-live-vessels'], scope: { kind: 'radius', name: 'Singapore', km: 200 } });
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.code, 'BAD_SCOPE');
|
||||
const ok = await makeEngine().query({ layers: ['ais-live-vessels'], scope: { kind: 'radius', name: 'Houston', km: 200, center: { lat: 29.6, lon: -95.0 } } });
|
||||
assert.equal(ok.ok, true);
|
||||
});
|
||||
|
||||
test('a field copied with its hinted unit still resolves', async () => {
|
||||
const r = await makeEngine().query({ layers: ['local-firms'], scope: { kind: 'anywhere' }, sortBy: 'frp(MW)', filters: [{ field: 'frp (MW)', op: 'gt', value: 100 }] });
|
||||
assert.equal(r.ok, true);
|
||||
assert.deepEqual(r.items.map((i) => i.id), ['FIRE-3', 'FIRE-1']);
|
||||
});
|
||||
|
||||
test('a written unit converts to the field unit or is refused', async () => {
|
||||
const engine = makeEngine();
|
||||
const feet = await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' }, filters: [{ field: 'altitudeM(ft)', op: 'gt', value: 40000 }] });
|
||||
assert.equal(feet.ok, true);
|
||||
assert.deepEqual(feet.items.map((i) => i.id), ['RCH01'], '40,000 ft is 12,192 m, not 40,000 m');
|
||||
const same = await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' }, filters: [{ field: 'altitudeM (m)', op: 'gt', value: 12192 }] });
|
||||
assert.deepEqual(same.items.map((i) => i.id), ['RCH01']);
|
||||
const knots = await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' }, filters: [{ field: 'speedMps(kts)', op: 'gt', value: 470 }] });
|
||||
assert.deepEqual(knots.items.map((i) => i.id), ['RCH01'], '470 kn is ~242 m/s');
|
||||
const wrong = await engine.query({ layers: ['flights'], filters: [{ field: 'altitudeM(kts)', op: 'gt', value: 1 }] });
|
||||
assert.equal(wrong.code, 'BAD_UNIT');
|
||||
const unitless = await engine.query({ layers: ['flights'], filters: [{ field: 'callsign(ft)', op: 'eq', value: 'X' }] });
|
||||
assert.equal(unitless.code, 'BAD_UNIT');
|
||||
const hinted = await engine.query({ layers: ['local-firms'], scope: { kind: 'anywhere' }, sortBy: 'frp(MW)' });
|
||||
assert.equal(hinted.ok, true);
|
||||
});
|
||||
|
||||
test('unambiguous numeric strings are numbers; blanks, booleans and words are not', async () => {
|
||||
const engine = makeEngine();
|
||||
const text = await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' }, filters: [{ field: 'altitudeM', op: 'gt', value: '12192' }] });
|
||||
assert.equal(text.ok, true);
|
||||
assert.deepEqual(text.items.map((i) => i.id), ['RCH01']);
|
||||
for (const value of ['', ' ', true, 'high', '12k'])
|
||||
assert.equal((await engine.query({ layers: ['flights'], filters: [{ field: 'altitudeM', op: 'gt', value }] })).code, 'BAD_VALUE', JSON.stringify(value));
|
||||
});
|
||||
|
||||
test('any supplied centre must be a real coordinate', async () => {
|
||||
const engine = makeEngine();
|
||||
for (const center of [{ lat: 0 }, { lat: 999, lon: 0 }, { lat: 10, lon: 200 }, { lat: '10', lon: 20 }])
|
||||
assert.equal((await engine.query({ layers: ['flights'], scope: { kind: 'radius', km: 100, center } })).code, 'BAD_SCOPE', JSON.stringify(center));
|
||||
assert.equal((await engine.query({ layers: ['flights'], scope: { kind: 'radius', km: 100, center: { lat: 30.2, lon: -97.7 } } })).ok, true);
|
||||
});
|
||||
|
||||
test('mixed off/loading/unavailable layers refuse; a partly answerable query says so', async () => {
|
||||
const snapshots = { earthquakes: { enabled: true, feedState: 'unavailable' }, 'local-firms': { enabled: true, feedState: 'loading' }, flights: { enabled: false, feedState: 'off' } };
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: (key) => (key === 'ais-live-vessels' ? SHIPS : []),
|
||||
getLayerSnapshot: (key) => snapshots[key] || { enabled: true, feedState: 'nominal' },
|
||||
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
|
||||
});
|
||||
const none = await engine.query({ layers: ['earthquakes', 'local-firms', 'flights'] });
|
||||
assert.equal(none.ok, false);
|
||||
assert.equal(none.code, 'NOT_READY');
|
||||
assert.deepEqual(none.layerStatus.map((l) => l.status), ['error', 'loading', 'off']);
|
||||
assert.equal(engine.hasMemory(), false, 'a refusal never becomes follow-up memory');
|
||||
const down = await engine.query({ layers: ['earthquakes', 'flights'] });
|
||||
assert.equal(down.code, 'FEED_UNAVAILABLE');
|
||||
const partial = await engine.query({ layers: ['ais-live-vessels', 'earthquakes'], scope: { kind: 'anywhere' } });
|
||||
assert.equal(partial.ok, true);
|
||||
assert.equal(partial.partial, true);
|
||||
assert.deepEqual(partial.unanswered, ['earthquakes']);
|
||||
assert.match(partial.display.caveat, /earthquakes error/);
|
||||
});
|
||||
|
||||
test('returned items are copies: mutating them cannot change the remembered answer', async () => {
|
||||
const engine = makeEngine();
|
||||
const first = await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' }, sortBy: 'altitudeM' });
|
||||
for (const item of first.items) {
|
||||
item.altitudeM = -1;
|
||||
item.id = 'MUTATED';
|
||||
}
|
||||
const again = await engine.query({ followUp: true, sortBy: 'altitudeM' });
|
||||
assert.deepEqual(again.items.map((i) => i.id), ['RCH01', 'SWA1', 'N123', 'GND1']);
|
||||
assert.equal(FLIGHTS[1].altitudeM, 13500, 'the provider rows are untouched');
|
||||
});
|
||||
|
||||
test('ranking 250k rows stays within a budget and yields to the page', async () => {
|
||||
const rows = Array.from({ length: 250_000 }, (_, i) => ({ id: `F${i}`, lat: (i % 170) - 85, lon: (i % 350) - 175, frp: (i * 7919) % 100_003 }));
|
||||
const engine = createAnalystEngine({ getRecords: () => rows, getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }) });
|
||||
let ticks = 0;
|
||||
const timer = setInterval(() => ticks++, 0);
|
||||
const started = performance.now();
|
||||
const result = await engine.query({ layers: ['local-firms'], scope: { kind: 'anywhere' }, sortBy: 'frp', limit: 1 });
|
||||
const elapsed = performance.now() - started;
|
||||
clearInterval(timer);
|
||||
assert.equal(result.count, 250_000);
|
||||
assert.equal(result.items[0].frp, 100_002);
|
||||
assert.ok(elapsed < 400, `took ${Math.round(elapsed)} ms`);
|
||||
assert.ok(ticks > 0, 'the scan yielded between slices');
|
||||
let current = true;
|
||||
const pending = engine.query({ layers: ['local-firms'], scope: { kind: 'anywhere' }, sortBy: 'distance' }, { isCurrent: () => current });
|
||||
current = false;
|
||||
assert.equal((await pending).code, 'CANCELLED', 'a superseded scan stops at the next slice');
|
||||
});
|
||||
|
||||
test('analyst: a follow-up naming other layers than the last answer is refused', async () => {
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: (key) => ({ flights: FLIGHTS, 'local-firms': FIRES }[key] || []),
|
||||
resolveRegionRing: async () => null,
|
||||
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
|
||||
});
|
||||
await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' } });
|
||||
const r = await engine.query({ layers: ['local-firms'], followUp: true });
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.code, 'FOLLOW_UP_MISMATCH');
|
||||
assert.equal(engine.hasMemory(), true, 'the earlier answer is still the last answer');
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createDirectionsSource } from '../layers/directions/source.js';
|
||||
/**
|
||||
* @module directions
|
||||
* @description Service bundle for the Directions layer.
|
||||
@@ -37,8 +38,10 @@ export const directionsServices = Object.freeze({
|
||||
});
|
||||
|
||||
/** Construct one Directions layer over the application scene owners. */
|
||||
export function createApplicationDirectionsLayer() {
|
||||
return createDirectionsLayer({ services: directionsServices });
|
||||
export function createApplicationDirectionsLayer({
|
||||
source = createDirectionsSource(),
|
||||
} = {}) {
|
||||
return createDirectionsLayer({ services: directionsServices, source });
|
||||
}
|
||||
|
||||
export {
|
||||
|
||||
@@ -25,6 +25,7 @@ test('firms analyst record: full record maps every contract field', () => {
|
||||
frp: 1520.4,
|
||||
confidence: 0.9,
|
||||
satellite: 'N21',
|
||||
sensor: 'VIIRS',
|
||||
acqTime: 1_753_600_000_000,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
const MAX_SWITCHES_LENGTH = 256;
|
||||
const ENTRY = /^([a-z][a-z0-9-]*)-([01])$/;
|
||||
|
||||
/** Canonical, bounded boolean switches keyed by stable slugs. */
|
||||
export function normalizeIdSwitches(value, reservedIds = []) {
|
||||
if (typeof value !== 'string' || value.length > MAX_SWITCHES_LENGTH)
|
||||
return null;
|
||||
if (!value) return '';
|
||||
const entries = new Map();
|
||||
for (const field of value.split('*')) {
|
||||
const match = ENTRY.exec(field);
|
||||
if (!match || reservedIds.includes(match[1]) || entries.has(match[1]))
|
||||
return null;
|
||||
entries.set(match[1], match[2] === '1');
|
||||
}
|
||||
return [...entries]
|
||||
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
||||
.map(([id, on]) => `${id}-${on ? '1' : '0'}`)
|
||||
.join('*');
|
||||
}
|
||||
|
||||
/** Encode by stable IDs rather than registration order or generated tokens. */
|
||||
export function encodeIdSwitches(switches) {
|
||||
const value = [...switches]
|
||||
.map(([id, on]) => `${id}-${on === true ? '1' : '0'}`)
|
||||
.join('*');
|
||||
if (value.length > MAX_SWITCHES_LENGTH)
|
||||
throw new RangeError(
|
||||
`Switch IDs exceed the ${MAX_SWITCHES_LENGTH}-character share-link limit`,
|
||||
);
|
||||
const normalized = normalizeIdSwitches(value);
|
||||
if (normalized === null) throw new TypeError('Invalid boolean switch IDs');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/** A Map keeps switch IDs separate from object prototype properties. */
|
||||
export function decodeIdSwitches(value) {
|
||||
const normalized = normalizeIdSwitches(value);
|
||||
return new Map(
|
||||
normalized
|
||||
? normalized.split('*').map((entry) => {
|
||||
const [, id, on] = ENTRY.exec(entry);
|
||||
return [id, on === '1'];
|
||||
})
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
/** Persist only explicitly requested switch IDs, not unrelated live changes. */
|
||||
export function patchRequestedIdSwitches(previous, applied, requested) {
|
||||
const current = decodeIdSwitches(applied);
|
||||
const next = new Map(
|
||||
[...decodeIdSwitches(previous)].filter(([id]) => current.has(id)),
|
||||
);
|
||||
let changed = false;
|
||||
for (const [id, on] of current) {
|
||||
if (Object.hasOwn(requested, id) && typeof requested[id] === 'boolean') {
|
||||
next.set(id, on);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? encodeIdSwitches(next) : undefined;
|
||||
}
|
||||
+11
-1
@@ -1,3 +1,4 @@
|
||||
import { normalizeIdSwitches, patchRequestedIdSwitches } from './idSwitches.js';
|
||||
import reservationRows from './layerStateTokenReservations.json' with { type: 'json' };
|
||||
|
||||
const VALID_DISPOSITIONS = new Set([
|
||||
@@ -411,8 +412,17 @@ const OPTION_GROUPS = Object.freeze({
|
||||
booleanOption('viirs', 'v', false),
|
||||
]),
|
||||
'street-level': Object.freeze([
|
||||
// One switch per registered imagery provider (src/app/layers/streetLevel.js).
|
||||
// Preserve the published Mapillary token; custom switches use stable IDs.
|
||||
booleanOption('mapillary', 'm', true),
|
||||
Object.freeze({
|
||||
key: 'providerSwitches',
|
||||
patchRequested: patchRequestedIdSwitches,
|
||||
token: 'r',
|
||||
defaultValue: '',
|
||||
normalize: (value) => normalizeIdSwitches(value, ['mapillary']),
|
||||
encode: normalizeIdSwitches,
|
||||
decode: (value) => normalizeIdSwitches(value, ['mapillary']),
|
||||
}),
|
||||
enumOption('pano', 'p', 'all', ['all', 'pano', 'flat'], {
|
||||
all: 'a',
|
||||
pano: 'p',
|
||||
|
||||
+102
-18
@@ -1,3 +1,4 @@
|
||||
import { encodeParams, decodeParams } from '../layers/streetLevel/params.js';
|
||||
import { readShellSource } from '../testSupport/readShellSource.mjs';
|
||||
import { expandApplicationHtml } from '../../build/application-html.js';
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
@@ -243,7 +244,11 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
}
|
||||
assert.equal(nextLayerStateToken(), '3');
|
||||
assert.equal(
|
||||
nextLayerStateToken({ ...LAYER_STATE_TOKEN_RESERVATIONS, alpha: '3', bravo: '4' }),
|
||||
nextLayerStateToken({
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
alpha: '3',
|
||||
bravo: '4',
|
||||
}),
|
||||
'5',
|
||||
);
|
||||
const digitsExhausted = {
|
||||
@@ -276,7 +281,9 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
[
|
||||
...'0123456789',
|
||||
...[...LAYER_STATE_TOKEN_ALPHABET].flatMap((first) =>
|
||||
[...LAYER_STATE_TOKEN_ALPHABET].map((second) => `${first}${second}`),
|
||||
[...LAYER_STATE_TOKEN_ALPHABET].map(
|
||||
(second) => `${first}${second}`,
|
||||
),
|
||||
),
|
||||
].map((token, index) => [`occupied-${index}`, token]),
|
||||
),
|
||||
@@ -298,33 +305,37 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(
|
||||
LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, future: '3', next: '4' },
|
||||
),
|
||||
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
future: '3',
|
||||
next: '4',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
future: '00',
|
||||
}),
|
||||
() =>
|
||||
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
future: '00',
|
||||
}),
|
||||
/next free token 3/,
|
||||
);
|
||||
const beforeLastDigit = { ...digitsExhausted };
|
||||
delete beforeLastDigit['prior-9'];
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(
|
||||
beforeLastDigit,
|
||||
{ ...beforeLastDigit, futurePair: '00', futureDigit: '9' },
|
||||
),
|
||||
validateLayerStateAllocations(beforeLastDigit, {
|
||||
...beforeLastDigit,
|
||||
futurePair: '00',
|
||||
futureDigit: '9',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(
|
||||
digitsExhausted,
|
||||
{ ...digitsExhausted, pairB: '01', pairA: '00' },
|
||||
),
|
||||
validateLayerStateAllocations(digitsExhausted, {
|
||||
...digitsExhausted,
|
||||
pairB: '01',
|
||||
pairA: '00',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.throws(
|
||||
@@ -2660,12 +2671,14 @@ test('Street Level: every option round-trips through a share link and stored sta
|
||||
state.enabledLayerIds = ['street-level'];
|
||||
state.options['street-level'] = {
|
||||
mapillary: false,
|
||||
providerSwitches: '',
|
||||
pano: 'flat',
|
||||
sinceDays: MAX_SINCE_DAYS,
|
||||
};
|
||||
const decoded = decodeLayerStateParams(new URLSearchParams(encode(state)));
|
||||
assert.deepEqual(decoded.options['street-level'], {
|
||||
mapillary: false,
|
||||
providerSwitches: '',
|
||||
pano: 'flat',
|
||||
sinceDays: MAX_SINCE_DAYS,
|
||||
});
|
||||
@@ -2693,6 +2706,7 @@ test('Street Level: the link codec accepts exactly the windows the filter keeps'
|
||||
test('Street Level: a link without the provider switch keeps Mapillary on', () => {
|
||||
assert.deepEqual(streetLevelOptions(''), {
|
||||
mapillary: true,
|
||||
providerSwitches: '',
|
||||
pano: 'all',
|
||||
sinceDays: 0,
|
||||
});
|
||||
@@ -2703,3 +2717,73 @@ test('Street Level: a link without the provider switch keeps Mapillary on', () =
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('explicit provider switches reach durable state without adopting unrelated live switches', () => {
|
||||
const manager = new DataLayerManager({});
|
||||
const providers = new Map([
|
||||
['mapillary', true],
|
||||
['alpha', true],
|
||||
['beta', true],
|
||||
]);
|
||||
const filter = { pano: 'all', sinceDays: 0 };
|
||||
for (const id of REGISTERED_LAYER_IDS) {
|
||||
manager.register(
|
||||
id === 'street-level'
|
||||
? {
|
||||
...fakeLayer(id),
|
||||
getParams: () => encodeParams({ providers, filter }),
|
||||
setParams(params) {
|
||||
const decoded = decodeParams(params, {
|
||||
providerIds: providers.keys(),
|
||||
filter,
|
||||
});
|
||||
for (const [id, on] of decoded.providers) providers.set(id, on);
|
||||
return true;
|
||||
},
|
||||
}
|
||||
: fakeLayer(id),
|
||||
);
|
||||
}
|
||||
manager.finalizeRegistrations(LAYER_STATE_REGISTRY);
|
||||
const storage = memoryStorage();
|
||||
const coordinator = new LayerStateCoordinator(manager, shareSink(), {
|
||||
storage,
|
||||
});
|
||||
try {
|
||||
providers.set('beta', false); // Passive state does not belong to the next action.
|
||||
assert.equal(
|
||||
manager.setLayerParams(
|
||||
'street-level',
|
||||
{ alpha: false },
|
||||
{ origin: 'user' },
|
||||
),
|
||||
true,
|
||||
);
|
||||
let saved = parseStoredLayerState(storage.getItem(LAYER_STATE_STORAGE_KEY));
|
||||
assert.equal(saved.options['street-level'].providerSwitches, 'alpha-0');
|
||||
const shared = decodeLayerStateParams(new URLSearchParams(encode(saved)));
|
||||
const restored = decodeParams(shared.options['street-level'], {
|
||||
providerIds: ['beta', 'alpha'],
|
||||
filter,
|
||||
});
|
||||
assert.deepEqual([...restored.providers], [['alpha', false]]);
|
||||
manager.setLayerParams('street-level', { beta: true }, { origin: 'user' });
|
||||
saved = parseStoredLayerState(storage.getItem(LAYER_STATE_STORAGE_KEY));
|
||||
assert.equal(
|
||||
saved.options['street-level'].providerSwitches,
|
||||
'alpha-0*beta-1',
|
||||
);
|
||||
manager.setLayerParams(
|
||||
'street-level',
|
||||
{ alpha: true },
|
||||
{ origin: 'local-restore' },
|
||||
);
|
||||
saved = parseStoredLayerState(storage.getItem(LAYER_STATE_STORAGE_KEY));
|
||||
assert.equal(
|
||||
saved.options['street-level'].providerSwitches,
|
||||
'alpha-0*beta-1',
|
||||
);
|
||||
} finally {
|
||||
coordinator.destroy();
|
||||
}
|
||||
});
|
||||
@@ -255,8 +255,22 @@ export class LayerStateCoordinator {
|
||||
const explicitlyRequested = Object.hasOwn(requestedParams, spec.key);
|
||||
const implicitTrackingSync =
|
||||
!explicitlyRequested && spec.key === trackingOptionKey;
|
||||
if (!explicitlyRequested && !implicitTrackingSync) continue;
|
||||
const value = spec.normalize(change.params[spec.key]);
|
||||
// Packed options can persist only the subkeys owned by this request.
|
||||
const requestedValue =
|
||||
explicitlyRequested || implicitTrackingSync
|
||||
? change.params[spec.key]
|
||||
: spec.patchRequested?.(
|
||||
nextOwnerOptions[spec.key],
|
||||
change.params[spec.key],
|
||||
requestedParams,
|
||||
);
|
||||
if (
|
||||
!explicitlyRequested &&
|
||||
!implicitTrackingSync &&
|
||||
requestedValue === undefined
|
||||
)
|
||||
continue;
|
||||
const value = spec.normalize(requestedValue);
|
||||
if (value === null) {
|
||||
if (implicitTrackingSync) {
|
||||
nextOwnerOptions[spec.key] = null;
|
||||
|
||||
@@ -98,6 +98,12 @@ function withPublishedBase(
|
||||
}
|
||||
|
||||
function writeCodecFixture(cwd, rows, entries) {
|
||||
const providerCodec = path.join(cwd, 'src/data/idSwitches.js');
|
||||
mkdirSync(path.dirname(providerCodec), { recursive: true });
|
||||
writeFileSync(
|
||||
providerCodec,
|
||||
readFileSync(new URL('./idSwitches.js', import.meta.url)),
|
||||
);
|
||||
const source = readFileSync(CODEC_SOURCE_PATH, 'utf8');
|
||||
const registryEnd = ']);\n\nexport const REGISTERED_LAYER_IDS';
|
||||
assert.equal(source.split(registryEnd).length, 2);
|
||||
|
||||
+23
-1
@@ -74,7 +74,11 @@ function refreshFailureFromStats(stats, label) {
|
||||
* for real-time data overlays on the CesiumJS globe.
|
||||
*/
|
||||
export class LayerLifecycle {
|
||||
constructor(viewer, { allowQaRegistration = false } = {}) {
|
||||
constructor(
|
||||
viewer,
|
||||
{ allowQaRegistration = false, getSourceAvailability } = {},
|
||||
) {
|
||||
this._catalogSourceAvailability = getSourceAvailability;
|
||||
this.viewer = viewer;
|
||||
this._activityListeners = new Set();
|
||||
this.layers = new Map(); // id → { module, enabled, initialized, intervalId, lifecycleState, lifecycleUncertain }
|
||||
@@ -228,6 +232,7 @@ export class LayerLifecycle {
|
||||
|
||||
_normalizedStats(entry) {
|
||||
const moduleStats = this._moduleStats(entry);
|
||||
const availability = this._sourceAvailability(entry.module.id);
|
||||
const lifecycleLoading =
|
||||
entry.lifecycleState === 'enabling' ||
|
||||
entry.lifecycleState === 'disabling';
|
||||
@@ -235,6 +240,13 @@ export class LayerLifecycle {
|
||||
count: 0,
|
||||
lastUpdate: null,
|
||||
...moduleStats,
|
||||
...(availability?.available === false
|
||||
? {
|
||||
status: 'unavailable',
|
||||
sourceUnavailable: true,
|
||||
error: availability.reason,
|
||||
}
|
||||
: {}),
|
||||
loading: lifecycleLoading || moduleStats.loading === true,
|
||||
refreshing: entry.refreshing || moduleStats.refreshing === true,
|
||||
managerRefreshError: entry.managerRefreshError,
|
||||
@@ -2282,7 +2294,17 @@ export class LayerLifecycle {
|
||||
}
|
||||
}
|
||||
|
||||
_sourceAvailability(layerId) {
|
||||
return (
|
||||
this._catalogSourceAvailability?.(layerId) ??
|
||||
this.layers.get(layerId)?.module.getSourceAvailability?.()
|
||||
);
|
||||
}
|
||||
|
||||
async _visibilityBlockReason(change) {
|
||||
const availability = this._sourceAvailability(change.layerId);
|
||||
if (change.enabled && availability?.available === false)
|
||||
return availability.reason || 'Data source unavailable';
|
||||
for (const callback of this._visibilityGuards) {
|
||||
try {
|
||||
const result = await callback(change);
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
formatAwarenessLabel,
|
||||
getAwarenessNavigationTargets,
|
||||
} from './militaryAwarenessEngine.js';
|
||||
import { formatAwarenessCount } from '../layers/awareness/panel.js';
|
||||
import { NAVIGATION_AUTHORITY_EVENT } from '../navigationPolicy.js';
|
||||
|
||||
const militaryAwarenessSource = readLayerSource(
|
||||
@@ -55,6 +56,18 @@ const AWARENESS_DEPENDENCIES = [
|
||||
'military-installations',
|
||||
];
|
||||
|
||||
test('Contacts labels a capped cohort as a lower bound', () => {
|
||||
assert.equal(
|
||||
formatAwarenessCount({ count: 20_000, complete: false, truncated: true }),
|
||||
'At least 20,000',
|
||||
);
|
||||
assert.equal(
|
||||
formatAwarenessCount({ count: 19_999, complete: true, truncated: false }),
|
||||
'19,999',
|
||||
);
|
||||
assert.equal(formatAwarenessCount({ count: null }), '?');
|
||||
});
|
||||
|
||||
function installAwarenessRuntime({
|
||||
isEnabled = () => true,
|
||||
isEffectivelyEnabled = isEnabled,
|
||||
@@ -2554,6 +2567,7 @@ test('the contacts window reports exactly the counts the panel renders', () => {
|
||||
assert.equal(window.flights, 42);
|
||||
assert.equal(window.military, 13);
|
||||
assert.equal(window.aircraft, 55);
|
||||
assert.equal(window.complete, true);
|
||||
assert.equal(window.centeredOn, 'ASA635');
|
||||
assert.equal(window.radiusKm, Math.round(AWARENESS_RADIUS_M / 1000));
|
||||
// A feed that cannot answer says so rather than reporting a confident zero.
|
||||
@@ -2561,6 +2575,57 @@ test('the contacts window reports exactly the counts the panel renders', () => {
|
||||
assert.equal(window.vessels, 'unknown');
|
||||
});
|
||||
|
||||
test('the voice query snapshot is a detached copy of the rendered aircraft cohort', async () => {
|
||||
const subjectPosition = Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 1_000);
|
||||
const nearbyPosition = Cesium.Cartesian3.fromDegrees(-97.7, 30.3, 1_500);
|
||||
const restoreCollections = stubAwarenessCollections({
|
||||
flights: [
|
||||
{
|
||||
icao24: 'subject',
|
||||
id: 'SUBJECT',
|
||||
position: subjectPosition,
|
||||
distance: 0,
|
||||
},
|
||||
{
|
||||
icao24: 'nearby',
|
||||
id: 'NEARBY',
|
||||
position: nearbyPosition,
|
||||
distance: 5_000,
|
||||
},
|
||||
],
|
||||
});
|
||||
const runtime = installAwarenessRuntime();
|
||||
try {
|
||||
runtime.dispatch(
|
||||
'gev:awareness-subject-selected',
|
||||
awarenessSubject('flights', 'subject', subjectPosition),
|
||||
);
|
||||
await nextTurn();
|
||||
const first = militaryAwarenessLayer.getAircraftQuerySnapshot();
|
||||
assert.equal(first.cohorts.flights.count, 1);
|
||||
assert.equal(first.cohorts.flights.complete, true);
|
||||
assert.equal(first.cohorts.flights.provenance.id, 'flights');
|
||||
assert.equal(first.cohorts.flights.provenance.enabled, true);
|
||||
assert.equal(first.cohorts.flights.items[0].icao24, 'nearby');
|
||||
first.cohorts.flights.items[0].icao24 = 'mutated';
|
||||
first.cohorts.flights.items[0].position.x = 99;
|
||||
first.cohorts.flights.provenance.source = 'mutated';
|
||||
first.cohorts.flights.provenance.stats.coverage = { radiusM: 1 };
|
||||
first.subject.position.x = 77;
|
||||
const second = militaryAwarenessLayer.getAircraftQuerySnapshot();
|
||||
assert.equal(second.cohorts.flights.items[0].icao24, 'nearby');
|
||||
assert.equal(second.cohorts.flights.items[0].position.x, nearbyPosition.x);
|
||||
assert.notEqual(second.cohorts.flights.provenance.source, 'mutated');
|
||||
assert.notDeepEqual(second.cohorts.flights.provenance.stats.coverage, {
|
||||
radiusM: 1,
|
||||
});
|
||||
assert.equal(second.subject.position.x, subjectPosition.x);
|
||||
} finally {
|
||||
runtime.restore();
|
||||
restoreCollections();
|
||||
}
|
||||
});
|
||||
|
||||
test('there is no contacts window without a subject', () => {
|
||||
assert.equal(contactsWindowFromSnapshot(null), null);
|
||||
assert.equal(contactsWindowFromSnapshot({ cohorts: [] }), null);
|
||||
|
||||
@@ -65,6 +65,23 @@ export function unregisterPickOwner(layerId) {
|
||||
_owners.delete(layerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The registered layer that owns a picked id, or null.
|
||||
* @param {string} pickedId - Canonical pick id from {@link resolvePickId}.
|
||||
* @returns {string|null} Owning layer id.
|
||||
*/
|
||||
export function findPickOwner(pickedId) {
|
||||
if (!pickedId) return null;
|
||||
for (const [ownerId, predicate] of _owners) {
|
||||
try {
|
||||
if (predicate(pickedId)) return ownerId;
|
||||
} catch {
|
||||
// a broken predicate must never break picking
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when some OTHER registered layer owns the picked id.
|
||||
* @param {string} layerId - The asking layer's id (excluded from the scan).
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { getContextStore, isContextRecordActive } from './contextStore.js';
|
||||
import { findPickOwner, resolvePickId } from './pickRegistry.js';
|
||||
|
||||
/**
|
||||
* Resolve a `scene.pick()` result to the identity a person would name: which
|
||||
* layer, which record, and its display label.
|
||||
*
|
||||
* Two lanes, in order:
|
||||
* 1. Context-store carriers. Entity-backed layers (infrastructure GeoJSON,
|
||||
* selected vessels, fires, installations) stamp `__gevContextId` on the
|
||||
* picked object, and the store record already carries the label and
|
||||
* position voice uses everywhere else.
|
||||
* 2. Pick owners. Primitive-backed layers (aircraft, satellites, vessels)
|
||||
* register an ownership predicate in `pickRegistry`; the owning layer's
|
||||
* `findByQuery` turns the pick id into a live descriptor.
|
||||
*
|
||||
* Trails claim their pick ids only to make clicks inert, so they never
|
||||
* resolve. Disabled layers never resolve: what the user cannot see is not
|
||||
* what they are pointing at.
|
||||
*/
|
||||
|
||||
const KIND_BY_LAYER = Object.freeze({
|
||||
flights: 'aircraft',
|
||||
military: 'aircraft',
|
||||
'local-adsb': 'aircraft',
|
||||
'ais-live-vessels': 'vessel',
|
||||
satellites: 'satellite',
|
||||
'local-firms': 'fire',
|
||||
});
|
||||
|
||||
const IGNORED_OWNERS = new Set(['trails']);
|
||||
|
||||
function text(value) {
|
||||
return String(value ?? '').trim();
|
||||
}
|
||||
|
||||
function finite(value) {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : null;
|
||||
}
|
||||
|
||||
/** Kind of thing a layer holds, for deixis ("this aircraft", "this place"). */
|
||||
export function pickedKindForLayer(layerId) {
|
||||
return KIND_BY_LAYER[layerId] || 'feature';
|
||||
}
|
||||
|
||||
function contextCarrierRecord(picked, dataManager) {
|
||||
const store = getContextStore();
|
||||
for (const carrier of [
|
||||
picked?.id,
|
||||
picked?.primitive?.id,
|
||||
picked?.primitive,
|
||||
]) {
|
||||
const contextId =
|
||||
carrier && typeof carrier === 'object' ? carrier.__gevContextId : null;
|
||||
if (!contextId) continue;
|
||||
const record = store.entities.get(contextId);
|
||||
if (record && isContextRecordActive(record, dataManager)) return record;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function fromRecord(record) {
|
||||
const layerId = record.layerId || null;
|
||||
const id =
|
||||
layerId === 'ais-live-vessels' && record.properties?.mmsi
|
||||
? String(record.properties.mmsi)
|
||||
: String(record.id);
|
||||
return {
|
||||
layerId,
|
||||
id,
|
||||
label:
|
||||
text(record.label) ||
|
||||
text(record.properties?.name) ||
|
||||
text(record.layerName) ||
|
||||
id,
|
||||
kind: pickedKindForLayer(layerId),
|
||||
lat: finite(record.latitude),
|
||||
lon: finite(record.longitude),
|
||||
};
|
||||
}
|
||||
|
||||
function descriptorLabel(found, fallback) {
|
||||
return (
|
||||
text(found?.callsign) ||
|
||||
text(found?.registration) ||
|
||||
text(found?.name) ||
|
||||
text(found?.icao24) ||
|
||||
text(found?.mmsi) ||
|
||||
text(found?.noradId) ||
|
||||
fallback
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object|null} picked Result of `scene.pick()`.
|
||||
* @param {object} [options]
|
||||
* @param {object} [options.dataManager] Layer lifecycle (enabled state, modules).
|
||||
* @returns {{layerId: string, id: string, label: string, kind: string, lat: number|null, lon: number|null, altitudeM?: number|null}|null}
|
||||
*/
|
||||
export function resolvePickedContext(picked, { dataManager = null } = {}) {
|
||||
if (!picked) return null;
|
||||
const record = contextCarrierRecord(picked, dataManager);
|
||||
if (record) return fromRecord(record);
|
||||
|
||||
const pickId = resolvePickId(picked);
|
||||
if (!pickId) return null;
|
||||
const layerId = findPickOwner(pickId);
|
||||
if (!layerId || IGNORED_OWNERS.has(layerId)) return null;
|
||||
if (dataManager?.isEnabled && !dataManager.isEnabled(layerId)) return null;
|
||||
const layer = dataManager?.layers?.get?.(layerId);
|
||||
let found = null;
|
||||
try {
|
||||
found = layer?.module?.findByQuery?.(pickId) || null;
|
||||
} catch {
|
||||
found = null;
|
||||
}
|
||||
return {
|
||||
layerId,
|
||||
id: pickId,
|
||||
label: descriptorLabel(found, text(layer?.name) || pickId),
|
||||
kind: pickedKindForLayer(layerId),
|
||||
lat: finite(found?.latitude),
|
||||
lon: finite(found?.longitude),
|
||||
altitudeM: finite(found?.altitudeM),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
globalThis.window ||= globalThis;
|
||||
globalThis.CustomEvent ||= class CustomEvent {
|
||||
constructor(type, init) {
|
||||
this.type = type;
|
||||
this.detail = init?.detail;
|
||||
}
|
||||
};
|
||||
globalThis.dispatchEvent ||= () => true;
|
||||
|
||||
const { registerEntityContext } = await import('./contextStore.js');
|
||||
const { registerPickOwner, unregisterPickOwner, findPickOwner } = await import('./pickRegistry.js');
|
||||
const { resolvePickedContext, pickedKindForLayer } = await import('./pickedContext.js');
|
||||
|
||||
function manager({ enabled = new Set(['flights', 'local-datacenters']), modules = {} } = {}) {
|
||||
return {
|
||||
isEnabled: (id) => enabled.has(id),
|
||||
layers: new Map(Object.entries(modules).map(([id, module]) => [id, { name: id, module }])),
|
||||
};
|
||||
}
|
||||
|
||||
test('an aircraft billboard resolves through its pick owner to a live label', () => {
|
||||
registerPickOwner('flights', (id) => id === 'a1b2c3');
|
||||
const dataManager = manager({
|
||||
modules: {
|
||||
flights: {
|
||||
findByQuery: (q) => (q === 'a1b2c3' ? { icao24: 'a1b2c3', callsign: 'UPS793', latitude: 30.2, longitude: -97.7, altitudeM: 9000 } : null),
|
||||
},
|
||||
},
|
||||
});
|
||||
const picked = { id: 'a1b2c3', primitive: {} };
|
||||
assert.equal(findPickOwner('a1b2c3'), 'flights');
|
||||
assert.deepEqual(resolvePickedContext(picked, { dataManager }), {
|
||||
layerId: 'flights',
|
||||
id: 'a1b2c3',
|
||||
label: 'UPS793',
|
||||
kind: 'aircraft',
|
||||
lat: 30.2,
|
||||
lon: -97.7,
|
||||
altitudeM: 9000,
|
||||
});
|
||||
unregisterPickOwner('flights');
|
||||
});
|
||||
|
||||
test('an entity-backed feature resolves through the context store', () => {
|
||||
const entity = { show: true };
|
||||
registerEntityContext(entity, {
|
||||
id: 'local-datacenters:7',
|
||||
layerId: 'local-datacenters',
|
||||
label: 'Data Center 7',
|
||||
latitude: 30.1,
|
||||
longitude: -97.6,
|
||||
});
|
||||
const out = resolvePickedContext({ id: entity }, { dataManager: manager() });
|
||||
assert.deepEqual(out, {
|
||||
layerId: 'local-datacenters',
|
||||
id: 'local-datacenters:7',
|
||||
label: 'Data Center 7',
|
||||
kind: 'feature',
|
||||
lat: 30.1,
|
||||
lon: -97.6,
|
||||
});
|
||||
});
|
||||
|
||||
test('disabled layers, trails and empty picks never resolve', () => {
|
||||
registerPickOwner('military', (id) => id === 'm1');
|
||||
assert.equal(resolvePickedContext({ id: 'm1' }, { dataManager: manager() }), null, 'military is disabled');
|
||||
unregisterPickOwner('military');
|
||||
assert.equal(resolvePickedContext({ id: 'gev-trail:1' }, { dataManager: manager() }), null);
|
||||
assert.equal(resolvePickedContext(null), null);
|
||||
assert.equal(resolvePickedContext({ id: 'unknown' }, { dataManager: manager() }), null);
|
||||
assert.equal(pickedKindForLayer('ais-live-vessels'), 'vessel');
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { createBhoteKoshiLocatorLayer } from './bhoteKoshiLocator.js';
|
||||
import { createWorldAnnotationRenderer } from '../annotations/worldAnnotationRenderer.js';
|
||||
import * as annotations from '../annotations/worldAnnotationRenderer.js';
|
||||
import { createDirectionsLayer } from '../layers/directions/index.js';
|
||||
import { createDirectionsSource } from '../layers/directions/source.js';
|
||||
import { createLifecycle as createCctvLifecycle } from '../layers/cctv/lifecycle.js';
|
||||
import * as input from './inputOwnership.js';
|
||||
import { createOverpassAlprSource } from '../layers/alpr/source.js';
|
||||
@@ -64,7 +65,7 @@ test('voice route geometry introduces the shared OSM credit', (t) => {
|
||||
test('Directions introduces the shared OSM credit when the requested route draws', async (t) => {
|
||||
const v = viewer(t);
|
||||
t.mock.method(globalThis, 'fetch', async () => Response.json({ ok: true, geometry: [[0, 0], [0.01, 0.01]], steps: [] }));
|
||||
const layer = createDirectionsLayer({ services: {
|
||||
const layer = createDirectionsLayer({ source: createDirectionsSource(), services: {
|
||||
credits: { showOsmCredit, hideOsmCredit }, annotations, input, scenePick: {}, camera: {},
|
||||
overlays: { setOverlayEntries: noop, setOverlaySourceVisible: noop, clearOverlaySource: noop },
|
||||
render: { governorRequestRender: noop, holdContinuousRender: noop, releaseContinuousRender: noop },
|
||||
|
||||
Reference in new issue
Block a user