Offer voice actions on whichever globe has the capability they need instead of refusing them without a Cesium viewer

This commit is contained in:
German Khokhlov committed 2026-11-03 22:42:25 +04:00
1 parent b20579b06a
commit 59a2e31aa8
5 files changed
+390 -110

No files matched your search

+23 -11
View File
@@ -8,8 +8,7 @@
// - a set_layer_visibility call after each switch ran on the engine shown; // - a set_layer_visibility call after each switch ran on the engine shown;
// - a call made during a switch waited and ran on the incoming engine, and // - a call made during a switch waited and ran on the incoming engine, and
// one whose wait ran out was refused with ENGINE_SWITCHING; // one whose wait ran out was refused with ENGINE_SWITCHING;
// - annotate_map works on Cesium, is refused on Navara, and the mark drawn // - annotate_map works on both engines and marks survive each switch;
// before the switches is still on the board back on Cesium;
// - the session heard which engine it drives after each switch. // - the session heard which engine it drives after each switch.
// //
// node scripts/qa-voice-engine-switch.mjs http://127.0.0.1:<port>/ // node scripts/qa-voice-engine-switch.mjs http://127.0.0.1:<port>/
@@ -138,7 +137,7 @@ try {
const annotated = await callTool(page, 'annotate_map', ANNOTATION); const annotated = await callTool(page, 'annotate_map', ANNOTATION);
assert.equal(annotated.ok, true, JSON.stringify(annotated)); assert.equal(annotated.ok, true, JSON.stringify(annotated));
assert.equal(annotated.engine, 'cesium'); assert.equal(annotated.engine, 'cesium');
const marks = await page.evaluate(() => window.__gevAnnotations.count()); let marks = await page.evaluate(() => window.__gevAnnotations.count());
assert.ok(marks >= 1, 'the mark is on the board'); assert.ok(marks >= 1, 'the mark is on the board');
results.push('PASS annotate_map drew a mark on Cesium'); results.push('PASS annotate_map drew a mark on Cesium');
@@ -154,14 +153,27 @@ try {
await page.evaluate(() => window.__godsEyeView.engine.kind), await page.evaluate(() => window.__godsEyeView.engine.kind),
kind, kind,
); );
if (kind === 'navara') { const count = await page.evaluate(() => window.__gevAnnotations.count());
const refused = await callTool(page, 'annotate_map', ANNOTATION); assert.equal(count, marks, 'the marks survived the switch');
assert.equal(refused.ok, false, JSON.stringify(refused)); const annotation = await callTool(page, 'annotate_map', {
assert.equal(refused.engine, 'navara'); annotations: [
} else { {
const count = await page.evaluate(() => window.__gevAnnotations.count()); type: 'pin',
assert.equal(count, marks, 'the mark survived the round trip'); latitude: 42.3554 + marks * 0.01,
} longitude: -71.0605,
label: `QA voice mark ${marks}`,
},
],
});
assert.equal(annotation.ok, true, JSON.stringify(annotation));
assert.equal(annotation.engine, kind);
marks = await page.evaluate(() => window.__gevAnnotations.count());
assert.equal(marks, count + 1, 'voice added a visible mark on this engine');
assert.equal(
await page.evaluate(() => window.__gevAnnotations.attached),
true,
);
results.push(`PASS ${kind}: annotate_map drew a mark`);
results.push(`PASS ${kind}: set_layer_visibility ran on ${layer.engine}`); results.push(`PASS ${kind}: set_layer_visibility ran on ${layer.engine}`);
} }
+81 -71
View File
@@ -44,6 +44,7 @@ import { voiceLayer, voiceLayerAliasMap } from './layerManifest.js';
import { layerFeedState } from '../data/feedState.js'; import { layerFeedState } from '../data/feedState.js';
import { import {
initCameraVerbs, initCameraVerbs,
cameraVerbsSupported,
moveCamera, moveCamera,
flyRoute, flyRoute,
interruptCameraMotion, interruptCameraMotion,
@@ -52,6 +53,7 @@ import {
import * as defaultFloorServices from '../data/groundFloor.js'; import * as defaultFloorServices from '../data/groundFloor.js';
import { isPickedWorldPosition } from '../data/scenePick.js'; import { isPickedWorldPosition } from '../data/scenePick.js';
import { engineFromViewer } from '../globe/cesium/engineFromViewer.js'; import { engineFromViewer } from '../globe/cesium/engineFromViewer.js';
import { engineCapabilities } from '../globe/contract.js';
import { unavailablePlaceSearch } from '../search/placeSearch.js'; import { unavailablePlaceSearch } from '../search/placeSearch.js';
import * as defaultAnnotationResolver from '../annotations/annotationResolver.js'; import * as defaultAnnotationResolver from '../annotations/annotationResolver.js';
import { normalizeRadioCountryInput } from '../data/radioCountry.js'; import { normalizeRadioCountryInput } from '../data/radioCountry.js';
@@ -233,31 +235,9 @@ const FRAME_TARGETS = new Map([
['ships', 'ais-live-vessels'], ['ships', 'ais-live-vessels'],
]); ]);
/** /** A tool's required capability is missing from the attached scene. */
* The tools that still need the Cesium viewer or a subsystem built on it:
* annotations and routes over them, camera verbs, the context mode and CCTV. Without a viewer (the Navara engine) each answers a structured
* refusal (UNAVAILABLE_ON_ENGINE) instead of running.
*/
export const VIEWER_ONLY_ACTIONS = Object.freeze([
'annotate_map',
'clear_annotations',
'fly_route',
'move_camera',
]);
/** The refusal code of a viewer-only tool on an engine without a viewer. */
export const UNAVAILABLE_ON_ENGINE = 'UNAVAILABLE_ON_ENGINE'; export const UNAVAILABLE_ON_ENGINE = 'UNAVAILABLE_ON_ENGINE';
/**
* The tools that change the look of the Cesium scene (post stages, bloom,
* sharpen). The Navara engine has no visual styles yet: without a viewer
* these refuse too, rather than answer ok while changing nothing (and
* recording a style the view does not show).
*/
export const CESIUM_LOOK_ACTIONS = Object.freeze([
'set_visual_style',
'set_post_processing',
]);
/** What each refused tool is, for the refusal's message. */ /** What each refused tool is, for the refusal's message. */
export const UNAVAILABLE_FEATURES = Object.freeze({ export const UNAVAILABLE_FEATURES = Object.freeze({
control_scene: 'The scene director', control_scene: 'The scene director',
@@ -265,11 +245,9 @@ export const UNAVAILABLE_FEATURES = Object.freeze({
clear_annotations: 'Map annotation', clear_annotations: 'Map annotation',
fly_route: 'Route flying', fly_route: 'Route flying',
move_camera: 'Camera motion (orbit, pan, tilt)', move_camera: 'Camera motion (orbit, pan, tilt)',
set_context_mode: 'The Contacts mode',
control_radio: 'Radio',
control_cctv: 'CCTV',
set_visual_style: 'The visual style', set_visual_style: 'The visual style',
set_post_processing: 'Post-processing (bloom, sharpen)', set_post_processing: 'Post-processing (bloom, sharpen)',
set_cyber_sonar: 'Cyber sonar',
}); });
/** The engine to switch to for a refused feature. */ /** The engine to switch to for a refused feature. */
@@ -332,8 +310,8 @@ const cameraCartographic = (engine) =>
/** /**
* Create application actions over the supplied scene and services. The * Create application actions over the supplied scene and services. The
* actions drive the globe `engine` (by default the one registered for the * actions drive the globe `engine` (by default the one registered for the
* viewer); `viewer` is null on an engine without one (Navara), where the * viewer). Each action checks its scene services and render/camera capabilities;
* VIEWER_ONLY_ACTIONS refuse. * a missing capability returns UNAVAILABLE_ON_ENGINE on either engine.
*/ */
export function createGevActionRunner({ export function createGevActionRunner({
viewer, viewer,
@@ -376,11 +354,44 @@ export function createGevActionRunner({
{ memory: analystMemory }, { memory: analystMemory },
)); ));
installViewTargetPrewarm(engine); installViewTargetPrewarm(engine);
// The camera verbs (move_camera, fly_route) still run on the viewer. // Keep Cesium's viewer rig; viewerless engines use the contract rig.
if (viewer) if (cameraVerbsSupported(viewer ?? engine))
initCameraVerbs(viewer, () => getViewTargetCartesian(engine), { initCameraVerbs(viewer ?? engine, () => getViewTargetCartesian(engine), {
isCockpitActive: () => Boolean(styleManager?.getCockpitState?.()?.active), isCockpitActive: () => Boolean(styleManager?.getCockpitState?.()?.active),
}); });
const annotationsAvailable = (method) =>
annotations?.attached !== false &&
typeof annotations?.[method] === 'function';
const renderSupported = (capability, method) =>
typeof styleManager?.[method] === 'function' &&
Boolean(viewer || engineCapabilities(engine)[capability]);
const postEffectSupported = (effect) =>
effect === 'bloom'
? renderSupported('bloom', 'setBloom')
: typeof styleManager?.setSharpen === 'function' &&
Boolean(viewer || typeof engine?.render?.setSharpen === 'function');
function actionSupported(name) {
switch (name) {
case 'annotate_map':
return annotationsAvailable('annotate');
case 'clear_annotations':
return annotationsAvailable('clear');
case 'move_camera':
return cameraVerbsSupported(viewer ?? engine);
case 'fly_route':
return (
cameraVerbsSupported(viewer ?? engine) && annotationsAvailable('list')
);
case 'set_visual_style':
return renderSupported('visualStyles', 'setStyle');
case 'set_cyber_sonar':
return renderSupported('contactSonar', 'setCyberSonar');
case 'control_scene':
return Boolean(sceneDirector);
default:
return true;
}
}
// Nested calls (select_nearest_aircraft composing layer + flight) use the // Nested calls (select_nearest_aircraft composing layer + flight) use the
// raw runner; only the outer call gets a voice envelope. // raw runner; only the outer call gets a voice envelope.
const voiceUnavailable = (action) => ({ const voiceUnavailable = (action) => ({
@@ -475,13 +486,7 @@ export function createGevActionRunner({
/* Progress is advisory. */ /* Progress is advisory. */
} }
}; };
if ( if (!actionSupported(name)) return unavailableOnEngine(name, engine);
!viewer &&
((name === 'control_scene' && !sceneDirector) ||
VIEWER_ONLY_ACTIONS.includes(name) ||
CESIUM_LOOK_ACTIONS.includes(name))
)
return unavailableOnEngine(name, engine);
// Navigation tools interrupt any continuous camera motion (spec §1.1) — // Navigation tools interrupt any continuous camera motion (spec §1.1) —
// checked FIRST because each handler returns. // checked FIRST because each handler returns.
@@ -843,6 +848,12 @@ export function createGevActionRunner({
const style = normalizeStyle(args.style); const style = normalizeStyle(args.style);
if (!style) if (!style)
throw new Error(`Unknown visual style: ${args.style || 'missing'}`); throw new Error(`Unknown visual style: ${args.style || 'missing'}`);
if (
!viewer &&
style !== 'normal' &&
!engine.render.visualStyles.includes(style)
)
return unavailableOnEngine(name, engine, `The ${style} visual style`);
styleManager.setStyle(style, { userInitiated: true }); styleManager.setStyle(style, { userInitiated: true });
return { ok: true, action: 'set_visual_style', style }; return { ok: true, action: 'set_visual_style', style };
} }
@@ -1371,13 +1382,6 @@ export function createGevActionRunner({
} }
if (name === 'set_cyber_sonar') { if (name === 'set_cyber_sonar') {
if (typeof styleManager?.setCyberSonar !== 'function') {
return {
ok: false,
action: 'set_cyber_sonar',
error: 'Cyber sonar controls are unavailable.',
};
}
return { action: 'set_cyber_sonar', ...styleManager.setCyberSonar(args) }; return { action: 'set_cyber_sonar', ...styleManager.setCyberSonar(args) };
} }
@@ -1405,35 +1409,39 @@ export function createGevActionRunner({
} }
if (name === 'set_post_processing') { if (name === 'set_post_processing') {
const out = { ok: true, action: 'set_post_processing' }; const out = { ok: true, action: 'set_post_processing', effects: {} };
if (args.bloom && typeof args.bloom === 'object') { for (const effect of ['bloom', 'sharpen']) {
Object.assign( const request = args[effect];
out, if (!request || typeof request !== 'object') continue;
styleManager.setBloom({ const method = effect === 'bloom' ? 'setBloom' : 'setSharpen';
enabled: const result = postEffectSupported(effect)
typeof args.bloom.enabled === 'boolean' ? styleManager[method]({
? args.bloom.enabled enabled:
typeof request.enabled === 'boolean'
? request.enabled
: undefined,
intensityPct: Number.isFinite(Number(request.intensityPct))
? Number(request.intensityPct)
: undefined, : undefined,
intensityPct: Number.isFinite(Number(args.bloom.intensityPct)) })
? Number(args.bloom.intensityPct) : unavailableOnEngine(name, engine, `Post-processing (${effect})`);
: undefined, out.effects[effect] = result;
}), if (result[effect] !== undefined) out[effect] = result[effect];
);
} }
if (args.sharpen && typeof args.sharpen === 'object') { const results = Object.values(out.effects);
Object.assign( const failed = results.filter((result) => result.ok === false);
out, if (failed.length) {
styleManager.setSharpen({ Object.assign(out, failed[0]);
enabled: out.ok = false;
typeof args.sharpen.enabled === 'boolean' out.partial = results.some((result) => result.ok === true);
? args.sharpen.enabled out.error = failed.map((result) => result.error).join(' ');
: undefined,
intensityPct: Number.isFinite(Number(args.sharpen.intensityPct))
? Number(args.sharpen.intensityPct)
: undefined,
}),
);
} }
if (
!results.length &&
!postEffectSupported('bloom') &&
!postEffectSupported('sharpen')
)
return unavailableOnEngine(name, engine);
return out; return out;
} }
@@ -1531,6 +1539,8 @@ export function createGevActionRunner({
let result = await runGevAction(name, deictic.args, runOptions); let result = await runGevAction(name, deictic.args, runOptions);
if (deictic.used && result && typeof result === 'object') if (deictic.used && result && typeof result === 'object')
result = { ...result, resolvedFrom: deictic.used }; result = { ...result, resolvedFrom: deictic.used };
if (Object.hasOwn(UNAVAILABLE_FEATURES, name))
result = { engine: engine?.kind ?? null, ...result };
const out = attachVoiceResult(name, result, deictic.args, builders); const out = attachVoiceResult(name, result, deictic.args, builders);
// Only an accepted, still-current result may become "the last list": a // Only an accepted, still-current result may become "the last list": a
// cancelled turn or one finishing after the session ended must not // cancelled turn or one finishing after the session ended must not
+270
View File
@@ -0,0 +1,270 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
import { getActiveCameraMotion, releaseCameraVerbs } from '../cameraVerbs.js';
import { createGevActionRunner, UNAVAILABLE_ON_ENGINE } from './gevActions.js';
import { GEV_REALTIME_TOOLS } from '../../server/providers/openai/tools.js';
globalThis.window ||= { setTimeout, clearTimeout, requestIdleCallback: null };
const route = {
type: 'route',
label: 'walk',
path: [
{ lat: 30, lon: 10 },
{ lat: 30.01, lon: 10 },
],
};
function fixture(t, kind, { native = kind === 'cesium' } = {}) {
const engine = createFakeEngine({
kind,
canvas: new EventTarget(),
visualStyles: { thermal: [] },
bloom: true,
contactSonar: true,
});
let viewer;
if (native) {
const scene = {
canvas: Object.assign(new EventTarget(), {
clientWidth: 1280,
clientHeight: 800,
}),
drawingBufferWidth: 1280,
drawingBufferHeight: 800,
frameState: { mode: Cesium.SceneMode.SCENE3D },
mapProjection: new Cesium.GeographicProjection(),
globe: { ellipsoid: Cesium.Ellipsoid.WGS84 },
mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL,
mode: Cesium.SceneMode.SCENE3D,
pixelRatio: 1,
tweens: [],
requestRender() {},
};
const camera = new Cesium.Camera(scene);
scene.camera = camera;
camera.setView({
destination: Cesium.Cartesian3.fromDegrees(10, 30, 5000),
});
viewer = { scene, camera, clock: { onTick: new Cesium.Event() } };
}
const calls = [];
const annotations = {
attached: true,
list: () => [route],
annotate: async (...args) => {
calls.push(['annotate', ...args]);
return { drawn: 1, failed: 0, results: [{ ok: true }] };
},
clear: () => calls.push(['clear']),
};
const styleManager = {
runImmediateNavigation: (noun, navigate) => navigate(),
setStyle: (style) => {
calls.push(['style', style]);
if (!native && style !== 'normal')
engine.render.setStyle(style, { intensity: 1 });
},
setBloom: (options) => {
calls.push(['bloom', options]);
if (!native) engine.render.setBloom({ enabled: options.enabled });
return { ok: true, bloom: options };
},
setSharpen: (options) => {
calls.push(['sharpen', options]);
engine.render.setSharpen({ enabled: options.enabled });
return { ok: true, sharpen: options };
},
setCyberSonar: (options) => {
calls.push(['sonar', options]);
if (!native) engine.render.setContactSonar(options);
return { ok: true, ...options };
},
};
const build = (options = {}) =>
createGevActionRunner({
viewer,
engine,
annotations,
styleManager,
dataManager: { layers: new Map() },
...options,
floorServices: { cachedGroundFloor: () => 0, warmGroundFloor: () => {} },
});
t.after(() => releaseCameraVerbs(viewer ?? engine));
return { engine, viewer, annotations, styleManager, calls, build };
}
const cases = [
[
'annotate_map',
{ annotations: [{ type: 'pin', latitude: 30, longitude: 10 }] },
'annotate',
],
['clear_annotations', {}, 'clear'],
['move_camera', { motion: 'pan', direction: 'left' }, null],
['fly_route', {}, null],
['set_visual_style', { style: 'thermal' }, 'style'],
[
'set_post_processing',
{ bloom: { enabled: true }, sharpen: { enabled: true } },
'bloom',
],
['set_cyber_sonar', { enabled: true }, 'sonar'],
];
for (const kind of ['cesium', 'navara']) {
for (const [action, args, call] of cases) {
test(`${kind}: ${action} runs with its capability and reports the engine`, async (t) => {
const f = fixture(t, kind);
// Cesium must keep working without the optional renderer ports.
if (f.viewer) {
delete f.engine.render.setStyle;
delete f.engine.render.setBloom;
delete f.engine.render.setContactSonar;
}
assert.ok(GEV_REALTIME_TOOLS.some((tool) => tool.name === action));
const result = await f.build()(action, args);
assert.equal(result.ok, true, JSON.stringify(result));
assert.equal(result.engine, kind);
if (call) assert.equal(f.calls[0][0], call);
else
assert.equal(
getActiveCameraMotion().rig,
f.viewer ? 'viewer' : 'engine',
);
if (action === 'set_post_processing') {
assert.deepEqual(
f.calls.map(([name]) => name),
['bloom', 'sharpen'],
);
assert.equal(result.effects.bloom.ok, true);
assert.equal(result.effects.sharpen.ok, true);
}
if (!f.viewer && action === 'set_cyber_sonar')
assert.equal(f.engine.render.contactSonar().enabled, true);
if (!f.viewer && action === 'set_visual_style')
assert.equal(f.engine.render.style('thermal').intensity, 1);
});
test(`${kind}: ${action} refuses a missing capability without side effects`, async (t) => {
const f = fixture(t, kind);
if (action === 'annotate_map') delete f.annotations.annotate;
if (action === 'clear_annotations') delete f.annotations.clear;
if (action === 'fly_route') delete f.annotations.list;
if (action === 'move_camera') {
if (f.viewer) delete f.viewer.clock.onTick;
else delete f.engine.camera.lookAt;
}
if (action === 'set_visual_style') {
if (f.viewer) delete f.styleManager.setStyle;
else delete f.engine.render.setStyle;
}
if (action === 'set_post_processing') {
if (f.viewer) {
delete f.styleManager.setBloom;
delete f.styleManager.setSharpen;
} else {
delete f.engine.render.setBloom;
delete f.engine.render.setSharpen;
}
}
if (action === 'set_cyber_sonar') {
if (f.viewer) delete f.styleManager.setCyberSonar;
else delete f.engine.render.setContactSonar;
}
const run = f.build();
f.engine.fake.resetCalls();
const result = await run(action, args);
assert.equal(result.ok, false);
assert.equal(result.code, UNAVAILABLE_ON_ENGINE);
assert.equal(result.unavailable, true);
assert.equal(result.engine, kind);
assert.equal(result.suggestion, '?engine=cesium');
assert.deepEqual(f.calls, []);
assert.deepEqual(f.engine.fake.calls, []);
});
}
for (const action of ['annotate_map', 'clear_annotations', 'fly_route']) {
test(`${kind}: ${action} refuses a detached annotation board`, async (t) => {
const f = fixture(t, kind);
f.annotations.attached = false;
const result = await f.build()(
action,
cases.find(([name]) => name === action)[1],
);
assert.equal(result.code, UNAVAILABLE_ON_ENGINE);
assert.deepEqual(f.calls, []);
});
}
test(`${kind}: fly_route also requires camera verbs`, async (t) => {
const f = fixture(t, kind);
if (f.viewer) delete f.viewer.clock.onTick;
else delete f.engine.camera.lookAt;
assert.equal((await f.build()('fly_route')).code, UNAVAILABLE_ON_ENGINE);
});
test(`${kind}: control_scene requires an attached director`, async (t) => {
const f = fixture(t, kind);
const missing = await f.build()('control_scene', { action: 'status' });
assert.equal(missing.code, UNAVAILABLE_ON_ENGINE);
assert.equal(missing.engine, kind);
const result = await f.build({
sceneDirector: {
getPlaybackStatus: () => ({ running: false }),
},
})('control_scene', { action: 'status' });
assert.equal(result.ok, true);
assert.equal(result.engine, kind);
assert.equal(result.running, false);
});
for (const missing of ['bloom', 'sharpen']) {
test(`${kind}: post-processing reports unsupported ${missing} and applies the other effect`, async (t) => {
const f = fixture(t, kind);
const method = missing === 'bloom' ? 'setBloom' : 'setSharpen';
delete (f.viewer ? f.styleManager : f.engine.render)[method];
const result = await f.build()('set_post_processing', {
bloom: { enabled: true },
sharpen: { enabled: true },
});
const supported = missing === 'bloom' ? 'sharpen' : 'bloom';
assert.equal(result.ok, false);
assert.equal(result.partial, true);
assert.equal(result.engine, kind);
assert.equal(result.effects[missing].code, UNAVAILABLE_ON_ENGINE);
assert.equal(result.effects[supported].ok, true);
assert.match(result.error, new RegExp(missing));
assert.deepEqual(
f.calls.map(([name]) => name),
[supported],
);
});
}
}
test('Navara refuses a style absent from its visualStyles list', async (t) => {
const f = fixture(t, 'navara');
assert.equal(
(await f.build()('set_visual_style', { style: 'anime' })).code,
UNAVAILABLE_ON_ENGINE,
);
assert.deepEqual(f.calls, []);
});
test('post-processing keeps a controller failure when the next effect succeeds', async (t) => {
const f = fixture(t, 'navara');
f.styleManager.setBloom = () => ({ ok: false, error: 'Bloom failed' });
const result = await f.build()('set_post_processing', {
bloom: { enabled: true },
sharpen: { enabled: true },
});
assert.equal(result.ok, false);
assert.equal(result.partial, true);
assert.equal(result.error, 'Bloom failed');
assert.equal(result.effects.sharpen.ok, true);
});
+14 -26
View File
@@ -1,7 +1,7 @@
// The W1 voice actions on the globe engine (phase 3, Task 9): the same // The W1 voice actions on the globe engine (phase 3, Task 9): the same
// results as on the Cesium viewer, through engine.camera / picker / // results as on the Cesium viewer, through engine.camera / picker /
// projector only, and a structured refusal for the actions that still need // projector only, and a structured refusal when a required capability
// a viewer when there is none (the Navara engine). // is missing from the attached scene.
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
@@ -10,10 +10,8 @@ import * as geo from '../geo/index.js';
import { createFakeEngine } from '../globe/testing/fakeEngine.js'; import { createFakeEngine } from '../globe/testing/fakeEngine.js';
import { GEV_ACTION_SCHEMAS } from './actionSchemas.js'; import { GEV_ACTION_SCHEMAS } from './actionSchemas.js';
import { import {
CESIUM_LOOK_ACTIONS,
UNAVAILABLE_FEATURES, UNAVAILABLE_FEATURES,
UNAVAILABLE_ON_ENGINE, UNAVAILABLE_ON_ENGINE,
VIEWER_ONLY_ACTIONS,
createGevActionRunner, createGevActionRunner,
} from './gevActions.js'; } from './gevActions.js';
import { cameraPoseKey } from './pointerContext.js'; import { cameraPoseKey } from './pointerContext.js';
@@ -87,16 +85,9 @@ const follow = (engine, position) =>
viewFrom: { x: 0, y: -2000, z: 800 }, viewFrom: { x: 0, y: -2000, z: 800 },
}); });
test('the viewer-only actions are one exported list of real tools', () => { test('every capability-gated action is a real offered tool', () => {
assert.deepEqual(VIEWER_ONLY_ACTIONS, [
'annotate_map',
'clear_annotations',
'fly_route',
'move_camera',
]);
assert.ok(Object.isFrozen(VIEWER_ONLY_ACTIONS));
const tools = new Set(GEV_ACTION_SCHEMAS.map((schema) => schema.name)); const tools = new Set(GEV_ACTION_SCHEMAS.map((schema) => schema.name));
for (const name of VIEWER_ONLY_ACTIONS) for (const name of Object.keys(UNAVAILABLE_FEATURES))
assert.ok(tools.has(name), `${name} is a voice tool`); assert.ok(tools.has(name), `${name} is a voice tool`);
}); });
@@ -119,7 +110,7 @@ for (const kind of ['cesium', 'navara']) test(`${kind} enters cockpit on a ready
assert.equal(result.state.active, true); assert.equal(result.state.active, true);
}); });
test('without a viewer the viewer-only actions refuse with a structured result', async () => { test('missing scene capabilities refuse with a structured result', async () => {
const engine = engineOverAustin({ kind: 'navara' }); const engine = engineOverAustin({ kind: 'navara' });
const touched = []; const touched = [];
const styleManager = new Proxy(navigatingStyleManager(), { const styleManager = new Proxy(navigatingStyleManager(), {
@@ -128,9 +119,15 @@ test('without a viewer the viewer-only actions refuse with a structured result',
return target[key]; return target[key];
}, },
}); });
delete engine.camera.lookAt;
const runner = runnerOn(engine, { styleManager }); const runner = runnerOn(engine, { styleManager });
engine.fake.resetCalls(); engine.fake.resetCalls();
for (const name of VIEWER_ONLY_ACTIONS) { for (const name of [
'annotate_map',
'clear_annotations',
'fly_route',
'move_camera',
]) {
const result = await runner(name, { action: 'status' }); const result = await runner(name, { action: 'status' });
assert.equal(result.ok, false, name); assert.equal(result.ok, false, name);
assert.equal(result.action, name); assert.equal(result.action, name);
@@ -149,7 +146,7 @@ test('without a viewer the viewer-only actions refuse with a structured result',
assert.deepEqual(engine.fake.calls, [], 'a refusal moves no camera'); assert.deepEqual(engine.fake.calls, [], 'a refusal moves no camera');
}); });
test('with a viewer the viewer-only actions still run', async () => { test('with a viewer radio still reports a missing layer', async () => {
const runner = createGevActionRunner({ const runner = createGevActionRunner({
viewer: { viewer: {
clock: { onTick: { addEventListener: () => () => {} } }, clock: { onTick: { addEventListener: () => () => {} } },
@@ -443,16 +440,7 @@ test('a fresh frame for capture comes from the engine render loop', async () =>
} }
}); });
test('every refused tool names its feature', () => { test('missing visual style and bloom capabilities refuse and record nothing', async () => {
for (const name of [...VIEWER_ONLY_ACTIONS, ...CESIUM_LOOK_ACTIONS])
assert.ok(UNAVAILABLE_FEATURES[name], `${name} has a feature name`);
assert.deepEqual(CESIUM_LOOK_ACTIONS, [
'set_visual_style',
'set_post_processing',
]);
});
test('without a viewer the visual style and post-processing refuse and record nothing', async () => {
const engine = engineOverAustin({ kind: 'navara' }); const engine = engineOverAustin({ kind: 'navara' });
const calls = []; const calls = [];
const styleManager = navigatingStyleManager({ const styleManager = navigatingStyleManager({
+2 -2
View File
@@ -169,8 +169,8 @@ export function createSceneVoice(options) {
/** /**
* Compose the standalone action runner with the voice controls, on the globe * Compose the standalone action runner with the voice controls, on the globe
* `engine` (by default the one registered for `options.viewer`). Without a * `engine` (by default the one registered for `options.viewer`). The runner
* viewer (the Navara engine) the runner refuses the viewer-only tools. * checks each action against the capabilities of the attached scene.
*/ */
export function initGevVoiceCommands(options) { export function initGevVoiceCommands(options) {
const voice = createSceneVoice(options); const voice = createSceneVoice(options);