fix(street-level): re-check an unknown key, Esc leaves full screen, say why a view is empty

- A failed or missing key status is asked again on the next switch-on, and
  the check gives up after 10 s instead of holding LOADING forever.
- Waiting for the key status reads as LOADING while the layer is on.
- Esc shrinks the photo in full screen too, before the panel's own Esc.
- Destroying the panel with the iPhone popover open leaves no popover behind.
- An empty view says whether the filter emptied it.
- A freed upstream tile slot passes straight to the oldest waiter.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-08 18:25:16 +04:00
1 parent d313b6e62c
commit 8e5e3cdd3b
10 files changed
+201 -30

No files matched your search

+24 -8
View File
@@ -16,6 +16,9 @@ import {
export { STREET_LEVEL_LAYER_ID } from './policy.js';
/** Longest wait for the server's key status; past it, the bundle's token decides. */
const KEY_STATUS_TIMEOUT_MS = 10_000;
/** Coverage as the panel sees it while the `mapillary` switch is off. */
const COVERAGE_OFF = Object.freeze({
count: 0,
@@ -73,15 +76,25 @@ export function createStreetLevelLayer({
}
state.notify = notify;
/** The key-status request in flight; overlapping checks share it. */
let keyCheck = null;
/** Coverage waits for the key status: no tile requests without a key. */
async function checkKey() {
let configured;
try {
configured = (await source.getStatus())?.configured === true;
} catch {
configured = source.hasToken?.() === true;
}
parts.coverage.setKeyStatus(configured);
function checkKey() {
keyCheck ||= (async () => {
let configured;
try {
// A hung server must not hold LOADING, or the next check, forever.
const signal = AbortSignal.timeout(KEY_STATUS_TIMEOUT_MS);
configured = (await source.getStatus({ signal }))?.configured === true;
} catch {
configured = source.hasToken?.() === true;
}
parts.coverage.setKeyStatus(configured);
})().finally(() => {
keyCheck = null;
});
return keyCheck;
}
function startDrawing() {
@@ -90,6 +103,9 @@ export function createStreetLevelLayer({
parts.sequences.setVisible(true);
parts.coverage.attach(viewer);
credit.show(viewer);
// Without a known key, a switch-on asks again: the server may be back,
// or have a key now.
if (['status', 'no-key'].includes(state.coverage.blocked)) checkKey();
}
/** Close the photo, clear lines and cones, forget refusals. */
+58
View File
@@ -171,6 +171,64 @@ test('coverage asks for no tiles before the key status is known, and none withou
}
});
test('a key status that failed is asked again on the next switch-on', async (t) => {
let up = false;
let asked = 0;
const source = fakeMapillarySource({
hasToken: () => false,
getStatus: async () => {
asked++;
if (!up) throw new TypeError('fetch failed');
return { configured: true };
},
});
const { layer, viewer } = await startLayer(t, { source, view: true });
assert.equal(asked, 1, 'one check for init and the first switch-on');
assert.equal(layer.getUIState().keyRequired, true);
up = true; // the server is reachable again
layer.disable();
layer.enable(viewer);
await settle();
assert.equal(asked, 2);
assert.equal(layer.getUIState().keyRequired, false);
assert.ok(source.calls.tiles.length > 0, 'coverage loads');
// A known key is not asked again.
layer.disable();
layer.enable(viewer);
await settle();
assert.equal(asked, 2);
});
test('a key status that never answers gives up, so the next switch-on asks again', async (t) => {
// The check's timeout, fired when the test says.
const timeouts = [];
t.mock.method(AbortSignal, 'timeout', (ms) => {
const controller = new AbortController();
timeouts.push({ ms, fire: () => controller.abort(new Error('timed out')) });
return controller.signal;
});
let asked = 0;
const source = fakeMapillarySource({
hasToken: () => false,
getStatus: ({ signal } = {}) =>
new Promise((_, reject) => {
asked++;
signal?.addEventListener('abort', () => reject(signal.reason));
}),
});
const { layer, viewer } = await startLayer(t, { source, view: true });
assert.equal(asked, 1);
assert.equal(timeouts[0].ms, 10_000, 'the check can be cut short');
assert.equal(layer.getUIState().coverage.loading, true, 'LOADING meanwhile');
timeouts[0].fire();
await settle();
assert.equal(layer.getUIState().coverage.loading, false);
assert.equal(layer.getUIState().keyRequired, true, 'no token: KEY REQUIRED');
layer.disable();
layer.enable(viewer);
assert.equal(asked, 2, 'asked again, not stuck on the first');
});
test('a rejected key gates the layer as KEY REJECTED until it goes off', async (t) => {
const rejected = Object.assign(new Error('Mapillary rejected the token'), {
keyRejected: true,
@@ -407,7 +407,10 @@ export function createCoverage({ state, source }) {
const { blocked } = state.coverage;
return {
count: sequenceCount(),
loading: state.coverage.pending.size > 0,
// Waiting for the key status is loading too, not an empty view.
loading:
state.coverage.pending.size > 0 ||
(blocked === 'status' && isActive(state)),
hint: state.coverage.hint,
keyRequired: blocked === 'no-key' || blocked === 'rejected',
keyRejected: blocked === 'rejected',
@@ -220,11 +220,16 @@ test('the key status gates coverage: nothing before it answers, nothing without
state.coverage.blocked = 'status';
coverage.refresh();
assert.equal(source.calls.length, 0, 'nothing before the status answers');
assert.equal(coverage.stats().loading, true, 'waiting reads as loading');
state.enabled = false;
assert.equal(coverage.stats().loading, false, 'but not while off');
state.enabled = true;
coverage.setKeyStatus(configured);
if (configured)
assert.equal(source.calls.length, 1, 'coverage loads once it is known');
else {
assert.equal(source.calls.length, 0, 'a key-less install never asks');
assert.equal(coverage.stats().loading, false);
assert.equal(coverage.stats().keyRequired, true);
assert.equal(coverage.stats().keyRejected, false);
coverage.unblock();