Files
gods-eye-view/src/voice/cctvRouting.test.mjs
T

497 lines
15 KiB
JavaScript

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' }],
]);
},
);