feat(app): voice on the Navara engine

createViewerlessTools creates the voice commands with viewer: null on the
engine (tools.js passes it the voice options and place search), so
window.__gevVoiceCommands exists in ?engine=navara: the W1 tools drive the
engine and the viewer-only tools answer UNAVAILABLE_ON_ENGINE. The Cesium
path hands its engine to initGevVoiceCommands explicitly.

viewerlessTools.test.mjs: the Navara tools now return voice commands
(created without DOM through injected control and session stand-ins),
whose runner answers get_current_view_state on the engine and refuses
control_scene; the debug handle carries them and teardown removes them.
This commit is contained in:
German Khokhlov committed 2026-10-16 22:52:21 +04:00
1 parent bc281ab246
commit 18b5475b8a
3 files changed
+74 -12

No files matched your search

+28 -3
View File
@@ -12,6 +12,7 @@ import {
installRenderGovernor, installRenderGovernor,
uninstallRenderGovernor, uninstallRenderGovernor,
} from '../renderGovernor.js'; } from '../renderGovernor.js';
import { initGevVoiceCommands } from '../voice/gevRealtime.js';
/** The Navara globe module, loaded on demand. */ /** The Navara globe module, loaded on demand. */
const loadNavaraGlobeModule = async () => const loadNavaraGlobeModule = async () =>
@@ -109,14 +110,17 @@ export async function createNavaraScene({
/** /**
* The tools without a viewer (the experimental Navara engine): the startup * The tools without a viewer (the experimental Navara engine): the startup
* chrome, the render governor on the engine, hidden-tab handling and the * chrome, the render governor on the engine, hidden-tab handling, voice on
* debug handle. The shell shows which features are unavailable. * the engine and the debug handle. The shell shows which features are
* unavailable.
*/ */
export function createViewerlessTools({ export function createViewerlessTools({
scene: { engine, mapStackController, operations }, scene: { engine, mapStackController, operations },
controls: { styleManager }, controls: { styleManager },
data, data,
loadingScreen, loadingScreen,
placeSearch,
voice = {},
startChrome, startChrome,
signal, signal,
defer, defer,
@@ -170,5 +174,26 @@ export function createViewerlessTools({
defer(() => { defer(() => {
if (window.__godsEyeView === debug) delete window.__godsEyeView; if (window.__godsEyeView === debug) delete window.__godsEyeView;
}); });
return { sceneDirector: null, annotations: null, voiceCommands: null }; // Voice on the engine (phase 3, Task 9): the W1 tools drive the engine;
// the viewer-only ones (director, annotations, cockpit, radio…) refuse.
const voiceCommands = initGevVoiceCommands({
...voice,
annotationResolver: operations.annotationResolver,
searchNavigation: operations.searchAndFlyTo,
signal,
placeSearch,
viewer: null,
engine,
styleManager,
dataManager,
sceneDirector: null,
annotations: null,
});
defer(() => {
voiceCommands.stop({ removeUi: true });
if (window.__gevVoiceCommands === voiceCommands)
delete window.__gevVoiceCommands;
});
debug.voiceCommands = voiceCommands;
return { sceneDirector: null, annotations: null, voiceCommands };
} }
+6 -2
View File
@@ -17,8 +17,9 @@ import {
/** /**
* Attach scene tools, rendering listeners and the application debug handle. * Attach scene tools, rendering listeners and the application debug handle.
* Without a viewer (the experimental Navara engine) the viewer-bound tools * Without a viewer (the experimental Navara engine) the viewer-bound tools
* (director, annotations, drawing, imagery box, scope mask, tracking, voice, * (director, annotations, drawing, imagery box, scope mask, tracking,
* embedded views) are not created; their handles are null. * embedded views) are not created; their handles are null. Voice runs on
* the engine there (src/app/navaraMode.js).
*/ */
export function createApplicationTools({ export function createApplicationTools({
scene, scene,
@@ -44,6 +45,8 @@ export function createApplicationTools({
controls, controls,
data, data,
loadingScreen, loadingScreen,
placeSearch,
voice,
startChrome, startChrome,
signal, signal,
defer, defer,
@@ -189,6 +192,7 @@ export function createApplicationTools({
signal, signal,
placeSearch, placeSearch,
viewer, viewer,
engine,
styleManager, styleManager,
dataManager, dataManager,
sceneDirector, sceneDirector,
+40 -7
View File
@@ -16,7 +16,7 @@ import {
// The tools without a viewer (the experimental Navara engine); they load // The tools without a viewer (the experimental Navara engine); they load
// from src/app/navaraMode.js. // from src/app/navaraMode.js.
test('without a viewer the tools run the governor on the engine, show the notice and create no viewer tools', async (t) => { test('without a viewer the tools run the governor on the engine, show the notice, create no viewer tools and run voice on the engine', async (t) => {
const saved = { window: globalThis.window, document: globalThis.document }; const saved = { window: globalThis.window, document: globalThis.document };
const listeners = new Map(); const listeners = new Map();
globalThis.window = {}; globalThis.window = {};
@@ -34,7 +34,9 @@ test('without a viewer the tools run the governor on the engine, show the notice
else globalThis[name] = value; else globalThis[name] = value;
} }
}); });
const engine = createFakeEngine({ kind: 'navara' }); // The voice pointer listens on the engine's canvas.
const canvas = new EventTarget();
const engine = createFakeEngine({ kind: 'navara', canvas });
const notices = []; const notices = [];
const chrome = []; const chrome = [];
const cleanups = []; const cleanups = [];
@@ -43,7 +45,27 @@ test('without a viewer the tools run the governor on the engine, show the notice
_showGlobalStatusNotice: (label, options) => _showGlobalStatusNotice: (label, options) =>
notices.push({ label, options }), notices.push({ label, options }),
}; };
const dataManager = { layers: new Map() }; const dataManager = { layers: new Map(), getAll: () => [] };
// Voice without DOM or provider: a control and session stand-in.
const button = new EventTarget();
button.setAttribute = () => {};
let uiRemoved = 0;
const voice = {
createControl: () => ({
button,
root: { dataset: {}, remove: () => uiRemoved++ },
status: {},
detail: {},
}),
createCard: () => null,
createSession: (hooks) => ({
runner: (voice.runner = hooks.runner),
async start() {},
stop() {},
sendText() {},
sendMapEvent() {},
}),
};
const tools = await createApplicationTools({ const tools = await createApplicationTools({
scene: { scene: {
viewer: null, viewer: null,
@@ -52,6 +74,7 @@ test('without a viewer the tools run the governor on the engine, show the notice
mapStackController: { kind: 'maps' }, mapStackController: { kind: 'maps' },
operations: { surface: { kind: 'surface' } }, operations: { surface: { kind: 'surface' } },
}, },
voice,
controls: { styleManager, weatherEffects: null, cockpitCloudEffects: null }, controls: { styleManager, weatherEffects: null, cockpitCloudEffects: null },
data: { dataManager, presentation: { flushVisible: () => flushed++ } }, data: { dataManager, presentation: { flushVisible: () => flushed++ } },
loadingScreen: { kind: 'loading' }, loadingScreen: { kind: 'loading' },
@@ -62,11 +85,18 @@ test('without a viewer the tools run the governor on the engine, show the notice
signal: new AbortController().signal, signal: new AbortController().signal,
defer: (dispose) => cleanups.push(dispose), defer: (dispose) => cleanups.push(dispose),
}); });
assert.deepEqual(tools, { assert.equal(tools.sceneDirector, null);
sceneDirector: null, assert.equal(tools.annotations, null);
annotations: null, assert.ok(tools.voiceCommands, 'voice runs on the engine');
voiceCommands: null, assert.equal(window.__gevVoiceCommands, tools.voiceCommands);
// The W1 tools drive the engine; the viewer-only ones refuse.
const view = await voice.runner('get_current_view_state');
assert.equal(view.ok, true);
const scene = await voice.runner('control_scene', {
action: 'list',
}); });
assert.equal(scene.ok, false);
assert.equal(scene.code, 'UNAVAILABLE_ON_ENGINE');
assert.equal(chrome.length, 1); assert.equal(chrome.length, 1);
assert.equal(chrome[0].initializeWelcome, null, 'no first-run welcome'); assert.equal(chrome[0].initializeWelcome, null, 'no first-run welcome');
assert.equal(chrome[0].styleManager, styleManager); assert.equal(chrome[0].styleManager, styleManager);
@@ -103,9 +133,12 @@ test('without a viewer the tools run the governor on the engine, show the notice
assert.equal(debug.engine, engine); assert.equal(debug.engine, engine);
assert.equal(debug.sceneDirector, null); assert.equal(debug.sceneDirector, null);
assert.equal(debug.dataManager, dataManager); assert.equal(debug.dataManager, dataManager);
assert.equal(debug.voiceCommands, tools.voiceCommands);
listeners.get('visibilitychange')(); listeners.get('visibilitychange')();
assert.equal(flushed, 1); assert.equal(flushed, 1);
while (cleanups.length) await cleanups.pop()(); while (cleanups.length) await cleanups.pop()();
assert.equal(window.__godsEyeView, undefined); assert.equal(window.__godsEyeView, undefined);
assert.equal(window.__gevVoiceCommands, undefined);
assert.equal(uiRemoved, 1, 'the voice control is removed');
assert.equal(listeners.size, 0); assert.equal(listeners.size, 0);
}); });