mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04: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
@@ -65,13 +65,28 @@ test('data setup seals the caller catalog before controls can restore and drains
|
||||
},
|
||||
},
|
||||
},
|
||||
catalog: createLayerCatalog(layers, metadata(layers)),
|
||||
catalog: {
|
||||
...createLayerCatalog(layers, metadata(layers)),
|
||||
getSourceAvailability: (id) =>
|
||||
id === 'one'
|
||||
? {
|
||||
available: false,
|
||||
reason: 'One: data source not configured',
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
defer: (release) => releases.push(release),
|
||||
onData: (manager) => {
|
||||
observed = manager;
|
||||
},
|
||||
});
|
||||
assert.equal(observed, dataManager);
|
||||
assert.equal(await dataManager.setEnabled('one', true), false);
|
||||
assert.equal(dataManager.layers.get('one').initialized, false);
|
||||
assert.equal(
|
||||
dataManager.getAll().find(({ id }) => id === 'one').stats.error,
|
||||
'One: data source not configured',
|
||||
);
|
||||
assert.deepEqual(attached, [['one', 'two']]);
|
||||
assert.deepEqual([...dataManager.layers.keys()], ['one', 'two']);
|
||||
assert.throws(() => dataManager.register({ id: 'late' }), /finalized/);
|
||||
|
||||
+12
-38
@@ -1,9 +1,9 @@
|
||||
import { resolveCatalogSources } from './sourceComposition.js';
|
||||
import { createWeatherClock } from '../layers/weather/clock.js';
|
||||
import { createWeatherLayer } from '../layers/weather/index.js';
|
||||
import { createCyclonesLayer } from '../layers/cyclones/index.js';
|
||||
import { createWindLayer } from '../layers/wind/index.js';
|
||||
import { createLayerCatalog } from './catalog.js';
|
||||
import { MAPILLARY_SOURCE_METHODS } from '../layers/streetLevel/providers/mapillary/policy.js';
|
||||
import { LAYER_STATE_REGISTRY } from '../data/layerState.js';
|
||||
import { createMilitaryRegistry } from '../layers/aircraft/classification.js';
|
||||
import { createApplicationFlights } from './layers/flights.js';
|
||||
@@ -32,34 +32,6 @@ import { localGeoJsonServices } from './localGeojsonServices.js';
|
||||
import { createBhoteKoshiEventLayer } from '../data/bhoteKoshiEvent.js';
|
||||
import { createBhoteKoshiLocatorLayer } from '../data/bhoteKoshiLocator.js';
|
||||
|
||||
const SOURCE_METHODS = Object.freeze({
|
||||
flights: ['getSnapshot'],
|
||||
military: ['getSnapshot'],
|
||||
vessels: ['getSnapshot'],
|
||||
cctv: ['getCatalog', 'getHealth', 'getFrameUrl', 'getMediaUrl'],
|
||||
radio: ['getDirectory', 'recordClick'],
|
||||
traffic: [
|
||||
'requestRoads',
|
||||
'getStatus',
|
||||
'fetchFlowForBounds',
|
||||
'getFlowSessionStats',
|
||||
'resetFlowTileCache',
|
||||
],
|
||||
bikeshare: ['getStations'],
|
||||
installations: ['getMappedSites', 'searchNearby'],
|
||||
satellites: ['readGroup'],
|
||||
launches: ['getLaunches', 'getActiveTle'],
|
||||
alpr: ['fetch'],
|
||||
firms: ['getSnapshot'],
|
||||
wind: ['getSnapshot'],
|
||||
weather: ['getSnapshot'],
|
||||
cyclones: ['getSnapshot'],
|
||||
earthquakes: ['getSnapshot'],
|
||||
'fire-perimeters': ['getSnapshot'],
|
||||
cables: ['fetch'],
|
||||
mapillary: MAPILLARY_SOURCE_METHODS,
|
||||
});
|
||||
|
||||
/**
|
||||
* Hardware-local layers are registered like any other but never enter share
|
||||
* links or stored layer state: another browser cannot have this receiver.
|
||||
@@ -86,6 +58,7 @@ export function createApplicationCatalog({
|
||||
vesselOptions,
|
||||
resolveAsset,
|
||||
nepalBoundaryResolver,
|
||||
streetLevelProviders,
|
||||
}) {
|
||||
if (!signal?.addEventListener)
|
||||
throw new TypeError('An application lifetime signal is required');
|
||||
@@ -93,12 +66,8 @@ export function createApplicationCatalog({
|
||||
if (!surface?.groundFloor || !surface?.terrain)
|
||||
throw new TypeError('Application surface services are required');
|
||||
|
||||
for (const [name, methods] of Object.entries(SOURCE_METHODS)) {
|
||||
if (
|
||||
methods.some((method) => typeof sources?.[name]?.[method] !== 'function')
|
||||
)
|
||||
throw new TypeError(`Invalid catalog source: ${name}`);
|
||||
}
|
||||
const sourceComposition = resolveCatalogSources(sources);
|
||||
sources = sourceComposition.sources;
|
||||
const militaryRegistry = createMilitaryRegistry();
|
||||
const weatherClock = createWeatherClock();
|
||||
const dispose = () => {
|
||||
@@ -108,7 +77,8 @@ export function createApplicationCatalog({
|
||||
};
|
||||
signal.addEventListener('abort', dispose, { once: true });
|
||||
try {
|
||||
militaryRegistry.configureSource(sources.military, { signal });
|
||||
if (sourceComposition.isConfigured('military'))
|
||||
militaryRegistry.configureSource(sources.military, { signal });
|
||||
const flights = createApplicationFlights({
|
||||
surface,
|
||||
source: sources.flights,
|
||||
@@ -154,6 +124,7 @@ export function createApplicationCatalog({
|
||||
createApplicationStreetLevel({
|
||||
surface,
|
||||
sources: { mapillary: sources.mapillary },
|
||||
providers: streetLevelProviders,
|
||||
}),
|
||||
satellites,
|
||||
createApplicationLaunches({ source: sources.launches, satellites }),
|
||||
@@ -162,8 +133,8 @@ export function createApplicationCatalog({
|
||||
createApplicationRadio({ surface, source: sources.radio }),
|
||||
createApplicationTransit({ surface, source: sources.transit }),
|
||||
createApplicationBikeshare({ source: sources.bikeshare }),
|
||||
createApplicationDirections(),
|
||||
createApplicationRecentImagery(),
|
||||
createApplicationDirections({ source: sources.directions }),
|
||||
createApplicationRecentImagery({ source: sources['recent-imagery'] }),
|
||||
vessels,
|
||||
installations,
|
||||
createApplicationAwareness({
|
||||
@@ -204,6 +175,9 @@ export function createApplicationCatalog({
|
||||
);
|
||||
return Object.freeze({
|
||||
...catalog,
|
||||
getSourceAvailability: sourceComposition.createAvailabilityLookup(
|
||||
catalog.layers,
|
||||
),
|
||||
militaryRegistry,
|
||||
surface,
|
||||
weatherClock,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createApplicationCatalog } from './constructCatalog.js';
|
||||
import { createStandaloneLayerSources } from '../standalone/layerSources.js';
|
||||
import { catalogControlServices } from './catalog.js';
|
||||
import { LayerLifecycle } from '../data/lifecycle.js';
|
||||
import { fakeStreetLevelProvider } from '../testSupport/streetLevelFakes.mjs';
|
||||
|
||||
function fixtureSources(ids, calls) {
|
||||
const sources = createStandaloneLayerSources();
|
||||
@@ -116,7 +117,7 @@ test('invalid or already cancelled construction fails before classification can
|
||||
assert.throws(
|
||||
() =>
|
||||
createApplicationCatalog({
|
||||
sources: {},
|
||||
sources: { flights: {} },
|
||||
signal: lifetime.signal,
|
||||
surface: fixtureSurface(lifetime.signal),
|
||||
}),
|
||||
@@ -140,3 +141,53 @@ function fixtureSurface(signal) {
|
||||
eventTarget: null,
|
||||
});
|
||||
}
|
||||
|
||||
test('Mapillary can be omitted or replaced without changing catalog membership', (t) => {
|
||||
const lifetime = new AbortController();
|
||||
t.after(() => lifetime.abort());
|
||||
const sources = fixtureSources([], []);
|
||||
delete sources.mapillary;
|
||||
const options = {
|
||||
sources,
|
||||
signal: lifetime.signal,
|
||||
surface: fixtureSurface(lifetime.signal),
|
||||
};
|
||||
const catalog = createApplicationCatalog(options);
|
||||
assert.equal(catalog.layers.length, 30);
|
||||
assert.deepEqual(catalog.get('street-level').providerIds, []);
|
||||
assert.equal(
|
||||
catalogControlServices(catalog).streetLevelLayer,
|
||||
catalog.get('street-level'),
|
||||
);
|
||||
assert.match(
|
||||
catalog.get('street-level').getUIState().coverage.hint,
|
||||
/No street-level imagery providers/,
|
||||
);
|
||||
const provider = fakeStreetLevelProvider({
|
||||
id: 'example',
|
||||
pickPrefix: 'example:',
|
||||
});
|
||||
const replacement = createApplicationCatalog({
|
||||
...options,
|
||||
streetLevelProviders: [provider],
|
||||
});
|
||||
assert.deepEqual(replacement.get('street-level').providerIds, ['example']);
|
||||
assert.deepEqual(replacement.metadata, catalog.metadata);
|
||||
assert.equal(provider.calls.activate, 0, 'construction must not acquire');
|
||||
assert.deepEqual(
|
||||
createApplicationCatalog({
|
||||
...options,
|
||||
sources: fixtureSources([], []),
|
||||
streetLevelProviders: [],
|
||||
}).get('street-level').providerIds,
|
||||
[],
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createApplicationCatalog({
|
||||
...options,
|
||||
sources: { ...sources, mapillary: {} },
|
||||
}),
|
||||
/Mapillary source/,
|
||||
);
|
||||
});
|
||||
@@ -13,6 +13,7 @@ export function createApplicationData({
|
||||
// Initialize data layer manager
|
||||
const dataManager = new LayerLifecycle(viewer, {
|
||||
allowQaRegistration,
|
||||
getSourceAvailability: catalog?.getSourceAvailability,
|
||||
});
|
||||
defer(async () => {
|
||||
await dataManager.destroyAll();
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { createApplicationDirectionsLayer } from '../../data/directions.js';
|
||||
|
||||
/**
|
||||
* Construct one Directions layer using the application scene owners.
|
||||
*
|
||||
* It takes no data source: a route is fetched from `/api/route` when the
|
||||
* operator places both endpoints, so there is nothing to supply up front.
|
||||
* @returns {object} A fresh layer instance for this catalog.
|
||||
*/
|
||||
export function createApplicationDirections() {
|
||||
return createApplicationDirectionsLayer();
|
||||
/** Construct Directions with application scene owners and an explicit route source. */
|
||||
export function createApplicationDirections({ source }) {
|
||||
return createApplicationDirectionsLayer({ source });
|
||||
}
|
||||
@@ -3,17 +3,17 @@ import { createRecentImageryRenderer } from '../../layers/recentImagery/renderin
|
||||
import { createThumbnailLoader } from '../../layers/recentImagery/thumbnails.js';
|
||||
import { governorRequestRender } from '../../renderGovernor.js';
|
||||
|
||||
/**
|
||||
* Construct the Recent Imagery layer with the real GIBS renderer and the
|
||||
* browser thumbnail loader. It takes no data source: the catalog is NASA
|
||||
* CMR and the tiles are GIBS, both browser-direct and keyless.
|
||||
* @returns {object} A fresh layer instance for this catalog.
|
||||
*/
|
||||
export function createApplicationRecentImagery() {
|
||||
/** Construct imagery presentation over one explicitly supplied acquisition source. */
|
||||
export function createApplicationRecentImagery({ source }) {
|
||||
return createRecentImageryLayer({
|
||||
catalog: { searchHls: (...args) => source.searchHls(...args) },
|
||||
renderer: createRecentImageryRenderer({
|
||||
requestRender: governorRequestRender,
|
||||
tileTemplate: (...args) => source.getTileTemplate(...args),
|
||||
credit: source.credit || source.label || 'Imagery',
|
||||
}),
|
||||
thumbnails: createThumbnailLoader({
|
||||
requestThumbnail: (...args) => source.getThumbnail(...args),
|
||||
}),
|
||||
thumbnails: createThumbnailLoader(),
|
||||
});
|
||||
}
|
||||
@@ -5,10 +5,20 @@ import * as picking from '../../data/pickRegistry.js';
|
||||
import * as input from '../../data/inputOwnership.js';
|
||||
import * as render from '../../renderGovernor.js';
|
||||
|
||||
/** A new provider registers here; its chip, credit and share bit follow. */
|
||||
export function createApplicationStreetLevel({ surface, sources }) {
|
||||
/**
|
||||
* Inject providers, or use Mapillary only when its source is supplied.
|
||||
* An explicit provider list replaces the default; unused sources.mapillary is
|
||||
* neither constructed nor validated, including when the list is empty.
|
||||
*/
|
||||
export function createApplicationStreetLevel({
|
||||
surface,
|
||||
sources = {},
|
||||
providers = sources.mapillary == null
|
||||
? []
|
||||
: [createMapillaryProvider({ source: sources.mapillary })],
|
||||
}) {
|
||||
return createStreetLevelLayer({
|
||||
providers: [createMapillaryProvider({ source: sources.mapillary })],
|
||||
providers,
|
||||
services: {
|
||||
sprites,
|
||||
picking,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createApplicationStreetLevel } from './streetLevel.js';
|
||||
import { fakeStreetLevelProvider } from '../../testSupport/streetLevelFakes.mjs';
|
||||
|
||||
test('explicit providers replace the default source and an empty list removes it', () => {
|
||||
const sources = { mapillary: {} };
|
||||
const provider = fakeStreetLevelProvider({
|
||||
id: 'example',
|
||||
pickPrefix: 'example:',
|
||||
});
|
||||
const layer = createApplicationStreetLevel({
|
||||
sources,
|
||||
providers: [provider],
|
||||
});
|
||||
assert.deepEqual(layer.providerIds, ['example']);
|
||||
assert.deepEqual(
|
||||
createApplicationStreetLevel({ sources, providers: [] }).providerIds,
|
||||
[],
|
||||
);
|
||||
assert.throws(
|
||||
() => createApplicationStreetLevel({ sources }),
|
||||
/Mapillary source/,
|
||||
);
|
||||
assert.throws(
|
||||
() => createApplicationStreetLevel({ providers: null }),
|
||||
/array/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
const contract = (layers, methods) =>
|
||||
Object.freeze({
|
||||
layers: Object.freeze(layers),
|
||||
methods: Object.freeze(methods),
|
||||
});
|
||||
|
||||
/** Acquisition contracts and their consumers; records remain family-specific. */
|
||||
export const CATALOG_SOURCE_CONTRACTS = Object.freeze({
|
||||
directions: contract(['directions'], ['getRoute']),
|
||||
'recent-imagery': contract(
|
||||
['recent-imagery'],
|
||||
['searchHls', 'getThumbnail', 'getTileTemplate'],
|
||||
),
|
||||
flights: contract(['flights'], ['getSnapshot']),
|
||||
military: contract(['military'], ['getSnapshot']),
|
||||
vessels: contract(['ais-live-vessels'], ['getSnapshot']),
|
||||
cctv: contract(
|
||||
['cctv'],
|
||||
['getCatalog', 'getHealth', 'getFrameUrl', 'getMediaUrl'],
|
||||
),
|
||||
radio: contract(['radio'], ['getDirectory', 'recordClick']),
|
||||
traffic: contract(
|
||||
['traffic'],
|
||||
[
|
||||
'requestRoads',
|
||||
'getStatus',
|
||||
'fetchFlowForBounds',
|
||||
'getFlowSessionStats',
|
||||
'resetFlowTileCache',
|
||||
],
|
||||
),
|
||||
bikeshare: contract(['bikeshare'], ['getStations']),
|
||||
installations: contract(
|
||||
['military-installations'],
|
||||
['getMappedSites', 'searchNearby'],
|
||||
),
|
||||
satellites: contract(['satellites'], ['readGroup']),
|
||||
launches: contract(['rocket-launches'], ['getLaunches', 'getActiveTle']),
|
||||
alpr: contract(['alpr-cameras'], ['fetch']),
|
||||
firms: contract(['local-firms'], ['getSnapshot']),
|
||||
wind: contract(['wind'], ['getSnapshot']),
|
||||
weather: contract(
|
||||
['weather-radar', 'weather-satellite', 'weather-lightning'],
|
||||
['getSnapshot'],
|
||||
),
|
||||
cyclones: contract(['weather-cyclones'], ['getSnapshot']),
|
||||
earthquakes: contract(['earthquakes'], ['getSnapshot']),
|
||||
'fire-perimeters': contract(['fire-perimeters'], ['getSnapshot']),
|
||||
cables: contract(['telegeography-submarine-cables'], ['fetch']),
|
||||
transit: contract(['transit'], ['requestSnapshot', 'getHistory']),
|
||||
});
|
||||
|
||||
/** An omitted provider is unavailable, never an authoritative empty dataset. */
|
||||
export class SourceUnavailableError extends Error {
|
||||
constructor(name) {
|
||||
super(`Data source not configured: ${name}`);
|
||||
this.name = 'SourceUnavailableError';
|
||||
this.code = 'unavailable';
|
||||
this.unavailable = true;
|
||||
this.retryable = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate supplied contracts and keep omitted sources inert during construction. */
|
||||
export function resolveCatalogSources(supplied = {}) {
|
||||
if (!supplied || typeof supplied !== 'object' || Array.isArray(supplied))
|
||||
throw new TypeError('Catalog sources must be an object');
|
||||
const sources = { ...supplied };
|
||||
const missing = new Set();
|
||||
for (const [name, { methods }] of Object.entries(CATALOG_SOURCE_CONTRACTS)) {
|
||||
const source = sources[name];
|
||||
if (source == null) {
|
||||
missing.add(name);
|
||||
const unavailable = () => {
|
||||
throw new SourceUnavailableError(name);
|
||||
};
|
||||
sources[name] = Object.freeze({
|
||||
label: name,
|
||||
...Object.fromEntries(methods.map((method) => [method, unavailable])),
|
||||
});
|
||||
} else if (methods.some((method) => typeof source[method] !== 'function')) {
|
||||
throw new TypeError(`Invalid catalog source: ${name}`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
sources,
|
||||
isConfigured: (name) => !missing.has(name),
|
||||
createAvailabilityLookup(layers) {
|
||||
const byId = new Map(layers.map((layer) => [layer.id, layer]));
|
||||
const availability = new Map();
|
||||
for (const [name, { layers: ids }] of Object.entries(
|
||||
CATALOG_SOURCE_CONTRACTS,
|
||||
)) {
|
||||
for (const id of ids) {
|
||||
const layer = byId.get(id);
|
||||
if (!layer) throw new Error(`Missing source consumer: ${id}`);
|
||||
availability.set(
|
||||
id,
|
||||
Object.freeze({
|
||||
available: !missing.has(name),
|
||||
source: name,
|
||||
reason: missing.has(name)
|
||||
? `${layer.name || id}: data source not configured`
|
||||
: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
return (id) =>
|
||||
availability.get(id) ?? byId.get(id)?.getSourceAvailability?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
resolveCatalogSources,
|
||||
CATALOG_SOURCE_CONTRACTS,
|
||||
} from './sourceComposition.js';
|
||||
import { createApplicationCatalog } from './constructCatalog.js';
|
||||
import { createSurfaceServices } from './surfaceServices.js';
|
||||
import { createStandaloneLayerSources } from '../standalone/layerSources.js';
|
||||
import { LayerLifecycle } from '../data/lifecycle.js';
|
||||
|
||||
function catalog(sources, t) {
|
||||
const lifetime = new AbortController();
|
||||
t.after(() => lifetime.abort());
|
||||
return createApplicationCatalog({
|
||||
sources,
|
||||
signal: lifetime.signal,
|
||||
surface: createSurfaceServices({
|
||||
terrainSource: { getHeights: async () => [] },
|
||||
signal: lifetime.signal,
|
||||
eventTarget: null,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
test('every acquisition source can be omitted without losing catalog membership', (t) => {
|
||||
const baseline = catalog(createStandaloneLayerSources(), t);
|
||||
for (const name of Object.keys(CATALOG_SOURCE_CONTRACTS)) {
|
||||
const sources = createStandaloneLayerSources({ [name]: null });
|
||||
const actual = catalog(sources, t);
|
||||
assert.deepEqual(actual.metadata, baseline.metadata, name);
|
||||
for (const id of CATALOG_SOURCE_CONTRACTS[name].layers) {
|
||||
assert.equal(actual.getSourceAvailability(id).available, false, id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => {
|
||||
const actual = catalog({}, t);
|
||||
assert.equal(actual.layers.length, 30);
|
||||
const manager = new LayerLifecycle(
|
||||
{},
|
||||
{ getSourceAvailability: actual.getSourceAvailability },
|
||||
);
|
||||
for (const layer of actual.layers) manager.register(layer);
|
||||
for (const { layers: ids } of Object.values(CATALOG_SOURCE_CONTRACTS)) {
|
||||
for (const id of ids) {
|
||||
assert.equal(await manager.setEnabled(id, true), false, id);
|
||||
assert.equal(manager.layers.get(id).initialized, false, id);
|
||||
const row = manager.getAll().find((entry) => entry.id === id);
|
||||
assert.equal(row.enabled, false);
|
||||
assert.equal(row.stats.status, 'unavailable');
|
||||
assert.match(row.stats.error, /data source not configured/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('configured sources retain identity; invalid implementations never become unavailable silently', () => {
|
||||
const source = { getSnapshot: async () => [] };
|
||||
assert.equal(
|
||||
resolveCatalogSources({ earthquakes: source }).sources.earthquakes,
|
||||
source,
|
||||
);
|
||||
for (const name of Object.keys(CATALOG_SOURCE_CONTRACTS)) {
|
||||
assert.throws(
|
||||
() => resolveCatalogSources({ [name]: {} }),
|
||||
/Invalid catalog source/,
|
||||
);
|
||||
}
|
||||
const missing = resolveCatalogSources({}).sources.earthquakes;
|
||||
assert.throws(() => missing.getSnapshot(), { code: 'unavailable' });
|
||||
});
|
||||
|
||||
test('standalone defaults allow exact replacements and explicit removal, and reject typos', () => {
|
||||
const source = { getSnapshot: async () => [] };
|
||||
const sources = createStandaloneLayerSources({
|
||||
flights: source,
|
||||
military: null,
|
||||
});
|
||||
assert.equal(sources.flights, source);
|
||||
assert.equal(sources.military, null);
|
||||
assert.equal(typeof sources.cctv.getCatalog, 'function');
|
||||
assert.throws(
|
||||
() => createStandaloneLayerSources({ fligts: source }),
|
||||
/Unknown source/,
|
||||
);
|
||||
});
|
||||
|
||||
test('empty Street Level uses the shared unavailable lifecycle without initialization', async (t) => {
|
||||
const actual = catalog({}, t);
|
||||
const manager = new LayerLifecycle(
|
||||
{},
|
||||
{ getSourceAvailability: actual.getSourceAvailability },
|
||||
);
|
||||
manager.register(actual.get('street-level'));
|
||||
assert.equal(await manager.setEnabled('street-level', true), false);
|
||||
assert.equal(manager.layers.get('street-level').initialized, false);
|
||||
const row = manager.getAll()[0];
|
||||
assert.equal(row.stats.status, 'unavailable');
|
||||
assert.equal(row.stats.error, 'No street-level imagery providers configured');
|
||||
assert.equal(await manager.setEnabled('street-level', false), true);
|
||||
const configured = catalog(createStandaloneLayerSources(), t);
|
||||
assert.equal(
|
||||
configured.get('street-level').getSourceAvailability().available,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('availability lookup accepts frozen layers and uses their display names', () => {
|
||||
const composition = resolveCatalogSources({});
|
||||
const layers = Object.values(CATALOG_SOURCE_CONTRACTS)
|
||||
.flatMap(({ layers }) => layers)
|
||||
.map((id) =>
|
||||
Object.freeze({
|
||||
id,
|
||||
name: id === 'fire-perimeters' ? 'Fire perimeters' : id,
|
||||
}),
|
||||
);
|
||||
const lookup = composition.createAvailabilityLookup(layers);
|
||||
assert.equal(
|
||||
lookup('fire-perimeters').reason,
|
||||
'Fire perimeters: data source not configured',
|
||||
);
|
||||
assert.equal(Object.hasOwn(layers[0], 'getSourceAvailability'), false);
|
||||
const manager = new LayerLifecycle({}, { getSourceAvailability: lookup });
|
||||
manager.register(layers.find(({ id }) => id === 'fire-perimeters'));
|
||||
assert.equal(
|
||||
manager.getAll()[0].stats.error,
|
||||
'Fire perimeters: data source not configured',
|
||||
);
|
||||
});
|
||||
|
||||
test('absent military acquisition does not poll, suppress flights, or erase supplied classification', async (t) => {
|
||||
let fetches = 0;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
fetches++;
|
||||
throw new Error('Unexpected fallback acquisition');
|
||||
});
|
||||
const actual = catalog(createStandaloneLayerSources({ military: null }), t);
|
||||
const registry = actual.militaryRegistry;
|
||||
assert.equal(actual.getSourceAvailability('flights').available, true);
|
||||
assert.equal(registry.isMilitaryLayerActive(), false);
|
||||
assert.equal(registry.isMilitaryIcao('abc123'), false);
|
||||
registry.registerMilitaryIcaos([' ABC123 ']);
|
||||
await registry.refreshMilitaryRegistryIfStale();
|
||||
assert.equal(registry.isMilitaryIcao('abc123'), true);
|
||||
assert.equal(registry.isMilitaryIcao('def456'), false);
|
||||
const manager = new LayerLifecycle(
|
||||
{},
|
||||
{ getSourceAvailability: actual.getSourceAvailability },
|
||||
);
|
||||
manager.register(actual.get('military'));
|
||||
assert.equal(await manager.setEnabled('military', true), false);
|
||||
assert.equal(registry.isMilitaryLayerActive(), false);
|
||||
assert.equal(fetches, 0);
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import test from 'node:test';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
codexAuthPath,
|
||||
preferCodexOAuth,
|
||||
readCodexOAuthAccessToken,
|
||||
resolveCodexExecutable,
|
||||
startCodexChatGptLogin,
|
||||
} from '../server/providers/openai/codex-auth.js';
|
||||
|
||||
const nowMs = Date.UTC(2026, 8, 27);
|
||||
const jwt = (payload) =>
|
||||
`${Buffer.from('{}').toString('base64url')}.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.signature`;
|
||||
const auth = (token, extras = {}) =>
|
||||
JSON.stringify({
|
||||
auth_mode: 'chatgpt',
|
||||
tokens: { access_token: token, refresh_token: 'refresh-fixture' },
|
||||
...extras,
|
||||
});
|
||||
|
||||
test('optional Codex OAuth pin fails closed on ambiguous values', () => {
|
||||
assert.equal(preferCodexOAuth({}), false);
|
||||
assert.equal(preferCodexOAuth({ GEV_PREFER_CODEX_OAUTH: 'true' }), true);
|
||||
assert.equal(preferCodexOAuth({ GEV_PREFER_CODEX_OAUTH: 'off' }), false);
|
||||
assert.throws(
|
||||
() => preferCodexOAuth({ GEV_PREFER_CODEX_OAUTH: 'maybe' }),
|
||||
/must be true or false/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Codex auth path follows explicit auth file, CODEX_HOME, then home directory', () => {
|
||||
assert.equal(
|
||||
codexAuthPath({
|
||||
environment: { CODEX_AUTH_JSON: '/tmp/custom-auth.json' },
|
||||
home: '/home/fixture',
|
||||
}),
|
||||
path.resolve('/tmp/custom-auth.json'),
|
||||
);
|
||||
assert.equal(
|
||||
codexAuthPath({
|
||||
environment: { CODEX_HOME: '/tmp/codex-home' },
|
||||
home: '/home/fixture',
|
||||
}),
|
||||
path.join(path.resolve('/tmp/codex-home'), 'auth.json'),
|
||||
);
|
||||
assert.equal(
|
||||
codexAuthPath({ environment: {}, home: '/home/fixture' }),
|
||||
path.join('/home/fixture', '.codex', 'auth.json'),
|
||||
);
|
||||
});
|
||||
|
||||
test('Codex OAuth reader accepts a current ChatGPT token and rejects unusable credentials', () => {
|
||||
const current = jwt({ exp: nowMs / 1000 + 3600 });
|
||||
assert.equal(
|
||||
readCodexOAuthAccessToken({
|
||||
authPath: '/unused',
|
||||
nowMs,
|
||||
readFile: () => auth(current),
|
||||
}),
|
||||
current,
|
||||
);
|
||||
assert.equal(
|
||||
readCodexOAuthAccessToken({
|
||||
authPath: '/unused',
|
||||
nowMs,
|
||||
readFile: () =>
|
||||
JSON.stringify({
|
||||
auth_mode: 'chatgpt',
|
||||
tokens: { access_token: current },
|
||||
}),
|
||||
}),
|
||||
current,
|
||||
'GEV does not require or consume the vendor refresh token',
|
||||
);
|
||||
for (const readFile of [
|
||||
() => '{bad json',
|
||||
() => JSON.stringify({ tokens: {} }),
|
||||
() => auth(current, { auth_mode: 'apikey' }),
|
||||
() =>
|
||||
JSON.stringify({
|
||||
OPENAI_API_KEY: 'sk-fixture',
|
||||
tokens: { access_token: current, refresh_token: 'refresh-fixture' },
|
||||
}),
|
||||
() => auth('opaque-token'),
|
||||
() => auth('header.not-json.signature'),
|
||||
() => auth(jwt({ exp: 'tomorrow' })),
|
||||
() => {
|
||||
throw new Error('missing');
|
||||
},
|
||||
]) {
|
||||
assert.throws(
|
||||
() => readCodexOAuthAccessToken({ authPath: '/unused', readFile, nowMs }),
|
||||
/ChatGPT sign-in is unavailable/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('Codex OAuth reader enforces expiry margin and bounds tokens without exp by file age', () => {
|
||||
for (const exp of [nowMs / 1000 - 1, nowMs / 1000 + 59]) {
|
||||
assert.throws(
|
||||
() => {
|
||||
readCodexOAuthAccessToken({
|
||||
authPath: '/unused',
|
||||
nowMs,
|
||||
readFile: () => auth(jwt({ exp })),
|
||||
});
|
||||
},
|
||||
(error) => {
|
||||
assert.match(error.message, /sign-in has expired/);
|
||||
assert.equal(error.code, 'CODEX_OAUTH_REAUTH_REQUIRED');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
}
|
||||
const noExp = jwt({ sub: 'fixture' });
|
||||
const base = { authPath: '/unused', nowMs, readFile: () => auth(noExp) };
|
||||
assert.equal(
|
||||
readCodexOAuthAccessToken({
|
||||
...base,
|
||||
statFile: () => ({ mtimeMs: nowMs }),
|
||||
}),
|
||||
noExp,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
readCodexOAuthAccessToken({
|
||||
...base,
|
||||
statFile: () => ({ mtimeMs: nowMs - 3600_000 }),
|
||||
}),
|
||||
/sign-in has expired/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
readCodexOAuthAccessToken({
|
||||
...base,
|
||||
statFile: () => {
|
||||
throw new Error('missing');
|
||||
},
|
||||
}),
|
||||
/sign-in is unavailable/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Codex login resolves an installed executable before PATH and starts browser auth detached', async () => {
|
||||
const checked = [];
|
||||
assert.equal(
|
||||
resolveCodexExecutable({
|
||||
environment: {},
|
||||
home: '/home/fixture',
|
||||
access(candidate) {
|
||||
checked.push(candidate);
|
||||
if (candidate.endsWith('/.local/bin/codex')) return;
|
||||
throw new Error('missing');
|
||||
},
|
||||
}),
|
||||
path.join('/home/fixture', '.local', 'bin', 'codex'),
|
||||
);
|
||||
assert.equal(checked.length, 1);
|
||||
|
||||
const child = new EventEmitter();
|
||||
let unref = 0;
|
||||
child.unref = () => {
|
||||
unref += 1;
|
||||
};
|
||||
const calls = [];
|
||||
const pending = startCodexChatGptLogin({
|
||||
executable: '/fixture/codex',
|
||||
home: '/home/fixture',
|
||||
environment: {
|
||||
PATH: '/fixture',
|
||||
https_proxy: 'http://proxy.fixture:8080',
|
||||
OPENAI_API_KEY: 'fixture-api-key',
|
||||
GOOGLE_MAPS_SERVER_API_KEY: 'fixture-google-key',
|
||||
OPENSKY_CLIENT_SECRET: 'fixture-opensky-secret',
|
||||
},
|
||||
spawnImpl(command, args, options) {
|
||||
calls.push({ command, args, options });
|
||||
queueMicrotask(() => child.emit('spawn'));
|
||||
return child;
|
||||
},
|
||||
});
|
||||
const login = await pending;
|
||||
assert.equal(unref, 1);
|
||||
assert.equal(calls[0].command, '/fixture/codex');
|
||||
assert.deepEqual(calls[0].args, ['login']);
|
||||
assert.equal(calls[0].options.detached, true);
|
||||
assert.equal(calls[0].options.stdio, 'ignore');
|
||||
assert.deepEqual(
|
||||
calls[0].options.env,
|
||||
{
|
||||
PATH: '/fixture',
|
||||
https_proxy: 'http://proxy.fixture:8080',
|
||||
CODEX_HOME: path.join('/home/fixture', '.codex'),
|
||||
},
|
||||
'provider secrets stay out of the login process',
|
||||
);
|
||||
child.emit('exit', 1);
|
||||
assert.deepEqual(await login.completion, { exitCode: 1 });
|
||||
});
|
||||
|
||||
test('Codex login refuses a read/write storage mismatch before spawning', async () => {
|
||||
await assert.rejects(
|
||||
startCodexChatGptLogin({
|
||||
environment: { CODEX_AUTH_JSON: '/tmp/another-auth.json' },
|
||||
home: '/home/fixture',
|
||||
executable: 'codex',
|
||||
spawnImpl: () => assert.fail('mismatched storage must not launch login'),
|
||||
}),
|
||||
{ code: 'CODEX_OAUTH_STORAGE_MISMATCH' },
|
||||
);
|
||||
});
|
||||
|
||||
test('Codex login resolves relative CODEX_HOME before changing the child directory', async () => {
|
||||
const child = new EventEmitter();
|
||||
const environment = { CODEX_HOME: './fixture-codex' };
|
||||
const login = await startCodexChatGptLogin({
|
||||
environment,
|
||||
executable: 'codex',
|
||||
spawnImpl(_command, _args, options) {
|
||||
assert.equal(
|
||||
options.env.CODEX_HOME,
|
||||
path.resolve(environment.CODEX_HOME),
|
||||
);
|
||||
queueMicrotask(() => child.emit('spawn'));
|
||||
return child;
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
environment.CODEX_HOME,
|
||||
'./fixture-codex',
|
||||
'caller environment is unchanged',
|
||||
);
|
||||
child.emit('exit', 0);
|
||||
assert.deepEqual(await login.completion, { exitCode: 0 });
|
||||
});
|
||||
|
||||
test('Codex login hides spawn errors and can cancel only its owned child', async () => {
|
||||
const child = new EventEmitter();
|
||||
await assert.rejects(
|
||||
startCodexChatGptLogin({
|
||||
executable: 'codex',
|
||||
spawnImpl() {
|
||||
queueMicrotask(() =>
|
||||
child.emit('error', new Error('private-token-fixture')),
|
||||
);
|
||||
return child;
|
||||
},
|
||||
}),
|
||||
(error) =>
|
||||
error.code === 'CODEX_OAUTH_LOGIN_FAILED' &&
|
||||
!error.message.includes('private-token-fixture'),
|
||||
);
|
||||
|
||||
const running = new EventEmitter();
|
||||
let cancelled = 0;
|
||||
running.kill = (signal) => {
|
||||
assert.equal(signal, 'SIGTERM');
|
||||
cancelled += 1;
|
||||
running.emit('exit', null);
|
||||
};
|
||||
const login = await startCodexChatGptLogin({
|
||||
executable: 'codex',
|
||||
spawnImpl() {
|
||||
queueMicrotask(() => running.emit('spawn'));
|
||||
return running;
|
||||
},
|
||||
});
|
||||
login.cancel();
|
||||
assert.equal(cancelled, 1);
|
||||
assert.deepEqual(await login.completion, { exitCode: null });
|
||||
});
|
||||
+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 },
|
||||
|
||||
@@ -656,7 +656,7 @@ test('the DISPLAY rail starts collapsed on a first run, and a stored choice wins
|
||||
|
||||
// ── Voice: instruction-only, tool schema unchanged ─────────────────────
|
||||
|
||||
test('the voice TOOL SCHEMA matches the pinned release — the mission mapping is instructions only', () => {
|
||||
test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping is instructions only', () => {
|
||||
// Analyst layers and the separate satellite-pass tool deliberately extend the schema.
|
||||
// Canonical serialization pins every tool name, description, property and
|
||||
// ordering while allowing source formatting. Derived from the unchanged
|
||||
@@ -667,12 +667,17 @@ test('the voice TOOL SCHEMA matches the pinned release — the mission mapping i
|
||||
// Cyber deliberately adds one layout; first-run missions still change no tools.
|
||||
hudLayout.enum = hudLayout.enum.filter((layout) => layout !== 'cyber');
|
||||
const block = JSON.stringify(legacyTools);
|
||||
// Re-derived for the additive `local-adsb` set_layer_visibility value and
|
||||
// its common-name mapping; the missions still ride existing tools.
|
||||
assert.equal(block.length, 27432, 'serialized tool schema length drifted');
|
||||
// Re-derived for the voice layer manifest (generated layer enums, alias and
|
||||
// field hints), point-and-ask's pointer/referent arguments and the prompt
|
||||
// consolidation (shorter analyst, annotate_map and ISS wording), plus the
|
||||
// referent-only track_entity alternative (kept out of the model-facing
|
||||
// schema, which may not carry top-level anyOf), and Contacts requested-radius
|
||||
// list descriptions; the missions still ride existing tools.
|
||||
// Street Level adds toggle enum values and the generated alias hint.
|
||||
assert.equal(block.length, 29802, 'serialized tool schema length drifted');
|
||||
assert.equal(
|
||||
crypto.createHash('sha256').update(block).digest('hex'),
|
||||
'a2a4a787f4528f75b01f3f42caec636f29c37452c0d45b11f4f986171d6be57d',
|
||||
'b316ca2e67848eb565b6847ac044a19d1465d8ef8252a5dfbd7d947a2a36d5ed',
|
||||
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
|
||||
);
|
||||
const instructions = fs.readFileSync(new URL('../server/providers/openai/instructions.js', import.meta.url), 'utf8');
|
||||
|
||||
+179
@@ -1,4 +1,8 @@
|
||||
import { createSurfaceKeyboard } from './ui/surfaceKeyboard.js';
|
||||
import {
|
||||
readStoredCloudVoiceAuthMode,
|
||||
writeStoredCloudVoiceAuthMode,
|
||||
} from './voice/cloudVoiceAuth.js';
|
||||
|
||||
/**
|
||||
* The POWER UP surface — paste a key, get a power.
|
||||
@@ -38,6 +42,52 @@ export function collectKeyUpdates(fields) {
|
||||
return updates;
|
||||
}
|
||||
|
||||
function sleepWithSignal(ms, signal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(signal.reason || new DOMException('Aborted', 'AbortError'));
|
||||
return;
|
||||
}
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer);
|
||||
reject(signal.reason || new DOMException('Aborted', 'AbortError'));
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
resolve();
|
||||
}, ms);
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForChatGptOAuth({
|
||||
fetchImpl,
|
||||
signal,
|
||||
timeoutMs = 120_000,
|
||||
pollMs = 1_000,
|
||||
now = () => Date.now(),
|
||||
sleep = sleepWithSignal,
|
||||
} = {}) {
|
||||
const deadline = now() + Math.max(0, timeoutMs);
|
||||
while (!signal?.aborted && now() <= deadline) {
|
||||
const response = await fetchImpl('/api/realtime/oauth-status', {
|
||||
cache: 'no-store',
|
||||
signal,
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (signal?.aborted) return false;
|
||||
if (response.ok && payload.available) return true;
|
||||
if (!response.ok || payload.loginFailed) {
|
||||
throw new Error(
|
||||
payload.error || 'Could not check ChatGPT sign-in. Try again.',
|
||||
);
|
||||
}
|
||||
if (now() >= deadline) break;
|
||||
await sleep(Math.max(0, pollMs), signal);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* After the FIRST Google key lands, the restart's reload should boot the
|
||||
* photoreal default — not faithfully restore the auto-selected keyless OSM
|
||||
@@ -61,6 +111,21 @@ export function stripKeylessBasemapFromHash(hash) {
|
||||
|
||||
const TIER_DOTS = Object.freeze({ metered: '🔴', free: '🟡' });
|
||||
|
||||
function updateCloudVoiceAuthControl(auth, busy = false) {
|
||||
const mode = readStoredCloudVoiceAuthMode();
|
||||
const state = auth.querySelector('.key-setup-cloud-auth-state');
|
||||
const toggle = auth.querySelector('[data-cloud-voice-auth-toggle]');
|
||||
state.textContent =
|
||||
mode === 'oauth' ? 'VOICE AUTH · CHATGPT OAUTH' : 'VOICE AUTH · API KEY';
|
||||
toggle.textContent = mode === 'oauth' ? 'USE API KEY' : 'USE CHATGPT OAUTH';
|
||||
toggle.title =
|
||||
mode === 'oauth'
|
||||
? 'Use OPENAI_API_KEY for the next cloud voice session'
|
||||
: 'Use the signed-in local ChatGPT/Codex OAuth session for the next cloud voice session';
|
||||
// Keep the control focusable while the delegated handler blocks repeats.
|
||||
toggle.setAttribute('aria-disabled', String(busy));
|
||||
}
|
||||
|
||||
/** Build one key row. All content is our own registry text, set via textContent. */
|
||||
function buildRow(documentRef, key) {
|
||||
const row = documentRef.createElement('section');
|
||||
@@ -144,6 +209,19 @@ function buildRow(documentRef, key) {
|
||||
}
|
||||
row.append(fields);
|
||||
}
|
||||
if (key.id === 'openai') {
|
||||
const auth = documentRef.createElement('div');
|
||||
auth.className = 'key-setup-cloud-auth';
|
||||
const state = documentRef.createElement('span');
|
||||
state.className = 'key-setup-cloud-auth-state';
|
||||
const toggle = documentRef.createElement('button');
|
||||
toggle.type = 'button';
|
||||
toggle.className = 'key-setup-cloud-auth-toggle';
|
||||
toggle.dataset.cloudVoiceAuthToggle = 'true';
|
||||
auth.append(state, toggle);
|
||||
updateCloudVoiceAuthControl(auth);
|
||||
row.append(auth);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
@@ -224,8 +302,14 @@ export async function initKeySetup({
|
||||
const statusLine = root.querySelector('[data-key-setup-status]');
|
||||
const defaultStatusText = statusLine?.textContent || '';
|
||||
let busy = false;
|
||||
let oauthBusy = false;
|
||||
let open = false;
|
||||
|
||||
const syncCloudVoiceAuth = () => {
|
||||
for (const auth of rowsHost.querySelectorAll('.key-setup-cloud-auth'))
|
||||
updateCloudVoiceAuthControl(auth, oauthBusy);
|
||||
};
|
||||
|
||||
const render = (nextStatus) => {
|
||||
if (disposed) return;
|
||||
status = nextStatus;
|
||||
@@ -239,6 +323,7 @@ export async function initKeySetup({
|
||||
rowsHost.textContent = '';
|
||||
for (const key of status.keys || [])
|
||||
rowsHost.append(buildRow(documentRef, key));
|
||||
syncCloudVoiceAuth();
|
||||
};
|
||||
|
||||
const visible = () =>
|
||||
@@ -367,6 +452,100 @@ export async function initKeySetup({
|
||||
applyButton?.addEventListener('click', onApply);
|
||||
// Remove buttons are rendered per row; delegate so re-renders stay wired.
|
||||
rowsHost?.addEventListener('click', (event) => {
|
||||
const authButton = event.target?.closest?.(
|
||||
'[data-cloud-voice-auth-toggle]',
|
||||
);
|
||||
if (authButton && !disposed) {
|
||||
if (oauthBusy) return;
|
||||
void (async () => {
|
||||
const current = readStoredCloudVoiceAuthMode();
|
||||
if (current === 'oauth') {
|
||||
writeStoredCloudVoiceAuthMode('api-key');
|
||||
syncCloudVoiceAuth();
|
||||
say('Cloud voice will use OPENAI_API_KEY on the next session.');
|
||||
return;
|
||||
}
|
||||
oauthBusy = true;
|
||||
syncCloudVoiceAuth();
|
||||
say('Checking local ChatGPT OAuth sign-in…');
|
||||
try {
|
||||
const response = await doFetch('/api/realtime/oauth-status', {
|
||||
cache: 'no-store',
|
||||
signal: lifetime.signal,
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (lifetime.signal.aborted) return;
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
payload.error || 'Could not check ChatGPT sign-in.',
|
||||
);
|
||||
if (payload.available) {
|
||||
writeStoredCloudVoiceAuthMode('oauth');
|
||||
syncCloudVoiceAuth();
|
||||
say(
|
||||
'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
say(
|
||||
payload.code === 'CODEX_OAUTH_REAUTH_REQUIRED'
|
||||
? 'ChatGPT sign-in expired. Opening sign-in again…'
|
||||
: 'Opening ChatGPT sign-in in your browser…',
|
||||
);
|
||||
const loginResponse = await doFetch('/api/realtime/oauth-login', {
|
||||
method: 'POST',
|
||||
cache: 'no-store',
|
||||
signal: lifetime.signal,
|
||||
});
|
||||
const loginPayload = await loginResponse.json().catch(() => ({}));
|
||||
if (lifetime.signal.aborted) return;
|
||||
if (!loginResponse.ok) {
|
||||
say(
|
||||
loginPayload.error ||
|
||||
payload.error ||
|
||||
'Could not start ChatGPT sign-in on this machine.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (loginPayload.available) {
|
||||
writeStoredCloudVoiceAuthMode('oauth');
|
||||
syncCloudVoiceAuth();
|
||||
say(
|
||||
'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
say(
|
||||
'Finish ChatGPT sign-in in the browser. Waiting for it to complete…',
|
||||
);
|
||||
const available = await waitForChatGptOAuth({
|
||||
fetchImpl: doFetch,
|
||||
signal: lifetime.signal,
|
||||
});
|
||||
if (lifetime.signal.aborted) return;
|
||||
if (!available) {
|
||||
say(
|
||||
'ChatGPT sign-in timed out. Click USE CHATGPT OAUTH to try again.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
writeStoredCloudVoiceAuthMode('oauth');
|
||||
syncCloudVoiceAuth();
|
||||
say(
|
||||
'ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.',
|
||||
);
|
||||
} catch (error) {
|
||||
if (lifetime.signal.aborted) return;
|
||||
say(`OAuth check failed: ${error?.message || error}`);
|
||||
} finally {
|
||||
oauthBusy = false;
|
||||
if (!disposed) syncCloudVoiceAuth();
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
const button = event.target?.closest?.('[data-key-setup-remove]');
|
||||
if (disposed || !button || busy) return;
|
||||
let envVars = [];
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
keySetupChipLabel,
|
||||
stripKeylessBasemapFromHash,
|
||||
bindKeySetupPlacement,
|
||||
waitForChatGptOAuth,
|
||||
} from './keySetup.js';
|
||||
|
||||
test('setup placement moves the same button only in Cyber and disconnects on teardown', () => {
|
||||
@@ -61,6 +62,64 @@ test('collectKeyUpdates keeps only non-empty trimmed values', () => {
|
||||
assert.deepEqual(collectKeyUpdates(null), {});
|
||||
});
|
||||
|
||||
test('OAuth login polling stops as soon as local ChatGPT auth becomes available', async () => {
|
||||
let checks = 0;
|
||||
let clock = 0;
|
||||
const available = await waitForChatGptOAuth({
|
||||
fetchImpl: async () => {
|
||||
checks += 1;
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ available: checks >= 3 }),
|
||||
};
|
||||
},
|
||||
timeoutMs: 10_000,
|
||||
pollMs: 100,
|
||||
now: () => clock,
|
||||
sleep: async (ms) => {
|
||||
clock += ms;
|
||||
},
|
||||
});
|
||||
assert.equal(available, true);
|
||||
assert.equal(checks, 3);
|
||||
});
|
||||
|
||||
test('OAuth login polling stops on login or HTTP failure instead of waiting for timeout', async () => {
|
||||
for (const result of [
|
||||
{ ok: true, payload: { available: false, loginFailed: true, error: 'Sign-in failed.' } },
|
||||
{ ok: false, payload: { error: 'Sign-in is local only.' } },
|
||||
]) {
|
||||
let checks = 0;
|
||||
await assert.rejects(waitForChatGptOAuth({
|
||||
fetchImpl: async () => { checks += 1; return { ok: result.ok, json: async () => result.payload }; },
|
||||
sleep: () => assert.fail('failed login must stop polling'),
|
||||
}), { message: result.payload.error });
|
||||
assert.equal(checks, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('OAuth login polling respects timeout and an aborted setup surface', async () => {
|
||||
let clock = 0;
|
||||
let checks = 0;
|
||||
const poll = { fetchImpl: async () => { checks += 1; return { ok: true, json: async () => ({ available: false, pending: true }) }; } };
|
||||
assert.equal(await waitForChatGptOAuth({ ...poll, timeoutMs: 100, pollMs: 100, now: () => clock, sleep: async (ms) => { clock += ms; } }), false);
|
||||
assert.equal(checks, 2);
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
assert.equal(await waitForChatGptOAuth({ ...poll, signal: controller.signal }), false);
|
||||
assert.equal(checks, 2, 'aborted polling does not fetch');
|
||||
});
|
||||
|
||||
test('OAuth polling ignores a ready response received after the surface was aborted', async () => {
|
||||
const controller = new AbortController();
|
||||
const available = await waitForChatGptOAuth({
|
||||
signal: controller.signal,
|
||||
fetchImpl: async () => ({ ok: true, json: async () => { controller.abort(); return { available: true }; } }),
|
||||
sleep: () => assert.fail('aborted polling must not continue'),
|
||||
});
|
||||
assert.equal(available, false);
|
||||
});
|
||||
|
||||
test('the first Google key strips ONLY the keyless OSM basemap from the share hash', () => {
|
||||
const stripped = stripKeylessBasemapFromHash('lat=30.2&lon=-97.7&map=osm&style=normal');
|
||||
assert.ok(stripped !== null);
|
||||
|
||||
@@ -55,7 +55,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
||||
Object.freeze({
|
||||
id: 'openai',
|
||||
title: 'OPENAI',
|
||||
unlocks: 'Voice control — talk to the planet',
|
||||
unlocks: 'Voice control — API key or ChatGPT OAuth',
|
||||
getUrl: 'https://platform.openai.com/api-keys',
|
||||
envVars: Object.freeze(['OPENAI_API_KEY']),
|
||||
tier: 'metered',
|
||||
|
||||
@@ -392,6 +392,37 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
||||
],
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Snapshot loaded ALPR cameras (the nearby query box) as plain JSON-safe records for the analyst
|
||||
* query engine. On demand only (once per spoken query); [] while the
|
||||
* layer is off.
|
||||
* @param {number} [maxCount=Infinity] Most records to return.
|
||||
* @returns {Array<Object>} {id, lat, lon, operator, manufacturer, cameraType, directionDeg}.
|
||||
*/
|
||||
getAnalystRecords(maxCount = Infinity) {
|
||||
if (!state.enabled) return [];
|
||||
const result = [];
|
||||
for (const record of state.records || []) {
|
||||
if (result.length >= maxCount) break;
|
||||
if (
|
||||
!Number.isFinite(record.latitude) ||
|
||||
!Number.isFinite(record.longitude)
|
||||
)
|
||||
continue;
|
||||
result.push({
|
||||
id: record.id,
|
||||
lat: record.latitude,
|
||||
lon: record.longitude,
|
||||
operator: record.operator ?? null,
|
||||
manufacturer: record.manufacturer ?? null,
|
||||
cameraType: record.cameraType ?? null,
|
||||
directionDeg: Number.isFinite(record.directionDeg)
|
||||
? record.directionDeg
|
||||
: null,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
getStats() {
|
||||
return {
|
||||
count: state.dataSource?.entities.values.length || 0,
|
||||
|
||||
Vendored
+50
@@ -75,6 +75,56 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Return the exact retained aircraft cohorts behind the current Contacts
|
||||
* panel snapshot. Voice reads this immutable copy instead of re-scanning
|
||||
* live layers, so panel and spoken counts share one evaluation timestamp.
|
||||
*/
|
||||
getAircraftQuerySnapshot() {
|
||||
if (!layerState.enabled || !layerState.subject || !layerState.results)
|
||||
return null;
|
||||
// Build every public count from this exact results object before
|
||||
// returning. A refresh may replace `layerState.results` as soon as the
|
||||
// caller awaits, so consumers must not make a second read later and
|
||||
// accidentally combine two evaluation timestamps.
|
||||
const panelSnapshot = parts.model.buildAwarenessContextSnapshot(
|
||||
layerState.results,
|
||||
parts.model.navigationState(),
|
||||
{
|
||||
subjectPresent: !layerState.subjectMissing,
|
||||
},
|
||||
);
|
||||
const cohorts = {};
|
||||
for (const cohort of layerState.results.cohorts || []) {
|
||||
if (!['flights', 'military'].includes(cohort?.id)) continue;
|
||||
cohorts[cohort.id] = {
|
||||
count: cohort.summary?.count ?? null,
|
||||
complete:
|
||||
Number.isFinite(cohort.summary?.count) &&
|
||||
cohort.summary?.complete !== false,
|
||||
truncated:
|
||||
!Number.isFinite(cohort.summary?.count) ||
|
||||
cohort.summary?.truncated === true,
|
||||
// These records carry structured values such as Cartesian positions.
|
||||
// A shallow spread would let a voice consumer mutate the retained
|
||||
// Contacts cohort through the advertised read-only snapshot.
|
||||
items: structuredClone(cohort.summary?.navigationNearest || []),
|
||||
source: cohort.source || null,
|
||||
provenance: cohort.provenance
|
||||
? structuredClone(cohort.provenance)
|
||||
: null,
|
||||
reason: cohort.summary?.reason || null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
subject: structuredClone(layerState.results.subject),
|
||||
evaluatedAt: layerState.results.evaluatedAt,
|
||||
radiusM: layerState.results.radiusM,
|
||||
cohorts,
|
||||
contactsWindow: parts.model.contactsWindowFromSnapshot(panelSnapshot),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Release Contact-owned camera tracking without discarding the selected
|
||||
* subject. Reset-to-globe uses this route so the normal Context FOCUS action
|
||||
|
||||
@@ -144,6 +144,9 @@ export function createModel({ state: layerState, services, parts, source }) {
|
||||
|
||||
function contactsWindowFromSnapshot(snapshot) {
|
||||
if (!snapshot?.subject) return null;
|
||||
const aircraftCohorts = (snapshot.cohorts || []).filter((cohort) =>
|
||||
['flights', 'military'].includes(cohort?.id),
|
||||
);
|
||||
const countFor = (cohortId) => {
|
||||
const cohort = Array.isArray(snapshot.cohorts)
|
||||
? snapshot.cohorts.find((item) => item?.id === cohortId)
|
||||
@@ -164,6 +167,12 @@ export function createModel({ state: layerState, services, parts, source }) {
|
||||
flights,
|
||||
military,
|
||||
vessels: countFor('ais-live-vessels'),
|
||||
complete:
|
||||
aircraftCohorts.length === 2 &&
|
||||
aircraftCohorts.every(
|
||||
(cohort) =>
|
||||
Number.isFinite(cohort.count) && cohort.complete !== false,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -190,6 +199,8 @@ export function createModel({ state: layerState, services, parts, source }) {
|
||||
coverage: cohort.coverage || null,
|
||||
relationship: cohort.summary.relationship,
|
||||
count: cohort.summary.count,
|
||||
complete: cohort.summary.complete !== false,
|
||||
truncated: cohort.summary.truncated === true,
|
||||
reason: cohort.summary.reason,
|
||||
nearest: cohort.summary.nearest.slice(),
|
||||
})),
|
||||
|
||||
@@ -25,6 +25,7 @@ export function createNavigation({
|
||||
{
|
||||
displayLimit = AWARENESS_MAX_EXAMPLES,
|
||||
navigationLimit = AWARENESS_MAX_NAVIGATION_EXAMPLES,
|
||||
complete = true,
|
||||
} = {},
|
||||
) {
|
||||
const summary = summarizeAwarenessCohort(items, {
|
||||
@@ -34,6 +35,8 @@ export function createNavigation({
|
||||
if (summary.count === null) return summary;
|
||||
return {
|
||||
...summary,
|
||||
complete: Boolean(complete),
|
||||
truncated: !complete,
|
||||
nearest: summary.nearest.slice(0, displayLimit),
|
||||
navigationNearest: summary.nearest,
|
||||
};
|
||||
|
||||
@@ -5,6 +5,15 @@ import {
|
||||
} from '../../data/militaryAwarenessEngine.js';
|
||||
import { AWARENESS_PAGE_SIZE, AWARENESS_PAGE_ROTATE_MS } from './policy.js';
|
||||
|
||||
/** Format a cohort total without presenting a retained cap as exact. */
|
||||
export function formatAwarenessCount(summary = {}) {
|
||||
if (!Number.isFinite(summary.count)) return '?';
|
||||
const count = summary.count.toLocaleString('en-US');
|
||||
return summary.complete === false || summary.truncated === true
|
||||
? `At least ${count}`
|
||||
: count;
|
||||
}
|
||||
|
||||
export function createPanel({ state: layerState, services, parts, source }) {
|
||||
function ensurePanel() {
|
||||
if (layerState.panel) return layerState.panel;
|
||||
@@ -61,7 +70,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
|
||||
function rowHtml(cohort) {
|
||||
const summary = cohort.summary;
|
||||
const count = summary.count === null ? '?' : String(summary.count);
|
||||
const count = formatAwarenessCount(summary);
|
||||
const page = layerState.cohortPages.get(cohort.id) || 0;
|
||||
const nearest = summary.nearest
|
||||
.slice(page, page + AWARENESS_PAGE_SIZE)
|
||||
|
||||
@@ -38,8 +38,8 @@ export const AWARENESS_MAX_EXAMPLES = 10;
|
||||
// so these caps sit far above any realistic in-range population rather than at
|
||||
// the display limit. They are still FINITE: an unbounded materialization would
|
||||
// let a pathological feed sort an eleven-thousand-contact array on every
|
||||
// refresh. Truthfulness does not depend on them — `summarizeAwarenessCohort`
|
||||
// derives `count` from the full in-range set before any slice.
|
||||
// refresh. Callers sample beyond this cap and carry explicit completeness, so
|
||||
// a saturated cohort is a lower bound rather than a silently exact count.
|
||||
|
||||
export const AWARENESS_QUERY_LIMIT = 20000;
|
||||
|
||||
|
||||
@@ -53,7 +53,12 @@ export function createQueries({ state: layerState, services, parts, source }) {
|
||||
neverAnswered ||
|
||||
['unavailable', 'zoom-in'].includes(stats.status) ||
|
||||
Boolean(stats.error && stats.count === 0);
|
||||
return { available: !unavailable, stale: Boolean(stats.stale), stats };
|
||||
return {
|
||||
available: !unavailable,
|
||||
enabled,
|
||||
stale: Boolean(stats.stale),
|
||||
stats,
|
||||
};
|
||||
}
|
||||
|
||||
function collectSourceStates() {
|
||||
@@ -147,8 +152,10 @@ export function createQueries({ state: layerState, services, parts, source }) {
|
||||
* @param {object} [options]
|
||||
* @param {number} [options.radiusM=AWARENESS_RADIUS_M] Window radius.
|
||||
* @param {object|null} [options.subject=null] Contact at the centre, excluded.
|
||||
* @returns {{flights: Array, military: Array, aircraft: number}|null} Cohorts
|
||||
* plus the combined aircraft count, or null without a position.
|
||||
* @returns {{flights: Array, military: Array, aircraft: number,
|
||||
* completeByLayer: {flights: boolean, military: boolean}}|null} Cohorts,
|
||||
* their lower-bound completeness, and the combined aircraft count, or
|
||||
* null without a position.
|
||||
*/
|
||||
|
||||
function collectAircraftProximityWindow(
|
||||
@@ -156,21 +163,35 @@ export function createQueries({ state: layerState, services, parts, source }) {
|
||||
{ radiusM = AWARENESS_RADIUS_M, subject = null } = {},
|
||||
) {
|
||||
if (!position) return null;
|
||||
const flights = flightsLayer
|
||||
.getNearby(position, radiusM, AWARENESS_QUERY_LIMIT, {
|
||||
// Ask for one usable row beyond the retained limit so a capped cohort is
|
||||
// reported as a lower bound instead of an exact count. The selected
|
||||
// subject can consume one slot before exclusion, hence the extra two.
|
||||
const sampleLimit = AWARENESS_QUERY_LIMIT + 2;
|
||||
const flightSample = flightsLayer
|
||||
.getNearby(position, radiusM, sampleLimit, {
|
||||
includeHidden: true,
|
||||
})
|
||||
.filter(
|
||||
(item) => !subject || !isSame(subject, item, 'flights', 'icao24'),
|
||||
);
|
||||
const military = militaryFlightsLayer
|
||||
.getNearby(position, radiusM, AWARENESS_QUERY_LIMIT, {
|
||||
const militarySample = militaryFlightsLayer
|
||||
.getNearby(position, radiusM, sampleLimit, {
|
||||
includeHidden: true,
|
||||
})
|
||||
.filter(
|
||||
(item) => !subject || !isSame(subject, item, 'military', 'icao24'),
|
||||
);
|
||||
return { flights, military, aircraft: flights.length + military.length };
|
||||
const flights = flightSample.slice(0, AWARENESS_QUERY_LIMIT);
|
||||
const military = militarySample.slice(0, AWARENESS_QUERY_LIMIT);
|
||||
return {
|
||||
flights,
|
||||
military,
|
||||
aircraft: flights.length + military.length,
|
||||
completeByLayer: {
|
||||
flights: flightSample.length <= AWARENESS_QUERY_LIMIT,
|
||||
military: militarySample.length <= AWARENESS_QUERY_LIMIT,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
sourceState,
|
||||
|
||||
@@ -6,6 +6,39 @@ import { createSubject } from './subject.js';
|
||||
import { createControls } from '../installations/controls.js';
|
||||
import { createModel } from '../installations/model.js';
|
||||
import { summarizeAwarenessCohort } from '../../data/militaryAwarenessEngine.js';
|
||||
import { AWARENESS_QUERY_LIMIT } from './policy.js';
|
||||
|
||||
test('aircraft window marks a capped layer as a lower bound', () => {
|
||||
const rows = Array.from({ length: AWARENESS_QUERY_LIMIT + 1 }, (_, index) => ({
|
||||
icao24: `f${index}`,
|
||||
distance: index,
|
||||
}));
|
||||
let requested = null;
|
||||
const services = {
|
||||
flights: {
|
||||
getNearby: (_position, _radius, limit) => {
|
||||
requested = limit;
|
||||
return rows.slice(0, limit);
|
||||
},
|
||||
},
|
||||
military: { getNearby: () => [] },
|
||||
};
|
||||
const parts = {
|
||||
navigation: {
|
||||
summarizeAwarenessCohortForNavigation: (items, source) =>
|
||||
summarizeAwarenessCohort(items, source),
|
||||
},
|
||||
};
|
||||
const queries = createQueries({ state: {}, services, parts });
|
||||
const window = queries.collectAircraftProximityWindow({ center: true });
|
||||
assert.equal(requested, AWARENESS_QUERY_LIMIT + 2);
|
||||
assert.equal(window.flights.length, AWARENESS_QUERY_LIMIT);
|
||||
assert.equal(window.aircraft, AWARENESS_QUERY_LIMIT);
|
||||
assert.deepEqual(window.completeByLayer, {
|
||||
flights: false,
|
||||
military: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('Contacts excludes square corners and measures from the current moving subject', () => {
|
||||
const records = [
|
||||
|
||||
@@ -37,11 +37,23 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
||||
const militaryState = sourceStates.military;
|
||||
const vesselsState = sourceStates['ais-live-vessels'];
|
||||
const installationsState = sourceStates['military-installations'];
|
||||
const evaluatedAt = Date.now();
|
||||
const retainedProvenance = (id, label, sourceState, fallbackSource) => {
|
||||
const retainedSource = sourceState.stats.source || fallbackSource;
|
||||
return {
|
||||
id,
|
||||
name: label,
|
||||
enabled: sourceState.enabled,
|
||||
source: retainedSource,
|
||||
stats: { ...sourceState.stats, source: retainedSource },
|
||||
};
|
||||
};
|
||||
// Same engine the voice analyst calls — see collectAircraftProximityWindow.
|
||||
const { flights, military } = parts.queries.collectAircraftProximityWindow(
|
||||
const aircraftWindow = parts.queries.collectAircraftProximityWindow(
|
||||
position,
|
||||
{ subject },
|
||||
);
|
||||
const { flights, military, completeByLayer } = aircraftWindow;
|
||||
const vessels = aisLiveVesselsLayer
|
||||
.getNearby(position, AWARENESS_RADIUS_M, AWARENESS_QUERY_LIMIT)
|
||||
.filter(
|
||||
@@ -61,31 +73,57 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
||||
);
|
||||
return {
|
||||
subject,
|
||||
evaluatedAt: Date.now(),
|
||||
evaluatedAt,
|
||||
radiusM: AWARENESS_RADIUS_M,
|
||||
cohorts: [
|
||||
{
|
||||
id: 'flights',
|
||||
label: 'Flights',
|
||||
source: flightsState.stats.source || SOURCE_LABEL.flights,
|
||||
provenance: retainedProvenance(
|
||||
'flights',
|
||||
'Flights',
|
||||
flightsState,
|
||||
SOURCE_LABEL.flights,
|
||||
),
|
||||
summary: parts.navigation.summarizeAwarenessCohortForNavigation(
|
||||
flights,
|
||||
flightsState,
|
||||
{
|
||||
navigationLimit: AWARENESS_QUERY_LIMIT,
|
||||
complete: completeByLayer.flights,
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'military',
|
||||
label: 'Military flights',
|
||||
source: militaryState.stats.source || SOURCE_LABEL.military,
|
||||
provenance: retainedProvenance(
|
||||
'military',
|
||||
'Military flights',
|
||||
militaryState,
|
||||
SOURCE_LABEL.military,
|
||||
),
|
||||
summary: parts.navigation.summarizeAwarenessCohortForNavigation(
|
||||
military,
|
||||
militaryState,
|
||||
{
|
||||
navigationLimit: AWARENESS_QUERY_LIMIT,
|
||||
complete: completeByLayer.military,
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'ais-live-vessels',
|
||||
label: 'AIS vessels',
|
||||
source: vesselsState.stats.source || SOURCE_LABEL['ais-live-vessels'],
|
||||
provenance: retainedProvenance(
|
||||
'ais-live-vessels',
|
||||
'AIS vessels',
|
||||
vesselsState,
|
||||
SOURCE_LABEL['ais-live-vessels'],
|
||||
),
|
||||
summary: parts.navigation.summarizeAwarenessCohortForNavigation(
|
||||
vessels,
|
||||
vesselsState,
|
||||
@@ -97,6 +135,12 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
||||
source:
|
||||
installationsState.stats.source ||
|
||||
SOURCE_LABEL['military-installations'],
|
||||
provenance: retainedProvenance(
|
||||
'military-installations',
|
||||
'Mapped installations',
|
||||
installationsState,
|
||||
SOURCE_LABEL['military-installations'],
|
||||
),
|
||||
coverage:
|
||||
installationsState.stats.coverageLabel || 'CURRENT VIEWPORT ONLY',
|
||||
summary: parts.queries.summarizeInstallationViewport(
|
||||
|
||||
Vendored
+33
@@ -21,6 +21,39 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
return parts.queries.collectDetectableStations(options);
|
||||
},
|
||||
|
||||
/**
|
||||
* Snapshot loaded bikeshare stations as plain JSON-safe records for the analyst
|
||||
* query engine. On demand only (once per spoken query); [] while the
|
||||
* layer is off.
|
||||
* @param {number} [maxCount=Infinity] Most records to return.
|
||||
* @returns {Array<Object>} {id, name, city, lat, lon, bikesAvailable, docksAvailable, capacity, renting}.
|
||||
*/
|
||||
getAnalystRecords(maxCount = Infinity) {
|
||||
if (!layerState._enabled) return [];
|
||||
const num = (v) => (Number.isFinite(v) ? v : null);
|
||||
const result = [];
|
||||
for (const record of layerState._stationRenderMap.values()) {
|
||||
if (result.length >= maxCount) break;
|
||||
const info = layerState._stationInfoCache
|
||||
.get(record.cityId)
|
||||
?.get(record.stationId);
|
||||
if (!Number.isFinite(info?.lat) || !Number.isFinite(info?.lon))
|
||||
continue;
|
||||
result.push({
|
||||
id: record.stationName || `Dock ${record.stationId}`,
|
||||
name: record.stationName || null,
|
||||
city: record.cityId || null,
|
||||
lat: info.lat,
|
||||
lon: info.lon,
|
||||
bikesAvailable: num(record.bikesAvailable),
|
||||
docksAvailable: num(record.docksAvailable),
|
||||
capacity: num(record.capacity),
|
||||
renting: record.isRenting !== false,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Return current layer statistics for the UI status display.
|
||||
* @returns {{ count: number, lastUpdate: number|null, loading: boolean, loadingLabel?: string, error?: string }}
|
||||
|
||||
@@ -403,6 +403,35 @@ export function createCyclonesLayer({
|
||||
setRowControlsListener(value) {
|
||||
listener = typeof value === 'function' ? value : null;
|
||||
},
|
||||
/**
|
||||
* Snapshot loaded active storms as plain JSON-safe records for the analyst
|
||||
* query engine. On demand only (once per spoken query); [] while the
|
||||
* layer is off.
|
||||
* @param {number} [maxCount=Infinity] Most records to return.
|
||||
* @returns {Array<Object>} {id, name, classification, basin, lat, lon, windKt, pressureHpa}.
|
||||
*/
|
||||
getAnalystRecords(maxCount = Infinity) {
|
||||
if (!enabled || destroyed) return [];
|
||||
const num = (v) => (Number.isFinite(v) ? v : null);
|
||||
const result = [];
|
||||
for (const storm of snapshot?.storms || []) {
|
||||
if (result.length >= maxCount) break;
|
||||
const lat = storm.position?.latitude;
|
||||
const lon = storm.position?.longitude;
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
||||
result.push({
|
||||
id: storm.name || storm.id,
|
||||
name: storm.name || null,
|
||||
classification: storm.classification || null,
|
||||
basin: storm.basin || null,
|
||||
lat,
|
||||
lon,
|
||||
windKt: num(storm.windKt),
|
||||
pressureHpa: num(storm.pressureHpa),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
getStats() {
|
||||
const storm = selected();
|
||||
return {
|
||||
|
||||
@@ -452,10 +452,7 @@ export function createDirectionsStepOverlayEntry(index, position, copy) {
|
||||
* @param {{lat:number, lon:number}} b
|
||||
* @returns {string}
|
||||
*/
|
||||
export function directionsRequestUrl(mode, a, b) {
|
||||
const coords = `${a.lon.toFixed(6)},${a.lat.toFixed(6)};${b.lon.toFixed(6)},${b.lat.toFixed(6)}`;
|
||||
return `/api/route?profile=${encodeURIComponent(mode)}&coords=${encodeURIComponent(coords)}&steps=1`;
|
||||
}
|
||||
export { directionsRequestUrl } from './source.js';
|
||||
|
||||
/**
|
||||
* Validate a proxy payload into the route record the layer keeps, or null.
|
||||
@@ -509,10 +506,12 @@ export function normalizeRoutePayload(payload, mode) {
|
||||
* route, the markers, the pointer claim or the flight this layer owns. The
|
||||
* shared services are injected, which is what keeps this module's own imports
|
||||
* down to Cesium and the pure step formatter.
|
||||
* @param {{services: object}} options
|
||||
* @param {{services: object, source: {getRoute: Function}}} options
|
||||
* @returns {object} The layer module the data manager registers.
|
||||
*/
|
||||
export function createDirectionsLayer({ services }) {
|
||||
export function createDirectionsLayer({ services, source }) {
|
||||
if (typeof source?.getRoute !== 'function')
|
||||
throw new TypeError('Directions require a route source');
|
||||
/**
|
||||
* Where the maneuver card is drawn. Defaults to the shared world-overlay host;
|
||||
* tests swap it. Resolved on use, not at import, because the services arrive
|
||||
@@ -827,18 +826,12 @@ export function createDirectionsLayer({ services }) {
|
||||
_error = null;
|
||||
notifyRow();
|
||||
try {
|
||||
const response = await fetch(directionsRequestUrl(mode, _a, _b), {
|
||||
signal: controller.signal,
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (response.status === 429) {
|
||||
throw new Error(
|
||||
typeof payload?.error === 'string' && payload.error
|
||||
? `${payload.error} — try again in a moment`
|
||||
: 'Routing is rate limited — try again in a moment',
|
||||
);
|
||||
}
|
||||
const payload = await source.getRoute(
|
||||
{ mode, a: _a, b: _b },
|
||||
{
|
||||
signal: controller.signal,
|
||||
},
|
||||
);
|
||||
if (seq !== _routeSeq || !_enabled) return;
|
||||
const route = normalizeRoutePayload(payload, mode);
|
||||
if (!route) {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/** Build the existing route-proxy query from geographic endpoints. */
|
||||
export function directionsRequestUrl(mode, a, b) {
|
||||
const coords = `${a.lon.toFixed(6)},${a.lat.toFixed(6)};${b.lon.toFixed(6)},${b.lat.toFixed(6)}`;
|
||||
return `/api/route?profile=${encodeURIComponent(mode)}&coords=${encodeURIComponent(coords)}&steps=1`;
|
||||
}
|
||||
|
||||
/** Acquire route records using caller-owned transport and an optional endpoint. */
|
||||
export function createDirectionsSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
endpoint = '/api/route',
|
||||
} = {}) {
|
||||
return {
|
||||
async getRoute({ mode, a, b }, { signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
const query = directionsRequestUrl(mode, a, b).split('?')[1];
|
||||
const response = await fetchImpl(`${endpoint}?${query}`, {
|
||||
signal,
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const payload = await response.json();
|
||||
signal?.throwIfAborted();
|
||||
if (response.status === 429) {
|
||||
throw new Error(
|
||||
typeof payload?.error === 'string' && payload.error
|
||||
? `${payload.error} — try again in a moment`
|
||||
: 'Routing is rate limited — try again in a moment',
|
||||
);
|
||||
}
|
||||
if (!response.ok && !payload?.error)
|
||||
throw new Error(`Routing unavailable (HTTP ${response.status})`);
|
||||
return payload;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createDirectionsSource } from './source.js';
|
||||
|
||||
const query = { mode: 'foot', a: { lon: 1, lat: 2 }, b: { lon: 3, lat: 4 } };
|
||||
|
||||
test('route acquisition uses only the supplied transport and endpoint', async () => {
|
||||
const controller = new AbortController();
|
||||
const payload = { ok: true, geometry: [[1, 2], [3, 4]] };
|
||||
const calls = [];
|
||||
const source = createDirectionsSource({
|
||||
endpoint: 'https://routes.example/route',
|
||||
fetchImpl: async (...args) => { calls.push(args); return Response.json(payload); },
|
||||
});
|
||||
assert.deepEqual(await source.getRoute(query, { signal: controller.signal }), payload);
|
||||
assert.equal(calls.length, 1);
|
||||
const [url, init] = calls[0];
|
||||
assert.equal(new URL(url).origin, 'https://routes.example');
|
||||
assert.equal(new URL(url).searchParams.get('coords'), '1.000000,2.000000;3.000000,4.000000');
|
||||
assert.equal(new URL(url).searchParams.get('profile'), 'foot');
|
||||
assert.equal(init.signal, controller.signal);
|
||||
assert.equal(init.headers.Accept, 'application/json');
|
||||
});
|
||||
|
||||
test('aborting during route parsing discards the result', async () => {
|
||||
const controller = new AbortController();
|
||||
const source = createDirectionsSource({ fetchImpl: async () => ({
|
||||
ok: true, json: async () => { controller.abort(); return { ok: true }; },
|
||||
}) });
|
||||
await assert.rejects(source.getRoute(query, { signal: controller.signal }), { name: 'AbortError' });
|
||||
});
|
||||
|
||||
test('route failures are surfaced without fallback requests', async () => {
|
||||
for (const status of [429, 503]) {
|
||||
let calls = 0;
|
||||
const source = createDirectionsSource({ fetchImpl: async () => {
|
||||
calls++;
|
||||
return Response.json({}, { status });
|
||||
} });
|
||||
await assert.rejects(source.getRoute(query), status === 429 ? /rate limited/ : /HTTP 503/);
|
||||
assert.equal(calls, 1);
|
||||
}
|
||||
});
|
||||
@@ -29,7 +29,8 @@ export function createModel({
|
||||
* context-store ids, so the two stay consistent).
|
||||
* @param {Object|null|undefined} fire - Internal fire record.
|
||||
* @returns {{id: string, lat: number|null, lon: number|null, frp: number|null,
|
||||
* confidence: number|null, satellite: string|null, acqTime: number|null}}
|
||||
* confidence: number|null, satellite: string|null, sensor: string|null,
|
||||
* acqTime: number|null}}
|
||||
*/
|
||||
|
||||
function mapAnalystRecord(fire) {
|
||||
@@ -45,6 +46,7 @@ export function createModel({
|
||||
frp: num(fire?.frp),
|
||||
confidence: num(fire?.confidence), // normalized 0..1 (firmsAdapt.normalizeConfidence)
|
||||
satellite: text(fire?.satellite) || text(fire?.sensor),
|
||||
sensor: text(fire?.sensor), // VIIRS or MODIS
|
||||
acqTime:
|
||||
Number.isFinite(fire?.acqMs) && fire.acqMs > 0 ? fire.acqMs : null, // epoch ms; 0 = unparseable → null
|
||||
};
|
||||
|
||||
Vendored
+29
@@ -162,6 +162,35 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Snapshot loaded mapped installations as plain JSON-safe records for the analyst
|
||||
* query engine. On demand only (once per spoken query); [] while the
|
||||
* layer is off.
|
||||
* @param {number} [maxCount=Infinity] Most records to return.
|
||||
* @returns {Array<Object>} {id, name, class, kind, lat, lon}.
|
||||
*/
|
||||
getAnalystRecords(maxCount = Infinity) {
|
||||
if (!layerState.enabled) return [];
|
||||
const result = [];
|
||||
for (const record of layerState.records || []) {
|
||||
if (result.length >= maxCount) break;
|
||||
if (
|
||||
!Number.isFinite(record.latitude) ||
|
||||
!Number.isFinite(record.longitude)
|
||||
)
|
||||
continue;
|
||||
result.push({
|
||||
id: record.name || record.id,
|
||||
name: record.name || null,
|
||||
class: record.class || null,
|
||||
kind: record.kind || null,
|
||||
lat: record.latitude,
|
||||
lon: record.longitude,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
getStats() {
|
||||
const namedMarkers = parts.namedMarkers?.stats();
|
||||
const subjectWindow =
|
||||
|
||||
Vendored
+34
@@ -17,6 +17,40 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
parts.selection.stopMissionZoomAnchor();
|
||||
},
|
||||
|
||||
/**
|
||||
* Snapshot loaded launches as plain JSON-safe records for the analyst
|
||||
* query engine. On demand only (once per spoken query); [] while the
|
||||
* layer is off.
|
||||
* @param {number} [maxCount=Infinity] Most records to return.
|
||||
* @returns {Array<Object>} {id, name, provider, status, launchSite, missionName, lat, lon, launchTimeMs, hoursUntil}.
|
||||
*/
|
||||
getAnalystRecords(maxCount = Infinity) {
|
||||
if (!layerState._enabled) return [];
|
||||
const now = Date.now();
|
||||
const text = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
|
||||
const result = [];
|
||||
for (const launch of layerState._launches || []) {
|
||||
if (result.length >= maxCount) break;
|
||||
const at = Date.parse(launch.launchTime);
|
||||
result.push({
|
||||
id: launch.id,
|
||||
name: text(launch.name),
|
||||
provider: text(launch.provider),
|
||||
status: text(launch.status),
|
||||
launchSite: text(launch.launchSite),
|
||||
missionName: text(launch.missionName),
|
||||
lat: launch.lat,
|
||||
lon: launch.lon,
|
||||
launchTimeMs: Number.isFinite(at) ? at : null,
|
||||
// Negative for launches already flown.
|
||||
hoursUntil: Number.isFinite(at)
|
||||
? Math.round(((at - now) / 3_600_000) * 10) / 10
|
||||
: null,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
getStats() {
|
||||
return {
|
||||
count: layerState._count,
|
||||
|
||||
@@ -828,6 +828,40 @@ export function createLocalAdsbLayer({
|
||||
return enabled && selectMarker(`${ENTITY_PREFIX}${icao}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Snapshot loaded locally received aircraft as plain JSON-safe records for the analyst
|
||||
* query engine. On demand only (once per spoken query); [] while the
|
||||
* layer is off.
|
||||
* @param {number} [maxCount=Infinity] Most records to return.
|
||||
* @returns {Array<Object>} {id, icao24, callsign, lat, lon, altitudeM, speedKts, heading, onGround}.
|
||||
*/
|
||||
getAnalystRecords(maxCount = Infinity) {
|
||||
if (!enabled) return [];
|
||||
const num = (v) => (Number.isFinite(v) ? v : null);
|
||||
const result = [];
|
||||
for (const marker of markers.values()) {
|
||||
if (result.length >= maxCount) break;
|
||||
const record = marker.record || {};
|
||||
const lat = marker.lat ?? record.lat;
|
||||
const lon = marker.lon ?? record.lon;
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
||||
result.push({
|
||||
id: record.callsign || record.icao,
|
||||
icao24: record.icao || null,
|
||||
callsign: record.callsign || null,
|
||||
lat,
|
||||
lon,
|
||||
altitudeM: Number.isFinite(record.altitudeFt)
|
||||
? Math.round(record.altitudeFt * 0.3048)
|
||||
: null,
|
||||
speedKts: num(record.groundSpeedKt),
|
||||
heading: num(record.trackDeg),
|
||||
onGround: record.onGround === true,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
getAllPositions(maxCount = 500) {
|
||||
return positionRows(maxCount);
|
||||
},
|
||||
|
||||
@@ -70,6 +70,8 @@ function admitTileRequests(provider, admission, requestRender, retry) {
|
||||
*/
|
||||
export function createRecentImageryRenderer({
|
||||
cesium = Cesium,
|
||||
tileTemplate = gibsTemplate,
|
||||
credit = 'NASA GIBS',
|
||||
maxTileRequests = 6,
|
||||
requestRender = () => {},
|
||||
setTimeoutImpl = (fn, ms) => globalThis.setTimeout(fn, ms),
|
||||
@@ -101,7 +103,7 @@ export function createRecentImageryRenderer({
|
||||
function createProvider(candidate, box) {
|
||||
const spec = PRODUCTS[candidate.product];
|
||||
const provider = new cesium.UrlTemplateImageryProvider({
|
||||
url: gibsTemplate(candidate.product, candidate.day),
|
||||
url: tileTemplate(candidate.product, candidate.day),
|
||||
subdomains: ['a', 'b', 'c'],
|
||||
minimumLevel: 0,
|
||||
maximumLevel: spec.maxLevel,
|
||||
@@ -112,7 +114,7 @@ export function createRecentImageryRenderer({
|
||||
box.east,
|
||||
box.north,
|
||||
),
|
||||
credit: 'NASA GIBS',
|
||||
credit,
|
||||
hasAlphaChannel: spec.format === 'png',
|
||||
});
|
||||
return admitTileRequests(provider, admission, requestRender, retry);
|
||||
|
||||
@@ -53,10 +53,11 @@ const S30 = { key: 'S30:2026-09-18', product: 'S30', day: '2026-09-18' };
|
||||
const L30 = { key: 'L30:2026-09-16', product: 'L30', day: '2026-09-16' };
|
||||
const VIIRS = { key: 'VIIRS:2026-09-20', product: 'VIIRS', day: '2026-09-20' };
|
||||
|
||||
function fixture({ requestImage, maxTileRequests } = {}) {
|
||||
function fixture({ requestImage, maxTileRequests, ...options } = {}) {
|
||||
const renders = [];
|
||||
const timers = manualTimers();
|
||||
const renderer = createRecentImageryRenderer({
|
||||
...options,
|
||||
cesium: fakeCesium(requestImage),
|
||||
maxTileRequests,
|
||||
requestRender: (reason) => renders.push(reason),
|
||||
@@ -208,3 +209,21 @@ test('against the basemap slot a splits left with no second layer, and leaving t
|
||||
assert.equal(layer.splitDirection, 0);
|
||||
assert.deepEqual(renders, ['recent-imagery-show', 'recent-imagery-look']);
|
||||
});
|
||||
|
||||
test('replacement imagery uses source-owned tile URLs and credit and tears down normally', () => {
|
||||
const { renderer, globe } = fixture({
|
||||
tileTemplate: (product, day) =>
|
||||
`https://tiles.example/${product}/${day}/{z}/{x}/{y}`,
|
||||
credit: 'Example imagery',
|
||||
});
|
||||
assert.equal(renderer.showSlot('a', S30, BOX), true);
|
||||
const layer = globe.layers[0];
|
||||
assert.equal(
|
||||
layer.provider.options.url,
|
||||
'https://tiles.example/S30/2026-09-18/{z}/{x}/{y}',
|
||||
);
|
||||
assert.equal(layer.provider.options.credit, 'Example imagery');
|
||||
renderer.destroy();
|
||||
assert.equal(globe.layers.length, 0);
|
||||
assert.equal(layer.destroyed, true);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { searchHls } from './catalog.js';
|
||||
import { gibsTemplate, wvsSnapshotUrl } from './model.js';
|
||||
|
||||
/** NASA-compatible imagery acquisition; transport and URL builders are replaceable. */
|
||||
export function createRecentImagerySource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
search = searchHls,
|
||||
thumbnailUrl = wvsSnapshotUrl,
|
||||
tileTemplate = gibsTemplate,
|
||||
credit = 'NASA GIBS',
|
||||
} = {}) {
|
||||
return {
|
||||
credit,
|
||||
searchHls: (query) => search({ ...query, fetchImpl }),
|
||||
async getThumbnail(candidate, box, { size = 256, signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl(
|
||||
thumbnailUrl({
|
||||
product: candidate.product,
|
||||
day: candidate.day,
|
||||
box,
|
||||
width: size,
|
||||
height: size,
|
||||
}),
|
||||
{ signal },
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
return response;
|
||||
},
|
||||
getTileTemplate: (product, day) => tileTemplate(product, day),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRecentImagerySource } from './source.js';
|
||||
|
||||
test('imagery source routes catalog, thumbnails and tiles through supplied adapters', async () => {
|
||||
const controller = new AbortController();
|
||||
const query = { box: { west: 0, east: 1, south: 0, north: 1 }, signal: controller.signal };
|
||||
const candidate = { product: 'S30', day: '2026-10-01' };
|
||||
const calls = [];
|
||||
const fetchImpl = async (...args) => { calls.push(args); return new Response('image'); };
|
||||
const source = createRecentImagerySource({
|
||||
fetchImpl,
|
||||
search: async (request) => {
|
||||
assert.equal(request.fetchImpl, fetchImpl);
|
||||
assert.equal(request.signal, controller.signal);
|
||||
assert.equal(request.box, query.box);
|
||||
return [candidate];
|
||||
},
|
||||
thumbnailUrl: ({ product, day, width }) => `https://imagery.example/${product}/${day}/${width}`,
|
||||
tileTemplate: (product, day) => `https://tiles.example/${product}/${day}/{z}/{x}/{y}`,
|
||||
credit: 'Example imagery',
|
||||
});
|
||||
assert.deepEqual(await source.searchHls(query), [candidate]);
|
||||
assert.equal(await (await source.getThumbnail(candidate, query.box, { signal: controller.signal })).text(), 'image');
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0][0], 'https://imagery.example/S30/2026-10-01/256');
|
||||
assert.equal(calls[0][1].signal, controller.signal);
|
||||
assert.equal(source.getTileTemplate(candidate.product, candidate.day), 'https://tiles.example/S30/2026-10-01/{z}/{x}/{y}');
|
||||
assert.equal(source.credit, 'Example imagery');
|
||||
});
|
||||
|
||||
test('cancelled thumbnail acquisition does not return stale responses or retry', async () => {
|
||||
const controller = new AbortController();
|
||||
let calls = 0;
|
||||
const source = createRecentImagerySource({
|
||||
thumbnailUrl: () => 'https://imagery.example/thumbnail',
|
||||
fetchImpl: async () => { calls++; controller.abort(); return new Response('image'); },
|
||||
});
|
||||
await assert.rejects(source.getThumbnail({}, {}, { signal: controller.signal }), { name: 'AbortError' });
|
||||
await assert.rejects(source.getThumbnail({}, {}, { signal: controller.signal }), { name: 'AbortError' });
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
@@ -8,7 +8,8 @@
|
||||
* card on every render, so a read that counted would evict the visible cards
|
||||
* first). Eviction revokes the image only; what the probe proved is kept.
|
||||
*/
|
||||
import { thumbnailOrder, wvsSnapshotUrl } from './model.js';
|
||||
import { thumbnailOrder } from './model.js';
|
||||
import { createRecentImagerySource } from './source.js';
|
||||
|
||||
const EMPTY = Object.freeze({
|
||||
status: 'unknown',
|
||||
@@ -22,6 +23,7 @@ const EMPTY = Object.freeze({
|
||||
*/
|
||||
export function createThumbnailLoader({
|
||||
fetchImpl = globalThis.fetch?.bind(globalThis),
|
||||
requestThumbnail = createRecentImagerySource({ fetchImpl }).getThumbnail,
|
||||
maxInFlight = 3,
|
||||
maxDecoded = 16,
|
||||
createObjectUrl = (blob) => URL.createObjectURL(blob),
|
||||
@@ -90,14 +92,10 @@ export function createThumbnailLoader({
|
||||
let acquisitionTime = null;
|
||||
let cancelled = false;
|
||||
try {
|
||||
const url = wvsSnapshotUrl({
|
||||
product: entry.candidate.product,
|
||||
day: entry.candidate.day,
|
||||
box: entry.box,
|
||||
width: size,
|
||||
height: size,
|
||||
const response = await requestThumbnail(entry.candidate, entry.box, {
|
||||
size,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const response = await fetchImpl(url, { signal: controller.signal });
|
||||
if (controller.signal.aborted) {
|
||||
cancelled = true;
|
||||
} else if (response?.ok) {
|
||||
|
||||
@@ -315,3 +315,24 @@ test('a request queued behind an aborted fetch still starts once the abort settl
|
||||
tracked: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test('injected thumbnail acquisition owns requests while the loader releases object URLs', async () => {
|
||||
const calls = [];
|
||||
const { loader, fetch, revoked } = fixture({
|
||||
requestThumbnail: async (...args) => {
|
||||
calls.push(args);
|
||||
return response({ headers: { 'Data-Present': 'true' } });
|
||||
},
|
||||
});
|
||||
const entry = candidate('S30', '2026-09-18');
|
||||
loader.request(entry, BOX, 0);
|
||||
await settle();
|
||||
assert.equal(fetch.calls.length, 0);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0][0], entry);
|
||||
assert.deepEqual(calls[0][1], BOX);
|
||||
assert.ok(calls[0][2].signal instanceof AbortSignal);
|
||||
assert.equal(loader.get(entry.key).status, 'present');
|
||||
loader.destroy();
|
||||
assert.deepEqual(revoked, ['blob:1']);
|
||||
});
|
||||
@@ -333,6 +333,16 @@ export function createStreetLevelLayer({
|
||||
requiresKeyId: requiresKeyIdFor(definitionsFrozen),
|
||||
/** Registered providers, in chip order. */
|
||||
providerIds: definitionsFrozen.map((def) => def.id),
|
||||
getSourceAvailability() {
|
||||
const available = definitionsFrozen.length > 0;
|
||||
return {
|
||||
available,
|
||||
source: STREET_LEVEL_LAYER_ID,
|
||||
reason: available
|
||||
? null
|
||||
: 'No street-level imagery providers configured',
|
||||
};
|
||||
},
|
||||
|
||||
init(viewer) {
|
||||
if (state.initialized)
|
||||
|
||||
@@ -482,3 +482,17 @@ test('switching a provider on while the layer is off activates nothing (M01)', a
|
||||
assert.equal(provider.calls.activate, 1);
|
||||
assert.equal(viewer.credits.length, 1);
|
||||
});
|
||||
|
||||
test('an empty provider composition enables and tears down without acquiring imagery', async (t) => {
|
||||
const { layer, viewer } = await enabledLayer(t, []);
|
||||
assert.deepEqual(layer.providerIds, []);
|
||||
assert.deepEqual(layer.getUIState().providers, []);
|
||||
assert.equal(layer.getUIState().keyRequired, false);
|
||||
assert.match(
|
||||
layer.getUIState().coverage.hint,
|
||||
/No street-level imagery providers/,
|
||||
);
|
||||
layer.disable();
|
||||
layer.enable(viewer);
|
||||
assert.deepEqual(layer.getUIState().providers, []);
|
||||
});
|
||||
@@ -1,13 +1,19 @@
|
||||
import { normalizeFilter } from './filter.js';
|
||||
import { encodeIdSwitches, decodeIdSwitches } from '../../data/idSwitches.js';
|
||||
|
||||
/**
|
||||
* Share-link params: one boolean per provider plus the filter. The codec in
|
||||
* src/data/layerState.js names the same keys, so a new provider needs one there.
|
||||
* Provider params plus the filter. Custom switches share a stable ID-based
|
||||
* field; Mapillary retains its existing boolean and share token.
|
||||
* @param {{providers: Iterable<[string, boolean]>, filter: {pano: string, sinceDays: number}}} input
|
||||
*/
|
||||
export function encodeParams({ providers, filter }) {
|
||||
const params = {};
|
||||
for (const [id, on] of providers) params[id] = on === true;
|
||||
const entries = [...providers];
|
||||
for (const [id, on] of entries) params[id] = on === true;
|
||||
const switches = encodeIdSwitches(
|
||||
entries.filter(([id]) => id !== 'mapillary'),
|
||||
);
|
||||
if (switches) params.providerSwitches = switches;
|
||||
params.pano = filter.pano;
|
||||
params.sinceDays = filter.sinceDays;
|
||||
return params;
|
||||
@@ -23,8 +29,13 @@ export function encodeParams({ providers, filter }) {
|
||||
export function decodeParams(params, { providerIds, filter }) {
|
||||
const providers = new Map();
|
||||
const source = params && typeof params === 'object' ? params : {};
|
||||
for (const id of providerIds)
|
||||
if (typeof source[id] === 'boolean') providers.set(id, source[id]);
|
||||
const switches = decodeIdSwitches(source.providerSwitches);
|
||||
switches.delete('mapillary');
|
||||
for (const id of providerIds) {
|
||||
if (switches.has(id)) providers.set(id, switches.get(id));
|
||||
else if (Object.hasOwn(source, id) && typeof source[id] === 'boolean')
|
||||
providers.set(id, source[id]);
|
||||
}
|
||||
const next = {};
|
||||
if ('pano' in source) next.pano = source.pano;
|
||||
if ('sinceDays' in source) next.sinceDays = source.sinceDays;
|
||||
|
||||
@@ -11,7 +11,13 @@ test('encodeParams writes one boolean per provider plus the filter', () => {
|
||||
],
|
||||
filter: { pano: 'flat', sinceDays: 365 },
|
||||
}),
|
||||
{ mapillary: true, panoramax: false, pano: 'flat', sinceDays: 365 },
|
||||
{
|
||||
mapillary: true,
|
||||
panoramax: false,
|
||||
providerSwitches: 'panoramax-0',
|
||||
pano: 'flat',
|
||||
sinceDays: 365,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { encodeParams, decodeParams } from './params.js';
|
||||
import {
|
||||
normalizeIdSwitches,
|
||||
patchRequestedIdSwitches,
|
||||
decodeIdSwitches,
|
||||
encodeIdSwitches,
|
||||
} from '../../data/idSwitches.js';
|
||||
import {
|
||||
createDefaultLayerState,
|
||||
encodeLayerStateParams,
|
||||
decodeLayerStateParams,
|
||||
serializeStoredLayerState,
|
||||
parseStoredLayerState,
|
||||
} from '../../data/layerState.js';
|
||||
import { validateProviders } from './registry.js';
|
||||
import { fakeStreetLevelProvider } from '../../testSupport/streetLevelFakes.mjs';
|
||||
|
||||
const filter = { pano: 'flat', sinceDays: 365 };
|
||||
|
||||
test('custom switches survive sharing and storage without depending on provider order', () => {
|
||||
const providers = [
|
||||
['panoramax', false],
|
||||
['example', true],
|
||||
['mapillary', false],
|
||||
];
|
||||
const options = encodeParams({ providers, filter });
|
||||
assert.equal(options.providerSwitches, 'example-1*panoramax-0');
|
||||
assert.equal(
|
||||
encodeParams({ providers: [...providers].reverse(), filter })
|
||||
.providerSwitches,
|
||||
options.providerSwitches,
|
||||
);
|
||||
const state = createDefaultLayerState();
|
||||
state.enabledLayerIds = ['street-level'];
|
||||
state.options['street-level'] = options;
|
||||
const params = new URLSearchParams({ v: '2' });
|
||||
encodeLayerStateParams(params, state);
|
||||
assert.match(params.get('lo'), /0\.m\.0/);
|
||||
assert.match(params.get('lo'), /0\.r\.example-1\*panoramax-0/);
|
||||
assert.ok(
|
||||
params.toString().includes('0.r.example-1*panoramax-0'),
|
||||
'custom switches need no percent escaping',
|
||||
);
|
||||
for (const restored of [
|
||||
decodeLayerStateParams(params),
|
||||
parseStoredLayerState(serializeStoredLayerState(state)),
|
||||
]) {
|
||||
const decoded = decodeParams(restored.options['street-level'], {
|
||||
providerIds: ['example', 'mapillary', 'panoramax'],
|
||||
filter: { pano: 'all', sinceDays: 0 },
|
||||
});
|
||||
assert.deepEqual(
|
||||
[...decoded.providers],
|
||||
[
|
||||
['example', true],
|
||||
['mapillary', false],
|
||||
['panoramax', false],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(decoded.filter, filter);
|
||||
assert.deepEqual(
|
||||
[
|
||||
...decodeParams(restored.options['street-level'], {
|
||||
providerIds: ['panoramax'],
|
||||
filter,
|
||||
}).providers,
|
||||
],
|
||||
[['panoramax', false]],
|
||||
);
|
||||
assert.equal(
|
||||
decodeParams(restored.options['street-level'], {
|
||||
providerIds: [],
|
||||
filter,
|
||||
}).providers.size,
|
||||
0,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('legacy links retain Mapillary semantics and leave custom defaults alone', () => {
|
||||
for (const [lo, on] of [
|
||||
['', true],
|
||||
['0.m.0', false],
|
||||
]) {
|
||||
const state = decodeLayerStateParams(
|
||||
new URLSearchParams({ v: '2', l: '0', lo }),
|
||||
);
|
||||
const restored = decodeParams(state.options['street-level'], {
|
||||
providerIds: ['mapillary', 'example'],
|
||||
filter,
|
||||
});
|
||||
assert.deepEqual([...restored.providers], [['mapillary', on]]);
|
||||
}
|
||||
});
|
||||
|
||||
test('switches reject ambiguous or oversized fields and cannot override the legacy token', () => {
|
||||
for (const value of [
|
||||
'example-0*example-1',
|
||||
'mapillary-0',
|
||||
'__proto__-1',
|
||||
'a-2',
|
||||
'a-1*',
|
||||
'a.b-1',
|
||||
'a_b-0',
|
||||
`${'a'.repeat(255)}-1`,
|
||||
null,
|
||||
{},
|
||||
]) {
|
||||
assert.equal(normalizeIdSwitches(value, ['mapillary']), null);
|
||||
}
|
||||
assert.equal(normalizeIdSwitches('constructor-0'), 'constructor-0');
|
||||
assert.throws(
|
||||
() => encodeIdSwitches([['a'.repeat(255), true]]),
|
||||
/share-link/,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateProviders([fakeStreetLevelProvider({ id: 'a'.repeat(255) })]),
|
||||
/share-link/,
|
||||
);
|
||||
const decoded = decodeParams(
|
||||
{ providerSwitches: 'mapillary-0', mapillary: true },
|
||||
{ providerIds: ['mapillary'], filter },
|
||||
);
|
||||
assert.equal(decoded.providers.get('mapillary'), true);
|
||||
});
|
||||
|
||||
test('neutral switch codec preserves hyphens and numeric suffixes in IDs', () => {
|
||||
const switches = [
|
||||
['provider-0', false],
|
||||
['provider-1', true],
|
||||
['a-b-c', true],
|
||||
];
|
||||
const encoded = encodeIdSwitches(switches);
|
||||
assert.equal(encoded, 'a-b-c-1*provider-0-0*provider-1-1');
|
||||
const decoded = decodeIdSwitches(encoded);
|
||||
for (const [id, on] of switches) assert.equal(decoded.get(id), on);
|
||||
assert.equal(decodeIdSwitches('valid-1*broken').size, 0);
|
||||
});
|
||||
|
||||
test('editing a receiving composition drops unknown IDs before enforcing the share bound', () => {
|
||||
const previous = `${'x'.repeat(254)}-1`;
|
||||
assert.equal(normalizeIdSwitches(previous), previous);
|
||||
assert.equal(
|
||||
patchRequestedIdSwitches(previous, 'alpha-0*beta-0', { alpha: false }),
|
||||
'alpha-0',
|
||||
);
|
||||
assert.equal(
|
||||
patchRequestedIdSwitches(previous, 'alpha-0', { pano: 'flat' }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { encodeIdSwitches } from '../../data/idSwitches.js';
|
||||
|
||||
/**
|
||||
* The contract an imagery provider registers with the Street Level layer.
|
||||
*
|
||||
@@ -86,8 +88,8 @@ const ID_GRAMMAR = /^[a-z][a-z0-9-]*$/;
|
||||
* @returns {ReadonlyArray<StreetLevelProvider>}
|
||||
*/
|
||||
export function validateProviders(providers) {
|
||||
if (!Array.isArray(providers) || providers.length === 0)
|
||||
throw new TypeError('Street Level needs at least one imagery provider');
|
||||
if (!Array.isArray(providers))
|
||||
throw new TypeError('Street Level providers must be an array');
|
||||
const ids = new Set();
|
||||
const prefixes = [];
|
||||
for (const provider of providers) {
|
||||
@@ -125,6 +127,19 @@ export function validateProviders(providers) {
|
||||
ids.add(provider.id);
|
||||
prefixes.push(provider.pickPrefix);
|
||||
}
|
||||
// Validate the complete bounded share field once, before any instance exists.
|
||||
try {
|
||||
encodeIdSwitches(
|
||||
providers
|
||||
.filter(({ id }) => id !== 'mapillary')
|
||||
.map(({ id }) => [id, true]),
|
||||
);
|
||||
} catch (error) {
|
||||
if (!(error instanceof RangeError)) throw error;
|
||||
throw new RangeError(
|
||||
'Street Level provider IDs cannot fit in the 256-character share-link field; use shorter IDs or fewer providers',
|
||||
);
|
||||
}
|
||||
return Object.freeze([...providers]);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,9 +26,14 @@ test('a valid list is frozen in registration order', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('an empty list, duplicate ids and malformed ids are rejected', () => {
|
||||
assert.throws(() => validateProviders([]), /at least one/);
|
||||
assert.throws(() => validateProviders(null), /at least one/);
|
||||
test('an empty provider list is valid and has no key requirement', () => {
|
||||
assert.deepEqual(validateProviders([]), []);
|
||||
assert.ok(Object.isFrozen(validateProviders([])));
|
||||
assert.equal(requiresKeyIdFor([]), null);
|
||||
});
|
||||
|
||||
test('non-arrays, duplicate ids and malformed ids are rejected', () => {
|
||||
assert.throws(() => validateProviders(null), /array/);
|
||||
assert.throws(() => validateProviders([def(), def()]), /Duplicate/);
|
||||
assert.throws(() => validateProviders([def({ id: 'Bad Id' })]), /slug/);
|
||||
});
|
||||
|
||||
@@ -27,7 +27,10 @@ export function summarizeCoverage(providers) {
|
||||
return {
|
||||
count: active.reduce((sum, p) => sum + (p.count || 0), 0),
|
||||
loading: active.some((p) => p.loading),
|
||||
hint: active.find((p) => p.hint)?.hint || '',
|
||||
hint:
|
||||
providers.length === 0
|
||||
? 'No street-level imagery providers configured'
|
||||
: active.find((p) => p.hint)?.hint || '',
|
||||
error: active.find((p) => p.error)?.error || null,
|
||||
keyRequired:
|
||||
active.length > 0 && active.every((p) => p.keyRequired === true),
|
||||
|
||||
@@ -176,6 +176,38 @@ export function createQueries({ state, parts }) {
|
||||
return collectDetectableVehicles(options);
|
||||
},
|
||||
|
||||
/**
|
||||
* Snapshot loaded transit vehicles (last reported fix) as plain JSON-safe records for the analyst
|
||||
* query engine. On demand only (once per spoken query); [] while the
|
||||
* layer is off.
|
||||
* @param {number} [maxCount=Infinity] Most records to return.
|
||||
* @returns {Array<Object>} {id, label, routeId, mode, lat, lon, speedMps, courseDeg, status, occupancy}.
|
||||
*/
|
||||
getAnalystRecords(maxCount = Infinity) {
|
||||
if (!state._enabled) return [];
|
||||
const num = (v) => (Number.isFinite(v) ? v : null);
|
||||
const result = [];
|
||||
for (const entry of state._vehicles.values()) {
|
||||
if (result.length >= maxCount) break;
|
||||
const record = entry.record || {};
|
||||
if (!Number.isFinite(record.lat) || !Number.isFinite(record.lon))
|
||||
continue;
|
||||
result.push({
|
||||
id: record.label || record.id || entry.key,
|
||||
label: record.label || null,
|
||||
routeId: record.routeId || null,
|
||||
mode: entry.mode || null,
|
||||
lat: record.lat,
|
||||
lon: record.lon,
|
||||
speedMps: num(record.speedMps),
|
||||
courseDeg: num(entry.courseDeg ?? record.bearing),
|
||||
status: record.status || null,
|
||||
occupancy: record.occupancy || null,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
getStats() {
|
||||
const active = [...state._activeFeeds.values()];
|
||||
const statuses = active.map((feed) =>
|
||||
|
||||
@@ -109,7 +109,7 @@ export function createIngestion({
|
||||
feed.acceptedRowCount = snapshot.acceptedRowCount;
|
||||
feed.partial = payload?.complete === false;
|
||||
|
||||
if (snapshot.acceptedRowCount === 0) {
|
||||
if (snapshot.acceptedRowCount === 0 && !snapshot.emptyCoverage) {
|
||||
feed.count = readCount();
|
||||
feed.stale = feed.count > 0 || Boolean(payload?.refreshing);
|
||||
if (isDefinitiveTransportFailure(snapshot.transportStatus)) {
|
||||
|
||||
@@ -361,3 +361,47 @@ test('partial vessel snapshots preserve freshness and recover after a complete u
|
||||
layer.destroy();
|
||||
assert.equal(layer.getStats().partial, false);
|
||||
});
|
||||
|
||||
test('authoritative empty coverage clears old contacts without declaring an outage', async () => {
|
||||
let current = snapshot([observation('111')]);
|
||||
const { layer } = setup({
|
||||
async getSnapshot() {
|
||||
return current;
|
||||
},
|
||||
});
|
||||
await layer.update();
|
||||
current = snapshot([], { observedAtMs: null, lastMessageAt: 500 });
|
||||
await layer.update();
|
||||
assert.ok(!layer.hasContact('111'));
|
||||
assert.equal(layer.getStats().count, 0);
|
||||
assert.equal(layer.getStats().error, null);
|
||||
assert.equal(layer.getStats().stale, false);
|
||||
assert.equal(layer.getStats().lastUpdate, null);
|
||||
});
|
||||
|
||||
test('empty coverage cannot hide stale, incomplete or rejected source data', async () => {
|
||||
for (const patch of [
|
||||
{ freshness: 'unknown' },
|
||||
{ freshness: 'stale' },
|
||||
{ complete: false },
|
||||
{ rawRowCount: 1 },
|
||||
{ transportStatus: 'stale' },
|
||||
{ lastMessageAt: null },
|
||||
]) {
|
||||
let current = snapshot([observation('111')]);
|
||||
const { layer } = setup({
|
||||
async getSnapshot() {
|
||||
return current;
|
||||
},
|
||||
});
|
||||
await layer.update();
|
||||
current = snapshot([], {
|
||||
observedAtMs: null,
|
||||
lastMessageAt: 500,
|
||||
...patch,
|
||||
});
|
||||
await layer.update();
|
||||
assert.equal(layer.hasContact('111'), true, JSON.stringify(patch));
|
||||
assert.ok(layer.getStats().error, JSON.stringify(patch));
|
||||
}
|
||||
});
|
||||
@@ -119,7 +119,16 @@ export function createQueries({
|
||||
typeof payload?.status === 'string' ? payload.status : null;
|
||||
const lastMessageAt = payload?.lastMessageAt ?? null;
|
||||
const acceptedRowCount = acceptedRows.length;
|
||||
const emptyCoverage =
|
||||
rawRows.length === 0 &&
|
||||
payload?.rawRowCount === 0 &&
|
||||
payload?.complete === true &&
|
||||
payload?.freshness === 'current' &&
|
||||
AIS_HEALTHY_STATUSES.has(transportStatus) &&
|
||||
Number.isFinite(Number(lastMessageAt)) &&
|
||||
Number(lastMessageAt) > 0;
|
||||
return {
|
||||
emptyCoverage,
|
||||
transportStatus,
|
||||
lastMessageAt,
|
||||
rawRows,
|
||||
@@ -130,9 +139,10 @@ export function createQueries({
|
||||
? payload.rawRowCount
|
||||
: rawRows.length,
|
||||
acceptedRowCount,
|
||||
error:
|
||||
deriveAisFeedError(payload, acceptedRowCount) ||
|
||||
(acceptedRowCount === 0 ? 'awaiting usable AIS positions…' : null),
|
||||
error: emptyCoverage
|
||||
? null
|
||||
: deriveAisFeedError(payload, acceptedRowCount) ||
|
||||
(acceptedRowCount === 0 ? 'awaiting usable AIS positions…' : null),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ const radio = ['playback', 'interaction'].map(name =>
|
||||
).join('\n').replace(/layerState\.|parts\.\w+\./g, '');
|
||||
const rocketLaunches = readLayerSource(new URL('./data/rocketLaunches.js', import.meta.url), 'utf8');
|
||||
const realtime = readRealtimeSource();
|
||||
const voice = readFileSync(new URL('./voice/actionSchemas.js', import.meta.url), 'utf8') + '\n' + ['toolDescriptions', 'instructions'].map(name => readFileSync(new URL(`../server/providers/openai/${name}.js`, import.meta.url), 'utf8')).join('\n');
|
||||
const voice = ['./voice/actionSchemas.js', '../server/providers/openai/toolDescriptions.js', '../server/providers/openai/instructions.js'].map(file => readFileSync(new URL(file, import.meta.url), 'utf8')).join('\n');
|
||||
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
||||
|
||||
function realtimeTools() { return GEV_REALTIME_TOOLS; }
|
||||
@@ -67,7 +67,13 @@ test('the counting contract is stated in the Realtime instructions', () => {
|
||||
assert.match(text, /scopeLabel/, 'rule 3 names its mechanism');
|
||||
assert.match(text, /never a bare number/, 'rule 3 is stated as a prohibition too');
|
||||
assert.match(text, /VERBATIM/, 'rule 4: no estimating');
|
||||
assert.match(text, /flights layer loads where you look/, 'rule 5: the loaded-data caveat');
|
||||
// Rule 5, the loaded-data caveat, is now one word inside the spoken scope
|
||||
// ("37 loaded flights over Texas"); the longer note stays on screen.
|
||||
const analyst = voice.slice(
|
||||
voice.indexOf("'For ANALYTICAL questions"),
|
||||
voice.indexOf('\n', voice.indexOf("'For ANALYTICAL questions")),
|
||||
);
|
||||
assert.match(analyst, /"37 loaded flights over Texas"/, 'rule 5: loaded scope stays audible');
|
||||
});
|
||||
|
||||
test('Context panel opening stays distinct from Contacts activation', () => {
|
||||
@@ -147,7 +153,7 @@ test('the edited existing tools changed exactly as intended', () => {
|
||||
const panel = byName.get('set_panel_open');
|
||||
assert.deepEqual(
|
||||
panel.parameters.properties.panelId.enum,
|
||||
['data-panel', 'location-bar', 'control-panel', 'cctv-panel', 'radio-panel', 'scene-panel', 'pp-toggles', 'global-context-panel'],
|
||||
['data-panel', 'location-bar', 'control-panel', 'cctv-panel', 'radio-panel', 'scene-panel', 'pp-toggles', 'global-context-panel', 'weather-panel', 'recent-imagery-panel'],
|
||||
);
|
||||
assert.deepEqual(panel.parameters.required, ['panelId', 'open']);
|
||||
|
||||
@@ -186,6 +192,13 @@ test('no unchanged Realtime tool definition drifts silently', () => {
|
||||
'next_satellite_pass',
|
||||
// Local ADS-B adds one layer enum value and its common-name mapping.
|
||||
'set_layer_visibility',
|
||||
// Layer enums generated from the voice layer manifest, and the corrected
|
||||
// track_entity layer hint.
|
||||
'show_data_layers_menu',
|
||||
'get_entity_context',
|
||||
'track_entity',
|
||||
// Point-and-ask: 'pointer' sentinels and referent arguments.
|
||||
'annotate_map',
|
||||
]);
|
||||
const unchanged = realtimeTools()
|
||||
.filter((tool) => !TOUCHED.has(tool.name))
|
||||
@@ -196,13 +209,13 @@ test('no unchanged Realtime tool definition drifts silently', () => {
|
||||
const hudLayout = unchanged.find((tool) => tool.name === 'set_hud').parameters.properties.layout;
|
||||
assert.deepEqual(hudLayout.enum, ['tactical', 'operator', 'minimal', 'cyber']);
|
||||
hudLayout.enum = hudLayout.enum.filter((layout) => layout !== 'cyber');
|
||||
assert.equal(unchanged.length, 18);
|
||||
assert.equal(unchanged.length, 14);
|
||||
const digest = createHash('sha256')
|
||||
.update(JSON.stringify(unchanged))
|
||||
.digest('hex')
|
||||
.slice(0, 16);
|
||||
// Analyst additions and ISS wording correction are explicitly excluded above; all other tool definitions retain their pin.
|
||||
assert.equal(digest, '91935845ef2598b1', 'an unchanged Realtime tool definition drifted');
|
||||
assert.equal(digest, '25d1b902ecaa22fb', 'an unchanged Realtime tool definition drifted');
|
||||
});
|
||||
|
||||
test('Radio volume and mission speed share the Sharpen slider visual language', () => {
|
||||
@@ -296,7 +309,11 @@ test('panel collapse is presentation-only and Radio exposes explicit voice playb
|
||||
const method = ui.slice(start, ui.indexOf('toggleCleanView(forceEnabled)', start));
|
||||
assert.doesNotMatch(method, /stopRadio|stopPlayback|setEnabled\('radio'/);
|
||||
assert.match(voice, /'radio-panel'/);
|
||||
assert.match(voice, /'radio'/);
|
||||
assert.ok(
|
||||
realtimeTools()
|
||||
.find((tool) => tool.name === 'set_layer_visibility')
|
||||
.parameters.properties.layerId.enum.includes('radio'),
|
||||
);
|
||||
assert.match(voice, /name:\s*'control_radio'/);
|
||||
assert.deepEqual(realtimeTools().find(tool => tool.name === 'control_radio').parameters.properties.action.enum, ['enable', 'disable', 'play', 'resume', 'pause', 'stop', 'next', 'previous', 'volume', 'select', 'status']);
|
||||
const enableMethod = radioBindings;
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { findNaturalRegion, pointInRing } from '../data/naturalEarthRegions.js';
|
||||
|
||||
/**
|
||||
* The unqualified Alps ask names the European range, not a same-named peak
|
||||
* or local POI. Keep this correction exact and Alps-only: other ranges and
|
||||
* geographic qualifiers retain the caller's configured geocoder semantics.
|
||||
*/
|
||||
export function createAlpsGeocoder({ lookupRegion = findNaturalRegion } = {}) {
|
||||
return {
|
||||
async geocode(query, { signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
const name = String(query ?? '')
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ');
|
||||
if (!/^(?:the )?alps(?: mountains)?$/i.test(name))
|
||||
return { place: null, answered: true };
|
||||
|
||||
const region = await lookupRegion('Alps');
|
||||
signal?.throwIfAborted();
|
||||
if (
|
||||
region?.name !== 'Alps' ||
|
||||
region.kind !== 'natural' ||
|
||||
!Array.isArray(region.polygons) ||
|
||||
region.polygons.length === 0 ||
|
||||
!region.polygons.every(
|
||||
(ring) =>
|
||||
Array.isArray(ring) &&
|
||||
ring.length >= 3 &&
|
||||
ring.every(
|
||||
(point) =>
|
||||
Array.isArray(point) &&
|
||||
point.length === 2 &&
|
||||
point.every(Number.isFinite) &&
|
||||
Math.abs(point[0]) <= 180 &&
|
||||
Math.abs(point[1]) <= 90,
|
||||
),
|
||||
)
|
||||
)
|
||||
throw new Error('Bundled Alps geometry unavailable');
|
||||
const vertices = region.polygons.flat();
|
||||
const west = Math.min(...vertices.map(([lon]) => lon));
|
||||
const east = Math.max(...vertices.map(([lon]) => lon));
|
||||
const south = Math.min(...vertices.map(([, lat]) => lat));
|
||||
const north = Math.max(...vertices.map(([, lat]) => lat));
|
||||
const lat = (south + north) / 2;
|
||||
const lng = (west + east) / 2;
|
||||
if (
|
||||
![west, east, south, north, lat, lng].every(Number.isFinite) ||
|
||||
!region.polygons.some((ring) => pointInRing(ring, lat, lng))
|
||||
) {
|
||||
// A broken pack is not permission to cache an unrelated network peak
|
||||
// as the canonical range. Existing search error/retry handling owns it.
|
||||
throw new Error('Bundled Alps geometry unavailable');
|
||||
}
|
||||
return {
|
||||
answered: true,
|
||||
place: {
|
||||
lat,
|
||||
lng,
|
||||
name: region.name,
|
||||
label: region.name,
|
||||
types: ['natural_feature'],
|
||||
exact: true,
|
||||
viewport: {
|
||||
southwest: { lat: south, lng: west },
|
||||
northeast: { lat: north, lng: east },
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -6,9 +6,10 @@ import { createGoogleGeocoder } from './google.js';
|
||||
import { createPhotonGeocoder } from '../keylessGeocoder.js';
|
||||
import { createCoordinateGeocoder } from './coordinateGeocoder.js';
|
||||
import { createPresetGeocoder } from './presetGeocoder.js';
|
||||
import { createAlpsGeocoder } from './alpsGeocoder.js';
|
||||
|
||||
/**
|
||||
* Coordinates and bundled names first — both answer offline and with no key —
|
||||
* Coordinates, bundled names and the unqualified Alps first — all offline —
|
||||
* then Google when configured, then keyless Photon, then the local Nominatim
|
||||
* route as a last resort. Transport stays local to setup.
|
||||
*
|
||||
@@ -37,6 +38,7 @@ export function createDefaultPlaceSearch({
|
||||
providers: providers.geocode || [
|
||||
createCoordinateGeocoder(),
|
||||
...(presets ? [createPresetGeocoder({ presets })] : []),
|
||||
createAlpsGeocoder(),
|
||||
...(selected
|
||||
? [selected]
|
||||
: [
|
||||
|
||||
@@ -274,6 +274,13 @@ test('doctor describes the credential ladder without exposing values', () => {
|
||||
);
|
||||
assert.match(capabilities.map, /Bing and world-terrain stacks/);
|
||||
assert.equal(capabilities.voice, 'available');
|
||||
assert.equal(
|
||||
buildCapabilitySummary({
|
||||
...credentials,
|
||||
OPENAI_API_KEY: { configured: false },
|
||||
}).voice,
|
||||
'off until an OpenAI key is added, or a Codex ChatGPT sign-in is selected in Provider Settings (experimental)',
|
||||
);
|
||||
assert.match(capabilities.missions, /token allowance/);
|
||||
assert.equal(
|
||||
capabilities.flights,
|
||||
|
||||
@@ -214,11 +214,18 @@ function decodeLayers(bytes, z, x, y, layerNames) {
|
||||
return { roads, military };
|
||||
}
|
||||
|
||||
/** Construct an immutable-version road/military tile source without starting I/O. */
|
||||
/**
|
||||
* Construct an immutable-version road/military tile source without starting I/O.
|
||||
*
|
||||
* `allowedOrigin` is deliberately not set here: `createVectorTileSource`
|
||||
* derives it from whichever `tileJsonUrl` is in effect, so a caller that
|
||||
* repoints only `tileJsonUrl` gets tiles from that host or a named error —
|
||||
* never a silent fall back to the default host (#933). Passing
|
||||
* `allowedOrigin` explicitly still overrides both.
|
||||
*/
|
||||
export function createOpenFreeMapSource(options = {}) {
|
||||
return createVectorTileSource({
|
||||
tileJsonUrl: 'https://tiles.openfreemap.org/planet',
|
||||
allowedOrigin: 'https://tiles.openfreemap.org',
|
||||
decode: decodeOpenFreeMapTile,
|
||||
maxEntries: 192,
|
||||
maxCacheBytes: 64 * 1024 * 1024,
|
||||
|
||||
@@ -320,3 +320,41 @@ test('Capitol, greenbelt and commercial property fixtures exclude non-public dri
|
||||
assert.ok(records.some((r) => r.area === area && !r.drivable));
|
||||
}
|
||||
});
|
||||
|
||||
test('repointing tileJsonUrl at a mirror never falls back to the default tile host', async () => {
|
||||
const urls = [];
|
||||
const mirror = 'http://127.0.0.1:12348';
|
||||
const source = createOpenFreeMapSource({
|
||||
fetchImpl: async (url) => {
|
||||
urls.push(url);
|
||||
return url.endsWith('/planet')
|
||||
? Response.json({ tiles: [`${mirror}/planet/v/{z}/{x}/{y}.pbf`] })
|
||||
: new Response(fixture('ofm-austin-14-3743-6745.pbf'));
|
||||
},
|
||||
tileJsonUrl: `${mirror}/planet`,
|
||||
});
|
||||
await source.fetchBounds(
|
||||
{ south: 30.264, north: 30.271, west: -97.75, east: -97.74 },
|
||||
{ zoom: 14 },
|
||||
);
|
||||
assert.ok(urls.length > 1, 'tiles were fetched');
|
||||
assert.deepEqual(
|
||||
urls.filter((url) => !url.startsWith(mirror)),
|
||||
[],
|
||||
'a repointed source must not fetch from tiles.openfreemap.org',
|
||||
);
|
||||
});
|
||||
|
||||
test('a mirror that serves the default host TileJSON verbatim fails loudly', async () => {
|
||||
// A transparent caching proxy returns upstream's TileJSON unchanged, whose
|
||||
// tiles[0] is an absolute upstream URL. That must be an error, not 100% of
|
||||
// tile traffic quietly leaving the configured mirror.
|
||||
const source = createOpenFreeMapSource({
|
||||
fetchImpl: async () =>
|
||||
Response.json({
|
||||
tiles: ['https://tiles.openfreemap.org/planet/v/{z}/{x}/{y}.pbf'],
|
||||
}),
|
||||
tileJsonUrl: 'http://127.0.0.1:12348/planet',
|
||||
});
|
||||
await assert.rejects(source.getMetadata(), { retryable: false });
|
||||
});
|
||||
@@ -16,11 +16,27 @@ export function validTileBounds(box) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The origin a configured TileJSON is served from, or undefined when there is
|
||||
* no absolute URL to read one off. Deriving `allowedOrigin` from it means
|
||||
* repointing `tileJsonUrl` at a mirror cannot silently keep loading tiles from
|
||||
* the old host: the origin check below then names the mismatch instead of
|
||||
* passing it (#933).
|
||||
*/
|
||||
function tileJsonOrigin(tileJsonUrl) {
|
||||
if (typeof tileJsonUrl !== 'string') return undefined;
|
||||
try {
|
||||
return new URL(tileJsonUrl).origin;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** A decoded XYZ cache with bounded workers, reads, retained bytes and request lifetimes. */
|
||||
export function createVectorTileSource({
|
||||
tileJsonUrl,
|
||||
template,
|
||||
allowedOrigin,
|
||||
allowedOrigin = tileJsonOrigin(tileJsonUrl),
|
||||
decode,
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
maxTiles = 16,
|
||||
|
||||
@@ -203,3 +203,45 @@ test('polygon-selected tiles keep nearest-first order and cached revisits issue
|
||||
'over-budget selection is rejected before fetching',
|
||||
);
|
||||
});
|
||||
|
||||
test('a configured tileJsonUrl sets the tile origin, and a foreign tiles[0] is still refused', async () => {
|
||||
// Without an explicit allowedOrigin the mirror's own TileJSON is honoured…
|
||||
const mirrored = createVectorTileSource({
|
||||
tileJsonUrl: 'http://127.0.0.1:12348/planet',
|
||||
decode: () => [],
|
||||
fetchImpl: async () =>
|
||||
Response.json({ tiles: ['http://127.0.0.1:12348/p/{z}/{x}/{y}.pbf'] }),
|
||||
});
|
||||
assert.equal(
|
||||
(await mirrored.getMetadata()).template,
|
||||
'http://127.0.0.1:12348/p/{z}/{x}/{y}.pbf',
|
||||
);
|
||||
|
||||
// …while a TileJSON that names a different host is refused rather than
|
||||
// silently fetched, which is what the origin check is for.
|
||||
const foreign = createVectorTileSource({
|
||||
tileJsonUrl: 'http://127.0.0.1:12348/planet',
|
||||
decode: () => [],
|
||||
fetchImpl: async () =>
|
||||
Response.json({
|
||||
tiles: ['https://tiles.example/planet/{z}/{x}/{y}.pbf'],
|
||||
}),
|
||||
});
|
||||
await assert.rejects(foreign.getMetadata(), { retryable: false });
|
||||
});
|
||||
|
||||
test('an explicit allowedOrigin still wins over the one derived from tileJsonUrl', async () => {
|
||||
const source = createVectorTileSource({
|
||||
tileJsonUrl: 'http://127.0.0.1:12348/planet',
|
||||
allowedOrigin: 'https://tiles.example',
|
||||
decode: () => [],
|
||||
fetchImpl: async () =>
|
||||
Response.json({
|
||||
tiles: ['https://tiles.example/planet/{z}/{x}/{y}.pbf'],
|
||||
}),
|
||||
});
|
||||
assert.equal(
|
||||
(await source.getMetadata()).template,
|
||||
'https://tiles.example/planet/{z}/{x}/{y}.pbf',
|
||||
);
|
||||
});
|
||||
@@ -17,6 +17,8 @@ export function createStandaloneApplication({
|
||||
cesiumToken,
|
||||
geospatial = {},
|
||||
voice = {},
|
||||
sources = {},
|
||||
streetLevelProviders,
|
||||
allowQaRegistration = false,
|
||||
}) {
|
||||
if (constructed)
|
||||
@@ -47,6 +49,8 @@ export function createStandaloneApplication({
|
||||
loaderStatus,
|
||||
});
|
||||
catalog = createStandaloneCatalog({
|
||||
sources,
|
||||
streetLevelProviders,
|
||||
nepalBoundaryResolver: (signal) =>
|
||||
scene.operations.annotationResolver.resolveRegionRingForQuery(
|
||||
'Nepal',
|
||||
|
||||
@@ -7,6 +7,8 @@ export { createStandaloneReferenceSources } from './layerSources.js';
|
||||
/** Create fresh layer instances using the existing standalone source choices. */
|
||||
export function createStandaloneCatalog({
|
||||
nepalBoundaryResolver,
|
||||
sources = {},
|
||||
streetLevelProviders,
|
||||
signal = new AbortController().signal,
|
||||
surface = createSurfaceServices({
|
||||
terrainSource: createApplicationRequestServices().terrain,
|
||||
@@ -16,7 +18,8 @@ export function createStandaloneCatalog({
|
||||
return createApplicationCatalog({
|
||||
nepalBoundaryResolver,
|
||||
surface,
|
||||
sources: createStandaloneLayerSources(),
|
||||
sources: createStandaloneLayerSources(sources),
|
||||
streetLevelProviders,
|
||||
signal,
|
||||
vesselOptions: {
|
||||
maxRows: import.meta.env?.VITE_AIS_LIVE_MAX_ROWS,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createDirectionsSource } from '../layers/directions/source.js';
|
||||
import { createRecentImagerySource } from '../layers/recentImagery/source.js';
|
||||
import { createOpenFreeMapSource } from '../sources/openFreeMap.js';
|
||||
import {
|
||||
createFlightSource,
|
||||
@@ -22,10 +24,14 @@ import { createReferenceSources } from '../sources/reference.js';
|
||||
export { createReferenceSources as createStandaloneReferenceSources } from '../sources/reference.js';
|
||||
|
||||
/** Select standalone providers without starting their acquisition. */
|
||||
export function createStandaloneLayerSources() {
|
||||
export function createStandaloneLayerSources(overrides = {}) {
|
||||
if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides))
|
||||
throw new TypeError('Source overrides must be an object');
|
||||
const mapTiles = createOpenFreeMapSource();
|
||||
return {
|
||||
const defaults = {
|
||||
...createReferenceSources(),
|
||||
directions: createDirectionsSource(),
|
||||
'recent-imagery': createRecentImagerySource(),
|
||||
flights: createFlightSource(),
|
||||
military: createMilitarySource(),
|
||||
vessels: createVesselSource({
|
||||
@@ -51,4 +57,9 @@ export function createStandaloneLayerSources() {
|
||||
token: import.meta.env?.MAPILLARY_CLIENT_TOKEN || '',
|
||||
}),
|
||||
};
|
||||
for (const name of Object.keys(overrides)) {
|
||||
if (!Object.hasOwn(defaults, name))
|
||||
throw new TypeError(`Unknown source: ${name}`);
|
||||
}
|
||||
return { ...defaults, ...overrides };
|
||||
}
|
||||
@@ -21,6 +21,9 @@ import { openAiRealtimeProxy } from 'gods-eye-view/server/providers/openai';
|
||||
import { keySetupEndpoint } from 'gods-eye-view/server/standalone/key-setup';
|
||||
import { realtimeInstructions } from '../../server/providers/openai/instructions.js';
|
||||
import { GEV_REALTIME_TOOLS } from '../../server/providers/openai/tools.js';
|
||||
import { createDebugLogHandler } from '../../server/providers/openai/debug-log.js';
|
||||
import { attachVoiceResult } from '../voice/speech.js';
|
||||
import { sanitizeDebugValue } from '../voice/realtimeDiagnostics.js';
|
||||
import { standaloneVoiceTools } from '../../server/standalone/voiceTools.js';
|
||||
|
||||
function install(plugin, preview = false) {
|
||||
@@ -38,6 +41,7 @@ function request(
|
||||
url = '/',
|
||||
body = '',
|
||||
origin = 'http://localhost:4173',
|
||||
remoteAddress = '127.0.0.1',
|
||||
} = {},
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -50,7 +54,7 @@ function request(
|
||||
origin,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
socket: { remoteAddress: '127.0.0.1' },
|
||||
socket: { remoteAddress },
|
||||
});
|
||||
const headers = {};
|
||||
const res = {
|
||||
@@ -196,6 +200,410 @@ test('Realtime handler preserves tools and default instructions, isolates suppli
|
||||
assert.equal(sent[0].session.instructions, sent[2].session.instructions);
|
||||
});
|
||||
|
||||
test('Realtime handler keeps API key as default and uses OAuth only when requested', async (t) => {
|
||||
env(t, 'GEV_RATELIMIT_OPENAI_PER_MIN', undefined);
|
||||
let oauthReads = 0;
|
||||
const authorizations = [];
|
||||
const routes = install(
|
||||
openAiRealtimeProxy({
|
||||
realtime: {
|
||||
resolveApiKey: () => 'fixture-api-key',
|
||||
resolveOAuthAccessToken: () => {
|
||||
oauthReads += 1;
|
||||
return 'fixture-oauth-token';
|
||||
},
|
||||
fetchImpl: async (_url, options) => {
|
||||
authorizations.push(options.headers.Authorization);
|
||||
return Response.json({ value: 'fixture-ephemeral' });
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const token = routes.get('/api/realtime/token');
|
||||
|
||||
const apiResponse = await request(token);
|
||||
assert.equal(apiResponse.status, 200);
|
||||
assert.equal(apiResponse.headers['x-gev-voice-auth'], 'env');
|
||||
assert.equal(oauthReads, 0, 'default cloud voice does not read OAuth');
|
||||
assert.equal(authorizations.at(-1), 'Bearer fixture-api-key');
|
||||
|
||||
const oauthResponse = await request(token, { url: '/?auth=oauth' });
|
||||
assert.equal(oauthResponse.status, 200);
|
||||
assert.equal(oauthResponse.headers['x-gev-voice-auth'], 'codex-oauth');
|
||||
assert.equal(oauthReads, 1);
|
||||
assert.equal(authorizations.at(-1), 'Bearer fixture-oauth-token');
|
||||
});
|
||||
|
||||
test('a Codex OAuth pin refuses API-key minting without silently switching modes', async (t) => {
|
||||
env(t, 'GEV_RATELIMIT_OPENAI_PER_MIN', undefined);
|
||||
const authorizations = [];
|
||||
const routes = install(
|
||||
openAiRealtimeProxy({
|
||||
realtime: {
|
||||
environment: { GEV_PREFER_CODEX_OAUTH: 'true' },
|
||||
resolveApiKey: () => assert.fail('pinned OAuth cannot read an API key'),
|
||||
resolveOAuthAccessToken: () => 'fixture-oauth-token',
|
||||
fetchImpl: async (_url, options) => {
|
||||
authorizations.push(options.headers.Authorization);
|
||||
return Response.json({ value: 'fixture-ephemeral' });
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const token = routes.get('/api/realtime/token');
|
||||
const refused = await request(token);
|
||||
assert.equal(refused.status, 503);
|
||||
assert.equal(refused.json().code, 'CODEX_OAUTH_REQUIRED');
|
||||
assert.equal(authorizations.length, 0);
|
||||
assert.equal((await request(token, { url: '/?auth=oauth' })).status, 200);
|
||||
assert.deepEqual(authorizations, ['Bearer fixture-oauth-token']);
|
||||
});
|
||||
|
||||
test('OAuth status and token minting never expose local OAuth to remote clients', async () => {
|
||||
let oauthReads = 0;
|
||||
const routes = install(
|
||||
openAiRealtimeProxy({
|
||||
realtime: {
|
||||
resolveOAuthAccessToken: () => {
|
||||
oauthReads += 1;
|
||||
return 'must-not-be-read-remotely';
|
||||
},
|
||||
fetchImpl: () =>
|
||||
assert.fail('remote OAuth request must not reach OpenAI'),
|
||||
},
|
||||
}),
|
||||
);
|
||||
const remote = { remoteAddress: '192.168.1.20' };
|
||||
assert.equal(
|
||||
(await request(routes.get('/api/realtime/oauth-status'), remote)).status,
|
||||
403,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await request(routes.get('/api/realtime/token'), {
|
||||
...remote,
|
||||
url: '/?auth=oauth',
|
||||
})
|
||||
).status,
|
||||
403,
|
||||
);
|
||||
assert.equal(oauthReads, 0);
|
||||
});
|
||||
|
||||
test('OAuth status reports local sign-in availability without returning the token', async () => {
|
||||
const routes = install(
|
||||
openAiRealtimeProxy({
|
||||
realtime: { resolveOAuthAccessToken: () => 'fixture-oauth-secret' },
|
||||
}),
|
||||
);
|
||||
const response = await request(routes.get('/api/realtime/oauth-status'));
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(response.json(), { available: true });
|
||||
assert.equal(response.body.includes('fixture-oauth-secret'), false);
|
||||
});
|
||||
|
||||
test('OAuth status and token minting report expired sign-in as re-auth required', async () => {
|
||||
const expired = () => {
|
||||
const error = new Error('ChatGPT sign-in has expired.');
|
||||
error.code = 'CODEX_OAUTH_REAUTH_REQUIRED';
|
||||
throw error;
|
||||
};
|
||||
const routes = install(
|
||||
openAiRealtimeProxy({
|
||||
realtime: {
|
||||
resolveOAuthAccessToken: expired,
|
||||
fetchImpl: () => assert.fail('expired OAuth must not reach OpenAI'),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const status = await request(routes.get('/api/realtime/oauth-status'));
|
||||
assert.equal(status.status, 200);
|
||||
assert.deepEqual(status.json(), {
|
||||
available: false,
|
||||
error: 'ChatGPT sign-in has expired.',
|
||||
code: 'CODEX_OAUTH_REAUTH_REQUIRED',
|
||||
reauthRequired: true,
|
||||
});
|
||||
|
||||
const token = await request(routes.get('/api/realtime/token'), {
|
||||
url: '/?auth=oauth',
|
||||
});
|
||||
assert.equal(token.status, 503);
|
||||
assert.equal(token.json().code, 'CODEX_OAUTH_REAUTH_REQUIRED');
|
||||
});
|
||||
|
||||
test('OAuth login endpoint shares pending attempts and never exposes credentials', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
let starts = 0;
|
||||
let spawnReady;
|
||||
let exit;
|
||||
const plugin = openAiRealtimeProxy({
|
||||
realtime: {
|
||||
resolveOAuthAccessToken: () => {
|
||||
throw new Error('not signed in');
|
||||
},
|
||||
startOAuthLogin: async () => {
|
||||
starts += 1;
|
||||
await new Promise((resolve) => {
|
||||
spawnReady = resolve;
|
||||
});
|
||||
return {
|
||||
completion: new Promise((resolve) => {
|
||||
exit = resolve;
|
||||
}),
|
||||
cancel() {},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
t.after(plugin.closeBundle);
|
||||
const routes = install(plugin);
|
||||
const handler = routes.get('/api/realtime/oauth-login');
|
||||
|
||||
const firstRequest = request(handler, { method: 'POST' });
|
||||
const concurrent = await request(handler, { method: 'POST' });
|
||||
assert.equal(concurrent.status, 202);
|
||||
assert.equal(concurrent.json().started, false);
|
||||
assert.equal(starts, 1, 'reserve the attempt before awaiting spawn');
|
||||
spawnReady();
|
||||
const started = await firstRequest;
|
||||
assert.equal(started.status, 202);
|
||||
assert.deepEqual(started.json(), {
|
||||
available: false,
|
||||
started: true,
|
||||
pending: true,
|
||||
});
|
||||
assert.equal(starts, 1);
|
||||
|
||||
t.mock.timers.tick(31_000);
|
||||
const duplicate = await request(handler, { method: 'POST' });
|
||||
assert.equal(duplicate.status, 202);
|
||||
assert.equal(duplicate.json().pending, true);
|
||||
assert.equal(starts, 1, 'rapid retries must not spawn another login');
|
||||
|
||||
const remote = await request(handler, {
|
||||
method: 'POST',
|
||||
remoteAddress: '192.168.1.20',
|
||||
});
|
||||
assert.equal(remote.status, 403);
|
||||
assert.equal(starts, 1);
|
||||
|
||||
const crossOrigin = await request(handler, {
|
||||
method: 'POST',
|
||||
origin: 'https://example.com',
|
||||
});
|
||||
assert.equal(crossOrigin.status, 403);
|
||||
assert.equal(starts, 1);
|
||||
|
||||
exit({ exitCode: 1 });
|
||||
await Promise.resolve();
|
||||
const failed = await request(routes.get('/api/realtime/oauth-status'));
|
||||
assert.equal(failed.json().loginFailed, true);
|
||||
assert.equal(failed.json().code, 'CODEX_OAUTH_LOGIN_FAILED');
|
||||
});
|
||||
|
||||
test('OAuth status follows process completion, permits retry, and checks credential storage', async (t) => {
|
||||
let token = null;
|
||||
let exit;
|
||||
let starts = 0;
|
||||
const plugin = openAiRealtimeProxy({
|
||||
realtime: {
|
||||
resolveOAuthAccessToken: () => token,
|
||||
startOAuthLogin: async () => {
|
||||
starts += 1;
|
||||
return {
|
||||
completion: new Promise((resolve) => {
|
||||
exit = resolve;
|
||||
}),
|
||||
cancel() {},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
t.after(plugin.closeBundle);
|
||||
const routes = install(plugin);
|
||||
const launch = () =>
|
||||
request(routes.get('/api/realtime/oauth-login'), { method: 'POST' });
|
||||
const status = () => request(routes.get('/api/realtime/oauth-status'));
|
||||
await launch();
|
||||
exit({ exitCode: 0 });
|
||||
await Promise.resolve();
|
||||
assert.equal(
|
||||
(await status()).json().code,
|
||||
'CODEX_OAUTH_STORAGE_UNAVAILABLE',
|
||||
'exit success alone is not usable sign-in',
|
||||
);
|
||||
await launch();
|
||||
assert.equal(starts, 2, 'failed attempts can be retried immediately');
|
||||
token = 'fixture-private-token';
|
||||
exit({ exitCode: 0 });
|
||||
await Promise.resolve();
|
||||
const ready = await status();
|
||||
assert.deepEqual(ready.json(), { available: true });
|
||||
assert.equal(ready.body.includes(token), false);
|
||||
});
|
||||
|
||||
test('OAuth login timeout and server shutdown cancel the owned process', async () => {
|
||||
let timeout;
|
||||
let cancelled = 0;
|
||||
let cleared = 0;
|
||||
const plugin = openAiRealtimeProxy({
|
||||
realtime: {
|
||||
resolveOAuthAccessToken: () => null,
|
||||
startOAuthLogin: async () => ({
|
||||
completion: new Promise(() => {}),
|
||||
cancel() {
|
||||
cancelled += 1;
|
||||
},
|
||||
}),
|
||||
setTimer(callback, duration) {
|
||||
timeout = callback;
|
||||
assert.equal(duration, 120_000);
|
||||
return 1;
|
||||
},
|
||||
clearTimer() {
|
||||
cleared += 1;
|
||||
},
|
||||
},
|
||||
});
|
||||
const routes = install(plugin);
|
||||
const launch = () =>
|
||||
request(routes.get('/api/realtime/oauth-login'), { method: 'POST' });
|
||||
await launch();
|
||||
timeout();
|
||||
assert.equal(cancelled, 1);
|
||||
const status = await request(routes.get('/api/realtime/oauth-status'));
|
||||
assert.equal(status.json().code, 'CODEX_OAUTH_LOGIN_TIMEOUT');
|
||||
await launch();
|
||||
plugin.closeBundle();
|
||||
plugin.closeBundle();
|
||||
assert.equal(cancelled, 2, 'shutdown is idempotent');
|
||||
assert.equal(cleared, 2);
|
||||
});
|
||||
|
||||
test('OAuth launch errors are safe and cross-origin status requests cannot read credentials', async () => {
|
||||
let reads = 0;
|
||||
const routes = install(
|
||||
openAiRealtimeProxy({
|
||||
realtime: {
|
||||
resolveOAuthAccessToken() {
|
||||
reads += 1;
|
||||
return null;
|
||||
},
|
||||
startOAuthLogin: async () => {
|
||||
throw new Error('private-token-fixture');
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const remote = await request(routes.get('/api/realtime/oauth-status'), {
|
||||
origin: 'https://example.com',
|
||||
});
|
||||
assert.equal(remote.status, 403);
|
||||
assert.equal(reads, 0);
|
||||
const failed = await request(routes.get('/api/realtime/oauth-login'), {
|
||||
method: 'POST',
|
||||
});
|
||||
assert.equal(failed.status, 503);
|
||||
assert.equal(failed.json().code, 'CODEX_OAUTH_LOGIN_FAILED');
|
||||
assert.equal(failed.body.includes('private-token-fixture'), false);
|
||||
});
|
||||
|
||||
test('a login that spawns after timeout is cancelled and cannot replace a retry', async (t) => {
|
||||
let timeout;
|
||||
let releaseSpawn;
|
||||
let cancelled = 0;
|
||||
let launches = 0;
|
||||
const plugin = openAiRealtimeProxy({
|
||||
realtime: {
|
||||
resolveOAuthAccessToken: () => null,
|
||||
startOAuthLogin: async () => {
|
||||
launches += 1;
|
||||
if (launches === 1)
|
||||
await new Promise((resolve) => {
|
||||
releaseSpawn = resolve;
|
||||
});
|
||||
return {
|
||||
completion: new Promise(() => {}),
|
||||
cancel() {
|
||||
cancelled += 1;
|
||||
},
|
||||
};
|
||||
},
|
||||
setTimer(callback) {
|
||||
timeout = callback;
|
||||
return 1;
|
||||
},
|
||||
clearTimer() {},
|
||||
},
|
||||
});
|
||||
t.after(plugin.closeBundle);
|
||||
const routes = install(plugin);
|
||||
const launch = () =>
|
||||
request(routes.get('/api/realtime/oauth-login'), { method: 'POST' });
|
||||
const first = launch();
|
||||
timeout();
|
||||
await launch();
|
||||
releaseSpawn();
|
||||
await first;
|
||||
assert.equal(launches, 2);
|
||||
assert.equal(cancelled, 1, 'late process must not become an orphan');
|
||||
const status = (
|
||||
await request(routes.get('/api/realtime/oauth-status'))
|
||||
).json();
|
||||
assert.equal(status.pending, true);
|
||||
assert.equal(
|
||||
status.loginFailed,
|
||||
undefined,
|
||||
'the older attempt cannot fail the retry',
|
||||
);
|
||||
});
|
||||
|
||||
test('OAuth login endpoint selects an existing local sign-in without launching Codex', async () => {
|
||||
const handler = install(
|
||||
openAiRealtimeProxy({
|
||||
realtime: {
|
||||
resolveOAuthAccessToken: () => 'fixture-existing-secret',
|
||||
startOAuthLogin: () =>
|
||||
assert.fail('existing auth must not launch login'),
|
||||
},
|
||||
}),
|
||||
).get('/api/realtime/oauth-login');
|
||||
const response = await request(handler, { method: 'POST' });
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(response.json(), {
|
||||
available: true,
|
||||
started: false,
|
||||
pending: false,
|
||||
});
|
||||
assert.equal(response.body.includes('fixture-existing-secret'), false);
|
||||
});
|
||||
|
||||
test('caption transcription is on by default, reported to the client meter, and can be turned off', async (t) => {
|
||||
env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret');
|
||||
env(t, 'GEV_RATELIMIT_OPENAI_PER_MIN', undefined);
|
||||
const sent = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url, options) => {
|
||||
sent.push(JSON.parse(options.body));
|
||||
return Response.json({ value: 'fixture-ephemeral' });
|
||||
});
|
||||
for (const [setting, expected] of [
|
||||
[undefined, 'gpt-4o-mini-transcribe'],
|
||||
['off', 'off'],
|
||||
]) {
|
||||
env(t, 'OPENAI_REALTIME_TRANSCRIBE_MODEL', setting);
|
||||
const response = await request(
|
||||
install(openAiRealtimeProxy()).get('/api/realtime/token'),
|
||||
);
|
||||
assert.equal(response.headers['x-gev-voice-transcribe-model'], expected);
|
||||
assert.equal(
|
||||
sent.at(-1).session.audio.input.transcription?.model ?? 'off',
|
||||
expected,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('Realtime sessions carry supplied tools, and the standalone voice adds the catalog queries', async (t) => {
|
||||
env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret');
|
||||
env(t, 'GEV_RATELIMIT_OPENAI_PER_MIN', undefined);
|
||||
@@ -374,6 +782,269 @@ test('OpenAI routes answer generically when the upstream or the request fails',
|
||||
assert.equal(token.body.includes('fixture-upstream-secret'), false);
|
||||
});
|
||||
|
||||
test('the debug-log sink omits what people said and tool arguments unless content logging is enabled', async (t) => {
|
||||
const nestObject = (value, levels) => {
|
||||
let nested = value;
|
||||
for (let index = 0; index < levels; index += 1) nested = { nested };
|
||||
return nested;
|
||||
};
|
||||
const nestArray = (value, levels) => {
|
||||
let nested = value;
|
||||
for (let index = 0; index < levels; index += 1) nested = [nested];
|
||||
return nested;
|
||||
};
|
||||
const records = [
|
||||
{
|
||||
timestamp: '2026-10-05T00:00:00.000Z',
|
||||
sessionId: 'gev-session-1',
|
||||
event: 'tool.call',
|
||||
status: 'active',
|
||||
payload: {
|
||||
name: 'analyst_query',
|
||||
callId: 'call-1',
|
||||
arguments: { location: '12 Elm Street', note: 'meet Alice there' },
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamp: '2026-10-05T00:00:01.000Z',
|
||||
sessionId: 'gev-session-1',
|
||||
event: 'server.event',
|
||||
status: 'active',
|
||||
payload: {
|
||||
type: 'response.function_call_arguments.done',
|
||||
eventId: 'event-1',
|
||||
responseId: 'response-1',
|
||||
payload: {
|
||||
type: 'response.function_call_arguments.done',
|
||||
event_id: 'event-1',
|
||||
response_id: 'response-1',
|
||||
item_id: 'item-1',
|
||||
output_index: 0,
|
||||
call_id: 'call-1',
|
||||
name: 'analyst_query',
|
||||
arguments: '{"query":"Tokyo Station"}',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
event: 'server.event',
|
||||
status: 'active',
|
||||
payload: {
|
||||
type: 'response.audio_transcript.done',
|
||||
transcript: 'my home address is 12 Elm Street',
|
||||
text: 'call my sister',
|
||||
delta: 'Flying to Tokyo.',
|
||||
usage: { input_tokens: 40 },
|
||||
},
|
||||
},
|
||||
{
|
||||
event: 'voice.cost',
|
||||
status: 'active',
|
||||
payload: { delta: 17, total: 41 },
|
||||
},
|
||||
{
|
||||
event: 'tool.call',
|
||||
status: 'active',
|
||||
payload: { name: 'adjust_camera_zoom', callId: 'call-2', arguments: 2 },
|
||||
},
|
||||
{
|
||||
event: 'deep.object',
|
||||
status: 'active',
|
||||
payload: nestObject(
|
||||
{
|
||||
transcript: 'deep-object-transcript-secret',
|
||||
arguments: { query: 'deep-object-argument-secret' },
|
||||
},
|
||||
14,
|
||||
),
|
||||
},
|
||||
{
|
||||
event: 'deep.array',
|
||||
status: 'active',
|
||||
payload: nestArray(
|
||||
{
|
||||
transcript: 'deep-array-transcript-secret',
|
||||
arguments: { query: 'deep-array-argument-secret' },
|
||||
},
|
||||
14,
|
||||
),
|
||||
},
|
||||
];
|
||||
for (const includeContent of [false, true]) {
|
||||
const sourceRoot = root(t);
|
||||
const handler = createDebugLogHandler({ sourceRoot, includeContent });
|
||||
for (const record of records) {
|
||||
const response = await request(handler, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(record),
|
||||
});
|
||||
assert.equal(response.status, 204);
|
||||
}
|
||||
const logged = readFileSync(
|
||||
path.join(sourceRoot, '.gev-logs/realtime-conversations.jsonl'),
|
||||
'utf8',
|
||||
);
|
||||
assert.equal(logged.includes('Elm Street'), includeContent);
|
||||
assert.equal(logged.includes('call my sister'), includeContent);
|
||||
assert.equal(logged.includes('meet Alice there'), includeContent);
|
||||
assert.equal(logged.includes('Tokyo Station'), includeContent);
|
||||
assert.equal(logged.includes('Flying to Tokyo'), includeContent);
|
||||
assert.equal(
|
||||
logged.includes('deep-object-transcript-secret'),
|
||||
includeContent,
|
||||
);
|
||||
assert.equal(
|
||||
logged.includes('deep-object-argument-secret'),
|
||||
includeContent,
|
||||
);
|
||||
assert.equal(
|
||||
logged.includes('deep-array-transcript-secret'),
|
||||
includeContent,
|
||||
);
|
||||
assert.equal(logged.includes('deep-array-argument-secret'), includeContent);
|
||||
const [
|
||||
toolCall,
|
||||
serverEvent,
|
||||
transcriptEvent,
|
||||
numericDelta,
|
||||
numericArguments,
|
||||
deepObject,
|
||||
deepArray,
|
||||
] = logged
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line));
|
||||
assert.equal(toolCall.timestamp, records[0].timestamp);
|
||||
assert.equal(toolCall.sessionId, 'gev-session-1');
|
||||
assert.equal(toolCall.event, 'tool.call');
|
||||
assert.equal(toolCall.status, 'active');
|
||||
assert.equal(toolCall.payload.name, 'analyst_query');
|
||||
assert.equal(toolCall.payload.callId, 'call-1');
|
||||
assert.equal(serverEvent.payload.eventId, 'event-1');
|
||||
assert.equal(serverEvent.payload.responseId, 'response-1');
|
||||
assert.equal(serverEvent.payload.payload.event_id, 'event-1');
|
||||
assert.equal(serverEvent.payload.payload.response_id, 'response-1');
|
||||
assert.equal(serverEvent.payload.payload.item_id, 'item-1');
|
||||
assert.equal(serverEvent.payload.payload.output_index, 0);
|
||||
assert.equal(serverEvent.payload.payload.call_id, 'call-1');
|
||||
assert.equal(serverEvent.payload.payload.name, 'analyst_query');
|
||||
assert.equal(transcriptEvent.payload.usage.input_tokens, 40);
|
||||
assert.equal(numericDelta.payload.delta, 17);
|
||||
assert.equal(numericDelta.payload.total, 41);
|
||||
if (!includeContent) {
|
||||
assert.equal(toolCall.payload.arguments, '[omitted]');
|
||||
assert.match(
|
||||
serverEvent.payload.payload.arguments,
|
||||
/^\[omitted \d+ chars\]$/,
|
||||
);
|
||||
assert.equal(transcriptEvent.payload.transcript, '[omitted 32 chars]');
|
||||
assert.equal(transcriptEvent.payload.text, '[omitted 14 chars]');
|
||||
assert.equal(transcriptEvent.payload.delta, '[omitted 16 chars]');
|
||||
assert.equal(numericArguments.payload.arguments, '[omitted]');
|
||||
assert.equal(
|
||||
JSON.stringify(deepObject.payload).includes('[omitted: max depth]'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
JSON.stringify(deepArray.payload).includes('[omitted: max depth]'),
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
assert.deepEqual(
|
||||
toolCall.payload.arguments,
|
||||
records[0].payload.arguments,
|
||||
);
|
||||
assert.equal(
|
||||
serverEvent.payload.payload.arguments,
|
||||
records[1].payload.payload.arguments,
|
||||
);
|
||||
assert.equal(numericArguments.payload.arguments, 2);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('debug logs omit structured and serialized tool results unless content logging is enabled', async (t) => {
|
||||
const secret = 'private-address-fixture';
|
||||
const result = attachVoiceResult('fly_to_location', {
|
||||
ok: true,
|
||||
query: secret,
|
||||
label: secret,
|
||||
arrived: true,
|
||||
// New tool-specific fields must not need their own redaction rule.
|
||||
futureField: { nested: [secret] },
|
||||
});
|
||||
const records = [
|
||||
{
|
||||
event: 'tool.result',
|
||||
payload: { name: 'fly_to_location', callId: 'call-1', result },
|
||||
},
|
||||
{
|
||||
event: 'client.function_call_output',
|
||||
payload: {
|
||||
message: {
|
||||
type: 'conversation.item.create',
|
||||
item: {
|
||||
type: 'function_call_output',
|
||||
call_id: 'call-1',
|
||||
output: JSON.stringify(result),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
event: 'server.event',
|
||||
payload: {
|
||||
response: {
|
||||
id: 'response-1',
|
||||
output: [
|
||||
{
|
||||
type: 'message',
|
||||
content: [{ type: 'output_text', text: secret }],
|
||||
},
|
||||
],
|
||||
usage: { input_tokens: 40, output_tokens: 10 },
|
||||
},
|
||||
},
|
||||
},
|
||||
].map((record) => sanitizeDebugValue(record));
|
||||
for (const includeContent of [false, true]) {
|
||||
const sourceRoot = root(t);
|
||||
const handler = createDebugLogHandler({ sourceRoot, includeContent });
|
||||
for (const record of records) {
|
||||
const response = await request(handler, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(record),
|
||||
});
|
||||
assert.equal(response.status, 204);
|
||||
}
|
||||
const logged = readFileSync(
|
||||
path.join(sourceRoot, '.gev-logs/realtime-conversations.jsonl'),
|
||||
'utf8',
|
||||
);
|
||||
assert.equal(logged.includes(secret), includeContent);
|
||||
const [tool, client, server] = logged.trim().split('\n').map(JSON.parse);
|
||||
assert.equal(tool.payload.name, 'fly_to_location');
|
||||
assert.equal(tool.payload.callId, 'call-1');
|
||||
assert.equal(client.payload.message.item.call_id, 'call-1');
|
||||
assert.equal(server.payload.response.id, 'response-1');
|
||||
assert.equal(server.payload.response.output[0].type, 'message');
|
||||
assert.deepEqual(
|
||||
server.payload.response.usage,
|
||||
records[2].payload.response.usage,
|
||||
);
|
||||
if (includeContent) {
|
||||
assert.deepEqual(tool.payload.result, result);
|
||||
assert.equal(client.payload.message.item.output, JSON.stringify(result));
|
||||
} else {
|
||||
assert.equal(tool.payload.result, '[omitted]');
|
||||
assert.match(
|
||||
client.payload.message.item.output,
|
||||
/^\[omitted \d+ chars\]$/,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('the debug-log sink stays bounded, rate limited, and quiet about failures', async (t) => {
|
||||
const sourceRoot = root(t);
|
||||
const handler = install(openAiRealtimeProxy({ sourceRoot })).get(
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import { GEV_ACTION_SCHEMAS } from '../voice/actionSchemas.js';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
normalizeSchemaForChat,
|
||||
toChatTools,
|
||||
toGeminiDeclarations,
|
||||
toGeminiSchema,
|
||||
chatNormalizationDiff,
|
||||
} from '../../scripts/voice-bench/toolFormats.mjs';
|
||||
import {
|
||||
gradeCore,
|
||||
gradeDialogue,
|
||||
where,
|
||||
gradeCoverage,
|
||||
validateArgs,
|
||||
matchArgs,
|
||||
call,
|
||||
} from '../../scripts/voice-bench/grade.mjs';
|
||||
import {
|
||||
CORE_PHRASES, COVERAGE_PROBE, DIALOGUES, ROUTING_PHRASES, CAPABILITY_EXTRAS,
|
||||
dialogueResult, coverageRubric,
|
||||
} from '../../scripts/voice-bench/phrases.mjs';
|
||||
import { stubResult } from '../../scripts/voice-bench/stub.mjs';
|
||||
import { summarize, percentile } from '../../scripts/voice-bench/report.mjs';
|
||||
import { geminiUsageCostUsd } from '../../scripts/voice-bench/pricing.mjs';
|
||||
import { GEV_REALTIME_TOOLS } from '../../server/providers/openai/tools.js';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
// The 4b56d0e9 tool schema: the coverage v1 rubric grades against it.
|
||||
const BASELINE_TOOLS = JSON.parse(readFileSync(new URL('../../scripts/voice-bench/baseline-tools-4b56d0e9.json', import.meta.url), 'utf8'));
|
||||
|
||||
const byPhrase = (text) => CORE_PHRASES.find((p) => p.phrase === text);
|
||||
const coverage = (id) => COVERAGE_PROBE.find((c) => c.id === id);
|
||||
|
||||
test('toChatTools wraps every Realtime tool without changing names or descriptions', () => {
|
||||
const chat = toChatTools(GEV_REALTIME_TOOLS);
|
||||
assert.equal(chat.length, GEV_REALTIME_TOOLS.length);
|
||||
chat.forEach((t, i) => {
|
||||
assert.equal(t.type, 'function');
|
||||
assert.equal(t.function.name, GEV_REALTIME_TOOLS[i].name);
|
||||
assert.equal(t.function.description, GEV_REALTIME_TOOLS[i].description);
|
||||
assert.equal(t.function.parameters.type, 'object');
|
||||
});
|
||||
});
|
||||
|
||||
test('normalizeSchemaForChat types the untyped analyst_query filter value and nothing else', () => {
|
||||
const analyst = GEV_REALTIME_TOOLS.find((t) => t.name === 'analyst_query');
|
||||
assert.deepEqual(analyst.parameters.properties.filters.items.properties.value, {});
|
||||
const normalized = normalizeSchemaForChat(analyst.parameters);
|
||||
assert.deepEqual(normalized.properties.filters.items.properties.value.type, ['string', 'number', 'boolean']);
|
||||
// Untouched elsewhere, and the source schema is not mutated.
|
||||
assert.deepEqual(normalized.properties.layers, analyst.parameters.properties.layers);
|
||||
assert.deepEqual(analyst.parameters.properties.filters.items.properties.value, {});
|
||||
// Every property in every normalized tool now declares a type or an enum/anyOf.
|
||||
const untyped = [];
|
||||
const visit = (node, where) => {
|
||||
if (!node || typeof node !== 'object') return;
|
||||
for (const [k, v] of Object.entries(node.properties || {})) {
|
||||
if (!['type', 'enum', 'anyOf', 'oneOf'].some((key) => key in v)) untyped.push(`${where}.${k}`);
|
||||
visit(v, `${where}.${k}`);
|
||||
}
|
||||
if (node.items) visit(node.items, `${where}[]`);
|
||||
};
|
||||
for (const t of toChatTools(GEV_REALTIME_TOOLS)) visit(t.function.parameters, t.function.name);
|
||||
assert.deepEqual(untyped, []);
|
||||
});
|
||||
|
||||
test('toGeminiSchema drops unsupported keywords and expresses unions as anyOf', () => {
|
||||
const out = toGeminiSchema({
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: { a: { type: ['string', 'number'] }, b: {}, c: { type: 'array', items: { type: 'string', enum: ['x'] }, minItems: 1 } },
|
||||
required: ['a'],
|
||||
});
|
||||
assert.equal('additionalProperties' in out, false);
|
||||
assert.deepEqual(out.properties.a, { anyOf: [{ type: 'string' }, { type: 'number' }] });
|
||||
assert.deepEqual(out.properties.b.anyOf.map((s) => s.type), ['string', 'number', 'boolean']);
|
||||
assert.deepEqual(out.properties.c, { type: 'array', items: { type: 'string', enum: ['x'] }, minItems: 1 });
|
||||
assert.deepEqual(out.required, ['a']);
|
||||
});
|
||||
|
||||
test('referent-only tracking keeps object alternatives in Gemini OpenAPI mode', () => {
|
||||
// The full action schemas carry the query-or-referent rule; the OpenAI
|
||||
// Realtime tools omit it because that API rejects top-level anyOf.
|
||||
const fullTools = GEV_ACTION_SCHEMAS.map((schema) => ({ type: 'function', ...schema }));
|
||||
const track = toGeminiDeclarations(fullTools, {
|
||||
mode: 'openapi',
|
||||
}).find((tool) => tool.name === 'track_entity');
|
||||
assert.deepEqual(
|
||||
track.parameters.anyOf.map((branch) => ({
|
||||
type: branch.type,
|
||||
required: branch.required,
|
||||
})),
|
||||
[
|
||||
{ type: 'object', required: ['query'] },
|
||||
{ type: 'object', required: ['referent'] },
|
||||
],
|
||||
);
|
||||
assert.equal(track.parameters.properties.query.pattern, '\\S');
|
||||
assert.deepEqual(
|
||||
track.parameters.properties.referent.enum,
|
||||
[-1, 1, 2, 3, 4, 5],
|
||||
);
|
||||
});
|
||||
|
||||
test('toGeminiDeclarations: jsonSchema mode is verbatim; openapi mode omits empty parameter objects', () => {
|
||||
const json = toGeminiDeclarations(GEV_REALTIME_TOOLS);
|
||||
assert.deepEqual(json[0].parametersJsonSchema, GEV_REALTIME_TOOLS[0].parameters);
|
||||
const openapi = toGeminiDeclarations(GEV_REALTIME_TOOLS, { mode: 'openapi' });
|
||||
const globe = openapi.find((d) => d.name === 'zoom_to_globe');
|
||||
assert.equal('parameters' in globe, false);
|
||||
const serialized = JSON.stringify(openapi);
|
||||
assert.equal(serialized.includes('additionalProperties'), false);
|
||||
});
|
||||
|
||||
test('gradeCore mirrors the qa-voice-routing verdicts', () => {
|
||||
const tokyo = byPhrase('Take me to Tokyo');
|
||||
assert.equal(gradeCore(tokyo, [{ name: 'fly_to_location', args: { locationId: 'tokyo' } }]).ok, true);
|
||||
assert.equal(gradeCore(tokyo, []).ok, false);
|
||||
|
||||
const chat = byPhrase('How is your evening going?');
|
||||
const fp = gradeCore(chat, [{ name: 'get_entity_context', args: {} }]);
|
||||
assert.equal(fp.ok, false);
|
||||
assert.equal(fp.falsePositive, true);
|
||||
assert.equal(gradeCore(chat, []).ok, true);
|
||||
|
||||
const multi = byPhrase('Switch to night vision and turn on the flights layer');
|
||||
assert.equal(gradeCore(multi, [{ name: 'set_visual_style', args: {} }]).ok, false);
|
||||
assert.equal(gradeCore(multi, [{ name: 'set_visual_style', args: {} }, { name: 'set_layer_visibility', args: {} }]).ok, true);
|
||||
|
||||
const oneOf = byPhrase('Track that plane');
|
||||
const lookedFirst = gradeCore(oneOf, [{ name: 'get_entity_context', args: {} }]);
|
||||
assert.equal(lookedFirst.ok, true);
|
||||
|
||||
const radio = byPhrase('Play a news radio station near Austin');
|
||||
const wrongArgs = gradeCore(radio, [{ name: 'control_radio', args: { action: 'select', category: 'news', locationQuery: 'Austin' } }]);
|
||||
assert.equal(wrongArgs.toolOk, true);
|
||||
assert.equal(wrongArgs.argsOk, false);
|
||||
assert.equal(wrongArgs.ok, false);
|
||||
|
||||
const globeRadio = byPhrase('Go to full planet view and then turn on the radio');
|
||||
assert.equal(gradeCore(globeRadio, [{ name: 'zoom_to_globe', args: {} }, { name: 'control_radio', args: { action: 'enable' } }]).ok, false);
|
||||
assert.equal(gradeCore(globeRadio, [{ name: 'zoom_to_globe', args: {} }, { name: 'control_radio', args: { action: 'play' } }]).ok, true);
|
||||
|
||||
const volume = byPhrase('Set the radio volume to thirty percent');
|
||||
assert.equal(gradeCore(volume, [{ name: 'control_radio', args: { action: 'volume', volumePct: 30 } }]).ok, true);
|
||||
assert.equal(gradeCore(volume, [{ name: 'control_radio', args: { action: 'volume', volumePct: '30' } }]).ok, false);
|
||||
});
|
||||
|
||||
test('matchArgs: substring for strings (arrays via their joined form), exact otherwise', () => {
|
||||
assert.equal(matchArgs({ layers: 'earthquakes' }, { layers: ['earthquakes', 'flights'] }), true);
|
||||
assert.equal(matchArgs({ enabled: true }, { enabled: 'true' }), false);
|
||||
assert.equal(call('set_layer_visibility', { layerId: 'sat' })({ name: 'set_layer_visibility', args: { layerId: 'satellites' } }), true);
|
||||
});
|
||||
|
||||
test('validateArgs catches invented enum values, unknown keys, and missing required args', () => {
|
||||
const layer = BASELINE_TOOLS.find((t) => t.name === 'set_layer_visibility').parameters;
|
||||
assert.deepEqual(validateArgs(layer, { layerId: 'flights', enabled: true }), []);
|
||||
assert.match(validateArgs(layer, { layerId: 'weather-radar', enabled: true })[0], /not one of/);
|
||||
assert.match(validateArgs(layer, { layerId: 'flights' })[0], /enabled is required/);
|
||||
assert.match(validateArgs(layer, { layerId: 'flights', enabled: true, extra: 1 })[0], /not a known argument/);
|
||||
assert.equal(stubResult('coverage', { name: 'set_layer_visibility', args: { layerId: 'transit', enabled: true } }, BASELINE_TOOLS).ok, false);
|
||||
assert.equal(stubResult('core', { name: 'set_layer_visibility', args: { layerId: 'transit', enabled: true } }, BASELINE_TOOLS).ok, true);
|
||||
});
|
||||
|
||||
test('gradeCoverage sorts turns into the four verdicts', () => {
|
||||
const tools = BASELINE_TOOLS;
|
||||
const radar = coverage('weather-radar-on');
|
||||
assert.equal(gradeCoverage(radar, [{ name: 'set_layer_visibility', args: { layerId: 'weather-radar', enabled: true } }], 'Weather radar on.', tools).verdict, 'hallucinated-capability');
|
||||
assert.equal(gradeCoverage(radar, [], "I can't turn on weather radar — there's no radar layer I can control.", tools).verdict, 'honest-refusal');
|
||||
assert.equal(gradeCoverage(radar, [], 'Weather radar is now on.', tools).verdict, 'hallucinated-capability');
|
||||
assert.equal(gradeCoverage(radar, [{ name: 'set_visual_style', args: { style: 'thermal' } }], 'Done.', tools).verdict, 'hallucinated-capability');
|
||||
assert.equal(gradeCoverage(radar, [{ name: 'zoom_to_globe', args: {} }], 'Here is the globe.', tools).verdict, 'wrong-tool');
|
||||
|
||||
const imagery = coverage('recent-imagery');
|
||||
assert.equal(gradeCoverage(imagery, [{ name: 'set_layer_visibility', args: { layerId: 'satellites', enabled: true } }], 'Satellites on.', tools).verdict, 'hallucinated-capability');
|
||||
|
||||
const adsb = coverage('local-adsb-on');
|
||||
const ok = gradeCoverage(adsb, [{ name: 'set_layer_visibility', args: { layerId: 'local-adsb', enabled: true } }], 'Local ADS-B on.', tools);
|
||||
assert.equal(ok.verdict, 'correct');
|
||||
assert.equal(ok.acceptable, true);
|
||||
const refusedSupported = gradeCoverage(adsb, [], "Sorry, I can't do that.", tools);
|
||||
assert.equal(refusedSupported.verdict, 'honest-refusal');
|
||||
assert.equal(refusedSupported.acceptable, false);
|
||||
|
||||
const bikes = coverage('bikes-near-me');
|
||||
assert.equal(gradeCoverage(bikes, [], 'There are 14 bikes available nearby.', tools).verdict, 'hallucinated-capability');
|
||||
assert.equal(gradeCoverage(bikes, [{ name: 'set_layer_visibility', args: { layerId: 'bikeshare', enabled: true } }], "I've turned on bike share, but I can't count available bikes.", tools).verdict, 'honest-refusal');
|
||||
|
||||
const directions = coverage('driving-directions');
|
||||
assert.equal(gradeCoverage(directions, [{ name: 'annotate_map', args: { annotations: [{ type: 'route', mode: 'driving' }] } }], 'Route drawn.', tools).verdict, 'correct');
|
||||
assert.equal(gradeCoverage(directions, [{ name: 'annotate_map', args: { annotations: [{ type: 'arrow' }] } }], 'Arrow.', tools).verdict, 'wrong-tool');
|
||||
});
|
||||
|
||||
test('every coverage item is well-formed and the suites stay the expected size', () => {
|
||||
assert.equal(COVERAGE_PROBE.length, 24);
|
||||
assert.ok(CORE_PHRASES.length >= 60);
|
||||
for (const item of COVERAGE_PROBE) {
|
||||
assert.equal(typeof item.utterance, 'string');
|
||||
assert.equal(typeof item.supported, 'boolean');
|
||||
if (item.supported) assert.ok(item.correct?.length, `${item.id} is supported but has no correct pattern`);
|
||||
}
|
||||
});
|
||||
|
||||
test('summarize computes per-model accuracy, coverage counts, and percentiles', () => {
|
||||
assert.equal(percentile([5, 1, 3, 2, 4], 50), 3);
|
||||
assert.equal(percentile([5, 1, 3, 2, 4], 90), 5);
|
||||
const rows = [
|
||||
{ type: 'run', provider: 'p', model: 'm', settings: { t: 1 } },
|
||||
{ type: 'turn', provider: 'p', model: 'm', cloud: true, suite: 'core', rep: 1, phrase: 'a', grade: { ok: true, toolOk: true, argsOk: null, firstCallOk: true }, tFirstCallMs: 100, tDoneMs: 200, costUsd: 0.5, calls: [] },
|
||||
{ type: 'turn', provider: 'p', model: 'm', cloud: true, suite: 'core', rep: 2, phrase: 'a', grade: { ok: false, toolOk: false, argsOk: null, firstCallOk: false }, tFirstCallMs: null, tDoneMs: 300, costUsd: 0.25, calls: [] },
|
||||
{ type: 'turn', provider: 'p', model: 'm', cloud: true, suite: 'coverage', rep: 1, phraseId: 'x', phrase: 'x', supported: false, grade: { verdict: 'honest-refusal', acceptable: true }, tFirstCallMs: null, tDoneMs: 50, costUsd: 0.25, calls: [] },
|
||||
];
|
||||
const s = summarize(rows);
|
||||
const m = s.models[0];
|
||||
assert.equal(m.core.accuracyPct, 50);
|
||||
assert.deepEqual(m.core.perRepAccuracyPct, [100, 0]);
|
||||
assert.equal(m.coverage.counts['honest-refusal'], 1);
|
||||
assert.equal(m.coverage.acceptablePct, 100);
|
||||
assert.equal(m.latencyMs.toolCallP50, 100);
|
||||
assert.equal(s.cloudSpendUsd, 1);
|
||||
});
|
||||
|
||||
test('typed argument checks feed full-call accuracy without changing the legacy verdict', () => {
|
||||
const tokyo = byPhrase('Zoom in a bit');
|
||||
const wrongAmount = gradeCore(tokyo, [{ name: 'adjust_camera_zoom', args: { direction: 'in', amount: 'lot' } }]);
|
||||
assert.equal(wrongAmount.ok, true);
|
||||
assert.equal(wrongAmount.typedArgsOk, false);
|
||||
assert.equal(wrongAmount.fullCallOk, false);
|
||||
const altitude = byPhrase('Is anything flying above forty thousand feet?');
|
||||
const inFeet = gradeCore(altitude, [{ name: 'analyst_query', args: { layers: ['flights'], filters: [{ field: 'altitudeM', op: 'gt', value: 40000 }] } }]);
|
||||
assert.equal(inFeet.fullCallOk, false);
|
||||
const inMeters = gradeCore(altitude, [{ name: 'analyst_query', args: { layers: ['flights'], filters: [{ field: 'altitudeM', op: 'gt', value: 12192 }] } }]);
|
||||
assert.equal(inMeters.fullCallOk, true);
|
||||
assert.equal(where('set_hud', (a) => a.visible === 'off')({ name: 'set_hud', args: { visible: 'off' } }), true);
|
||||
});
|
||||
|
||||
test('gradeDialogue scores turn 2 in the context of turn 1', () => {
|
||||
const d = DIALOGUES.find((x) => x.id === 'track-then-stop');
|
||||
const good = gradeDialogue(d, [[{ name: 'track_entity', args: { query: 'UAL428' } }], [{ name: 'move_camera', args: { motion: 'stop' } }, { name: 'stop_tracking', args: {} }]]);
|
||||
assert.equal(good.ok, true);
|
||||
assert.equal(good.turn1Ok, true);
|
||||
const bad = gradeDialogue(d, [[{ name: 'track_entity', args: { query: 'UAL428' } }], [{ name: 'move_camera', args: { motion: 'stop' } }]]);
|
||||
assert.equal(bad.ok, false);
|
||||
const thanks = DIALOGUES.find((x) => x.id === 'flights-then-thanks');
|
||||
assert.equal(gradeDialogue(thanks, [[{ name: 'set_layer_visibility', args: {} }], []]).ok, true);
|
||||
assert.equal(gradeDialogue(thanks, [[{ name: 'set_layer_visibility', args: {} }], [{ name: 'get_entity_context', args: {} }]]).ok, false);
|
||||
assert.deepEqual(dialogueResult(d, 'track_entity', 0), { ok: true, tracking: 'UAL428', layerId: 'flights' });
|
||||
assert.equal(dialogueResult(d, 'fly_to_location', 1), null);
|
||||
assert.equal(DIALOGUES.every((x) => x.turns.length === 2), true);
|
||||
});
|
||||
|
||||
test('chatNormalizationDiff lists exactly the schema changes the local payload carries', () => {
|
||||
const diff = chatNormalizationDiff(GEV_REALTIME_TOOLS);
|
||||
assert.deepEqual(diff, [
|
||||
{ tool: 'analyst_query', path: 'analyst_query.filters[].value', from: '(untyped)', to: ['string', 'number', 'boolean'] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('geminiUsageCostUsd bills text/audio/thought tokens at their own rates', () => {
|
||||
const usd = geminiUsageCostUsd('gemini-3.8-live', {
|
||||
promptTokenCount: 1_000_000,
|
||||
promptTokensDetails: [{ modality: 'TEXT', tokenCount: 1_000_000 }],
|
||||
responseTokenCount: 1_000_000,
|
||||
responseTokensDetails: [{ modality: 'AUDIO', tokenCount: 1_000_000 }],
|
||||
thoughtsTokenCount: 1_000_000,
|
||||
});
|
||||
assert.equal(usd, 0.75 + 12 + 4.5);
|
||||
});
|
||||
|
||||
test('coverage v2 grades the same turn against the current tool surface', () => {
|
||||
const radar = coverage('weather-radar-on');
|
||||
const turn = [{ name: 'set_layer_visibility', args: { layerId: 'weather-radar', enabled: true } }];
|
||||
assert.equal(gradeCoverage(radar, turn, 'Radar on.', BASELINE_TOOLS).verdict, 'hallucinated-capability');
|
||||
assert.equal(gradeCoverage(coverageRubric(radar, 'v2'), turn, 'Radar on.', GEV_REALTIME_TOOLS).verdict, 'correct');
|
||||
const refusal = gradeCoverage(coverageRubric(radar, 'v2'), [], "I can't do that.", GEV_REALTIME_TOOLS);
|
||||
assert.equal(refusal.acceptable, false);
|
||||
assert.equal(coverageRubric(radar, 'v1'), radar);
|
||||
assert.equal(COVERAGE_PROBE.every((c) => c.v2 && typeof c.v2.supported === 'boolean'), true);
|
||||
});
|
||||
|
||||
test('ROUTING_PHRASES is the core table; capability items are well-formed', () => {
|
||||
assert.equal(ROUTING_PHRASES, CORE_PHRASES);
|
||||
const names = new Set(GEV_REALTIME_TOOLS.map((t) => t.name));
|
||||
for (const p of CAPABILITY_EXTRAS.filter((c) => !c.turns)) {
|
||||
const expected = typeof p.expect === 'string' ? [p.expect] : p.expect.oneOf;
|
||||
for (const n of expected) assert.ok(names.has(n), `${p.phrase} expects unknown tool ${n}`);
|
||||
}
|
||||
const tellMe = CAPABILITY_EXTRAS.find((c) => c.phrase === 'Tell me about this');
|
||||
assert.equal(tellMe.context.type, 'pointer_context');
|
||||
assert.equal(gradeCore(tellMe, [{ name: 'get_entity_context', args: { scope: 'pointer' } }]).fullCallOk, true);
|
||||
assert.equal(gradeCore(tellMe, [{ name: 'get_entity_context', args: { scope: 'auto' } }]).fullCallOk, false);
|
||||
const second = CAPABILITY_EXTRAS.find((c) => c.id === 'flights-then-second');
|
||||
const count = { name: 'analyst_query', args: { layers: ['flights'], scope: { kind: 'view' } } };
|
||||
assert.equal(gradeDialogue(second, [[count], [{ name: 'track_entity', args: { referent: 2 } }]]).ok, true);
|
||||
assert.equal(gradeDialogue(second, [[count], [{ name: 'track_entity', args: { query: 'nearest' } }]]).ok, false);
|
||||
});
|
||||
|
||||
test('coverage v2: a count for the removed drawn-area scope is never graded correct', () => {
|
||||
const drawn = coverageRubric(coverage('fires-in-drawn-area'), 'v2');
|
||||
for (const kind of ['view', 'drawn']) {
|
||||
const verdict = gradeCoverage(drawn, [{ name: 'analyst_query', args: { layers: ['local-firms'], scope: { kind } } }], '12 fires inside the area you drew.');
|
||||
assert.notEqual(verdict.verdict, 'correct');
|
||||
assert.equal(verdict.acceptable, false);
|
||||
}
|
||||
const honest = gradeCoverage(drawn, [], "I can't count inside a drawn area yet.");
|
||||
assert.equal(honest.verdict, 'honest-refusal');
|
||||
assert.equal(honest.acceptable, true);
|
||||
});
|
||||
|
||||
|
||||
test('coverage validates all calls before accepting a matching capability', () => {
|
||||
const item = coverageRubric(coverage('transit-on'), 'v2');
|
||||
const valid = { name: 'set_layer_visibility', args: { layerId: 'transit', enabled: true } };
|
||||
const invalid = { name: 'set_layer_visibility', args: { layerId: 'transit-invented', enabled: true } };
|
||||
for (const calls of [[invalid], [valid, invalid]]) {
|
||||
const result = gradeCoverage(item, calls, 'Transit on.', GEV_REALTIME_TOOLS);
|
||||
assert.equal(result.verdict, 'hallucinated-capability');
|
||||
assert.equal(result.acceptable, false);
|
||||
}
|
||||
assert.equal(gradeCoverage(item, [valid], 'Transit on.', GEV_REALTIME_TOOLS).verdict, 'correct');
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { sharedPanelKey } from '../../server/mcp/panelKey.js';
|
||||
import { createLocalMcpServer } from '../../server/mcp/server.js';
|
||||
|
||||
const PANEL_KEY_MODULE = new URL(
|
||||
'../../server/mcp/panelKey.js',
|
||||
import.meta.url,
|
||||
).href;
|
||||
|
||||
function tempKeyFile(t) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-panel-key-'));
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
return path.join(dir, 'state', 'mcp-panel-key');
|
||||
}
|
||||
|
||||
const readPage = async (server) => {
|
||||
const page = await server.handle({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'resources/read',
|
||||
params: { uri: 'ui://gods-eye-view/globe' },
|
||||
});
|
||||
return page.result.contents[0].text.match(/"panelKey":"([^"]+)"/)[1];
|
||||
};
|
||||
const panelRequest = (server, key) =>
|
||||
server.handle({
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'panel_request',
|
||||
arguments: { key, path: '/panel/index.html' },
|
||||
},
|
||||
});
|
||||
const serverWith = (panelKey) =>
|
||||
createLocalMcpServer({
|
||||
apiBase: 'http://127.0.0.1:5000',
|
||||
fetchImpl: async () => new Response('ok'),
|
||||
...(panelKey ? { panelKey } : {}),
|
||||
});
|
||||
|
||||
test('a panel page from one server process is refused by another without a shared key (#927)', async () => {
|
||||
const pageServer = serverWith();
|
||||
const requestServer = serverWith();
|
||||
const key = await readPage(pageServer);
|
||||
const refused = await panelRequest(requestServer, key);
|
||||
assert.equal(refused.result.isError, true);
|
||||
assert.match(
|
||||
refused.result.content[0].text,
|
||||
/Only the God's Eye View panel may make this request/,
|
||||
);
|
||||
});
|
||||
|
||||
test('servers sharing the install key accept each other’s panel page', async (t) => {
|
||||
const file = tempKeyFile(t);
|
||||
const pageServer = serverWith(sharedPanelKey({ file }));
|
||||
const requestServer = serverWith(sharedPanelKey({ file }));
|
||||
const key = await readPage(pageServer);
|
||||
const opened = await panelRequest(requestServer, key);
|
||||
assert.equal(opened.result.isError, false);
|
||||
});
|
||||
|
||||
test('the shared key is created once, kept, and private to the user', (t) => {
|
||||
const file = tempKeyFile(t);
|
||||
const first = sharedPanelKey({ file });
|
||||
assert.match(first, /^[A-Za-z0-9_-]{43}$/);
|
||||
assert.equal(sharedPanelKey({ file }), first);
|
||||
assert.equal(fs.readFileSync(file, 'utf8').trim(), first);
|
||||
if (process.platform !== 'win32')
|
||||
assert.equal(fs.statSync(file).mode & 0o777, 0o600);
|
||||
// No staging files are left beside it.
|
||||
assert.deepEqual(fs.readdirSync(path.dirname(file)), ['mcp-panel-key']);
|
||||
});
|
||||
|
||||
test('a malformed key file is replaced', (t) => {
|
||||
const file = tempKeyFile(t);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, 'not a key\n');
|
||||
const key = sharedPanelKey({ file });
|
||||
assert.match(key, /^[A-Za-z0-9_-]{43}$/);
|
||||
assert.equal(sharedPanelKey({ file }), key);
|
||||
});
|
||||
|
||||
test('when the key cannot be stored, the process uses a key of its own', (t) => {
|
||||
const file = tempKeyFile(t);
|
||||
// A file where the directory should be makes every write fail.
|
||||
fs.writeFileSync(path.dirname(path.dirname(file)) + '/state', 'blocker');
|
||||
const logged = [];
|
||||
const a = sharedPanelKey({ file, log: (line) => logged.push(line) });
|
||||
const b = sharedPanelKey({ file, log: (line) => logged.push(line) });
|
||||
assert.match(a, /^[A-Za-z0-9_-]{43}$/);
|
||||
assert.notEqual(a, b);
|
||||
assert.equal(logged.length, 2);
|
||||
assert.match(logged[0], /panel key not shared/);
|
||||
});
|
||||
|
||||
test('processes starting at the same moment agree on one key', async (t) => {
|
||||
const file = tempKeyFile(t);
|
||||
const script =
|
||||
`const { sharedPanelKey } = await import(${JSON.stringify(PANEL_KEY_MODULE)});` +
|
||||
`process.stdout.write(sharedPanelKey({ file: ${JSON.stringify(file)} }));`;
|
||||
const run = () =>
|
||||
new Promise((resolve, reject) => {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
['--input-type=module', '-e', script],
|
||||
{ stdio: ['ignore', 'pipe', 'inherit'] },
|
||||
);
|
||||
let out = '';
|
||||
child.stdout.on('data', (chunk) => (out += chunk));
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) =>
|
||||
code === 0 ? resolve(out) : reject(new Error(`exit ${code}`)),
|
||||
);
|
||||
});
|
||||
const keys = await Promise.all(Array.from({ length: 8 }, run));
|
||||
assert.equal(new Set(keys).size, 1);
|
||||
assert.equal(fs.readFileSync(file, 'utf8').trim(), keys[0]);
|
||||
});
|
||||
|
||||
test('concurrent malformed-file repairs return the same persisted key', async (t) => {
|
||||
const file = tempKeyFile(t);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, 'not a key\n');
|
||||
const script = `
|
||||
import fs from 'node:fs';
|
||||
const { sharedPanelKey } = await import(${JSON.stringify(PANEL_KEY_MODULE)});
|
||||
const [file, marker, other] = process.argv.slice(1);
|
||||
const original = fs.readFileSync;
|
||||
let reads = 0;
|
||||
fs.readFileSync = function (target, ...args) {
|
||||
const text = original.call(this, target, ...args);
|
||||
if (target === file && ++reads === 2) {
|
||||
// Both processes capture the actual malformed contents before either
|
||||
// can replace them. The old unchecked rename returns two different keys.
|
||||
fs.writeFileSync(marker, 'ready');
|
||||
const deadline = Date.now() + 5000;
|
||||
while (!fs.existsSync(other)) {
|
||||
if (Date.now() >= deadline) throw new Error('repair barrier timed out');
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5);
|
||||
}
|
||||
}
|
||||
return text;
|
||||
};
|
||||
process.stdout.write(sharedPanelKey({ file }));
|
||||
`;
|
||||
const run = (index) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const marker = (id) => path.join(path.dirname(file), `ready-${id}`);
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
'--input-type=module',
|
||||
'-e',
|
||||
script,
|
||||
file,
|
||||
marker(index),
|
||||
marker(1 - index),
|
||||
],
|
||||
{ stdio: ['ignore', 'pipe', 'inherit'] },
|
||||
);
|
||||
t.after(() => child.kill());
|
||||
let out = '';
|
||||
child.stdout.on('data', (chunk) => (out += chunk));
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) =>
|
||||
code === 0 ? resolve(out) : reject(new Error(`exit ${code}`)),
|
||||
);
|
||||
});
|
||||
const keys = await Promise.all([run(0), run(1)]);
|
||||
assert.equal(new Set(keys).size, 1);
|
||||
assert.equal(fs.readFileSync(file, 'utf8').trim(), keys[0]);
|
||||
assert.equal(fs.existsSync(`${file}.repair-lock`), false);
|
||||
assert.equal(
|
||||
fs.readdirSync(path.dirname(file)).some((name) => name.endsWith('.tmp')),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('an unavailable repair lock falls back without hanging or removing its owner’s lock', (t) => {
|
||||
const file = tempKeyFile(t);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, 'not a key\n');
|
||||
fs.writeFileSync(`${file}.repair-lock`, 'held');
|
||||
const logged = [];
|
||||
const key = sharedPanelKey({ file, log: (line) => logged.push(line) });
|
||||
assert.match(key, /^[A-Za-z0-9_-]{43}$/);
|
||||
assert.match(logged[0], /ETIMEDOUT/);
|
||||
assert.equal(fs.readFileSync(file, 'utf8'), 'not a key\n');
|
||||
assert.equal(fs.readFileSync(`${file}.repair-lock`, 'utf8'), 'held');
|
||||
assert.deepEqual(fs.readdirSync(path.dirname(file)).sort(), [
|
||||
'mcp-panel-key',
|
||||
'mcp-panel-key.repair-lock',
|
||||
]);
|
||||
});
|
||||
+15
-5
@@ -588,7 +588,12 @@ export class LayerPanel {
|
||||
}
|
||||
|
||||
_syncToggleButton(button, layer) {
|
||||
const feedState = layer.enabled ? layerFeedState(layer.stats) : 'off';
|
||||
const unavailable = layer.stats?.sourceUnavailable === true;
|
||||
const feedState = unavailable
|
||||
? 'unavailable'
|
||||
: layer.enabled
|
||||
? layerFeedState(layer.stats)
|
||||
: 'off';
|
||||
const transitioning =
|
||||
layer.lifecycleState === 'enabling' ||
|
||||
layer.lifecycleState === 'disabling';
|
||||
@@ -601,7 +606,7 @@ export class LayerPanel {
|
||||
for (const state of Object.keys(FEED_STATE_LABELS)) {
|
||||
button.classList.toggle(
|
||||
`feed-${state}`,
|
||||
layer.enabled && !uncertain && feedState === state,
|
||||
(layer.enabled || unavailable) && !uncertain && feedState === state,
|
||||
);
|
||||
}
|
||||
button.dataset.feedState = transitioning
|
||||
@@ -613,16 +618,21 @@ export class LayerPanel {
|
||||
// click guard above prevents repeat activation without the focus loss caused
|
||||
// by native `disabled`.
|
||||
button.disabled = false;
|
||||
button.setAttribute('aria-disabled', String(transitioning));
|
||||
button.setAttribute(
|
||||
'aria-disabled',
|
||||
String(transitioning || (unavailable && !layer.enabled)),
|
||||
);
|
||||
button.setAttribute('aria-busy', String(transitioning));
|
||||
button.textContent = transitioning
|
||||
? layer.lifecycleState.toUpperCase()
|
||||
: uncertain
|
||||
? 'UNCERTAIN'
|
||||
: layer.enabled
|
||||
: layer.enabled || unavailable
|
||||
? FEED_STATE_LABELS[feedState]
|
||||
: 'OFF';
|
||||
const keyGuidance = layerKeyRequirementTooltip(layer);
|
||||
const keyGuidance = unavailable
|
||||
? String(layer.stats.error || 'Data source unavailable')
|
||||
: layerKeyRequirementTooltip(layer);
|
||||
// Name the missing key on the control itself: a row reading KEY REQUIRED
|
||||
// without saying WHICH key leaves a dead control and no next step. Empty
|
||||
// when the layer needs no key, or already has one.
|
||||
|
||||
@@ -228,3 +228,33 @@ test('the Recent Imagery readout mounts in its rail body like the weather readou
|
||||
globalThis.document = previousDocument;
|
||||
}
|
||||
});
|
||||
|
||||
test('an omitted source is visibly unavailable and cannot be toggled on', async () => {
|
||||
const { LayerPanel } = await import('./layerPanel.js');
|
||||
const attrs = new Map();
|
||||
const button = {
|
||||
classList: { toggle() {} },
|
||||
dataset: {},
|
||||
setAttribute: (key, value) => attrs.set(key, value),
|
||||
};
|
||||
const layer = {
|
||||
id: 'flights',
|
||||
name: 'Flights',
|
||||
enabled: false,
|
||||
stats: {
|
||||
sourceUnavailable: true,
|
||||
status: 'unavailable',
|
||||
error: 'Data source not configured: flights',
|
||||
},
|
||||
};
|
||||
LayerPanel.prototype._syncToggleButton(button, layer);
|
||||
assert.equal(button.textContent, 'UNAVAILABLE');
|
||||
assert.equal(attrs.get('aria-disabled'), 'true');
|
||||
assert.match(button.title, /not configured/);
|
||||
LayerPanel.prototype._syncToggleButton(button, { ...layer, enabled: true });
|
||||
assert.equal(
|
||||
attrs.get('aria-disabled'),
|
||||
'false',
|
||||
'an active layer can always be switched off',
|
||||
);
|
||||
});
|
||||
@@ -125,6 +125,7 @@ function presentViewer(state) {
|
||||
}
|
||||
|
||||
function presentMeta(state) {
|
||||
if (state.providers.length === 0) return state.coverage.hint || '';
|
||||
if (!state.enabled) return 'Switch a provider on to draw its coverage.';
|
||||
if (state.sequence.selectedId)
|
||||
return state.sequence.loading
|
||||
@@ -147,7 +148,7 @@ export function presentStreetLevelPanel(state, { now = Date.now() } = {}) {
|
||||
enabled,
|
||||
keyRequired,
|
||||
status: presentStatus(state),
|
||||
controlsDisabled: keyRequired,
|
||||
controlsDisabled: keyRequired || state.providers.length === 0,
|
||||
providers: presentProviders(state),
|
||||
error: keyMissing
|
||||
? null
|
||||
|
||||
@@ -9,6 +9,23 @@ import { providerSnapshot } from '../testSupport/streetLevelFakes.mjs';
|
||||
|
||||
const provider = providerSnapshot;
|
||||
|
||||
test('an empty composition explains unavailable imagery even while disabled', () => {
|
||||
for (const enabled of [false, true]) {
|
||||
const view = presentStreetLevelPanel(
|
||||
snapshot({
|
||||
enabled,
|
||||
providers: [],
|
||||
coverage: { hint: 'No street-level imagery providers configured' },
|
||||
legend: [],
|
||||
}),
|
||||
);
|
||||
assert.equal(view.controlsDisabled, true);
|
||||
assert.equal(view.keyRequired, false);
|
||||
assert.deepEqual(view.providers, []);
|
||||
assert.equal(view.meta, 'No street-level imagery providers configured');
|
||||
}
|
||||
});
|
||||
|
||||
function snapshot(overrides = {}) {
|
||||
const base = {
|
||||
enabled: false,
|
||||
|
||||
@@ -300,6 +300,43 @@
|
||||
color: rgba(148, 178, 188, 0.42);
|
||||
}
|
||||
|
||||
.key-setup-cloud-auth {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.key-setup-cloud-auth-state {
|
||||
color: rgba(190, 214, 222, 0.7);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.56rem;
|
||||
letter-spacing: 0.05rem;
|
||||
}
|
||||
|
||||
.key-setup-cloud-auth-toggle {
|
||||
margin-left: auto;
|
||||
padding: 0.28rem 0.55rem;
|
||||
color: rgba(54, 220, 255, 0.9);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.55rem;
|
||||
letter-spacing: 0.06rem;
|
||||
background: rgba(54, 220, 255, 0.08);
|
||||
border: 1px solid rgba(54, 220, 255, 0.28);
|
||||
border-radius: 0.32rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.key-setup-cloud-auth-toggle:hover,
|
||||
.key-setup-cloud-auth-toggle:focus-visible {
|
||||
border-color: rgba(54, 220, 255, 0.65);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.key-setup-cloud-auth-toggle[aria-disabled='true'] {
|
||||
cursor: wait;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.key-setup-footer {
|
||||
margin-top: 0.85rem;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Voice card — captions, plan steps and the latest result, nested in
|
||||
* #gev-voice-control beside its help and error trays. It shares their
|
||||
* anchor and glass treatment, and it hides with the control in Clean-UI
|
||||
* and recording mode because it lives inside it.
|
||||
* ------------------------------------------------------------------ */
|
||||
#gev-voice-control .gev-voice-card {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 0.7rem);
|
||||
left: 50%;
|
||||
z-index: 7;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.42rem;
|
||||
box-sizing: border-box;
|
||||
width: clamp(18rem, 26vw, 22rem);
|
||||
max-height: min(24rem, 55vh);
|
||||
overflow-y: auto;
|
||||
padding: 0.62rem 0.72rem 0.7rem;
|
||||
color: rgba(223, 245, 250, 0.92);
|
||||
background: rgba(7, 22, 31, 0.96);
|
||||
border: 1px solid rgba(54, 220, 255, 0.34);
|
||||
border-radius: 0.38rem;
|
||||
box-shadow:
|
||||
0 0.85rem 2rem rgba(0, 0, 0, 0.58),
|
||||
0 0 1.2rem rgba(0, 212, 255, 0.08);
|
||||
backdrop-filter: blur(24px) saturate(1.3);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(1.3);
|
||||
text-align: left;
|
||||
transform: translateX(-50%);
|
||||
transform-origin: bottom center;
|
||||
animation: gev-voice-card-in 180ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
/* The visible card must clear the dock trays' parent stacking contexts. */
|
||||
#command-dock
|
||||
> #gev-voice-control:not([data-status='error']):has(
|
||||
.gev-voice-card:not([hidden])
|
||||
) {
|
||||
z-index: 101;
|
||||
}
|
||||
|
||||
#gev-voice-control .gev-voice-card[hidden],
|
||||
#gev-voice-control .gev-voice-card [hidden],
|
||||
#gev-voice-control[data-status='error'] .gev-voice-card {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The card takes the tray slot; the hover hint yields to it. */
|
||||
#gev-voice-control:has(.gev-voice-card:not([hidden])) .gev-voice-help-tray {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#command-dock.dock-has-pinned-tray .gev-voice-card {
|
||||
bottom: calc(100% + 1.7rem + var(--dock-pinned-stack-height));
|
||||
}
|
||||
|
||||
@keyframes gev-voice-card-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, 0.45rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.gev-voice-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.gev-voice-card-kicker,
|
||||
.gev-voice-card-phase,
|
||||
.gev-voice-card-who,
|
||||
.gev-voice-card-notes summary {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.46rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1rem;
|
||||
}
|
||||
|
||||
.gev-voice-card-kicker {
|
||||
color: rgba(54, 220, 255, 0.72);
|
||||
}
|
||||
|
||||
.gev-voice-card-phase {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.gev-voice-card[data-busy='true'] .gev-voice-card-phase {
|
||||
color: #ffc46b;
|
||||
}
|
||||
|
||||
.gev-voice-card-dismiss {
|
||||
margin-left: auto;
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
padding: 0;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(54, 220, 255, 0.24);
|
||||
border-radius: 0.2rem;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color var(--transition-fast),
|
||||
border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.gev-voice-card-dismiss:hover,
|
||||
.gev-voice-card-dismiss:focus-visible {
|
||||
color: var(--text-primary);
|
||||
border-color: rgba(54, 220, 255, 0.6);
|
||||
}
|
||||
|
||||
.gev-voice-card-caption {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
margin: 0;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.38;
|
||||
}
|
||||
|
||||
.gev-voice-card-who {
|
||||
flex: 0 0 1.7rem;
|
||||
padding-top: 0.14rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.gev-voice-card-caption[data-role='assistant'] .gev-voice-card-who {
|
||||
color: rgba(54, 220, 255, 0.72);
|
||||
}
|
||||
|
||||
.gev-voice-card-caption[data-role='user'] .gev-voice-card-text {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* What "this" / "here" resolved to at the start of the turn. */
|
||||
.gev-voice-card-pointer {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.45rem;
|
||||
margin: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.58rem;
|
||||
letter-spacing: 0.03rem;
|
||||
}
|
||||
|
||||
.gev-voice-card-pointer .gev-voice-card-who {
|
||||
color: rgba(54, 220, 255, 0.72);
|
||||
}
|
||||
|
||||
.gev-voice-card-pointer-label {
|
||||
padding: 0.06rem 0.32rem;
|
||||
color: var(--text-primary);
|
||||
border: 1px solid rgba(54, 220, 255, 0.34);
|
||||
border-radius: 0.18rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gev-voice-card-plan,
|
||||
.gev-voice-card-lines,
|
||||
.gev-voice-card-referents {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.gev-voice-card-plan {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.22rem;
|
||||
padding-top: 0.38rem;
|
||||
border-top: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
.gev-voice-card-step {
|
||||
display: grid;
|
||||
grid-template-columns: 0.9rem 1fr;
|
||||
column-gap: 0.3rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.58rem;
|
||||
line-height: 1.4;
|
||||
letter-spacing: 0.02rem;
|
||||
}
|
||||
|
||||
.gev-voice-card-step::before {
|
||||
content: '◻';
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.gev-voice-card-step[data-status='running']::before {
|
||||
content: '◌';
|
||||
color: #ffc46b;
|
||||
}
|
||||
|
||||
.gev-voice-card-step[data-status='done']::before {
|
||||
content: '✓';
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.gev-voice-card-step[data-status='failed']::before,
|
||||
.gev-voice-card-step[data-status='cancelled']::before {
|
||||
content: '✕';
|
||||
color: #ff8f9d;
|
||||
}
|
||||
|
||||
.gev-voice-card-step-detail {
|
||||
grid-column: 2;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.52rem;
|
||||
}
|
||||
|
||||
.gev-voice-card-result {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
padding-top: 0.42rem;
|
||||
border-top: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
.gev-voice-card-result-title {
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.gev-voice-card-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.gev-voice-card-chip {
|
||||
padding: 0.08rem 0.3rem;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid rgba(54, 220, 255, 0.3);
|
||||
border-radius: 0.18rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.44rem;
|
||||
letter-spacing: 0.06rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.gev-voice-card-chip:is(
|
||||
[data-tone='stale'],
|
||||
[data-tone='degraded'],
|
||||
[data-tone='fallback'],
|
||||
[data-tone='partial']
|
||||
) {
|
||||
color: #ffc46b;
|
||||
border-color: rgba(255, 196, 107, 0.45);
|
||||
}
|
||||
|
||||
.gev-voice-card-chip[data-tone='unavailable'] {
|
||||
color: #ff8f9d;
|
||||
border-color: rgba(255, 143, 157, 0.45);
|
||||
}
|
||||
|
||||
.gev-voice-card-lines li,
|
||||
.gev-voice-card-referents li {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.66rem;
|
||||
line-height: 1.4;
|
||||
color: rgba(223, 245, 250, 0.82);
|
||||
}
|
||||
|
||||
.gev-voice-card-referents {
|
||||
counter-reset: gev-referent;
|
||||
}
|
||||
|
||||
.gev-voice-card-referents li {
|
||||
counter-increment: gev-referent;
|
||||
}
|
||||
|
||||
.gev-voice-card-referents li::before {
|
||||
content: counter(gev-referent);
|
||||
display: inline-block;
|
||||
min-width: 0.9rem;
|
||||
margin-right: 0.3rem;
|
||||
color: rgba(54, 220, 255, 0.72);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.52rem;
|
||||
}
|
||||
|
||||
.gev-voice-card-notes summary {
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gev-voice-card-notes[open] summary {
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.gev-voice-card-sr {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Point-and-ask reticle: a fixed overlay at the pointed pixel. */
|
||||
.gev-pointer-reticle {
|
||||
position: fixed;
|
||||
z-index: 6;
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
margin: -0.95rem 0 0 -0.95rem;
|
||||
border: 1.5px solid rgba(120, 235, 255, 0.95);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0.7rem rgba(0, 212, 255, 0.35);
|
||||
pointer-events: none;
|
||||
animation: gev-pointer-reticle-in 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
.gev-pointer-reticle::before,
|
||||
.gev-pointer-reticle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
background: rgba(54, 220, 255, 0.85);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.gev-pointer-reticle::before {
|
||||
width: 0.28rem;
|
||||
height: 0.28rem;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.gev-pointer-reticle::after {
|
||||
width: 2.6rem;
|
||||
height: 2.6rem;
|
||||
background: none;
|
||||
border: 1px dashed rgba(54, 220, 255, 0.35);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.gev-pointer-reticle[data-target='ground'] {
|
||||
border-color: rgba(255, 196, 107, 0.85);
|
||||
box-shadow: 0 0 0.7rem rgba(255, 196, 107, 0.3);
|
||||
}
|
||||
|
||||
.gev-pointer-reticle[data-target='ground']::before {
|
||||
background: rgba(255, 196, 107, 0.9);
|
||||
}
|
||||
|
||||
.gev-pointer-reticle[hidden],
|
||||
body.ui-clean-view .gev-pointer-reticle,
|
||||
body.recording-mode .gev-pointer-reticle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Scale only: a frame caught at the animation start still shows the mark. */
|
||||
@keyframes gev-pointer-reticle-in {
|
||||
from {
|
||||
transform: scale(1.6);
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
#gev-voice-control .gev-voice-card {
|
||||
width: min(22rem, calc(100vw - 1.5rem));
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#gev-voice-control .gev-voice-card,
|
||||
.gev-pointer-reticle {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
+51
-60
@@ -1,6 +1,16 @@
|
||||
import { HUD_LAYOUTS } from '../hudLayoutPolicy.js';
|
||||
|
||||
// Canonical action arguments. Descriptive wording is supplied separately.
|
||||
import {
|
||||
VOICE_CONTEXT_LAYER_IDS,
|
||||
VOICE_QUERY_LAYER_IDS,
|
||||
VOICE_TOGGLE_LAYER_IDS,
|
||||
} from './layerManifest.js';
|
||||
|
||||
// Layer enums come from the voice layer manifest so every shipped layer is
|
||||
// reachable; the manifest test keeps it in step with the layer catalog.
|
||||
const toggleLayerIds = () => [...VOICE_TOGGLE_LAYER_IDS];
|
||||
|
||||
const schemas = [
|
||||
{
|
||||
name: 'fly_to_location',
|
||||
@@ -46,6 +56,9 @@ const schemas = [
|
||||
waitForArrival: {
|
||||
type: 'boolean',
|
||||
},
|
||||
referent: {
|
||||
type: 'integer',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -124,25 +137,7 @@ const schemas = [
|
||||
properties: {
|
||||
layerId: {
|
||||
type: 'string',
|
||||
enum: [
|
||||
'flights',
|
||||
'military',
|
||||
'earthquakes',
|
||||
'satellites',
|
||||
'rocket-launches',
|
||||
'traffic',
|
||||
'cctv',
|
||||
'radio',
|
||||
'bikeshare',
|
||||
'ais-live-vessels',
|
||||
'local-datacenters',
|
||||
'local-dams',
|
||||
'telegeography-submarine-cables',
|
||||
'local-firms',
|
||||
'fire-perimeters',
|
||||
'alpr-cameras',
|
||||
'local-adsb',
|
||||
],
|
||||
enum: toggleLayerIds(),
|
||||
},
|
||||
enabled: {
|
||||
type: 'boolean',
|
||||
@@ -159,23 +154,7 @@ const schemas = [
|
||||
properties: {
|
||||
layerId: {
|
||||
type: 'string',
|
||||
enum: [
|
||||
'flights',
|
||||
'military',
|
||||
'earthquakes',
|
||||
'satellites',
|
||||
'traffic',
|
||||
'cctv',
|
||||
'radio',
|
||||
'bikeshare',
|
||||
'ais-live-vessels',
|
||||
'local-datacenters',
|
||||
'local-dams',
|
||||
'telegeography-submarine-cables',
|
||||
'local-firms',
|
||||
'fire-perimeters',
|
||||
'alpr-cameras',
|
||||
],
|
||||
enum: toggleLayerIds(),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -197,6 +176,8 @@ const schemas = [
|
||||
'scene-panel',
|
||||
'pp-toggles',
|
||||
'global-context-panel',
|
||||
'weather-panel',
|
||||
'recent-imagery-panel',
|
||||
],
|
||||
},
|
||||
open: {
|
||||
@@ -276,16 +257,14 @@ const schemas = [
|
||||
properties: {
|
||||
scope: {
|
||||
type: 'string',
|
||||
enum: ['auto', 'selected', 'in_view'],
|
||||
enum: ['auto', 'selected', 'in_view', 'pointer'],
|
||||
},
|
||||
referent: {
|
||||
type: 'integer',
|
||||
},
|
||||
layerId: {
|
||||
type: 'string',
|
||||
enum: [
|
||||
'local-datacenters',
|
||||
'local-dams',
|
||||
'telegeography-submarine-cables',
|
||||
'local-firms',
|
||||
],
|
||||
enum: [...VOICE_CONTEXT_LAYER_IDS],
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
@@ -548,12 +527,21 @@ const schemas = [
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
pattern: '\\S',
|
||||
},
|
||||
layerId: {
|
||||
type: 'string',
|
||||
},
|
||||
referent: {
|
||||
type: 'integer',
|
||||
enum: [-1, 1, 2, 3, 4, 5],
|
||||
},
|
||||
},
|
||||
required: ['query'],
|
||||
anyOf: [
|
||||
{ type: 'object', required: ['query'] },
|
||||
{ type: 'object', required: ['referent'] },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -785,17 +773,7 @@ const schemas = [
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
enum: [
|
||||
'flights',
|
||||
'military',
|
||||
'ais-live-vessels',
|
||||
'local-firms',
|
||||
'earthquakes',
|
||||
'satellites',
|
||||
'local-datacenters',
|
||||
'local-dams',
|
||||
'fire-perimeters',
|
||||
],
|
||||
enum: [...VOICE_QUERY_LAYER_IDS],
|
||||
},
|
||||
},
|
||||
scope: {
|
||||
@@ -804,7 +782,7 @@ const schemas = [
|
||||
properties: {
|
||||
kind: {
|
||||
type: 'string',
|
||||
enum: ['view', 'region', 'radius', 'anywhere'],
|
||||
enum: ['view', 'region', 'radius', 'anywhere', 'pointer'],
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
@@ -818,11 +796,16 @@ const schemas = [
|
||||
properties: {
|
||||
lat: {
|
||||
type: 'number',
|
||||
minimum: -90,
|
||||
maximum: 90,
|
||||
},
|
||||
lon: {
|
||||
type: 'number',
|
||||
minimum: -180,
|
||||
maximum: 180,
|
||||
},
|
||||
},
|
||||
required: ['lat', 'lon'],
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -918,12 +901,20 @@ export function createActionTools(descriptions = {}) {
|
||||
if (!schemas.some((schema) => schema.name === name))
|
||||
throw new TypeError('Unknown action description: ' + name);
|
||||
}
|
||||
return schemas.map((schema) => ({
|
||||
type: 'function',
|
||||
...describe(schema, descriptions[schema.name] || {}),
|
||||
}));
|
||||
return schemas.map((schema) => {
|
||||
const tool = {
|
||||
type: 'function',
|
||||
...describe(schema, descriptions[schema.name] || {}),
|
||||
};
|
||||
// Function-calling APIs reject anyOf/oneOf/allOf at the top level of
|
||||
// parameters; the runner and benchmark validate those rules themselves.
|
||||
for (const key of TOP_LEVEL_COMBINATORS) delete tool.parameters?.[key];
|
||||
return tool;
|
||||
});
|
||||
}
|
||||
|
||||
const TOP_LEVEL_COMBINATORS = ['anyOf', 'oneOf', 'allOf'];
|
||||
|
||||
function describe(schema, metadata, allowDescription = true) {
|
||||
if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata))
|
||||
throw new TypeError('Descriptions must be an object');
|
||||
|
||||
@@ -3,6 +3,10 @@ import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { GEV_ACTION_SCHEMAS, createActionTools } from './actionSchemas.js';
|
||||
import { GEV_REALTIME_TOOLS } from '../../server/providers/openai/tools.js';
|
||||
import {
|
||||
validateArgs,
|
||||
validateCall,
|
||||
} from '../../scripts/voice-bench/grade.mjs';
|
||||
|
||||
const stable = (value) =>
|
||||
Array.isArray(value)
|
||||
@@ -15,7 +19,7 @@ const stable = (value) =>
|
||||
)
|
||||
: value;
|
||||
|
||||
test('the complete Realtime tool payload pins the additive analyst, satellite, Local ADS-B and Cyber release', () => {
|
||||
test('the complete Realtime tool payload pins the manifest-generated layer release', () => {
|
||||
const digest = createHash('sha256')
|
||||
.update(
|
||||
JSON.stringify(
|
||||
@@ -27,9 +31,13 @@ test('the complete Realtime tool payload pins the additive analyst, satellite, L
|
||||
.digest('hex');
|
||||
assert.equal(
|
||||
digest,
|
||||
// Re-derived for the additive `local-adsb` set_layer_visibility value and
|
||||
// the Cyber HUD layout; the separate sonar tool is excluded above.
|
||||
'590d537d93e132ac64ac5e211ad5bb9d7d1b1f22e2dd963dda5465fab4510a3b',
|
||||
// Re-derived for the voice layer manifest (generated layer enums and
|
||||
// aliases), point-and-ask (pointer sentinels, referent args) and the
|
||||
// consolidated tool wording (each policy stated once); model-facing tools
|
||||
// omit top-level anyOf (OpenAI rejects it). Contacts list routing preserves
|
||||
// requested radius and center authority in descriptions only.
|
||||
// Street Level adds its toggle enum values and generated alias hint.
|
||||
'9eb113454c5af5e32e27ffcdc7919d0e4600ba1d204f8f175e071152be41ad0f',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -89,35 +97,88 @@ test('all legacy action arguments are byte-identical after removing the delibera
|
||||
const legacy = structuredClone(GEV_ACTION_SCHEMAS).filter(
|
||||
(tool) => !['next_satellite_pass', 'set_cyber_sonar'].includes(tool.name),
|
||||
);
|
||||
const layers = legacy.find((tool) => tool.name === 'analyst_query').parameters
|
||||
.properties.layers.items;
|
||||
layers.enum = layers.enum.filter(
|
||||
(key) =>
|
||||
![
|
||||
'satellites',
|
||||
'local-datacenters',
|
||||
'local-dams',
|
||||
'fire-perimeters',
|
||||
].includes(key),
|
||||
);
|
||||
// Local ADS-B is an additive set_layer_visibility enum value.
|
||||
const visibility = legacy.find((tool) => tool.name === 'set_layer_visibility')
|
||||
.parameters.properties.layerId;
|
||||
visibility.enum = visibility.enum.filter(
|
||||
(key) => !['local-adsb', 'fire-perimeters'].includes(key),
|
||||
// Layer enums are generated from the voice layer manifest and pinned by
|
||||
// layerManifest.test.mjs; the two shipped right-rail panels are additive.
|
||||
const property = (name) =>
|
||||
legacy.find((tool) => tool.name === name).parameters.properties;
|
||||
delete property('set_layer_visibility').layerId.enum;
|
||||
delete property('show_data_layers_menu').layerId.enum;
|
||||
delete property('get_entity_context').layerId.enum;
|
||||
delete property('analyst_query').layers.items.enum;
|
||||
const panels = property('set_panel_open').panelId;
|
||||
panels.enum = panels.enum.filter(
|
||||
(id) => !['weather-panel', 'recent-imagery-panel'].includes(id),
|
||||
);
|
||||
for (const tool of legacy) {
|
||||
// Point-and-ask adds `referent` arguments and 'pointer' enum values.
|
||||
delete tool.parameters.properties.referent;
|
||||
for (const value of Object.values(tool.parameters.properties)) {
|
||||
if (value.enum)
|
||||
value.enum = value.enum.filter((key) => key !== 'fire-perimeters');
|
||||
value.enum = value.enum.filter((key) => key !== 'pointer');
|
||||
}
|
||||
}
|
||||
// Independently derived by executing trusted c9f9896 actionSchemas in the restricted container.
|
||||
const hud = legacy.find((tool) => tool.name === 'set_hud').parameters
|
||||
.properties.layout;
|
||||
const track = legacy.find((tool) => tool.name === 'track_entity').parameters;
|
||||
delete track.anyOf;
|
||||
delete track.properties.query.minLength;
|
||||
delete track.properties.query.pattern;
|
||||
track.required = ['query'];
|
||||
const analystScopeKind = legacy.find((tool) => tool.name === 'analyst_query')
|
||||
.parameters.properties.scope.properties.kind;
|
||||
analystScopeKind.enum = analystScopeKind.enum.filter(
|
||||
(key) => key !== 'pointer',
|
||||
);
|
||||
// The analyst centre now requires a real coordinate.
|
||||
const center = property('analyst_query').scope.properties.center;
|
||||
delete center.required;
|
||||
for (const axis of ['lat', 'lon']) {
|
||||
delete center.properties[axis].minimum;
|
||||
delete center.properties[axis].maximum;
|
||||
}
|
||||
// Cyber adds one HUD layout.
|
||||
const hud = property('set_hud').layout;
|
||||
hud.enum = hud.enum.filter((layout) => layout !== 'cyber');
|
||||
// Derived by applying the same removals to the 4b56d0e9 actionSchemas.
|
||||
assert.equal(
|
||||
createHash('sha256').update(JSON.stringify(legacy)).digest('hex'),
|
||||
'820fff21658f6907e1010b2b79c5431a77f4e34afd2277d62d8de46c368b6f8c',
|
||||
'01f14fdb1523eebfcbff8b115e48e6ab99e36fa06f55a3bb13c65e198f79d758',
|
||||
);
|
||||
});
|
||||
|
||||
test('track_entity accepts a referent alone but rejects an empty target', () => {
|
||||
const schema = GEV_ACTION_SCHEMAS.find(
|
||||
(tool) => tool.name === 'track_entity',
|
||||
).parameters;
|
||||
assert.deepEqual(validateArgs(schema, { referent: 2 }), []);
|
||||
assert.deepEqual(validateArgs(schema, { referent: -1 }), []);
|
||||
assert.deepEqual(validateArgs(schema, { query: 'UPS793' }), []);
|
||||
assert.ok(validateArgs(schema, {}).length > 0);
|
||||
assert.ok(validateArgs(schema, { query: '' }).length > 0);
|
||||
assert.ok(validateArgs(schema, { query: ' ' }).length > 0);
|
||||
assert.ok(validateArgs(schema, { referent: 0 }).length > 0);
|
||||
assert.ok(validateArgs(schema, { referent: 6 }).length > 0);
|
||||
});
|
||||
|
||||
test('tools sent to the model never carry a top-level schema combinator', () => {
|
||||
for (const tool of createActionTools()) {
|
||||
assert.equal(tool.parameters.type, 'object', tool.name);
|
||||
for (const key of ['anyOf', 'oneOf', 'allOf', 'enum', 'not'])
|
||||
assert.equal(tool.parameters[key], undefined, `${tool.name}.${key}`);
|
||||
}
|
||||
// The runner still knows track_entity needs a query or a referent.
|
||||
const track = GEV_ACTION_SCHEMAS.find((s) => s.name === 'track_entity');
|
||||
assert.ok(track.parameters.anyOf);
|
||||
});
|
||||
|
||||
test('the benchmark still rejects an empty track_entity although the model-facing tool omits anyOf', () => {
|
||||
assert.ok(
|
||||
validateCall(GEV_REALTIME_TOOLS, { name: 'track_entity', args: {} })
|
||||
.length > 0,
|
||||
);
|
||||
assert.deepEqual(
|
||||
validateCall(GEV_REALTIME_TOOLS, {
|
||||
name: 'track_entity',
|
||||
args: { referent: 2 },
|
||||
}),
|
||||
[],
|
||||
);
|
||||
});
|
||||
@@ -2,37 +2,51 @@ import { readRealtimeSource } from '../testSupport/readRealtimeSource.mjs';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { responseInstructionForToolResult } from './realtimeProtocol.js';
|
||||
import { attachVoiceResult } from './speech.js';
|
||||
|
||||
const voiceConfig = readFileSync(new URL('../../server/providers/openai/instructions.js', import.meta.url), 'utf8');
|
||||
const voiceConfig = readFileSync(
|
||||
new URL('../../server/providers/openai/instructions.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const realtime = readRealtimeSource();
|
||||
|
||||
test('aircraft identity narration acknowledges missing enrichment', () => {
|
||||
const start = voiceConfig.indexOf("'For \"what is this aircraft?\" answers");
|
||||
const start = voiceConfig.indexOf('\'For "what is this aircraft?"');
|
||||
assert.ok(start >= 0, 'aircraft identity honesty instruction is missing');
|
||||
const text = voiceConfig.slice(start, voiceConfig.indexOf('\n', start));
|
||||
assert.match(text, /get_entity_context selected\.properties/);
|
||||
assert.match(text, /callsign, operator, registration, type, and route/);
|
||||
assert.match(text, /route, routeOrigin, and routeDestination as the only authoritative route fields/);
|
||||
assert.match(text, /Every aircraft identity answer MUST explicitly cover operator, type, and route/);
|
||||
assert.match(text, /repeat its endpoint codes exactly/);
|
||||
assert.match(text, /do not expand airport codes into city names/);
|
||||
assert.match(text, /"Operator details are unavailable"/);
|
||||
assert.match(text, /"Aircraft type is unavailable"/);
|
||||
assert.match(text, /"Route details are unavailable"/);
|
||||
assert.match(text, /never silently omit missing enrichment/i);
|
||||
assert.match(text, /never .* infer it from the callsign/i);
|
||||
assert.match(text, /get_entity_context/);
|
||||
assert.match(
|
||||
text,
|
||||
/callsign, operator, type and route from returned fields only/,
|
||||
);
|
||||
assert.match(text, /airport codes verbatim/);
|
||||
assert.match(text, /Never infer them from the callsign/);
|
||||
assert.match(
|
||||
text,
|
||||
/Other questions about the selection answer from its properties/,
|
||||
);
|
||||
|
||||
const followupStart = realtime.indexOf("if (result?.action === 'get_entity_context')");
|
||||
const followupEnd = realtime.indexOf("if (result?.action === 'get_current_view_state')", followupStart);
|
||||
assert.ok(followupStart >= 0 && followupEnd > followupStart, 'entity-context follow-up instruction is missing');
|
||||
const followup = realtime.slice(followupStart, followupEnd);
|
||||
assert.match(followup, /selectedLayerId === 'flights' \|\| selectedLayerId === 'military'/);
|
||||
assert.match(followup, /Begin with the returned callsign and include the returned registration when available/);
|
||||
assert.match(followup, /explicitly cover operator, aircraft type, and route before finishing/);
|
||||
assert.match(followup, /selectedProperties\.operator/);
|
||||
assert.match(followup, /selectedProperties\.type/);
|
||||
assert.match(followup, /selectedProperties\.route \|\|\s*selectedProperties\.routeOrigin \|\|\s*selectedProperties\.routeDestination/);
|
||||
assert.match(followup, /Operator details are unavailable/);
|
||||
assert.match(followup, /Aircraft type is unavailable/);
|
||||
assert.match(followup, /Route details are unavailable/);
|
||||
// The follow-up now speaks the code-built say line, which always covers
|
||||
// operator, type and route (see speech.test.mjs for the field rules).
|
||||
const followup = responseInstructionForToolResult(
|
||||
attachVoiceResult('get_entity_context', {
|
||||
ok: true,
|
||||
action: 'get_entity_context',
|
||||
selected: {
|
||||
id: 'a',
|
||||
layerId: 'flights',
|
||||
properties: { callsign: 'UAL1' },
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.match(
|
||||
followup,
|
||||
/asked what the aircraft is, speak its identityLine and add nothing/,
|
||||
);
|
||||
assert.match(
|
||||
followup,
|
||||
/any other question \(altitude, speed, registration\), answer from selected\.properties/,
|
||||
);
|
||||
assert.doesNotMatch(realtime, /Operator details are unavailable/);
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createControls as bikeshareControls } from '../layers/bikeshare/controls.js';
|
||||
import { createControls as launchControls } from '../layers/launches/controls.js';
|
||||
import { createControls as installationControls } from '../layers/installations/controls.js';
|
||||
import { createQueries as transitQueries } from '../layers/transit/queries.js';
|
||||
import { createCyclonesLayer } from '../layers/cyclones/index.js';
|
||||
import { ANALYST_LAYERS } from '../data/analystEngine.js';
|
||||
|
||||
/** Every declared analyst field must be a key the accessor actually emits. */
|
||||
function assertDeclaredFields(layerId, record) {
|
||||
const layer = ANALYST_LAYERS[layerId];
|
||||
for (const field of [
|
||||
...layer.numeric,
|
||||
...layer.text,
|
||||
...layer.flags,
|
||||
...layer.time,
|
||||
])
|
||||
assert.ok(Object.hasOwn(record, field), `${layerId} record lacks ${field}`);
|
||||
assert.equal(typeof record.lat, 'number');
|
||||
assert.equal(typeof record.lon, 'number');
|
||||
}
|
||||
|
||||
test('bikeshare stations join coordinates from station information', () => {
|
||||
const state = {
|
||||
_enabled: true,
|
||||
_stationRenderMap: new Map([
|
||||
[
|
||||
'austin:1',
|
||||
{
|
||||
cityId: 'austin',
|
||||
stationId: '1',
|
||||
stationName: 'Congress',
|
||||
bikesAvailable: 4,
|
||||
docksAvailable: 6,
|
||||
capacity: 10,
|
||||
isRenting: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
'austin:2',
|
||||
{
|
||||
cityId: 'austin',
|
||||
stationId: '2',
|
||||
stationName: 'No coordinates',
|
||||
bikesAvailable: 1,
|
||||
},
|
||||
],
|
||||
]),
|
||||
_stationInfoCache: new Map([
|
||||
['austin', new Map([['1', { lat: 30.27, lon: -97.74 }]])],
|
||||
]),
|
||||
};
|
||||
const { methods } = bikeshareControls({ state, parts: {} });
|
||||
const rows = methods.getAnalystRecords();
|
||||
assert.equal(
|
||||
rows.length,
|
||||
1,
|
||||
'a station without coordinates is not countable',
|
||||
);
|
||||
assert.deepEqual(rows[0], {
|
||||
id: 'Congress',
|
||||
name: 'Congress',
|
||||
city: 'austin',
|
||||
lat: 30.27,
|
||||
lon: -97.74,
|
||||
bikesAvailable: 4,
|
||||
docksAvailable: 6,
|
||||
capacity: 10,
|
||||
renting: true,
|
||||
});
|
||||
assertDeclaredFields('bikeshare', rows[0]);
|
||||
state._enabled = false;
|
||||
assert.deepEqual(methods.getAnalystRecords(), []);
|
||||
});
|
||||
|
||||
test('launches carry time and hours until launch', () => {
|
||||
const at = Date.now() + 5 * 3_600_000;
|
||||
const state = {
|
||||
_enabled: true,
|
||||
_launches: [
|
||||
{
|
||||
id: 'L1',
|
||||
name: 'Falcon 9 | Starlink',
|
||||
provider: 'SpaceX',
|
||||
status: 'Go',
|
||||
launchSite: 'SLC-40',
|
||||
missionName: 'Starlink',
|
||||
lat: 28.56,
|
||||
lon: -80.58,
|
||||
launchTime: new Date(at).toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
const { methods } = launchControls({ state, parts: {} });
|
||||
const [row] = methods.getAnalystRecords();
|
||||
assert.equal(row.launchTimeMs, at);
|
||||
assert.equal(row.hoursUntil, 5);
|
||||
assertDeclaredFields('rocket-launches', row);
|
||||
});
|
||||
|
||||
test('mapped installations flatten latitude/longitude', () => {
|
||||
const state = {
|
||||
enabled: true,
|
||||
records: [
|
||||
{
|
||||
id: 'node/1',
|
||||
name: 'Fort Example',
|
||||
class: 'military_land',
|
||||
kind: 'installation',
|
||||
latitude: 31,
|
||||
longitude: -97,
|
||||
},
|
||||
],
|
||||
};
|
||||
const { methods } = installationControls({ state, parts: {} });
|
||||
const [row] = methods.getAnalystRecords();
|
||||
assert.deepEqual(row, {
|
||||
id: 'Fort Example',
|
||||
name: 'Fort Example',
|
||||
class: 'military_land',
|
||||
kind: 'installation',
|
||||
lat: 31,
|
||||
lon: -97,
|
||||
});
|
||||
assertDeclaredFields('military-installations', row);
|
||||
});
|
||||
|
||||
test('transit vehicles report their last fix, mode and route', () => {
|
||||
const state = {
|
||||
_enabled: true,
|
||||
_vehicles: new Map([
|
||||
[
|
||||
'cap:1',
|
||||
{
|
||||
key: 'cap:1',
|
||||
mode: 'bus',
|
||||
courseDeg: 90,
|
||||
record: {
|
||||
id: '1',
|
||||
label: '2554',
|
||||
routeId: '335',
|
||||
lat: 30.2,
|
||||
lon: -97.7,
|
||||
speedMps: 8,
|
||||
status: 'IN_TRANSIT_TO',
|
||||
occupancy: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
]),
|
||||
};
|
||||
const { methods } = transitQueries({ state, parts: {} });
|
||||
const [row] = methods.getAnalystRecords();
|
||||
assert.equal(row.id, '2554');
|
||||
assert.equal(row.mode, 'bus');
|
||||
assertDeclaredFields('transit', row);
|
||||
});
|
||||
|
||||
test('cyclones flatten the storm position and stay empty while off', async () => {
|
||||
const storm = {
|
||||
id: 'al052026',
|
||||
name: 'Fay',
|
||||
classification: 'TS',
|
||||
basin: 'AL',
|
||||
position: { latitude: 29.8, longitude: -40 },
|
||||
windKt: 35,
|
||||
pressureHpa: 1010,
|
||||
issuedAt: '2026-09-23T00:00:00Z',
|
||||
forecastPoints: [],
|
||||
track: null,
|
||||
cone: null,
|
||||
};
|
||||
const layer = createCyclonesLayer({
|
||||
feed: {
|
||||
getSnapshot: async () => ({
|
||||
schemaVersion: 1,
|
||||
storms: [storm],
|
||||
unavailable: false,
|
||||
stale: false,
|
||||
fetchedAt: Date.now(),
|
||||
}),
|
||||
},
|
||||
cesium: {
|
||||
ScreenSpaceEventType: { LEFT_CLICK: 'left' },
|
||||
ScreenSpaceEventHandler: class {
|
||||
setInputAction() {}
|
||||
destroy() {}
|
||||
isDestroyed() {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
},
|
||||
createRendering: () => ({
|
||||
setSnapshot: async () => true,
|
||||
setSelection() {},
|
||||
clear() {},
|
||||
destroy() {},
|
||||
pickStorm: () => null,
|
||||
ownsPickId: () => false,
|
||||
getDiagnostics: () => ({}),
|
||||
}),
|
||||
hitTestOverlay: () => null,
|
||||
});
|
||||
layer.init({
|
||||
scene: { canvas: new EventTarget(), pick: () => null },
|
||||
camera: {},
|
||||
});
|
||||
assert.deepEqual(layer.getAnalystRecords(), []);
|
||||
layer.enable();
|
||||
await layer.update();
|
||||
const [row] = layer.getAnalystRecords();
|
||||
assert.deepEqual(row, {
|
||||
id: 'Fay',
|
||||
name: 'Fay',
|
||||
classification: 'TS',
|
||||
basin: 'AL',
|
||||
lat: 29.8,
|
||||
lon: -40,
|
||||
windKt: 35,
|
||||
pressureHpa: 1010,
|
||||
});
|
||||
assertDeclaredFields('weather-cyclones', row);
|
||||
layer.destroy?.();
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
export const CLOUD_VOICE_AUTH_MODES = Object.freeze(['api-key', 'oauth']);
|
||||
export const DEFAULT_CLOUD_VOICE_AUTH_MODE = 'api-key';
|
||||
export const CLOUD_VOICE_AUTH_STORAGE_KEY = 'godsEyeView.voice.cloudAuth';
|
||||
|
||||
function storageRef(storage) {
|
||||
if (storage) return storage;
|
||||
try {
|
||||
return typeof localStorage === 'undefined' ? null : localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeCloudVoiceAuthMode(mode) {
|
||||
const normalized = String(mode || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return CLOUD_VOICE_AUTH_MODES.includes(normalized)
|
||||
? normalized
|
||||
: DEFAULT_CLOUD_VOICE_AUTH_MODE;
|
||||
}
|
||||
|
||||
export function readStoredCloudVoiceAuthMode(storage) {
|
||||
try {
|
||||
return normalizeCloudVoiceAuthMode(
|
||||
storageRef(storage)?.getItem(CLOUD_VOICE_AUTH_STORAGE_KEY),
|
||||
);
|
||||
} catch {
|
||||
return DEFAULT_CLOUD_VOICE_AUTH_MODE;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeStoredCloudVoiceAuthMode(mode, storage) {
|
||||
const resolved = normalizeCloudVoiceAuthMode(mode);
|
||||
try {
|
||||
storageRef(storage)?.setItem(CLOUD_VOICE_AUTH_STORAGE_KEY, resolved);
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
CLOUD_VOICE_AUTH_STORAGE_KEY,
|
||||
readStoredCloudVoiceAuthMode,
|
||||
writeStoredCloudVoiceAuthMode,
|
||||
} from './cloudVoiceAuth.js';
|
||||
|
||||
function memoryStorage(seed = {}) {
|
||||
const values = new Map(Object.entries(seed));
|
||||
return {
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
};
|
||||
}
|
||||
|
||||
test('cloud voice auth defaults to API key and persists OAuth independently', () => {
|
||||
const storage = memoryStorage();
|
||||
assert.equal(readStoredCloudVoiceAuthMode(storage), 'api-key');
|
||||
assert.equal(writeStoredCloudVoiceAuthMode('oauth', storage), 'oauth');
|
||||
assert.equal(readStoredCloudVoiceAuthMode(storage), 'oauth');
|
||||
assert.equal(writeStoredCloudVoiceAuthMode('unknown', storage), 'api-key');
|
||||
assert.equal(
|
||||
readStoredCloudVoiceAuthMode(
|
||||
memoryStorage({ [CLOUD_VOICE_AUTH_STORAGE_KEY]: 'oauth' }),
|
||||
),
|
||||
'oauth',
|
||||
);
|
||||
});
|
||||
@@ -33,6 +33,28 @@ export function createVoiceControl({ reset = false } = {}) {
|
||||
<span class="gev-voice-help-kicker">VOICE CONTROL</span>
|
||||
<span class="gev-voice-help-detail">Hold Space to speak · tap Space to activate focused controls</span>
|
||||
</div>
|
||||
<section id="gev-voice-card" class="gev-voice-card" hidden aria-label="Voice assistant">
|
||||
<div class="gev-voice-card-header">
|
||||
<span class="gev-voice-card-kicker">VOICE</span>
|
||||
<span id="gev-voice-card-phase" class="gev-voice-card-phase"></span>
|
||||
<button id="gev-voice-card-dismiss" class="gev-voice-card-dismiss" type="button" aria-label="Dismiss voice card">×</button>
|
||||
</div>
|
||||
<p id="gev-voice-card-user" class="gev-voice-card-caption" data-role="user" hidden><span class="gev-voice-card-who">YOU</span><span id="gev-voice-card-user-text" class="gev-voice-card-text"></span></p>
|
||||
<p id="gev-voice-card-pointer" class="gev-voice-card-pointer" hidden><span id="gev-voice-card-pointer-kind" class="gev-voice-card-who">THIS</span><span class="gev-voice-card-sr">: </span><span id="gev-voice-card-pointer-text" class="gev-voice-card-pointer-label"></span></p>
|
||||
<p id="gev-voice-card-assistant" class="gev-voice-card-caption" data-role="assistant" hidden><span class="gev-voice-card-who">GEV</span><span id="gev-voice-card-assistant-text" class="gev-voice-card-text"></span></p>
|
||||
<ol id="gev-voice-card-plan" class="gev-voice-card-plan" aria-label="Plan" hidden></ol>
|
||||
<div id="gev-voice-card-result" class="gev-voice-card-result" hidden>
|
||||
<div id="gev-voice-card-result-title" class="gev-voice-card-result-title"></div>
|
||||
<div id="gev-voice-card-chips" class="gev-voice-card-chips" hidden></div>
|
||||
<ul id="gev-voice-card-lines" class="gev-voice-card-lines"></ul>
|
||||
<ol id="gev-voice-card-referents" class="gev-voice-card-referents" aria-label="Referenced items" hidden></ol>
|
||||
<details id="gev-voice-card-notes" class="gev-voice-card-notes" hidden>
|
||||
<summary>NOTES & SOURCES</summary>
|
||||
<ul id="gev-voice-card-note-list" class="gev-voice-card-lines"></ul>
|
||||
</details>
|
||||
</div>
|
||||
<div id="gev-voice-card-live" class="gev-voice-card-sr" aria-live="polite" aria-atomic="true"></div>
|
||||
</section>
|
||||
<div class="gev-voice-error-tray" role="alert" aria-live="assertive">
|
||||
<div class="gev-voice-error-header">
|
||||
<span>VOICE SYSTEM ERROR</span>
|
||||
@@ -68,5 +90,6 @@ export function createVoiceControl({ reset = false } = {}) {
|
||||
errorDetail: root.querySelector('#gev-voice-error-detail'),
|
||||
tierButton: root.querySelector('#gev-voice-tier'),
|
||||
costValue: root.querySelector('#gev-voice-cost-value'),
|
||||
card: root.querySelector('#gev-voice-card'),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* Resolve deictic tool arguments before an action runs.
|
||||
*
|
||||
* The model never copies coordinates or ids for "this", "here" or "the second
|
||||
* one". It passes a sentinel and the runner fills in the real target:
|
||||
*
|
||||
* track_entity {query:'pointer'} | {referent:n}
|
||||
* fly_to_location {query:'pointer'} | {referent:n}
|
||||
* get_entity_context {scope:'pointer'} | {referent:n}
|
||||
* analyst_query {scope:{kind:'pointer', km?}}
|
||||
* annotate_map annotations[].target / toTarget / points[].target = 'pointer'
|
||||
*
|
||||
* The pointer is the current turn's snapshot (see pointerContext.js); the
|
||||
* referents are the latest result's numbered list (see referents.js).
|
||||
* Resolution is pure: it returns rewritten arguments, what they resolved
|
||||
* from, or a plain error the model can say.
|
||||
*/
|
||||
|
||||
import { finiteOrNull } from './pointerContext.js';
|
||||
|
||||
export const POINTER_SENTINEL = 'pointer';
|
||||
|
||||
const TRACKABLE_LAYERS = new Set([
|
||||
'flights',
|
||||
'military',
|
||||
'ais-live-vessels',
|
||||
'satellites',
|
||||
]);
|
||||
|
||||
const DEFAULT_POINTER_RADIUS_KM = 50;
|
||||
|
||||
const NO_POINTER =
|
||||
'Nothing is under the pointer. Ask the user to point at it or name it.';
|
||||
|
||||
function isPointer(value) {
|
||||
return (
|
||||
typeof value === 'string' && value.trim().toLowerCase() === POINTER_SENTINEL
|
||||
);
|
||||
}
|
||||
|
||||
function hasPosition(target) {
|
||||
return (
|
||||
finiteOrNull(target?.lat) !== null && finiteOrNull(target?.lon) !== null
|
||||
);
|
||||
}
|
||||
|
||||
function referentIndex(args) {
|
||||
if (args?.referent === undefined || args?.referent === null) return null;
|
||||
const n = Number(args.referent);
|
||||
return Number.isInteger(n) ? n : NaN;
|
||||
}
|
||||
|
||||
function withoutKeys(args, keys) {
|
||||
const out = { ...args };
|
||||
for (const key of keys) delete out[key];
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The pointer as a target record, or null. */
|
||||
function pointerTarget(pointer) {
|
||||
if (!pointer?.fresh) return null;
|
||||
if (pointer.entity) {
|
||||
return {
|
||||
...pointer.entity,
|
||||
lat: pointer.lat,
|
||||
lon: pointer.lon,
|
||||
};
|
||||
}
|
||||
if (hasPosition(pointer))
|
||||
return { lat: pointer.lat, lon: pointer.lon, label: null };
|
||||
return null;
|
||||
}
|
||||
|
||||
function lookupReferent(referents, n) {
|
||||
if (Number.isNaN(n)) return { error: 'referent must be a whole number.' };
|
||||
const ref = referents?.get?.(n) || null;
|
||||
if (!ref)
|
||||
return {
|
||||
error: `There is no item ${n} in the last list.`,
|
||||
};
|
||||
return { ref };
|
||||
}
|
||||
|
||||
function resolveTrack(args, { pointer, referents }) {
|
||||
const n = referentIndex(args);
|
||||
let target = null;
|
||||
let source = null;
|
||||
if (n !== null) {
|
||||
const { ref, error } = lookupReferent(referents, n);
|
||||
if (error) return { error };
|
||||
target = ref;
|
||||
source = 'referent';
|
||||
} else if (isPointer(args.query)) {
|
||||
target = pointerTarget(pointer);
|
||||
if (!target) return { error: NO_POINTER };
|
||||
if (!target.layerId)
|
||||
return {
|
||||
error:
|
||||
'The pointer is on the ground, not on an aircraft, ship or satellite.',
|
||||
};
|
||||
source = 'pointer';
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
if (!TRACKABLE_LAYERS.has(target.layerId))
|
||||
return {
|
||||
error: `${target.label || 'That'} cannot be followed; only aircraft, ships and satellites can.`,
|
||||
};
|
||||
return {
|
||||
args: {
|
||||
...withoutKeys(args, ['referent']),
|
||||
query: String(target.id || target.label),
|
||||
layerId: target.layerId,
|
||||
},
|
||||
used: { source, label: target.label || null, layerId: target.layerId },
|
||||
};
|
||||
}
|
||||
|
||||
function resolveFly(args, { pointer, referents }) {
|
||||
const n = referentIndex(args);
|
||||
let target = null;
|
||||
let source = null;
|
||||
if (n !== null) {
|
||||
const { ref, error } = lookupReferent(referents, n);
|
||||
if (error) return { error };
|
||||
target = ref;
|
||||
source = 'referent';
|
||||
} else if (isPointer(args.query)) {
|
||||
target = pointerTarget(pointer);
|
||||
if (!target) return { error: NO_POINTER };
|
||||
source = 'pointer';
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
const rest = withoutKeys(args, ['referent', 'query', 'locationId']);
|
||||
const used = { source, label: target.label || null };
|
||||
if (target.layerId && target.id && source === 'referent') {
|
||||
// Contacts move: locate them when the flight starts, not from a list.
|
||||
return {
|
||||
args: {
|
||||
...rest,
|
||||
entity: {
|
||||
layerId: target.layerId,
|
||||
id: target.id,
|
||||
...(hasPosition(target) ? { lat: target.lat, lon: target.lon } : {}),
|
||||
},
|
||||
},
|
||||
used,
|
||||
};
|
||||
}
|
||||
if (hasPosition(target))
|
||||
return {
|
||||
args: { ...rest, latitude: target.lat, longitude: target.lon },
|
||||
used,
|
||||
};
|
||||
if (target.label) return { args: { ...rest, query: target.label }, used };
|
||||
return { error: 'That item has no position to fly to.' };
|
||||
}
|
||||
|
||||
function resolveContext(args, { pointer, referents }) {
|
||||
const n = referentIndex(args);
|
||||
if (n !== null) {
|
||||
const { ref, error } = lookupReferent(referents, n);
|
||||
if (error) return { error };
|
||||
return {
|
||||
args: {
|
||||
...withoutKeys(args, ['referent']),
|
||||
scope: 'target',
|
||||
target: ref,
|
||||
},
|
||||
used: { source: 'referent', label: ref.label || null },
|
||||
};
|
||||
}
|
||||
if (String(args.scope || '').toLowerCase() !== POINTER_SENTINEL) return null;
|
||||
const target = pointerTarget(pointer);
|
||||
if (!target) {
|
||||
// A read-only lookup falls back to the usual scope instead of failing.
|
||||
return {
|
||||
args: { ...args, scope: 'auto' },
|
||||
used: { source: 'pointer', label: null, missing: true },
|
||||
};
|
||||
}
|
||||
return {
|
||||
args: { ...args, scope: 'target', target },
|
||||
used: {
|
||||
source: 'pointer',
|
||||
label: target.label || null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAnalyst(args, { pointer }) {
|
||||
const scope = args.scope;
|
||||
if (!scope || String(scope.kind || '').toLowerCase() !== POINTER_SENTINEL)
|
||||
return null;
|
||||
const target = pointerTarget(pointer);
|
||||
if (!target || !hasPosition(target)) return { error: NO_POINTER };
|
||||
const asked = finiteOrNull(scope.km);
|
||||
const km =
|
||||
asked > 0
|
||||
? asked
|
||||
: finiteOrNull(pointer.radiusKm) > 0
|
||||
? pointer.radiusKm
|
||||
: DEFAULT_POINTER_RADIUS_KM;
|
||||
const nextScope = withoutKeys(scope, ['name']);
|
||||
return {
|
||||
args: {
|
||||
...args,
|
||||
scope: {
|
||||
...nextScope,
|
||||
kind: 'radius',
|
||||
center: { lat: target.lat, lon: target.lon },
|
||||
km,
|
||||
},
|
||||
},
|
||||
used: { source: 'pointer', label: target.label || null, km },
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAnnotate(args, { pointer }) {
|
||||
const list = Array.isArray(args.annotations) ? args.annotations : null;
|
||||
if (!list) return null;
|
||||
const mentions = list.some(
|
||||
(spec) =>
|
||||
isPointer(spec?.target) ||
|
||||
isPointer(spec?.toTarget) ||
|
||||
(Array.isArray(spec?.points) &&
|
||||
spec.points.some((point) => isPointer(point?.target))),
|
||||
);
|
||||
if (!mentions) return null;
|
||||
const target = pointerTarget(pointer);
|
||||
if (!target || !hasPosition(target)) return { error: NO_POINTER };
|
||||
const annotations = list.map((spec) => {
|
||||
if (!spec || typeof spec !== 'object') return spec;
|
||||
let next = spec;
|
||||
if (isPointer(spec.target)) {
|
||||
next = withoutKeys(next, ['target']);
|
||||
next.latitude = target.lat;
|
||||
next.longitude = target.lon;
|
||||
if (!next.label && target.label) next.label = target.label;
|
||||
}
|
||||
if (isPointer(spec.toTarget)) {
|
||||
next = withoutKeys(next, ['toTarget']);
|
||||
next.toLatitude = target.lat;
|
||||
next.toLongitude = target.lon;
|
||||
}
|
||||
if (Array.isArray(spec.points)) {
|
||||
next = {
|
||||
...next,
|
||||
points: spec.points.map((point) =>
|
||||
isPointer(point?.target)
|
||||
? {
|
||||
...withoutKeys(point, ['target']),
|
||||
latitude: target.lat,
|
||||
longitude: target.lon,
|
||||
}
|
||||
: point,
|
||||
),
|
||||
};
|
||||
}
|
||||
return next;
|
||||
});
|
||||
return {
|
||||
args: { ...args, annotations },
|
||||
used: { source: 'pointer', label: target.label || null },
|
||||
};
|
||||
}
|
||||
|
||||
const SCREEN_PAIRS = [
|
||||
['screenX', 'screenY'],
|
||||
['toScreenX', 'toScreenY'],
|
||||
];
|
||||
|
||||
function hasScreenPoint(spec) {
|
||||
return (
|
||||
SCREEN_PAIRS.some(
|
||||
([x, y]) => spec?.[x] !== undefined || spec?.[y] !== undefined,
|
||||
) ||
|
||||
(Array.isArray(spec?.points) &&
|
||||
spec.points.some(
|
||||
(point) => point?.screenX !== undefined || point?.screenY !== undefined,
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* screenX/screenY are normalized against the latest image the model saw, but
|
||||
* the annotation resolver reads them against the full canvas. When that image
|
||||
* is the pointer crop, map crop coordinates back to the canvas; when the
|
||||
* camera moved since, no pixel names anything any more.
|
||||
*/
|
||||
function resolveCropPixels(args, { imageFrame, cameraKey }) {
|
||||
const list = Array.isArray(args.annotations) ? args.annotations : null;
|
||||
if (!list || imageFrame?.kind !== 'crop' || !list.some(hasScreenPoint))
|
||||
return null;
|
||||
const rect = imageFrame.rect;
|
||||
if (
|
||||
!rect ||
|
||||
(imageFrame.cameraKey && cameraKey && imageFrame.cameraKey !== cameraKey)
|
||||
)
|
||||
return {
|
||||
error:
|
||||
'The view has moved since that image, so its pixels no longer match. Name the place or point at it.',
|
||||
};
|
||||
const map = (value, origin, span) => {
|
||||
const n = finiteOrNull(value);
|
||||
return n === null ? value : origin + Math.min(1, Math.max(0, n)) * span;
|
||||
};
|
||||
const remap = (spec) => {
|
||||
const next = { ...spec };
|
||||
for (const [xKey, yKey] of SCREEN_PAIRS) {
|
||||
if (xKey in next) next[xKey] = map(next[xKey], rect.x, rect.w);
|
||||
if (yKey in next) next[yKey] = map(next[yKey], rect.y, rect.h);
|
||||
}
|
||||
return next;
|
||||
};
|
||||
return {
|
||||
args: {
|
||||
...args,
|
||||
annotations: list.map((spec) => {
|
||||
if (!spec || typeof spec !== 'object') return spec;
|
||||
const next = remap(spec);
|
||||
if (Array.isArray(spec.points)) next.points = spec.points.map(remap);
|
||||
return next;
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const RESOLVERS = Object.freeze({
|
||||
track_entity: resolveTrack,
|
||||
fly_to_location: resolveFly,
|
||||
get_entity_context: resolveContext,
|
||||
analyst_query: resolveAnalyst,
|
||||
annotate_map: resolveAnnotate,
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {string} name Tool name.
|
||||
* @param {object} args Model arguments.
|
||||
* @param {object} context
|
||||
* @param {object|null} [context.pointer] Current turn's pointer snapshot.
|
||||
* @param {{get: Function}|null} [context.referents] Referent registry.
|
||||
* @param {object|null} [context.imageFrame] What the retained image shows.
|
||||
* @param {string|null} [context.cameraKey] Current camera pose identity.
|
||||
* @returns {{args: object, used: object|null, error: string|null}}
|
||||
*/
|
||||
export function resolveDeicticArgs(
|
||||
name,
|
||||
args,
|
||||
{
|
||||
pointer = null,
|
||||
referents = null,
|
||||
imageFrame = null,
|
||||
cameraKey = null,
|
||||
} = {},
|
||||
) {
|
||||
let input = args && typeof args === 'object' ? args : {};
|
||||
if (name === 'annotate_map') {
|
||||
const pixels = resolveCropPixels(input, { imageFrame, cameraKey });
|
||||
if (pixels?.error) return { args: input, used: null, error: pixels.error };
|
||||
if (pixels) input = pixels.args;
|
||||
}
|
||||
const resolver = RESOLVERS[name];
|
||||
if (!resolver) return { args: input, used: null, error: null };
|
||||
const resolved = resolver(input, { pointer, referents });
|
||||
if (!resolved) return { args: input, used: null, error: null };
|
||||
if (resolved.error) return { args: input, used: null, error: resolved.error };
|
||||
return { args: resolved.args, used: resolved.used || null, error: null };
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { attachVoiceResult } from './speech.js';
|
||||
import { resolveDeicticArgs } from './deixis.js';
|
||||
import { createReferentRegistry, referentsFromResult } from './referents.js';
|
||||
|
||||
const aircraft = {
|
||||
fresh: true,
|
||||
at: 'speech_start',
|
||||
target: 'entity',
|
||||
entity: { layerId: 'flights', id: 'a1b2c3', label: 'UPS793', kind: 'aircraft' },
|
||||
lat: 30.2,
|
||||
lon: -97.7,
|
||||
radiusKm: 20,
|
||||
};
|
||||
const ground = { fresh: true, at: 'keydown', target: 'ground', entity: null, lat: 30.27, lon: -97.74, radiusKm: 8 };
|
||||
const datacenter = {
|
||||
...aircraft,
|
||||
entity: { layerId: 'local-datacenters', id: 'dc-7', label: 'Data Center 7', kind: 'feature' },
|
||||
};
|
||||
|
||||
test('track_entity: "track that one" follows the pointed aircraft by identity', () => {
|
||||
const out = resolveDeicticArgs('track_entity', { query: 'pointer' }, { pointer: aircraft });
|
||||
assert.equal(out.error, null);
|
||||
assert.deepEqual(out.args, { query: 'a1b2c3', layerId: 'flights' });
|
||||
assert.deepEqual(out.used, { source: 'pointer', label: 'UPS793', layerId: 'flights' });
|
||||
});
|
||||
|
||||
test('track_entity: pointer misses are plain errors the model can say', () => {
|
||||
assert.match(resolveDeicticArgs('track_entity', { query: 'Pointer' }, { pointer: null }).error, /Nothing is under the pointer/);
|
||||
assert.match(resolveDeicticArgs('track_entity', { query: 'pointer' }, { pointer: ground }).error, /on the ground/);
|
||||
assert.match(resolveDeicticArgs('track_entity', { query: 'pointer' }, { pointer: datacenter }).error, /cannot be followed/);
|
||||
});
|
||||
|
||||
test('names are untouched: a real query never reads the pointer', () => {
|
||||
const args = { query: 'UAL428' };
|
||||
const out = resolveDeicticArgs('track_entity', args, { pointer: aircraft });
|
||||
assert.equal(out.args, args);
|
||||
assert.equal(out.used, null);
|
||||
assert.equal(resolveDeicticArgs('set_hud', { enabled: true }, { pointer: aircraft }).used, null);
|
||||
});
|
||||
|
||||
test('fly_to_location: "take me there" flies to the pointed ground point', () => {
|
||||
const out = resolveDeicticArgs('fly_to_location', { query: 'pointer', rangeM: 2000 }, { pointer: ground });
|
||||
assert.deepEqual(out.args, { rangeM: 2000, latitude: 30.27, longitude: -97.74 });
|
||||
assert.equal(out.used.source, 'pointer');
|
||||
});
|
||||
|
||||
test('get_entity_context: pointer scope targets the entity; no pointer falls back to auto', () => {
|
||||
const out = resolveDeicticArgs('get_entity_context', { scope: 'pointer' }, { pointer: aircraft });
|
||||
assert.equal(out.args.scope, 'target');
|
||||
assert.equal(out.args.target.id, 'a1b2c3');
|
||||
assert.equal(out.args.target.lat, 30.2);
|
||||
const fallback = resolveDeicticArgs('get_entity_context', { scope: 'pointer' }, { pointer: null });
|
||||
assert.equal(fallback.error, null, 'a read-only lookup never fails for a missing pointer');
|
||||
assert.equal(fallback.args.scope, 'auto');
|
||||
assert.equal(fallback.used.missing, true);
|
||||
});
|
||||
|
||||
test('analyst_query: "how many flights around here" becomes a radius at the pointer', () => {
|
||||
const out = resolveDeicticArgs(
|
||||
'analyst_query',
|
||||
{ layers: ['flights'], scope: { kind: 'pointer' } },
|
||||
{ pointer: ground },
|
||||
);
|
||||
assert.deepEqual(out.args.scope, { kind: 'radius', center: { lat: 30.27, lon: -97.74 }, km: 8 });
|
||||
const explicit = resolveDeicticArgs('analyst_query', { scope: { kind: 'pointer', km: 100 } }, { pointer: ground });
|
||||
assert.equal(explicit.args.scope.km, 100, 'an explicit radius wins');
|
||||
assert.match(resolveDeicticArgs('analyst_query', { scope: { kind: 'pointer' } }, {}).error, /Nothing is under/);
|
||||
});
|
||||
|
||||
test('annotate_map: pointer targets become coordinates and keep the label', () => {
|
||||
const out = resolveDeicticArgs(
|
||||
'annotate_map',
|
||||
{
|
||||
annotations: [
|
||||
{ type: 'pin', target: 'pointer' },
|
||||
{ type: 'arrow', target: 'Texas State Capitol', toTarget: 'pointer' },
|
||||
{ type: 'route', points: [{ target: 'Zilker Park' }, { target: 'pointer' }] },
|
||||
],
|
||||
},
|
||||
{ pointer: aircraft },
|
||||
);
|
||||
const [pin, arrow, route] = out.args.annotations;
|
||||
assert.deepEqual(pin, { type: 'pin', latitude: 30.2, longitude: -97.7, label: 'UPS793' });
|
||||
assert.deepEqual(arrow, { type: 'arrow', target: 'Texas State Capitol', toLatitude: 30.2, toLongitude: -97.7 });
|
||||
assert.deepEqual(route.points[1], { latitude: 30.2, longitude: -97.7 });
|
||||
});
|
||||
|
||||
test('referents: numbered items resolve for track, fly and context', () => {
|
||||
const referents = createReferentRegistry();
|
||||
referents.recordResult('frame_overhead', {
|
||||
ok: true,
|
||||
display: { title: '3 aircraft in frame' },
|
||||
referents: [
|
||||
{ n: 1, id: 'aaa111', label: 'SWA12', layerId: 'flights' },
|
||||
{ n: 2, id: 'bbb222', label: 'UPS793', layerId: 'flights' },
|
||||
{ n: 3, id: 'ccc333', label: 'AAL9', layerId: 'flights' },
|
||||
],
|
||||
});
|
||||
const track = resolveDeicticArgs('track_entity', { query: 'second one', referent: 2 }, { referents });
|
||||
assert.deepEqual(track.args, { query: 'bbb222', layerId: 'flights' });
|
||||
assert.equal(track.used.source, 'referent');
|
||||
const last = resolveDeicticArgs('track_entity', { query: 'x', referent: -1 }, { referents });
|
||||
assert.equal(last.args.query, 'ccc333', '-1 is the last one');
|
||||
const fly = resolveDeicticArgs('fly_to_location', { referent: 1 }, { referents });
|
||||
assert.deepEqual(fly.args, { entity: { layerId: 'flights', id: 'aaa111' } }, 'moving contacts are located at run time');
|
||||
const context = resolveDeicticArgs('get_entity_context', { referent: 3 }, { referents });
|
||||
assert.equal(context.args.scope, 'target');
|
||||
assert.equal(context.args.target.label, 'AAL9');
|
||||
assert.match(resolveDeicticArgs('track_entity', { query: 'x', referent: 7 }, { referents }).error, /no item 7/);
|
||||
});
|
||||
|
||||
test('referents: map marks fly by name and cannot be tracked', () => {
|
||||
const referents = createReferentRegistry();
|
||||
referents.recordResult('annotate_map', {
|
||||
ok: true,
|
||||
display: { title: 'Marked 2 places' },
|
||||
referents: [
|
||||
{ n: 1, id: 'anno-1', label: 'Texas State Capitol' },
|
||||
{ n: 2, id: 'anno-2', label: 'Zilker Park' },
|
||||
],
|
||||
});
|
||||
assert.deepEqual(resolveDeicticArgs('fly_to_location', { referent: 2 }, { referents }).args, { query: 'Zilker Park' });
|
||||
assert.match(resolveDeicticArgs('track_entity', { query: 'x', referent: 1 }, { referents }).error, /cannot be followed/);
|
||||
});
|
||||
|
||||
test('registry: mirrors the card, records analyst items, clears per session', () => {
|
||||
const referents = createReferentRegistry();
|
||||
assert.equal(
|
||||
referents.recordResult('get_current_view_state', { ok: true, schedule: 'silent', display: { title: 'View' }, referents: [{ n: 1, label: 'x' }] }),
|
||||
false,
|
||||
'silent lookups do not replace the list',
|
||||
);
|
||||
assert.equal(referents.recordResult('fly_to_location', { ok: false, referents: [{ n: 1, label: 'x' }] }), false);
|
||||
referents.recordResult('analyst_query', {
|
||||
ok: true,
|
||||
items: [
|
||||
{ layerKey: 'flights', id: 'abc', callsign: 'DAL1', latitude: 1, longitude: 2 },
|
||||
{ layerKey: 'military', id: 'def' },
|
||||
],
|
||||
});
|
||||
assert.deepEqual(referents.get(1), { n: 1, id: 'abc', label: 'DAL1', layerId: 'flights', lat: 1, lon: 2 });
|
||||
assert.equal(referents.get(2).label, 'def');
|
||||
assert.equal(referents.recordResult('track_entity', { ok: true, label: 'DAL1' }), false, 'results without a list keep it');
|
||||
assert.equal(referents.list().length, 2);
|
||||
referents.clear();
|
||||
assert.equal(referents.get(1), null);
|
||||
assert.deepEqual(referentsFromResult('x', null), []);
|
||||
});
|
||||
|
||||
test('registry: hidden card rows never resolve and last means displayed item five', () => {
|
||||
const referents = createReferentRegistry();
|
||||
referents.recordResult('analyst_query', {
|
||||
ok: true,
|
||||
count: 6,
|
||||
scopeLabel: 'anywhere',
|
||||
coverage: { layersQueried: [{ layerKey: 'flights' }] },
|
||||
items: Array.from({ length: 6 }, (_, index) => ({
|
||||
layerKey: 'flights',
|
||||
id: `F${index + 1}`,
|
||||
})),
|
||||
});
|
||||
assert.deepEqual(
|
||||
referents.list().map((entry) => entry.n),
|
||||
[1, 2, 3, 4, 5],
|
||||
);
|
||||
assert.equal(referents.get(6), null);
|
||||
assert.equal(referents.get(-1)?.id, 'F5');
|
||||
});
|
||||
|
||||
|
||||
test('numbered custom-labeled pins retain their resolved coordinates', () => {
|
||||
const referents = createReferentRegistry();
|
||||
const result = attachVoiceResult('annotate_map', {
|
||||
ok: true,
|
||||
items: [{ ok: true, id: 'pin-1', label: 'Meeting point', latitude: 30.27, longitude: -97.74 }],
|
||||
});
|
||||
referents.recordResult('annotate_map', result);
|
||||
assert.deepEqual(resolveDeicticArgs('fly_to_location', { referent: 1 }, { referents }).args,
|
||||
{ latitude: 30.27, longitude: -97.74 });
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/** Seconds between the two soft "working" ticks. */
|
||||
const TICK_INTERVAL_S = 1.1;
|
||||
const TICK_GAIN = 0.035;
|
||||
|
||||
/**
|
||||
* A quiet, repeating two-note tick that says "still working" when nothing
|
||||
* else is audible. It is the floor under spoken progress, never a substitute
|
||||
* for it.
|
||||
*
|
||||
* `available()` gates it on the microphone being muted (push-to-talk after
|
||||
* release): with an open microphone a local sound could be heard as user
|
||||
* speech and end the turn, so an open-mic session relies on the voice card's
|
||||
* visual progress instead.
|
||||
* @param {{ isMicrophoneMuted: () => boolean, createContext?: () => AudioContext|null }} options
|
||||
*/
|
||||
export function createEarcon({
|
||||
isMicrophoneMuted,
|
||||
createContext = () => {
|
||||
const Context = globalThis.AudioContext || globalThis.webkitAudioContext;
|
||||
return Context ? new Context() : null;
|
||||
},
|
||||
} = {}) {
|
||||
let context = null;
|
||||
let timer = null;
|
||||
|
||||
function tick() {
|
||||
if (!context) return;
|
||||
const start = context.currentTime + 0.01;
|
||||
for (const [offset, frequency] of [
|
||||
[0, 880],
|
||||
[0.09, 1320],
|
||||
]) {
|
||||
const oscillator = context.createOscillator();
|
||||
const gain = context.createGain();
|
||||
oscillator.type = 'sine';
|
||||
oscillator.frequency.value = frequency;
|
||||
gain.gain.setValueAtTime(0, start + offset);
|
||||
gain.gain.linearRampToValueAtTime(TICK_GAIN, start + offset + 0.012);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, start + offset + 0.09);
|
||||
oscillator.connect(gain).connect(context.destination);
|
||||
oscillator.start(start + offset);
|
||||
oscillator.stop(start + offset + 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
available: () => Boolean(isMicrophoneMuted?.()),
|
||||
start() {
|
||||
if (timer) return;
|
||||
context ||= createContext();
|
||||
if (!context) return;
|
||||
context.resume?.().catch?.(() => {});
|
||||
tick();
|
||||
timer = setInterval(tick, TICK_INTERVAL_S * 1000);
|
||||
},
|
||||
stop() {
|
||||
if (timer) clearInterval(timer);
|
||||
timer = null;
|
||||
},
|
||||
dispose() {
|
||||
this.stop();
|
||||
context?.close?.().catch?.(() => {});
|
||||
context = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
+769
-223
File diff suppressed because it is too large.
Load diff
@@ -1,3 +1,4 @@
|
||||
import { createReferentRegistry } from './referents.js';
|
||||
import { createStandalonePlaceSearch } from '../standalone/placeSearch.js';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
@@ -20,6 +21,8 @@ import {
|
||||
} from './gevActions.js';
|
||||
import { MAP_STACKS } from '../mapStackController.js';
|
||||
import { GEV_REALTIME_TOOLS } from '../../server/providers/openai/tools.js';
|
||||
import { presentResult } from './resultDisplay.js';
|
||||
import { attachVoiceResult } from './speech.js';
|
||||
|
||||
test('every live basemap is reachable by its own id — no enum value without a voice alias', () => {
|
||||
// B1 regression: a stack added to MAP_STACKS (and the set_map_stack enum)
|
||||
@@ -730,7 +733,9 @@ test('Data Layers voice inventory hides the Context coordinator while current-vi
|
||||
const menu = await runner('show_data_layers_menu');
|
||||
assert.deepEqual(menu.layers.map(({ id }) => id), ['flights']);
|
||||
const current = await runner('get_current_view_state');
|
||||
assert.deepEqual(current.layers.map(({ id }) => id), ['flights', 'military-awareness']);
|
||||
// Enabled layers only; the disabled ones are counted, not listed.
|
||||
assert.deepEqual(current.layers.map(({ id }) => id), ['military-awareness']);
|
||||
assert.equal(current.disabledLayerCount, 1);
|
||||
// The Contacts mode's internal id is 'flights'; the tools accept 'contacts'.
|
||||
// State output reports the accepted word so the model cannot read its own
|
||||
// active context as "off", with the internal id kept for layer reasoning.
|
||||
@@ -1062,7 +1067,8 @@ test('generic layer visibility exposes lifecycle truth for every manager phase a
|
||||
layerId: 'radio',
|
||||
enabled: true,
|
||||
});
|
||||
assert.deepEqual(missingRadio, {
|
||||
const { say, display, ...missingRadioOutcome } = missingRadio;
|
||||
assert.deepEqual(missingRadioOutcome, {
|
||||
ok: false,
|
||||
action: 'set_layer_visibility',
|
||||
layerId: 'radio',
|
||||
@@ -1071,6 +1077,9 @@ test('generic layer visibility exposes lifecycle truth for every manager phase a
|
||||
lifecycleState: 'disabled',
|
||||
lifecycleUncertain: false,
|
||||
});
|
||||
// The voice envelope is additive and states the failure plainly.
|
||||
assert.equal(say, "Couldn't turn on radio.");
|
||||
assert.equal(display.title, 'radio');
|
||||
});
|
||||
|
||||
test('generic voice visibility maps Space Missions to the explicit mission layer', async () => {
|
||||
@@ -2812,8 +2821,58 @@ test('a nested Cockpit rollback result is translated too', async () => {
|
||||
*/
|
||||
function awarenessSubjectHarness({ subject, flights = [], military = [] }) {
|
||||
const position = subject ? { __subject: true } : null;
|
||||
const snapshotFlights = flights.slice(0, 20_000);
|
||||
const snapshotMilitary = military.slice(0, 20_000);
|
||||
return {
|
||||
snapshot: subject ? { subject: { ...subject, position }, radiusM: 250_000, cohorts: [] } : null,
|
||||
snapshot: subject
|
||||
? {
|
||||
subject: { ...subject, position },
|
||||
evaluatedAt: 1_790_782_400_000,
|
||||
radiusM: 250_000,
|
||||
cohorts: [
|
||||
{
|
||||
id: 'flights',
|
||||
count: snapshotFlights.length,
|
||||
complete: flights.length <= 20_000,
|
||||
},
|
||||
{
|
||||
id: 'military',
|
||||
count: snapshotMilitary.length,
|
||||
complete: military.length <= 20_000,
|
||||
},
|
||||
],
|
||||
}
|
||||
: null,
|
||||
aircraftSnapshot: subject
|
||||
? {
|
||||
subject: { ...subject, position },
|
||||
evaluatedAt: 1_790_782_400_000,
|
||||
radiusM: 250_000,
|
||||
cohorts: {
|
||||
flights: {
|
||||
count: snapshotFlights.length,
|
||||
complete: flights.length <= 20_000,
|
||||
truncated: flights.length > 20_000,
|
||||
items: snapshotFlights.map((item) => ({ ...item })),
|
||||
},
|
||||
military: {
|
||||
count: snapshotMilitary.length,
|
||||
complete: military.length <= 20_000,
|
||||
truncated: military.length > 20_000,
|
||||
items: snapshotMilitary.map((item) => ({ ...item })),
|
||||
},
|
||||
},
|
||||
contactsWindow: {
|
||||
centeredOn: subject.label || subject.id,
|
||||
radiusKm: 250,
|
||||
aircraft: snapshotFlights.length + snapshotMilitary.length,
|
||||
flights: snapshotFlights.length,
|
||||
military: snapshotMilitary.length,
|
||||
vessels: 'unknown',
|
||||
complete: flights.length <= 20_000 && military.length <= 20_000,
|
||||
},
|
||||
}
|
||||
: null,
|
||||
flights,
|
||||
military,
|
||||
};
|
||||
@@ -2825,11 +2884,40 @@ async function withAwareness(harness, run) {
|
||||
const militaryLayer = (await import('../data/militaryFlights.js')).default;
|
||||
const originals = {
|
||||
snapshot: awareness.getContextSnapshot,
|
||||
aircraftSnapshot: awareness.getAircraftQuerySnapshot,
|
||||
flightsNearby: flightsLayer.getNearby,
|
||||
militaryNearby: militaryLayer.getNearby,
|
||||
cartoFrom: Cesium.Cartographic.fromCartesian,
|
||||
};
|
||||
awareness.getContextSnapshot = () => harness.snapshot;
|
||||
awareness.getAircraftQuerySnapshot = () => {
|
||||
const snapshot = harness.aircraftSnapshot;
|
||||
if (!snapshot) return null;
|
||||
return {
|
||||
...snapshot,
|
||||
subject: { ...snapshot.subject },
|
||||
contactsWindow: snapshot.contactsWindow
|
||||
? { ...snapshot.contactsWindow }
|
||||
: null,
|
||||
cohorts: Object.fromEntries(
|
||||
Object.entries(snapshot.cohorts).map(([key, cohort]) => [
|
||||
key,
|
||||
{
|
||||
...cohort,
|
||||
provenance: cohort.provenance
|
||||
? {
|
||||
...cohort.provenance,
|
||||
stats: cohort.provenance.stats
|
||||
? { ...cohort.provenance.stats }
|
||||
: undefined,
|
||||
}
|
||||
: null,
|
||||
items: cohort.items.map((item) => ({ ...item })),
|
||||
},
|
||||
]),
|
||||
),
|
||||
};
|
||||
};
|
||||
flightsLayer.getNearby = () => harness.flights.slice();
|
||||
militaryLayer.getNearby = () => harness.military.slice();
|
||||
Cesium.Cartographic.fromCartesian = (value) => (
|
||||
@@ -2841,13 +2929,14 @@ async function withAwareness(harness, run) {
|
||||
return await run(awareness);
|
||||
} finally {
|
||||
awareness.getContextSnapshot = originals.snapshot;
|
||||
awareness.getAircraftQuerySnapshot = originals.aircraftSnapshot;
|
||||
flightsLayer.getNearby = originals.flightsNearby;
|
||||
militaryLayer.getNearby = originals.militaryNearby;
|
||||
Cesium.Cartographic.fromCartesian = originals.cartoFrom;
|
||||
}
|
||||
}
|
||||
|
||||
function analystRunner(awareness) {
|
||||
function analystRunner(awareness, { getAll } = {}) {
|
||||
const flights = {
|
||||
id: 'flights',
|
||||
// Deliberately a DIFFERENT population from the proximity window: this is
|
||||
@@ -2871,7 +2960,16 @@ function analystRunner(awareness) {
|
||||
dataManager: {
|
||||
layers: new Map([['flights', { module: flights }], ['military-awareness', { module: awareness }]]),
|
||||
isEnabled: (id) => id === 'flights',
|
||||
getAll: () => [{ id: 'flights', name: 'Live Flights', enabled: true, stats: { count: 1 } }],
|
||||
getAll:
|
||||
getAll ||
|
||||
(() => [
|
||||
{
|
||||
id: 'flights',
|
||||
name: 'Live Flights',
|
||||
enabled: true,
|
||||
stats: { count: 1 },
|
||||
},
|
||||
]),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -3294,3 +3392,496 @@ test('general satellite pass resolves once, refuses ambiguity, and preserves ISS
|
||||
assert.equal(iss.visible, false);
|
||||
assert.equal(iss.action, 'next_iss_pass');
|
||||
});
|
||||
|
||||
function manifestRunner(modules, { enabled = Object.keys(modules) } = {}) {
|
||||
const viewer = {
|
||||
clock: { onTick: { addEventListener: () => () => {} } },
|
||||
scene: { canvas: { addEventListener() {}, removeEventListener() {} } },
|
||||
camera: {
|
||||
moveEnd: { addEventListener() {} },
|
||||
positionCartographic: { height: 300_000, latitude: 0.52, longitude: -1.71 },
|
||||
},
|
||||
};
|
||||
const toggles = [];
|
||||
const runner = createGevActionRunner({
|
||||
viewer,
|
||||
styleManager: {},
|
||||
dataManager: {
|
||||
layers: new Map(Object.entries(modules).map(([id, module]) => [id, { module }])),
|
||||
isEnabled: (id) => enabled.includes(id),
|
||||
getAll: () => Object.entries(modules).map(([id, module]) => ({
|
||||
id, name: id, enabled: enabled.includes(id), stats: module.getStats?.() || {},
|
||||
})),
|
||||
async setEnabled(id, on) { toggles.push([id, on]); return true; },
|
||||
},
|
||||
});
|
||||
return { runner, toggles };
|
||||
}
|
||||
|
||||
test('analyst_query counts the whole loaded set, not a 2,000-record slice', async () => {
|
||||
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||
const rows = Array.from({ length: 8114 }, (_, i) => ({
|
||||
id: `F${i}`, icao24: `f${i}`, lat: 10 + (i % 50), lon: 20, altitudeM: 1000 + i, onGround: false,
|
||||
}));
|
||||
const flights = {
|
||||
getStats: () => ({ count: rows.length, lastUpdate: Date.now() }),
|
||||
getAnalystRecords: (maxCount = 2000) => rows.slice(0, maxCount),
|
||||
};
|
||||
const { runner } = manifestRunner({ flights });
|
||||
const result = await runner('analyst_query', { layers: ['flights'], scope: { kind: 'anywhere' }, sortBy: 'altitudeM', limit: 1 });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.count, 8114);
|
||||
assert.deepEqual(result.coverage.records, { returned: 8114, total: 8114, truncated: false });
|
||||
assert.equal(result.items[0].id, 'F8113');
|
||||
assert.equal(typeof result.items[0].lat, 'number', 'items carry lat/lon for a follow-up fly-to');
|
||||
assert.equal(typeof result.items[0].lon, 'number');
|
||||
assert.equal(result.display.scope, 'anywhere in the loaded data');
|
||||
assert.equal(result.truncated, undefined, 'list length is reported as listed, not as data truncation');
|
||||
assert.equal(result.listed, 1);
|
||||
});
|
||||
|
||||
test('analyst_query refusals carry their code and the allowed values', async () => {
|
||||
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||
const earthquakes = {
|
||||
getStats: () => ({ count: 1, lastUpdate: Date.now() }),
|
||||
getAnalystRecords: () => [{ id: 'q', lat: 0, lon: 0, magnitude: 5, timeMs: Date.now() }],
|
||||
};
|
||||
const transit = { getStats: () => ({ count: 0 }), getAnalystRecords: () => [] };
|
||||
const { runner } = manifestRunner({ earthquakes, transit }, { enabled: ['earthquakes'] });
|
||||
const unknown = await runner('analyst_query', { layers: ['earthquakes'], filters: [{ field: 'time', op: 'gte', value: '2026-09-16' }] });
|
||||
assert.equal(unknown.ok, false);
|
||||
assert.equal(unknown.code, 'UNKNOWN_FIELD');
|
||||
assert.ok(unknown.allowed.includes('timeMs') && unknown.allowed.includes('ageHours'));
|
||||
const off = await runner('analyst_query', { layers: ['transit'] });
|
||||
assert.equal(off.code, 'LAYER_OFF');
|
||||
const ok = await runner('analyst_query', { layers: ['earthquakes'], scope: { kind: 'anywhere' } });
|
||||
assert.equal(ok.display.window, 'last 24 h');
|
||||
assert.match(ok.items[0].time, /^\d{4}-\d\d-\d\dT/);
|
||||
});
|
||||
|
||||
test('spoken layer names from the manifest reach the newer layers', async () => {
|
||||
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||
const stub = () => ({ getStats: () => ({ count: 0 }) });
|
||||
const { runner, toggles } = manifestRunner(
|
||||
{ transit: stub(), 'weather-cyclones': stub(), 'military-installations': stub() },
|
||||
{ enabled: [] },
|
||||
);
|
||||
for (const phrase of ['buses', 'hurricanes', 'military bases']) {
|
||||
await runner('set_layer_visibility', { layerId: phrase, enabled: true }).catch(() => null);
|
||||
}
|
||||
assert.deepEqual(toggles.map(([id]) => id), ['transit', 'weather-cyclones', 'military-installations']);
|
||||
});
|
||||
|
||||
test('the Contacts window ranks, filters and formats like any analyst answer', async () => {
|
||||
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||
const harness = awarenessSubjectHarness({
|
||||
subject: { id: 'a1b2c3', label: 'N546PC' },
|
||||
flights: Array.from({ length: 20 }, (_, i) => ({ id: `F${i}`, icao24: `f${i}`, distance: 1000 * (i + 1) })),
|
||||
military: [],
|
||||
});
|
||||
await withAwareness(harness, async (awareness) => {
|
||||
const flights = {
|
||||
id: 'flights',
|
||||
getAnalystRecords: () => Array.from({ length: 20 }, (_, i) => ({
|
||||
id: `F${i}`, icao24: `f${i}`, lat: 30 + i / 100, lon: -97.9, altitudeM: 1000 * i,
|
||||
})),
|
||||
};
|
||||
const viewer = {
|
||||
clock: { onTick: { addEventListener: () => () => {} } },
|
||||
scene: { canvas: { addEventListener() {}, removeEventListener() {} } },
|
||||
camera: { moveEnd: { addEventListener() {} }, positionCartographic: { height: 300_000, latitude: 0.52, longitude: -1.71 } },
|
||||
};
|
||||
const runner = createGevActionRunner({
|
||||
viewer,
|
||||
styleManager: {},
|
||||
dataManager: {
|
||||
layers: new Map([['flights', { module: flights }], ['military-awareness', { module: awareness }]]),
|
||||
isEnabled: (id) => id === 'flights',
|
||||
getAll: () => [{ id: 'flights', name: 'Live Flights', enabled: true, stats: { count: 20 } }],
|
||||
},
|
||||
});
|
||||
const farthest = await runner('analyst_query', {
|
||||
layers: ['flights'], scope: { kind: 'radius', km: 250 }, sortBy: 'distance', sortDir: 'desc', limit: 1,
|
||||
});
|
||||
assert.equal(farthest.window.engine, 'contacts-window');
|
||||
assert.equal(farthest.count, 20, 'the unfiltered count is still the panel cohort');
|
||||
assert.equal(farthest.items[0].id, 'F19');
|
||||
assert.equal(farthest.items[0].distanceKm, 20);
|
||||
assert.equal(typeof farthest.items[0].lat, 'number');
|
||||
assert.equal(farthest.listed, 1);
|
||||
assert.match(farthest.display.scope, /Contacts window/);
|
||||
const high = await runner('analyst_query', {
|
||||
layers: ['flights'], scope: { kind: 'radius', km: 250 }, filters: [{ field: 'altitudeM', op: 'gte', value: 15000 }],
|
||||
});
|
||||
assert.equal(high.count, 5);
|
||||
});
|
||||
});
|
||||
|
||||
test('a cancelled Contacts scan returns cancellation and cannot seed follow-up memory', async () => {
|
||||
globalThis.window = globalThis.window || {
|
||||
clearTimeout,
|
||||
setTimeout,
|
||||
requestIdleCallback: null,
|
||||
};
|
||||
const harness = awarenessSubjectHarness({
|
||||
subject: { id: 'a1b2c3', label: 'N546PC' },
|
||||
flights: Array.from({ length: 20_000 }, (_, index) => ({
|
||||
id: `F${index}`,
|
||||
icao24: `f${index}`,
|
||||
distance: index,
|
||||
})),
|
||||
});
|
||||
await withAwareness(harness, async (awareness) => {
|
||||
const runner = analystRunner(awareness);
|
||||
let current = true;
|
||||
const pending = runner(
|
||||
'analyst_query',
|
||||
{
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'radius', km: 250 },
|
||||
},
|
||||
{ isCurrent: () => current },
|
||||
);
|
||||
current = false;
|
||||
const stale = await pending;
|
||||
assert.equal(stale.ok, false);
|
||||
assert.equal(stale.cancelled, true);
|
||||
assert.equal(stale.code, 'CANCELLED');
|
||||
|
||||
const followUp = await runner('analyst_query', { followUp: true });
|
||||
assert.equal(followUp.ok, false);
|
||||
assert.equal(followUp.code, 'NO_RESULT_CONTEXT');
|
||||
});
|
||||
});
|
||||
|
||||
test('overlapping analyst siblings from one response complete independently', async () => {
|
||||
globalThis.window = globalThis.window || {
|
||||
clearTimeout,
|
||||
setTimeout,
|
||||
requestIdleCallback: null,
|
||||
};
|
||||
const harness = awarenessSubjectHarness({
|
||||
subject: { id: 'a1b2c3', label: 'N546PC' },
|
||||
flights: Array.from({ length: 20_000 }, (_, index) => ({
|
||||
id: `F${index}`,
|
||||
icao24: `f${index}`,
|
||||
distance: index,
|
||||
})),
|
||||
});
|
||||
await withAwareness(harness, async (awareness) => {
|
||||
const runner = analystRunner(awareness);
|
||||
const older = runner('analyst_query', {
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'radius', km: 250 },
|
||||
});
|
||||
const sibling = await runner('analyst_query', {
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'anywhere' },
|
||||
});
|
||||
assert.equal(sibling.ok, true);
|
||||
assert.deepEqual(
|
||||
sibling.items.map((item) => item.id),
|
||||
['STALE1'],
|
||||
);
|
||||
assert.equal((await older).ok, true);
|
||||
});
|
||||
});
|
||||
|
||||
test('a capped Contacts snapshot is a lower bound from one immutable cohort', async () => {
|
||||
globalThis.window = globalThis.window || {
|
||||
clearTimeout,
|
||||
setTimeout,
|
||||
requestIdleCallback: null,
|
||||
};
|
||||
const harness = awarenessSubjectHarness({
|
||||
subject: { id: 'a1b2c3', label: 'N546PC' },
|
||||
flights: Array.from({ length: 20_001 }, (_, index) => ({
|
||||
id: `F${index}`,
|
||||
icao24: `f${index}`,
|
||||
distance: index,
|
||||
})),
|
||||
});
|
||||
await withAwareness(harness, async (awareness) => {
|
||||
let source = 'snapshot-a';
|
||||
const capture = awareness.getAircraftQuerySnapshot;
|
||||
awareness.getAircraftQuerySnapshot = () => {
|
||||
const frozen = capture();
|
||||
frozen.cohorts.flights.provenance = {
|
||||
id: 'flights',
|
||||
name: 'Live Flights',
|
||||
enabled: true,
|
||||
feedState: 'nominal',
|
||||
source,
|
||||
count: 20_000,
|
||||
lastUpdate: frozen.evaluatedAt,
|
||||
ageSec: 0,
|
||||
ageLabel: 'now',
|
||||
error: null,
|
||||
};
|
||||
harness.aircraftSnapshot.cohorts.flights.items.length = 1;
|
||||
harness.aircraftSnapshot.cohorts.flights.count = 1;
|
||||
harness.aircraftSnapshot.contactsWindow.aircraft = 1;
|
||||
harness.aircraftSnapshot.contactsWindow.flights = 1;
|
||||
queueMicrotask(() => {
|
||||
source = 'snapshot-b';
|
||||
});
|
||||
return frozen;
|
||||
};
|
||||
const pending = analystRunner(awareness, {
|
||||
// The live registry has already advanced before query entry. The answer
|
||||
// must still narrate the provenance retained with panel cohort A.
|
||||
getAll: () => [
|
||||
{
|
||||
id: 'flights',
|
||||
name: 'Live Flights',
|
||||
enabled: true,
|
||||
source: 'live-snapshot-b',
|
||||
stats: { count: 1, source: 'live-snapshot-b' },
|
||||
},
|
||||
],
|
||||
})('analyst_query', {
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'radius', km: 250 },
|
||||
});
|
||||
const result = await pending;
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.count, 20_000);
|
||||
assert.equal(result.complete, false);
|
||||
assert.deepEqual(result.coverage.records, {
|
||||
returned: 20_000,
|
||||
total: null,
|
||||
truncated: true,
|
||||
});
|
||||
assert.match(result.say, /^At least 20,000 aircraft within 250 km/);
|
||||
assert.equal(result.window.flights, 20_000);
|
||||
assert.equal(result.window.complete, false);
|
||||
assert.equal(result.contactsWindow.aircraft, 20_000);
|
||||
assert.equal(result.contactsWindow.flights, 20_000);
|
||||
assert.equal(result.feedProvenance.layers[0].source, 'snapshot-a');
|
||||
assert.equal(result.coverage.layersQueried[0].source, 'snapshot-a');
|
||||
});
|
||||
});
|
||||
|
||||
test('Contacts follow-ups filter the cohort that was actually displayed', async () => {
|
||||
globalThis.window = globalThis.window || {
|
||||
clearTimeout,
|
||||
setTimeout,
|
||||
requestIdleCallback: null,
|
||||
};
|
||||
const harness = awarenessSubjectHarness({
|
||||
subject: { id: 'a1b2c3', label: 'N546PC' },
|
||||
flights: Array.from({ length: 6 }, (_, index) => ({
|
||||
id: `F${index + 1}`,
|
||||
icao24: `f${index + 1}`,
|
||||
distance: index * 1000,
|
||||
})),
|
||||
});
|
||||
await withAwareness(harness, async (awareness) => {
|
||||
const runner = analystRunner(awareness);
|
||||
const first = await runner('analyst_query', {
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'radius', km: 250 },
|
||||
limit: 6,
|
||||
});
|
||||
assert.deepEqual(
|
||||
first.items.map((item) => item.id),
|
||||
['F1', 'F2', 'F3', 'F4', 'F5', 'F6'],
|
||||
);
|
||||
const followUp = await runner('analyst_query', {
|
||||
followUp: true,
|
||||
filters: [{ field: 'id', op: 'eq', value: 'F3' }],
|
||||
});
|
||||
assert.equal(followUp.count, 1);
|
||||
assert.equal(followUp.items[0].id, 'F3');
|
||||
assert.equal(followUp.scopeLabel, 'within 250 km of N546PC');
|
||||
assert.equal(
|
||||
followUp.display.scope,
|
||||
'within 250 km of N546PC (Contacts window)',
|
||||
);
|
||||
assert.equal(followUp.coverage.scope, 'window:250km@N546PC');
|
||||
assert.equal(
|
||||
presentResult('analyst_query', followUp).display.title,
|
||||
'1 aircraft within 250 km of N546PC',
|
||||
);
|
||||
assert.equal(
|
||||
attachVoiceResult('analyst_query', followUp).say,
|
||||
'1 aircraft within 250 km of N546PC.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('point-and-ask: the runner resolves pointer and referent targets', async () => {
|
||||
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||
const { viewer, styleManager } = createVoiceNavigationHarness();
|
||||
const tracked = [];
|
||||
const aircraft = {
|
||||
a1b2c3: { icao24: 'a1b2c3', callsign: 'UPS793', latitude: 30.2, longitude: -97.7, altitudeM: 9000 },
|
||||
bbb222: { icao24: 'bbb222', callsign: 'SWA12', latitude: 30.3, longitude: -97.6, altitudeM: 5000 },
|
||||
};
|
||||
let active = null;
|
||||
const referents = createReferentRegistry();
|
||||
const runner = createActionRunner({
|
||||
viewer,
|
||||
styleManager,
|
||||
dataManager: {
|
||||
layers: new Map([['flights', { module: {
|
||||
findByQuery: (q) => aircraft[q] || null,
|
||||
trackById: (id) => { tracked.push(id); return true; },
|
||||
} }]]),
|
||||
isEnabled: () => true,
|
||||
getAll: () => [],
|
||||
},
|
||||
deixis: { pointer: { activeSnapshot: () => active }, referents },
|
||||
});
|
||||
|
||||
const missing = await runner('track_entity', { query: 'pointer' });
|
||||
assert.equal(missing.ok, false);
|
||||
assert.match(missing.error, /Nothing is under the pointer/);
|
||||
assert.equal(tracked.length, 0, 'no pointer, no side effect');
|
||||
|
||||
active = { fresh: true, target: 'entity', entity: { layerId: 'flights', id: 'a1b2c3', label: 'UPS793', kind: 'aircraft' }, lat: 30.2, lon: -97.7 };
|
||||
const followed = await runner('track_entity', { query: 'pointer' });
|
||||
assert.equal(followed.ok, true);
|
||||
assert.deepEqual(tracked, ['a1b2c3'], 'tracks the pointed contact by identity');
|
||||
assert.deepEqual(followed.resolvedFrom, { source: 'pointer', label: 'UPS793', layerId: 'flights' });
|
||||
|
||||
referents.recordResult('frame_overhead', {
|
||||
ok: true,
|
||||
display: { title: '2 aircraft in frame' },
|
||||
referents: [
|
||||
{ n: 1, id: 'a1b2c3', label: 'UPS793', layerId: 'flights' },
|
||||
{ n: 2, id: 'bbb222', label: 'SWA12', layerId: 'flights' },
|
||||
],
|
||||
});
|
||||
const second = await runner('track_entity', { query: 'the second one', referent: 2 });
|
||||
assert.equal(second.ok, true);
|
||||
assert.equal(tracked.at(-1), 'bbb222');
|
||||
});
|
||||
|
||||
test('point-and-ask: only accepted, current results become the referent list', async () => {
|
||||
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||
const { viewer, styleManager } = createVoiceNavigationHarness();
|
||||
const referents = createReferentRegistry();
|
||||
let used = 0;
|
||||
let tracked = 0;
|
||||
const runner = createActionRunner({
|
||||
viewer,
|
||||
styleManager,
|
||||
dataManager: {
|
||||
layers: new Map([
|
||||
[
|
||||
'flights',
|
||||
{
|
||||
module: {
|
||||
findByQuery: () => ({
|
||||
icao24: 'ccc333',
|
||||
callsign: 'AAL9',
|
||||
latitude: 1,
|
||||
longitude: 2,
|
||||
}),
|
||||
trackById: () => {
|
||||
tracked++;
|
||||
return true;
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
]),
|
||||
isEnabled: () => true,
|
||||
getAll: () => [],
|
||||
},
|
||||
// A list-producing result, so the registry has something to record.
|
||||
speechBuilders: {
|
||||
track_entity: (result) => ({
|
||||
say: null,
|
||||
display: { title: 'Tracking' },
|
||||
referents: [{ n: 1, id: 'ccc333', label: result.label, layerId: 'flights' }],
|
||||
}),
|
||||
},
|
||||
deixis: {
|
||||
pointer: { activeSnapshot: () => ({ fresh: true, entity: { layerId: 'flights', id: 'ccc333', label: 'AAL9' }, lat: 1, lon: 2 }) },
|
||||
referents,
|
||||
onPointerUsed: () => used++,
|
||||
},
|
||||
});
|
||||
const aborted = new AbortController();
|
||||
aborted.abort();
|
||||
await runner('track_entity', { query: 'pointer' }, { signal: aborted.signal });
|
||||
assert.equal(referents.get(1), null, 'a cancelled action leaves the registry alone');
|
||||
assert.equal(used, 0, 'nor does it announce the pointer');
|
||||
assert.equal(tracked, 0, 'nor does it start tracking');
|
||||
await runner(
|
||||
'track_entity',
|
||||
{ query: 'pointer' },
|
||||
{ isCurrent: () => false },
|
||||
);
|
||||
assert.equal(referents.get(1), null, 'a superseded action leaves it alone');
|
||||
assert.equal(tracked, 0, 'a superseded action does not start tracking');
|
||||
await runner('track_entity', { query: 'pointer' });
|
||||
assert.equal(referents.get(1)?.id, 'ccc333');
|
||||
assert.equal(used, 1, 'a current pointer resolution announces the chip');
|
||||
assert.equal(tracked, 1);
|
||||
});
|
||||
|
||||
|
||||
test('numbered analyst records navigate using the current layer accessor', async () => {
|
||||
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||
const { viewer, styleManager } = createVoiceNavigationHarness();
|
||||
const referents = createReferentRegistry();
|
||||
let enabled = true;
|
||||
let records = [{ id: 'congress', name: 'Congress station', lat: 30.27, lon: -97.74 }];
|
||||
const runner = createActionRunner({
|
||||
viewer, styleManager,
|
||||
dataManager: {
|
||||
layers: new Map([['bikeshare', { module: { getAnalystRecords: () => records } }]]),
|
||||
isEnabled: () => enabled,
|
||||
getAll: () => [],
|
||||
},
|
||||
deixis: { referents },
|
||||
});
|
||||
referents.recordResult('analyst_query', { ok: true, items: [{ ...records[0], layerKey: 'bikeshare' }] });
|
||||
const result = await runner('fly_to_location', { referent: 1 });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.latitude, 30.27);
|
||||
assert.equal(result.longitude, -97.74);
|
||||
enabled = false;
|
||||
assert.equal((await runner('fly_to_location', { referent: 1 })).ok, false, 'disabled records cannot be revisited');
|
||||
enabled = true;
|
||||
records = [];
|
||||
assert.equal((await runner('fly_to_location', { referent: 1 })).ok, false, 'removed records are not resurrected');
|
||||
});
|
||||
|
||||
|
||||
test('analyst-only lookup distinguishes duplicate names and uses canonical aircraft IDs', async () => {
|
||||
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||
const { viewer, styleManager } = createVoiceNavigationHarness();
|
||||
const referents = createReferentRegistry();
|
||||
const stations = [
|
||||
{ id: 'Central Station', name: 'Central Station', lat: 30, lon: -97 },
|
||||
{ id: 'Central Station', name: 'Central Station', lat: 40, lon: -74 },
|
||||
];
|
||||
const aircraft = [{ id: 'UAL1', icao24: 'abc123', lat: 35, lon: -100 }];
|
||||
const runner = createActionRunner({
|
||||
viewer, styleManager,
|
||||
dataManager: {
|
||||
layers: new Map([
|
||||
['bikeshare', { module: { getAnalystRecords: () => stations } }],
|
||||
['local-adsb', { module: { getAnalystRecords: () => aircraft } }],
|
||||
]),
|
||||
isEnabled: () => true, getAll: () => [],
|
||||
},
|
||||
deixis: { referents },
|
||||
});
|
||||
referents.recordResult('analyst_query', { ok: true, items: stations.map(row => ({ ...row, layerKey: 'bikeshare' })) });
|
||||
const station = await runner('fly_to_location', { referent: 2 });
|
||||
assert.equal(station.ok, true);
|
||||
assert.equal(station.latitude, 40);
|
||||
assert.equal(station.longitude, -74);
|
||||
stations.pop();
|
||||
assert.equal((await runner('fly_to_location', { referent: 2 })).ok, false, 'a removed station must not resolve to its remaining namesake');
|
||||
referents.recordResult('analyst_query', { ok: true, items: aircraft.map(row => ({ ...row, layerKey: 'local-adsb' })) });
|
||||
aircraft[0].lat = 36;
|
||||
const plane = await runner('fly_to_location', { referent: 1 });
|
||||
assert.equal(plane.ok, true);
|
||||
assert.equal(plane.latitude, 36, 'canonical ID resolves the current position, not old coordinates');
|
||||
});
|
||||
@@ -2,6 +2,9 @@ import { toFunctionOutput } from '../tools/functions.js';
|
||||
import { GEV_ACTION_SCHEMAS } from './actionSchemas.js';
|
||||
import { createGevActionRunner } from './gevActions.js';
|
||||
import { createVoiceCommands } from './commands.js';
|
||||
import { cameraPoseKey, createPointerTracker } from './pointerContext.js';
|
||||
import { createPointerPicker } from './pointerPick.js';
|
||||
import { createReferentRegistry } from './referents.js';
|
||||
export * from './realtimeController.js';
|
||||
|
||||
const ACTION_NAMES = new Set(GEV_ACTION_SCHEMAS.map((schema) => schema.name));
|
||||
@@ -12,7 +15,7 @@ const ACTION_NAMES = new Set(GEV_ACTION_SCHEMAS.map((schema) => schema.name));
|
||||
*/
|
||||
export function withToolCatalog(runner, loadCatalog) {
|
||||
if (typeof loadCatalog !== 'function') return runner;
|
||||
return async function runGevTool(name, args, options = {}) {
|
||||
const runGevTool = async function runGevTool(name, args, options = {}) {
|
||||
if (ACTION_NAMES.has(name)) return runner(name, args, options);
|
||||
const catalog = await loadCatalog();
|
||||
if (!catalog?.get(name)) return runner(name, args, options);
|
||||
@@ -21,15 +24,47 @@ export function withToolCatalog(runner, loadCatalog) {
|
||||
});
|
||||
return toFunctionOutput(name, result);
|
||||
};
|
||||
runGevTool.resetConversation = (...args) =>
|
||||
runner.resetConversation?.(...args);
|
||||
runGevTool.dispose = (...args) => runner.dispose?.(...args);
|
||||
return runGevTool;
|
||||
}
|
||||
|
||||
/** Compose the standalone action runner with the voice controls. */
|
||||
export function initGevVoiceCommands(options) {
|
||||
return createVoiceCommands({
|
||||
const canvas = options.viewer?.scene?.canvas || null;
|
||||
const cameraKey = () => cameraPoseKey(options.viewer);
|
||||
// Point-and-ask: the tracker and referents are shared by the runner (which
|
||||
// resolves 'pointer' and referent:n) and the controls (which snapshot the
|
||||
// pointer at turn start and clear both when the session ends).
|
||||
const pointer = canvas
|
||||
? createPointerTracker({
|
||||
element: canvas,
|
||||
cameraKey,
|
||||
pick: createPointerPicker({
|
||||
viewer: options.viewer,
|
||||
dataManager: options.dataManager,
|
||||
}),
|
||||
})
|
||||
: null;
|
||||
const referents = createReferentRegistry();
|
||||
// The controller exists only after the runner, so its hooks bind late.
|
||||
const deixis = { pointer, referents, cameraKey };
|
||||
const commands = createVoiceCommands({
|
||||
...options,
|
||||
pointer,
|
||||
referents,
|
||||
runner: withToolCatalog(
|
||||
createGevActionRunner(options),
|
||||
createGevActionRunner({ ...options, deixis }),
|
||||
options.toolCatalog,
|
||||
),
|
||||
});
|
||||
deixis.imageFrame = () => commands.retainedImageFrame?.() || null;
|
||||
deixis.onPointerUsed = () => commands.announcePointer?.();
|
||||
commands.session?.signal?.addEventListener(
|
||||
'abort',
|
||||
() => pointer?.destroy(),
|
||||
{ once: true },
|
||||
);
|
||||
return commands;
|
||||
}
|
||||
@@ -3195,6 +3195,54 @@ test('F1: the toggle still records the next-session preference while live', () =
|
||||
assert.match(ui.tierButton.title, /this session stays on/i);
|
||||
});
|
||||
|
||||
test('OAuth cloud sessions report unknown cost and usage without tripping the API spend guard', () => {
|
||||
const { controller, ui } = costControllerHarness();
|
||||
controller.status = 'listening';
|
||||
controller._cost.cloudVoiceAuth = 'oauth';
|
||||
controller._cost.sessionCloudVoiceAuth = 'oauth';
|
||||
controller.syncCostUi();
|
||||
const before = controller.costTracker.state();
|
||||
assert.equal(ui.costValue.hidden, false);
|
||||
assert.equal(ui.costValue.textContent, 'COST UNKNOWN');
|
||||
assert.deepEqual(controller.recordUsage(usdUsage(100)), {
|
||||
responses: 1,
|
||||
input: 0,
|
||||
output: 1562500,
|
||||
captions: 0,
|
||||
costKnown: false,
|
||||
});
|
||||
const after = controller.costTracker.state();
|
||||
assert.equal(after.totalUsd, before.totalUsd);
|
||||
assert.equal(controller.costCapStopped, false);
|
||||
assert.match(ui.costValue.title, /1 response\(s\).*1562500 output tokens/);
|
||||
controller.dc = {};
|
||||
controller.responseActive = true;
|
||||
controller.stop();
|
||||
assert.match(ui.costValue.title, /Usage is incomplete/);
|
||||
});
|
||||
|
||||
test('OAuth cloud sessions count caption transcriptions without pricing them into the API cap', () => {
|
||||
const { controller, ui } = costControllerHarness();
|
||||
controller.status = 'listening';
|
||||
controller._cost.cloudVoiceAuth = 'oauth';
|
||||
controller._cost.sessionCloudVoiceAuth = 'oauth';
|
||||
controller._cost.bindTranscriptionModel('gpt-4o-mini-transcribe');
|
||||
const before = controller.costTracker.state();
|
||||
// Priced, this much caption audio would cross the $5 cap.
|
||||
const result = controller.recordTranscriptionUsage({
|
||||
type: 'duration',
|
||||
seconds: 3_600_000,
|
||||
});
|
||||
assert.equal(result.captions, 1);
|
||||
assert.equal(result.costKnown, false);
|
||||
const after = controller.costTracker.state();
|
||||
assert.equal(after.totalUsd, before.totalUsd);
|
||||
assert.equal(after.capReached, false);
|
||||
assert.equal(controller.costCapStopped, false);
|
||||
assert.equal(ui.costValue.textContent, 'COST UNKNOWN');
|
||||
assert.match(ui.costValue.title, /1 caption transcription\(s\)/);
|
||||
});
|
||||
|
||||
test('F1: when idle, toggling does re-price the preview meter', () => {
|
||||
const { controller } = costControllerHarness();
|
||||
controller.status = 'idle';
|
||||
@@ -3679,6 +3727,50 @@ test('a genuinely different refused call still gets its own output', async () =>
|
||||
assert.deepEqual(outputs, ['call_one', 'call_two'], 'each distinct call is answered');
|
||||
});
|
||||
|
||||
test('a claimed Space hold barges in on the assistant; a short tap never does', (t) => {
|
||||
const f = createPushToTalkFixture(t);
|
||||
const canvas = pushToTalkTarget({ tagName: 'CANVAS', id: 'world-overlay-canvas', tabIndex: 0 });
|
||||
let bargeIns = 0;
|
||||
f.controller._turns.bargeIn = () => {
|
||||
bargeIns += 1;
|
||||
return true;
|
||||
};
|
||||
// First hold starts the push-to-talk session; nothing to interrupt yet.
|
||||
f.key('keydown', canvas);
|
||||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
|
||||
f.key('keyup', canvas);
|
||||
assert.equal(bargeIns, 0);
|
||||
// A short tap during the reply stays a tap.
|
||||
f.key('keydown', canvas);
|
||||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS - 1);
|
||||
f.key('keyup', canvas);
|
||||
assert.equal(bargeIns, 0);
|
||||
// Only the 500 ms claim interrupts, and the microphone opens as before.
|
||||
f.key('keydown', canvas);
|
||||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
|
||||
assert.equal(bargeIns, 1);
|
||||
assert.equal(f.microphone.enabled, true);
|
||||
f.key('keyup', canvas);
|
||||
// Radio's claim is read before Radio is paused, and a held handoff is
|
||||
// never interrupted.
|
||||
const order = [];
|
||||
const input = f.controller._input;
|
||||
input.mayClaimSpeaker = () => {
|
||||
order.push('claim');
|
||||
return false;
|
||||
};
|
||||
const pause = input.pauseRadioForVoice;
|
||||
input.pauseRadioForVoice = (...args) => {
|
||||
order.push('pause');
|
||||
return pause(...args);
|
||||
};
|
||||
f.key('keydown', canvas);
|
||||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
|
||||
assert.deepEqual(order.slice(0, 2), ['claim', 'pause']);
|
||||
assert.equal(bargeIns, 1, 'no barge-in while Radio holds the speaker');
|
||||
f.key('keyup', canvas);
|
||||
});
|
||||
|
||||
const testPlaceSearch = () => createStandalonePlaceSearch({ resolveApiKey: () => globalThis.window?.__GOOGLE_MAPS_API_KEY__ });
|
||||
function createGevActionRunner(options) { return createActionRunner({ placeSearch: testPlaceSearch(), ...options }); }
|
||||
function controlRadio(viewer, manager, args, options) { return runControlRadio(viewer, manager, args, { placeSearch: testPlaceSearch(), ...options }); }
|
||||
@@ -3689,6 +3781,16 @@ test('voice runs app actions itself and other tools through the catalog', async
|
||||
actions.push([name, args]);
|
||||
return { ok: true, action: name };
|
||||
};
|
||||
const resets = [];
|
||||
const disposals = [];
|
||||
runner.resetConversation = (...args) => {
|
||||
resets.push(args);
|
||||
return 'reset';
|
||||
};
|
||||
runner.dispose = (...args) => {
|
||||
disposals.push(args);
|
||||
return 'disposed';
|
||||
};
|
||||
const calls = [];
|
||||
let loads = 0;
|
||||
const catalog = {
|
||||
@@ -3720,5 +3822,9 @@ test('voice runs app actions itself and other tools through the catalog', async
|
||||
actions.map(([name]) => name),
|
||||
['zoom_to_globe', 'not_a_tool'],
|
||||
);
|
||||
assert.equal(run.resetConversation('stopped'), 'reset');
|
||||
assert.equal(run.dispose('removed'), 'disposed');
|
||||
assert.deepEqual(resets, [['stopped']]);
|
||||
assert.deepEqual(disposals, [['removed']]);
|
||||
assert.equal(withToolCatalog(runner, undefined), runner);
|
||||
});
|
||||
Loaded 100 of 182 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user