mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 19:34:01 +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 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:<port>/
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
|
||||
+81
-71
@@ -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
|
||||
|
||||
@@ -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
|
||||
// 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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user