From 59a2e31aa81a9a36eb3821bdeb85fc7e1de88296 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 22:42:25 +0400 Subject: [PATCH] Offer voice actions on whichever globe has the capability they need instead of refusing them without a Cesium viewer --- scripts/qa-voice-engine-switch.mjs | 34 ++- src/voice/gevActions.js | 152 ++++++------ src/voice/gevActionsCapabilities.test.mjs | 270 ++++++++++++++++++++++ src/voice/gevActionsEngine.test.mjs | 40 ++-- src/voice/gevRealtime.js | 4 +- 5 files changed, 390 insertions(+), 110 deletions(-) create mode 100644 src/voice/gevActionsCapabilities.test.mjs diff --git a/scripts/qa-voice-engine-switch.mjs b/scripts/qa-voice-engine-switch.mjs index 741d35361..de25d7a52 100644 --- a/scripts/qa-voice-engine-switch.mjs +++ b/scripts/qa-voice-engine-switch.mjs @@ -8,8 +8,7 @@ // - 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 // one whose wait ran out was refused with ENGINE_SWITCHING; -// - annotate_map works on Cesium, is refused on Navara, and the mark drawn -// before the switches is still on the board back on Cesium; +// - annotate_map works on both engines and marks survive each switch; // - the session heard which engine it drives after each switch. // // node scripts/qa-voice-engine-switch.mjs http://127.0.0.1:/ @@ -138,7 +137,7 @@ try { const annotated = await callTool(page, 'annotate_map', ANNOTATION); assert.equal(annotated.ok, true, JSON.stringify(annotated)); 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'); results.push('PASS annotate_map drew a mark on Cesium'); @@ -154,14 +153,27 @@ try { await page.evaluate(() => window.__godsEyeView.engine.kind), kind, ); - if (kind === 'navara') { - const refused = await callTool(page, 'annotate_map', ANNOTATION); - assert.equal(refused.ok, false, JSON.stringify(refused)); - assert.equal(refused.engine, 'navara'); - } else { - const count = await page.evaluate(() => window.__gevAnnotations.count()); - assert.equal(count, marks, 'the mark survived the round trip'); - } + const count = await page.evaluate(() => window.__gevAnnotations.count()); + assert.equal(count, marks, 'the marks survived the switch'); + const annotation = await callTool(page, 'annotate_map', { + annotations: [ + { + type: 'pin', + 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}`); } diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index ddd35b127..3176dff35 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -44,6 +44,7 @@ import { voiceLayer, voiceLayerAliasMap } from './layerManifest.js'; import { layerFeedState } from '../data/feedState.js'; import { initCameraVerbs, + cameraVerbsSupported, moveCamera, flyRoute, interruptCameraMotion, @@ -52,6 +53,7 @@ import { import * as defaultFloorServices from '../data/groundFloor.js'; import { isPickedWorldPosition } from '../data/scenePick.js'; import { engineFromViewer } from '../globe/cesium/engineFromViewer.js'; +import { engineCapabilities } from '../globe/contract.js'; import { unavailablePlaceSearch } from '../search/placeSearch.js'; import * as defaultAnnotationResolver from '../annotations/annotationResolver.js'; import { normalizeRadioCountryInput } from '../data/radioCountry.js'; @@ -233,31 +235,9 @@ const FRAME_TARGETS = new Map([ ['ships', 'ais-live-vessels'], ]); -/** - * 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. */ +/** A tool's required capability is missing from the attached scene. */ 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. */ export const UNAVAILABLE_FEATURES = Object.freeze({ control_scene: 'The scene director', @@ -265,11 +245,9 @@ export const UNAVAILABLE_FEATURES = Object.freeze({ clear_annotations: 'Map annotation', fly_route: 'Route flying', 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_post_processing: 'Post-processing (bloom, sharpen)', + set_cyber_sonar: 'Cyber sonar', }); /** 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 * 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_ONLY_ACTIONS refuse. + * viewer). Each action checks its scene services and render/camera capabilities; + * a missing capability returns UNAVAILABLE_ON_ENGINE on either engine. */ export function createGevActionRunner({ viewer, @@ -376,11 +354,44 @@ export function createGevActionRunner({ { memory: analystMemory }, )); installViewTargetPrewarm(engine); - // The camera verbs (move_camera, fly_route) still run on the viewer. - if (viewer) - initCameraVerbs(viewer, () => getViewTargetCartesian(engine), { + // Keep Cesium's viewer rig; viewerless engines use the contract rig. + if (cameraVerbsSupported(viewer ?? engine)) + initCameraVerbs(viewer ?? engine, () => getViewTargetCartesian(engine), { 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 // raw runner; only the outer call gets a voice envelope. const voiceUnavailable = (action) => ({ @@ -475,13 +486,7 @@ export function createGevActionRunner({ /* Progress is advisory. */ } }; - if ( - !viewer && - ((name === 'control_scene' && !sceneDirector) || - VIEWER_ONLY_ACTIONS.includes(name) || - CESIUM_LOOK_ACTIONS.includes(name)) - ) - return unavailableOnEngine(name, engine); + if (!actionSupported(name)) return unavailableOnEngine(name, engine); // Navigation tools interrupt any continuous camera motion (spec §1.1) — // checked FIRST because each handler returns. @@ -843,6 +848,12 @@ export function createGevActionRunner({ const style = normalizeStyle(args.style); if (!style) 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 }); return { ok: true, action: 'set_visual_style', style }; } @@ -1371,13 +1382,6 @@ export function createGevActionRunner({ } 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) }; } @@ -1405,35 +1409,39 @@ export function createGevActionRunner({ } if (name === 'set_post_processing') { - const out = { ok: true, action: 'set_post_processing' }; - if (args.bloom && typeof args.bloom === 'object') { - Object.assign( - out, - styleManager.setBloom({ - enabled: - typeof args.bloom.enabled === 'boolean' - ? args.bloom.enabled + const out = { ok: true, action: 'set_post_processing', effects: {} }; + for (const effect of ['bloom', 'sharpen']) { + const request = args[effect]; + if (!request || typeof request !== 'object') continue; + const method = effect === 'bloom' ? 'setBloom' : 'setSharpen'; + const result = postEffectSupported(effect) + ? styleManager[method]({ + enabled: + typeof request.enabled === 'boolean' + ? request.enabled + : undefined, + intensityPct: Number.isFinite(Number(request.intensityPct)) + ? Number(request.intensityPct) : undefined, - intensityPct: Number.isFinite(Number(args.bloom.intensityPct)) - ? Number(args.bloom.intensityPct) - : undefined, - }), - ); + }) + : unavailableOnEngine(name, engine, `Post-processing (${effect})`); + out.effects[effect] = result; + if (result[effect] !== undefined) out[effect] = result[effect]; } - if (args.sharpen && typeof args.sharpen === 'object') { - Object.assign( - out, - styleManager.setSharpen({ - enabled: - typeof args.sharpen.enabled === 'boolean' - ? args.sharpen.enabled - : undefined, - intensityPct: Number.isFinite(Number(args.sharpen.intensityPct)) - ? Number(args.sharpen.intensityPct) - : undefined, - }), - ); + const results = Object.values(out.effects); + const failed = results.filter((result) => result.ok === false); + if (failed.length) { + Object.assign(out, failed[0]); + out.ok = false; + out.partial = results.some((result) => result.ok === true); + out.error = failed.map((result) => result.error).join(' '); } + if ( + !results.length && + !postEffectSupported('bloom') && + !postEffectSupported('sharpen') + ) + return unavailableOnEngine(name, engine); return out; } @@ -1531,6 +1539,8 @@ export function createGevActionRunner({ let result = await runGevAction(name, deictic.args, runOptions); if (deictic.used && result && typeof result === 'object') 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); // Only an accepted, still-current result may become "the last list": a // cancelled turn or one finishing after the session ended must not diff --git a/src/voice/gevActionsCapabilities.test.mjs b/src/voice/gevActionsCapabilities.test.mjs new file mode 100644 index 000000000..dbbde9a7f --- /dev/null +++ b/src/voice/gevActionsCapabilities.test.mjs @@ -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); +}); diff --git a/src/voice/gevActionsEngine.test.mjs b/src/voice/gevActionsEngine.test.mjs index ce5e2ee09..8b1c4a977 100644 --- a/src/voice/gevActionsEngine.test.mjs +++ b/src/voice/gevActionsEngine.test.mjs @@ -1,7 +1,7 @@ // The W1 voice actions on the globe engine (phase 3, Task 9): the same // results as on the Cesium viewer, through engine.camera / picker / -// projector only, and a structured refusal for the actions that still need -// a viewer when there is none (the Navara engine). +// projector only, and a structured refusal when a required capability +// is missing from the attached scene. import { test } from 'node:test'; 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 { GEV_ACTION_SCHEMAS } from './actionSchemas.js'; import { - CESIUM_LOOK_ACTIONS, UNAVAILABLE_FEATURES, UNAVAILABLE_ON_ENGINE, - VIEWER_ONLY_ACTIONS, createGevActionRunner, } from './gevActions.js'; import { cameraPoseKey } from './pointerContext.js'; @@ -87,16 +85,9 @@ const follow = (engine, position) => viewFrom: { x: 0, y: -2000, z: 800 }, }); -test('the viewer-only actions are one exported list of real tools', () => { - assert.deepEqual(VIEWER_ONLY_ACTIONS, [ - 'annotate_map', - 'clear_annotations', - 'fly_route', - 'move_camera', - ]); - assert.ok(Object.isFrozen(VIEWER_ONLY_ACTIONS)); +test('every capability-gated action is a real offered tool', () => { 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`); }); @@ -119,7 +110,7 @@ for (const kind of ['cesium', 'navara']) test(`${kind} enters cockpit on a ready 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 touched = []; 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]; }, }); + delete engine.camera.lookAt; const runner = runnerOn(engine, { styleManager }); 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' }); assert.equal(result.ok, false, 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'); }); -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({ viewer: { 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', () => { - 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 () => { +test('missing visual style and bloom capabilities refuse and record nothing', async () => { const engine = engineOverAustin({ kind: 'navara' }); const calls = []; const styleManager = navigatingStyleManager({ diff --git a/src/voice/gevRealtime.js b/src/voice/gevRealtime.js index 8fad7a368..658e4460d 100644 --- a/src/voice/gevRealtime.js +++ b/src/voice/gevRealtime.js @@ -169,8 +169,8 @@ export function createSceneVoice(options) { /** * Compose the standalone action runner with the voice controls, on the globe - * `engine` (by default the one registered for `options.viewer`). Without a - * viewer (the Navara engine) the runner refuses the viewer-only tools. + * `engine` (by default the one registered for `options.viewer`). The runner + * checks each action against the capabilities of the attached scene. */ export function initGevVoiceCommands(options) { const voice = createSceneVoice(options);