mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
Merge branch 'main' into codex/fix-launch-hover-throttle
This commit is contained in:
87 files changed
+5775
-280
No files matched your search
@@ -72,3 +72,17 @@ test('the defaults are Mapillary (given its source), Street View, KartaView and
|
||||
// Without the application's Street Level source there are none.
|
||||
assert.deepEqual(createApplicationStreetLevel({}).providerIds, []);
|
||||
});
|
||||
|
||||
test('every built-in provider is exported, so hosts can choose their own list', async () => {
|
||||
const google = await import('gods-eye-view/layers/street-level/google');
|
||||
const kartaview = await import('gods-eye-view/layers/street-level/kartaview');
|
||||
const panoramax = await import('gods-eye-view/layers/street-level/panoramax');
|
||||
const layer = createApplicationStreetLevel({
|
||||
providers: [
|
||||
google.createGoogleProvider({ getApiKey: () => null }),
|
||||
kartaview.createKartaViewProvider(),
|
||||
panoramax.createPanoramaxProvider(),
|
||||
],
|
||||
});
|
||||
assert.deepEqual(layer.providerIds, ['google', 'kartaview', 'panoramax']);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -223,7 +223,7 @@ test('validated voice camera destinations share the UI navigation authority faca
|
||||
'move camera',
|
||||
);
|
||||
ordered(move, [
|
||||
"if (!['orbit', 'pan', 'tilt', 'rotate'].includes(motion))",
|
||||
"if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion))",
|
||||
'const start = () => {',
|
||||
/const preserveCameraFlight =\s*motion === 'orbit'/,
|
||||
'runNavigation(start, { preserveCameraFlight })',
|
||||
|
||||
+300
-12
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* One motion at a time, driven per clock tick. `once` = bounded eased nudge;
|
||||
* `continuous` runs until move_camera{stop}, ANY manual camera input on the
|
||||
* canvas (pointerdown/wheel — the cancelFlight reflex), or a navigation tool
|
||||
* canvas (pointerdown/wheel/touchstart — the cancelFlight reflex), or a navigation tool
|
||||
* starts (the shared UI navigation policy releases this owner before the new
|
||||
* destination mutates the camera). fly_route is a
|
||||
* continuous motion in the same slot: a cinematic dolly along an existing
|
||||
@@ -13,14 +13,24 @@
|
||||
* Orbit circles the current screen-center ground target holding range and
|
||||
* pitch while advancing heading (per-frame camera.lookAt; the lookAt
|
||||
* transform is cleared on every stop so manual control returns cleanly).
|
||||
* Globe rotation turns the world-frame camera about its screen-up axis through
|
||||
* Earth's center, preserving range and framing. Left/right name the visible
|
||||
* globe motion, opposite to the camera's travel around Earth.
|
||||
*/
|
||||
|
||||
import * as Cesium from 'cesium';
|
||||
import { GLOBE_VIEW } from './locations.js';
|
||||
import {
|
||||
projectEarthDiscToViewport,
|
||||
earthDiscScreenRadius,
|
||||
getKeyholeGeometry,
|
||||
} from './celestialRing.js';
|
||||
import {
|
||||
holdContinuousRender,
|
||||
releaseContinuousRender,
|
||||
} from './renderGovernor.js';
|
||||
|
||||
let _isCockpitActive = null;
|
||||
/** °/s by speed word — orbit; pan uses fractions of view height/s. */
|
||||
const ORBIT_DEG_S = { slow: 2, normal: 6, fast: 15 };
|
||||
const TILT_DEG_S = { slow: 4, normal: 10, fast: 20 };
|
||||
@@ -952,6 +962,7 @@ let _viewer = null;
|
||||
let _getTarget = null;
|
||||
let _active = null; // { kind, motion, direction, speed, mode, motionId, ...state }
|
||||
let _tickRemover = null;
|
||||
let _trackingRemover = null;
|
||||
let _inputRemovers = [];
|
||||
/** Monotonic id for the motion currently running; 0 means "no motion". */
|
||||
let _motionSeq = 0;
|
||||
@@ -1162,7 +1173,11 @@ function onTick() {
|
||||
else if (m.direction === 'right') cam.moveRight(step);
|
||||
else if (m.direction === 'up') cam.moveUp(step);
|
||||
else cam.moveDown(step);
|
||||
} else if (m.kind === 'tilt' || m.kind === 'rotate') {
|
||||
} else if (
|
||||
m.kind === 'tilt' ||
|
||||
m.kind === 'rotate' ||
|
||||
m.kind === 'rotate_globe'
|
||||
) {
|
||||
const degS = TILT_DEG_S[m.speed];
|
||||
const stepRad =
|
||||
m.mode === 'once'
|
||||
@@ -1172,7 +1187,38 @@ function onTick() {
|
||||
(scale - (m.lastScale || 0))
|
||||
: Cesium.Math.toRadians(degS) * dt;
|
||||
m.lastScale = scale;
|
||||
if (m.kind === 'tilt') {
|
||||
if (m.kind === 'rotate_globe') {
|
||||
// Tracking can acquire the camera without going through navigation.
|
||||
// Yield without resetting its pose/frame: the world-space arc proof
|
||||
// only describes Cesium's rotation while the frame is identity.
|
||||
if (
|
||||
_viewer.scene.mode !== Cesium.SceneMode.SCENE3D ||
|
||||
_viewer.trackedEntity ||
|
||||
_isCockpitActive?.() ||
|
||||
!Cesium.Matrix4.equals(cam.transform, Cesium.Matrix4.IDENTITY)
|
||||
) {
|
||||
interruptCameraMotion('globe-camera-owner-changed');
|
||||
return;
|
||||
}
|
||||
// Cesium rotates both position and basis about the reference-frame
|
||||
// origin. The captured screen-up axis keeps left/right horizontal even
|
||||
// in a tilted or rolled view; a positive angle moves the globe right.
|
||||
const angle = m.direction === 'left' ? -stepRad : stepRad;
|
||||
if (globeRotationFitsKeyhole() !== true) {
|
||||
interruptCameraMotion('globe-framing-changed');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!globeRotationHasClearance(cam.positionWC) ||
|
||||
Math.abs(
|
||||
Cesium.Cartesian3.magnitude(cam.positionWC) - m.rotationRadius,
|
||||
) > 0.01
|
||||
) {
|
||||
interruptCameraMotion('globe-surface-limit');
|
||||
return;
|
||||
}
|
||||
cam.rotate(m.rotationAxis, angle);
|
||||
} else if (m.kind === 'tilt') {
|
||||
const up = m.direction === 'up';
|
||||
// Clamp pitch to [-89°, -5°]: never through the ground, never at the sky.
|
||||
const next = cam.pitch + (up ? stepRad : -stepRad);
|
||||
@@ -1206,6 +1252,133 @@ function onTick() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Rigid origin-centred rotation stays outside the actual ellipsoid's bounding sphere. */
|
||||
function globeRotationHasClearance(position) {
|
||||
const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84;
|
||||
const { minimumRadius, maximumRadius } = ellipsoid;
|
||||
const radius = Cesium.Cartesian3.magnitude(position);
|
||||
return (
|
||||
Number.isFinite(minimumRadius) &&
|
||||
minimumRadius > 0 &&
|
||||
Number.isFinite(maximumRadius) &&
|
||||
maximumRadius >= minimumRadius &&
|
||||
Number.isFinite(radius) &&
|
||||
radius > maximumRadius * (1 + 1e-12)
|
||||
);
|
||||
}
|
||||
|
||||
/** Use the existing WGS84/CSS-pixel projection; null means unsupported, not close-up. */
|
||||
function globeRotationFitsKeyhole(camera = _viewer.camera) {
|
||||
const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84;
|
||||
if (!Cesium.Cartesian3.equals(ellipsoid.radii, Cesium.Ellipsoid.WGS84.radii))
|
||||
return null;
|
||||
const canvas = _viewer.scene.canvas;
|
||||
const width = canvas.clientWidth;
|
||||
const height = canvas.clientHeight;
|
||||
const frustum = camera.frustum;
|
||||
if (
|
||||
!Number.isFinite(width) ||
|
||||
!Number.isFinite(height) ||
|
||||
width <= 0 ||
|
||||
height <= 0 ||
|
||||
!(frustum instanceof Cesium.PerspectiveFrustum) ||
|
||||
!Number.isFinite(frustum.near) ||
|
||||
frustum.near <= 0 ||
|
||||
!Number.isFinite(frustum.far) ||
|
||||
frustum.far <= frustum.near ||
|
||||
frustum.xOffset !== 0 ||
|
||||
frustum.yOffset !== 0
|
||||
)
|
||||
return null;
|
||||
try {
|
||||
const cam = camera;
|
||||
const distance = Cesium.Cartesian3.magnitude(cam.positionWC);
|
||||
const sphereRadius = ellipsoid.maximumRadius;
|
||||
if (!Number.isFinite(distance) || distance <= 0) return null;
|
||||
if (distance <= sphereRadius) return false; // Near-surface polar views are close-up too.
|
||||
const radiusPx = earthDiscScreenRadius(distance, height, frustum.fovy);
|
||||
if (!radiusPx) return null;
|
||||
const keyhole = getKeyholeGeometry(width, height);
|
||||
// The known nadir reset probe uses the same exact cone below; projecting
|
||||
// through SceneTransforms would read the live scene.camera instead.
|
||||
if (
|
||||
camera === _viewer.camera &&
|
||||
!projectEarthDiscToViewport(_viewer, width, height)
|
||||
) {
|
||||
// A near-surface horizon view cannot fit even when pointed at Earth;
|
||||
// an orbital camera looking away is unsupported, not a close-up offer.
|
||||
return radiusPx > Math.min(keyhole.radius, width / 2, height / 2)
|
||||
? false
|
||||
: null;
|
||||
}
|
||||
// The shared disc is an overlay approximation off-axis. Bound the sphere
|
||||
// against the existing keyhole cone. Canvas-edge clipping does not change
|
||||
// framing during rigid globe rotation and is not a clearance condition,
|
||||
// without changing the UI projection or defining another keyhole/mask.
|
||||
const toCenter = Cesium.Cartesian3.negate(
|
||||
cam.positionWC,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const depth = Cesium.Cartesian3.dot(toCenter, cam.directionWC);
|
||||
const right = Math.abs(Cesium.Cartesian3.dot(toCenter, cam.rightWC));
|
||||
const up = Math.abs(Cesium.Cartesian3.dot(toCenter, cam.upWC));
|
||||
const focalY = height / (2 * Math.tan(frustum.fovy / 2));
|
||||
const focalX =
|
||||
width / (2 * Math.tan(frustum.fovy / 2) * frustum.aspectRatio);
|
||||
if (
|
||||
![depth, right, up, focalX, focalY].every(Number.isFinite) ||
|
||||
focalX <= 0 ||
|
||||
focalY <= 0
|
||||
)
|
||||
return null;
|
||||
const halfAngle = Math.asin(sphereRadius / distance);
|
||||
const centerAngle = Math.atan2(Math.hypot(right, up), depth);
|
||||
const keyholeAngle = Math.atan(keyhole.radius / Math.max(focalX, focalY));
|
||||
// No overlay visibility hysteresis: a fully contained smaller globe needs no reset.
|
||||
const framed = centerAngle + halfAngle <= keyholeAngle;
|
||||
if (!framed) return false;
|
||||
// Projection alone does not prove that a tiny globe is within depth planes.
|
||||
if (
|
||||
depth - sphereRadius < frustum.near ||
|
||||
depth + sphereRadius > frustum.far
|
||||
)
|
||||
return null;
|
||||
return true;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Check the unchanged reset destination without moving the live camera. */
|
||||
function globeResetFitsKeyhole() {
|
||||
try {
|
||||
const camera = Cesium.Camera.clone(_viewer.camera);
|
||||
const { longitude, latitude } = _viewer.camera.positionCartographic;
|
||||
camera.setView({
|
||||
destination: Cesium.Cartesian3.fromRadians(
|
||||
longitude,
|
||||
latitude,
|
||||
GLOBE_VIEW.heightM,
|
||||
),
|
||||
orientation: {
|
||||
heading: 0,
|
||||
pitch: Cesium.Math.toRadians(GLOBE_VIEW.pitchDeg),
|
||||
roll: 0,
|
||||
},
|
||||
endTransform: Cesium.Matrix4.IDENTITY,
|
||||
});
|
||||
return globeRotationFitsKeyhole(camera) === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Release only globe rotation when a new camera intent takes ownership. */
|
||||
export function interruptGlobeRotation(reason = 'camera-intent') {
|
||||
if (_active?.kind === 'rotate_globe') return interruptCameraMotion(reason);
|
||||
return { wasActive: false, reason, leveled: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire the module to the viewer once (idempotent). The view-target getter is
|
||||
* optional so a non-voice caller (the Directions FLY chip) can arm route
|
||||
@@ -1218,24 +1391,42 @@ function onTick() {
|
||||
* call brings none, so a callback from the old viewer can never answer for the
|
||||
* new one.
|
||||
*/
|
||||
export function initCameraVerbs(viewer, getViewTargetCartesian = null) {
|
||||
export function initCameraVerbs(
|
||||
viewer,
|
||||
getViewTargetCartesian = null,
|
||||
{ isCockpitActive } = {},
|
||||
) {
|
||||
const nextTarget =
|
||||
typeof getViewTargetCartesian === 'function'
|
||||
? getViewTargetCartesian
|
||||
: null;
|
||||
if (_viewer === viewer) {
|
||||
if (nextTarget) _getTarget = nextTarget;
|
||||
if (typeof isCockpitActive === 'function')
|
||||
_isCockpitActive = isCockpitActive;
|
||||
return;
|
||||
}
|
||||
// A globe motion belongs to the viewer whose world pose it captured.
|
||||
if (_active?.kind === 'rotate_globe')
|
||||
interruptCameraMotion('viewer-replaced');
|
||||
_getTarget = nextTarget;
|
||||
_isCockpitActive =
|
||||
typeof isCockpitActive === 'function' ? isCockpitActive : null;
|
||||
_viewer = viewer;
|
||||
if (_tickRemover) _tickRemover();
|
||||
_tickRemover = viewer.clock.onTick.addEventListener(onTick);
|
||||
if (_trackingRemover) _trackingRemover();
|
||||
// Cockpit can acquire then clear tracking before the next clock tick.
|
||||
// Yield on acquisition itself without changing legacy camera motions.
|
||||
_trackingRemover = viewer.trackedEntityChanged?.addEventListener((entity) => {
|
||||
if (entity && _active?.kind === 'rotate_globe')
|
||||
interruptCameraMotion('globe-camera-owner-changed');
|
||||
});
|
||||
for (const rm of _inputRemovers) rm();
|
||||
_inputRemovers = [];
|
||||
const canvas = viewer.scene.canvas;
|
||||
// ANY manual camera input reclaims control — the cancelFlight reflex.
|
||||
for (const evt of ['pointerdown', 'wheel']) {
|
||||
for (const evt of ['pointerdown', 'touchstart', 'wheel']) {
|
||||
const h = () => interruptCameraMotion('manual-input');
|
||||
canvas.addEventListener(evt, h, { passive: true });
|
||||
_inputRemovers.push(() => canvas.removeEventListener(evt, h));
|
||||
@@ -1256,7 +1447,11 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
const speed = ORBIT_DEG_S[String(args.speed || 'normal').toLowerCase()]
|
||||
? String(args.speed || 'normal').toLowerCase()
|
||||
: 'normal';
|
||||
const mode = args.mode === 'continuous' ? 'continuous' : 'once';
|
||||
const mode =
|
||||
args.mode === 'continuous' ||
|
||||
(motion === 'rotate_globe' && args.mode === undefined)
|
||||
? 'continuous'
|
||||
: 'once';
|
||||
if (motion === 'stop') {
|
||||
const wasActiveBeforeHandoff = Boolean(_active);
|
||||
const stop = () => {
|
||||
@@ -1270,15 +1465,16 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
};
|
||||
return typeof runNavigation === 'function' ? runNavigation(stop) : stop();
|
||||
}
|
||||
if (!['orbit', 'pan', 'tilt', 'rotate'].includes(motion)) {
|
||||
if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion)) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error: `Unknown motion "${args.motion}" — use orbit, pan, tilt, rotate, or stop.`,
|
||||
error: `Unknown motion "${args.motion}" — use orbit, pan, tilt, rotate, rotate_globe, or stop.`,
|
||||
};
|
||||
}
|
||||
if (
|
||||
motion !== 'orbit' &&
|
||||
motion !== 'rotate_globe' &&
|
||||
!['left', 'right', 'up', 'down'].includes(direction)
|
||||
) {
|
||||
return {
|
||||
@@ -1294,6 +1490,17 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
error: 'rotate needs left or right.',
|
||||
};
|
||||
}
|
||||
if (
|
||||
motion === 'rotate_globe' &&
|
||||
direction !== null &&
|
||||
!['left', 'right'].includes(direction)
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error: 'rotate_globe needs left or right.',
|
||||
};
|
||||
}
|
||||
if (motion === 'tilt') {
|
||||
// Honest limits: at the clamp a tilt is a no-op — say so instead of
|
||||
// reporting success and letting the model gaslight itself (field test).
|
||||
@@ -1318,9 +1525,9 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
}
|
||||
}
|
||||
if (
|
||||
motion === 'orbit' &&
|
||||
_viewer?.trackedEntity &&
|
||||
typeof runNavigation !== 'function'
|
||||
(motion === 'rotate_globe' ||
|
||||
(motion === 'orbit' && typeof runNavigation !== 'function')) &&
|
||||
_viewer?.trackedEntity
|
||||
) {
|
||||
// The follow camera owns a tracked view; a lookAt orbit fights it frame
|
||||
// by frame and stop rips the camera out (field finding). Honest refusal
|
||||
@@ -1332,9 +1539,74 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error: `Already following ${label} — the follow camera owns the view while tracking. Say "stop tracking" first if you want a free orbit.`,
|
||||
error: `Already following ${label} — the follow camera owns the view while tracking. Say "stop tracking" first if you want ${motion === 'rotate_globe' ? 'to rotate the globe' : 'a free orbit'}.`,
|
||||
};
|
||||
}
|
||||
let globePose;
|
||||
if (motion === 'rotate_globe') {
|
||||
if (_viewer.scene.mode !== Cesium.SceneMode.SCENE3D) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error: 'Globe rotation requires a 3D view.',
|
||||
};
|
||||
}
|
||||
const cam = _viewer.camera;
|
||||
globePose = {
|
||||
position: Cesium.Cartesian3.clone(cam.positionWC),
|
||||
direction: Cesium.Cartesian3.clone(cam.directionWC),
|
||||
up: Cesium.Cartesian3.normalize(cam.upWC, new Cesium.Cartesian3()),
|
||||
right: Cesium.Cartesian3.clone(cam.rightWC),
|
||||
};
|
||||
if (_isCockpitActive?.())
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
reason: 'camera-owned',
|
||||
error: 'Exit Cockpit before rotating the globe.',
|
||||
};
|
||||
const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84;
|
||||
if (!(
|
||||
Number.isFinite(ellipsoid.minimumRadius) &&
|
||||
ellipsoid.minimumRadius > 0 &&
|
||||
Number.isFinite(ellipsoid.maximumRadius) &&
|
||||
ellipsoid.maximumRadius >= ellipsoid.minimumRadius &&
|
||||
Number.isFinite(Cesium.Cartesian3.magnitude(globePose.position))
|
||||
))
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
reason: 'unsafe-ellipsoid',
|
||||
error:
|
||||
'Globe rotation requires a finite supported ellipsoid and a clear globe view.',
|
||||
};
|
||||
const fits = globeRotationFitsKeyhole();
|
||||
if (fits === null)
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
reason: 'unsupported-view',
|
||||
error: 'Globe rotation requires a supported perspective view.',
|
||||
};
|
||||
if (!fits) {
|
||||
const canReset = globeResetFitsKeyhole();
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
reason: canReset ? 'globe-view-required' : 'globe-reset-unavailable',
|
||||
error: canReset
|
||||
? 'Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’'
|
||||
: 'Zoom out further to fit the globe inside the keyhole; the Globe View preset does not fit this viewport.',
|
||||
};
|
||||
}
|
||||
if (!globeRotationHasClearance(globePose.position))
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
reason: 'unsafe-ellipsoid',
|
||||
error: 'Globe rotation requires a clear ellipsoid bounding sphere.',
|
||||
};
|
||||
}
|
||||
const start = () => {
|
||||
interruptCameraMotion('replaced');
|
||||
const state = {
|
||||
@@ -1343,6 +1615,22 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
speed,
|
||||
mode,
|
||||
};
|
||||
if (motion === 'rotate_globe') {
|
||||
// Convert an inherited ground-target frame without changing the world
|
||||
// pose. Rotation must pivot about Earth, never the previous local target.
|
||||
const cam = _viewer.camera;
|
||||
cam.cancelFlight();
|
||||
cam.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
||||
// Handoff may level a route's bank. Keep the caller's exact world pose.
|
||||
// Copy the basis directly: setView's heading/pitch conversion is singular
|
||||
// at nadir and can change the captured orientation near the poles.
|
||||
Cesium.Cartesian3.clone(globePose.position, cam.position);
|
||||
Cesium.Cartesian3.clone(globePose.direction, cam.direction);
|
||||
Cesium.Cartesian3.clone(globePose.up, cam.up);
|
||||
Cesium.Cartesian3.clone(globePose.right, cam.right);
|
||||
state.rotationAxis = globePose.up;
|
||||
state.rotationRadius = Cesium.Cartesian3.magnitude(globePose.position);
|
||||
}
|
||||
if (motion === 'orbit') {
|
||||
if (_viewer.scene.tweens.length > 0) {
|
||||
// A camera flight is in progress ("fly to X and orbit it") — ALWAYS arm
|
||||
|
||||
@@ -1011,6 +1011,34 @@ test('vessel interaction wire: flights-owned pick preserves vessel selection', (
|
||||
}
|
||||
});
|
||||
|
||||
test('vessel touch interaction yields to the first nearby flight behind map tiles', () => {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'matchMedia');
|
||||
Object.defineProperty(globalThis, 'matchMedia', {
|
||||
configurable: true,
|
||||
value: () => ({ matches: true }),
|
||||
});
|
||||
registerPickOwner('flights', (pickedId) => pickedId === 'a1b2c3');
|
||||
const tile = { primitive: { isCesium3DTileset: true } };
|
||||
const harness = installWireHarness(tile);
|
||||
let drills = 0;
|
||||
harness.viewer.scene.drillPick = (_position, limit, width, height) => {
|
||||
drills++;
|
||||
assert.deepEqual([limit, width, height], [8, 14, 14]);
|
||||
return [tile, { id: 'a1b2c3' }];
|
||||
};
|
||||
try {
|
||||
harness.handler.click({ position: { x: 10, y: 20 } });
|
||||
assert.equal(drills, 1);
|
||||
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi);
|
||||
assert.equal(harness.trail.clearCalls, 0);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
unregisterPickOwner('flights');
|
||||
if (previous) Object.defineProperty(globalThis, 'matchMedia', previous);
|
||||
else delete globalThis.matchMedia;
|
||||
}
|
||||
});
|
||||
|
||||
test('vessel interaction wire: CCTV-owned pick preserves vessel selection', () => {
|
||||
registerPickOwner('cctv', (pickedId) => pickedId === 'atx-cam-3');
|
||||
const harness = installWireHarness({ id: 'atx-cam-3' });
|
||||
|
||||
+95
-18
@@ -1040,26 +1040,103 @@ export function detachImportedTime(dataSource) {
|
||||
return dataSource;
|
||||
}
|
||||
|
||||
/** Repair only the first KML opening tag, scanning its delimiter once. */
|
||||
export function repairKmlRootNamespaces(value, what = 'KML') {
|
||||
const text = String(value);
|
||||
// Skip XML comments and processing instructions without treating their
|
||||
// literal '<kml' text as an opening root tag.
|
||||
let start = 0;
|
||||
for (;;) {
|
||||
start = text.indexOf('<', start);
|
||||
if (start < 0) return text;
|
||||
if (text.slice(start, start + 9).toUpperCase() === '<!DOCTYPE') {
|
||||
let quote = null;
|
||||
let subsetDepth = 0;
|
||||
let end = start + 9;
|
||||
for (; end < text.length; end++) {
|
||||
const char = text[end];
|
||||
if (quote) {
|
||||
if (char === quote) quote = null;
|
||||
} else if (char === '"' || char === "'") quote = char;
|
||||
else if (char === '<' && text.startsWith('<!--', end)) {
|
||||
// Comments and processing instructions inside the internal subset
|
||||
// carry no declaration syntax; jump past them as a whole.
|
||||
const skip = text.indexOf('-->', end + 4);
|
||||
if (skip < 0) return text;
|
||||
end = skip + 2;
|
||||
} else if (char === '<' && text.startsWith('<?', end)) {
|
||||
const skip = text.indexOf('?>', end + 2);
|
||||
if (skip < 0) return text;
|
||||
end = skip + 1;
|
||||
} else if (char === '[') subsetDepth++;
|
||||
else if (char === ']') subsetDepth--;
|
||||
else if (char === '>' && subsetDepth === 0) break;
|
||||
}
|
||||
if (end === text.length) return text;
|
||||
start = end + 1;
|
||||
continue;
|
||||
}
|
||||
const close = text.startsWith('<!--', start)
|
||||
? '-->'
|
||||
: text.startsWith('<?', start)
|
||||
? '?>'
|
||||
: null;
|
||||
if (!close) break;
|
||||
const end = text.indexOf(close, start + 2);
|
||||
if (end < 0) return text; // The XML parser reports an unterminated preamble.
|
||||
start = end + close.length;
|
||||
}
|
||||
const match = /^<kml\b/i.exec(text.slice(start, start + 5));
|
||||
if (!match) return text;
|
||||
let quote = null;
|
||||
let end = -1;
|
||||
for (let index = start + match[0].length; index < text.length; index++) {
|
||||
const char = text[index];
|
||||
if (char === '<') break; // Raw '<' is invalid even inside an XML attribute.
|
||||
if (quote) {
|
||||
if (char === quote) quote = null;
|
||||
} else if (char === '"' || char === "'") {
|
||||
quote = char;
|
||||
} else if (char === '>') {
|
||||
end = index + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (end < 0)
|
||||
throw new GeoImportError(
|
||||
`That ${what} file is not valid XML.`,
|
||||
msg('That {format} file is not valid XML.'),
|
||||
{ format: what },
|
||||
);
|
||||
// Walk the root's attributes in order so quoted values are never read as
|
||||
// declarations. Anything the walk cannot tokenize is kept verbatim for the
|
||||
// XML parser to judge.
|
||||
const seen = new Set();
|
||||
const attribute = /(\s+)([^\s=/>"']+)\s*=\s*("[^"]*"|'[^']*')/y;
|
||||
let position = start + match[0].length;
|
||||
let root = text.slice(start, position);
|
||||
for (;;) {
|
||||
attribute.lastIndex = position;
|
||||
const found = attribute.exec(text);
|
||||
if (!found || attribute.lastIndex > end) break;
|
||||
position = attribute.lastIndex;
|
||||
const prefix = /^xmlns:([\w.-]+)$/.exec(found[2])?.[1];
|
||||
if (prefix && seen.has(prefix)) continue;
|
||||
if (prefix) seen.add(prefix);
|
||||
root += found[0];
|
||||
}
|
||||
root += text.slice(position, end);
|
||||
if (!seen.has('xsi') && /[<\s]xsi:/.test(text))
|
||||
root = root.replace(
|
||||
/(\/?)>$/,
|
||||
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"$1>',
|
||||
);
|
||||
return text.slice(0, start) + root + text.slice(end);
|
||||
}
|
||||
|
||||
function parseXml(text, what) {
|
||||
// Match the namespace repairs Cesium applies to ordinary exported KML.
|
||||
if (what !== 'GPX')
|
||||
text = String(text).replace(/<kml\b[^>]*>/i, (root) => {
|
||||
const seen = new Set();
|
||||
root = root.replace(
|
||||
/\s+xmlns:([\w.-]+)\s*=\s*(["'])[^"']*\2/g,
|
||||
(declaration, prefix) => {
|
||||
if (seen.has(prefix)) return '';
|
||||
seen.add(prefix);
|
||||
return declaration;
|
||||
},
|
||||
);
|
||||
if (!seen.has('xsi') && /[<\s]xsi:/.test(text))
|
||||
root = root.replace(
|
||||
/>$/,
|
||||
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">',
|
||||
);
|
||||
return root;
|
||||
});
|
||||
if (what !== 'GPX') text = repairKmlRootNamespaces(text, what);
|
||||
const doc = new DOMParser().parseFromString(
|
||||
String(text).replace(/^/, ''),
|
||||
'application/xml',
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { deflateRawSync } from 'node:zlib';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
GeoImportError,
|
||||
repairKmlRootNamespaces,
|
||||
loadGeoFile,
|
||||
MAX_IMPORT_POSITIONS,
|
||||
MAX_IMPORT_ENTITIES,
|
||||
@@ -1171,3 +1173,92 @@ test('a dated import exposes no clock and draws for all time', () => {
|
||||
assert.equal(recorded.getValue(Cesium.JulianDate.now()).length, 2);
|
||||
assert.ok(track.polyline.clampToGround.getValue());
|
||||
});
|
||||
|
||||
test('KML root namespace repair respects quoted delimiters and preserves the document tail', () => {
|
||||
const text =
|
||||
'<KML note="a > b" xmlns:x="first" xmlns:x="second"><Placemark xsi:type="test"/></KML>';
|
||||
const result = repairKmlRootNamespaces(text);
|
||||
assert.match(result, /note="a > b"/);
|
||||
assert.match(result, /xmlns:x="first"/);
|
||||
assert.doesNotMatch(result, /second/);
|
||||
assert.match(
|
||||
result,
|
||||
/xmlns:xsi="http:\/\/www.w3.org\/2001\/XMLSchema-instance"/,
|
||||
);
|
||||
assert.ok(result.endsWith('<Placemark xsi:type="test"/></KML>'));
|
||||
assert.match(
|
||||
repairKmlRootNamespaces('<kml xsi:type="test"/>'),
|
||||
/XMLSchema-instance"\/>$/,
|
||||
);
|
||||
for (const malformed of ['<kml ', '<kml name="unterminated>', '<kml <kml >'])
|
||||
assert.throws(() => repairKmlRootNamespaces(malformed), GeoImportError);
|
||||
});
|
||||
|
||||
test('repeated incomplete KML roots finish within an isolated process deadline', () => {
|
||||
const source = `import { repairKmlRootNamespaces } from ${JSON.stringify(new URL('./geoFileImport.js', import.meta.url).href)};
|
||||
try { repairKmlRootNamespaces('<kml '.repeat(1000000)); process.exit(1); }
|
||||
catch (error) { if (error.name !== 'GeoImportError') throw error; }
|
||||
try { repairKmlRootNamespaces('<!DOCTYPE kml [' + ${JSON.stringify("<!-- ' --><?pi ' ?>")}.repeat(100000) + ']><kml '); process.exit(1); }
|
||||
catch (error) { if (error.name !== 'GeoImportError') throw error; }
|
||||
const wide = '<kml' + ${JSON.stringify(` xmlns:gx="a" note='xmlns:gx="b"'`)}.repeat(20000) + '/>';
|
||||
if (repairKmlRootNamespaces(wide).split('xmlns:gx="a"').length !== 2) process.exit(1);`;
|
||||
const child = spawnSync(
|
||||
process.execPath,
|
||||
['--input-type=module', '-e', source],
|
||||
{ timeout: 5000, encoding: 'utf8' },
|
||||
);
|
||||
assert.ifError(child.error);
|
||||
assert.equal(child.status, 0, child.stderr);
|
||||
});
|
||||
|
||||
test('KML root repair ignores incomplete tag text inside preamble comments', () => {
|
||||
const input =
|
||||
'<?xml version="1.0"?><!-- example <kml incomplete -->\n<kml xmlns:gx="a" xmlns:gx="b"><Document/></kml>';
|
||||
assert.equal(
|
||||
repairKmlRootNamespaces(input),
|
||||
input.replace(' xmlns:gx="b"', ''),
|
||||
);
|
||||
});
|
||||
|
||||
test('KML namespace repair follows a doctype with a quoted root-like entity', () => {
|
||||
const input =
|
||||
'<!DOCTYPE kml [<!ENTITY example "<kml incomplete >">]><kml xmlns:gx="a" xmlns:gx="b"/>';
|
||||
assert.equal(
|
||||
repairKmlRootNamespaces(input),
|
||||
input.replace(' xmlns:gx="b"', ''),
|
||||
);
|
||||
});
|
||||
|
||||
test('KML namespace repair skips doctype comments and processing instructions whole', () => {
|
||||
for (const prolog of [
|
||||
"<!DOCTYPE kml [<!-- don't count this quote -->]>",
|
||||
'<!DOCTYPE kml [<!-- a ] or > here --><!ENTITY x "y">]>',
|
||||
"<!DOCTYPE kml [<?note it's ] > fine?>]>",
|
||||
'<!DOCTYPE kml [<!ENTITY x "<!-- \' -->">]>',
|
||||
]) {
|
||||
const input = `${prolog}<kml xmlns:gx="a" xmlns:gx="b"><Placemark xsi:type="t"/></kml>`;
|
||||
assert.equal(
|
||||
repairKmlRootNamespaces(input),
|
||||
input.replace(
|
||||
' xmlns:gx="b">',
|
||||
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">',
|
||||
),
|
||||
prolog,
|
||||
);
|
||||
}
|
||||
const unterminated = '<!DOCTYPE kml [<!-- open <kml xmlns:gx="a">';
|
||||
assert.equal(repairKmlRootNamespaces(unterminated), unterminated);
|
||||
});
|
||||
|
||||
test('KML namespace repair ignores declaration text inside quoted attribute values', () => {
|
||||
const input =
|
||||
'<kml note=\'a > b xmlns:gx="fake"\' xmlns:gx="http://www.google.com/kml/ext/2.2" xmlns:gx="dup"><gx:Track/></kml>';
|
||||
const result = repairKmlRootNamespaces(input);
|
||||
assert.equal(result, input.replace(' xmlns:gx="dup"', ''));
|
||||
assert.match(result, /\sxmlns:gx="http:\/\/www.google.com\/kml\/ext\/2.2"/);
|
||||
const xsi = '<kml note="xmlns:xsi=\'x\'"><Placemark xsi:type="t"/></kml>';
|
||||
assert.match(
|
||||
repairKmlRootNamespaces(xsi),
|
||||
/note="xmlns:xsi='x'" xmlns:xsi="http:\/\/www.w3.org\/2001\/XMLSchema-instance">/,
|
||||
);
|
||||
});
|
||||
@@ -134,7 +134,10 @@ test('every scene click handler consults ownership before it picks', () => {
|
||||
'src/layers/submarineCables/interaction.js',
|
||||
'viewer.scene.pick(click.position)',
|
||||
],
|
||||
['src/layers/vessels/selection.js', 'viewer.scene.pick(click.position)'],
|
||||
[
|
||||
'src/layers/vessels/selection.js',
|
||||
'pickContact(viewer.scene, click.position)',
|
||||
],
|
||||
];
|
||||
for (const [file, firstPick] of guarded) {
|
||||
const source = read(file);
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
* that belong to someone else and leave them alone.
|
||||
*/
|
||||
|
||||
import { isSurfacePick } from './scenePick.js';
|
||||
|
||||
/** @type {Map<string, (pickedId: string) => boolean>} layerId -> ownership predicate */
|
||||
const _owners = new Map();
|
||||
|
||||
@@ -100,3 +102,36 @@ export function isOwnedByOtherLayer(layerId, pickedId) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a moving-contact click without letting map tiles hide a nearby touch
|
||||
* target. Exact picks always win; only a coarse primary pointer may expand a
|
||||
* surface miss. Every consumer uses the same ordered, bounded scene hits, so sibling layers
|
||||
* do not each choose their own contact under one tap.
|
||||
* Unowned non-surface objects remain barriers, not permission to pick through.
|
||||
*
|
||||
* @param {object} scene Cesium scene with pick and optional drillPick.
|
||||
* @param {{x: number, y: number}} position Screen position in CSS pixels.
|
||||
* @returns {object|null|undefined} Original pick or the first registered touch hit.
|
||||
*/
|
||||
export function pickContact(scene, position) {
|
||||
const picked = scene.pick(position);
|
||||
if (
|
||||
!globalThis.matchMedia?.('(pointer: coarse)').matches ||
|
||||
findPickOwner(resolvePickId(picked)) ||
|
||||
!isSurfacePick(picked) ||
|
||||
typeof scene.drillPick !== 'function'
|
||||
)
|
||||
return picked;
|
||||
let hits;
|
||||
try {
|
||||
hits = scene.drillPick(position, 8, 14, 14) || [];
|
||||
} catch {
|
||||
return picked;
|
||||
}
|
||||
for (const hit of hits.slice(0, 8)) {
|
||||
if (findPickOwner(resolvePickId(hit))) return hit;
|
||||
if (!isSurfacePick(hit)) break;
|
||||
}
|
||||
return picked;
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
unregisterPickOwner,
|
||||
isOwnedByOtherLayer,
|
||||
resolvePickId,
|
||||
pickContact,
|
||||
} from './pickRegistry.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -97,3 +98,122 @@ test('ownership: unregister removes the predicate', () => {
|
||||
unregisterPickOwner('bikeshare');
|
||||
assert.equal(isOwnedByOtherLayer('flights', 'station:1'), false);
|
||||
});
|
||||
|
||||
function primaryPointer(t, coarse) {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'matchMedia');
|
||||
Object.defineProperty(globalThis, 'matchMedia', {
|
||||
configurable: true,
|
||||
value: (query) => ({ matches: query === '(pointer: coarse)' && coarse }),
|
||||
});
|
||||
t.after(() => {
|
||||
if (previous) Object.defineProperty(globalThis, 'matchMedia', previous);
|
||||
else delete globalThis.matchMedia;
|
||||
});
|
||||
}
|
||||
|
||||
function contactOwners(t) {
|
||||
registerPickOwner('flights', (id) => id === 'civil');
|
||||
registerPickOwner('military', (id) => id === 'military');
|
||||
registerPickOwner('ais-live-vessels', (id) => id === '123456789');
|
||||
t.after(() => {
|
||||
for (const id of ['flights', 'military', 'ais-live-vessels'])
|
||||
unregisterPickOwner(id);
|
||||
});
|
||||
}
|
||||
|
||||
const mapTile = { primitive: { isCesium3DTileset: true } };
|
||||
|
||||
test('touch contact pick expands only a surface miss, bounded to 8 hits in 14 CSS pixels', (t) => {
|
||||
primaryPointer(t, true);
|
||||
contactOwners(t);
|
||||
const position = { x: 30, y: 50 };
|
||||
const contact = { primitive: { id: 'civil' } };
|
||||
let drills = 0;
|
||||
const scene = {
|
||||
pick(point) {
|
||||
assert.equal(point, position);
|
||||
return mapTile;
|
||||
},
|
||||
drillPick(...args) {
|
||||
drills++;
|
||||
assert.deepEqual(args, [position, 8, 14, 14]);
|
||||
return [mapTile, contact];
|
||||
},
|
||||
};
|
||||
assert.equal(pickContact(scene, position), contact);
|
||||
assert.equal(drills, 1);
|
||||
});
|
||||
|
||||
test('touch contact pick returns the first registered sibling, never a caller-specific preference', (t) => {
|
||||
primaryPointer(t, true);
|
||||
contactOwners(t);
|
||||
for (const first of [{ id: 'military' }, { id: { mmsi: '123456789' } }]) {
|
||||
const scene = {
|
||||
pick: () => mapTile,
|
||||
drillPick: () => [mapTile, first, { id: 'civil' }],
|
||||
};
|
||||
assert.equal(pickContact(scene, { x: 1, y: 1 }), first);
|
||||
}
|
||||
});
|
||||
|
||||
test('touch contact pick does not drill through exact registered or unregistered interactive picks', (t) => {
|
||||
primaryPointer(t, true);
|
||||
contactOwners(t);
|
||||
for (const exact of [
|
||||
{ id: 'military' },
|
||||
{ id: 'custom-annotation' },
|
||||
{ primitive: { id: 'custom-gizmo' } },
|
||||
{ primitive: { id: 'civil', isCesium3DTileset: true } },
|
||||
]) {
|
||||
const scene = {
|
||||
pick: () => exact,
|
||||
drillPick: () => assert.fail('must keep exact interactive hit'),
|
||||
};
|
||||
assert.equal(pickContact(scene, { x: 1, y: 1 }), exact);
|
||||
}
|
||||
});
|
||||
|
||||
test('touch expansion stops at a non-surface object and respects the drill bound', (t) => {
|
||||
primaryPointer(t, true);
|
||||
contactOwners(t);
|
||||
for (const hits of [
|
||||
[mapTile, { id: 'custom-annotation' }, { id: 'civil' }],
|
||||
[...Array(8).fill(mapTile), { id: 'civil' }],
|
||||
[mapTile],
|
||||
]) {
|
||||
assert.equal(
|
||||
pickContact(
|
||||
{ pick: () => mapTile, drillPick: () => hits },
|
||||
{ x: 1, y: 1 },
|
||||
),
|
||||
mapTile,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('fine primary pointers keep the existing exact mouse pick without a drill', (t) => {
|
||||
primaryPointer(t, false);
|
||||
contactOwners(t);
|
||||
const scene = {
|
||||
pick: () => mapTile,
|
||||
drillPick: () => assert.fail('mouse must not expand'),
|
||||
};
|
||||
assert.equal(pickContact(scene, { x: 1, y: 1 }), mapTile);
|
||||
});
|
||||
|
||||
test('touch picking tolerates unavailable or failing drill support and preserves empty sky', (t) => {
|
||||
primaryPointer(t, true);
|
||||
contactOwners(t);
|
||||
for (const drillPick of [
|
||||
undefined,
|
||||
() => {
|
||||
throw new Error('context lost');
|
||||
},
|
||||
() => [],
|
||||
]) {
|
||||
assert.equal(
|
||||
pickContact({ pick: () => undefined, drillPick }, { x: 1, y: 1 }),
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -712,6 +712,58 @@ test('unusable directory responses preserve warm client state atomically', async
|
||||
assert.equal(getRadioUIState().stale, true);
|
||||
assert.equal(getRadioUIState().degraded, true);
|
||||
}
|
||||
globalThis.fetch = async () => new Response(JSON.stringify({
|
||||
stations: [replacement, { id: 'malformed' }], stale: false, degraded: false,
|
||||
acceptedGeneration: 2, catalogInstance: 'qa-instance-a', updatedAt: now,
|
||||
}));
|
||||
await radioLayer.update();
|
||||
assert.equal(getRadioUIState().stationCount, 1);
|
||||
assert.equal(getRadioUIState().rejectedStationCount, 1);
|
||||
assert.equal(getRadioUIState().degraded, true);
|
||||
assert.equal(getRadioUIState().stale, false);
|
||||
assert.equal(getRadioAcceptedCatalogSnapshot().generation, 2);
|
||||
assert.equal(getRadioAcceptedCatalogSnapshot().stations[0].id, replacement.id);
|
||||
// Server-side structural rejections are combined with client rejections
|
||||
// after admission; the healthy generation is still accepted.
|
||||
globalThis.fetch = async () => new Response(JSON.stringify({
|
||||
stations: [replacement, { id: 'malformed' }], stale: false, degraded: false,
|
||||
acceptedGeneration: 3, catalogInstance: 'qa-instance-a', updatedAt: now,
|
||||
rejectedStationCount: 2,
|
||||
}));
|
||||
await radioLayer.update();
|
||||
assert.equal(getRadioUIState().rejectedStationCount, 3);
|
||||
assert.equal(getRadioUIState().degraded, true);
|
||||
assert.equal(getRadioAcceptedCatalogSnapshot().generation, 3);
|
||||
globalThis.fetch = async () => new Response(JSON.stringify({
|
||||
stations: [replacement], stale: false, degraded: false,
|
||||
acceptedGeneration: 4, catalogInstance: 'qa-instance-a', updatedAt: now,
|
||||
rejectedStationCount: 10_000,
|
||||
}));
|
||||
await radioLayer.update();
|
||||
assert.equal(getRadioUIState().rejectedStationCount, 10_000);
|
||||
assert.equal(getRadioAcceptedCatalogSnapshot().generation, 4);
|
||||
for (const rejectedStationCount of [-1, 1.5, '2', null, 10_001]) {
|
||||
globalThis.fetch = async () => new Response(JSON.stringify({
|
||||
stations: [replacement], stale: false, degraded: false,
|
||||
acceptedGeneration: 5, catalogInstance: 'qa-instance-a', updatedAt: now,
|
||||
rejectedStationCount,
|
||||
}));
|
||||
await radioLayer.update();
|
||||
assert.equal(getRadioAcceptedCatalogSnapshot().generation, 4);
|
||||
assert.equal(getRadioUIState().stale, true);
|
||||
}
|
||||
globalThis.fetch = async () => new Response(JSON.stringify({
|
||||
stations: [{ id: 'malformed' }], stale: false, degraded: false,
|
||||
acceptedGeneration: 5, catalogInstance: 'qa-instance-a', updatedAt: now,
|
||||
}));
|
||||
await radioLayer.update();
|
||||
assert.equal(getRadioAcceptedCatalogSnapshot().generation, 4);
|
||||
assert.equal(getRadioUIState().stale, true);
|
||||
assert.equal(getRadioUIState().rejectedStationCount, 10_000);
|
||||
radioLayer.destroy();
|
||||
radioLayer.init(viewer);
|
||||
assert.equal(getRadioUIState().rejectedStationCount, 0);
|
||||
assert.equal(getRadioUIState().degraded, false);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
radioLayer.destroy();
|
||||
|
||||
@@ -11,7 +11,14 @@ import {
|
||||
publicRadioStation,
|
||||
publicRadioHttpsUrl,
|
||||
} from '../../vite.config.js';
|
||||
import { rankRadioStationsForRequest } from './radio.js';
|
||||
import {
|
||||
getRadioAcceptedCatalogSnapshot,
|
||||
getRadioUIState,
|
||||
radioLayer,
|
||||
rankRadioStationsForRequest,
|
||||
} from './radio.js';
|
||||
import { RADIO_REJECTED_STATION_COUNT_MAX as SERVER_REJECTED_MAX } from '../../server/providers/radio/constants.js';
|
||||
import { RADIO_REJECTED_STATION_COUNT_MAX as CLIENT_REJECTED_MAX } from '../layers/radio/policy.js';
|
||||
|
||||
const UUID = '12345678-1234-4234-8234-123456789abc';
|
||||
const publicLookup = async () => [{ address: '93.184.216.34', family: 4 }];
|
||||
@@ -533,3 +540,62 @@ test('a mirror answering an invalid reason phrase rejects instead of crashing',
|
||||
const crashed = mockMirrorStatus(t, 200, 'OK\x7f');
|
||||
await assert.rejects(Promise.race([fetchMirror(), crashed]), TypeError);
|
||||
});
|
||||
|
||||
|
||||
test('case expansion is capped after tag normalization in the portable and server station path', () => {
|
||||
const row = normalizeRadioBrowserStation(station({tags: 'İ'.repeat(80) + ', NEWS__RADIO ,news-radio'}));
|
||||
assert.equal(row.tags[0].length, 80);
|
||||
assert.equal(row.tags[0], 'i\u0307'.repeat(40));
|
||||
assert.deepEqual(row.tags.slice(1), ['news radio']);
|
||||
});
|
||||
|
||||
test('server structural rejections reach the client diagnostic without discarding a healthy generation', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const middleware = createProxy({
|
||||
now: () => Date.now(),
|
||||
fetchImpl: async (url) => {
|
||||
if (String(url).includes('/json/servers')) return responseJson([{ name: 'de1.api.radio-browser.info' }]);
|
||||
const rows = healthyRowsForQuery(url);
|
||||
if (queryTag(url)) return responseJson(rows);
|
||||
// 400 valid broad rows, one row missing its required name, and one
|
||||
// schema-valid row that normalization filters by policy (not counted).
|
||||
const { name: _name, ...nameless } = station({ stationuuid: '50000000-0000-4000-8000-000000000001' });
|
||||
return responseJson([...rows, nameless, station({ stationuuid: '50000000-0000-4000-8000-000000000002', lastcheckok: 0 })]);
|
||||
},
|
||||
});
|
||||
const response = await invoke(middleware, '/stations');
|
||||
assert.equal(response.status, 200);
|
||||
const body = JSON.parse(response.body);
|
||||
assert.equal(body.degraded, false);
|
||||
assert.equal(body.degradedReason, null);
|
||||
assert.equal(body.acceptedGeneration, 1);
|
||||
assert.equal(body.rejectedStationCount, 1);
|
||||
assert.equal(SERVER_REJECTED_MAX, CLIENT_REJECTED_MAX);
|
||||
|
||||
const viewer = {
|
||||
camera: { positionWC: { x: 7_000_000, y: 0, z: 0 } },
|
||||
scene: { canvas: { disableRootEvents: true, onwheel: null, addEventListener() {}, removeEventListener() {} } },
|
||||
dataSources: { add() {}, remove() {} },
|
||||
entities: { add(entity) { return entity; }, remove() {} },
|
||||
};
|
||||
radioLayer.destroy();
|
||||
try {
|
||||
globalThis.fetch = async () => new Response(response.body, {
|
||||
status: response.status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
radioLayer.init(viewer);
|
||||
radioLayer.enable();
|
||||
await radioLayer.update();
|
||||
const state = getRadioUIState();
|
||||
assert.equal(state.stationCount, body.stations.length);
|
||||
assert.equal(state.rejectedStationCount, 1);
|
||||
assert.equal(state.degraded, true);
|
||||
assert.equal(state.stale, false);
|
||||
assert.equal(getRadioAcceptedCatalogSnapshot().generation, 1);
|
||||
assert.equal(getRadioAcceptedCatalogSnapshot().instance, body.catalogInstance);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
radioLayer.destroy();
|
||||
}
|
||||
});
|
||||
@@ -988,6 +988,16 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
|
||||
]);
|
||||
// Cyber deliberately adds one layout; first-run missions still change no tools.
|
||||
hudLayout.enum = hudLayout.enum.filter((layout) => layout !== 'cyber');
|
||||
const cameraMotion = legacyTools.find((tool) => tool.name === 'move_camera')
|
||||
.parameters.properties.motion;
|
||||
assert.deepEqual(cameraMotion.enum, [
|
||||
'orbit',
|
||||
'pan',
|
||||
'tilt',
|
||||
'rotate',
|
||||
'rotate_globe',
|
||||
'stop',
|
||||
]);
|
||||
const block = JSON.stringify(legacyTools);
|
||||
// Re-derived for the voice layer manifest (generated layer enums, alias and
|
||||
// field hints), point-and-ask's pointer/referent arguments and the prompt
|
||||
@@ -997,10 +1007,14 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
|
||||
// list descriptions; the missions still ride existing tools.
|
||||
// Street Level and GNSS extend the manifest; broadcast caveats describe tracked and analyst aircraft.
|
||||
// Air quality (ECCC AQHI) extends the manifest.
|
||||
assert.equal(block.length, 30655, 'serialized tool schema length drifted');
|
||||
// Globe rotation adds one camera motion and its descriptions; Globe View
|
||||
// framing/default guidance updates only move_camera and mode descriptions.
|
||||
// Radio markup and the complete production-request comparison pin locality.
|
||||
// Restore the base camera/Globe View wording with the globe-rotation append.
|
||||
assert.equal(block.length, 30878, 'serialized tool schema length drifted');
|
||||
assert.equal(
|
||||
crypto.createHash('sha256').update(block).digest('hex'),
|
||||
'3b1d5e77c10a92f6543a4f9d1ff1870101a0306b65e5b8a6113111c81eda371e',
|
||||
'7b95f0f4258896fe5c3316265a19f3a6602809d4e7e681ee7ecd8f2df514ef1f',
|
||||
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
|
||||
);
|
||||
const instructions = fs.readFileSync(
|
||||
|
||||
@@ -47,7 +47,7 @@ export function createTracking({
|
||||
const { isMilitaryLayerActive, isMilitaryIcao } = services.militaryRegistry;
|
||||
const { trackedLabelModelFromText, refreshTrackedReadout } = services.readout;
|
||||
const { applyTrackedCameraFrame } = services.camera;
|
||||
const { resolvePickId, isOwnedByOtherLayer } = services.picking;
|
||||
const { resolvePickId, isOwnedByOtherLayer, pickContact } = services.picking;
|
||||
const flightsLayer = layer;
|
||||
|
||||
function _emitAwarenessEvent(type, detail) {
|
||||
@@ -1174,7 +1174,9 @@ export function createTracking({
|
||||
// first-person reference. A globe click must not fall through to the
|
||||
// normal empty-space deselection path; cockpit has explicit exit controls.
|
||||
if (document.body.classList.contains('cockpit-mode')) return;
|
||||
const picked = viewer.scene.pick(click.position);
|
||||
const picked = pickContact
|
||||
? pickContact(viewer.scene, click.position)
|
||||
: viewer.scene.pick(click.position);
|
||||
|
||||
if (picked) {
|
||||
// Clicking the tracked entity itself — ignore (don't deselect)
|
||||
|
||||
@@ -47,7 +47,7 @@ export function createTracking({
|
||||
const { ensureGeoidReady, geoidHeight } = services.geoid;
|
||||
const { clearFocusTarget } = services.focus;
|
||||
const { applyTrackedCameraFrame } = services.camera;
|
||||
const { resolvePickId, isOwnedByOtherLayer } = services.picking;
|
||||
const { resolvePickId, isOwnedByOtherLayer, pickContact } = services.picking;
|
||||
|
||||
function _emitAwarenessEvent(type, detail) {
|
||||
if (
|
||||
@@ -593,7 +593,7 @@ export function createTracking({
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget adsb.lol readsb trace backfill (PRD F2). Trace points are
|
||||
* Fire-and-forget optional source history backfill (PRD F2). Trace points are
|
||||
* stride-thinned to the remaining vertex budget, then spliced strictly older
|
||||
* than the oldest seeded fix AHEAD of the locally accumulated fine segment,
|
||||
* capped at TRAIL_MAX_POINTS (newest kept). Any failure (404/timeout/
|
||||
@@ -1079,7 +1079,9 @@ export function createTracking({
|
||||
// first-person reference. Empty globe clicks are inert until the user
|
||||
// exits with C, Escape, or the dedicated button.
|
||||
if (document.body.classList.contains('cockpit-mode')) return;
|
||||
const picked = viewer.scene.pick(click.position);
|
||||
const picked = pickContact
|
||||
? pickContact(viewer.scene, click.position)
|
||||
: viewer.scene.pick(click.position);
|
||||
|
||||
if (picked) {
|
||||
// Clicking the tracked entity itself -- ignore (don't deselect)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { msg } from '../../i18n/index.js';
|
||||
import {
|
||||
RADIO_DIRECTORY_STALE_MS,
|
||||
RADIO_DIRECTORY_FUTURE_SKEW_MS,
|
||||
RADIO_REJECTED_STATION_COUNT_MAX,
|
||||
} from './policy.js';
|
||||
|
||||
// Directory errors stay English in layer state and stats; the layer row and
|
||||
@@ -64,17 +65,24 @@ export function createIngestion({
|
||||
typeof body.degraded !== 'boolean'
|
||||
)
|
||||
throw new Error('Radio directory freshness metadata was malformed');
|
||||
// Rows the server skipped as structurally malformed. Older envelopes
|
||||
// omit the field; a present value must be a bounded count.
|
||||
const serverRejectedStationCount =
|
||||
body.rejectedStationCount === undefined
|
||||
? 0
|
||||
: body.rejectedStationCount;
|
||||
if (
|
||||
!Number.isSafeInteger(serverRejectedStationCount) ||
|
||||
serverRejectedStationCount < 0 ||
|
||||
serverRejectedStationCount > RADIO_REJECTED_STATION_COUNT_MAX
|
||||
)
|
||||
throw new Error('Radio directory diagnostic metadata was malformed');
|
||||
const rows = body.stations;
|
||||
const acceptedRows = rows.filter(
|
||||
parts.catalogModel.isValidRadioDirectoryStation,
|
||||
);
|
||||
if (!acceptedRows.length)
|
||||
throw new Error('Radio directory returned no usable stations');
|
||||
if (acceptedRows.length !== rows.length) {
|
||||
throw new Error(
|
||||
'Radio directory response contained malformed stations',
|
||||
);
|
||||
}
|
||||
const acceptedGeneration = body.acceptedGeneration;
|
||||
if (
|
||||
acceptedGeneration !== null &&
|
||||
@@ -147,7 +155,15 @@ export function createIngestion({
|
||||
}
|
||||
if (!repeatingAcceptedGeneration) layerState._updatedAt = updatedAt;
|
||||
}
|
||||
layerState._degraded = body.degraded;
|
||||
// Combine server-side structural rejections with rows this client
|
||||
// refused. The count is diagnostic: it marks coverage degraded but does
|
||||
// not undo the generation admitted above.
|
||||
layerState._rejectedStationCount = Math.min(
|
||||
RADIO_REJECTED_STATION_COUNT_MAX,
|
||||
serverRejectedStationCount + rows.length - acceptedRows.length,
|
||||
);
|
||||
layerState._degraded =
|
||||
body.degraded || layerState._rejectedStationCount > 0;
|
||||
layerState._stale = body.stale;
|
||||
layerState._error = preservingWarmCatalog
|
||||
? ERROR_REFRESH_DEGRADED
|
||||
|
||||
@@ -108,6 +108,7 @@ export function createLifecycle({
|
||||
layerState._loading = false;
|
||||
layerState._stale = false;
|
||||
layerState._degraded = false;
|
||||
layerState._rejectedStationCount = 0;
|
||||
layerState._error = null;
|
||||
layerState._updatedAt = null;
|
||||
layerState._managerLifecyclePresentation = null;
|
||||
|
||||
@@ -24,6 +24,10 @@ export const RADIO_DIRECTORY_STALE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export const RADIO_DIRECTORY_FUTURE_SKEW_MS = 5 * 60 * 1000;
|
||||
|
||||
// Bound for the server's `rejectedStationCount` envelope field and the
|
||||
// combined client counter; matches server/providers/radio/constants.js.
|
||||
export const RADIO_REJECTED_STATION_COUNT_MAX = 10_000;
|
||||
|
||||
export const RADIO_UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ export function createPresentation({
|
||||
loading: layerState._loading,
|
||||
stale: layerState._stale,
|
||||
degraded: layerState._degraded,
|
||||
rejectedStationCount: layerState._rejectedStationCount,
|
||||
error: layerState._error,
|
||||
updatedAt: layerState._updatedAt,
|
||||
filter: layerState._filter,
|
||||
|
||||
@@ -25,6 +25,7 @@ export function createState({ services }) {
|
||||
layerState._stale = false;
|
||||
|
||||
layerState._degraded = false;
|
||||
layerState._rejectedStationCount = 0;
|
||||
|
||||
layerState._error = null;
|
||||
|
||||
|
||||
@@ -36,16 +36,29 @@ async function readJsonOrThrow(response, label) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
/** `endpoint` with exactly one trailing slash, or '' when unset. */
|
||||
function endpointBase(endpoint) {
|
||||
const text = String(endpoint || '').trim();
|
||||
return text ? text.replace(/\/*$/, '/') : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapillary network access: tiles via the local proxy, which adds the token
|
||||
* and caches; Graph API lookups direct with the client token, as MapillaryJS.
|
||||
* @param {{token?: string, fetchImpl?: Function, endpoints?: object}} options
|
||||
* With `graphEndpoint` (a Graph API base that adds the token itself, such as
|
||||
* a same-origin proxy), the source's Graph lookups go there through
|
||||
* `fetchImpl` instead, and the photo viewer sends its own lookups there too;
|
||||
* no token is sent or needed.
|
||||
* @param {{token?: string, fetchImpl?: Function, endpoints?: object,
|
||||
* graphEndpoint?: string}} options
|
||||
*/
|
||||
export function createMapillarySource({
|
||||
token = '',
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
endpoints = {},
|
||||
graphEndpoint = '',
|
||||
} = {}) {
|
||||
const graphBase = endpointBase(graphEndpoint);
|
||||
const urls = {
|
||||
status: '/api/mapillary/status',
|
||||
tiles: '/api/mapillary/tiles',
|
||||
@@ -54,6 +67,13 @@ export function createMapillarySource({
|
||||
};
|
||||
|
||||
async function graph(path, params = {}, { signal } = {}) {
|
||||
if (graphBase) {
|
||||
const search = new URLSearchParams(params);
|
||||
const response = await fetchImpl(`${graphBase}${path}?${search}`, {
|
||||
signal,
|
||||
});
|
||||
return readJsonOrThrow(response, 'Mapillary graph');
|
||||
}
|
||||
if (!token)
|
||||
throw new MapillarySourceError('Mapillary token not configured', {
|
||||
status: 503,
|
||||
@@ -70,6 +90,10 @@ export function createMapillarySource({
|
||||
get token() {
|
||||
return token;
|
||||
},
|
||||
/** The Graph API base that adds the token itself, or '' (direct). */
|
||||
get graphEndpoint() {
|
||||
return graphBase;
|
||||
},
|
||||
hasToken: () => Boolean(token),
|
||||
|
||||
async getStatus({ signal } = {}) {
|
||||
|
||||
@@ -125,3 +125,35 @@ test('a rejected key and a rate limit are told apart from other tile errors', as
|
||||
'the Retry-After header wins over the body',
|
||||
);
|
||||
});
|
||||
|
||||
test('with a Graph endpoint, sequence lookups go there without a token', async () => {
|
||||
const { calls, fetchImpl } = fakeFetch(() =>
|
||||
jsonResponse({ data: [{ id: '1', geometry: { coordinates: [1, 2] } }] }),
|
||||
);
|
||||
const source = createMapillarySource({
|
||||
graphEndpoint: '/api/mapillary/graph',
|
||||
fetchImpl,
|
||||
});
|
||||
assert.equal(source.graphEndpoint, '/api/mapillary/graph/');
|
||||
assert.equal(source.hasToken(), false);
|
||||
const images = await source.getSequenceImages('seq-1');
|
||||
assert.equal(images.length, 1);
|
||||
const url = new URL(calls[0].url, 'http://localhost');
|
||||
assert.equal(url.pathname, '/api/mapillary/graph/images');
|
||||
assert.equal(url.searchParams.get('sequence_ids'), 'seq-1');
|
||||
assert.equal(url.searchParams.has('access_token'), false);
|
||||
});
|
||||
|
||||
test('Graph endpoint errors keep their status', async () => {
|
||||
const { fetchImpl } = fakeFetch(() =>
|
||||
jsonResponse({ error: 'no_key', keyRequired: true }, 503),
|
||||
);
|
||||
const source = createMapillarySource({
|
||||
graphEndpoint: '/api/mapillary/graph/',
|
||||
fetchImpl,
|
||||
});
|
||||
await assert.rejects(
|
||||
() => source.getSequenceImages('1'),
|
||||
(error) => error.status === 503 && error.keyRequired === true,
|
||||
);
|
||||
});
|
||||
@@ -13,6 +13,20 @@ import { mapillaryImageUrl } from './policy.js';
|
||||
* @property {string} externalUrl Deep link to the image on mapillary.com.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The source's Graph endpoint as an absolute URL ending in `/`, as MapillaryJS
|
||||
* resolves its lookup paths against it; '' when the source has none.
|
||||
*/
|
||||
export function graphEndpointUrl(source) {
|
||||
if (!source?.graphEndpoint) return '';
|
||||
const url = new URL(
|
||||
source.graphEndpoint,
|
||||
globalThis.location?.href || 'http://localhost/',
|
||||
);
|
||||
url.pathname = url.pathname.replace(/\/*$/, '/');
|
||||
return url.href;
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel's photo viewer: loads MapillaryJS on the first `mount` and emits
|
||||
* a pose on every image, view or position change.
|
||||
@@ -115,12 +129,18 @@ export function createMapillaryViewer({ source, render } = {}) {
|
||||
async function buildViewer(host) {
|
||||
const built = generation;
|
||||
destroyViewer();
|
||||
const { Viewer } = await ensureLibrary();
|
||||
const { Viewer, GraphDataProvider } = await ensureLibrary();
|
||||
// Unmounted (layer off) while the library loaded: a viewer built now
|
||||
// would hold a WebGL context nobody releases.
|
||||
if (built !== generation) throw new Error('Mapillary viewer was unmounted');
|
||||
const endpoint = graphEndpointUrl(source);
|
||||
viewer = new Viewer({
|
||||
accessToken: source.token,
|
||||
// A source with its own Graph endpoint (which adds the token itself)
|
||||
// answers the viewer's lookups, sent without a token; otherwise
|
||||
// MapillaryJS calls the Graph API with the client token.
|
||||
...(endpoint
|
||||
? { dataProvider: new GraphDataProvider({ endpoint }) }
|
||||
: { accessToken: source.token }),
|
||||
container: host,
|
||||
component: {
|
||||
cover: false,
|
||||
|
||||
@@ -22,13 +22,16 @@ registerHooks({
|
||||
const source = url.includes('/library?')
|
||||
? 'const lib = await globalThis.__gevMapillaryLibrary();\n' +
|
||||
'export const Viewer = lib.Viewer;\n' +
|
||||
'export const RenderMode = lib.RenderMode;\n'
|
||||
'export const RenderMode = lib.RenderMode;\n' +
|
||||
'export const GraphDataProvider = lib.GraphDataProvider;\n'
|
||||
: 'export default {};\n';
|
||||
return { format: 'module', shortCircuit: true, source };
|
||||
},
|
||||
});
|
||||
const { createMapillaryViewer } =
|
||||
const { createMapillaryViewer, graphEndpointUrl } =
|
||||
await import('./providers/mapillary/viewer.js');
|
||||
const { createMapillarySource } =
|
||||
await import('./providers/mapillary/source.js');
|
||||
|
||||
/**
|
||||
* A viewer host over `adapter`. `marker` records where the position marker
|
||||
@@ -345,7 +348,7 @@ function fakeLibrary() {
|
||||
}
|
||||
globalThis.__gevMapillaryLibrary = async () => {
|
||||
await gate.promise;
|
||||
return { Viewer, RenderMode: {} };
|
||||
return { Viewer, RenderMode: {}, GraphDataProvider: FakeGraphDataProvider };
|
||||
};
|
||||
return { gate, viewers };
|
||||
}
|
||||
@@ -565,3 +568,52 @@ test('a pose’s licence reaches the caption state, and a closed viewer forgets
|
||||
host.close();
|
||||
assert.equal(state.street.license, null);
|
||||
});
|
||||
|
||||
/** MapillaryJS 4's Graph provider, recording what it was built with. */
|
||||
class FakeGraphDataProvider {
|
||||
constructor(options = {}) {
|
||||
this.options = options;
|
||||
}
|
||||
}
|
||||
|
||||
test('without a Graph endpoint the viewer gets the client token, as before', async () => {
|
||||
const library = fakeLibrary();
|
||||
library.gate.resolve();
|
||||
const adapter = createMapillaryViewer({ source: { token: 't' } });
|
||||
await adapter.mount({});
|
||||
const [viewer] = library.viewers.instances;
|
||||
assert.equal(viewer.options.accessToken, 't');
|
||||
assert.equal(viewer.options.dataProvider, undefined);
|
||||
});
|
||||
|
||||
test("with a Graph endpoint the viewer uses MapillaryJS's Graph provider there, without a token", async () => {
|
||||
const library = fakeLibrary();
|
||||
library.gate.resolve();
|
||||
const source = createMapillarySource({
|
||||
graphEndpoint: 'https://app.example/api/mapillary/graph',
|
||||
fetchImpl: async () => {
|
||||
throw new Error('the viewer does not use the source transport');
|
||||
},
|
||||
});
|
||||
const adapter = createMapillaryViewer({ source });
|
||||
await adapter.mount({});
|
||||
const [viewer] = library.viewers.instances;
|
||||
assert.equal(viewer.options.accessToken, undefined);
|
||||
const provider = viewer.options.dataProvider;
|
||||
assert.ok(provider instanceof FakeGraphDataProvider);
|
||||
assert.deepEqual(provider.options, {
|
||||
endpoint: 'https://app.example/api/mapillary/graph/',
|
||||
});
|
||||
});
|
||||
|
||||
test('a relative Graph endpoint resolves against the page, with one trailing slash', () => {
|
||||
assert.equal(
|
||||
graphEndpointUrl({ graphEndpoint: '/api/mapillary/graph' }),
|
||||
'http://localhost/api/mapillary/graph/',
|
||||
);
|
||||
assert.equal(
|
||||
graphEndpointUrl({ graphEndpoint: '/api/mapillary/graph//' }),
|
||||
'http://localhost/api/mapillary/graph/',
|
||||
);
|
||||
assert.equal(graphEndpointUrl({ token: 't' }), '');
|
||||
});
|
||||
@@ -20,6 +20,23 @@ export function createAnimation({
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
function syncRenderDemand() {
|
||||
const needed = Boolean(
|
||||
layerState._enabled &&
|
||||
(layerState._dots.length || layerState._heatJamPrim),
|
||||
);
|
||||
// Height easing only runs in a held frame. Settle it before releasing, so
|
||||
// a population emptied mid-correction cannot report loading forever.
|
||||
if (!needed && layerState._heightPending) parts.retention.finishHeights();
|
||||
if (needed === Boolean(layerState._renderHeld)) return;
|
||||
layerState._renderHeld = needed;
|
||||
if (needed) {
|
||||
layerState._lastAnimTime = 0;
|
||||
services.render.holdContinuousRender('traffic');
|
||||
layerState._viewer?.scene?.requestRender?.();
|
||||
} else services.render.releaseContinuousRender('traffic');
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn animated dot primitives along a single road.
|
||||
*
|
||||
@@ -188,6 +205,7 @@ export function createAnimation({
|
||||
: null,
|
||||
});
|
||||
}
|
||||
syncRenderDemand();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -206,6 +224,8 @@ export function createAnimation({
|
||||
*/
|
||||
|
||||
function animate() {
|
||||
syncRenderDemand();
|
||||
if (!layerState._renderHeld) return;
|
||||
const now = Date.now();
|
||||
// Delta time in seconds, capped to avoid jumps when returning from background tab
|
||||
const dt = layerState._lastAnimTime
|
||||
@@ -339,6 +359,13 @@ export function createAnimation({
|
||||
layerState._count = 0;
|
||||
layerState._bucketCounts = { free: 0, slow: 0, jam: 0, sim: 0 };
|
||||
layerState._closedRoads = 0;
|
||||
syncRenderDemand();
|
||||
}
|
||||
return { spawnDotsForRoad, animate, maybeStopLight, clearDots };
|
||||
return {
|
||||
spawnDotsForRoad,
|
||||
animate,
|
||||
maybeStopLight,
|
||||
clearDots,
|
||||
syncRenderDemand,
|
||||
};
|
||||
}
|
||||
@@ -12,7 +12,6 @@ export function createLifecycle({
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
const { holdContinuousRender, releaseContinuousRender } = services.render;
|
||||
const { resetFlowTileCache } = source;
|
||||
|
||||
const methods = {
|
||||
@@ -81,7 +80,7 @@ export function createLifecycle({
|
||||
layerState._enabled = true;
|
||||
layerState._surfaceFrameRemover = observeTrafficSurface(viewer.scene);
|
||||
if (layerState._roadMode !== 'tomtom') source.prefetch?.();
|
||||
holdContinuousRender('traffic'); // per-frame animator (perf wave 2)
|
||||
parts.animation.syncRenderDemand();
|
||||
layerState._lastAnimTime = 0;
|
||||
layerState._pointCollection.show = true;
|
||||
|
||||
@@ -142,7 +141,7 @@ export function createLifecycle({
|
||||
disable(viewer) {
|
||||
layerState._enabled = false;
|
||||
services.credits?.hideOsmCredit?.(layerState._viewer, 'traffic');
|
||||
releaseContinuousRender('traffic');
|
||||
parts.animation.syncRenderDemand();
|
||||
clearTimeout(layerState._fetchTimeout);
|
||||
clearInterval(layerState._enableKickTimer);
|
||||
layerState._enableKickTimer = null;
|
||||
|
||||
@@ -37,7 +37,14 @@ function setup(t) {
|
||||
visibleRoadsForAltitude: (roads) => roads,
|
||||
},
|
||||
};
|
||||
const ctx = { state, services: {}, parts, source: {} };
|
||||
const ctx = {
|
||||
state,
|
||||
services: {
|
||||
render: { holdContinuousRender() {}, releaseContinuousRender() {} },
|
||||
},
|
||||
parts,
|
||||
source: {},
|
||||
};
|
||||
parts.style = createStyle(ctx);
|
||||
parts.model = createModel(ctx);
|
||||
parts.animation = createAnimation(ctx);
|
||||
@@ -558,6 +565,37 @@ test('provisional opposite-direction heights cannot overwrite a measured sibling
|
||||
assert.notEqual(forward.waypoints, reverse.waypoints);
|
||||
});
|
||||
|
||||
test('a closure that empties the population mid-correction settles heights and clears loading before release', (t) => {
|
||||
const h = setup(t);
|
||||
h.parts.retention.reconcile(h.roads(), 450, true);
|
||||
h.settle();
|
||||
assert.equal(h.state._renderHeld, true);
|
||||
const road = h.state._roads[0];
|
||||
// Republishing the sole road starts a height correction.
|
||||
const corrected = h.roads(0, 120);
|
||||
h.parts.retention.reconcile(corrected, 450, true);
|
||||
assert.equal(h.state._heightPending, true);
|
||||
assert.equal(road.heightMoving, true);
|
||||
// The closure arrives before the next population pass, which then removes
|
||||
// the final points after their fade, long before the correction finishes.
|
||||
corrected[0].flow = { level: 0, closure: true };
|
||||
for (let i = 0; i < 20 && h.state._dots.length; i++) h.frame();
|
||||
assert.equal(h.state._dots.length, 0);
|
||||
assert.equal(h.state._heatJamPrim, null);
|
||||
assert.equal(h.state._renderHeld, false);
|
||||
assert.equal(h.state._heightPending, false);
|
||||
assert.equal(h.state._populationPending, false);
|
||||
assert.equal(road.heightMoving, false);
|
||||
assert.ok(
|
||||
Math.abs(C.Cartographic.fromCartesian(road.waypoints[0]).height - 120) <
|
||||
0.01,
|
||||
);
|
||||
// Frames driven by other layers stay idle and cannot revive the work.
|
||||
h.frame();
|
||||
assert.equal(h.state._heightPending, false);
|
||||
assert.equal(h.state._renderHeld, false);
|
||||
});
|
||||
|
||||
test('large height corrections obey a screen-pixel bound and reuse projection scratch objects', (t) => {
|
||||
const h = setup(t);
|
||||
h.state._viewer.scene = {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createAnimation } from './animation.js';
|
||||
|
||||
test('traffic render demand follows animated population and jam lines across empty, disable and re-enable', () => {
|
||||
const calls = [];
|
||||
const state = { _enabled: true, _dots: [], _motion: { rebuilds: 0 } };
|
||||
const animation = createAnimation({
|
||||
state,
|
||||
services: {
|
||||
render: {
|
||||
holdContinuousRender: (id) => calls.push(['hold', id]),
|
||||
releaseContinuousRender: (id) => calls.push(['release', id]),
|
||||
},
|
||||
},
|
||||
parts: {
|
||||
retention: { clear() {} },
|
||||
rendering: {
|
||||
removeHeatLines() {
|
||||
state._heatJamPrim = null;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
animation.syncRenderDemand();
|
||||
assert.deepEqual(calls, []);
|
||||
state._dots.push({});
|
||||
animation.syncRenderDemand();
|
||||
animation.syncRenderDemand();
|
||||
assert.deepEqual(calls, [['hold', 'traffic']]);
|
||||
animation.clearDots();
|
||||
assert.deepEqual(calls.at(-1), ['release', 'traffic']);
|
||||
state._heatJamPrim = {};
|
||||
animation.syncRenderDemand();
|
||||
assert.deepEqual(calls.at(-1), ['hold', 'traffic']);
|
||||
state._enabled = false;
|
||||
animation.syncRenderDemand();
|
||||
assert.deepEqual(calls.at(-1), ['release', 'traffic']);
|
||||
state._enabled = true;
|
||||
animation.clearDots();
|
||||
assert.equal(state._renderHeld, false);
|
||||
state._dots.push({});
|
||||
animation.syncRenderDemand();
|
||||
assert.equal(state._renderHeld, true);
|
||||
});
|
||||
@@ -50,6 +50,7 @@ export function createRendering({
|
||||
layerState._heatSlowPrim = null;
|
||||
}
|
||||
layerState._heatLineCount = 0;
|
||||
parts.animation.syncRenderDemand?.();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -152,6 +153,7 @@ export function createRendering({
|
||||
}
|
||||
|
||||
layerState._heatLineCount = kept.length;
|
||||
parts.animation.syncRenderDemand?.();
|
||||
if (candidates.length > kept.length) {
|
||||
console.log(
|
||||
`[Data:Traffic] Heat-lines capped at ${HEAT_LINE_CAP} (${candidates.length} congested roads in view)`,
|
||||
|
||||
@@ -125,6 +125,7 @@ export function createRetention({ state, parts }) {
|
||||
count++;
|
||||
}
|
||||
state._count = count;
|
||||
parts.animation.syncRenderDemand?.();
|
||||
}
|
||||
function reconcile(roads, height, replace) {
|
||||
altitude = height;
|
||||
@@ -241,6 +242,29 @@ export function createRetention({ state, parts }) {
|
||||
road.heightMoving = moving;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Settle every retained road on its corrected heights at once. Easing only
|
||||
* runs inside the held animation frame, so once nothing is left to animate
|
||||
* the remaining correction would otherwise stay pending indefinitely.
|
||||
* Published roads whose record was just dropped are settled too, so a later
|
||||
* held frame cannot revive their correction.
|
||||
*/
|
||||
function finishHeights() {
|
||||
const roads = new Set(state._roads);
|
||||
for (const record of records.values()) roads.add(record.road);
|
||||
for (const road of roads) {
|
||||
if (!road.heightMoving) continue;
|
||||
for (let i = 0; i < road.waypoints.length; i++)
|
||||
Cesium.Cartesian3.clone(road.targetWaypoints[i], road.waypoints[i]);
|
||||
for (let i = 0; i < road.segmentDist.length; i++)
|
||||
road.segmentDist[i] = Cesium.Cartesian3.distance(
|
||||
road.waypoints[i],
|
||||
road.waypoints[i + 1],
|
||||
);
|
||||
road.heightMoving = false;
|
||||
}
|
||||
state._heightPending = false;
|
||||
}
|
||||
function clear() {
|
||||
if (timer !== null) {
|
||||
if (typeof cancelAnimationFrame === 'function')
|
||||
@@ -252,5 +276,5 @@ export function createRetention({ state, parts }) {
|
||||
state._populationPending = false;
|
||||
state._heightPending = false;
|
||||
}
|
||||
return { reconcile, easeHeights, clear, refreshCounts };
|
||||
return { reconcile, easeHeights, finishHeights, clear, refreshCounts };
|
||||
}
|
||||
@@ -3574,7 +3574,7 @@ test('actual Traffic, Bikeshare and Transit lifecycles retain sensitivity in eve
|
||||
source: {},
|
||||
parts: {
|
||||
flow: { ensureFlowStatus: noop },
|
||||
animation: { animate: noop, clearDots: noop },
|
||||
animation: { animate: noop, clearDots: noop, syncRenderDemand: noop },
|
||||
viewport: { onCameraChanged: noop },
|
||||
ingestion: { cancelActiveFetch: noop },
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@ export function createSelection({
|
||||
options,
|
||||
}) {
|
||||
const { state } = vesselState;
|
||||
const { resolvePickId, isOwnedByOtherLayer } = services.picking;
|
||||
const { resolvePickId, isOwnedByOtherLayer, pickContact } = services.picking;
|
||||
const { requestWorldFocus } = services.worldFocus;
|
||||
const {
|
||||
selectEntityContext,
|
||||
@@ -37,7 +37,9 @@ export function createSelection({
|
||||
// A tool owns the pointer (src/data/inputOwnership.js): yield the click.
|
||||
if (!isPointerFree()) return;
|
||||
if (!state.feed.enabled) return;
|
||||
const picked = viewer.scene.pick(click.position);
|
||||
const picked = pickContact
|
||||
? pickContact(viewer.scene, click.position)
|
||||
: viewer.scene.pick(click.position);
|
||||
const pickedId = resolvePickId(picked);
|
||||
let record = pickedId ? state.records.byMmsi.get(pickedId) : null;
|
||||
const rawId = picked?.id ?? picked?.primitive?.id;
|
||||
|
||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { providerCooldownMs } from '../server/providers/space/cooldown.js';
|
||||
import { parseTerrainPoints } from './data/terrainHeightsProxy.js';
|
||||
import { readResponseTextCapped, coalesceProxyRequest } from './sources/httpBody.js';
|
||||
import { makeRateLimiter, clientKey } from '../server/providers/common/rate-limit.js';
|
||||
@@ -23,7 +24,7 @@ function extract(name) {
|
||||
function fixture(name, overrides = {}, preview = false) {
|
||||
const logs = [];
|
||||
const deps = {
|
||||
readResponseTextCapped, coalesceProxyRequest,
|
||||
readResponseTextCapped, coalesceProxyRequest, providerCooldownMs,
|
||||
// The rate-limited proxies reach for these; give them the real ones so a
|
||||
// fixture request is throttled exactly as production would be.
|
||||
makeRateLimiter, clientKey,
|
||||
@@ -117,7 +118,7 @@ test('Launch Library retains single-flight, fresh cache, stale fallback, and met
|
||||
|
||||
test('CelesTrak unexpected failures hide details', async () => {
|
||||
const app = fixture('celestrakProxy', { Date: { now() { throw new Error(detail); } } });
|
||||
const res = await app.request('/active');
|
||||
const res = await app.request('/stations');
|
||||
assert.equal(res.status, 500);
|
||||
assert.equal(res.body, 'celestrak proxy error');
|
||||
assert.equal(res.headers['x-tle-cache'], 'ERROR');
|
||||
@@ -126,7 +127,7 @@ test('CelesTrak unexpected failures hide details', async () => {
|
||||
test('CelesTrak retains invalid-group and unavailable responses', async () => {
|
||||
const app = fixture('celestrakProxy');
|
||||
assert.equal((await app.request('/../')).status, 400);
|
||||
const res = await app.request('/active');
|
||||
const res = await app.request('/stations');
|
||||
assert.equal(res.status, 502);
|
||||
assert.equal(res.headers['x-tle-cache'], 'NONE');
|
||||
});
|
||||
@@ -135,13 +136,13 @@ test('CelesTrak retains fresh and stale TLE caches', async () => {
|
||||
let now = Date.now();
|
||||
let calls = 0;
|
||||
const app = fixture('celestrakProxy', {
|
||||
Date: { now: () => now },
|
||||
Date: class extends Date { static now() { return now; } },
|
||||
fetch: async () => { if (++calls > 1) throw new Error(detail); return new Response('1 valid-fixture-TLE'); },
|
||||
});
|
||||
assert.equal((await app.request('/active')).headers['x-tle-cache'], 'MISS');
|
||||
assert.equal((await app.request('/active')).headers['x-tle-cache'], 'HIT');
|
||||
assert.equal((await app.request('/stations')).headers['x-tle-cache'], 'MISS');
|
||||
assert.equal((await app.request('/stations')).headers['x-tle-cache'], 'HIT');
|
||||
now += 7 * 3600_000;
|
||||
const stale = await app.request('/active');
|
||||
const stale = await app.request('/stations');
|
||||
assert.equal(stale.status, 200);
|
||||
assert.equal(stale.body, '1 valid-fixture-TLE');
|
||||
assert.equal(stale.headers['x-tle-cache'], 'STALE-ERROR');
|
||||
|
||||
+56
-1
@@ -1,6 +1,10 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { MACOS_CHROME, chromeExecutablePath } from '../scripts/qa-chrome.mjs';
|
||||
import {
|
||||
MACOS_CHROME,
|
||||
chromeExecutablePath,
|
||||
captureQaScreenshot,
|
||||
} from '../scripts/qa-chrome.mjs';
|
||||
|
||||
test('gates keep macOS Chrome where it is installed', () => {
|
||||
assert.equal(
|
||||
@@ -25,3 +29,54 @@ test('an explicit executable path wins', () => {
|
||||
'C:\\Chrome\\chrome.exe',
|
||||
);
|
||||
});
|
||||
|
||||
test('screenshots restore minimized test windows before capture and detach their session', async () => {
|
||||
const calls = [];
|
||||
const session = {
|
||||
send: async (method, params) => {
|
||||
calls.push([method, params]);
|
||||
return { windowId: 7, bounds: { windowState: 'minimized' } };
|
||||
},
|
||||
detach: async () => calls.push(['detach']),
|
||||
};
|
||||
const page = {
|
||||
createCDPSession: async () => session,
|
||||
bringToFront: async () => calls.push(['front']),
|
||||
screenshot: async (options) => {
|
||||
calls.push(['capture', options]);
|
||||
return 'pixels';
|
||||
},
|
||||
};
|
||||
assert.equal(
|
||||
await captureQaScreenshot(page, { path: 'proof.png' }),
|
||||
'pixels',
|
||||
);
|
||||
assert.deepEqual(
|
||||
calls.map((c) => c[0]),
|
||||
[
|
||||
'Browser.getWindowForTarget',
|
||||
'Browser.setWindowBounds',
|
||||
'front',
|
||||
'detach',
|
||||
'capture',
|
||||
],
|
||||
);
|
||||
assert.deepEqual(calls[1][1], {
|
||||
windowId: 7,
|
||||
bounds: { windowState: 'normal' },
|
||||
});
|
||||
});
|
||||
|
||||
test('headless capture does not manipulate a native window', async () => {
|
||||
const options = { path: 'proof.png' };
|
||||
const page = {
|
||||
screenshot: async (supplied) => {
|
||||
assert.equal(supplied, options);
|
||||
return 'pixels';
|
||||
},
|
||||
};
|
||||
assert.equal(
|
||||
await captureQaScreenshot(page, options, { headful: false }),
|
||||
'pixels',
|
||||
);
|
||||
});
|
||||
@@ -209,6 +209,35 @@ test('no unchanged Realtime tool definition drifts silently', () => {
|
||||
const hudLayout = unchanged.find((tool) => tool.name === 'set_hud').parameters.properties.layout;
|
||||
assert.deepEqual(hudLayout.enum, ['tactical', 'operator', 'minimal', 'cyber']);
|
||||
hudLayout.enum = hudLayout.enum.filter((layout) => layout !== 'cyber');
|
||||
// Globe rotation adds one motion and clarifies the two affected descriptions.
|
||||
// Check those edits explicitly, then keep the legacy pin for every other field.
|
||||
const camera = unchanged.find((tool) => tool.name === 'move_camera');
|
||||
assert.deepEqual(camera.parameters.properties.motion.enum, [
|
||||
'orbit',
|
||||
'pan',
|
||||
'tilt',
|
||||
'rotate',
|
||||
'rotate_globe',
|
||||
'stop',
|
||||
]);
|
||||
assert.equal(
|
||||
camera.description,
|
||||
'Direct the camera like a drone operator: orbit the current view target, pan, tilt, or rotate — one bounded nudge (mode=once) or continuous motion until stopped (mode=continuous). Continuous motion also stops on any manual camera input or when a navigation tool runs. Say the RESULTING state when confirming ("Orbiting slowly"). Globe rotation (rotate_globe) spins Earth right continuously by default; explicit direction, mode and speed take precedence. It checks fit and may refuse.',
|
||||
);
|
||||
assert.equal(
|
||||
camera.parameters.properties.direction.description,
|
||||
'Required for pan/tilt/rotate; orbit/rotate_globe default right.',
|
||||
);
|
||||
assert.equal(camera.parameters.properties.mode.description, 'once = bounded eased nudge; continuous = until stop/manual input. rotate_globe defaults continuous; other motions default once.');
|
||||
camera.parameters.properties.mode.description = 'once = bounded eased nudge (default); continuous = until stop/manual input.';
|
||||
camera.parameters.properties.motion.enum =
|
||||
camera.parameters.properties.motion.enum.filter(
|
||||
(motion) => motion !== 'rotate_globe',
|
||||
);
|
||||
camera.description =
|
||||
'Direct the camera like a drone operator: orbit the current view target, pan, tilt, or rotate — one bounded nudge (mode=once) or continuous motion until stopped (mode=continuous). Continuous motion also stops on any manual camera input or when a navigation tool runs. Say the RESULTING state when confirming ("Orbiting slowly").';
|
||||
camera.parameters.properties.direction.description =
|
||||
'Required except for orbit (defaults right/clockwise) and stop.';
|
||||
assert.equal(unchanged.length, 14);
|
||||
const digest = createHash('sha256')
|
||||
.update(JSON.stringify(unchanged))
|
||||
|
||||
@@ -214,11 +214,10 @@ test('construction is inert; adapters preserve routes, viewport query, cache epo
|
||||
assert.equal(mil.observedAtMs, now - 60000);
|
||||
assert.equal(mil.records[0].positionTimeMs, now - 75000);
|
||||
assert.equal(mil.stale, true);
|
||||
const track = await military.getTrack('abc123');
|
||||
assert.equal(track.complete, false);
|
||||
assert.equal(track.records[0].observedAtMs, now - 50000);
|
||||
assert.equal(track.records[0].baroAltitudeM, 304.8);
|
||||
assert.equal(track.records[0].ellipsoidAltitudeM, null);
|
||||
assert.equal(military.getTrack, undefined);
|
||||
assert.ok(
|
||||
requests.every(({ url }) => !url.startsWith('/api/military/track')),
|
||||
);
|
||||
});
|
||||
|
||||
test('cancellation after slow body parsing rejects even with a transport that ignores abort', async () => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
epoch,
|
||||
finite,
|
||||
httpError,
|
||||
LiveSourceError,
|
||||
@@ -159,26 +158,8 @@ export function createMilitarySource({
|
||||
status: response.status,
|
||||
};
|
||||
},
|
||||
async getTrack(reference, { signal } = {}) {
|
||||
const { response, payload } = await readResponse(
|
||||
fetchImpl,
|
||||
'/api/military/track?hex=' + encodeURIComponent(reference),
|
||||
{ signal },
|
||||
'Military aircraft',
|
||||
);
|
||||
if (!response.ok) throw httpError(response, 'Military aircraft');
|
||||
const baseTimeMs = epoch(payload?.timestamp, 1000);
|
||||
return {
|
||||
records:
|
||||
baseTimeMs == null
|
||||
? []
|
||||
: normalizeAircraftTrack(payload?.trace, {
|
||||
baseTimeMs,
|
||||
readsb: true,
|
||||
}),
|
||||
complete: false,
|
||||
};
|
||||
},
|
||||
// ADSB.lol does not expose a supported live-history endpoint. Omitting
|
||||
// optional getTrack keeps the tracking layer on its accumulated trail.
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,16 @@ export function cleanRadioText(value, maxLength) {
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Normalize a station/filter tag and cap its final, case-folded representation. */
|
||||
export function normalizeRadioTag(value) {
|
||||
return cleanRadioText(
|
||||
String(value ?? '')
|
||||
.toLowerCase()
|
||||
.replace(/[_-]+/g, ' '),
|
||||
80,
|
||||
);
|
||||
}
|
||||
|
||||
export function isNonGlobalIpv4(hostname) {
|
||||
const pieces = hostname.split('.');
|
||||
if (pieces.length !== 4 || pieces.some((piece) => !/^\d{1,3}$/.test(piece)))
|
||||
@@ -99,13 +109,7 @@ export function normalizeRadioBrowserStation(
|
||||
if (!name) return null;
|
||||
const tags = String(raw?.tags ?? '')
|
||||
.split(',')
|
||||
.map((tag) =>
|
||||
cleanRadioText(tag, 80)
|
||||
.toLocaleLowerCase()
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim(),
|
||||
)
|
||||
.map(normalizeRadioTag)
|
||||
.filter(Boolean)
|
||||
.filter((tag, index, all) => all.indexOf(tag) === index)
|
||||
.slice(0, 24);
|
||||
|
||||
@@ -584,6 +584,24 @@ test('track backfill proxy returns 502 on an oversized upstream body and caches
|
||||
assert.equal(callCount, 1);
|
||||
});
|
||||
|
||||
test('retired military history refuses without upstream I/O while validating identifiers', async (t) => {
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
assert.fail('retired history must not contact the upstream');
|
||||
});
|
||||
const route = install(providers.trackBackfillProxies());
|
||||
for (const hex of ['abc123', 'abc456']) {
|
||||
const response = await route('/api/military/track', '?hex=' + hex);
|
||||
assert.equal(response.statusCode, 503);
|
||||
assert.equal(JSON.parse(response.body).code, 'history_unavailable');
|
||||
assert.equal(response.headers['retry-after'], '1800');
|
||||
assert.equal(response.headers['cache-control'], 'no-store');
|
||||
}
|
||||
assert.equal(
|
||||
(await route('/api/military/track', '?hex=invalid')).statusCode,
|
||||
400,
|
||||
);
|
||||
});
|
||||
|
||||
test('global aircraft snapshots coalesce cold and expired callers and clear rejected flights', async (t) => {
|
||||
environment(t, { OPENSKY_AUTH_MODE: 'anon' });
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
launchLibraryRecentUrl,
|
||||
} from 'gods-eye-view/sources/space';
|
||||
import * as compatibility from '../../server/providers/local.js';
|
||||
import { createLaunchSource } from 'gods-eye-view/layers/launches/source';
|
||||
|
||||
function install(plugin, preview = false) {
|
||||
const routes = new Map();
|
||||
@@ -22,17 +23,23 @@ function install(plugin, preview = false) {
|
||||
},
|
||||
},
|
||||
});
|
||||
return async (route, url = '/', method = 'GET') => {
|
||||
return async (route, url = '/', method = 'GET', headers = {}) => {
|
||||
const res = {
|
||||
headersSent: false,
|
||||
headers: {},
|
||||
writeHead(status, headers) {
|
||||
Object.assign(this, { status, headers, headersSent: true });
|
||||
},
|
||||
// The same-site gates answer 403 through statusCode + setHeader.
|
||||
setHeader(name, value) {
|
||||
this.headers[name] = value;
|
||||
},
|
||||
end(body) {
|
||||
this.body = body;
|
||||
this.status ??= this.statusCode;
|
||||
},
|
||||
};
|
||||
await routes.get(route)({ url, method }, res);
|
||||
await routes.get(route)({ url, method, headers, socket: {} }, res);
|
||||
return res;
|
||||
};
|
||||
}
|
||||
@@ -161,7 +168,11 @@ test('CelesTrak answers 502 to an oversized TLE body without caching it', async
|
||||
assert.ok(bodies[0].pulled <= 17, 'reading stops at the 16 MiB cap');
|
||||
assert.equal(fsp.writeFile.mock.callCount(), 0);
|
||||
assert.equal((await request('/api/celestrak', '/stations')).status, 502);
|
||||
assert.equal(bodies.length, 2, 'nothing was cached in memory');
|
||||
assert.equal(
|
||||
bodies.length,
|
||||
1,
|
||||
'failed bodies are not cached but a cooldown bounds retry work',
|
||||
);
|
||||
});
|
||||
|
||||
for (const preview of [false, true])
|
||||
@@ -190,3 +201,194 @@ for (const preview of [false, true])
|
||||
assert.equal(hit.body, first.body);
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test('space providers honor Retry-After, gate groups, preserve stale dates and recover', async (t) => {
|
||||
isolateDisk(t);
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
let now = Date.now();
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
for (const kind of ['tle', 'launches']) {
|
||||
for (const retryAfter of ['90', new Date(now + 90000).toUTCString()]) {
|
||||
let calls = 0;
|
||||
let fail = false;
|
||||
let cancelled = false;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
calls++;
|
||||
if (fail)
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: kind === 'tle' ? 403 : 429,
|
||||
headers: { 'Retry-After': retryAfter },
|
||||
},
|
||||
);
|
||||
return new Response(
|
||||
kind === 'tle'
|
||||
? 'ISS\n1 25544U fixture\n2 25544 fixture\n'
|
||||
: '{"results":[]}',
|
||||
);
|
||||
});
|
||||
const request = install(
|
||||
kind === 'tle' ? celestrakProxy() : rocketLaunchesProxy(),
|
||||
);
|
||||
const route = kind === 'tle' ? '/api/celestrak' : '/api/launches';
|
||||
const url = kind === 'tle' ? '/stations' : '/';
|
||||
if (kind === 'tle') {
|
||||
assert.equal(
|
||||
(await request(route, '/not-a-supported-group')).status,
|
||||
400,
|
||||
);
|
||||
assert.equal(calls, 0);
|
||||
}
|
||||
const first = await request(route, url);
|
||||
now += 7 * 3600000;
|
||||
// Recompute the HTTP-date relative to the clock at the failed refresh.
|
||||
const originalFetch = globalThis.fetch;
|
||||
t.mock.method(globalThis, 'fetch', async (...args) => {
|
||||
const response = await originalFetch(...args);
|
||||
if (fail && !/^\d+$/.test(retryAfter))
|
||||
response.headers.set(
|
||||
'Retry-After',
|
||||
new Date(now + 90000).toUTCString(),
|
||||
);
|
||||
return response;
|
||||
});
|
||||
fail = true;
|
||||
const stale = await request(route, url);
|
||||
assert.equal(stale.status, 200);
|
||||
assert.equal(stale.body, first.body);
|
||||
assert.equal(stale.headers['Retry-After'], '90');
|
||||
assert.ok(
|
||||
stale.headers[kind === 'tle' ? 'X-TLE-Cached-At' : 'X-GEV-Cached-At'],
|
||||
);
|
||||
assert.equal(cancelled, true);
|
||||
await request(route, url);
|
||||
if (kind === 'tle')
|
||||
assert.equal((await request(route, '/visual')).status, 502);
|
||||
assert.equal(calls, 2, 'cooldown applies across TLE groups');
|
||||
now += 91000;
|
||||
fail = false;
|
||||
assert.equal((await request(route, url)).status, 200);
|
||||
assert.equal(calls, 3);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Space Missions reads the active catalog through the real proxy while unknown groups stay rejected', async (t) => {
|
||||
isolateDisk(t);
|
||||
const tle = 'ISS\n1 25544U fixture\n2 25544 fixture\n';
|
||||
const fetched = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
fetched.push(new URL(url).searchParams.get('GROUP'));
|
||||
return new Response(tle);
|
||||
});
|
||||
const request = install(celestrakProxy());
|
||||
// Route the browser source's real request into the middleware.
|
||||
const source = createLaunchSource({
|
||||
fetchImpl: async (url) => {
|
||||
const prefix = '/api/celestrak';
|
||||
assert.ok(url.startsWith(`${prefix}/`));
|
||||
const res = await request(prefix, url.slice(prefix.length));
|
||||
return new Response(res.body, { status: res.status });
|
||||
},
|
||||
});
|
||||
assert.equal(await source.getActiveTle(), tle);
|
||||
assert.deepEqual(fetched, ['active']);
|
||||
for (const group of ['/not-a-group', '/active-extra', '/../active'])
|
||||
assert.equal((await request('/api/celestrak', group)).status, 400);
|
||||
assert.deepEqual(fetched, ['active'], 'rejected groups never reach upstream');
|
||||
});
|
||||
|
||||
test('CelesTrak admits a same-origin request behind the documented reverse proxy and refuses foreign origins', async (t) => {
|
||||
isolateDisk(t);
|
||||
let calls = 0;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
calls++;
|
||||
return new Response('ISS\n1 25544U fixture\n2 25544 fixture\n');
|
||||
});
|
||||
const request = install(celestrakProxy());
|
||||
const proxied = {
|
||||
host: 'gev.example.test',
|
||||
'x-forwarded-for': '203.0.113.7',
|
||||
'x-forwarded-host': 'gev.example.test',
|
||||
'x-forwarded-proto': 'https',
|
||||
via: '1.1 proxy',
|
||||
};
|
||||
const app = await request('/api/celestrak', '/stations', 'GET', {
|
||||
...proxied,
|
||||
origin: 'https://gev.example.test',
|
||||
'sec-fetch-site': 'same-origin',
|
||||
});
|
||||
assert.equal(app.status, 200);
|
||||
assert.equal(app.headers['x-tle-cache'], 'MISS');
|
||||
assert.equal(calls, 1);
|
||||
for (const headers of [
|
||||
{ ...proxied, origin: 'https://unrelated.example' },
|
||||
{ ...proxied, origin: 'null' },
|
||||
{ ...proxied, 'sec-fetch-site': 'cross-site' },
|
||||
]) {
|
||||
const refused = await request('/api/celestrak', '/visual', 'GET', headers);
|
||||
assert.equal(refused.status, 403);
|
||||
}
|
||||
assert.equal(calls, 1, 'refused requests do no provider or cache work');
|
||||
});
|
||||
|
||||
for (const longFirst of [true, false])
|
||||
test(`CelesTrak keeps the longest cooldown when concurrent failures finish ${longFirst ? 'long then short' : 'short then long'}`, async (t) => {
|
||||
isolateDisk(t);
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
let now = Date.now();
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
const pending = new Map();
|
||||
let calls = 0;
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
calls++;
|
||||
const group = new URL(url).searchParams.get('GROUP');
|
||||
await new Promise((resolve) => pending.set(group, resolve));
|
||||
return group === 'stations'
|
||||
? new Response('slow down', {
|
||||
status: 429,
|
||||
headers: { 'Retry-After': '1800' },
|
||||
})
|
||||
: new Response('unavailable', { status: 503 });
|
||||
});
|
||||
const request = install(celestrakProxy());
|
||||
const long = request('/api/celestrak', '/stations');
|
||||
const short = request('/api/celestrak', '/visual');
|
||||
while (pending.size < 2) await new Promise(setImmediate);
|
||||
const order = longFirst
|
||||
? [
|
||||
['stations', long],
|
||||
['visual', short],
|
||||
]
|
||||
: [
|
||||
['visual', short],
|
||||
['stations', long],
|
||||
];
|
||||
for (const [group, response] of order) {
|
||||
pending.get(group)();
|
||||
assert.equal((await response).status, 502);
|
||||
}
|
||||
assert.equal(calls, 2);
|
||||
const cooling = await request('/api/celestrak', '/geo');
|
||||
assert.equal(cooling.status, 502);
|
||||
assert.equal(cooling.headers['Retry-After'], '1800');
|
||||
now += 61_000;
|
||||
assert.equal((await request('/api/celestrak', '/geo')).status, 502);
|
||||
assert.equal(
|
||||
calls,
|
||||
2,
|
||||
'the 60 s fallback did not replace the 30 min deadline',
|
||||
);
|
||||
now += 1800_000;
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async () => new Response('ISS\n1 25544U fixture\n2 25544 fixture\n'),
|
||||
);
|
||||
assert.equal((await request('/api/celestrak', '/geo')).status, 200);
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
trafficFixtureResponse,
|
||||
trafficFixtureTile,
|
||||
} from '../../scripts/traffic-fixtures.mjs';
|
||||
import { decodeOpenFreeMapTile } from '../sources/openFreeMap.js';
|
||||
import { isDrivableFlowSegment } from '../layers/traffic/roadModes.js';
|
||||
import { decodeFlowTile } from '../layers/traffic/flowDecode.js';
|
||||
|
||||
test('traffic fixtures provide aligned current road tiles and partial flow coverage', () => {
|
||||
for (const z of [12, 14]) {
|
||||
const roads = decodeOpenFreeMapTile(
|
||||
trafficFixtureTile(z),
|
||||
z,
|
||||
935,
|
||||
1686,
|
||||
).roads;
|
||||
const flow = decodeFlowTile(trafficFixtureTile(z, true), z, 935, 1686);
|
||||
assert.ok(roads.length > flow.length && flow.length > 0);
|
||||
for (const segment of flow) {
|
||||
assert.equal(segment.trafficLevel, 0.6);
|
||||
assert.equal(isDrivableFlowSegment(segment), true);
|
||||
assert.ok(
|
||||
roads.some(
|
||||
(road) =>
|
||||
JSON.stringify(road.coordinates) === JSON.stringify(segment.coords),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('fixture TileJSON and every road/flow request stay on intercepted sources', () => {
|
||||
const request = (url) => trafficFixtureResponse({ url: () => url });
|
||||
const tileJson = request('https://tiles.openfreemap.org/planet');
|
||||
assert.equal(tileJson.headers['access-control-allow-origin'], '*');
|
||||
const metadata = JSON.parse(tileJson.body);
|
||||
const tileUrl = metadata.tiles[0]
|
||||
.replace('{z}', '14')
|
||||
.replace('{x}', '935')
|
||||
.replace('{y}', '1686');
|
||||
assert.ok(
|
||||
decodeOpenFreeMapTile(request(tileUrl).body, 14, 935, 1686).roads.length,
|
||||
);
|
||||
assert.ok(
|
||||
decodeFlowTile(
|
||||
request('http://localhost/api/tomtom/flow/12/935/1686.pbf').body,
|
||||
12,
|
||||
935,
|
||||
1686,
|
||||
).length,
|
||||
);
|
||||
assert.throws(
|
||||
() => request('https://tiles.openfreemap.org/unexpected'),
|
||||
/Unrecognized/,
|
||||
);
|
||||
assert.equal(request('http://localhost/api/unrelated'), null);
|
||||
});
|
||||
|
||||
test('flat terrain fixture decodes through Cesium and advertises no network children', async () => {
|
||||
const { createServer } = await import('node:http');
|
||||
const { CesiumTerrainProvider } = await import('cesium');
|
||||
const server = createServer((req, res) => {
|
||||
const response = trafficFixtureResponse({
|
||||
url: () => `https://terrain.reearth.land${req.url}`,
|
||||
});
|
||||
res.writeHead(response.status, { 'content-type': response.contentType });
|
||||
res.end(response.body);
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
try {
|
||||
const provider = await CesiumTerrainProvider.fromUrl(
|
||||
`http://127.0.0.1:${server.address().port}`,
|
||||
);
|
||||
for (const x of [0, 1]) {
|
||||
const data = await provider.requestTileGeometry(x, 0, 0);
|
||||
const rect = provider.tilingScheme.tileXYToRectangle(x, 0, 0);
|
||||
assert.equal(
|
||||
data.interpolateHeight(rect, (rect.west + rect.east) / 2, 0),
|
||||
0,
|
||||
);
|
||||
for (const dx of [0, 1])
|
||||
for (const dy of [0, 1]) {
|
||||
assert.equal(data.isChildAvailable(x, 0, x * 2 + dx, dy), false);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
server.closeAllConnections();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
test('fixture interception targets provider URLs and leaves app workers alone', async () => {
|
||||
const { installTrafficRoutes } =
|
||||
await import('../../scripts/traffic-fixtures.mjs');
|
||||
const sent = [];
|
||||
let paused;
|
||||
const session = {
|
||||
on: (_, handler) => {
|
||||
paused = handler;
|
||||
},
|
||||
send: async (name, args) => {
|
||||
sent.push({ name, args });
|
||||
},
|
||||
};
|
||||
await installTrafficRoutes(
|
||||
{ createCDPSession: async () => session, isClosed: () => false },
|
||||
{ origin: 'http://localhost:4173', fixtures: true },
|
||||
);
|
||||
const patterns = sent[0].args.patterns.map((p) => p.urlPattern);
|
||||
assert.deepEqual(patterns, [
|
||||
'http://localhost:4173/api/tomtom/status*',
|
||||
'http://localhost:4173/api/tomtom/flow/*',
|
||||
'https://tiles.openfreemap.org/*',
|
||||
'https://terrain.reearth.land/*',
|
||||
]);
|
||||
await paused({
|
||||
requestId: 'terrain',
|
||||
request: { url: 'https://terrain.reearth.land/layer.json' },
|
||||
});
|
||||
const reply = sent.at(-1);
|
||||
assert.equal(reply.name, 'Fetch.fulfillRequest');
|
||||
assert.equal(
|
||||
JSON.parse(Buffer.from(reply.args.body, 'base64')).format,
|
||||
'heightmap-1.0',
|
||||
);
|
||||
assert.ok(
|
||||
reply.args.responseHeaders.some(
|
||||
(h) => h.name === 'access-control-allow-origin' && h.value === '*',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('keyless interception refuses and records unexpected flow requests', async () => {
|
||||
const { installTrafficRoutes } =
|
||||
await import('../../scripts/traffic-fixtures.mjs');
|
||||
const sent = [],
|
||||
flows = [];
|
||||
let paused;
|
||||
const session = {
|
||||
on: (_, handler) => {
|
||||
paused = handler;
|
||||
},
|
||||
send: async (name, args) => {
|
||||
sent.push({ name, args });
|
||||
},
|
||||
};
|
||||
await installTrafficRoutes(
|
||||
{ createCDPSession: async () => session, isClosed: () => false },
|
||||
{
|
||||
origin: 'http://localhost:4173',
|
||||
keyless: true,
|
||||
onFlow: (url) => flows.push(url),
|
||||
},
|
||||
);
|
||||
await paused({
|
||||
requestId: 'status',
|
||||
request: { url: 'http://localhost:4173/api/tomtom/status' },
|
||||
});
|
||||
assert.deepEqual(JSON.parse(Buffer.from(sent.at(-1).args.body, 'base64')), {
|
||||
hasKey: false,
|
||||
});
|
||||
await paused({
|
||||
requestId: 'flow',
|
||||
request: { url: 'http://localhost:4173/api/tomtom/flow/12/1/1.pbf' },
|
||||
});
|
||||
assert.equal(sent.at(-1).args.responseCode, 503);
|
||||
assert.equal(flows.length, 1);
|
||||
});
|
||||
@@ -234,6 +234,19 @@ test('typed argument checks feed full-call accuracy without changing the legacy
|
||||
const inMeters = gradeCore(altitude, [{ name: 'analyst_query', args: { layers: ['flights'], filters: [{ field: 'altitudeM', op: 'gt', value: 12192 }] } }]);
|
||||
assert.equal(inMeters.fullCallOk, true);
|
||||
assert.equal(where('set_hud', (a) => a.visible === 'off')({ name: 'set_hud', args: { visible: 'off' } }), true);
|
||||
const globe = byPhrase('Keep spinning the Earth slowly to the left');
|
||||
const globeArgs = { motion: 'rotate_globe', direction: 'left', speed: 'slow' };
|
||||
for (const mode of [undefined, 'continuous', 'once', null]) {
|
||||
assert.equal(gradeCore(globe, [{ name: 'move_camera', args: { ...globeArgs, mode } }]).fullCallOk, mode === undefined || mode === 'continuous');
|
||||
}
|
||||
assert.equal(gradeCore(globe, [{ name: 'move_camera', args: { ...globeArgs, direction: 'right' } }]).fullCallOk, false);
|
||||
assert.equal(gradeCore(globe, [{ name: 'move_camera', args: { ...globeArgs, speed: 'normal' } }]).fullCallOk, false);
|
||||
const right = byPhrase('Rotate the globe to the right');
|
||||
assert.equal(gradeCore(right, [{ name: 'move_camera', args: { motion: 'rotate_globe' } }]).fullCallOk, true);
|
||||
for (const direction of ['right', 'left', null]) {
|
||||
assert.equal(gradeCore(right, [{ name: 'move_camera', args: { motion: 'rotate_globe', direction } }]).fullCallOk, direction === 'right');
|
||||
}
|
||||
assert.equal(gradeCore(right, [{ name: 'move_camera', args: { motion: 'rotate', direction: 'right' } }]).fullCallOk, false);
|
||||
});
|
||||
|
||||
test('gradeDialogue scores turn 2 in the context of turn 1', () => {
|
||||
|
||||
@@ -333,13 +333,20 @@ export const getAircraftTrack = defineTool({
|
||||
requires: ['aircraft'],
|
||||
async run(args, { services, signal }) {
|
||||
const icao24 = args.icao24.toLowerCase();
|
||||
const source = feedFor(services, args.military);
|
||||
// History is optional: the standalone military source has none.
|
||||
if (typeof source.getTrack !== 'function')
|
||||
throw new ToolError(
|
||||
'unsupported',
|
||||
args.military
|
||||
? 'Track history is not available for the military aircraft feed'
|
||||
: 'Track history is not available for the aircraft feed',
|
||||
);
|
||||
// A track server answers 404 for an aircraft it has no positions for.
|
||||
const track = await feedFor(services, args.military)
|
||||
.getTrack(icao24, { signal })
|
||||
.catch((error) => {
|
||||
if (error?.status === 404) return { records: [] };
|
||||
throw error;
|
||||
});
|
||||
const track = await source.getTrack(icao24, { signal }).catch((error) => {
|
||||
if (error?.status === 404) return { records: [] };
|
||||
throw error;
|
||||
});
|
||||
const points = [...track.records].sort(
|
||||
(a, b) => a.observedAtMs - b.observedAtMs,
|
||||
);
|
||||
|
||||
@@ -3,6 +3,8 @@ import test from 'node:test';
|
||||
import { composeCatalog, coreTools } from '../index.js';
|
||||
import { airlineDesignator } from './aviation.js';
|
||||
import { LiveSourceError } from '../../sources/live/contract.js';
|
||||
import { createMcpServer } from '../mcp/index.js';
|
||||
import { createToolServices } from '../services.js';
|
||||
|
||||
const record = (id, latitude, longitude, extra = {}) => ({
|
||||
id,
|
||||
@@ -242,6 +244,61 @@ test('tracks are ordered and thinned to 200 points', async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('military track history is unsupported by the default composition and kept for custom sources', async () => {
|
||||
const requests = [];
|
||||
const services = createToolServices({
|
||||
fetchImpl: async (url) => {
|
||||
requests.push(String(url));
|
||||
throw new Error('no request expected');
|
||||
},
|
||||
appUrl: 'http://localhost:5173',
|
||||
});
|
||||
const catalog = composeCatalog({ tools: coreTools, services });
|
||||
await assert.rejects(
|
||||
catalog.call('get_aircraft_track', { icao24: 'AE0001', military: true }),
|
||||
(error) =>
|
||||
error.name === 'ToolError' &&
|
||||
error.code === 'unsupported' &&
|
||||
/military/.test(error.message),
|
||||
);
|
||||
const server = createMcpServer({ catalog, name: 'test', version: '1.0.0' });
|
||||
const { result } = await server.handle({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'get_aircraft_track',
|
||||
arguments: { icao24: 'ae0001', military: true },
|
||||
},
|
||||
});
|
||||
assert.equal(result.isError, true);
|
||||
assert.equal(result.structuredContent.error, 'unsupported');
|
||||
assert.doesNotMatch(result.content[0].text, /try again|later/i);
|
||||
assert.deepEqual(requests, []);
|
||||
|
||||
const track = [
|
||||
{
|
||||
latitude: 37.6,
|
||||
longitude: -122.3,
|
||||
observedAtMs: Date.UTC(2026, 0, 1),
|
||||
baroAltitudeM: 3000,
|
||||
onGround: false,
|
||||
},
|
||||
];
|
||||
const custom = feed(military, { track });
|
||||
const withHistory = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { aircraft: feed(civil), military: custom },
|
||||
});
|
||||
const kept = await withHistory.call('get_aircraft_track', {
|
||||
icao24: 'AE0001',
|
||||
military: true,
|
||||
});
|
||||
assert.equal(custom.calls[0], 'ae0001');
|
||||
assert.equal(kept.data.total, 1);
|
||||
assert.equal(kept.data.view.follow.kind, 'military_aircraft');
|
||||
});
|
||||
|
||||
test('aircraft info combines type and route lookups', async () => {
|
||||
const aircraft = feed(civil, {
|
||||
enrichment: {
|
||||
|
||||
@@ -147,6 +147,7 @@ export class StyleManager extends ShellFacade {
|
||||
},
|
||||
searchInput: this._locationSearch,
|
||||
interruptCameraMotion: services.interruptCameraMotion,
|
||||
interruptGlobeRotation: services.interruptGlobeRotation,
|
||||
isCockpitActive: () => !!this.cockpitView?.active,
|
||||
clearLocation: () => this.clearSearchedLocation(),
|
||||
cancelShareSelection: () => this._shareRestoration.cancelSelection(),
|
||||
@@ -322,6 +323,7 @@ export class StyleManager extends ShellFacade {
|
||||
GLOBE_VIEW: services.GLOBE_VIEW,
|
||||
flyToGlobeView: services.flyToGlobeView,
|
||||
interruptCameraMotion: services.interruptCameraMotion,
|
||||
interruptGlobeRotation: services.interruptGlobeRotation,
|
||||
flightsLayer: services.flightsLayer,
|
||||
militaryFlightsLayer: services.militaryFlightsLayer,
|
||||
satellitesLayer: services.satellitesLayer,
|
||||
@@ -862,9 +864,38 @@ export class StyleManager extends ShellFacade {
|
||||
setPanelCollapsed: (panelId, collapsed, options) =>
|
||||
this._panelChrome.setPanelCollapsed(panelId, collapsed, options),
|
||||
onSheetsClosed: () => this._syncShareState(),
|
||||
onTabOpened: (sheet) => {
|
||||
void this._onMobileTabOpened(sheet);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Reveal a tracked aircraft through Contacts when its phone tab is opened. */
|
||||
async _onMobileTabOpened(sheet) {
|
||||
const context = this.getContextModeState();
|
||||
if (
|
||||
sheet !== 'context' ||
|
||||
this._disposed ||
|
||||
!context ||
|
||||
context.mode ||
|
||||
context.changing ||
|
||||
context.entering ||
|
||||
!this.getAircraftTrackingTarget()
|
||||
)
|
||||
return false;
|
||||
try {
|
||||
const result = await this.setContextMode('contacts');
|
||||
if (result?.ok) return true;
|
||||
if (result?.cancelled || this._disposed) return false;
|
||||
} catch {
|
||||
if (this._disposed) return false;
|
||||
}
|
||||
this._showToast(
|
||||
t('Contacts could not complete the requested transition; try again'),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the owner-approved map stack chip row from the matching controller
|
||||
* entries. Cesium ion/Bing chips remain keyboard-focusable but unavailable,
|
||||
|
||||
@@ -9,7 +9,10 @@ import {
|
||||
flyToPOI,
|
||||
searchAndFlyTo,
|
||||
} from '../locations.js';
|
||||
import { interruptCameraMotion } from '../cameraVerbs.js';
|
||||
import {
|
||||
interruptCameraMotion,
|
||||
interruptGlobeRotation,
|
||||
} from '../cameraVerbs.js';
|
||||
import { IntelHUD } from '../hud.js';
|
||||
import { ShareLinkManager } from '../sharelink.js';
|
||||
import { OrbitController } from '../orbit.js';
|
||||
@@ -73,6 +76,7 @@ export class StyleManager extends ApplicationShell {
|
||||
flyToPOI,
|
||||
searchAndFlyTo,
|
||||
interruptCameraMotion,
|
||||
interruptGlobeRotation,
|
||||
IntelHUD,
|
||||
ShareLinkManager,
|
||||
OrbitController,
|
||||
|
||||
@@ -283,6 +283,7 @@ export class LocationNavigation {
|
||||
return;
|
||||
}
|
||||
|
||||
this.services.interruptGlobeRotation?.('orbit-intent');
|
||||
const isActive = this.orbitController.toggle(this._currentTarget, {
|
||||
radius: this._currentPoi?.alt || 500,
|
||||
pitch: this._currentPoi?.pitch || -30,
|
||||
|
||||
@@ -125,6 +125,8 @@ function buildChrome(documentRef) {
|
||||
* @param {(callback: () => void) => number} [input.requestFrame]
|
||||
* @param {() => void} [input.onSheetsClosed] Runs after the last sheet closes
|
||||
* and its panels are put back (e.g. to refresh the share link).
|
||||
* @param {(sheet: string) => void} [input.onTabOpened] Runs only when the user
|
||||
* opens a sheet from its tab, never on close or programmatic presentation.
|
||||
* @returns {{ destroy: () => void, setSheet: (sheet: string|null) => void, getSheet: () => string|null, sharedCollapsed: (panelId: string) => boolean|undefined }}
|
||||
*/
|
||||
export function initMobileShell({
|
||||
@@ -135,6 +137,7 @@ export function initMobileShell({
|
||||
ResizeObserverImpl = globalThis.ResizeObserver,
|
||||
requestFrame = globalThis.requestAnimationFrame?.bind(globalThis),
|
||||
onSheetsClosed = () => {},
|
||||
onTabOpened = () => {},
|
||||
} = {}) {
|
||||
const body = documentRef.body;
|
||||
const { backdrop, tabBar } = buildChrome(documentRef);
|
||||
@@ -159,6 +162,7 @@ export function initMobileShell({
|
||||
*/
|
||||
let borrowed = null;
|
||||
let returnFocus = null;
|
||||
let swipe = null;
|
||||
const written = new Map();
|
||||
|
||||
const writeVar = (name, value) => {
|
||||
@@ -259,6 +263,7 @@ export function initMobileShell({
|
||||
};
|
||||
|
||||
const setSheet = (requested) => {
|
||||
swipe = null;
|
||||
if (!body.classList.contains(MOBILE_SHELL_CLASS)) return;
|
||||
const sheet = sheetBlocked(body) ? null : requested;
|
||||
const previous = currentSheetOf(body);
|
||||
@@ -348,8 +353,123 @@ export function initMobileShell({
|
||||
// Closing from the tab bar returns focus to the tab that was pressed.
|
||||
if (!sheet) returnFocus = button;
|
||||
setSheet(sheet);
|
||||
if (sheet && currentSheetOf(body) === sheet) onTabOpened(sheet);
|
||||
});
|
||||
listen(backdrop, 'click', () => setSheet(null));
|
||||
// Touch events let native scrolling continue until a downward pull at the
|
||||
// top is intentional. Pointer capture/touch-action:none would also seize
|
||||
// ordinary vertical scrolling inside the borrowed panels.
|
||||
const sheetRoot = (target, sheet) => {
|
||||
const roots =
|
||||
sheet === 'layers'
|
||||
? [element('left-panel-stack')]
|
||||
: sheet === 'search'
|
||||
? [dock]
|
||||
: sheet === 'more'
|
||||
? [element('right-context-rail'), dock]
|
||||
: [element('right-context-rail')];
|
||||
return roots.find((root) => root?.contains(target));
|
||||
};
|
||||
const scrolled = (target, root) => {
|
||||
for (let node = target; node; node = node.parentElement) {
|
||||
if (node.scrollTop > 0) return true;
|
||||
if (node === root) break;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
listen(
|
||||
documentRef,
|
||||
'touchstart',
|
||||
(event) => {
|
||||
swipe = null;
|
||||
const sheet = currentSheetOf(body);
|
||||
const target = event.target;
|
||||
if (
|
||||
!sheet ||
|
||||
!body.classList.contains(MOBILE_SHELL_CLASS) ||
|
||||
event.defaultPrevented ||
|
||||
event.touches.length !== 1 ||
|
||||
target?.closest?.(
|
||||
'button, a, input, select, textarea, label, [contenteditable]:not([contenteditable="false"]), [role="slider"], [role="button"]',
|
||||
)
|
||||
)
|
||||
return;
|
||||
const root = sheetRoot(target, sheet);
|
||||
if (!root || scrolled(target, root)) return;
|
||||
const touch = event.touches[0];
|
||||
swipe = {
|
||||
sheet,
|
||||
target,
|
||||
root,
|
||||
id: touch.identifier,
|
||||
x: touch.clientX,
|
||||
y: touch.clientY,
|
||||
claimed: false,
|
||||
};
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
listen(
|
||||
documentRef,
|
||||
'touchmove',
|
||||
(event) => {
|
||||
if (!swipe) return;
|
||||
const touch = event.touches[0];
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.touches.length !== 1 ||
|
||||
touch.identifier !== swipe.id ||
|
||||
currentSheetOf(body) !== swipe.sheet ||
|
||||
scrolled(swipe.target, swipe.root)
|
||||
) {
|
||||
swipe = null;
|
||||
return;
|
||||
}
|
||||
const dx = touch.clientX - swipe.x;
|
||||
const dy = touch.clientY - swipe.y;
|
||||
if (!swipe.claimed) {
|
||||
// Claim direction before the browser crosses its native scroll slop.
|
||||
// Android can make the next move non-cancelable after a ~9px first move.
|
||||
if (Math.max(Math.abs(dx), Math.abs(dy)) < 4) return;
|
||||
if (dy <= Math.abs(dx) * 1.25 || !event.cancelable) {
|
||||
swipe = null;
|
||||
return;
|
||||
}
|
||||
swipe.claimed = true;
|
||||
}
|
||||
// Cancelling only the claimed pull leaves taps, sliders and normal scroll
|
||||
// native, and suppresses the synthetic click at the end of a dismissal.
|
||||
event.preventDefault();
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
listen(
|
||||
documentRef,
|
||||
'touchend',
|
||||
(event) => {
|
||||
const pull = swipe;
|
||||
swipe = null;
|
||||
if (!pull?.claimed || event.touches.length) return;
|
||||
const touch = [...event.changedTouches].find(
|
||||
(item) => item.identifier === pull.id,
|
||||
);
|
||||
if (!touch || currentSheetOf(body) !== pull.sheet) return;
|
||||
const dx = Math.abs(touch.clientX - pull.x);
|
||||
const dy = touch.clientY - pull.y;
|
||||
if (dy < 64 || dy <= dx * 1.25) return;
|
||||
returnFocus = tabFor(pull.sheet) ?? returnFocus;
|
||||
setSheet(null);
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
listen(
|
||||
documentRef,
|
||||
'touchcancel',
|
||||
() => {
|
||||
swipe = null;
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
// Bubble phase, so fields cancelling an edit, lightboxes, viewers and tools
|
||||
// take Escape first; panels yield it to the sheet (PanelChrome), and an open
|
||||
// dialog keeps its own native close.
|
||||
|
||||
+385
-3
@@ -1,6 +1,8 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { StyleManager } from './applicationShell.js';
|
||||
import { ContextControls } from './contextControls.js';
|
||||
import {
|
||||
MOBILE_SHEET_PANELS,
|
||||
MOBILE_TABS,
|
||||
@@ -21,6 +23,8 @@ function element(tag, document) {
|
||||
textContent: '',
|
||||
className: '',
|
||||
parent: null,
|
||||
scrollTop: 0,
|
||||
parentElement: null,
|
||||
classList: {
|
||||
add: (...names) => names.forEach((name) => classes.add(name)),
|
||||
remove: (...names) => names.forEach((name) => classes.delete(name)),
|
||||
@@ -41,6 +45,7 @@ function element(tag, document) {
|
||||
append(...children) {
|
||||
for (const child of children) {
|
||||
child.parent = node;
|
||||
child.parentElement = node;
|
||||
node.children.push(child);
|
||||
if (child.id) document.ids.set(child.id, child);
|
||||
}
|
||||
@@ -56,9 +61,20 @@ function element(tag, document) {
|
||||
return false;
|
||||
},
|
||||
closest(selector) {
|
||||
for (let current = node; current; current = current.parent)
|
||||
for (let current = node; current; current = current.parent) {
|
||||
if (
|
||||
selector.startsWith('button, a') &&
|
||||
(['BUTTON', 'A', 'INPUT', 'SELECT', 'TEXTAREA', 'LABEL'].includes(
|
||||
current.tagName,
|
||||
) ||
|
||||
['slider', 'button'].includes(current.getAttribute('role')) ||
|
||||
(current.getAttribute('contenteditable') !== null &&
|
||||
current.getAttribute('contenteditable') !== 'false'))
|
||||
)
|
||||
return current;
|
||||
if (selector === '[data-mobile-sheet]' && current.dataset.mobileSheet)
|
||||
return current;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
querySelectorAll(selector) {
|
||||
@@ -94,7 +110,11 @@ function element(tag, document) {
|
||||
|
||||
const PANEL_IDS = [...new Set(Object.values(MOBILE_SHEET_PANELS).flat())];
|
||||
|
||||
function fixture({ mobile = true, expanded = [] } = {}) {
|
||||
function fixture({
|
||||
mobile = true,
|
||||
expanded = [],
|
||||
onTabOpened = () => {},
|
||||
} = {}) {
|
||||
const document = new EventTarget();
|
||||
document.ids = new Map();
|
||||
document.createElement = (tag) => element(tag, document);
|
||||
@@ -112,6 +132,12 @@ function fixture({ mobile = true, expanded = [] } = {}) {
|
||||
const search = element('input', document);
|
||||
search.id = 'location-search';
|
||||
document.body.append(search);
|
||||
const rails = {};
|
||||
for (const id of ['left-panel-stack', 'right-context-rail', 'command-dock']) {
|
||||
rails[id] = element('div', document);
|
||||
rails[id].id = id;
|
||||
document.body.append(rails[id]);
|
||||
}
|
||||
for (const id of PANEL_IDS) {
|
||||
const panel = element('section', document);
|
||||
panel.id = id;
|
||||
@@ -123,7 +149,13 @@ function fixture({ mobile = true, expanded = [] } = {}) {
|
||||
const collapse = element('button', document);
|
||||
collapse.className = 'panel-collapse-btn';
|
||||
panel.append(toggle, collapse, element('button', document));
|
||||
document.body.append(panel);
|
||||
const root =
|
||||
id === 'data-panel'
|
||||
? 'left-panel-stack'
|
||||
: ['location-bar', 'control-panel'].includes(id)
|
||||
? 'command-dock'
|
||||
: 'right-context-rail';
|
||||
rails[root].append(panel);
|
||||
}
|
||||
|
||||
const media = new EventTarget();
|
||||
@@ -143,6 +175,7 @@ function fixture({ mobile = true, expanded = [] } = {}) {
|
||||
}
|
||||
const shell = initMobileShell({
|
||||
documentRef: document,
|
||||
onTabOpened,
|
||||
matchMedia: () => media,
|
||||
MutationObserverImpl: FakeMutationObserver,
|
||||
ResizeObserverImpl: null,
|
||||
@@ -481,3 +514,352 @@ test('the phone shell keeps the required attribution visible', () => {
|
||||
assert.ok(z(sheetCredit) > z(openDock));
|
||||
assert.ok(z(sheetCredit) > z(railSheet));
|
||||
});
|
||||
|
||||
function touchEvent(
|
||||
document,
|
||||
type,
|
||||
target,
|
||||
{ x = 10, y = 10, count = 1, id = 1, cancelable = true } = {},
|
||||
) {
|
||||
const event = new Event(type, { cancelable });
|
||||
const touch = { identifier: id, clientX: x, clientY: y };
|
||||
Object.defineProperty(event, 'target', { value: target });
|
||||
event.touches =
|
||||
type === 'touchend' || type === 'touchcancel'
|
||||
? []
|
||||
: Array.from({ length: count }, (_, i) => ({
|
||||
...touch,
|
||||
identifier: id + i,
|
||||
}));
|
||||
event.changedTouches = [touch];
|
||||
document.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
test('downward sheet pull closes through borrowed-panel lifecycle and returns tab focus', () => {
|
||||
for (const sheet of Object.keys(MOBILE_SHEET_PANELS)) {
|
||||
const phone = fixture();
|
||||
const { document, shell, press, tab } = phone;
|
||||
press(sheet);
|
||||
const target = document.getElementById(MOBILE_SHEET_PANELS[sheet][0]);
|
||||
touchEvent(document, 'touchstart', target);
|
||||
const move = touchEvent(document, 'touchmove', target, { y: 90 });
|
||||
assert.equal(move.defaultPrevented, true);
|
||||
assert.equal(shell.getSheet(), sheet, 'waits until finger lifts');
|
||||
touchEvent(document, 'touchend', target, { y: 90 });
|
||||
assert.equal(shell.getSheet(), null);
|
||||
assert.equal(phone.closedCount(), 1);
|
||||
assert.equal(document.activeElement, tab(sheet));
|
||||
for (const id of MOBILE_SHEET_PANELS[sheet])
|
||||
assert.equal(phone.collapsed(id), true);
|
||||
assert.ok(
|
||||
phone.calls.every(
|
||||
(call) => call.persist === false && call.syncShare === false,
|
||||
),
|
||||
);
|
||||
shell.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('content scroll stays native, including a scroll that reaches the top mid-gesture', () => {
|
||||
for (const nested of [false, true]) {
|
||||
const { document, shell, press } = fixture();
|
||||
press('layers');
|
||||
const panel = document.getElementById('data-panel');
|
||||
const scroller = nested
|
||||
? panel
|
||||
: document.getElementById('left-panel-stack');
|
||||
scroller.scrollTop = 40;
|
||||
touchEvent(document, 'touchstart', panel);
|
||||
scroller.scrollTop = 0;
|
||||
assert.equal(
|
||||
touchEvent(document, 'touchmove', panel, { y: 120 }).defaultPrevented,
|
||||
false,
|
||||
);
|
||||
touchEvent(document, 'touchend', panel, { y: 120 });
|
||||
assert.equal(shell.getSheet(), 'layers');
|
||||
shell.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('taps, short pulls, upward and horizontal gestures never dismiss', () => {
|
||||
for (const path of [
|
||||
[],
|
||||
[{ x: 12, y: 14 }],
|
||||
[{ y: 40 }],
|
||||
[{ y: -30 }, { y: 120 }],
|
||||
[{ x: 60, y: 12 }, { y: 120 }],
|
||||
]) {
|
||||
const { document, shell, press } = fixture();
|
||||
press('layers');
|
||||
const panel = document.getElementById('data-panel');
|
||||
touchEvent(document, 'touchstart', panel);
|
||||
for (const point of path) touchEvent(document, 'touchmove', panel, point);
|
||||
touchEvent(document, 'touchend', panel, path.at(-1) ?? {});
|
||||
assert.equal(shell.getSheet(), 'layers');
|
||||
shell.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('native controls and custom sliders keep touch gestures', () => {
|
||||
for (const tag of [
|
||||
'button',
|
||||
'a',
|
||||
'input',
|
||||
'select',
|
||||
'textarea',
|
||||
'label',
|
||||
'slider',
|
||||
'editable',
|
||||
]) {
|
||||
const { document, shell, press } = fixture();
|
||||
press('more');
|
||||
const control = document.createElement(
|
||||
['slider', 'editable'].includes(tag) ? 'div' : tag,
|
||||
);
|
||||
if (tag === 'slider') control.setAttribute('role', 'slider');
|
||||
if (tag === 'editable') control.setAttribute('contenteditable', 'true');
|
||||
const child = document.createElement('span');
|
||||
control.append(child);
|
||||
document.getElementById('control-panel').append(control);
|
||||
touchEvent(document, 'touchstart', child);
|
||||
assert.equal(
|
||||
touchEvent(document, 'touchmove', child, { y: 120 }).defaultPrevented,
|
||||
false,
|
||||
);
|
||||
touchEvent(document, 'touchend', child, { y: 120 });
|
||||
assert.equal(shell.getSheet(), 'more');
|
||||
shell.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('multitouch, cancellation, sheet changes and desktop transitions abandon the gesture', () => {
|
||||
for (const cancel of [
|
||||
'multitouch',
|
||||
'cancel',
|
||||
'sheet',
|
||||
'desktop',
|
||||
'noncancelable',
|
||||
]) {
|
||||
const { document, shell, press, resize } = fixture();
|
||||
press('layers');
|
||||
const panel = document.getElementById('data-panel');
|
||||
touchEvent(document, 'touchstart', panel);
|
||||
if (cancel === 'multitouch')
|
||||
touchEvent(document, 'touchstart', document.body, { count: 2 });
|
||||
if (cancel === 'cancel') touchEvent(document, 'touchcancel', panel);
|
||||
if (cancel === 'sheet') press('context');
|
||||
if (cancel === 'desktop') {
|
||||
resize(false);
|
||||
resize(true);
|
||||
press('layers');
|
||||
}
|
||||
if (cancel === 'noncancelable')
|
||||
touchEvent(document, 'touchmove', panel, { y: 30, cancelable: false });
|
||||
assert.equal(
|
||||
touchEvent(document, 'touchmove', panel, { y: 120 }).defaultPrevented,
|
||||
false,
|
||||
);
|
||||
touchEvent(document, 'touchend', panel, { y: 120 });
|
||||
assert.equal(shell.getSheet(), cancel === 'sheet' ? 'context' : 'layers');
|
||||
shell.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('touches outside the active sheet and destroyed listeners cannot dismiss or claim a gesture', () => {
|
||||
const { document, shell, press } = fixture();
|
||||
press('layers');
|
||||
const hiddenPanel = document.getElementById('global-context-panel');
|
||||
touchEvent(document, 'touchstart', hiddenPanel);
|
||||
assert.equal(
|
||||
touchEvent(document, 'touchmove', hiddenPanel, { y: 120 }).defaultPrevented,
|
||||
false,
|
||||
);
|
||||
touchEvent(document, 'touchend', hiddenPanel, { y: 120 });
|
||||
assert.equal(shell.getSheet(), 'layers');
|
||||
const panel = document.getElementById('data-panel');
|
||||
touchEvent(document, 'touchstart', panel);
|
||||
shell.destroy();
|
||||
assert.equal(
|
||||
touchEvent(document, 'touchmove', panel, { y: 120 }).defaultPrevented,
|
||||
false,
|
||||
);
|
||||
touchEvent(document, 'touchend', panel, { y: 120 });
|
||||
assert.equal(shell.getSheet(), null);
|
||||
});
|
||||
|
||||
test('the tab-open callback reports only a successful manual opening', () => {
|
||||
const opened = [];
|
||||
const phone = fixture({ onTabOpened: (sheet) => opened.push(sheet) });
|
||||
phone.shell.setSheet('context');
|
||||
assert.deepEqual(opened, []);
|
||||
phone.press('context');
|
||||
assert.deepEqual(opened, [], 'closing is not a new context request');
|
||||
phone.press('context');
|
||||
assert.deepEqual(opened, ['context']);
|
||||
phone.setBodyClass('cockpit-mode');
|
||||
phone.press('context');
|
||||
assert.deepEqual(
|
||||
opened,
|
||||
['context'],
|
||||
'blocked sheets cannot activate a mode',
|
||||
);
|
||||
phone.shell.destroy();
|
||||
});
|
||||
|
||||
test('manual Context opens the existing transaction without disabling the tracked aircraft', async () => {
|
||||
const calls = [];
|
||||
const tracked = { layerId: 'flights', icao24: 'abc123', callsign: 'EJA225' };
|
||||
const viewer = { trackedEntity: { gevTrackedId: 'flights:abc123' } };
|
||||
const entity = viewer.trackedEntity;
|
||||
const enabled = new Set(['flights', 'weather']);
|
||||
const controls = new ContextControls({
|
||||
elements: {},
|
||||
installations: {},
|
||||
actions: {
|
||||
getCockpit: () => null,
|
||||
claimVisualAuthority: () => calls.push('claim'),
|
||||
syncDetection() {},
|
||||
scheduleLayout() {},
|
||||
showToast() {},
|
||||
},
|
||||
});
|
||||
controls.connect({
|
||||
layers: new Map(
|
||||
['flights', 'weather', 'military-awareness'].map((id) => [id, {}]),
|
||||
),
|
||||
getEnabledLayerIds: () => [...enabled],
|
||||
getLayerParams: () => ({}),
|
||||
isEffectivelyEnabled: (id) => enabled.has(id),
|
||||
isEnabled: (id) => enabled.has(id),
|
||||
setLayerParams: (id, params) => calls.push({ id, params }),
|
||||
setEnabled: async (id, value) => {
|
||||
calls.push({ id, value });
|
||||
if (!value) enabled.delete(id);
|
||||
if (id === 'flights' && !value) viewer.trackedEntity = null;
|
||||
return true;
|
||||
},
|
||||
_setEnabledWithIntent: (id, value) => {
|
||||
calls.push({ id, value });
|
||||
enabled.add(id);
|
||||
return { intentEpoch: 1, promise: Promise.resolve(true) };
|
||||
},
|
||||
});
|
||||
const shell = {
|
||||
cockpitView: {
|
||||
readAircraftInfo: () => (viewer.trackedEntity ? tracked : null),
|
||||
},
|
||||
getAircraftTrackingTarget: StyleManager.prototype.getAircraftTrackingTarget,
|
||||
getContextModeState: () => controls.getContextModeState(),
|
||||
setContextMode: (mode) => controls.setContextMode(mode),
|
||||
_showToast: () => assert.fail('successful entry must not report a failure'),
|
||||
};
|
||||
try {
|
||||
assert.equal(
|
||||
await StyleManager.prototype._onMobileTabOpened.call(shell, 'context'),
|
||||
true,
|
||||
);
|
||||
assert.equal(controls.getContextModeState().mode, 'flights');
|
||||
assert.equal(viewer.trackedEntity, entity);
|
||||
assert.deepEqual(shell.getAircraftTrackingTarget(), {
|
||||
layerId: 'flights',
|
||||
id: 'abc123',
|
||||
});
|
||||
assert.ok(enabled.has('flights'));
|
||||
assert.ok(
|
||||
calls.some(
|
||||
(call) => call.id === 'military-awareness' && call.value === true,
|
||||
),
|
||||
);
|
||||
assert.ok(!calls.some((call) => call.id === 'flights'));
|
||||
const count = calls.length;
|
||||
assert.equal(
|
||||
await StyleManager.prototype._onMobileTabOpened.call(shell, 'context'),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
calls.length,
|
||||
count,
|
||||
'an already active Contacts session is not restarted',
|
||||
);
|
||||
} finally {
|
||||
controls.stop();
|
||||
controls.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test('manual Context respects Space Missions, busy transitions, missing tracks and disposal', async () => {
|
||||
for (const state of [
|
||||
{ mode: 'space-missions' },
|
||||
{ mode: 'flights' },
|
||||
{ changing: true },
|
||||
{ entering: 'space-missions' },
|
||||
{ noTrack: true },
|
||||
{ disposed: true },
|
||||
{ sheet: 'layers' },
|
||||
]) {
|
||||
const shell = {
|
||||
_disposed: state.disposed,
|
||||
getContextModeState: () => state,
|
||||
getAircraftTrackingTarget: () =>
|
||||
state.noTrack ? null : { layerId: 'flights', id: 'abc123' },
|
||||
setContextMode: () =>
|
||||
assert.fail('guarded tab must not mutate Context or tracking'),
|
||||
};
|
||||
assert.equal(
|
||||
await StyleManager.prototype._onMobileTabOpened.call(
|
||||
shell,
|
||||
state.sheet || 'context',
|
||||
),
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('failed mobile Contacts entry uses the existing notice and cancelled/disposed entries stay quiet', async () => {
|
||||
for (const outcome of ['failure', 'throw', 'cancelled', 'disposed']) {
|
||||
const notices = [];
|
||||
const shell = {
|
||||
getContextModeState: () => ({ mode: null }),
|
||||
getAircraftTrackingTarget: () => ({ layerId: 'flights', id: 'abc123' }),
|
||||
setContextMode: async () => {
|
||||
if (outcome === 'throw') throw new Error('source unavailable');
|
||||
if (outcome === 'disposed') shell._disposed = true;
|
||||
return { ok: false, cancelled: outcome === 'cancelled' };
|
||||
},
|
||||
_showToast: (message) => notices.push(message),
|
||||
};
|
||||
assert.equal(
|
||||
await StyleManager.prototype._onMobileTabOpened.call(shell, 'context'),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
notices.length,
|
||||
['failure', 'throw'].includes(outcome) ? 1 : 0,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('a slow downward pull is claimed before Android native scroll takes ownership', () => {
|
||||
const { document, shell, press } = fixture();
|
||||
press('layers');
|
||||
const panel = document.getElementById('data-panel');
|
||||
touchEvent(document, 'touchstart', panel, { y: 100 });
|
||||
assert.equal(
|
||||
touchEvent(document, 'touchmove', panel, { y: 102 }).defaultPrevented,
|
||||
false,
|
||||
'tap jitter is left native',
|
||||
);
|
||||
assert.equal(
|
||||
touchEvent(document, 'touchmove', panel, { y: 109.3 }).defaultPrevented,
|
||||
true,
|
||||
'cancel the first directional move while Android still allows it',
|
||||
);
|
||||
assert.equal(
|
||||
touchEvent(document, 'touchmove', panel, { y: 131 }).defaultPrevented,
|
||||
true,
|
||||
);
|
||||
touchEvent(document, 'touchend', panel, { y: 175 });
|
||||
assert.equal(shell.getSheet(), null);
|
||||
shell.destroy();
|
||||
});
|
||||
@@ -14,6 +14,7 @@ export class NavigationController {
|
||||
tracking,
|
||||
searchInput,
|
||||
interruptCameraMotion,
|
||||
interruptGlobeRotation = () => {},
|
||||
isCockpitActive,
|
||||
clearLocation,
|
||||
cancelShareSelection,
|
||||
@@ -27,6 +28,7 @@ export class NavigationController {
|
||||
tracking,
|
||||
searchInput,
|
||||
interruptCameraMotion,
|
||||
interruptGlobeRotation,
|
||||
isCockpitActive,
|
||||
clearLocation,
|
||||
cancelShareSelection,
|
||||
@@ -46,6 +48,7 @@ export class NavigationController {
|
||||
cancelPendingSelection = true,
|
||||
clearSearchedLocation = true,
|
||||
} = {}) {
|
||||
this.interruptGlobeRotation('navigation-intent');
|
||||
cancelCameraArrival(this.viewer);
|
||||
this.cancelOrientation();
|
||||
const { flightsLayer, militaryFlightsLayer, satellitesLayer } =
|
||||
|
||||
@@ -86,10 +86,14 @@ body.mobile-shell[data-mobile-sheet] #mobile-sheet-backdrop:not([hidden]) {
|
||||
|
||||
/* ── Top bar: logo left, globe actions right ───────────────────────────── */
|
||||
body.mobile-shell #title-bar {
|
||||
top: calc(var(--mobile-top-inset) + 12px);
|
||||
top: calc(var(--mobile-top-inset) + 10px);
|
||||
left: 14px;
|
||||
}
|
||||
|
||||
body.mobile-shell #title-bar .title-logo {
|
||||
width: 58px;
|
||||
}
|
||||
|
||||
body.mobile-shell #title-bar h1 > :not(.title-logo),
|
||||
body.mobile-shell #title-bar .subtitle {
|
||||
display: none;
|
||||
|
||||
@@ -729,7 +729,7 @@ const schemas = [
|
||||
properties: {
|
||||
motion: {
|
||||
type: 'string',
|
||||
enum: ['orbit', 'pan', 'tilt', 'rotate', 'stop'],
|
||||
enum: ['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe', 'stop'],
|
||||
},
|
||||
direction: {
|
||||
type: 'string',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { realtimeInstructions } from '../../server/providers/openai/instructions.js';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
@@ -38,7 +39,10 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea
|
||||
// requested radius and center authority in descriptions only.
|
||||
// Street Level and GNSS extend the manifest; tracked and analyst aircraft describe broadcasts.
|
||||
// Air quality (ECCC AQHI) extends the manifest.
|
||||
'775ba323384b4c1b62fb8415bac52aecc3fc668e20bedcfa57e426dcd1da3969',
|
||||
// Globe rotation adds one motion; visual keyhole fit and explicit
|
||||
// reset guidance/defaults update only move_camera and mode descriptions.
|
||||
// Restore base descriptions; append only globe-rotation defaults and fit guidance.
|
||||
'967b5a043b812e2f8c1e190fc571cf12b960c9d4486df736902221aab4d99cf6',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -106,6 +110,17 @@ test('all legacy action arguments are byte-identical after removing the delibera
|
||||
delete property('show_data_layers_menu').layerId.enum;
|
||||
delete property('get_entity_context').layerId.enum;
|
||||
delete property('analyst_query').layers.items.enum;
|
||||
// Earth-centered rotation is additive; local camera yaw keeps its contract.
|
||||
const motion = property('move_camera').motion;
|
||||
assert.deepEqual(motion.enum, [
|
||||
'orbit',
|
||||
'pan',
|
||||
'tilt',
|
||||
'rotate',
|
||||
'rotate_globe',
|
||||
'stop',
|
||||
]);
|
||||
motion.enum = motion.enum.filter((value) => value !== 'rotate_globe');
|
||||
const panels = property('set_panel_open').panelId;
|
||||
panels.enum = panels.enum.filter(
|
||||
(id) => !['weather-panel', 'recent-imagery-panel'].includes(id),
|
||||
@@ -145,6 +160,24 @@ test('all legacy action arguments are byte-identical after removing the delibera
|
||||
);
|
||||
});
|
||||
|
||||
test('globe rotation is an additive camera motion with the existing control arguments', () => {
|
||||
const schema = GEV_ACTION_SCHEMAS.find(
|
||||
(tool) => tool.name === 'move_camera',
|
||||
).parameters;
|
||||
for (const motion of ['rotate_globe', 'rotate', 'orbit', 'pan', 'tilt']) {
|
||||
assert.deepEqual(
|
||||
validateArgs(schema, {
|
||||
motion,
|
||||
direction: 'right',
|
||||
speed: 'slow',
|
||||
mode: 'continuous',
|
||||
}),
|
||||
[],
|
||||
);
|
||||
}
|
||||
assert.ok(validateArgs(schema, { motion: 'spin_camera' }).length > 0);
|
||||
});
|
||||
|
||||
test('track_entity accepts a referent alone but rejects an empty target', () => {
|
||||
const schema = GEV_ACTION_SCHEMAS.find(
|
||||
(tool) => tool.name === 'track_entity',
|
||||
@@ -183,3 +216,15 @@ test('the benchmark still rejects an empty track_entity although the model-facin
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('globe reset guidance requires a separate explicit command after refusal', () => {
|
||||
const paragraph = realtimeInstructions()
|
||||
.split('\n')
|
||||
.find((line) => line.includes('globe-view-required'));
|
||||
assert.ok(paragraph);
|
||||
assert.match(paragraph, /calls only move_camera motion rotate_globe/);
|
||||
assert.match(paragraph, /explicit user Reset\/Show Globe View/);
|
||||
assert.match(paragraph, /never from your own plan/);
|
||||
assert.match(paragraph, /bare yes\/ok/);
|
||||
assert.match(paragraph, /Do not ask a question/);
|
||||
});
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
shouldSendViewportImage,
|
||||
shouldStopVoiceAfterRadioTool,
|
||||
responseInstructionForToolResult,
|
||||
isGlobeViewRefusal,
|
||||
} from './realtimeProtocol.js';
|
||||
|
||||
const SOCKET_PATH =
|
||||
@@ -182,6 +183,8 @@ export class GeminiSessionController {
|
||||
this.model = null;
|
||||
this.epoch = 0;
|
||||
this.turn = 0;
|
||||
this.globeToolsBlocked = false;
|
||||
this.globeResetFailed = false;
|
||||
this.inputGeneration = 0;
|
||||
this.turnAbort = new AbortController();
|
||||
this.disposed = false;
|
||||
@@ -659,6 +662,8 @@ export class GeminiSessionController {
|
||||
return;
|
||||
if (!pushToTalk || activityCaptureId === metadata.captureId) return;
|
||||
this.captureOwner.admitted = true;
|
||||
this.globeToolsBlocked = false;
|
||||
this.globeResetFailed = false;
|
||||
if (
|
||||
!this.interrupt('push-to-talk', {
|
||||
keepPlayback: !this.pttPlaybackSuppressed,
|
||||
@@ -1294,6 +1299,8 @@ export class GeminiSessionController {
|
||||
this.replyPending = false;
|
||||
if (!this.interrupt()) return;
|
||||
} else if (!this.retiredReplyPending && !this.interrupt()) return;
|
||||
this.globeToolsBlocked = false;
|
||||
this.globeResetFailed = false;
|
||||
// Local activity ownership is only for expiry liveness, not tools.
|
||||
// Assign after retiring observed output so its boundary cannot clear
|
||||
// a newer detected activity that has not received a reply yet.
|
||||
@@ -1573,6 +1580,7 @@ export class GeminiSessionController {
|
||||
.then(async () => {
|
||||
if (!this.owns(epoch) || turn !== this.turn) return;
|
||||
if (this.cancelledCalls.has(call.id)) {
|
||||
if (call.name === 'zoom_to_globe') this.globeResetFailed = true;
|
||||
if (this.tools.get(call.id) === action) this.tools.delete(call.id);
|
||||
this.pendingTools--;
|
||||
if (!this.pendingTools) {
|
||||
@@ -1618,8 +1626,28 @@ export class GeminiSessionController {
|
||||
(authority !== 'playback' ||
|
||||
handoffEpoch === this.radio.radioHandoffEpoch);
|
||||
let result;
|
||||
let skippedGlobeRotation = false;
|
||||
let blockedGlobeTool = false;
|
||||
try {
|
||||
if (!actionCurrent()) throw cancelled();
|
||||
// Live has no response-scoped tool_choice. Recheck here so queued siblings
|
||||
// cannot reset after a rotation refusal.
|
||||
if (this.globeToolsBlocked) {
|
||||
blockedGlobeTool = true;
|
||||
throw new Error(
|
||||
'Wait for fresh user input before calling another tool.',
|
||||
);
|
||||
}
|
||||
if (
|
||||
this.globeResetFailed &&
|
||||
call.name === 'move_camera' &&
|
||||
String(args.motion || '').toLowerCase() === 'rotate_globe'
|
||||
) {
|
||||
skippedGlobeRotation = true;
|
||||
throw new Error(
|
||||
'Not run: the Globe View reset/rotation sequence failed or was cancelled.',
|
||||
);
|
||||
}
|
||||
if (stronger)
|
||||
reservation = this.radio.reserveRadioToolHandoff({ abortScope });
|
||||
handoffEpoch = this.radio.radioHandoffEpoch;
|
||||
@@ -1689,7 +1717,12 @@ export class GeminiSessionController {
|
||||
} catch (error) {
|
||||
result = {
|
||||
ok: false,
|
||||
tool: call.name,
|
||||
...(skippedGlobeRotation
|
||||
? { action: call.name, reason: 'globe-reset-failed', skipped: true }
|
||||
: {
|
||||
tool: call.name,
|
||||
...(blockedGlobeTool ? { skipped: true } : {}),
|
||||
}),
|
||||
...(radioVisibility ? { layerId: 'radio' } : {}),
|
||||
error:
|
||||
error?.name === 'AbortError'
|
||||
@@ -1716,6 +1749,11 @@ export class GeminiSessionController {
|
||||
this.noteRadioStrongerCommit(action, abortScope, turn);
|
||||
}
|
||||
if (!this.owns(epoch) || turn !== this.turn) return;
|
||||
if (
|
||||
call.name === 'zoom_to_globe' &&
|
||||
(result?.ok !== true || result.cancelled)
|
||||
)
|
||||
this.globeResetFailed = true;
|
||||
const playbackObsolete =
|
||||
authority === 'playback' && handoffEpoch !== this.radio.radioHandoffEpoch;
|
||||
if (radioControl || radioVisibility) {
|
||||
@@ -1744,6 +1782,8 @@ export class GeminiSessionController {
|
||||
this.stopAfterReply = true;
|
||||
// Native call cancellation suppresses its output, without canceling siblings.
|
||||
if (!this.cancelledCalls.has(call.id)) {
|
||||
if (isGlobeViewRefusal(result) && !skippedGlobeRotation)
|
||||
this.globeToolsBlocked = true;
|
||||
this.postToolOutput = false;
|
||||
this.modelComplete = false;
|
||||
this.replyPending = true;
|
||||
@@ -1751,7 +1791,9 @@ export class GeminiSessionController {
|
||||
const mapEvents = mapSnapshot.map((text) => JSON.parse(text));
|
||||
const response = {
|
||||
...(result ?? { ok: false, error: 'Command returned no result' }),
|
||||
responseGuidance: responseInstructionForToolResult(result),
|
||||
responseGuidance: blockedGlobeTool
|
||||
? 'That action did not run. Wait for fresh user input; do not claim any change or call another tool.'
|
||||
: responseInstructionForToolResult(result),
|
||||
};
|
||||
this.send(
|
||||
{
|
||||
@@ -1902,6 +1944,8 @@ export class GeminiSessionController {
|
||||
{
|
||||
onSent: () => {
|
||||
if (!owned() || this.typedIntent !== typedIntent) return;
|
||||
this.globeToolsBlocked = false;
|
||||
this.globeResetFailed = false;
|
||||
this.replyPending = true;
|
||||
this.bindTextIntent(typedIntent);
|
||||
if (!owned() || this.typedIntent !== typedIntent) return;
|
||||
@@ -2455,6 +2499,8 @@ export class GeminiSessionController {
|
||||
this.cancelledCalls.clear();
|
||||
this.pendingMapEvents = [];
|
||||
this.userTurnPending = false;
|
||||
this.globeToolsBlocked = false;
|
||||
this.globeResetFailed = false;
|
||||
this.replyPending = false;
|
||||
this.unrequestedReply = false;
|
||||
this.automaticTurnOpen = false;
|
||||
|
||||
@@ -3398,3 +3398,215 @@ test('a narration callback that submits a successor keeps the old packet tools f
|
||||
assert.deepEqual(h.calls, []);
|
||||
h.c.stop();
|
||||
});
|
||||
|
||||
for (const reply of ['typed', 'push-to-talk', 'open-mic']) {
|
||||
test(`globe refusal blocks queued and later tools until fresh ${reply} input`, async () => {
|
||||
const names = [];
|
||||
const h = harness({
|
||||
runAction: async (name) => {
|
||||
names.push(name);
|
||||
return name === 'move_camera'
|
||||
? { ok: false, action: 'move_camera', reason: 'globe-view-required' }
|
||||
: { ok: true, action: name };
|
||||
},
|
||||
});
|
||||
try {
|
||||
await h.c.start({ pushToTalk: reply === 'push-to-talk' });
|
||||
establishToolInput(h, 'rotate the globe');
|
||||
const ws = h.sockets[0];
|
||||
ws.message({
|
||||
toolCall: {
|
||||
functionCalls: [
|
||||
{
|
||||
id: 'rotate',
|
||||
name: 'move_camera',
|
||||
args: { motion: 'rotate_globe' },
|
||||
},
|
||||
{ id: 'queued-reset', name: 'zoom_to_globe', args: {} },
|
||||
],
|
||||
},
|
||||
});
|
||||
await h.c.toolQueue;
|
||||
assert.deepEqual(names, ['move_camera']);
|
||||
const blocked = ws.sent
|
||||
.flatMap((entry) => entry.toolResponse?.functionResponses || [])
|
||||
.find((call) => call.id === 'queued-reset');
|
||||
assert.equal(blocked.response.ok, false);
|
||||
assert.match(blocked.response.error, /fresh user input/i);
|
||||
assert.equal(blocked.response.skipped, true);
|
||||
assert.match(blocked.response.responseGuidance, /did not run/i);
|
||||
assert.doesNotMatch(
|
||||
blocked.response.responseGuidance,
|
||||
/confirm.*completed/i,
|
||||
);
|
||||
requestTool(ws, 'repeated-rotation', 'move_camera', {
|
||||
motion: 'rotate_globe',
|
||||
});
|
||||
await h.c.toolQueue;
|
||||
assert.deepEqual(names, ['move_camera']);
|
||||
const repeated = toolResponses(ws).find(
|
||||
(call) => call.id === 'repeated-rotation',
|
||||
).response;
|
||||
assert.equal(repeated.ok, false);
|
||||
assert.equal(repeated.skipped, true);
|
||||
assert.match(repeated.responseGuidance, /did not run/i);
|
||||
assert.doesNotMatch(
|
||||
repeated.responseGuidance,
|
||||
/confirm.*completed/i,
|
||||
);
|
||||
|
||||
if (reply === 'typed') {
|
||||
const stimuli = [
|
||||
() => {},
|
||||
() => ws.message({ serverContent: { interrupted: true } }),
|
||||
() =>
|
||||
ws.message({
|
||||
serverContent: {
|
||||
inputTranscription: { text: 'reset to globe view' },
|
||||
},
|
||||
}),
|
||||
() => {
|
||||
h.c.input.setMicrophoneEnabled(false);
|
||||
h.c.input.setMicrophoneEnabled(true);
|
||||
},
|
||||
];
|
||||
for (const [i, stimulus] of stimuli.entries()) {
|
||||
stimulus();
|
||||
requestTool(ws, `unrequested-reset-${i}`, 'zoom_to_globe', {});
|
||||
await h.c.toolQueue;
|
||||
assert.deepEqual(names, ['move_camera']);
|
||||
}
|
||||
}
|
||||
// Finish the refusal reply before admitting the actual next user input.
|
||||
ws.message({ serverContent: { turnComplete: true } });
|
||||
if (reply === 'typed')
|
||||
assert.equal(h.c.sendTextCommand('reset to globe view'), true);
|
||||
else if (reply === 'push-to-talk') {
|
||||
h.c.input.pushToTalkKeyHeld = true;
|
||||
h.c.input.spaceKeyHeld = true;
|
||||
h.c.input.setMicrophoneEnabled(true);
|
||||
h.c.input.setMicrophoneEnabled(false);
|
||||
} else ws.message({ voiceActivity: { type: 'ACTIVITY_START' } });
|
||||
requestTool(ws, 'accepted-reset', 'zoom_to_globe', {});
|
||||
await h.c.toolQueue;
|
||||
assert.deepEqual(names, ['move_camera', 'zoom_to_globe']);
|
||||
} finally {
|
||||
h.c.stop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('a cancelled globe reset skips queued rotation, preserves Stop and recovers on fresh input', async () => {
|
||||
const calls = [];
|
||||
let resets = 0;
|
||||
const h = harness({
|
||||
runAction: async (name, args) => {
|
||||
calls.push(name === 'move_camera' ? args.motion : name);
|
||||
return name === 'zoom_to_globe' && resets++ === 0
|
||||
? { ok: false, action: name, cancelled: true }
|
||||
: { ok: true, action: name };
|
||||
},
|
||||
});
|
||||
try {
|
||||
await h.c.start();
|
||||
establishToolInput(h, 'reset to globe view and rotate');
|
||||
const ws = h.sockets[0];
|
||||
ws.message({
|
||||
toolCall: {
|
||||
functionCalls: [
|
||||
{ id: 'reset-failed', name: 'zoom_to_globe', args: {} },
|
||||
{
|
||||
id: 'rotate-skipped',
|
||||
name: 'move_camera',
|
||||
args: { motion: 'rotate_globe' },
|
||||
},
|
||||
{ id: 'stop-allowed', name: 'move_camera', args: { motion: 'stop' } },
|
||||
],
|
||||
},
|
||||
});
|
||||
await h.c.toolQueue;
|
||||
assert.deepEqual(calls, ['zoom_to_globe', 'stop']);
|
||||
const skipped = toolResponses(ws).find(
|
||||
(call) => call.id === 'rotate-skipped',
|
||||
).response;
|
||||
assert.equal(skipped.ok, false);
|
||||
assert.equal(skipped.reason, 'globe-reset-failed');
|
||||
assert.equal(skipped.skipped, true);
|
||||
assert.equal(
|
||||
toolResponses(ws).find((call) => call.id === 'stop-allowed').response.ok,
|
||||
true,
|
||||
);
|
||||
|
||||
ws.message({ serverContent: { turnComplete: true } });
|
||||
establishToolInput(h, 'reset to globe view and rotate');
|
||||
ws.message({
|
||||
toolCall: {
|
||||
functionCalls: [
|
||||
{ id: 'reset-success', name: 'zoom_to_globe', args: {} },
|
||||
{
|
||||
id: 'rotate-success',
|
||||
name: 'move_camera',
|
||||
args: { motion: 'rotate_globe' },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await h.c.toolQueue;
|
||||
assert.deepEqual(calls, [
|
||||
'zoom_to_globe',
|
||||
'stop',
|
||||
'zoom_to_globe',
|
||||
'rotate_globe',
|
||||
]);
|
||||
} finally {
|
||||
h.c.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('cancelling a queued globe reset also skips its dependent rotation', async () => {
|
||||
const blocker = deferred();
|
||||
const calls = [];
|
||||
const h = harness({
|
||||
runAction: async (name, args) => {
|
||||
calls.push(name === 'move_camera' ? args.motion : name);
|
||||
return args.motion === 'pan'
|
||||
? blocker.promise
|
||||
: { ok: true, action: name };
|
||||
},
|
||||
});
|
||||
try {
|
||||
await h.c.start();
|
||||
establishToolInput(h, 'pan, reset to globe view and rotate');
|
||||
const ws = h.sockets[0];
|
||||
ws.message({
|
||||
toolCall: {
|
||||
functionCalls: [
|
||||
{
|
||||
id: 'blocker',
|
||||
name: 'move_camera',
|
||||
args: { motion: 'pan', direction: 'left' },
|
||||
},
|
||||
{ id: 'reset-cancelled', name: 'zoom_to_globe', args: {} },
|
||||
{
|
||||
id: 'rotate-after-cancel',
|
||||
name: 'move_camera',
|
||||
args: { motion: 'rotate_globe' },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await tick();
|
||||
ws.message({ toolCallCancellation: { ids: ['reset-cancelled'] } });
|
||||
blocker.resolve({ ok: true, action: 'move_camera' });
|
||||
await h.c.toolQueue;
|
||||
assert.deepEqual(calls, ['pan']);
|
||||
const skipped = toolResponses(ws).find(
|
||||
(call) => call.id === 'rotate-after-cancel',
|
||||
).response;
|
||||
assert.equal(skipped.reason, 'globe-reset-failed');
|
||||
assert.equal(skipped.skipped, true);
|
||||
} finally {
|
||||
blocker.resolve({ ok: true });
|
||||
h.c.stop();
|
||||
}
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { interruptGlobeRotation } from '../cameraVerbs.js';
|
||||
import {
|
||||
layerSnapshot,
|
||||
feedProvenanceEnvelope,
|
||||
@@ -262,7 +263,9 @@ export function createGevActionRunner({
|
||||
}),
|
||||
));
|
||||
installViewTargetPrewarm(viewer);
|
||||
initCameraVerbs(viewer, getViewTargetCartesian);
|
||||
initCameraVerbs(viewer, getViewTargetCartesian, {
|
||||
isCockpitActive: () => Boolean(styleManager?.getCockpitState?.()?.active),
|
||||
});
|
||||
// Nested calls (select_nearest_aircraft composing layer + flight) use the
|
||||
// raw runner; only the outer call gets a voice envelope.
|
||||
const runGevAction = async function runGevAction(
|
||||
@@ -298,6 +301,7 @@ export function createGevActionRunner({
|
||||
// Zoom during an active orbit adjusts the orbit RADIUS (spiral in/out) —
|
||||
// a straight camera move would be snapped back by the per-frame lookAt.
|
||||
if (name === 'adjust_camera_zoom') {
|
||||
interruptGlobeRotation('voice-zoom-intent');
|
||||
const zoomOut = String(args.direction || '').toLowerCase() === 'out';
|
||||
const amt = String(args.amount || 'medium').toLowerCase();
|
||||
const factor = { little: 1.25, medium: 1.6, lot: 2.4 }[amt] || 1.6;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -99,6 +99,18 @@ export function hasStructuredViewIdentity(result) {
|
||||
);
|
||||
}
|
||||
|
||||
export function isGlobeViewRefusal(result) {
|
||||
return (
|
||||
result?.action === 'move_camera' &&
|
||||
result.ok === false &&
|
||||
[
|
||||
'globe-view-required',
|
||||
'globe-reset-unavailable',
|
||||
'globe-reset-failed',
|
||||
].includes(result.reason)
|
||||
);
|
||||
}
|
||||
|
||||
export function responseInstructionForToolResult(result) {
|
||||
if (result?.action === 'control_radio' && result.radioPlaybackSuppressed) {
|
||||
if (result.audioState === 'paused') {
|
||||
@@ -123,6 +135,20 @@ export function responseInstructionForToolResult(result) {
|
||||
: 'Be concise: useful names, place, layer and one or two notable properties.',
|
||||
].join(' ');
|
||||
}
|
||||
if (isGlobeViewRefusal(result)) {
|
||||
// A refusal is not a completed action. Keep tool text out of instructions.
|
||||
if (result.reason === 'globe-reset-failed')
|
||||
return 'The globe did not rotate after the Globe View reset attempt. Say so briefly. Do not retry, offer or perform another reset for this request.';
|
||||
if (result.reason === 'globe-reset-unavailable')
|
||||
return 'The globe did not rotate. Ask the user to zoom out further; the Globe View preset does not fit this viewport. Do not offer or perform a reset.';
|
||||
return 'The globe did not rotate. Say "Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’" The suggested command is not a user request. Do not ask a question, perform a reset, or call tools in this reply.';
|
||||
}
|
||||
if (
|
||||
(result?.action ?? result?.tool) === 'zoom_to_globe' &&
|
||||
(result.ok !== true || result.cancelled)
|
||||
) {
|
||||
return 'The Globe View reset did not finish. Say so briefly; do not claim Globe View was reached. Do not retry the reset or rotate the globe for this request.';
|
||||
}
|
||||
if (result?.action === 'adjust_camera_zoom') {
|
||||
return result.ok
|
||||
? `Confirm once that the camera zoomed ${result.direction}. Do not claim any other change.`
|
||||
|
||||
+229
-10
@@ -6,6 +6,7 @@ import {
|
||||
SUPERSEDED_RESPONSE_MEMORY,
|
||||
shouldStopVoiceAfterRadioTool,
|
||||
responseInstructionForToolResult,
|
||||
isGlobeViewRefusal,
|
||||
extractFunctionCalls,
|
||||
callDedupeKeys,
|
||||
parseArguments,
|
||||
@@ -66,6 +67,11 @@ export class RealtimeTurns {
|
||||
this.pendingUserTextResponse = false;
|
||||
this.activeResponseId = null;
|
||||
this.supersededResponseIds = new Set();
|
||||
this.globeRefusals = new Map();
|
||||
this.globeAdmissionGates = new Map();
|
||||
this.globeResets = new Map();
|
||||
this.globeResponseRequests = new Map();
|
||||
this.globeRequest = { failed: false, pending: new Set() };
|
||||
this.activeToolAbortControllers = new Set();
|
||||
this.narration = null;
|
||||
this.narrationSequence = 0;
|
||||
@@ -138,6 +144,7 @@ export class RealtimeTurns {
|
||||
},
|
||||
};
|
||||
if (!this.sendRealtimeEvent(itemEvent, 'client.user_text')) return;
|
||||
this.globeRequest = { failed: false, pending: new Set() };
|
||||
this.requestUserTextResponse();
|
||||
}
|
||||
|
||||
@@ -330,6 +337,7 @@ export class RealtimeTurns {
|
||||
this.userTurnPending = true;
|
||||
this.pendingResponseInstructions = null;
|
||||
this.cancelRadioHandoff({ abortTools: true });
|
||||
this.globeRequest = { failed: false, pending: new Set() };
|
||||
this.setVoiceSpeaker('user');
|
||||
// Point-and-ask: read the pointer as speech starts (open mic), or reuse
|
||||
// the push-to-talk keydown snapshot, before the user audio commits.
|
||||
@@ -384,6 +392,27 @@ export class RealtimeTurns {
|
||||
}
|
||||
|
||||
const toolResponseId = payload.response_id || payload.response?.id || null;
|
||||
// Follow-up responses retain reset ownership until fresh admitted input.
|
||||
const arrivalRequest = this.globeRequest;
|
||||
const globeRequest =
|
||||
this.globeResponseRequests.get(toolResponseId) ?? arrivalRequest;
|
||||
// Later siblings must wait for globe admission before they can mutate state.
|
||||
// Other tool responses keep their existing concurrency.
|
||||
let heldForAdmission = false;
|
||||
while (toolResponseId && this.globeAdmissionGates.has(toolResponseId)) {
|
||||
// Drop claimed duplicates before every wait, before their keys can expire.
|
||||
if (
|
||||
calls.every((call) =>
|
||||
callDedupeKeys(call).some((key) => this.processedCalls.has(key)),
|
||||
)
|
||||
)
|
||||
return;
|
||||
heldForAdmission = true;
|
||||
await this.globeAdmissionGates.get(toolResponseId).promise;
|
||||
if (!this.ownsConversation(eventChannel) || this.isSessionEnding())
|
||||
return;
|
||||
}
|
||||
|
||||
// A newer typed command superseded the response these calls belong to.
|
||||
// They are stale intent — dispatching one would let the old turn mutate
|
||||
// the map after the operator asked for something else.
|
||||
@@ -394,7 +423,10 @@ export class RealtimeTurns {
|
||||
// hazard `callDedupeKeys` is written to avoid). So each refused call gets
|
||||
// a terminal output saying plainly that the turn moved on. No
|
||||
// `response.create` follows — the deferred typed turn is the single answer.
|
||||
if (this.isSupersededResponse(toolResponseId)) {
|
||||
if (
|
||||
this.isSupersededResponse(toolResponseId) ||
|
||||
(heldForAdmission && arrivalRequest !== this.globeRequest)
|
||||
) {
|
||||
this.pruneProcessedCalls();
|
||||
for (const call of calls) {
|
||||
const keys = callDedupeKeys(call);
|
||||
@@ -429,7 +461,19 @@ export class RealtimeTurns {
|
||||
if (keys.some((key) => this.processedCalls.has(key))) continue;
|
||||
keys.forEach((key) => this.processedCalls.set(key, performance.now()));
|
||||
|
||||
if (this.globeRefusals.get(toolResponseId)?.blocking) {
|
||||
this.sendToolOutput(call.call_id || call.id, {
|
||||
ok: false,
|
||||
action: call.name,
|
||||
skipped: true,
|
||||
error: 'Not run: this response must wait for the Globe View reply.',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let result;
|
||||
let globeAdmissionGate = null;
|
||||
let globeResetGate = null;
|
||||
const resultChannel = this.dc;
|
||||
let radioHandoffEpochAtStart = this.radio.radioHandoffEpoch;
|
||||
let toolController = null;
|
||||
@@ -443,6 +487,80 @@ export class RealtimeTurns {
|
||||
this.narration?.preamble();
|
||||
try {
|
||||
const parsedArguments = parseArguments(call.arguments);
|
||||
if (
|
||||
toolResponseId &&
|
||||
call.name === 'move_camera' &&
|
||||
String(parsedArguments.motion || '').toLowerCase() === 'rotate_globe'
|
||||
) {
|
||||
let resolve;
|
||||
const promise = new Promise((settle) => {
|
||||
resolve = settle;
|
||||
});
|
||||
globeAdmissionGate = { promise, resolve };
|
||||
this.globeAdmissionGates.set(toolResponseId, globeAdmissionGate);
|
||||
// OpenAI delivers sibling calls concurrently. A reset dispatched
|
||||
// first must finish before rotation evaluates the camera's fit.
|
||||
// Hold later siblings at admission while this rotation waits.
|
||||
const resets = this.globeResets.get(toolResponseId);
|
||||
if (
|
||||
resets ||
|
||||
globeRequest.failed ||
|
||||
globeRequest.pending.size ||
|
||||
arrivalRequest !== this.globeRequest
|
||||
) {
|
||||
while (resets?.pending.size || globeRequest.pending.size)
|
||||
await Promise.all(
|
||||
[
|
||||
...new Set([
|
||||
...(resets?.pending || []),
|
||||
...globeRequest.pending,
|
||||
]),
|
||||
].map((gate) => gate.promise),
|
||||
);
|
||||
if (!this.ownsConversation(eventChannel) || this.isSessionEnding())
|
||||
return;
|
||||
keys.forEach((key) =>
|
||||
this.processedCalls.set(key, performance.now()),
|
||||
);
|
||||
const superseded =
|
||||
this.isSupersededResponse(toolResponseId) ||
|
||||
arrivalRequest !== this.globeRequest;
|
||||
if (superseded || resets?.failed || globeRequest.failed) {
|
||||
result = {
|
||||
ok: false,
|
||||
action: call.name,
|
||||
reason: 'globe-reset-failed',
|
||||
skipped: true,
|
||||
...(superseded ? { superseded: true } : {}),
|
||||
error: superseded
|
||||
? 'Superseded by a newer command from the operator — this call was not run.'
|
||||
: 'Not run: the Globe View reset/rotation sequence failed or was cancelled.',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
if (toolResponseId && call.name === 'zoom_to_globe') {
|
||||
let resolve;
|
||||
const promise = new Promise((settle) => {
|
||||
resolve = settle;
|
||||
});
|
||||
let resets = this.globeResets.get(toolResponseId);
|
||||
if (!resets) {
|
||||
resets = { pending: new Set(), failed: false };
|
||||
this.globeResets.set(toolResponseId, resets);
|
||||
// Retain settled outcomes for later siblings, without evicting an
|
||||
// active reset that another response still needs to await.
|
||||
for (const [id, record] of this.globeResets) {
|
||||
if (this.globeResets.size <= SUPERSEDED_RESPONSE_MEMORY) break;
|
||||
if (!record.pending.size && id !== toolResponseId)
|
||||
this.globeResets.delete(id);
|
||||
}
|
||||
}
|
||||
globeResetGate = { promise, resolve, resets, request: globeRequest };
|
||||
resets.pending.add(globeResetGate);
|
||||
globeRequest.pending.add(globeResetGate);
|
||||
}
|
||||
|
||||
const isRadioControlCall = call.name === 'control_radio';
|
||||
const isRadioVisibilityCall =
|
||||
call.name === 'set_layer_visibility' &&
|
||||
@@ -499,7 +617,7 @@ export class RealtimeTurns {
|
||||
authorityDomain: radioAuthorityDomain,
|
||||
});
|
||||
}
|
||||
result = await (this.actionExecutor || this.runner)(
|
||||
result ??= await (this.actionExecutor || this.runner)(
|
||||
call.name,
|
||||
parsedArguments,
|
||||
{
|
||||
@@ -515,7 +633,28 @@ export class RealtimeTurns {
|
||||
radioHandoffEpochAtStart === this.radio.radioHandoffEpoch),
|
||||
},
|
||||
);
|
||||
if (
|
||||
globeAdmissionGate &&
|
||||
this.globeResets.has(toolResponseId) &&
|
||||
result?.reason === 'globe-view-required'
|
||||
)
|
||||
result = { ...result, reason: 'globe-reset-failed' };
|
||||
if (!this.ownsConversation(eventChannel)) return;
|
||||
if (
|
||||
isGlobeViewRefusal(result) &&
|
||||
toolResponseId &&
|
||||
arrivalRequest === this.globeRequest &&
|
||||
!this.isSupersededResponse(toolResponseId)
|
||||
) {
|
||||
this.globeRefusals.set(toolResponseId, {
|
||||
result,
|
||||
followupQueued: false,
|
||||
blocking: !result.skipped,
|
||||
});
|
||||
while (this.globeRefusals.size > SUPERSEDED_RESPONSE_MEMORY) {
|
||||
this.globeRefusals.delete(this.globeRefusals.keys().next().value);
|
||||
}
|
||||
}
|
||||
if (result?.ok && result.radioPlaybackRequested) {
|
||||
const sessionIsCurrent =
|
||||
this.activeToolAbortControllers.has(toolController) &&
|
||||
@@ -595,6 +734,23 @@ export class RealtimeTurns {
|
||||
: {}),
|
||||
};
|
||||
} finally {
|
||||
if (globeResetGate) {
|
||||
const { resets, request } = globeResetGate;
|
||||
if (result?.ok !== true || result.cancelled) {
|
||||
resets.failed = true;
|
||||
request.failed = true;
|
||||
}
|
||||
resets.pending.delete(globeResetGate);
|
||||
request.pending.delete(globeResetGate);
|
||||
globeResetGate.resolve();
|
||||
}
|
||||
if (globeAdmissionGate) {
|
||||
if (
|
||||
this.globeAdmissionGates.get(toolResponseId) === globeAdmissionGate
|
||||
)
|
||||
this.globeAdmissionGates.delete(toolResponseId);
|
||||
globeAdmissionGate.resolve();
|
||||
}
|
||||
if (toolController) {
|
||||
this.activeToolAbortControllers.delete(toolController);
|
||||
this.radio.releaseTool(toolController);
|
||||
@@ -671,13 +827,38 @@ export class RealtimeTurns {
|
||||
});
|
||||
}
|
||||
if (!this.ownsConversation(eventChannel)) return;
|
||||
// Keep the Radio handoff wording authoritative even when another tool
|
||||
// result follows Radio in the same multi-intent response.
|
||||
this.queueResponseCreate(
|
||||
responseInstructionForToolResult(
|
||||
this.radio.pendingRadioPlaybackResult || lastResult,
|
||||
),
|
||||
);
|
||||
const refusal = this.globeRefusals.get(toolResponseId);
|
||||
// A newer turn may arrive during admission or the viewport-image await.
|
||||
// Preserve its reply; neither the refusal map nor lastResult may revive
|
||||
// an old refusal follow-up after supersession.
|
||||
if (
|
||||
(this.isSupersededResponse(toolResponseId) ||
|
||||
arrivalRequest !== this.globeRequest) &&
|
||||
(refusal || isGlobeViewRefusal(lastResult) || lastResult?.superseded)
|
||||
) {
|
||||
// Complete an idle turn below, but leave a newer running tool's status
|
||||
// authoritative while suppressing this obsolete refusal follow-up.
|
||||
if (this.activeToolAbortControllers.size) return;
|
||||
} else if (refusal) {
|
||||
// Even an earlier sibling finishing late cannot replace the reset instruction.
|
||||
if (!refusal.followupQueued) {
|
||||
refusal.followupQueued = true;
|
||||
this.queueResponseCreate({
|
||||
instructions: responseInstructionForToolResult(refusal.result),
|
||||
tool_choice: 'none',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Keep Radio handoff wording authoritative for ordinary multi-tool replies.
|
||||
const followupResult =
|
||||
this.radio.pendingRadioPlaybackResult || lastResult;
|
||||
const instructions = responseInstructionForToolResult(followupResult);
|
||||
this.queueResponseCreate(
|
||||
isGlobeViewRefusal(followupResult)
|
||||
? { instructions, tool_choice: 'none' }
|
||||
: instructions,
|
||||
);
|
||||
}
|
||||
}
|
||||
this.setStatus('listening', t('Ask or command'));
|
||||
}
|
||||
@@ -966,6 +1147,22 @@ export class RealtimeTurns {
|
||||
this.userTurnPending = false;
|
||||
this.activeResponseId =
|
||||
payload.response?.id || payload.response_id || null;
|
||||
// Late calls retain only their globe reset/request ownership. Ordinary
|
||||
// tool admission and concurrency continue to use their existing policy.
|
||||
if (
|
||||
this.activeResponseId &&
|
||||
!this.globeResponseRequests.has(this.activeResponseId)
|
||||
) {
|
||||
this.globeResponseRequests.set(
|
||||
this.activeResponseId,
|
||||
this.globeRequest,
|
||||
);
|
||||
while (this.globeResponseRequests.size > SUPERSEDED_RESPONSE_MEMORY)
|
||||
this.globeResponseRequests.delete(
|
||||
this.globeResponseRequests.keys().next().value,
|
||||
);
|
||||
}
|
||||
|
||||
this.setVoiceSpeaker('ai');
|
||||
return;
|
||||
}
|
||||
@@ -1045,7 +1242,8 @@ export class RealtimeTurns {
|
||||
const sent = this.sendRealtimeEvent(
|
||||
{
|
||||
type: 'response.create',
|
||||
response: { instructions },
|
||||
response:
|
||||
typeof instructions === 'string' ? { instructions } : instructions,
|
||||
},
|
||||
'client.response_create.tool_followup',
|
||||
);
|
||||
@@ -1057,6 +1255,20 @@ export class RealtimeTurns {
|
||||
controller.abort();
|
||||
this.activeToolAbortControllers.clear();
|
||||
this.radio.clearTools();
|
||||
this.releaseGlobeResets();
|
||||
}
|
||||
|
||||
releaseGlobeResets() {
|
||||
for (const resets of this.globeResets.values()) {
|
||||
if (!resets.pending.size) continue;
|
||||
resets.failed = true;
|
||||
for (const gate of resets.pending) {
|
||||
gate.request.failed = true;
|
||||
gate.request.pending.delete(gate);
|
||||
gate.resolve();
|
||||
}
|
||||
resets.pending.clear();
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
@@ -1068,6 +1280,13 @@ export class RealtimeTurns {
|
||||
this.pendingUserTextResponse = false;
|
||||
this.activeResponseId = null;
|
||||
this.supersededResponseIds.clear();
|
||||
this.globeRefusals.clear();
|
||||
for (const gate of this.globeAdmissionGates.values()) gate.resolve();
|
||||
this.globeAdmissionGates.clear();
|
||||
this.releaseGlobeResets();
|
||||
this.globeResets.clear();
|
||||
this.globeResponseRequests.clear();
|
||||
this.globeRequest = { failed: false, pending: new Set() };
|
||||
this.narration?.cancel();
|
||||
this.assistantAudioPlaying = false;
|
||||
this.speechResponseIds.clear();
|
||||
|
||||
Reference in new issue
Block a user