mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-12 14:22:21 +00:00
Offer voice actions on whichever globe has the capability they need instead of refusing them without a Cesium viewer
This commit is contained in:
1 parent
b20579b06a
commit
59a2e31aa8
5 files changed
+390
-110
No files matched your search
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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({
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in new issue
Block a user