mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
497 lines
15 KiB
JavaScript
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' }],
|
|
]);
|
|
},
|
|
);
|