mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
feat(voice): add reusable geometry actions
- turn named places and mapped landmarks into reusable bounded areas - count loaded records inside resolved, drawn and annotated geometry - find recent imagery for an area, the view or the pointer snapshot - keep osm place search behind an operator-configured overpass endpoint - preserve cancellation, ownership and follow-up memory across every action - cover geometry, provider lifecycle, cache isolation and voice routing
This commit is contained in:
1 parent
01979bba1e
commit
b234d7b427
84 files changed
+14262
-1681
No files matched your search
@@ -41,11 +41,21 @@ function scriptedResolver(outcomes) {
|
||||
// Instant fake wait that records the requested backoff delays.
|
||||
function fakeWait() {
|
||||
const delays = [];
|
||||
const waitFn = async (ms) => { delays.push(ms); };
|
||||
const waitFn = async (ms) => {
|
||||
delays.push(ms);
|
||||
};
|
||||
return { waitFn, delays };
|
||||
}
|
||||
|
||||
const FP = { ring: [[0, 0], [0, 1], [1, 1], [0, 0]], footprintKind: 'area' };
|
||||
const FP = {
|
||||
ring: [
|
||||
[0, 0],
|
||||
[0, 1],
|
||||
[1, 1],
|
||||
[0, 0],
|
||||
],
|
||||
footprintKind: 'area',
|
||||
};
|
||||
|
||||
function installAnimationFrameStubs(t) {
|
||||
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
||||
@@ -53,9 +63,11 @@ function installAnimationFrameStubs(t) {
|
||||
globalThis.requestAnimationFrame = () => 1;
|
||||
globalThis.cancelAnimationFrame = () => {};
|
||||
t.after(() => {
|
||||
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
|
||||
if (originalRequestAnimationFrame === undefined)
|
||||
delete globalThis.requestAnimationFrame;
|
||||
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
|
||||
if (originalCancelAnimationFrame === undefined) delete globalThis.cancelAnimationFrame;
|
||||
if (originalCancelAnimationFrame === undefined)
|
||||
delete globalThis.cancelAnimationFrame;
|
||||
else globalThis.cancelAnimationFrame = originalCancelAnimationFrame;
|
||||
});
|
||||
}
|
||||
@@ -65,9 +77,15 @@ function fakeRenderer() {
|
||||
return {
|
||||
calls,
|
||||
renderer: {
|
||||
add() { calls.add += 1; },
|
||||
update() { calls.update += 1; },
|
||||
remove() { calls.remove += 1; },
|
||||
add() {
|
||||
calls.add += 1;
|
||||
},
|
||||
update() {
|
||||
calls.update += 1;
|
||||
},
|
||||
remove() {
|
||||
calls.remove += 1;
|
||||
},
|
||||
sync() {},
|
||||
},
|
||||
};
|
||||
@@ -81,14 +99,19 @@ function httpFailure(status, retryAfter = null) {
|
||||
return {
|
||||
ok: false,
|
||||
status,
|
||||
headers: { get: (name) => (name.toLowerCase() === 'retry-after' ? retryAfter : null) },
|
||||
headers: {
|
||||
get: (name) => (name.toLowerCase() === 'retry-after' ? retryAfter : null),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('retry: first-try footprint returns immediately, no retry, no waiting', async () => {
|
||||
const { resolve, calls } = scriptedResolver([FP]);
|
||||
const { waitFn, delays } = fakeWait();
|
||||
const fp = await resolveOutlineWithRetry(resolve, { delaysMs: [8000, 25000], waitFn });
|
||||
const fp = await resolveOutlineWithRetry(resolve, {
|
||||
delaysMs: [8000, 25000],
|
||||
waitFn,
|
||||
});
|
||||
assert.equal(fp, FP);
|
||||
assert.equal(calls.count, 1);
|
||||
assert.deepEqual(delays, []);
|
||||
@@ -97,7 +120,10 @@ test('retry: first-try footprint returns immediately, no retry, no waiting', asy
|
||||
test('retry: a DEFINITIVE miss (null) is never retried — the honest point stands', async () => {
|
||||
const { resolve, calls } = scriptedResolver([null, FP]);
|
||||
const { waitFn, delays } = fakeWait();
|
||||
const fp = await resolveOutlineWithRetry(resolve, { delaysMs: [8000, 25000], waitFn });
|
||||
const fp = await resolveOutlineWithRetry(resolve, {
|
||||
delaysMs: [8000, 25000],
|
||||
waitFn,
|
||||
});
|
||||
assert.equal(fp, null);
|
||||
assert.equal(calls.count, 1);
|
||||
assert.deepEqual(delays, []);
|
||||
@@ -106,7 +132,10 @@ test('retry: a DEFINITIVE miss (null) is never retried — the honest point stan
|
||||
test('retry: a TRANSIENT miss (undefined) re-runs after the first backoff and can succeed', async () => {
|
||||
const { resolve, calls } = scriptedResolver([undefined, FP]);
|
||||
const { waitFn, delays } = fakeWait();
|
||||
const fp = await resolveOutlineWithRetry(resolve, { delaysMs: [8000, 25000], waitFn });
|
||||
const fp = await resolveOutlineWithRetry(resolve, {
|
||||
delaysMs: [8000, 25000],
|
||||
waitFn,
|
||||
});
|
||||
assert.equal(fp, FP);
|
||||
assert.equal(calls.count, 2);
|
||||
assert.deepEqual(delays, [8000]); // waited once, succeeded on attempt 2
|
||||
@@ -115,16 +144,27 @@ test('retry: a TRANSIENT miss (undefined) re-runs after the first backoff and ca
|
||||
test('retry: a transient retry can still conclude with a definitive miss (no third run)', async () => {
|
||||
const { resolve, calls } = scriptedResolver([undefined, null]);
|
||||
const { waitFn, delays } = fakeWait();
|
||||
const fp = await resolveOutlineWithRetry(resolve, { delaysMs: [8000, 25000], waitFn });
|
||||
const fp = await resolveOutlineWithRetry(resolve, {
|
||||
delaysMs: [8000, 25000],
|
||||
waitFn,
|
||||
});
|
||||
assert.equal(fp, null);
|
||||
assert.equal(calls.count, 2);
|
||||
assert.deepEqual(delays, [8000]);
|
||||
});
|
||||
|
||||
test('retry: persistent transients exhaust the backoff schedule and give up as transient', async () => {
|
||||
const { resolve, calls } = scriptedResolver([undefined, undefined, undefined, FP]);
|
||||
const { resolve, calls } = scriptedResolver([
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
FP,
|
||||
]);
|
||||
const { waitFn, delays } = fakeWait();
|
||||
const fp = await resolveOutlineWithRetry(resolve, { delaysMs: [8000, 25000], waitFn });
|
||||
const fp = await resolveOutlineWithRetry(resolve, {
|
||||
delaysMs: [8000, 25000],
|
||||
waitFn,
|
||||
});
|
||||
assert.equal(fp, undefined); // never found within budget — mark honestly stays a point
|
||||
assert.equal(calls.count, 3); // initial + one per backoff entry
|
||||
assert.deepEqual(delays, [8000, 25000]);
|
||||
@@ -136,7 +176,10 @@ test('retry: a stale board (clear/supersede during backoff) stops retrying immed
|
||||
let stale = false;
|
||||
const fp = await resolveOutlineWithRetry(resolve, {
|
||||
delaysMs: [8000, 25000],
|
||||
waitFn: async (ms) => { await waitFn(ms); stale = true; }, // goes stale mid-wait
|
||||
waitFn: async (ms) => {
|
||||
await waitFn(ms);
|
||||
stale = true;
|
||||
}, // goes stale mid-wait
|
||||
isStale: () => stale,
|
||||
});
|
||||
assert.equal(fp, undefined);
|
||||
@@ -147,7 +190,10 @@ test('retry: a stale board (clear/supersede during backoff) stops retrying immed
|
||||
test('retry: a thrown resolver is a definitive miss (no retry), matching the old catch→point path', async () => {
|
||||
const { resolve, calls } = scriptedResolver([new Error('boom'), FP]);
|
||||
const { waitFn, delays } = fakeWait();
|
||||
const fp = await resolveOutlineWithRetry(resolve, { delaysMs: [8000, 25000], waitFn });
|
||||
const fp = await resolveOutlineWithRetry(resolve, {
|
||||
delaysMs: [8000, 25000],
|
||||
waitFn,
|
||||
});
|
||||
assert.equal(fp, null);
|
||||
assert.equal(calls.count, 1);
|
||||
assert.deepEqual(delays, []);
|
||||
@@ -169,43 +215,65 @@ test('outline queue: an 8-spec batch keeps at most two FIFO upgrades in flight',
|
||||
label: target,
|
||||
source: 'fake',
|
||||
ring: null,
|
||||
resolveOutline: () => new Promise((resolve) => {
|
||||
started.push(target);
|
||||
inFlight += 1;
|
||||
maxInFlight = Math.max(maxInFlight, inFlight);
|
||||
releases.set(target, () => {
|
||||
inFlight -= 1;
|
||||
resolve({
|
||||
ring: [[index, index], [index + 0.1, index], [index, index + 0.1], [index, index]],
|
||||
footprintKind: 'area',
|
||||
lat: index,
|
||||
lon: index,
|
||||
height: 0,
|
||||
resolveOutline: () =>
|
||||
new Promise((resolve) => {
|
||||
started.push(target);
|
||||
inFlight += 1;
|
||||
maxInFlight = Math.max(maxInFlight, inFlight);
|
||||
releases.set(target, () => {
|
||||
inFlight -= 1;
|
||||
resolve({
|
||||
ring: [
|
||||
[index, index],
|
||||
[index + 0.1, index],
|
||||
[index, index + 0.1],
|
||||
[index, index],
|
||||
],
|
||||
footprintKind: 'area',
|
||||
lat: index,
|
||||
lon: index,
|
||||
height: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
}),
|
||||
}),
|
||||
};
|
||||
};
|
||||
const engine = createAnnotationEngine({ viewer: {}, renderer, resolveTarget });
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {},
|
||||
renderer,
|
||||
resolveTarget,
|
||||
});
|
||||
let completed = 0;
|
||||
const allCompleted = new Promise((resolve) => engine.onOutlineEvent(() => {
|
||||
completed += 1;
|
||||
if (completed === 8) resolve();
|
||||
}));
|
||||
const allCompleted = new Promise((resolve) =>
|
||||
engine.onOutlineEvent(() => {
|
||||
completed += 1;
|
||||
if (completed === 8) resolve();
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await engine.annotate(Array.from({ length: 8 }, (_, i) => ({
|
||||
type: 'area',
|
||||
target: `queue-${i}`,
|
||||
footprint: true,
|
||||
})));
|
||||
const result = await engine.annotate(
|
||||
Array.from({ length: 8 }, (_, i) => ({
|
||||
type: 'area',
|
||||
target: `queue-${i}`,
|
||||
footprint: true,
|
||||
})),
|
||||
);
|
||||
|
||||
assert.equal(result.drawn, 8);
|
||||
assert.equal(calls.add, 8, 'all point renders land before queued outlines finish');
|
||||
assert.equal(
|
||||
calls.add,
|
||||
8,
|
||||
'all point renders land before queued outlines finish',
|
||||
);
|
||||
assert.deepEqual(started, ['queue-0', 'queue-1']);
|
||||
for (let next = 2; next < 8; next += 1) {
|
||||
releases.get(`queue-${next - 2}`)();
|
||||
await flushMicrotasks();
|
||||
assert.equal(started[next], `queue-${next}`, 'queued upgrades start in ask order');
|
||||
assert.equal(
|
||||
started[next],
|
||||
`queue-${next}`,
|
||||
'queued upgrades start in ask order',
|
||||
);
|
||||
}
|
||||
releases.get('queue-6')();
|
||||
releases.get('queue-7')();
|
||||
@@ -226,33 +294,47 @@ test('outline queue: clear drops queued-but-unstarted upgrades without a later f
|
||||
label: target,
|
||||
source: 'fake',
|
||||
ring: null,
|
||||
resolveOutline: () => new Promise((resolve) => {
|
||||
fetchesStarted += 1;
|
||||
const finish = () => resolve(undefined);
|
||||
if (signal.aborted) finish();
|
||||
else signal.addEventListener('abort', finish, { once: true });
|
||||
}),
|
||||
resolveOutline: () =>
|
||||
new Promise((resolve) => {
|
||||
fetchesStarted += 1;
|
||||
const finish = () => resolve(undefined);
|
||||
if (signal.aborted) finish();
|
||||
else signal.addEventListener('abort', finish, { once: true });
|
||||
}),
|
||||
});
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {},
|
||||
renderer,
|
||||
resolveTarget,
|
||||
});
|
||||
const engine = createAnnotationEngine({ viewer: {}, renderer, resolveTarget });
|
||||
|
||||
await engine.annotate(Array.from({ length: 8 }, (_, i) => ({
|
||||
type: 'area',
|
||||
target: `clear-${i}`,
|
||||
footprint: true,
|
||||
})));
|
||||
await engine.annotate(
|
||||
Array.from({ length: 8 }, (_, i) => ({
|
||||
type: 'area',
|
||||
target: `clear-${i}`,
|
||||
footprint: true,
|
||||
})),
|
||||
);
|
||||
assert.equal(fetchesStarted, 2);
|
||||
|
||||
engine.clear();
|
||||
await flushMicrotasks();
|
||||
assert.equal(engine.count(), 0);
|
||||
assert.equal(fetchesStarted, 2, 'the six queued upgrades were dropped on clear');
|
||||
assert.equal(
|
||||
fetchesStarted,
|
||||
2,
|
||||
'the six queued upgrades were dropped on clear',
|
||||
);
|
||||
});
|
||||
|
||||
test('retry: HTTP 429 Retry-After 5s gets one ladder-spaced retry; a second 429 stops', async (t) => {
|
||||
const originalWindow = globalThis.window;
|
||||
const originalFetch = globalThis.fetch;
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 10_000 });
|
||||
globalThis.window = { setTimeout: globalThis.setTimeout, clearTimeout: globalThis.clearTimeout };
|
||||
globalThis.window = {
|
||||
setTimeout: globalThis.setTimeout,
|
||||
clearTimeout: globalThis.clearTimeout,
|
||||
};
|
||||
const requestTimes = [];
|
||||
globalThis.fetch = async (url) => {
|
||||
// The one-time capability probe is not an outline request.
|
||||
@@ -283,11 +365,16 @@ test('retry: HTTP 429 Retry-After 5s gets one ladder-spaced retry; a second 429
|
||||
waits.push(ms);
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
},
|
||||
})
|
||||
.finally(() => { settled = true; });
|
||||
}).finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
while (waits.length === 0) await flushMicrotasks();
|
||||
assert.equal(requestTimes.length, 1);
|
||||
assert.deepEqual(waits, [8000], 'the 8s ladder floor is longer than Retry-After: 5');
|
||||
assert.deepEqual(
|
||||
waits,
|
||||
[8000],
|
||||
'the 8s ladder floor is longer than Retry-After: 5',
|
||||
);
|
||||
|
||||
t.mock.timers.tick(7999);
|
||||
await flushMicrotasks();
|
||||
@@ -302,8 +389,15 @@ test('retry: HTTP 429 Retry-After 5s gets one ladder-spaced retry; a second 429
|
||||
}
|
||||
|
||||
assert.equal(requestTimes.length, 2);
|
||||
assert.ok(requestTimes[1] - requestTimes[0] >= 5000, 'Retry-After is a hard minimum');
|
||||
assert.equal(settled, true, 'a second 429 must not schedule the 25s ladder replay');
|
||||
assert.ok(
|
||||
requestTimes[1] - requestTimes[0] >= 5000,
|
||||
'Retry-After is a hard minimum',
|
||||
);
|
||||
assert.equal(
|
||||
settled,
|
||||
true,
|
||||
'a second 429 must not schedule the 25s ladder replay',
|
||||
);
|
||||
assert.equal(await pending, undefined);
|
||||
});
|
||||
|
||||
@@ -311,7 +405,10 @@ test('retry: a plain HTTP 500 still exhausts the existing 8s/25s transient ladde
|
||||
const originalWindow = globalThis.window;
|
||||
const originalFetch = globalThis.fetch;
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 100_000 });
|
||||
globalThis.window = { setTimeout: globalThis.setTimeout, clearTimeout: globalThis.clearTimeout };
|
||||
globalThis.window = {
|
||||
setTimeout: globalThis.setTimeout,
|
||||
clearTimeout: globalThis.clearTimeout,
|
||||
};
|
||||
const requestTimes = [];
|
||||
globalThis.fetch = async () => {
|
||||
requestTimes.push(Date.now());
|
||||
@@ -340,8 +437,9 @@ test('retry: a plain HTTP 500 still exhausts the existing 8s/25s transient ladde
|
||||
waits.push(ms);
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
},
|
||||
})
|
||||
.finally(() => { settled = true; });
|
||||
}).finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
while (waits.length === 0) await flushMicrotasks();
|
||||
assert.equal(requestTimes.length, 1);
|
||||
assert.deepEqual(waits, [8000]);
|
||||
@@ -358,7 +456,10 @@ test('retry: a plain HTTP 500 still exhausts the existing 8s/25s transient ladde
|
||||
await flushMicrotasks();
|
||||
|
||||
assert.equal(requestTimes.length, 3);
|
||||
assert.deepEqual(requestTimes.map((at) => at - requestTimes[0]), [0, 8000, 33_000]);
|
||||
assert.deepEqual(
|
||||
requestTimes.map((at) => at - requestTimes[0]),
|
||||
[0, 8000, 33_000],
|
||||
);
|
||||
assert.equal(settled, true);
|
||||
assert.equal(await pending, undefined);
|
||||
});
|
||||
@@ -373,7 +474,10 @@ test('targetKey: trailing locality qualifiers are stripped so state names dedupe
|
||||
});
|
||||
|
||||
test('targetKey: lowercases, trims, and keeps comma-free names intact', () => {
|
||||
assert.equal(normalizeTargetKey(' Texas State Capitol '), 'texas state capitol');
|
||||
assert.equal(
|
||||
normalizeTargetKey(' Texas State Capitol '),
|
||||
'texas state capitol',
|
||||
);
|
||||
assert.equal(normalizeTargetKey('Lady Bird Lake'), 'lady bird lake');
|
||||
});
|
||||
|
||||
@@ -388,104 +492,146 @@ test('targetKey: empty / absent targets stay null (coord and pixel specs never p
|
||||
assert.equal(normalizeTargetKey(undefined), null);
|
||||
});
|
||||
|
||||
test('outline upgrade updates the rendered element in place without remove/add', { timeout: 5000 }, async (t) => {
|
||||
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
||||
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame;
|
||||
const originalWindow = globalThis.window;
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.requestAnimationFrame = () => 1;
|
||||
globalThis.cancelAnimationFrame = () => {};
|
||||
globalThis.window = {
|
||||
__GOOGLE_MAPS_API_KEY__: 'unit-test-key',
|
||||
setTimeout: globalThis.setTimeout,
|
||||
clearTimeout: globalThis.clearTimeout,
|
||||
};
|
||||
let overpassCall = 0;
|
||||
globalThis.fetch = async (url) => {
|
||||
if (String(url).startsWith('https://maps.googleapis.com/')) {
|
||||
return { json: async () => ({
|
||||
status: 'OK',
|
||||
results: [{
|
||||
formatted_address: 'FB-3 Engine Texas Fixture',
|
||||
types: ['administrative_area_level_1', 'political'],
|
||||
address_components: [{
|
||||
long_name: 'FB-3 Engine Texas Fixture',
|
||||
types: ['administrative_area_level_1', 'political'],
|
||||
}],
|
||||
geometry: { location: { lat: 31, lng: -99 } },
|
||||
}],
|
||||
}) };
|
||||
}
|
||||
assert.equal(String(url), '/api/overpass');
|
||||
overpassCall += 1;
|
||||
if (overpassCall === 1) {
|
||||
test(
|
||||
'outline upgrade updates the rendered element in place without remove/add',
|
||||
{ timeout: 5000 },
|
||||
async (t) => {
|
||||
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
||||
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame;
|
||||
const originalWindow = globalThis.window;
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.requestAnimationFrame = () => 1;
|
||||
globalThis.cancelAnimationFrame = () => {};
|
||||
globalThis.window = {
|
||||
__GOOGLE_MAPS_API_KEY__: 'unit-test-key',
|
||||
setTimeout: globalThis.setTimeout,
|
||||
clearTimeout: globalThis.clearTimeout,
|
||||
};
|
||||
let overpassCall = 0;
|
||||
globalThis.fetch = async (url) => {
|
||||
if (String(url).startsWith('https://maps.googleapis.com/')) {
|
||||
return {
|
||||
json: async () => ({
|
||||
status: 'OK',
|
||||
results: [
|
||||
{
|
||||
formatted_address: 'FB-3 Engine Texas Fixture',
|
||||
types: ['administrative_area_level_1', 'political'],
|
||||
address_components: [
|
||||
{
|
||||
long_name: 'FB-3 Engine Texas Fixture',
|
||||
types: ['administrative_area_level_1', 'political'],
|
||||
},
|
||||
],
|
||||
geometry: { location: { lat: 31, lng: -99 } },
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
assert.equal(String(url), '/api/overpass');
|
||||
overpassCall += 1;
|
||||
if (overpassCall === 1) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
elements: [
|
||||
{
|
||||
type: 'area',
|
||||
id: 54321,
|
||||
tags: { name: 'FB-3 Engine Texas Fixture', admin_level: '4' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ elements: [{
|
||||
type: 'area',
|
||||
id: 54321,
|
||||
tags: { name: 'FB-3 Engine Texas Fixture', admin_level: '4' },
|
||||
}] }),
|
||||
json: async () => ({
|
||||
elements: [
|
||||
{
|
||||
type: 'relation',
|
||||
geometry: [
|
||||
{ lon: -106, lat: 25 },
|
||||
{ lon: -93, lat: 25 },
|
||||
{ lon: -93, lat: 36 },
|
||||
{ lon: -106, lat: 36 },
|
||||
{ lon: -106, lat: 25 },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ elements: [{
|
||||
type: 'relation',
|
||||
geometry: [
|
||||
{ lon: -106, lat: 25 },
|
||||
{ lon: -93, lat: 25 },
|
||||
{ lon: -93, lat: 36 },
|
||||
{ lon: -106, lat: 36 },
|
||||
{ lon: -106, lat: 25 },
|
||||
],
|
||||
}] }),
|
||||
};
|
||||
};
|
||||
t.after(() => {
|
||||
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
|
||||
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
|
||||
if (originalCancelAnimationFrame === undefined) delete globalThis.cancelAnimationFrame;
|
||||
else globalThis.cancelAnimationFrame = originalCancelAnimationFrame;
|
||||
if (originalWindow === undefined) delete globalThis.window;
|
||||
else globalThis.window = originalWindow;
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
t.after(() => {
|
||||
if (originalRequestAnimationFrame === undefined)
|
||||
delete globalThis.requestAnimationFrame;
|
||||
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
|
||||
if (originalCancelAnimationFrame === undefined)
|
||||
delete globalThis.cancelAnimationFrame;
|
||||
else globalThis.cancelAnimationFrame = originalCancelAnimationFrame;
|
||||
if (originalWindow === undefined) delete globalThis.window;
|
||||
else globalThis.window = originalWindow;
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
const calls = { add: 0, update: 0, remove: 0 };
|
||||
const elements = new Map();
|
||||
let originalElement = null;
|
||||
const renderer = {
|
||||
add(anno) {
|
||||
calls.add += 1;
|
||||
originalElement = { id: anno.id };
|
||||
elements.set(anno.id, originalElement);
|
||||
},
|
||||
update(anno) {
|
||||
calls.update += 1;
|
||||
assert.equal(elements.get(anno.id), originalElement);
|
||||
},
|
||||
remove() { calls.remove += 1; },
|
||||
sync() {},
|
||||
};
|
||||
const viewer = {};
|
||||
const engine = createAnnotationEngine({ viewer, renderer, placeSearch: createStandalonePlaceSearch({ resolveApiKey: () => 'unit-test-key' }) });
|
||||
const upgraded = new Promise((resolve) => engine.onOutlineEvent(resolve));
|
||||
const calls = { add: 0, update: 0, remove: 0 };
|
||||
const elements = new Map();
|
||||
let originalElement = null;
|
||||
const renderer = {
|
||||
add(anno) {
|
||||
calls.add += 1;
|
||||
originalElement = { id: anno.id };
|
||||
elements.set(anno.id, originalElement);
|
||||
},
|
||||
update(anno) {
|
||||
calls.update += 1;
|
||||
assert.equal(elements.get(anno.id), originalElement);
|
||||
},
|
||||
remove() {
|
||||
calls.remove += 1;
|
||||
},
|
||||
sync() {},
|
||||
};
|
||||
const viewer = {};
|
||||
const engine = createAnnotationEngine({
|
||||
viewer,
|
||||
renderer,
|
||||
placeSearch: createStandalonePlaceSearch({
|
||||
resolveApiKey: () => 'unit-test-key',
|
||||
}),
|
||||
});
|
||||
const upgraded = new Promise((resolve) => engine.onOutlineEvent(resolve));
|
||||
|
||||
const result = await engine.annotate([{
|
||||
type: 'area',
|
||||
target: 'FB-3 Engine Texas Fixture',
|
||||
label: 'Texas',
|
||||
footprint: true,
|
||||
}]);
|
||||
await upgraded;
|
||||
const result = await engine.annotate([
|
||||
{
|
||||
type: 'area',
|
||||
target: 'FB-3 Engine Texas Fixture',
|
||||
label: 'Texas',
|
||||
footprint: true,
|
||||
},
|
||||
]);
|
||||
await upgraded;
|
||||
|
||||
assert.equal(result.drawn, 1);
|
||||
assert.deepEqual(calls, { add: 1, update: 1, remove: 0 });
|
||||
assert.equal(elements.get(result.ids[0]), originalElement, 'rendered element identity survives');
|
||||
assert.deepEqual(engine.list()[0].anchor, { lon: -100.8, lat: 29.4, height: 0 });
|
||||
assert.equal(engine.list()[0].ring.length, 5, 'the existing data-level centroid/ring snap remains');
|
||||
});
|
||||
assert.equal(result.drawn, 1);
|
||||
assert.deepEqual(calls, { add: 1, update: 1, remove: 0 });
|
||||
assert.equal(
|
||||
elements.get(result.ids[0]),
|
||||
originalElement,
|
||||
'rendered element identity survives',
|
||||
);
|
||||
assert.deepEqual(engine.list()[0].anchor, {
|
||||
lon: -100.8,
|
||||
lat: 29.4,
|
||||
height: 0,
|
||||
});
|
||||
assert.equal(
|
||||
engine.list()[0].ring.length,
|
||||
5,
|
||||
'the existing data-level centroid/ring snap remains',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// ── Renderer-throw rollback: no phantom mark, no permanent governor hold ──────
|
||||
//
|
||||
@@ -501,14 +647,21 @@ function throwingRendererHarness() {
|
||||
let failNextAdd = false;
|
||||
return {
|
||||
calls,
|
||||
failAddOnce() { failNextAdd = true; },
|
||||
failAddOnce() {
|
||||
failNextAdd = true;
|
||||
},
|
||||
renderer: {
|
||||
add() {
|
||||
calls.add += 1;
|
||||
if (failNextAdd) { failNextAdd = false; throw new Error('WebGL context lost'); }
|
||||
if (failNextAdd) {
|
||||
failNextAdd = false;
|
||||
throw new Error('WebGL context lost');
|
||||
}
|
||||
},
|
||||
update() {},
|
||||
remove() { calls.remove += 1; },
|
||||
remove() {
|
||||
calls.remove += 1;
|
||||
},
|
||||
sync() {},
|
||||
},
|
||||
};
|
||||
@@ -528,7 +681,9 @@ test('duplicate-replacement: a renderer throw leaves no phantom mark and no leak
|
||||
resolveTarget: async () => ({ ...FIXED_POINT }),
|
||||
});
|
||||
|
||||
const first = await engine.annotate([{ type: 'point', target: 'Texas Capitol', label: 'Capitol' }]);
|
||||
const first = await engine.annotate([
|
||||
{ type: 'point', target: 'Texas Capitol', label: 'Capitol' },
|
||||
]);
|
||||
assert.equal(first.drawn, 1);
|
||||
assert.equal(engine.list().length, 1);
|
||||
assert.ok(
|
||||
@@ -543,12 +698,25 @@ test('duplicate-replacement: a renderer throw leaves no phantom mark and no leak
|
||||
// branch for a point; see findDuplicate.)
|
||||
failAddOnce();
|
||||
const second = await engine.annotate([
|
||||
{ type: 'point', target: 'Texas Capitol', label: 'Capitol', color: 'amber' },
|
||||
{
|
||||
type: 'point',
|
||||
target: 'Texas Capitol',
|
||||
label: 'Capitol',
|
||||
color: 'amber',
|
||||
},
|
||||
]);
|
||||
|
||||
assert.equal(second.drawn, 0, 'the failed swap is reported as a failure, not a draw');
|
||||
assert.equal(
|
||||
second.drawn,
|
||||
0,
|
||||
'the failed swap is reported as a failure, not a draw',
|
||||
);
|
||||
assert.equal(second.failed, 1);
|
||||
assert.equal(engine.list().length, 0, 'no phantom annotation survives the failed swap');
|
||||
assert.equal(
|
||||
engine.list().length,
|
||||
0,
|
||||
'no phantom annotation survives the failed swap',
|
||||
);
|
||||
assert.ok(
|
||||
!getRenderGovernorDiagnostics().holds.includes('annotations'),
|
||||
'the annotations hold must be released — a leak defeats the idle governor permanently',
|
||||
@@ -568,10 +736,16 @@ test('fresh path: a renderer throw is rolled back the same way', async (t) => {
|
||||
});
|
||||
|
||||
failAddOnce();
|
||||
const result = await engine.annotate([{ type: 'point', target: 'Texas Capitol', label: 'Capitol' }]);
|
||||
const result = await engine.annotate([
|
||||
{ type: 'point', target: 'Texas Capitol', label: 'Capitol' },
|
||||
]);
|
||||
|
||||
assert.equal(result.drawn, 0);
|
||||
assert.equal(engine.list().length, 0, 'no phantom annotation from a failed first add');
|
||||
assert.equal(
|
||||
engine.list().length,
|
||||
0,
|
||||
'no phantom annotation from a failed first add',
|
||||
);
|
||||
assert.ok(
|
||||
!getRenderGovernorDiagnostics().holds.includes('annotations'),
|
||||
'a never-rendered mark must not hold continuous render',
|
||||
@@ -593,14 +767,21 @@ function partialStateRendererHarness() {
|
||||
let failNextAdd = false;
|
||||
return {
|
||||
live,
|
||||
failAddOnce() { failNextAdd = true; },
|
||||
failAddOnce() {
|
||||
failNextAdd = true;
|
||||
},
|
||||
renderer: {
|
||||
add(anno) {
|
||||
live.set(anno.id, anno); // partial renderer state exists NOW
|
||||
if (failNextAdd) { failNextAdd = false; throw new Error('WebGL context lost'); }
|
||||
if (failNextAdd) {
|
||||
failNextAdd = false;
|
||||
throw new Error('WebGL context lost');
|
||||
}
|
||||
},
|
||||
update() {},
|
||||
remove(anno) { live.delete(anno.id); },
|
||||
remove(anno) {
|
||||
live.delete(anno.id);
|
||||
},
|
||||
sync() {},
|
||||
},
|
||||
};
|
||||
@@ -619,10 +800,16 @@ test('fresh path: a throw AFTER partial renderer state leaves nothing on the boa
|
||||
});
|
||||
|
||||
failAddOnce();
|
||||
const failed = await engine.annotate([{ type: 'point', target: 'Texas Capitol', label: 'Capitol' }]);
|
||||
const failed = await engine.annotate([
|
||||
{ type: 'point', target: 'Texas Capitol', label: 'Capitol' },
|
||||
]);
|
||||
assert.equal(failed.drawn, 0);
|
||||
assert.equal(engine.list().length, 0, 'no phantom annotation');
|
||||
assert.equal(live.size, 0, 'the rollback must release what the renderer had already created');
|
||||
assert.equal(
|
||||
live.size,
|
||||
0,
|
||||
'the rollback must release what the renderer had already created',
|
||||
);
|
||||
assert.ok(
|
||||
!getRenderGovernorDiagnostics().holds.includes('annotations'),
|
||||
'a never-rendered mark must not hold continuous render',
|
||||
@@ -630,9 +817,15 @@ test('fresh path: a throw AFTER partial renderer state leaves nothing on the boa
|
||||
|
||||
// The real cost of an orphan: re-annotating the same place stacks a second
|
||||
// mark on top of the one nothing owns.
|
||||
const retry = await engine.annotate([{ type: 'point', target: 'Texas Capitol', label: 'Capitol' }]);
|
||||
const retry = await engine.annotate([
|
||||
{ type: 'point', target: 'Texas Capitol', label: 'Capitol' },
|
||||
]);
|
||||
assert.equal(retry.drawn, 1);
|
||||
assert.equal(live.size, 1, 'a re-annotate must draw ONE mark, not stack over an orphan');
|
||||
assert.equal(
|
||||
live.size,
|
||||
1,
|
||||
'a re-annotate must draw ONE mark, not stack over an orphan',
|
||||
);
|
||||
});
|
||||
|
||||
test('duplicate-replacement: a throw AFTER partial renderer state leaves nothing on the board', async (t) => {
|
||||
@@ -647,7 +840,9 @@ test('duplicate-replacement: a throw AFTER partial renderer state leaves nothing
|
||||
resolveTarget: async () => ({ ...FIXED_POINT }),
|
||||
});
|
||||
|
||||
const first = await engine.annotate([{ type: 'point', target: 'Texas Capitol', label: 'Capitol' }]);
|
||||
const first = await engine.annotate([
|
||||
{ type: 'point', target: 'Texas Capitol', label: 'Capitol' },
|
||||
]);
|
||||
assert.equal(first.drawn, 1);
|
||||
assert.equal(live.size, 1);
|
||||
|
||||
@@ -655,10 +850,19 @@ test('duplicate-replacement: a throw AFTER partial renderer state leaves nothing
|
||||
// creates its state and then throws.
|
||||
failAddOnce();
|
||||
const swap = await engine.annotate([
|
||||
{ type: 'point', target: 'Texas Capitol', label: 'Capitol', color: 'amber' },
|
||||
{
|
||||
type: 'point',
|
||||
target: 'Texas Capitol',
|
||||
label: 'Capitol',
|
||||
color: 'amber',
|
||||
},
|
||||
]);
|
||||
assert.equal(swap.drawn, 0);
|
||||
assert.equal(engine.list().length, 0, 'no phantom annotation survives the failed swap');
|
||||
assert.equal(
|
||||
engine.list().length,
|
||||
0,
|
||||
'no phantom annotation survives the failed swap',
|
||||
);
|
||||
assert.equal(live.size, 0, 'and no half-swapped mark survives on the board');
|
||||
assert.ok(
|
||||
!getRenderGovernorDiagnostics().holds.includes('annotations'),
|
||||
@@ -666,23 +870,38 @@ test('duplicate-replacement: a throw AFTER partial renderer state leaves nothing
|
||||
);
|
||||
|
||||
const retry = await engine.annotate([
|
||||
{ type: 'point', target: 'Texas Capitol', label: 'Capitol', color: 'amber' },
|
||||
{
|
||||
type: 'point',
|
||||
target: 'Texas Capitol',
|
||||
label: 'Capitol',
|
||||
color: 'amber',
|
||||
},
|
||||
]);
|
||||
assert.equal(retry.drawn, 1);
|
||||
assert.equal(live.size, 1, 'the recoloured mark redraws once, with no orphan underneath');
|
||||
assert.equal(
|
||||
live.size,
|
||||
1,
|
||||
'the recoloured mark redraws once, with no orphan underneath',
|
||||
);
|
||||
});
|
||||
|
||||
test('destroy releases the renderer and prevents subsequent annotation work', async (t) => {
|
||||
installAnimationFrameStubs(t);
|
||||
const { renderer, calls } = fakeRenderer();
|
||||
let destroyed = 0;
|
||||
renderer.destroy = () => { destroyed++; };
|
||||
renderer.destroy = () => {
|
||||
destroyed++;
|
||||
};
|
||||
const engine = createAnnotationEngine({ viewer: {}, renderer });
|
||||
const drawn = await engine.annotate([{ type: 'pin', longitude: -97.74, latitude: 30.27 }]);
|
||||
const drawn = await engine.annotate([
|
||||
{ type: 'pin', longitude: -97.74, latitude: 30.27 },
|
||||
]);
|
||||
assert.equal(drawn.drawn, 1);
|
||||
engine.destroy();
|
||||
const additions = calls.add;
|
||||
const result = await engine.annotate([{ type: 'pin', target: 'Must not resolve' }]);
|
||||
const result = await engine.annotate([
|
||||
{ type: 'pin', target: 'Must not resolve' },
|
||||
]);
|
||||
assert.equal(result.error, 'destroyed');
|
||||
assert.equal(calls.add, additions);
|
||||
assert.equal(engine.count(), 0);
|
||||
@@ -698,10 +917,13 @@ test('a late annotation resolver cannot redraw after destruction', async (t) =>
|
||||
let release;
|
||||
let signal;
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {}, renderer,
|
||||
viewer: {},
|
||||
renderer,
|
||||
resolveTarget: (options) => {
|
||||
signal = options.signal;
|
||||
return new Promise((resolve) => { release = resolve; });
|
||||
return new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
},
|
||||
});
|
||||
const pending = engine.annotate([{ type: 'pin', target: 'Pending place' }]);
|
||||
@@ -713,6 +935,44 @@ test('a late annotation resolver cannot redraw after destruction', async (t) =>
|
||||
assert.equal(engine.count(), 0);
|
||||
});
|
||||
|
||||
test('caller cancellation prevents a late annotation and camera mutation', async (t) => {
|
||||
installAnimationFrameStubs(t);
|
||||
const { renderer, calls } = fakeRenderer();
|
||||
let release;
|
||||
let resolverSignal;
|
||||
let flights = 0;
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {
|
||||
camera: {
|
||||
flyToBoundingSphere() {
|
||||
flights += 1;
|
||||
},
|
||||
},
|
||||
},
|
||||
renderer,
|
||||
resolveTarget: ({ signal }) => {
|
||||
resolverSignal = signal;
|
||||
return new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
},
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const pending = engine.annotate([{ type: 'pin', target: 'Pending place' }], {
|
||||
flyTo: true,
|
||||
signal: controller.signal,
|
||||
isCurrent: () => !controller.signal.aborted,
|
||||
});
|
||||
controller.abort();
|
||||
assert.equal(resolverSignal.aborted, true);
|
||||
release({ lon: -97.74, lat: 30.27, label: 'Pending place' });
|
||||
const result = await pending;
|
||||
assert.equal(result.aborted, true);
|
||||
assert.equal(calls.add, 0);
|
||||
assert.equal(flights, 0);
|
||||
assert.equal(engine.count(), 0);
|
||||
});
|
||||
|
||||
test('only an explicit navigation request permits resolving distant annotation targets', async (t) => {
|
||||
installAnimationFrameStubs(t);
|
||||
_resetRenderGovernorForTest();
|
||||
@@ -720,11 +980,21 @@ test('only an explicit navigation request permits resolving distant annotation t
|
||||
const { renderer } = throwingRendererHarness();
|
||||
renderer.destroy = () => {};
|
||||
const received = [];
|
||||
const engine = createAnnotationEngine({ viewer: {}, renderer,
|
||||
resolveTarget: async (options) => { received.push(options); return null; },
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {},
|
||||
renderer,
|
||||
resolveTarget: async (options) => {
|
||||
received.push(options);
|
||||
return null;
|
||||
},
|
||||
});
|
||||
await engine.annotate([{ type: 'point', target: 'Remote landmark' }]);
|
||||
await engine.annotate([{ type: 'point', target: 'Remote landmark' }], { flyTo: true });
|
||||
assert.deepEqual(received.map(options => options.allowDistant), [false, true]);
|
||||
await engine.annotate([{ type: 'point', target: 'Remote landmark' }], {
|
||||
flyTo: true,
|
||||
});
|
||||
assert.deepEqual(
|
||||
received.map((options) => options.allowDistant),
|
||||
[false, true],
|
||||
);
|
||||
engine.destroy();
|
||||
});
|
||||
Reference in new issue
Block a user