mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
test(street-level): close the mutation survivors; Esc in rich text
A mutation review left 18 earlier survivors; each now has a test that fails with the mutant applied: - the expanded viewer returns into the panel on shrink, close and destroy (the fake DOM had no parentNode, so reinsertion was never exercised); the fake DOM gains offsetParent and a MutationObserver that honours its options, and a <body> child added while expanded goes inert; - each pose moves the marker, drives FOLLOW and selects a new sequence; close() retires an open still mounting or opening; a provider toggled while the layer is off activates nothing; the adapter drops late and superseded poses; - share-link defaults: "any date" (0 days), the codec max pinned to the filter's MAX_SINCE_DAYS (layerState cannot import it across package boundaries, so a test ties the two), Mapillary on when a link omits it; - Esc that something else handled, or typed in a field, keeps the selection; a key problem outranks LOADING on the pill; - the marker's terrain height (mesh, DEM, clamp then re-place); - proxy: LRU memory cache and its half-budget rule, Retry-After capped, a request after a cancelled fetch starts a fresh one; - date-line tile ranking with and without focus; the horizon cull idles while the camera stands still; the old zoom goes as soon as the new one lands; a rate limit holds every refresh inside its wait. One real bug found and fixed: Esc inside a contenteditable cleared the selected sequence (selection.js now ignores isContentEditable targets). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
9dde7b4a62
commit
3af0f80cdb
15 files changed
+1198
-30
No files matched your search
@@ -30,6 +30,11 @@ registerHooks({
|
||||
const { createMapillaryViewer } =
|
||||
await import('./providers/mapillary/viewer.js');
|
||||
|
||||
/**
|
||||
* A viewer host over `adapter`. `marker` records where the position marker
|
||||
* was put (null once cleared), `followed` counts camera-follow updates and
|
||||
* `selected` every sequence the provider was asked to select.
|
||||
*/
|
||||
function harness(adapter) {
|
||||
const state = {
|
||||
enabled: true,
|
||||
@@ -38,17 +43,44 @@ function harness(adapter) {
|
||||
street: { host: {}, renderMode: 'letterbox', follow: false },
|
||||
};
|
||||
const def = { id: 'mapillary', name: 'Mapillary', label: 'MAPILLARY' };
|
||||
state.providers.set('mapillary', { def, instance: { viewer: adapter } });
|
||||
const selected = [];
|
||||
let selectedId = null;
|
||||
const instance = {
|
||||
viewer: adapter,
|
||||
selectSequence(id) {
|
||||
selected.push(id);
|
||||
selectedId = id;
|
||||
},
|
||||
sequenceStats: () => ({ selectedId }),
|
||||
};
|
||||
state.providers.set('mapillary', { def, instance });
|
||||
const framing = { started: 0, cancelled: 0 };
|
||||
const marker = [];
|
||||
const followed = { count: 0 };
|
||||
const parts = {
|
||||
marker: { set() {}, clear() {} },
|
||||
marker: {
|
||||
set: (position, bearing) =>
|
||||
marker.push(position && { position, bearing }),
|
||||
clear: () => marker.push(null),
|
||||
},
|
||||
follow: {
|
||||
followCamera() {},
|
||||
followCamera: () => followed.count++,
|
||||
lookAtPosition: () => framing.started++,
|
||||
cancelFraming: () => framing.cancelled++,
|
||||
},
|
||||
};
|
||||
return { state, framing, host: createViewerHost({ state, parts }) };
|
||||
return {
|
||||
state,
|
||||
framing,
|
||||
marker,
|
||||
followed,
|
||||
selected,
|
||||
/** Esc on the map: the provider drops its selected sequence. */
|
||||
clearSequence() {
|
||||
selectedId = null;
|
||||
},
|
||||
host: createViewerHost({ state, parts }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,7 +88,7 @@ function harness(adapter) {
|
||||
* construction (`gate` holds it open) and `unmount` destroys the viewer that
|
||||
* every later `open` needs.
|
||||
*/
|
||||
function fakeAdapter({ failMounts = 0, gate = null } = {}) {
|
||||
function fakeAdapter({ failMounts = 0, gate = null, openGate = null } = {}) {
|
||||
const calls = { mount: 0, open: [], unmount: 0, listeners: 0 };
|
||||
let emit = null;
|
||||
let mounted = false;
|
||||
@@ -70,7 +102,7 @@ function fakeAdapter({ failMounts = 0, gate = null } = {}) {
|
||||
mounted = true;
|
||||
},
|
||||
/** A pose event from the library, as MapillaryJS fires them. */
|
||||
emitPose(id) {
|
||||
emitPose(id, overrides = {}) {
|
||||
emit?.({
|
||||
providerId: 'mapillary',
|
||||
imageId: id,
|
||||
@@ -79,12 +111,15 @@ function fakeAdapter({ failMounts = 0, gate = null } = {}) {
|
||||
bearing: 90,
|
||||
isPano: false,
|
||||
externalUrl: `https://example.test/${id}`,
|
||||
...overrides,
|
||||
});
|
||||
},
|
||||
/** Like MapillaryJS: the image event comes first, then `moveTo` settles. */
|
||||
async open(id) {
|
||||
if (!mounted) throw new Error('viewer is not mounted');
|
||||
calls.open.push(id);
|
||||
this.emitPose(id);
|
||||
await openGate?.promise;
|
||||
},
|
||||
close() {},
|
||||
unmount() {
|
||||
@@ -220,10 +255,115 @@ test('switching the layer off and on during a cold first open keeps the shared v
|
||||
assert.equal(state.street.imageId, 'c');
|
||||
});
|
||||
|
||||
/** MapillaryJS, counting live viewers (each holds a WebGL context). */
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
test('every pose the viewer reports moves the marker and the followed camera', async () => {
|
||||
const adapter = fakeAdapter();
|
||||
const { state, host, marker, followed } = harness(adapter);
|
||||
await host.open('mapillary', 'a');
|
||||
assert.deepEqual(marker, [{ position: { lon: 1, lat: 2 }, bearing: 90 }]);
|
||||
assert.equal(followed.count, 1);
|
||||
// The user walks on, with FOLLOW on: the globe camera goes along.
|
||||
state.street.follow = true;
|
||||
adapter.emitPose('b', { position: { lon: 3, lat: 4 }, bearing: 180 });
|
||||
assert.deepEqual(marker.at(-1), {
|
||||
position: { lon: 3, lat: 4 },
|
||||
bearing: 180,
|
||||
});
|
||||
assert.equal(followed.count, 2);
|
||||
// Looking around in place is a pose too.
|
||||
adapter.emitPose('b', { position: { lon: 3, lat: 4 }, bearing: 270 });
|
||||
assert.equal(marker.at(-1).bearing, 270);
|
||||
assert.equal(followed.count, 3);
|
||||
});
|
||||
|
||||
test('a pose on another sequence selects it, but not while the image still loads', async () => {
|
||||
const openGate = deferred();
|
||||
const adapter = fakeAdapter({ openGate });
|
||||
const { state, host, selected, clearSequence } = harness(adapter);
|
||||
const opening = host.open('mapillary', 'a');
|
||||
await settle();
|
||||
assert.equal(state.street.loading, true, 'the image is downloading');
|
||||
assert.equal(state.street.sequenceId, 'seq-a', 'its pose is in');
|
||||
assert.deepEqual(selected, [], 'no sequence lookup competes with it');
|
||||
openGate.resolve();
|
||||
assert.equal(await opening, true);
|
||||
assert.deepEqual(selected, ['seq-a']);
|
||||
// Stepping onto the next sequence (an arrow at a junction) selects it …
|
||||
adapter.emitPose('b');
|
||||
assert.deepEqual(selected, ['seq-a', 'seq-b']);
|
||||
// … and walking along it does not ask again.
|
||||
adapter.emitPose('c', { sequenceId: 'seq-b' });
|
||||
assert.deepEqual(selected, ['seq-a', 'seq-b']);
|
||||
// Once Esc cleared it, looking around on the same sequence leaves it so.
|
||||
clearSequence();
|
||||
adapter.emitPose('c', { sequenceId: 'seq-b', bearing: 10 });
|
||||
assert.deepEqual(selected, ['seq-a', 'seq-b']);
|
||||
});
|
||||
|
||||
test('closing the photo clears the marker', async () => {
|
||||
const adapter = fakeAdapter();
|
||||
const { host, marker } = harness(adapter);
|
||||
await host.open('mapillary', 'a');
|
||||
assert.notEqual(marker.at(-1), null);
|
||||
host.close();
|
||||
assert.equal(marker.at(-1), null);
|
||||
});
|
||||
|
||||
test('an open still mounting when the photo is closed does nothing when the mount lands', async () => {
|
||||
const gate = deferred();
|
||||
const adapter = fakeAdapter({ gate });
|
||||
const { state, host, framing, selected } = harness(adapter);
|
||||
const opening = host.open('mapillary', 'a'); // MapillaryJS still loading
|
||||
await settle();
|
||||
host.close();
|
||||
gate.resolve();
|
||||
assert.equal(await opening, false);
|
||||
assert.deepEqual(adapter.calls.open, [], 'the image is never opened');
|
||||
assert.equal(framing.started, 0, 'the globe does not fly to it');
|
||||
assert.deepEqual(selected, []);
|
||||
assert.deepEqual(
|
||||
[state.street.open, state.street.loading, state.street.imageId],
|
||||
[false, false, null],
|
||||
);
|
||||
// The mounted viewer serves the next open.
|
||||
assert.equal(await host.open('mapillary', 'b'), true);
|
||||
assert.equal(state.street.imageId, 'b');
|
||||
});
|
||||
|
||||
test('an image still opening when the photo is closed is neither framed nor selected', async () => {
|
||||
const openGate = deferred();
|
||||
const adapter = fakeAdapter({ openGate });
|
||||
const { state, host, framing, selected } = harness(adapter);
|
||||
const opening = host.open('mapillary', 'a');
|
||||
await settle();
|
||||
assert.deepEqual(adapter.calls.open, ['a'], 'the image is downloading');
|
||||
host.close();
|
||||
openGate.resolve();
|
||||
assert.equal(await opening, false);
|
||||
assert.equal(framing.started, 0, 'the globe does not fly to it');
|
||||
assert.deepEqual(selected, []);
|
||||
assert.deepEqual(
|
||||
[state.street.open, state.street.loading, state.street.imageId],
|
||||
[false, false, null],
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* MapillaryJS, counting live viewers (each holds a WebGL context). A test can
|
||||
* hold `moveTo(id)` on `viewers.moveGates.get(id)` and every position read on
|
||||
* `viewers.positionGate`, and fire a viewer's events through `handlers`.
|
||||
*/
|
||||
function fakeLibrary() {
|
||||
const gate = deferred();
|
||||
const viewers = { created: 0, live: 0, instances: [] };
|
||||
const viewers = {
|
||||
created: 0,
|
||||
live: 0,
|
||||
instances: [],
|
||||
moveGates: new Map(),
|
||||
moves: [],
|
||||
positionGate: null,
|
||||
};
|
||||
class Viewer {
|
||||
constructor() {
|
||||
viewers.created++;
|
||||
@@ -231,21 +371,27 @@ function fakeLibrary() {
|
||||
viewers.instances.push(this);
|
||||
this.removed = false;
|
||||
this.playback = { stops: 0 };
|
||||
this.handlers = {};
|
||||
}
|
||||
/** The sequence component's play/stop API, as MapillaryJS 4 exposes it. */
|
||||
getComponent(name) {
|
||||
if (name !== 'sequence') return undefined;
|
||||
return { stop: () => this.playback.stops++ };
|
||||
}
|
||||
on() {}
|
||||
on(type, handler) {
|
||||
this.handlers[type] = handler;
|
||||
}
|
||||
async getPosition() {
|
||||
await viewers.positionGate?.promise;
|
||||
return { lng: 1, lat: 2 };
|
||||
}
|
||||
async getPointOfView() {
|
||||
return { bearing: 10, tilt: 0 };
|
||||
}
|
||||
async moveTo(id) {
|
||||
return { id, cameraType: 'perspective', sequenceId: 's' };
|
||||
viewers.moves.push(id);
|
||||
await viewers.moveGates.get(id)?.promise;
|
||||
return { id, cameraType: 'perspective', sequenceId: `seq-${id}` };
|
||||
}
|
||||
remove() {
|
||||
if (this.removed) return;
|
||||
@@ -337,3 +483,67 @@ test('closing the photo or switching the layer off stops its framing flight (rev
|
||||
host.unmount();
|
||||
assert.equal(framing.cancelled, 2, 'layer off: likewise');
|
||||
});
|
||||
|
||||
/** The real Mapillary adapter, mounted on a ready library, and its poses. */
|
||||
async function mountedMapillary() {
|
||||
const library = fakeLibrary();
|
||||
library.gate.resolve();
|
||||
const adapter = createMapillaryViewer({ source: { token: 't' } });
|
||||
const poses = [];
|
||||
adapter.onPose((pose) => poses.push(pose));
|
||||
await adapter.mount({});
|
||||
return { ...library, adapter, poses };
|
||||
}
|
||||
|
||||
test('a Mapillary pose still being read when the photo closes is never emitted (M57)', async () => {
|
||||
const { viewers, adapter, poses } = await mountedMapillary();
|
||||
await adapter.open('a');
|
||||
assert.deepEqual(
|
||||
poses.map((pose) => pose.imageId),
|
||||
['a'],
|
||||
);
|
||||
// The library steps to the next image; its position is still being read.
|
||||
const [viewer] = viewers.instances;
|
||||
viewers.positionGate = deferred();
|
||||
viewer.handlers.image({
|
||||
image: { id: 'b', cameraType: 'perspective', sequenceId: 'seq-b' },
|
||||
});
|
||||
adapter.close();
|
||||
viewers.positionGate.resolve();
|
||||
await settle();
|
||||
assert.deepEqual(
|
||||
poses.map((pose) => pose.imageId),
|
||||
['a'],
|
||||
'the closed photo does not come back',
|
||||
);
|
||||
});
|
||||
|
||||
test('a Mapillary image overtaken by a newer open emits no pose when it lands (M58)', async () => {
|
||||
const { viewers, adapter, poses } = await mountedMapillary();
|
||||
const gateA = deferred();
|
||||
const gateB = deferred();
|
||||
viewers.moveGates.set('a', gateA);
|
||||
viewers.moveGates.set('b', gateB);
|
||||
const openA = adapter.open('a');
|
||||
await settle(); // A's image is downloading …
|
||||
const openB = adapter.open('b'); // … when the user picks another one
|
||||
gateA.resolve();
|
||||
await openA;
|
||||
assert.deepEqual(poses, [], 'nothing for the image the user left');
|
||||
gateB.resolve();
|
||||
await openB;
|
||||
assert.deepEqual(
|
||||
poses.map((pose) => [pose.imageId, pose.sequenceId]),
|
||||
[['b', 'seq-b']],
|
||||
);
|
||||
});
|
||||
|
||||
test('back-to-back Mapillary opens download only the newer image', async () => {
|
||||
const { viewers, adapter, poses } = await mountedMapillary();
|
||||
await Promise.all([adapter.open('a'), adapter.open('b')]);
|
||||
assert.deepEqual(viewers.moves, ['b']);
|
||||
assert.deepEqual(
|
||||
poses.map((pose) => pose.imageId),
|
||||
['b'],
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user