mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +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).
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
|
||||
+42
-5
@@ -224,6 +224,20 @@ export class IntelHUD {
|
||||
// session, so this flips once — and both altitude readouts have to move
|
||||
// together when it does (see the repaint in _updateCameraData).
|
||||
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._markSummaryDirty();
|
||||
// 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._buildDOM();
|
||||
this.viewer.camera.moveStart.addEventListener(this._onCameraMoveStart);
|
||||
this.viewer.camera.moveEnd.addEventListener(this._onCameraMoveEnd);
|
||||
this._startTimers();
|
||||
}
|
||||
@@ -766,6 +781,12 @@ export class IntelHUD {
|
||||
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
|
||||
* (2 characters every 24ms).
|
||||
@@ -816,8 +837,10 @@ export class IntelHUD {
|
||||
} catch (error) {
|
||||
console.warn('[HUD] summary context unavailable:', error);
|
||||
// Left dirty on purpose: the next periodic tick retries instead of
|
||||
// sticking on the fallback line for the rest of the session.
|
||||
this._setSummaryText(fallbackText, animate);
|
||||
// sticking on the fallback line for the rest of the session. The
|
||||
// fallback was composed for this view; a moved camera keeps its own.
|
||||
if (revision === this._summaryRevision)
|
||||
this._setSummaryText(fallbackText, animate);
|
||||
return;
|
||||
}
|
||||
if (revision !== this._summaryRevision) return;
|
||||
@@ -832,7 +855,11 @@ export class IntelHUD {
|
||||
this._summaryDirty = false;
|
||||
this._lastSummarySignature = signature;
|
||||
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;
|
||||
try {
|
||||
this.summaryPolicy.onRequest?.();
|
||||
@@ -855,17 +882,24 @@ export class IntelHUD {
|
||||
animate,
|
||||
);
|
||||
} 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);
|
||||
// Invalidate the committed signature so the next periodic tick
|
||||
// retries instead of sticking on the fallback line forever.
|
||||
this._lastSummarySignature = null;
|
||||
this._summaryDirty = true;
|
||||
}
|
||||
this._setSummaryText(fallbackText, animate);
|
||||
if (!moved) this._setSummaryText(fallbackText, animate);
|
||||
} finally {
|
||||
window.clearTimeout(timeout);
|
||||
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);
|
||||
return;
|
||||
}
|
||||
// A plain paint replaces any reply still typing.
|
||||
this._stopSummaryTyping();
|
||||
const el = document.getElementById('hud-summary');
|
||||
if (el) el.textContent = text;
|
||||
}
|
||||
@@ -1044,6 +1080,7 @@ export class IntelHUD {
|
||||
clearInterval(this._timestampInterval);
|
||||
clearInterval(this._summaryInterval);
|
||||
clearInterval(this._summaryTypingInterval);
|
||||
this.viewer.camera.moveStart.removeEventListener(this._onCameraMoveStart);
|
||||
this.viewer.camera.moveEnd.removeEventListener(this._onCameraMoveEnd);
|
||||
this._dataManagerUnsubscribe?.();
|
||||
this._summaryRequest?.abort();
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
// 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 →
|
||||
// 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 assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
@@ -33,7 +36,8 @@ registerHooks({
|
||||
return {
|
||||
format: 'module',
|
||||
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);
|
||||
@@ -59,10 +63,20 @@ const SFO_ELLIPSOIDAL_M = -15;
|
||||
*/
|
||||
function installHudEnvironment() {
|
||||
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 cameraListeners = {};
|
||||
globalThis.document = {
|
||||
getElementById: (id) => elements.get(id) ?? null,
|
||||
querySelectorAll: () => [],
|
||||
@@ -78,12 +92,24 @@ function installHudEnvironment() {
|
||||
height: SFO_ELLIPSOIDAL_M,
|
||||
},
|
||||
computeViewRectangle: () => undefined,
|
||||
moveEnd: { addEventListener() {}, removeEventListener() {} },
|
||||
moveStart: {
|
||||
addEventListener(listener) {
|
||||
cameraListeners.moveStart = listener;
|
||||
},
|
||||
removeEventListener() {},
|
||||
},
|
||||
moveEnd: {
|
||||
addEventListener(listener) {
|
||||
cameraListeners.moveEnd = listener;
|
||||
},
|
||||
removeEventListener() {},
|
||||
},
|
||||
},
|
||||
};
|
||||
return {
|
||||
elements,
|
||||
viewer,
|
||||
cameraListeners,
|
||||
restore() {
|
||||
if (previousDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = previousDocument;
|
||||
@@ -93,7 +119,9 @@ function installHudEnvironment() {
|
||||
|
||||
test('hud.js corrects the camera height to MSL through the geoid module', () => {
|
||||
assert.equal(
|
||||
has(/import \{[^}]*\bellipsoidalToMslDisplayM\b[^}]*\} from '\.\/data\/geoid\.js';/s),
|
||||
has(
|
||||
/import \{[^}]*\bellipsoidalToMslDisplayM\b[^}]*\} from '\.\/data\/geoid\.js';/s,
|
||||
),
|
||||
true,
|
||||
'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',
|
||||
);
|
||||
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,
|
||||
'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
|
||||
// "ALT: 17m" in the other has found a bug, not a distinction.
|
||||
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,
|
||||
'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.
|
||||
hud._updateCameraData();
|
||||
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
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
|
||||
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