mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Merge pull request #1205 from manjunath22466/fix/cctv-tool-routing
fix(voice): report actual cctv toggle results
This commit is contained in:
4 files changed
+521
-5
No files matched your search
@@ -0,0 +1,3 @@
|
||||
- Report actual outcomes when voice enables or disables CCTV through the shared
|
||||
layer lifecycle, including failed and cancelled changes (manjunath22466,
|
||||
#1205).
|
||||
@@ -633,6 +633,14 @@ The standalone app offers OpenAI and Gemini Live through an explicit provider ch
|
||||
|
||||
Voice action argument schemas have one portable owner under `src/voice/actionSchemas.js`. The Realtime provider builds 30 tools using separate description-only metadata. Description customization cannot replace argument types, enum values or required fields.
|
||||
|
||||
`createGevActionRunner` routes `control_cctv` enable/disable through the shared
|
||||
layer visibility lifecycle. Results distinguish successful changes from
|
||||
manager-reported failures or cancellation and report enabled state and lifecycle
|
||||
uncertainty. A caller-supplied abort signal reaches the manager; a late abort
|
||||
preserves an already committed change. Missing-layer errors and other CCTV
|
||||
actions retain their existing behavior. The exported `controlCctv` helper refuses direct
|
||||
enable/disable requests and directs callers to `set_layer_visibility`.
|
||||
|
||||
`src/voice/layerManifest.js` declares each catalog layer's voice capabilities: spoken aliases, `toggle`, in-view `context`, `track`, and analyst `query` fields with type and unit, or `noQuery` with the reason. The `set_layer_visibility`, `show_data_layers_menu`, `get_entity_context` and `analyst_query` layer enums, the runner's alias table and the analyst field hints are generated from it. `src/voice/layerManifest.test.mjs` fails when a catalog layer is neither listed nor named in `VOICE_OFF_LAYERS` (today only the two scene-driven Bhote Koshi layers). Global Context is listed with `toggle: false`; `set_context_mode` reaches it. `set_panel_open` also opens `weather-panel` and `recent-imagery-panel`.
|
||||
|
||||
Portable source exports provide Radio Browser station normalization, CCTV feed types and regional records independently of HTTP middleware. Radio normalization accepts an explicit URL policy; the standalone directory retains its existing HTTPS rules. Tile coordinate validation accepts explicit zoom bounds with unchanged traffic defaults. Cache, request and rendering owners remain unchanged.
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
import { after, before, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DataLayerManager } from '../data/manager.js';
|
||||
import { CCTV_FOCUS_RESULT } from '../layers/cctv/index.js';
|
||||
import { controlCctv, createGevActionRunner } from './gevActions.js';
|
||||
|
||||
const originalWindow = globalThis.window;
|
||||
before(() => {
|
||||
globalThis.window ??= { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||
});
|
||||
after(() => {
|
||||
if (originalWindow === undefined) delete globalThis.window;
|
||||
else globalThis.window = originalWindow;
|
||||
});
|
||||
|
||||
function viewerFixture() {
|
||||
return {
|
||||
clock: { onTick: { addEventListener: () => () => {} } },
|
||||
scene: { canvas: { addEventListener() {}, removeEventListener() {} } },
|
||||
camera: { moveEnd: { addEventListener() {} } },
|
||||
};
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function runnerFixture(t, dataManager, options = {}) {
|
||||
const runner = createGevActionRunner({
|
||||
viewer: viewerFixture(),
|
||||
styleManager: {},
|
||||
dataManager,
|
||||
...options,
|
||||
});
|
||||
t.after(() => runner.dispose());
|
||||
return runner;
|
||||
}
|
||||
|
||||
// Only the inert module's lifecycle completion is controlled. The manager's
|
||||
// intent queue, cancellation, compensation, events, and runner are production.
|
||||
async function lifecycleFixture(t, targetEnabled) {
|
||||
const viewer = viewerFixture();
|
||||
const manager = new DataLayerManager(viewer);
|
||||
const calls = [];
|
||||
const gates = [];
|
||||
const pending = [];
|
||||
let nextGate = null;
|
||||
let moduleEnabled = false;
|
||||
const phase = targetEnabled ? 'enable' : 'disable';
|
||||
const lifecycle = async (name, options = {}) => {
|
||||
calls.push(name);
|
||||
if (name === phase && nextGate) {
|
||||
const gate = nextGate;
|
||||
nextGate = null;
|
||||
gate.entered.resolve(options.signal);
|
||||
await gate.release.promise;
|
||||
if (gate.outcome instanceof Error) throw gate.outcome;
|
||||
if (gate.outcome === false) return false;
|
||||
}
|
||||
moduleEnabled = name === 'enable';
|
||||
return true;
|
||||
};
|
||||
manager.register({
|
||||
id: 'cctv',
|
||||
name: 'CCTV',
|
||||
source: 'test',
|
||||
updateInterval: -1,
|
||||
init: async () => true,
|
||||
enable: (_viewer, options) => lifecycle('enable', options),
|
||||
disable: (_viewer, options) => lifecycle('disable', options),
|
||||
update: async () => true,
|
||||
getStats: () => ({ count: 0, lastUpdate: 0 }),
|
||||
});
|
||||
if (!targetEnabled)
|
||||
assert.equal(await manager.setEnabled('cctv', true), true);
|
||||
calls.length = 0;
|
||||
const events = [];
|
||||
const unsubscribe = manager.subscribe((event) => events.push(event));
|
||||
const runner = runnerFixture(t, manager, { viewer });
|
||||
t.after(async () => {
|
||||
for (const gate of gates) gate.release.resolve();
|
||||
await Promise.allSettled(pending);
|
||||
unsubscribe();
|
||||
await manager.destroyAll();
|
||||
});
|
||||
return {
|
||||
manager,
|
||||
events,
|
||||
calls,
|
||||
get moduleEnabled() {
|
||||
return moduleEnabled;
|
||||
},
|
||||
gate(outcome = true) {
|
||||
const gate = { entered: deferred(), release: deferred(), outcome };
|
||||
gates.push(gate);
|
||||
nextGate = gate;
|
||||
return gate;
|
||||
},
|
||||
run(action, options = {}) {
|
||||
const work = runner('control_cctv', { action }, options);
|
||||
pending.push(work);
|
||||
return work;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function assertLifecycle(result, manager, enabled, uncertain = false) {
|
||||
assert.equal(result.action, 'control_cctv');
|
||||
assert.equal(result.layerId, 'cctv');
|
||||
assert.equal(result.enabled, enabled);
|
||||
assert.equal(result.lifecycleState, enabled ? 'enabled' : 'disabled');
|
||||
assert.equal(result.lifecycleUncertain, uncertain);
|
||||
assert.deepEqual(manager.getLayerLifecycleState('cctv'), {
|
||||
enabled,
|
||||
lifecycleState: result.lifecycleState,
|
||||
uncertain,
|
||||
});
|
||||
}
|
||||
|
||||
const terminalTypes = new Set([
|
||||
'visibility',
|
||||
'visibility-cancelled',
|
||||
'visibility-failed',
|
||||
'visibility-blocked',
|
||||
]);
|
||||
|
||||
for (const action of ['enable', 'disable']) {
|
||||
const targetEnabled = action === 'enable';
|
||||
for (const outcome of [
|
||||
'success',
|
||||
'false',
|
||||
'throw',
|
||||
'caller-abort',
|
||||
'resource-abort',
|
||||
'late-abort',
|
||||
]) {
|
||||
test(
|
||||
`CCTV ${action}: ${outcome} preserves the manager outcome`,
|
||||
{ timeout: 2000 },
|
||||
async (t) => {
|
||||
const h = await lifecycleFixture(t, targetEnabled);
|
||||
const controller = new AbortController();
|
||||
const failure =
|
||||
outcome === 'resource-abort'
|
||||
? Object.assign(new Error('fixture resource ended'), {
|
||||
name: 'AbortError',
|
||||
})
|
||||
: outcome === 'throw'
|
||||
? new Error('fixture lifecycle failed')
|
||||
: null;
|
||||
const gate = h.gate(failure || (outcome === 'false' ? false : true));
|
||||
let lateAbortObserved = false;
|
||||
if (outcome === 'late-abort') {
|
||||
t.after(
|
||||
h.manager.subscribe((event) => {
|
||||
if (event.type === 'visibility' && event.origin === 'voice') {
|
||||
lateAbortObserved = true;
|
||||
controller.abort();
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
let settled = false;
|
||||
// Uppercase exercises the existing case-insensitive action contract.
|
||||
const work = h.run(
|
||||
outcome === 'success' ? action.toUpperCase() : action,
|
||||
{
|
||||
signal: controller.signal,
|
||||
isCurrent: () => !controller.signal.aborted,
|
||||
},
|
||||
);
|
||||
void work.then(
|
||||
() => {
|
||||
settled = true;
|
||||
},
|
||||
() => {
|
||||
settled = true;
|
||||
},
|
||||
);
|
||||
const moduleSignal = await gate.entered.promise;
|
||||
assert.equal(
|
||||
settled,
|
||||
false,
|
||||
'the tool must wait for lifecycle completion',
|
||||
);
|
||||
if (outcome === 'caller-abort') {
|
||||
controller.abort();
|
||||
assert.ok(
|
||||
moduleSignal,
|
||||
'the manager must receive a lifecycle signal',
|
||||
);
|
||||
assert.equal(
|
||||
moduleSignal.aborted,
|
||||
true,
|
||||
'caller abort must reach the manager lifecycle',
|
||||
);
|
||||
}
|
||||
gate.release.resolve();
|
||||
const result = await work;
|
||||
const committed = outcome === 'success' || outcome === 'late-abort';
|
||||
const cancelled =
|
||||
outcome === 'caller-abort' || outcome === 'resource-abort';
|
||||
assert.equal(result.ok, committed);
|
||||
assert.equal(result.cancelled, cancelled ? true : undefined);
|
||||
assertLifecycle(
|
||||
result,
|
||||
h.manager,
|
||||
committed ? targetEnabled : !targetEnabled,
|
||||
!targetEnabled && (outcome === 'false' || outcome === 'throw'),
|
||||
);
|
||||
assert.equal(
|
||||
h.moduleEnabled,
|
||||
committed ? targetEnabled : !targetEnabled,
|
||||
'the module must match the committed or compensated manager state',
|
||||
);
|
||||
if (cancelled) {
|
||||
assert.equal(result.cancellationReason, outcome);
|
||||
assert.equal(result.phase, action);
|
||||
assert.equal(result.successorIntentEpoch, null);
|
||||
assert.equal(result.successorEnabled, null);
|
||||
assert.equal(result.successorOrigin, null);
|
||||
} else if (!committed) {
|
||||
assert.equal(result.error, `Could not ${action} the requested layer`);
|
||||
}
|
||||
const terminal = h.events.filter(
|
||||
(event) => event.origin === 'voice' && terminalTypes.has(event.type),
|
||||
);
|
||||
assert.equal(
|
||||
terminal.length,
|
||||
1,
|
||||
'each voice intent has one terminal manager event',
|
||||
);
|
||||
assert.equal(
|
||||
terminal[0].type,
|
||||
committed
|
||||
? 'visibility'
|
||||
: cancelled
|
||||
? 'visibility-cancelled'
|
||||
: 'visibility-failed',
|
||||
);
|
||||
if (outcome === 'resource-abort')
|
||||
assert.equal(controller.signal.aborted, false);
|
||||
if (outcome === 'late-abort') assert.equal(lateAbortObserved, true);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test(
|
||||
`CCTV ${action}: already-aborted caller cannot enter lifecycle work`,
|
||||
{ timeout: 2000 },
|
||||
async (t) => {
|
||||
const h = await lifecycleFixture(t, targetEnabled);
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const result = await h.run(action, { signal: controller.signal });
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.cancelled, true);
|
||||
assert.equal(result.cancellationReason, 'caller-abort');
|
||||
assert.equal(result.phase, 'queued');
|
||||
assertLifecycle(result, h.manager, !targetEnabled);
|
||||
assert.equal(h.moduleEnabled, !targetEnabled);
|
||||
assert.deepEqual(h.calls, []);
|
||||
assert.equal(
|
||||
h.events.filter((event) => terminalTypes.has(event.type)).length,
|
||||
1,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test(
|
||||
'CCTV toggles retain missing-module errors without calling visibility',
|
||||
{ timeout: 2000 },
|
||||
async (t) => {
|
||||
for (const layers of [new Map(), new Map([['cctv', {}]])]) {
|
||||
const runner = runnerFixture(t, {
|
||||
layers,
|
||||
getAll: () => [],
|
||||
setEnabled: () =>
|
||||
assert.fail('missing CCTV must not dispatch visibility'),
|
||||
});
|
||||
for (const action of ['enable', 'disable']) {
|
||||
assert.deepEqual(await runner('control_cctv', { action }), {
|
||||
ok: false,
|
||||
action: 'control_cctv',
|
||||
error: 'CCTV layer unavailable',
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('exported controlCctv refuses toggles without calling the manager', async () => {
|
||||
const manager = {
|
||||
layers: new Map([['cctv', { module: {} }]]),
|
||||
setEnabled: () => assert.fail('direct helper must not change visibility'),
|
||||
isEnabled: () => assert.fail('direct toggle must not read enabled state'),
|
||||
};
|
||||
for (const action of ['ENABLE', 'disable']) {
|
||||
const result = await controlCctv(manager, { action });
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.action, 'control_cctv');
|
||||
assert.match(result.error, /set_layer_visibility/);
|
||||
}
|
||||
});
|
||||
|
||||
test(
|
||||
'CCTV fallback managers report rejection, transitional state, and uncertainty',
|
||||
{ timeout: 2000 },
|
||||
async (t) => {
|
||||
for (const enabled of [true, false]) {
|
||||
const action = enabled ? 'enable' : 'disable';
|
||||
for (const mode of [
|
||||
'rejection',
|
||||
'mismatch',
|
||||
'transitional',
|
||||
'uncertain',
|
||||
]) {
|
||||
const actualEnabled =
|
||||
mode === 'rejection' || mode === 'mismatch' ? !enabled : enabled;
|
||||
const lifecycleState =
|
||||
mode === 'transitional'
|
||||
? enabled
|
||||
? 'enabling'
|
||||
: 'disabling'
|
||||
: actualEnabled
|
||||
? 'enabled'
|
||||
: 'disabled';
|
||||
const manager = {
|
||||
layers: new Map([['cctv', { module: {} }]]),
|
||||
getAll: () => [{ id: 'cctv', name: 'CCTV' }],
|
||||
isEnabled: () => actualEnabled,
|
||||
getLayerLifecycleState: () => ({
|
||||
enabled: actualEnabled,
|
||||
lifecycleState,
|
||||
uncertain: mode === 'uncertain',
|
||||
}),
|
||||
async setEnabled() {
|
||||
if (mode === 'rejection') throw new Error('adapter rejected');
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const runner = runnerFixture(t, manager);
|
||||
const result = await runner('control_cctv', { action });
|
||||
assert.equal(result.ok, false, `${action}: ${mode}`);
|
||||
assert.equal(result.action, 'control_cctv');
|
||||
assert.equal(result.enabled, actualEnabled);
|
||||
assert.equal(result.lifecycleState, lifecycleState);
|
||||
assert.equal(result.lifecycleUncertain, mode === 'uncertain');
|
||||
assert.equal(
|
||||
result.error,
|
||||
mode === 'rejection'
|
||||
? 'adapter rejected'
|
||||
: `Could not ${action} the requested layer`,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'CCTV visibility composes only its outer speech envelope',
|
||||
{ timeout: 2000 },
|
||||
async (t) => {
|
||||
let enabled = false;
|
||||
const calls = [];
|
||||
const runner = runnerFixture(
|
||||
t,
|
||||
{
|
||||
layers: new Map([['cctv', { module: {} }]]),
|
||||
getAll: () => [{ id: 'cctv', name: 'CCTV' }],
|
||||
isEnabled: () => enabled,
|
||||
async setEnabled(_id, value) {
|
||||
enabled = value;
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
speechBuilders: {
|
||||
control_cctv(result, args) {
|
||||
calls.push(['cctv', result.action, args.action]);
|
||||
return {
|
||||
say: 'CCTV response',
|
||||
display: { title: 'CCTV', lines: [] },
|
||||
};
|
||||
},
|
||||
set_layer_visibility() {
|
||||
calls.push(['generic']);
|
||||
return { say: 'Layer response' };
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
const result = await runner('control_cctv', { action: 'enable' });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.action, 'control_cctv');
|
||||
assert.equal(result.say, 'CCTV response');
|
||||
assert.deepEqual(result.display, { title: 'CCTV', lines: [] });
|
||||
assert.deepEqual(calls, [['cctv', 'control_cctv', 'enable']]);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'CCTV non-toggle actions retain camera navigation and durable coverage',
|
||||
{ timeout: 2000 },
|
||||
async (t) => {
|
||||
const calls = [];
|
||||
const cameras = [
|
||||
{ id: 'a', name: 'Camera A' },
|
||||
{ id: 'b', name: 'Camera B' },
|
||||
];
|
||||
let activeCameraId = 'a';
|
||||
let coverageMode = 'viewshed';
|
||||
const module = {
|
||||
getUIState: () => ({
|
||||
cameras,
|
||||
activeCameraId,
|
||||
activeCamera: cameras.find((camera) => camera.id === activeCameraId),
|
||||
coverageMode,
|
||||
showCoverage: coverageMode !== 'off',
|
||||
}),
|
||||
selectCamera(id) {
|
||||
calls.push(['select', id]);
|
||||
activeCameraId = id;
|
||||
return true;
|
||||
},
|
||||
cycleCamera(step) {
|
||||
calls.push(['cycle', step]);
|
||||
activeCameraId = step > 0 ? 'b' : 'a';
|
||||
return activeCameraId;
|
||||
},
|
||||
focusNearest(options) {
|
||||
calls.push(['nearest', options]);
|
||||
activeCameraId = 'a';
|
||||
return 'a';
|
||||
},
|
||||
focusCamera(id, duration) {
|
||||
calls.push(['focus', id, duration]);
|
||||
return CCTV_FOCUS_RESULT.TRACKING_HOLDS_VIEW;
|
||||
},
|
||||
};
|
||||
let superseded = 0;
|
||||
const runner = runnerFixture(
|
||||
t,
|
||||
{
|
||||
layers: new Map([['cctv', { module }]]),
|
||||
isEnabled: () => true,
|
||||
setEnabled: () =>
|
||||
assert.fail('non-toggle action must not dispatch visibility'),
|
||||
setLayerParams(id, params, options) {
|
||||
calls.push(['params', id, params, options]);
|
||||
coverageMode = params.coverageMode;
|
||||
},
|
||||
},
|
||||
{
|
||||
styleManager: {
|
||||
supersedeDeferredNavigation() {
|
||||
superseded += 1;
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
for (const args of [
|
||||
{ action: 'select', cameraQuery: 'Camera B' },
|
||||
{ action: 'next' },
|
||||
{ action: 'prev' },
|
||||
{ action: 'nearest' },
|
||||
{ action: 'focus' },
|
||||
]) {
|
||||
const result = await runner('control_cctv', args);
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.action, 'control_cctv');
|
||||
assert.match(result.error, /tracking holds the view/);
|
||||
}
|
||||
const coverage = await runner('control_cctv', { action: 'coverage' });
|
||||
assert.equal(coverage.ok, true);
|
||||
assert.equal(coverage.coverageMode, 'off');
|
||||
assert.equal(superseded, 5, 'coverage must not acquire camera navigation');
|
||||
assert.deepEqual(calls, [
|
||||
['select', 'b'],
|
||||
['focus', 'b', 1.8],
|
||||
['cycle', 1],
|
||||
['focus', 'b', 1.8],
|
||||
['cycle', -1],
|
||||
['focus', 'a', 1.8],
|
||||
['nearest', { focus: false }],
|
||||
['focus', 'a', 1.8],
|
||||
['focus', 'a', undefined],
|
||||
['params', 'cctv', { coverageMode: 'off' }, { origin: 'voice' }],
|
||||
]);
|
||||
},
|
||||
);
|
||||
+14
-5
@@ -1092,6 +1092,18 @@ export function createGevActionRunner({
|
||||
}
|
||||
|
||||
if (name === 'control_cctv') {
|
||||
const action = String(args.action || '').toLowerCase();
|
||||
if (
|
||||
(action === 'enable' || action === 'disable') &&
|
||||
dataManager.layers.get('cctv')?.module
|
||||
) {
|
||||
const result = await runGevAction(
|
||||
'set_layer_visibility',
|
||||
{ layerId: 'cctv', enabled: action === 'enable' },
|
||||
runOptions,
|
||||
);
|
||||
return { ...result, action: 'control_cctv' };
|
||||
}
|
||||
return controlCctv(dataManager, args, styleManager);
|
||||
}
|
||||
|
||||
@@ -1432,13 +1444,10 @@ export async function controlCctv(dataManager, args = {}, styleManager = null) {
|
||||
}
|
||||
|
||||
if (action === 'enable' || action === 'disable') {
|
||||
await dataManager.setEnabled('cctv', action === 'enable', {
|
||||
origin: 'voice',
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
ok: false,
|
||||
action: 'control_cctv',
|
||||
enabled: dataManager.isEnabled('cctv'),
|
||||
error: 'Enable or disable CCTV through set_layer_visibility',
|
||||
};
|
||||
}
|
||||
if (!dataManager.isEnabled('cctv')) {
|
||||
|
||||
Reference in new issue
Block a user