mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
fix(hud): drop a summary reply once the camera starts moving (#1384)
Discard stale HUD summaries when camera movement starts and allow cancelled requests to retry for unchanged place context. Contribution by @quanticsoul4772.
This commit is contained in:
1 parent
5b6c52437e
commit
496d024431
4 files changed
+382
-13
No files matched your search
@@ -0,0 +1,4 @@
|
|||||||
|
- The HUD's AI place summary drops a reply that arrives after the camera has
|
||||||
|
started moving, so a summary asked for before a flight no longer names the
|
||||||
|
old place mid-flight. Cancelled or partially typed summaries retry even if the place
|
||||||
|
context stays the same (quanticsoul4772, #1384).
|
||||||
@@ -5107,6 +5107,7 @@ and unreachable upstream (502/504) separately from road geometry.
|
|||||||
- HUD `SUMMARY` readout requests a five-word intelligence-style summary from `/api/openai/hud-summary` (model `OPENAI_HUD_SUMMARY_MODEL`, default `gpt-5-nano`, minimal reasoning).
|
- HUD `SUMMARY` readout requests a five-word intelligence-style summary from `/api/openai/hud-summary` (model `OPENAI_HUD_SUMMARY_MODEL`, default `gpt-5-nano`, minimal reasoning).
|
||||||
- Input is the live basemap label context (place/street/nearby-place labels + enabled layers) — the model is instructed not to infer from coordinates.
|
- Input is the live basemap label context (place/street/nearby-place labels + enabled layers) — the model is instructed not to infer from coordinates.
|
||||||
- Output is sanitized to exactly five words; falls back to the deterministic telemetry summary on error/timeout (5s abort); typewriter animation on update.
|
- Output is sanitized to exactly five words; falls back to the deterministic telemetry summary on error/timeout (5s abort); typewriter animation on update.
|
||||||
|
- A summary asked for before the camera starts moving never paints over the next view: `moveStart` marks the summary dirty and bumps its revision, as `moveEnd` does, aborts the request in flight and stops a reply still typing; neither a reply nor a fallback line (on an error, the 5 s timeout, the abort, or a failed context build) paints once the revision has moved. A dropped request forgets its signature and a timeout counts as a failure, so the next 15 s tick asks again even over the same place. An interrupted typewriter reply also forgets its signature; fully displayed answers remain reusable.
|
||||||
|
|
||||||
### Place-search providers
|
### Place-search providers
|
||||||
|
|
||||||
|
|||||||
+42
-5
@@ -224,6 +224,20 @@ export class IntelHUD {
|
|||||||
// session, so this flips once — and both altitude readouts have to move
|
// session, so this flips once — and both altitude readouts have to move
|
||||||
// together when it does (see the repaint in _updateCameraData).
|
// together when it does (see the repaint in _updateCameraData).
|
||||||
this._geoidCorrectionApplied = false;
|
this._geoidCorrectionApplied = false;
|
||||||
|
// A summary built for one view must not land on the next: a request in
|
||||||
|
// flight when the camera starts moving is aborted and nothing it ends
|
||||||
|
// with paints (the revision moves), a reply still typing stops, and the
|
||||||
|
// summary is marked dirty so the next periodic tick asks about the view
|
||||||
|
// the camera has moved to.
|
||||||
|
this._onCameraMoveStart = () => {
|
||||||
|
this._markSummaryDirty();
|
||||||
|
if (this._summaryRequest || this._summaryTypingInterval) {
|
||||||
|
// Cancelled requests and partially typed replies are not reusable answers.
|
||||||
|
this._lastSummarySignature = null;
|
||||||
|
}
|
||||||
|
this._summaryRequest?.abort();
|
||||||
|
this._stopSummaryTyping();
|
||||||
|
};
|
||||||
this._onCameraMoveEnd = () => {
|
this._onCameraMoveEnd = () => {
|
||||||
this._markSummaryDirty();
|
this._markSummaryDirty();
|
||||||
// The 250 ms telemetry timer and 15 s semantic-summary timer must not
|
// The 250 ms telemetry timer and 15 s semantic-summary timer must not
|
||||||
@@ -249,6 +263,7 @@ export class IntelHUD {
|
|||||||
this._passNum = 100 + Math.floor(Math.random() * 200);
|
this._passNum = 100 + Math.floor(Math.random() * 200);
|
||||||
|
|
||||||
this._buildDOM();
|
this._buildDOM();
|
||||||
|
this.viewer.camera.moveStart.addEventListener(this._onCameraMoveStart);
|
||||||
this.viewer.camera.moveEnd.addEventListener(this._onCameraMoveEnd);
|
this.viewer.camera.moveEnd.addEventListener(this._onCameraMoveEnd);
|
||||||
this._startTimers();
|
this._startTimers();
|
||||||
}
|
}
|
||||||
@@ -766,6 +781,12 @@ export class IntelHUD {
|
|||||||
return provenance ? `${line} | ${provenance}` : line;
|
return provenance ? `${line} | ${provenance}` : line;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Stop a summary still typing out, leaving what it has typed so far. */
|
||||||
|
_stopSummaryTyping() {
|
||||||
|
clearInterval(this._summaryTypingInterval);
|
||||||
|
this._summaryTypingInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Animate the summary text into the DOM using a typewriter effect
|
* Animate the summary text into the DOM using a typewriter effect
|
||||||
* (2 characters every 24ms).
|
* (2 characters every 24ms).
|
||||||
@@ -816,8 +837,10 @@ export class IntelHUD {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[HUD] summary context unavailable:', error);
|
console.warn('[HUD] summary context unavailable:', error);
|
||||||
// Left dirty on purpose: the next periodic tick retries instead of
|
// Left dirty on purpose: the next periodic tick retries instead of
|
||||||
// sticking on the fallback line for the rest of the session.
|
// sticking on the fallback line for the rest of the session. The
|
||||||
this._setSummaryText(fallbackText, animate);
|
// fallback was composed for this view; a moved camera keeps its own.
|
||||||
|
if (revision === this._summaryRevision)
|
||||||
|
this._setSummaryText(fallbackText, animate);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (revision !== this._summaryRevision) return;
|
if (revision !== this._summaryRevision) return;
|
||||||
@@ -832,7 +855,11 @@ export class IntelHUD {
|
|||||||
this._summaryDirty = false;
|
this._summaryDirty = false;
|
||||||
this._lastSummarySignature = signature;
|
this._lastSummarySignature = signature;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = window.setTimeout(() => controller.abort(), 5000);
|
let timedOut = false;
|
||||||
|
const timeout = window.setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
controller.abort();
|
||||||
|
}, 5000);
|
||||||
this._summaryRequest = controller;
|
this._summaryRequest = controller;
|
||||||
try {
|
try {
|
||||||
this.summaryPolicy.onRequest?.();
|
this.summaryPolicy.onRequest?.();
|
||||||
@@ -855,17 +882,24 @@ export class IntelHUD {
|
|||||||
animate,
|
animate,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.name !== 'AbortError') {
|
// The 5 s timeout is a failure like any other; a move's abort (or
|
||||||
|
// destroy's) is not.
|
||||||
|
const moved = revision !== this._summaryRevision;
|
||||||
|
if (error?.name !== 'AbortError' || timedOut) {
|
||||||
console.warn('[HUD] AI summary unavailable:', error);
|
console.warn('[HUD] AI summary unavailable:', error);
|
||||||
// Invalidate the committed signature so the next periodic tick
|
// Invalidate the committed signature so the next periodic tick
|
||||||
// retries instead of sticking on the fallback line forever.
|
// retries instead of sticking on the fallback line forever.
|
||||||
this._lastSummarySignature = null;
|
this._lastSummarySignature = null;
|
||||||
this._summaryDirty = true;
|
this._summaryDirty = true;
|
||||||
}
|
}
|
||||||
this._setSummaryText(fallbackText, animate);
|
if (!moved) this._setSummaryText(fallbackText, animate);
|
||||||
} finally {
|
} finally {
|
||||||
window.clearTimeout(timeout);
|
window.clearTimeout(timeout);
|
||||||
if (this._summaryRequest === controller) this._summaryRequest = null;
|
if (this._summaryRequest === controller) this._summaryRequest = null;
|
||||||
|
// A request whose answer was dropped (aborted by a move, or a reply
|
||||||
|
// that came back after one) committed a signature it never painted;
|
||||||
|
// forget it, or the next tick over the same place would skip asking.
|
||||||
|
if (revision !== this._summaryRevision) this._lastSummarySignature = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -874,6 +908,8 @@ export class IntelHUD {
|
|||||||
this._typeSummary(text);
|
this._typeSummary(text);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// A plain paint replaces any reply still typing.
|
||||||
|
this._stopSummaryTyping();
|
||||||
const el = document.getElementById('hud-summary');
|
const el = document.getElementById('hud-summary');
|
||||||
if (el) el.textContent = text;
|
if (el) el.textContent = text;
|
||||||
}
|
}
|
||||||
@@ -1044,6 +1080,7 @@ export class IntelHUD {
|
|||||||
clearInterval(this._timestampInterval);
|
clearInterval(this._timestampInterval);
|
||||||
clearInterval(this._summaryInterval);
|
clearInterval(this._summaryInterval);
|
||||||
clearInterval(this._summaryTypingInterval);
|
clearInterval(this._summaryTypingInterval);
|
||||||
|
this.viewer.camera.moveStart.removeEventListener(this._onCameraMoveStart);
|
||||||
this.viewer.camera.moveEnd.removeEventListener(this._onCameraMoveEnd);
|
this.viewer.camera.moveEnd.removeEventListener(this._onCameraMoveEnd);
|
||||||
this._dataManagerUnsubscribe?.();
|
this._dataManagerUnsubscribe?.();
|
||||||
this._summaryRequest?.abort();
|
this._summaryRequest?.abort();
|
||||||
|
|||||||
@@ -16,6 +16,9 @@
|
|||||||
// globals exist: Cesium's widget bundle probes for a real `document` at module
|
// globals exist: Cesium's widget bundle probes for a real `document` at module
|
||||||
// scope and a partial stub sends it down the browser path. Hence hook →
|
// scope and a partial stub sends it down the browser path. Hence hook →
|
||||||
// import → install DOM, in that order.
|
// import → install DOM, in that order.
|
||||||
|
//
|
||||||
|
// The same hook is why the live AI-summary test lives here too: this is the
|
||||||
|
// one file that can construct a real IntelHUD.
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
@@ -33,7 +36,8 @@ registerHooks({
|
|||||||
return {
|
return {
|
||||||
format: 'module',
|
format: 'module',
|
||||||
shortCircuit: true,
|
shortCircuit: true,
|
||||||
source: 'export function forward() { return "10SEG55776339"; }\nexport default { forward };\n',
|
source:
|
||||||
|
'export function forward() { return "10SEG55776339"; }\nexport default { forward };\n',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return next(url, context);
|
return next(url, context);
|
||||||
@@ -59,10 +63,20 @@ const SFO_ELLIPSOIDAL_M = -15;
|
|||||||
*/
|
*/
|
||||||
function installHudEnvironment() {
|
function installHudEnvironment() {
|
||||||
const elements = new Map(
|
const elements = new Map(
|
||||||
['hud-alt', 'hud-summary', 'hud-mgrs', 'hud-latlon', 'hud-bottom-line', 'hud-gsd', 'hud-coll', 'hud-ona', 'hud-mode']
|
[
|
||||||
.map((id) => [id, { textContent: '' }]),
|
'hud-alt',
|
||||||
|
'hud-summary',
|
||||||
|
'hud-mgrs',
|
||||||
|
'hud-latlon',
|
||||||
|
'hud-bottom-line',
|
||||||
|
'hud-gsd',
|
||||||
|
'hud-coll',
|
||||||
|
'hud-ona',
|
||||||
|
'hud-mode',
|
||||||
|
].map((id) => [id, { textContent: '' }]),
|
||||||
);
|
);
|
||||||
const previousDocument = globalThis.document;
|
const previousDocument = globalThis.document;
|
||||||
|
const cameraListeners = {};
|
||||||
globalThis.document = {
|
globalThis.document = {
|
||||||
getElementById: (id) => elements.get(id) ?? null,
|
getElementById: (id) => elements.get(id) ?? null,
|
||||||
querySelectorAll: () => [],
|
querySelectorAll: () => [],
|
||||||
@@ -78,12 +92,24 @@ function installHudEnvironment() {
|
|||||||
height: SFO_ELLIPSOIDAL_M,
|
height: SFO_ELLIPSOIDAL_M,
|
||||||
},
|
},
|
||||||
computeViewRectangle: () => undefined,
|
computeViewRectangle: () => undefined,
|
||||||
moveEnd: { addEventListener() {}, removeEventListener() {} },
|
moveStart: {
|
||||||
|
addEventListener(listener) {
|
||||||
|
cameraListeners.moveStart = listener;
|
||||||
|
},
|
||||||
|
removeEventListener() {},
|
||||||
|
},
|
||||||
|
moveEnd: {
|
||||||
|
addEventListener(listener) {
|
||||||
|
cameraListeners.moveEnd = listener;
|
||||||
|
},
|
||||||
|
removeEventListener() {},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
elements,
|
elements,
|
||||||
viewer,
|
viewer,
|
||||||
|
cameraListeners,
|
||||||
restore() {
|
restore() {
|
||||||
if (previousDocument === undefined) delete globalThis.document;
|
if (previousDocument === undefined) delete globalThis.document;
|
||||||
else globalThis.document = previousDocument;
|
else globalThis.document = previousDocument;
|
||||||
@@ -93,7 +119,9 @@ function installHudEnvironment() {
|
|||||||
|
|
||||||
test('hud.js corrects the camera height to MSL through the geoid module', () => {
|
test('hud.js corrects the camera height to MSL through the geoid module', () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
has(/import \{[^}]*\bellipsoidalToMslDisplayM\b[^}]*\} from '\.\/data\/geoid\.js';/s),
|
has(
|
||||||
|
/import \{[^}]*\bellipsoidalToMslDisplayM\b[^}]*\} from '\.\/data\/geoid\.js';/s,
|
||||||
|
),
|
||||||
true,
|
true,
|
||||||
'hud.js must take the datum correction from ./data/geoid.js, not re-derive one',
|
'hud.js must take the datum correction from ./data/geoid.js, not re-derive one',
|
||||||
);
|
);
|
||||||
@@ -108,7 +136,9 @@ test('hud.js corrects the camera height to MSL through the geoid module', () =>
|
|||||||
'the ~2.7 MB grid must be requested once, on demand — not at HUD construction',
|
'the ~2.7 MB grid must be requested once, on demand — not at HUD construction',
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
has(/\.catch\(\(\) => \{\s*\/\* readout falls back to the uncorrected height \*\/\s*\}\)/),
|
has(
|
||||||
|
/\.catch\(\(\) => \{\s*\/\* readout falls back to the uncorrected height \*\/\s*\}\)/,
|
||||||
|
),
|
||||||
true,
|
true,
|
||||||
'a failed geoid load must leave the readout uncorrected, not unhandled',
|
'a failed geoid load must leave the readout uncorrected, not unhandled',
|
||||||
);
|
);
|
||||||
@@ -141,7 +171,9 @@ test('the summary ALT tag agrees with the corner readout', () => {
|
|||||||
// Both are on screen together; a viewer reading "ALT -15M" in one corner and
|
// Both are on screen together; a viewer reading "ALT -15M" in one corner and
|
||||||
// "ALT: 17m" in the other has found a bug, not a distinction.
|
// "ALT: 17m" in the other has found a bug, not a distinction.
|
||||||
assert.equal(
|
assert.equal(
|
||||||
has(/const altDisplayM = Number\.isFinite\(m\.altMslM\) \? m\.altMslM : m\.altM;/),
|
has(
|
||||||
|
/const altDisplayM = Number\.isFinite\(m\.altMslM\) \? m\.altMslM : m\.altM;/,
|
||||||
|
),
|
||||||
true,
|
true,
|
||||||
'the summary altitude tag must prefer the MSL datum and fall back to the raw height',
|
'the summary altitude tag must prefer the MSL datum and fall back to the raw height',
|
||||||
);
|
);
|
||||||
@@ -188,7 +220,11 @@ test('a cold tick paints both readouts uncorrected, and resolving flips both in
|
|||||||
// Tick 1 — cold. This is also the tick that requests the grid.
|
// Tick 1 — cold. This is also the tick that requests the grid.
|
||||||
hud._updateCameraData();
|
hud._updateCameraData();
|
||||||
assert.match(alt(), /^ALT: -15m/, `cold corner readout, got ${alt()}`);
|
assert.match(alt(), /^ALT: -15m/, `cold corner readout, got ${alt()}`);
|
||||||
assert.match(summary(), /\| ALT -15M \|/, `cold summary tag, got ${summary()}`);
|
assert.match(
|
||||||
|
summary(),
|
||||||
|
/\| ALT -15M \|/,
|
||||||
|
`cold summary tag, got ${summary()}`,
|
||||||
|
);
|
||||||
|
|
||||||
// The HUD registered its own continuation on this same shared promise
|
// The HUD registered its own continuation on this same shared promise
|
||||||
// during tick 1, and it registered first, so awaiting here means its
|
// during tick 1, and it registered first, so awaiting here means its
|
||||||
@@ -248,3 +284,294 @@ test('the corrected readouts are the MSL datum, not a coincidence of the SFO sig
|
|||||||
env.restore();
|
env.restore();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a summary asked for one view is dropped once the camera moves', async () => {
|
||||||
|
const env = installHudEnvironment();
|
||||||
|
const previousWindow = globalThis.window;
|
||||||
|
globalThis.window = globalThis;
|
||||||
|
const pending = [];
|
||||||
|
const summaryService = {
|
||||||
|
summarize: (context) =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
pending.push({
|
||||||
|
context,
|
||||||
|
reply: (summary) =>
|
||||||
|
resolve({ ok: true, status: 200, data: { summary, error: null } }),
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let hud;
|
||||||
|
try {
|
||||||
|
hud = new IntelHUD(env.viewer, { summaryService });
|
||||||
|
let place = 'Yellowstone';
|
||||||
|
hud._summaryContext = async () => ({ place });
|
||||||
|
const summary = () => env.elements.get('hud-summary').textContent;
|
||||||
|
const settle = () => new Promise((resolve) => setImmediate(resolve));
|
||||||
|
hud._updateCameraData();
|
||||||
|
|
||||||
|
// The periodic tick's call (force=false): it asks only for a dirty
|
||||||
|
// summary. Asked over Yellowstone; the camera leaves before the reply
|
||||||
|
// lands.
|
||||||
|
const first = hud._updateSummary(false);
|
||||||
|
await settle();
|
||||||
|
assert.equal(pending.length, 1);
|
||||||
|
env.cameraListeners.moveStart();
|
||||||
|
pending[0].reply('Canyon Junction Wyoming');
|
||||||
|
await first;
|
||||||
|
assert.doesNotMatch(
|
||||||
|
summary(),
|
||||||
|
/Canyon Junction/,
|
||||||
|
'a reply for the old view is dropped',
|
||||||
|
);
|
||||||
|
|
||||||
|
// The next tick, with the camera still moving (a follow camera or an
|
||||||
|
// orbit emits no moveEnd), asks about where it is now and paints it.
|
||||||
|
place = 'Atlantic';
|
||||||
|
const moving = hud._updateSummary(false);
|
||||||
|
await settle();
|
||||||
|
assert.equal(pending.length, 2, 'the move marked the summary dirty');
|
||||||
|
assert.deepEqual(pending[1].context, { place: 'Atlantic' });
|
||||||
|
pending[1].reply('North Atlantic Ocean');
|
||||||
|
await moving;
|
||||||
|
assert.equal(summary(), 'North Atlantic Ocean');
|
||||||
|
} finally {
|
||||||
|
for (const request of pending) request.reply('');
|
||||||
|
hud?.destroy();
|
||||||
|
if (previousWindow === undefined) delete globalThis.window;
|
||||||
|
else globalThis.window = previousWindow;
|
||||||
|
env.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a request the camera leaves paints nothing: aborted, failed, or its context build throws', async () => {
|
||||||
|
const env = installHudEnvironment();
|
||||||
|
const previousWindow = globalThis.window;
|
||||||
|
globalThis.window = globalThis;
|
||||||
|
const pending = [];
|
||||||
|
// Like fetch: an abort rejects with an AbortError, unless `ignoreAbort`.
|
||||||
|
let ignoreAbort = false;
|
||||||
|
const summaryService = {
|
||||||
|
summarize: (context, { signal }) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
pending.push({ signal, reject });
|
||||||
|
if (!ignoreAbort)
|
||||||
|
signal.addEventListener('abort', () =>
|
||||||
|
reject(Object.assign(new Error('aborted'), { name: 'AbortError' })),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let hud;
|
||||||
|
try {
|
||||||
|
hud = new IntelHUD(env.viewer, { summaryService });
|
||||||
|
hud._summaryContext = async () => ({ place: 'Yellowstone' });
|
||||||
|
const element = env.elements.get('hud-summary');
|
||||||
|
const settle = () => new Promise((resolve) => setImmediate(resolve));
|
||||||
|
hud._updateCameraData();
|
||||||
|
|
||||||
|
// The move aborts the request in flight, and the abort paints nothing.
|
||||||
|
let update = hud._updateSummary(false, true);
|
||||||
|
await settle();
|
||||||
|
element.textContent = 'shown before the move';
|
||||||
|
env.cameraListeners.moveStart();
|
||||||
|
assert.equal(pending[0].signal.aborted, true, 'the request is aborted');
|
||||||
|
await update;
|
||||||
|
assert.equal(element.textContent, 'shown before the move');
|
||||||
|
|
||||||
|
// A request that fails after the move (an error, the 5 s timeout) does
|
||||||
|
// not paint the old view's fallback line either.
|
||||||
|
ignoreAbort = true;
|
||||||
|
update = hud._updateSummary(false, true);
|
||||||
|
await settle();
|
||||||
|
element.textContent = 'shown before the move';
|
||||||
|
env.cameraListeners.moveStart();
|
||||||
|
pending[1].reject(new Error('network'));
|
||||||
|
await update;
|
||||||
|
assert.equal(element.textContent, 'shown before the move');
|
||||||
|
|
||||||
|
// Nor does a context build that throws once the camera has moved.
|
||||||
|
hud._summaryContext = async () => {
|
||||||
|
env.cameraListeners.moveStart();
|
||||||
|
throw new Error('view target unavailable');
|
||||||
|
};
|
||||||
|
element.textContent = 'shown before the move';
|
||||||
|
await hud._updateSummary(false, true);
|
||||||
|
assert.equal(element.textContent, 'shown before the move');
|
||||||
|
} finally {
|
||||||
|
hud?.destroy();
|
||||||
|
if (previousWindow === undefined) delete globalThis.window;
|
||||||
|
else globalThis.window = previousWindow;
|
||||||
|
env.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a cancelled summary retries the same context but completed answers stay reusable', async () => {
|
||||||
|
const env = installHudEnvironment();
|
||||||
|
const previousWindow = globalThis.window;
|
||||||
|
globalThis.window = globalThis;
|
||||||
|
const pending = [];
|
||||||
|
const summaryService = {
|
||||||
|
summarize: (context, { signal }) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
pending.push({
|
||||||
|
context,
|
||||||
|
reply: (summary) =>
|
||||||
|
resolve({ ok: true, status: 200, data: { summary } }),
|
||||||
|
});
|
||||||
|
signal.addEventListener('abort', () =>
|
||||||
|
reject(Object.assign(new Error('aborted'), { name: 'AbortError' })),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let hud;
|
||||||
|
try {
|
||||||
|
hud = new IntelHUD(env.viewer, { summaryService });
|
||||||
|
hud._summaryContext = async () => ({ place: 'San Francisco' });
|
||||||
|
hud._updateCameraData();
|
||||||
|
const settle = () => new Promise((resolve) => setImmediate(resolve));
|
||||||
|
const first = hud._updateSummary(false);
|
||||||
|
await settle();
|
||||||
|
assert.equal(pending.length, 1);
|
||||||
|
env.cameraListeners.moveStart();
|
||||||
|
await first;
|
||||||
|
|
||||||
|
// An orbit can leave all semantic place/layer labels unchanged. The next
|
||||||
|
// periodic tick must retry even though its context equals the aborted one.
|
||||||
|
const retry = hud._updateSummary(false);
|
||||||
|
await settle();
|
||||||
|
assert.equal(
|
||||||
|
pending.length,
|
||||||
|
2,
|
||||||
|
'the cancelled signature cannot suppress a retry',
|
||||||
|
);
|
||||||
|
assert.deepEqual(pending[1].context, pending[0].context);
|
||||||
|
pending[1].reply('San Francisco Bay Urban Landscape');
|
||||||
|
await retry;
|
||||||
|
assert.equal(
|
||||||
|
env.elements.get('hud-summary').textContent,
|
||||||
|
'San Francisco Bay Urban Landscape',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Movement without an active request must not discard a completed answer.
|
||||||
|
env.cameraListeners.moveStart();
|
||||||
|
await hud._updateSummary(false);
|
||||||
|
assert.equal(pending.length, 2, 'completed context still deduplicates');
|
||||||
|
} finally {
|
||||||
|
for (const request of pending) request.reply('');
|
||||||
|
hud?.destroy();
|
||||||
|
if (previousWindow === undefined) delete globalThis.window;
|
||||||
|
else globalThis.window = previousWindow;
|
||||||
|
env.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('after a dropped or timed-out request the next tick asks again, and a move stops typing', async (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] });
|
||||||
|
const env = installHudEnvironment();
|
||||||
|
const previousWindow = globalThis.window;
|
||||||
|
globalThis.window = globalThis;
|
||||||
|
const pending = [];
|
||||||
|
const summaryService = {
|
||||||
|
summarize: (context, { signal }) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
pending.push({ resolve });
|
||||||
|
signal.addEventListener('abort', () =>
|
||||||
|
reject(Object.assign(new Error('aborted'), { name: 'AbortError' })),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let hud;
|
||||||
|
try {
|
||||||
|
hud = new IntelHUD(env.viewer, { summaryService });
|
||||||
|
// The same place before and after: a slow follow camera, a small pan.
|
||||||
|
hud._summaryContext = async () => ({ place: 'Lisboa' });
|
||||||
|
const element = env.elements.get('hud-summary');
|
||||||
|
const settle = () => new Promise((resolve) => setImmediate(resolve));
|
||||||
|
hud._updateCameraData();
|
||||||
|
|
||||||
|
// A move aborts the request; the next tick over the same place asks again.
|
||||||
|
let update = hud._updateSummary(false);
|
||||||
|
await settle();
|
||||||
|
env.cameraListeners.moveStart();
|
||||||
|
await update;
|
||||||
|
update = hud._updateSummary(false);
|
||||||
|
await settle();
|
||||||
|
assert.equal(pending.length, 2, 'asked again after the abort');
|
||||||
|
|
||||||
|
// That one times out at 5 s: retried like any failure.
|
||||||
|
t.mock.timers.tick(5_000);
|
||||||
|
await update;
|
||||||
|
update = hud._updateSummary(false);
|
||||||
|
await settle();
|
||||||
|
assert.equal(pending.length, 3, 'asked again after the timeout');
|
||||||
|
pending[2].resolve({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
data: { summary: 'Lisboa Rua Augusta', error: null },
|
||||||
|
});
|
||||||
|
await update;
|
||||||
|
|
||||||
|
// A reply still typing stops when the camera starts moving.
|
||||||
|
hud._typeSummary('Canyon Junction Wyoming Yellowstone National Park');
|
||||||
|
t.mock.timers.tick(48);
|
||||||
|
const typed = element.textContent;
|
||||||
|
env.cameraListeners.moveStart();
|
||||||
|
t.mock.timers.tick(2_000);
|
||||||
|
assert.equal(
|
||||||
|
element.textContent,
|
||||||
|
typed,
|
||||||
|
'no more characters after the move',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
hud?.destroy();
|
||||||
|
if (previousWindow === undefined) delete globalThis.window;
|
||||||
|
else globalThis.window = previousWindow;
|
||||||
|
env.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a summary interrupted while typing can retry unchanged context', async (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] });
|
||||||
|
const env = installHudEnvironment();
|
||||||
|
const previousWindow = globalThis.window;
|
||||||
|
globalThis.window = globalThis;
|
||||||
|
let calls = 0;
|
||||||
|
const text = 'San Francisco Bay Urban Landscape';
|
||||||
|
const summaryService = {
|
||||||
|
summarize: async () => {
|
||||||
|
calls++;
|
||||||
|
return { ok: true, status: 200, data: { summary: text } };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let hud;
|
||||||
|
try {
|
||||||
|
hud = new IntelHUD(env.viewer, { summaryService });
|
||||||
|
hud._summaryContext = async () => ({ place: 'San Francisco' });
|
||||||
|
hud._updateCameraData();
|
||||||
|
await hud._updateSummary(true);
|
||||||
|
t.mock.timers.tick(48);
|
||||||
|
const element = env.elements.get('hud-summary');
|
||||||
|
assert.notEqual(element.textContent, text, 'the answer is still typing');
|
||||||
|
assert.equal(
|
||||||
|
hud._summaryRequest,
|
||||||
|
null,
|
||||||
|
'the network request already finished',
|
||||||
|
);
|
||||||
|
env.cameraListeners.moveStart();
|
||||||
|
assert.equal(hud._summaryTypingInterval, null);
|
||||||
|
await hud._updateSummary(false);
|
||||||
|
assert.equal(
|
||||||
|
calls,
|
||||||
|
2,
|
||||||
|
'a partially displayed answer must not suppress retry',
|
||||||
|
);
|
||||||
|
assert.equal(element.textContent, text);
|
||||||
|
env.cameraListeners.moveStart();
|
||||||
|
await hud._updateSummary(false);
|
||||||
|
assert.equal(calls, 2, 'the fully displayed answer remains reusable');
|
||||||
|
} finally {
|
||||||
|
hud?.destroy();
|
||||||
|
if (previousWindow === undefined) delete globalThis.window;
|
||||||
|
else globalThis.window = previousWindow;
|
||||||
|
env.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in new issue
Block a user