mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +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
@@ -419,6 +419,8 @@ const OPTION_GROUPS = Object.freeze({
|
||||
flat: 'f',
|
||||
}),
|
||||
// "Captured since" as relative days, so a link means the same next year.
|
||||
// The filter's MAX_SINCE_DAYS (streetLevel/policy.js); layerState.test
|
||||
// pins the two together.
|
||||
boundedIntegerOption('sinceDays', 's', 0, { min: 0, max: 36500 }),
|
||||
]),
|
||||
radio: Object.freeze([
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
import { LayerStateCoordinator } from './layerStateCoordinator.js';
|
||||
import radioLayer from './radio.js';
|
||||
import { stampInitialShareGesture } from '../navigationPolicy.js';
|
||||
import { MAX_SINCE_DAYS } from '../layers/streetLevel/policy.js';
|
||||
import { SINCE_STOPS } from '../ui/streetLevelPresentation.js';
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
@@ -2642,3 +2644,63 @@ test('fire perimeters uses digit 2 without colliding with wind or recent imagery
|
||||
decoded,
|
||||
);
|
||||
});
|
||||
|
||||
/** Street Level options decoded from a v2 link with these `lo` assignments. */
|
||||
function streetLevelOptions(lo) {
|
||||
const params = new URLSearchParams([
|
||||
['v', '2'],
|
||||
['l', '0'],
|
||||
]);
|
||||
if (lo) params.set('lo', lo);
|
||||
return decodeLayerStateParams(params).options['street-level'];
|
||||
}
|
||||
|
||||
test('Street Level: every option round-trips through a share link and stored state', () => {
|
||||
const state = createDefaultLayerState();
|
||||
state.enabledLayerIds = ['street-level'];
|
||||
state.options['street-level'] = {
|
||||
mapillary: false,
|
||||
pano: 'flat',
|
||||
sinceDays: MAX_SINCE_DAYS,
|
||||
};
|
||||
const decoded = decodeLayerStateParams(new URLSearchParams(encode(state)));
|
||||
assert.deepEqual(decoded.options['street-level'], {
|
||||
mapillary: false,
|
||||
pano: 'flat',
|
||||
sinceDays: MAX_SINCE_DAYS,
|
||||
});
|
||||
assert.deepEqual(
|
||||
parseStoredLayerState(serializeStoredLayerState(state)).options[
|
||||
'street-level'
|
||||
],
|
||||
state.options['street-level'],
|
||||
);
|
||||
});
|
||||
|
||||
test('Street Level: the link codec accepts exactly the windows the filter keeps', () => {
|
||||
// The codec's ceiling is the filter's MAX_SINCE_DAYS: a longer window is
|
||||
// rejected (read as "any date"), the longest one is kept whole.
|
||||
assert.equal(
|
||||
streetLevelOptions(`0.s.${MAX_SINCE_DAYS}`).sinceDays,
|
||||
MAX_SINCE_DAYS,
|
||||
);
|
||||
assert.equal(streetLevelOptions(`0.s.${MAX_SINCE_DAYS + 1}`).sinceDays, 0);
|
||||
// Every SINCE slider stop survives, the LAST 10 YEARS one included.
|
||||
for (const { days } of SINCE_STOPS)
|
||||
assert.equal(streetLevelOptions(`0.s.${days}`).sinceDays, days);
|
||||
assert.equal(streetLevelOptions('0.s.3652').sinceDays, 3652);
|
||||
});
|
||||
|
||||
test('Street Level: a link without the provider switch keeps Mapillary on', () => {
|
||||
assert.deepEqual(streetLevelOptions(''), {
|
||||
mapillary: true,
|
||||
pano: 'all',
|
||||
sinceDays: 0,
|
||||
});
|
||||
assert.equal(streetLevelOptions('0.p.f').mapillary, true);
|
||||
assert.equal(streetLevelOptions('0.m.0').mapillary, false);
|
||||
assert.equal(
|
||||
createDefaultLayerState().options['street-level'].mapillary,
|
||||
true,
|
||||
);
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
resolveFilter,
|
||||
sameFilter,
|
||||
} from './filter.js';
|
||||
import { MAX_SINCE_DAYS } from './policy.js';
|
||||
|
||||
const DAY = 86_400_000;
|
||||
|
||||
@@ -81,3 +82,20 @@ test('sameFilter compares the stored form', () => {
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeFilter takes "any date" (0 days) over a current window (share-link defaults)', () => {
|
||||
// Dragging SINCE back to ANY DATE, or a link that says so, must stick.
|
||||
assert.equal(
|
||||
normalizeFilter({ sinceDays: 0 }, { pano: 'all', sinceDays: 365 })
|
||||
.sinceDays,
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
normalizeFilter(
|
||||
{ sinceDays: MAX_SINCE_DAYS },
|
||||
{ pano: 'all', sinceDays: 0 },
|
||||
).sinceDays,
|
||||
MAX_SINCE_DAYS,
|
||||
'the longest window is kept whole',
|
||||
);
|
||||
});
|
||||
@@ -419,3 +419,66 @@ test('the Mapillary provider hands the lookup signal to its source (review IC8 P
|
||||
);
|
||||
assert.equal(requests[0]?.signal, signal);
|
||||
});
|
||||
|
||||
test('setParams takes "any date" (0 days) and a provider switch over the current values (share-link defaults)', async (t) => {
|
||||
const provider = fakeProvider();
|
||||
const { layer } = await enabledLayer(t, [provider]);
|
||||
layer.setParams({ pano: 'flat', sinceDays: 365 });
|
||||
assert.equal(layer.getParams().sinceDays, 365);
|
||||
layer.setParams({ sinceDays: 0 });
|
||||
assert.deepEqual(layer.getParams(), {
|
||||
mapillary: true,
|
||||
pano: 'flat',
|
||||
sinceDays: 0,
|
||||
});
|
||||
assert.equal(
|
||||
provider.filters.at(-1).sinceMs,
|
||||
null,
|
||||
'providers drop the cut-off',
|
||||
);
|
||||
layer.setParams({ mapillary: false });
|
||||
assert.equal(layer.getParams().mapillary, false);
|
||||
});
|
||||
|
||||
/** A layer initialised on a stand-in viewer but never enabled. */
|
||||
function initialisedLayer(t, providers) {
|
||||
const saved = globalThis.document;
|
||||
const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) });
|
||||
globalThis.document = Object.assign(new EventTarget(), {
|
||||
createElement: () => ({ getContext: () => drawing }),
|
||||
});
|
||||
const viewer = fakeViewer();
|
||||
const layer = createStreetLevelLayer({ providers });
|
||||
layer.init(viewer);
|
||||
t.after(() => {
|
||||
layer.destroy();
|
||||
globalThis.document = saved;
|
||||
});
|
||||
return { layer, viewer };
|
||||
}
|
||||
|
||||
test('switching a provider on while the layer is off activates nothing (M01)', async (t) => {
|
||||
const provider = fakeProvider();
|
||||
const prewarmed = [];
|
||||
const create = provider.create;
|
||||
provider.create = (context) => {
|
||||
const instance = create(context);
|
||||
instance.viewer.prewarm = async (host) => prewarmed.push(host);
|
||||
return instance;
|
||||
};
|
||||
const { layer, viewer } = initialisedLayer(t, [provider]);
|
||||
layer.attachViewerHost({});
|
||||
layer.setProviderEnabled('mapillary', false);
|
||||
layer.setProviderEnabled('mapillary', true);
|
||||
layer.setParams({ mapillary: false });
|
||||
layer.setParams({ mapillary: true });
|
||||
await new Promise((resolve) => setTimeout(resolve, 40)); // past whenIdle
|
||||
assert.equal(provider.calls.activate, 0, 'no coverage drawn');
|
||||
assert.deepEqual(viewer.credits, [], 'no credit shown');
|
||||
assert.deepEqual(prewarmed, [], 'no viewer stood up');
|
||||
assert.equal(layer.getUIState().providers[0].on, true, 'the switch is kept');
|
||||
// Enabling the layer is what activates it, once.
|
||||
layer.enable(viewer);
|
||||
assert.equal(provider.calls.activate, 1);
|
||||
assert.equal(viewer.credits.length, 1);
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createMarker } from './marker.js';
|
||||
import { GROUND_CAST_LIFT_M, MESH_LIFT_M } from './groundCast.js';
|
||||
|
||||
// The marker glyph draws on a canvas; Node has none, so drawing is a no-op.
|
||||
const noop = () => {};
|
||||
@@ -11,7 +12,7 @@ globalThis.document ??= {
|
||||
};
|
||||
|
||||
/** A viewer whose camera is 20,000 km over `view`, and its pre-render listeners. */
|
||||
function setup() {
|
||||
function setup({ parts = {}, surface = 'draped' } = {}) {
|
||||
const view = { lon: -121.49, lat: 38.58, height: 20_000_000 };
|
||||
const preRender = new Set();
|
||||
const viewer = {
|
||||
@@ -33,7 +34,7 @@ function setup() {
|
||||
const state = {
|
||||
services: {},
|
||||
viewer,
|
||||
surface: 'draped',
|
||||
surface,
|
||||
marker: {
|
||||
// Stands in for the billboard collection; ensure() keeps it.
|
||||
collection: {
|
||||
@@ -44,7 +45,7 @@ function setup() {
|
||||
billboard: null,
|
||||
},
|
||||
};
|
||||
const marker = createMarker({ state, parts: {} });
|
||||
const marker = createMarker({ state, parts });
|
||||
const frame = () => {
|
||||
for (const listener of [...preRender]) listener();
|
||||
};
|
||||
@@ -76,3 +77,113 @@ test('destroying the marker stops the horizon cull', () => {
|
||||
marker.destroy();
|
||||
assert.equal(preRender.size, 0);
|
||||
});
|
||||
|
||||
/**
|
||||
* Bare earth from a ground caster (null until its cell is fetched) and the
|
||||
* sampled Google 3D surface from a mesh sampler (undefined until sampled).
|
||||
*/
|
||||
function terrainParts({ dem = null, mesh } = {}) {
|
||||
const ground = { dem, prepares: [] };
|
||||
const sampler = { mesh, requests: [], listeners: [] };
|
||||
return {
|
||||
ground,
|
||||
sampler,
|
||||
parts: {
|
||||
groundCaster: {
|
||||
groundAt: () => ground.dem,
|
||||
prepare(points) {
|
||||
let resolve;
|
||||
const promise = new Promise((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
ground.prepares.push({ points, resolve });
|
||||
return promise;
|
||||
},
|
||||
},
|
||||
meshSampler: {
|
||||
meshAt: () => sampler.mesh,
|
||||
request: (points) => sampler.requests.push(points),
|
||||
onSampled: (listener) => sampler.listeners.push(listener),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const heightOf = (billboard) =>
|
||||
Cesium.Cartographic.fromCartesian(billboard.position).height;
|
||||
const SPOT = { lon: -121.49, lat: 38.58 };
|
||||
|
||||
test('in terrain mode the marker stands on the sampled road surface (mesh near bare earth)', () => {
|
||||
const { parts } = terrainParts({ dem: 10, mesh: 11.5 });
|
||||
const { state, marker } = setup({ parts, surface: 'terrain' });
|
||||
marker.set(SPOT, 45);
|
||||
const billboard = state.marker.billboard;
|
||||
assert.equal(billboard.heightReference, Cesium.HeightReference.NONE);
|
||||
assert.ok(
|
||||
Math.abs(heightOf(billboard) - (11.5 + MESH_LIFT_M)) < 1e-3,
|
||||
`height ${heightOf(billboard)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('in terrain mode without a mesh sample the marker stands on bare earth and asks for one', () => {
|
||||
const { parts, sampler } = terrainParts({ dem: 10 });
|
||||
const { state, marker } = setup({ parts, surface: 'terrain' });
|
||||
marker.set(SPOT, 45);
|
||||
const billboard = state.marker.billboard;
|
||||
assert.equal(billboard.heightReference, Cesium.HeightReference.NONE);
|
||||
assert.ok(
|
||||
Math.abs(heightOf(billboard) - (10 + GROUND_CAST_LIFT_M)) < 1e-3,
|
||||
`height ${heightOf(billboard)}`,
|
||||
);
|
||||
assert.deepEqual(sampler.requests, [[[SPOT.lon, SPOT.lat]]]);
|
||||
// The sample lands: the marker moves onto the road surface.
|
||||
sampler.mesh = 10.5;
|
||||
for (const listener of sampler.listeners) listener();
|
||||
assert.ok(Math.abs(heightOf(billboard) - (10.5 + MESH_LIFT_M)) < 1e-3);
|
||||
});
|
||||
|
||||
test('in terrain mode with no ground yet the marker clamps, then stands up once the ground arrives', async () => {
|
||||
const { parts, ground } = terrainParts({ mesh: 11 });
|
||||
const { state, marker } = setup({ parts, surface: 'terrain' });
|
||||
marker.set(SPOT, 45);
|
||||
const billboard = state.marker.billboard;
|
||||
assert.equal(
|
||||
billboard.heightReference,
|
||||
Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
);
|
||||
assert.deepEqual(ground.prepares[0].points, [[SPOT.lon, SPOT.lat]]);
|
||||
ground.dem = 10;
|
||||
ground.prepares[0].resolve(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(billboard.heightReference, Cesium.HeightReference.NONE);
|
||||
assert.ok(Math.abs(heightOf(billboard) - (11 + MESH_LIFT_M)) < 1e-3);
|
||||
assert.equal(billboard.rotation, -Cesium.Math.toRadians(45), 'bearing kept');
|
||||
});
|
||||
|
||||
test('ground that arrives after the marker moved on does not move it back', async () => {
|
||||
const { parts, ground } = terrainParts();
|
||||
const { state, marker } = setup({ parts, surface: 'terrain' });
|
||||
marker.set(SPOT, 0);
|
||||
const elsewhere = { lon: -121.5, lat: 38.6 };
|
||||
marker.set(elsewhere, 0);
|
||||
ground.dem = 10;
|
||||
ground.prepares[0].resolve(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const placed = Cesium.Cartographic.fromCartesian(
|
||||
state.marker.billboard.position,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(Cesium.Math.toDegrees(placed.longitude) - elsewhere.lon) < 1e-9,
|
||||
);
|
||||
});
|
||||
|
||||
test('draped mode clamps the marker to the globe whatever the ground says', () => {
|
||||
const { parts, ground } = terrainParts({ dem: 10, mesh: 11 });
|
||||
const { state, marker } = setup({ parts, surface: 'draped' });
|
||||
marker.set(SPOT, 0);
|
||||
assert.equal(
|
||||
state.marker.billboard.heightReference,
|
||||
Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
);
|
||||
assert.equal(ground.prepares.length, 0);
|
||||
});
|
||||
@@ -38,3 +38,21 @@ test('decodeParams round-trips and ignores unknown or malformed keys', () => {
|
||||
assert.equal(empty.providers.size, 0);
|
||||
assert.deepEqual(empty.filter, current);
|
||||
});
|
||||
|
||||
test('decodeParams applies "any date" (0 days) over a current window (share-link defaults)', () => {
|
||||
const decoded = decodeParams(
|
||||
{ sinceDays: 0 },
|
||||
{ providerIds: ['mapillary'], filter: { pano: 'flat', sinceDays: 365 } },
|
||||
);
|
||||
assert.deepEqual(decoded.filter, { pano: 'flat', sinceDays: 0 });
|
||||
// The provider switch off is a value too, not an absence.
|
||||
assert.deepEqual(
|
||||
[
|
||||
...decodeParams(
|
||||
{ mapillary: false },
|
||||
{ providerIds: ['mapillary'], filter: { pano: 'all', sinceDays: 0 } },
|
||||
).providers,
|
||||
],
|
||||
[['mapillary', false]],
|
||||
);
|
||||
});
|
||||
@@ -839,3 +839,57 @@ test('only lines within the mesh sampler range of the camera are asked for (revi
|
||||
assert.ok(farthest < 200, `nothing from the corner (${farthest} m)`);
|
||||
coverage.clear();
|
||||
});
|
||||
|
||||
test('the old zoom goes as soon as the new zoom has loaded, not after the stale wait', async () => {
|
||||
const { viewer, source, state, coverage, bytes } = setup();
|
||||
coverage.refresh();
|
||||
source.calls[0].resolve(bytes);
|
||||
await settle();
|
||||
const [old] = viewer.scene.groundPrimitives.items;
|
||||
assert.ok(old, 'the z14 lines are drawn');
|
||||
viewer.view.height = 3000; // zoom out to z13
|
||||
coverage.refresh();
|
||||
assert.equal(state.coverage.zoom, 13);
|
||||
const fresh = source.calls.slice(1);
|
||||
assert.ok(fresh.length > 0, 'the new zoom is loading');
|
||||
assert.equal(state.coverage.stale.size, 1, 'the old zoom stays meanwhile');
|
||||
assert.ok(viewer.scene.groundPrimitives.items.has(old));
|
||||
for (const call of fresh) {
|
||||
assert.ok(
|
||||
viewer.scene.groundPrimitives.items.has(old),
|
||||
'kept while a new tile is still loading',
|
||||
);
|
||||
call.resolve(new Uint8Array());
|
||||
await settle();
|
||||
}
|
||||
// The last new tile is in: the old lines go now, long before the wait.
|
||||
assert.equal(state.coverage.pending.size, 0);
|
||||
assert.equal(state.coverage.stale.size, 0);
|
||||
assert.equal(viewer.scene.groundPrimitives.items.has(old), false);
|
||||
assert.equal(state.coverage.staleTimer, null, 'the stale wait is cancelled');
|
||||
coverage.clear();
|
||||
});
|
||||
|
||||
test('a rate limit holds every refresh inside its wait, not only the first (review IC8 P2)', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
|
||||
const { viewer, source, coverage } = setup();
|
||||
coverage.refresh();
|
||||
source.calls[0].reject(
|
||||
Object.assign(new Error('rate limited'), { retryAfterSec: 30 }),
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
t.mock.timers.tick(1000);
|
||||
viewer.view.lon += 0.05;
|
||||
coverage.refresh();
|
||||
assert.equal(source.calls.length, 1, 'held 1 s into a 30 s wait');
|
||||
t.mock.timers.tick(28_000);
|
||||
viewer.view.lon += 0.05;
|
||||
coverage.refresh();
|
||||
assert.equal(source.calls.length, 1, 'held 29 s into a 30 s wait');
|
||||
t.mock.timers.tick(1000);
|
||||
assert.ok(source.calls.length > 1, 'asked again once the wait is over');
|
||||
coverage.clear();
|
||||
coverage.resetErrors();
|
||||
});
|
||||
@@ -36,8 +36,11 @@ export function createSelection({ state, parts }) {
|
||||
*/
|
||||
function onKeyDown(event) {
|
||||
if (event.key !== 'Escape' || event.defaultPrevented) return;
|
||||
// A text field keeps its Esc, rich-text editors (contenteditable) included.
|
||||
const target = event.target;
|
||||
if (
|
||||
event.target?.closest?.(
|
||||
target?.isContentEditable === true ||
|
||||
target?.closest?.(
|
||||
'.panel-collapsible, [role="dialog"], input, textarea, select',
|
||||
)
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ import { createSelection } from './selection.js';
|
||||
* `frame()` runs them; timers and Date.now() are mocked so a burst of moves
|
||||
* has a known timeline.
|
||||
*/
|
||||
function harness() {
|
||||
function harness({ selected = false } = {}) {
|
||||
const saved = {
|
||||
document: globalThis.document,
|
||||
requestAnimationFrame: globalThis.requestAnimationFrame,
|
||||
@@ -17,6 +17,23 @@ function harness() {
|
||||
const frames = [];
|
||||
globalThis.requestAnimationFrame = (task) => frames.push(task);
|
||||
globalThis.document = new EventTarget();
|
||||
// The document's keydown listeners, so a test can hand them any target.
|
||||
const keyListeners = new Set();
|
||||
const addListener = globalThis.document.addEventListener.bind(
|
||||
globalThis.document,
|
||||
);
|
||||
const removeListener = globalThis.document.removeEventListener.bind(
|
||||
globalThis.document,
|
||||
);
|
||||
globalThis.document.addEventListener = (type, listener, options) => {
|
||||
if (type === 'keydown') keyListeners.add(listener);
|
||||
addListener(type, listener, options);
|
||||
};
|
||||
globalThis.document.removeEventListener = (type, listener, options) => {
|
||||
if (type === 'keydown') keyListeners.delete(listener);
|
||||
removeListener(type, listener, options);
|
||||
};
|
||||
const sequences = { selected, cleared: 0 };
|
||||
const picks = [];
|
||||
/** What the pointer is over: a line this layer owns, or nothing. */
|
||||
const scene = { under: null };
|
||||
@@ -48,8 +65,11 @@ function harness() {
|
||||
state,
|
||||
parts: {
|
||||
router: { ownsPick: (id) => id === 'mly:seq:1', resolve: () => null },
|
||||
hasSelectedSequence: () => false,
|
||||
clearSequences() {},
|
||||
hasSelectedSequence: () => sequences.selected,
|
||||
clearSequences() {
|
||||
sequences.cleared++;
|
||||
sequences.selected = false;
|
||||
},
|
||||
},
|
||||
});
|
||||
selection.install(viewer);
|
||||
@@ -58,6 +78,20 @@ function harness() {
|
||||
);
|
||||
return {
|
||||
state,
|
||||
sequences,
|
||||
/** A keydown reaching the document from `target`; the event as handled. */
|
||||
key(key, target, { defaultPrevented = false } = {}) {
|
||||
const event = {
|
||||
key,
|
||||
target,
|
||||
defaultPrevented,
|
||||
preventDefault() {
|
||||
this.defaultPrevented = true;
|
||||
},
|
||||
};
|
||||
for (const listener of [...keyListeners]) listener(event);
|
||||
return event;
|
||||
},
|
||||
scene,
|
||||
canvas,
|
||||
camera,
|
||||
@@ -168,3 +202,58 @@ test('a hover frame queued before the layer went off does not pick', () => {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* An element as Esc sees it: `closest` matches the tag names a selector list
|
||||
* names, and `isContentEditable` is what the browser reports, inherited from
|
||||
* an editing host.
|
||||
*/
|
||||
function element(tag, { isContentEditable = false } = {}) {
|
||||
return {
|
||||
tagName: tag.toUpperCase(),
|
||||
isContentEditable,
|
||||
closest(selector) {
|
||||
const names = selector.split(',').map((part) => part.trim());
|
||||
return names.includes(tag) ? this : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('Esc on the globe clears the selected sequence', () => {
|
||||
const h = harness({ selected: true });
|
||||
try {
|
||||
const event = h.key('Escape', element('canvas'));
|
||||
assert.equal(h.sequences.cleared, 1);
|
||||
assert.equal(event.defaultPrevented, true, 'and claims the key');
|
||||
h.key('Escape', element('canvas'));
|
||||
assert.equal(h.sequences.cleared, 1, 'nothing left to clear');
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('an Esc something else already handled keeps the selection (M60)', () => {
|
||||
const h = harness({ selected: true });
|
||||
try {
|
||||
h.key('Escape', element('canvas'), { defaultPrevented: true });
|
||||
assert.equal(h.sequences.cleared, 0);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('Esc in a text field or rich-text editor keeps the selection (M61)', () => {
|
||||
const h = harness({ selected: true });
|
||||
try {
|
||||
for (const tag of ['input', 'textarea', 'select']) {
|
||||
const event = h.key('Escape', element(tag));
|
||||
assert.equal(h.sequences.cleared, 0, `typing in a <${tag}>`);
|
||||
assert.equal(event.defaultPrevented, false, `<${tag}> keeps its Esc`);
|
||||
}
|
||||
// A <div contenteditable> (or anything inside one) is a text field too.
|
||||
h.key('Escape', element('div', { isContentEditable: true }));
|
||||
assert.equal(h.sequences.cleared, 0, 'typing in a contenteditable');
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
@@ -108,6 +108,49 @@ test('a box across the date line takes tiles on both sides of it, not the far si
|
||||
assert.ok(limited.every((tile) => tile.x <= 8 || tile.x >= 247));
|
||||
});
|
||||
|
||||
test('a box across the date line ranks the tiles at the line first, without and with a focus', () => {
|
||||
const z = 14;
|
||||
const n = 2 ** z;
|
||||
const bbox = [179.9, 0, -179.9, 0.1];
|
||||
const key = (t) => `${t.x}/${t.y}`;
|
||||
const keys = (list) => new Set(list.map(key));
|
||||
const [north, south] = [latToTileY(0.1, z), latToTileY(0, z)];
|
||||
const plain = tilesForBbox(bbox, z);
|
||||
assert.equal(plain.total, 10 * (south - north + 1), '5 columns either side');
|
||||
// The box centre is the date line itself, not the middle of the map: the
|
||||
// middle two rows of the columns touching it come first, then the ring
|
||||
// around them, all within two columns of the line.
|
||||
const [mid1, mid2] = [(north + south - 1) / 2, (north + south + 1) / 2];
|
||||
assert.deepEqual(
|
||||
keys(plain.tiles.slice(0, 4)),
|
||||
new Set([`${n - 1}/${mid1}`, `${n - 1}/${mid2}`, `0/${mid1}`, `0/${mid2}`]),
|
||||
);
|
||||
for (const tile of plain.tiles.slice(4, 12))
|
||||
assert.ok(
|
||||
[n - 2, n - 1, 0, 1].includes(tile.x),
|
||||
`${key(tile)} near the line`,
|
||||
);
|
||||
// A line of sight across the date line just north of the equator: the two
|
||||
// tiles it crosses come first, then the two in the row below them.
|
||||
const row = latToTileY(0.05, z);
|
||||
const from = { lon: 179.99, lat: 0.05 };
|
||||
const to = { lon: -179.99, lat: 0.05 };
|
||||
for (const focus of [
|
||||
{ from, to },
|
||||
{ from: to, to: from },
|
||||
]) {
|
||||
const ranked = tilesForBbox(bbox, z, { focus }).tiles;
|
||||
assert.deepEqual(
|
||||
keys(ranked.slice(0, 2)),
|
||||
new Set([`${n - 1}/${row}`, `0/${row}`]),
|
||||
);
|
||||
assert.deepEqual(
|
||||
keys(ranked.slice(2, 4)),
|
||||
new Set([`${n - 1}/${row + 1}`, `0/${row + 1}`]),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('a focus ranks tiles along the line of sight, not from the box centre (review IC8 P1)', () => {
|
||||
// A box that runs far north of the camera, as a tilted view's does: the
|
||||
// box centre is kilometres from both the camera and the screen centre.
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
cameraHeightAboveGround,
|
||||
createHorizonCull,
|
||||
metresBetween,
|
||||
viewCentre,
|
||||
viewFocus,
|
||||
@@ -167,3 +168,100 @@ test('metresBetween measures the short way round the date line (review IC8 P2)',
|
||||
) < 1e-9,
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* A horizon cull over `items` for a camera the test moves, with the frame's
|
||||
* pre-render listeners and a count of the points tested against the horizon.
|
||||
*/
|
||||
function cullHarness(t, items) {
|
||||
const visible = t.mock.method(
|
||||
Cesium.EllipsoidalOccluder.prototype,
|
||||
'isPointVisible',
|
||||
);
|
||||
const preRender = new Set();
|
||||
const camera = { positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 1e7) };
|
||||
const viewer = {
|
||||
camera,
|
||||
scene: {
|
||||
preRender: {
|
||||
addEventListener(listener) {
|
||||
preRender.add(listener);
|
||||
return () => preRender.delete(listener);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
let changes = 0;
|
||||
const cull = createHorizonCull({
|
||||
getViewer: () => viewer,
|
||||
items: () => items,
|
||||
onChange: () => changes++,
|
||||
});
|
||||
return {
|
||||
cull,
|
||||
preRender,
|
||||
tested: () => visible.mock.callCount(),
|
||||
changes: () => changes,
|
||||
frame: () => {
|
||||
for (const listener of [...preRender]) listener();
|
||||
},
|
||||
// Moved in place, as Cesium updates positionWC.
|
||||
moveTo: (lon, lat) =>
|
||||
Cesium.Cartesian3.fromDegrees(
|
||||
lon,
|
||||
lat,
|
||||
1e7,
|
||||
undefined,
|
||||
camera.positionWC,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const point = (lon, lat) => ({
|
||||
position: Cesium.Cartesian3.fromDegrees(lon, lat),
|
||||
show: true,
|
||||
});
|
||||
|
||||
test('the horizon cull does no work while the camera stands still', (t) => {
|
||||
const near = point(0, 0);
|
||||
const far = point(180, 0);
|
||||
const h = cullHarness(t, [near, far]);
|
||||
h.cull.update();
|
||||
assert.equal(h.tested(), 2);
|
||||
assert.deepEqual([near.show, far.show], [true, false]);
|
||||
assert.equal(h.preRender.size, 1, 'listening for camera moves');
|
||||
for (let i = 0; i < 5; i++) h.frame();
|
||||
assert.equal(h.tested(), 2, 'a still camera re-culls nothing');
|
||||
h.moveTo(180, 0);
|
||||
h.frame();
|
||||
assert.equal(h.tested(), 4, 'a moved camera re-culls everything once');
|
||||
assert.deepEqual([near.show, far.show], [false, true]);
|
||||
h.frame();
|
||||
h.frame();
|
||||
assert.equal(h.tested(), 4, 'and then rests again');
|
||||
h.cull.stop();
|
||||
assert.equal(h.preRender.size, 0);
|
||||
});
|
||||
|
||||
test('the horizon cull stops listening once nothing is left, and starts again for new items', (t) => {
|
||||
const items = [point(0, 0)];
|
||||
const h = cullHarness(t, items);
|
||||
h.cull.update();
|
||||
assert.equal(h.preRender.size, 1);
|
||||
// Every item goes (the tiles are dropped); the next camera move finds none.
|
||||
items.length = 0;
|
||||
h.moveTo(10, 0);
|
||||
h.frame();
|
||||
assert.equal(h.preRender.size, 0, 'the listener is removed');
|
||||
// New items arrive: they are culled now, and camera moves are watched again.
|
||||
const far = point(-170, 0);
|
||||
items.push(far);
|
||||
h.cull.update([far]);
|
||||
assert.equal(far.show, false, 'culled on arrival');
|
||||
assert.equal(h.preRender.size, 1, 'the listener is back');
|
||||
h.moveTo(-170, 0);
|
||||
h.frame();
|
||||
assert.equal(far.show, true, 're-culled when the camera moves');
|
||||
assert.equal(h.changes(), 2, 'a redraw for each change');
|
||||
h.cull.stop();
|
||||
});
|
||||
@@ -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'],
|
||||
);
|
||||
});
|
||||
@@ -13,7 +13,9 @@ import * as tiles from '../../server/providers/mapillary/tiles.js';
|
||||
import {
|
||||
TILE_DISK_FILE_OVERHEAD_BYTES,
|
||||
TILE_MAX_BYTES,
|
||||
TILE_MEMORY_BUDGET_BYTES,
|
||||
TILE_MEMORY_ENTRY_OVERHEAD_BYTES,
|
||||
TILE_RATE_LIMIT_MAX_HOLD_MS,
|
||||
TILE_ROUTE_MAX_PER_MIN,
|
||||
TILE_UPSTREAM_CONCURRENCY,
|
||||
} from '../../server/providers/mapillary/constants.js';
|
||||
@@ -259,6 +261,45 @@ test('a 429 passes its Retry-After on and holds misses until it is over (review
|
||||
);
|
||||
});
|
||||
|
||||
for (const [why, retryAfter] of [
|
||||
['seconds', () => '999999'],
|
||||
['an HTTP date', () => new Date(Date.now() + 365 * 24 * HOUR).toUTCString()],
|
||||
])
|
||||
test(`a far-off Retry-After (${why}) holds misses for at most TILE_RATE_LIMIT_MAX_HOLD_MS`, async (t) => {
|
||||
const maxSec = TILE_RATE_LIMIT_MAX_HOLD_MS / 1000;
|
||||
await withUpstream(
|
||||
() =>
|
||||
new Response('{}', {
|
||||
status: 429,
|
||||
headers: { 'Retry-After': retryAfter() },
|
||||
}),
|
||||
async ({ calls, call }) => {
|
||||
const first = await call('/api/mapillary/tiles', '/coverage/14/5/5');
|
||||
assert.equal(first.statusCode, 429);
|
||||
assert.equal(first.headers['retry-after'], String(maxSec));
|
||||
assert.equal(json(first).retryAfter, maxSec);
|
||||
const now = Date.now();
|
||||
t.mock.method(
|
||||
Date,
|
||||
'now',
|
||||
() => now + TILE_RATE_LIMIT_MAX_HOLD_MS - 5000,
|
||||
);
|
||||
const held = await call('/api/mapillary/tiles', '/coverage/14/6/6');
|
||||
assert.equal(held.statusCode, 429);
|
||||
assert.ok(
|
||||
json(held).retryAfter <= 5,
|
||||
'the hold counts down from the cap',
|
||||
);
|
||||
assert.equal(calls.length, 1, 'held: Mapillary is not asked again');
|
||||
Date.now.mock.mockImplementation(
|
||||
() => now + TILE_RATE_LIMIT_MAX_HOLD_MS + 1000,
|
||||
);
|
||||
await call('/api/mapillary/tiles', '/coverage/14/6/6');
|
||||
assert.equal(calls.length, 2, 'asked again once the capped hold ends');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeTileAddress enforces layer names, zoom ranges and tile bounds', () => {
|
||||
assert.equal(
|
||||
normalizeTileAddress({ layer: 'coverage', z: 3, x: 1, y: 2 }).key,
|
||||
@@ -601,6 +642,45 @@ test('the upstream tile fetch is cancelled once every waiter has left', async ()
|
||||
});
|
||||
});
|
||||
|
||||
test('a request arriving before a cancelled fetch has wound down starts a fresh one', async () => {
|
||||
const tile = { layer: 'coverage', z: 13, x: 130, y: 100 };
|
||||
await withDeferredUpstream([tile], async ({ body, settle }) => {
|
||||
// A real fetch rejects some time after its abort; until then the
|
||||
// cancelled flight is still registered for its tile.
|
||||
const upstream = [];
|
||||
globalThis.fetch = (url, { signal } = {}) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const call = { aborted: false };
|
||||
call.release = () => resolve(new Response(body, { status: 200 }));
|
||||
call.windDown = () => reject(signal.reason);
|
||||
signal?.addEventListener('abort', () => (call.aborted = true), {
|
||||
once: true,
|
||||
});
|
||||
upstream.push(call);
|
||||
});
|
||||
const first = new AbortController();
|
||||
const a = fetchTile(tile, { signal: first.signal });
|
||||
await waitFor(() => upstream.length === 1, 'the upstream fetch');
|
||||
first.abort();
|
||||
await assert.rejects(a, { name: 'AbortError' });
|
||||
assert.equal(upstream[0].aborted, true, 'the last waiter cancelled it');
|
||||
// Joining the cancelled flight would end in its AbortError (a 502).
|
||||
const b = fetchTile(tile);
|
||||
await waitFor(() => upstream.length === 2, 'a fresh upstream fetch');
|
||||
// The cancelled flight winding down must not unregister the fresh one.
|
||||
upstream[0].windDown();
|
||||
await settle();
|
||||
const c = fetchTile(tile);
|
||||
await settle();
|
||||
assert.equal(upstream.length, 2, 'a third request joins the fresh fetch');
|
||||
upstream[1].release();
|
||||
const fresh = await b;
|
||||
assert.equal(fresh.source, 'upstream');
|
||||
assert.deepEqual(listTileLayers(fresh.bytes), ['sequence']);
|
||||
assert.equal((await c).source, 'inflight');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Route: success path, refusals, gate and limiter (review gekh) ──
|
||||
|
||||
/** Every header value and the body, as text, for a token-leak check. */
|
||||
@@ -1012,6 +1092,126 @@ test('empty tiles are charged against the memory budget (review gekh)', async ()
|
||||
);
|
||||
});
|
||||
|
||||
/** A one-layer tile of exactly `size` bytes (a big opaque payload). */
|
||||
function tileOfSize(size) {
|
||||
const probe = tile([{ name: 'sequence', payload: Buffer.alloc(size) }]);
|
||||
const bytes = tile([
|
||||
{ name: 'sequence', payload: Buffer.alloc(2 * size - probe.length) },
|
||||
]);
|
||||
assert.equal(bytes.length, size);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve each address's big tile from a scripted upstream, with disk writes
|
||||
* off: a tile memory dropped can only come back from Mapillary.
|
||||
*/
|
||||
async function withBigTiles(t, bodies, run) {
|
||||
for (const address of bodies.keys())
|
||||
await fsp.rm(tileFile(address), { force: true });
|
||||
t.mock.method(fsp, 'writeFile', async () => {
|
||||
throw new Error('disk writes are off in this test');
|
||||
});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const byPath = new Map(
|
||||
[...bodies].map(([{ z, x, y }, body]) => [`/2/${z}/${x}/${y}?`, body]),
|
||||
);
|
||||
await withUpstream(
|
||||
(_n, url) => {
|
||||
const [, body] = [...byPath].find(([key]) => url.includes(key));
|
||||
return new Response(body, { status: 200 });
|
||||
},
|
||||
({ calls }) => {
|
||||
const fetched = (address) =>
|
||||
calls.filter((url) =>
|
||||
url.includes(`/2/${address.z}/${address.x}/${address.y}?`),
|
||||
).length;
|
||||
return run({ fetched });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test('the memory cache evicts the least recently used tile, not the oldest fetched', async (t) => {
|
||||
const MB = 1024 * 1024;
|
||||
// Three fit in the budget, a fourth does not.
|
||||
const size = 30 * MB;
|
||||
const cost = size + TILE_MEMORY_ENTRY_OVERHEAD_BYTES;
|
||||
assert.ok(3 * cost <= TILE_MEMORY_BUDGET_BYTES);
|
||||
assert.ok(4 * cost > TILE_MEMORY_BUDGET_BYTES);
|
||||
const body = tileOfSize(size);
|
||||
const [a, b, c, d] = [20, 21, 22, 23].map((x) => ({
|
||||
layer: 'coverage',
|
||||
z: 14,
|
||||
x,
|
||||
y: 20,
|
||||
}));
|
||||
await withBigTiles(
|
||||
t,
|
||||
new Map([a, b, c, d].map((address) => [address, body])),
|
||||
async ({ fetched }) => {
|
||||
for (const address of [a, b, c])
|
||||
assert.equal((await fetchTile(address)).source, 'upstream');
|
||||
assert.deepEqual(tiles._tileMemoryForTest(), {
|
||||
entries: 3,
|
||||
bytes: 3 * cost,
|
||||
});
|
||||
// A is used again, so B is now the least recently used.
|
||||
assert.equal((await fetchTile(a)).source, 'memory');
|
||||
assert.equal((await fetchTile(d)).source, 'upstream');
|
||||
assert.deepEqual(tiles._tileMemoryForTest(), {
|
||||
entries: 3,
|
||||
bytes: 3 * cost,
|
||||
});
|
||||
for (const address of [a, c, d])
|
||||
assert.equal(
|
||||
(await fetchTile(address)).source,
|
||||
'memory',
|
||||
`${address.x} kept`,
|
||||
);
|
||||
// B was evicted; asking for it again goes back to Mapillary.
|
||||
assert.equal((await fetchTile(b)).source, 'upstream');
|
||||
assert.equal(fetched(b), 2);
|
||||
assert.equal(fetched(a), 1);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('a tile costing more than half the memory budget is served but not kept', async (t) => {
|
||||
const half = TILE_MEMORY_BUDGET_BYTES / 2;
|
||||
// At exactly half the budget a tile is kept; one byte more and it is not.
|
||||
const fits = tileOfSize(half - TILE_MEMORY_ENTRY_OVERHEAD_BYTES);
|
||||
const tooBig = tileOfSize(half - TILE_MEMORY_ENTRY_OVERHEAD_BYTES + 1);
|
||||
assert.ok(tooBig.length <= TILE_MAX_BYTES, 'within the upstream size cap');
|
||||
const small = { layer: 'coverage', z: 14, x: 24, y: 20 };
|
||||
const kept = { layer: 'coverage', z: 14, x: 25, y: 20 };
|
||||
const big = { layer: 'coverage', z: 14, x: 26, y: 20 };
|
||||
await withBigTiles(
|
||||
t,
|
||||
new Map([
|
||||
[small, tile([{ name: 'sequence' }])],
|
||||
[kept, fits],
|
||||
[big, tooBig],
|
||||
]),
|
||||
async ({ fetched }) => {
|
||||
await fetchTile(small);
|
||||
const before = tiles._tileMemoryForTest();
|
||||
const served = await fetchTile(big);
|
||||
assert.equal(served.source, 'upstream');
|
||||
assert.equal(served.bytes.length, tooBig.length, 'served in full');
|
||||
assert.deepEqual(
|
||||
tiles._tileMemoryForTest(),
|
||||
before,
|
||||
'nothing evicted to make room',
|
||||
);
|
||||
assert.equal((await fetchTile(small)).source, 'memory');
|
||||
assert.equal((await fetchTile(big)).source, 'upstream', 'not kept');
|
||||
assert.equal(fetched(big), 2);
|
||||
assert.equal((await fetchTile(kept)).source, 'upstream');
|
||||
assert.equal((await fetchTile(kept)).source, 'memory', 'half fits');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('empty tile files count toward the disk cap (review gekh)', async () => {
|
||||
await withCacheDir(async (dir) => {
|
||||
const files = [3, 2, 1].map((age) => ({
|
||||
|
||||
@@ -19,6 +19,22 @@ import { fakeStreetLevelProvider } from '../testSupport/streetLevelFakes.mjs';
|
||||
*/
|
||||
const mutations = { count: 0 };
|
||||
const REFLECTED = ['hidden', 'disabled', 'textContent', 'title', 'value'];
|
||||
/** The reflected properties a MutationObserver sees, as the record it gets. */
|
||||
const REFLECTED_RECORDS = {
|
||||
hidden: { type: 'attributes', attributeName: 'hidden' },
|
||||
disabled: { type: 'attributes', attributeName: 'disabled' },
|
||||
title: { type: 'attributes', attributeName: 'title' },
|
||||
textContent: { type: 'childList' },
|
||||
};
|
||||
|
||||
/** The fake MutationObservers currently observing (see fakeMutationObserver). */
|
||||
const liveObservers = new Set();
|
||||
|
||||
/** Queue a mutation record for every observer whose options cover it. */
|
||||
function recordMutation(target, { type, attributeName = null }) {
|
||||
for (const observer of liveObservers)
|
||||
observer.consider(target, type, attributeName);
|
||||
}
|
||||
|
||||
class FakeNode {
|
||||
constructor(document, tag, { id = null, dataset = {}, classes = [] } = {}) {
|
||||
@@ -42,18 +58,30 @@ class FakeNode {
|
||||
set: (value) => {
|
||||
mutations.count++;
|
||||
this.props[key] = value;
|
||||
if (REFLECTED_RECORDS[key])
|
||||
recordMutation(this, REFLECTED_RECORDS[key]);
|
||||
},
|
||||
});
|
||||
this.attributes = new Map();
|
||||
const names = new Set(classes);
|
||||
// Every DOMTokenList write sets the class attribute, changed or not.
|
||||
const classChanged = () =>
|
||||
recordMutation(this, { type: 'attributes', attributeName: 'class' });
|
||||
this.classList = {
|
||||
add: (name) => names.add(name),
|
||||
remove: (name) => names.delete(name),
|
||||
add: (name) => {
|
||||
names.add(name);
|
||||
classChanged();
|
||||
},
|
||||
remove: (name) => {
|
||||
names.delete(name);
|
||||
classChanged();
|
||||
},
|
||||
contains: (name) => names.has(name),
|
||||
toggle: (name, force) => {
|
||||
const on = force ?? !names.has(name);
|
||||
if (on) names.add(name);
|
||||
else names.delete(name);
|
||||
classChanged();
|
||||
return on;
|
||||
},
|
||||
};
|
||||
@@ -73,32 +101,59 @@ class FakeNode {
|
||||
names.clear();
|
||||
for (const name of String(value).split(/\s+/).filter(Boolean))
|
||||
names.add(name);
|
||||
classChanged();
|
||||
},
|
||||
});
|
||||
}
|
||||
get childElementCount() {
|
||||
return this.children.length;
|
||||
}
|
||||
get parentNode() {
|
||||
return this.parent;
|
||||
}
|
||||
get nextSibling() {
|
||||
if (!this.parent) return null;
|
||||
const siblings = this.parent.children;
|
||||
return siblings[siblings.indexOf(this) + 1] || null;
|
||||
}
|
||||
/**
|
||||
* As layout reports it: null when this node or an ancestor is hidden (no
|
||||
* box), or when it is not in the document; else its parent.
|
||||
*/
|
||||
get offsetParent() {
|
||||
if (!this.isConnected) return null;
|
||||
for (let node = this; node; node = node.parent)
|
||||
if (node.hidden) return null;
|
||||
return this.parent;
|
||||
}
|
||||
setAttribute(key, value) {
|
||||
mutations.count++;
|
||||
this.attributes.set(key, String(value));
|
||||
recordMutation(this, { type: 'attributes', attributeName: key });
|
||||
}
|
||||
getAttribute(key) {
|
||||
return this.attributes.get(key) ?? null;
|
||||
}
|
||||
removeAttribute(key) {
|
||||
if (this.attributes.delete(key)) mutations.count++;
|
||||
if (!this.attributes.delete(key)) return;
|
||||
mutations.count++;
|
||||
recordMutation(this, { type: 'attributes', attributeName: key });
|
||||
}
|
||||
appendChild(child) {
|
||||
return this.insertBefore(child, null);
|
||||
}
|
||||
/** Like the DOM's: a reference that is not a child throws NotFoundError. */
|
||||
insertBefore(child, reference) {
|
||||
if (reference != null && reference.parent !== this)
|
||||
throw new DOMException('not a child of this node', 'NotFoundError');
|
||||
if (reference === child) return child;
|
||||
child.remove();
|
||||
const index = reference
|
||||
? this.children.indexOf(reference)
|
||||
: this.children.length;
|
||||
this.children.splice(index < 0 ? this.children.length : index, 0, child);
|
||||
this.children.splice(index, 0, child);
|
||||
child.parent = this;
|
||||
recordMutation(this, { type: 'childList' });
|
||||
return child;
|
||||
}
|
||||
append(...nodes) {
|
||||
@@ -109,9 +164,11 @@ class FakeNode {
|
||||
this.append(...nodes);
|
||||
}
|
||||
remove() {
|
||||
if (!this.parent) return;
|
||||
this.parent.children.splice(this.parent.children.indexOf(this), 1);
|
||||
const parent = this.parent;
|
||||
if (!parent) return;
|
||||
parent.children.splice(parent.children.indexOf(this), 1);
|
||||
this.parent = null;
|
||||
recordMutation(parent, { type: 'childList' });
|
||||
}
|
||||
contains(node) {
|
||||
for (let current = node; current; current = current.parent)
|
||||
@@ -272,7 +329,7 @@ function panelDom() {
|
||||
globe.setAttribute('tabindex', '0');
|
||||
// A field in another panel (the location search).
|
||||
const search = add(document.body, 'input', { id: 'location-search' });
|
||||
return { document, root, globe, search };
|
||||
return { document, root, globe, search, main, wrap, settings };
|
||||
}
|
||||
|
||||
/** Run `fn` with the fake document and an immediate animation frame. */
|
||||
@@ -602,6 +659,11 @@ test('Esc shrinks the expanded viewer after focus left it, and the map keeps its
|
||||
test('Tab from outside the expanded viewer brings focus back into it', () =>
|
||||
withDom(async (dom) => {
|
||||
const { controls } = stubPanel(dom, uiState({ open: true }));
|
||||
// A tool hidden in this state (no box, so not a tab stop) ends the bar.
|
||||
const hiddenTool = dom.wrap.appendChild(
|
||||
new FakeNode(dom.document, 'button', { id: 'sl-hidden-tool' }),
|
||||
);
|
||||
hiddenTool.hidden = true;
|
||||
controls.setViewerExpanded(true);
|
||||
dom.globe.focus();
|
||||
keydown(dom.globe, 'Tab');
|
||||
@@ -614,7 +676,13 @@ test('Tab from outside the expanded viewer brings focus back into it', () =>
|
||||
assert.equal(
|
||||
dom.document.activeElement,
|
||||
wrap.querySelector('#sl-viewer-close'),
|
||||
'Shift+Tab wraps to the last control',
|
||||
'Shift+Tab wraps to the last control, past the hidden one',
|
||||
);
|
||||
keydown(dom.document.activeElement, 'Tab');
|
||||
assert.equal(
|
||||
dom.document.activeElement,
|
||||
wrap.querySelector('#sl-viewer-expand'),
|
||||
'Tab from the last shown control wraps to the first',
|
||||
);
|
||||
controls.destroy();
|
||||
}));
|
||||
@@ -742,7 +810,12 @@ test('a hidden expanded viewer (Clean View, recording, cockpit) holds neither Es
|
||||
controls.destroy();
|
||||
}));
|
||||
|
||||
/** A MutationObserver stand-in the test fires by hand. */
|
||||
/**
|
||||
* A MutationObserver stand-in. Like the real one it queues only the records
|
||||
* its `observe` options cover (childList, attributes / attributeFilter,
|
||||
* subtree) and delivers them in a microtask, only when there are some;
|
||||
* `fire()` delivers what is queued at once.
|
||||
*/
|
||||
function fakeMutationObserver() {
|
||||
const observers = [];
|
||||
class FakeMutationObserver {
|
||||
@@ -750,19 +823,46 @@ function fakeMutationObserver() {
|
||||
this.callback = callback;
|
||||
this.target = null;
|
||||
this.options = null;
|
||||
this.records = [];
|
||||
observers.push(this);
|
||||
}
|
||||
observe(target, options) {
|
||||
observe(target, options = {}) {
|
||||
this.target = target;
|
||||
this.options = options;
|
||||
this.options = {
|
||||
...options,
|
||||
// Per the spec, an attribute filter implies `attributes`.
|
||||
attributes: options.attributes ?? Boolean(options.attributeFilter),
|
||||
};
|
||||
liveObservers.add(this);
|
||||
}
|
||||
disconnect() {
|
||||
this.target = null;
|
||||
this.records = [];
|
||||
liveObservers.delete(this);
|
||||
}
|
||||
consider(node, type, attributeName) {
|
||||
const { childList, attributes, attributeFilter, subtree } = this.options;
|
||||
const inScope =
|
||||
node === this.target ||
|
||||
(subtree === true && this.target.contains(node));
|
||||
if (!inScope) return;
|
||||
if (type === 'childList' && childList !== true) return;
|
||||
if (type === 'attributes') {
|
||||
if (attributes !== true) return;
|
||||
if (attributeFilter && !attributeFilter.includes(attributeName)) return;
|
||||
}
|
||||
this.records.push({ type, target: node, attributeName });
|
||||
if (this.records.length === 1) queueMicrotask(() => this.deliver());
|
||||
}
|
||||
deliver() {
|
||||
if (!this.target || !this.records.length) return;
|
||||
const records = this.records;
|
||||
this.records = [];
|
||||
this.callback(records, this);
|
||||
}
|
||||
}
|
||||
const fire = () => {
|
||||
for (const observer of observers)
|
||||
if (observer.target) observer.callback([], observer);
|
||||
for (const observer of observers) observer.deliver();
|
||||
};
|
||||
const watching = () => observers.some((observer) => observer.target);
|
||||
return { FakeMutationObserver, fire, watching };
|
||||
@@ -776,6 +876,7 @@ async function withObserver(fn) {
|
||||
try {
|
||||
return await fn(fake);
|
||||
} finally {
|
||||
liveObservers.clear();
|
||||
if (saved === undefined) delete globalThis.MutationObserver;
|
||||
else globalThis.MutationObserver = saved;
|
||||
}
|
||||
@@ -970,3 +1071,82 @@ test('under KEY REQUIRED the provider chip stays live and explains the key inste
|
||||
assert.match(calls.toasts[0], /^Mapillary: Needs /);
|
||||
controls.destroy();
|
||||
}));
|
||||
|
||||
/* ── The expanded viewer goes back where it came from ──────────────────── */
|
||||
|
||||
/** A node by name, so a failed comparison prints a word, not the DOM. */
|
||||
const nameOf = (node) =>
|
||||
node ? node.id || node.className || node.tagName.toLowerCase() : null;
|
||||
|
||||
/** Where the viewer sits: its parent and the sibling it sits before. */
|
||||
const placeOf = (wrap) => [nameOf(wrap.parentNode), nameOf(wrap.nextSibling)];
|
||||
const PANEL_PLACE = ['sl-main', 'sl-settings'];
|
||||
|
||||
test('shrinking returns the viewer into the panel at its own place', () =>
|
||||
withDom(async (dom) => {
|
||||
const { controls } = stubPanel(dom, uiState({ open: true }));
|
||||
assert.deepEqual(placeOf(dom.wrap), PANEL_PLACE);
|
||||
controls.setViewerExpanded(true);
|
||||
assert.equal(nameOf(dom.wrap.parentNode), 'body', 'lifted out');
|
||||
controls.setViewerExpanded(false);
|
||||
assert.deepEqual(placeOf(dom.wrap), PANEL_PLACE, 'before the settings');
|
||||
assert.equal(dom.main.children.indexOf(dom.wrap), 0);
|
||||
assert.equal(dom.root.contains(dom.wrap), true);
|
||||
// A second round trip lands in the same place.
|
||||
controls.setViewerExpanded(true);
|
||||
controls.setViewerExpanded(false);
|
||||
assert.deepEqual(placeOf(dom.wrap), PANEL_PLACE);
|
||||
controls.destroy();
|
||||
}));
|
||||
|
||||
test('an image closed while expanded returns the viewer into the panel', () =>
|
||||
withDom(async (dom) => {
|
||||
const { layer, controls } = stubPanel(dom, uiState({ open: true }));
|
||||
controls.setViewerExpanded(true);
|
||||
layer.publish(uiState({ open: false }));
|
||||
assert.deepEqual(placeOf(dom.wrap), PANEL_PLACE);
|
||||
assert.equal(dom.wrap.hidden, true, 'and hidden there');
|
||||
controls.destroy();
|
||||
}));
|
||||
|
||||
test('destroying the panel while expanded returns the viewer into it', () =>
|
||||
withDom(async (dom) => {
|
||||
const { controls } = stubPanel(dom, uiState({ open: true }));
|
||||
controls.setViewerExpanded(true);
|
||||
controls.destroy();
|
||||
assert.deepEqual(placeOf(dom.wrap), PANEL_PLACE);
|
||||
assert.equal(
|
||||
dom.document.body.children.includes(dom.wrap),
|
||||
false,
|
||||
'nothing left on <body>',
|
||||
);
|
||||
}));
|
||||
|
||||
test('a <body> child added while the viewer is expanded goes inert with the rest', () =>
|
||||
withObserver((observer) =>
|
||||
withDom(async (dom) => {
|
||||
const { controls } = stubPanel(dom, uiState({ open: true }));
|
||||
controls.setViewerExpanded(true);
|
||||
// A toast, a menu or another panel's popup, appended later.
|
||||
const toast = dom.document.body.appendChild(
|
||||
new FakeNode(dom.document, 'div', { id: 'toast' }),
|
||||
);
|
||||
assert.equal(toast.inert, undefined, 'not before the observer runs');
|
||||
await settle(); // records are delivered in a microtask
|
||||
assert.equal(toast.inert, true);
|
||||
// Changes inside the app are not the modal's business: nothing runs.
|
||||
let syncs = 0;
|
||||
const sync = controls._syncModal.bind(controls);
|
||||
controls._syncModal = () => {
|
||||
syncs++;
|
||||
return sync();
|
||||
};
|
||||
dom.root.querySelector('#sl-status').setAttribute('class', 'x');
|
||||
dom.root.appendChild(new FakeNode(dom.document, 'div'));
|
||||
await settle();
|
||||
assert.equal(syncs, 0);
|
||||
controls.destroy();
|
||||
assert.equal(toast.inert, false, 'destroy releases it too');
|
||||
assert.equal(observer.watching(), false);
|
||||
}),
|
||||
));
|
||||
@@ -100,6 +100,23 @@ test('a key Mapillary rejected reads KEY REJECTED and names the fix (review IC8
|
||||
assert.equal(view.providers[0].title, `Mapillary: ${error}`);
|
||||
});
|
||||
|
||||
test('a key problem outranks loading on the pill: KEY REJECTED, then KEY REQUIRED (M65)', () => {
|
||||
// A keyless provider can still report loading (its status check, a retry):
|
||||
// the pill must say what is wrong, not that something is on its way.
|
||||
const loading = { enabled: true, coverage: { loading: true } };
|
||||
assert.equal(
|
||||
presentStreetLevelPanel(snapshot({ ...loading, keyRequired: true })).status
|
||||
.text,
|
||||
'KEY REQUIRED',
|
||||
);
|
||||
assert.equal(
|
||||
presentStreetLevelPanel(
|
||||
snapshot({ ...loading, keyRequired: true, keyRejected: true }),
|
||||
).status.text,
|
||||
'KEY REJECTED',
|
||||
);
|
||||
});
|
||||
|
||||
test('the header pill is the on/off switch and reads OFF, LOADING or ON', () => {
|
||||
assert.deepEqual(presentStreetLevelPanel(snapshot()).status, {
|
||||
text: 'OFF',
|
||||
|
||||
Reference in new issue
Block a user