mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
* fix(share): restore a share link opened by hash navigation Opening a different share link in an already-open tab (pasting it into the address bar, following a link, or Back/Forward) changed the URL but left the camera, visual style, map, panels and layers on the old view until a reload. The share manager now listens for hashchange and popstate. A new hash that parses as a share and differs from the last hash the app read or wrote runs the start-up restore pipeline: lane authority is reserved, URL writes are held (including a pending debounced write of the old view), the camera restore takes a new navigation generation, and the layer coordinator replaces any in-flight layer restore with the new share's layers through the passive share origin. A newer navigation supersedes an older restore end to end; only the newest one releases URL writes. The app's own replaceState writes never restore. Listeners are removed on destroy. Returning to the Normal style and resetting an already-active preset now also apply when a share is restored after start-up, so the result matches a fresh load of the same link. * docs: changelog for #1416
426 lines
14 KiB
JavaScript
426 lines
14 KiB
JavaScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { ShareLinkManager } from './sharelink.js';
|
|
import { ShareRestoration } from './ui/shareRestoration.js';
|
|
import { DataLayerManager } from './data/manager.js';
|
|
import {
|
|
LAYER_STATE_REGISTRY,
|
|
REGISTERED_LAYER_IDS,
|
|
createDefaultLayerState,
|
|
decodeLayerStateParams,
|
|
} from './data/layerState.js';
|
|
import { LayerStateCoordinator } from './data/layerStateCoordinator.js';
|
|
|
|
const SHARE_A = 'v=2&lat=10.0000&lon=20.0000&alt=800&style=nvg&l=e';
|
|
const SHARE_B = 'v=2&lat=48.8584&lon=2.2945&alt=500&style=normal&l=f';
|
|
|
|
/**
|
|
* A window whose address behaves like a tab's: `navigate` is a hash-only
|
|
* navigation (new history entry plus hashchange), `back`/`forward` traverse
|
|
* entries (popstate plus hashchange), and `history.replaceState` rewrites the
|
|
* current entry without firing anything.
|
|
*/
|
|
function installTab(t, initialHash = '') {
|
|
const prior = {
|
|
window: globalThis.window,
|
|
history: globalThis.history,
|
|
};
|
|
const target = new EventTarget();
|
|
const location = new URL(
|
|
`http://localhost/${initialHash ? `#${initialHash}` : ''}`,
|
|
);
|
|
const entries = [location.hash];
|
|
let index = 0;
|
|
const listeners = { hashchange: 0, popstate: 0 };
|
|
const add = target.addEventListener.bind(target);
|
|
const remove = target.removeEventListener.bind(target);
|
|
target.addEventListener = (type, handler, options) => {
|
|
if (type in listeners) listeners[type] += 1;
|
|
add(type, handler, options);
|
|
};
|
|
target.removeEventListener = (type, handler, options) => {
|
|
if (type in listeners) listeners[type] -= 1;
|
|
remove(type, handler, options);
|
|
};
|
|
target.location = location;
|
|
globalThis.window = target;
|
|
globalThis.history = {
|
|
replaceState(_state, _title, next) {
|
|
location.hash = new URL(next, location.href).hash;
|
|
entries[index] = location.hash;
|
|
},
|
|
};
|
|
t.after(() => Object.assign(globalThis, prior));
|
|
const go = (nextIndex) => {
|
|
index = nextIndex;
|
|
location.hash = entries[index];
|
|
target.dispatchEvent(new Event('popstate'));
|
|
target.dispatchEvent(new Event('hashchange'));
|
|
};
|
|
return {
|
|
listeners,
|
|
navigate(hash) {
|
|
entries.splice(index + 1);
|
|
entries.push(`#${hash}`);
|
|
index = entries.length - 1;
|
|
location.hash = hash;
|
|
target.dispatchEvent(new Event('hashchange'));
|
|
},
|
|
back: () => go(index - 1),
|
|
forward: () => go(index + 1),
|
|
};
|
|
}
|
|
|
|
/** Queue timers instead of running them, so delayed work runs only on demand. */
|
|
function installManualTimers(t) {
|
|
const prior = {
|
|
setTimeout: globalThis.setTimeout,
|
|
clearTimeout: globalThis.clearTimeout,
|
|
};
|
|
const timers = new Map();
|
|
let timerId = 0;
|
|
globalThis.setTimeout = (fn) => {
|
|
timers.set(++timerId, fn);
|
|
return timerId;
|
|
};
|
|
globalThis.clearTimeout = (id) => timers.delete(id);
|
|
t.after(() => Object.assign(globalThis, prior));
|
|
return timers;
|
|
}
|
|
|
|
function fakeViewer() {
|
|
return {
|
|
camera: {
|
|
changed: { addEventListener: () => () => {} },
|
|
positionCartographic: { latitude: 0, longitude: 0, height: 1000 },
|
|
heading: 0,
|
|
pitch: -Math.PI / 2,
|
|
roll: 0,
|
|
},
|
|
};
|
|
}
|
|
|
|
test('a hash navigation to a different share restores it and holds URL writes', (t) => {
|
|
const tab = installTab(t, SHARE_A);
|
|
const manager = new ShareLinkManager(fakeViewer());
|
|
const initial = manager.parseInitialHash();
|
|
manager.completeInitialRestore(initial.restoreToken);
|
|
const navigated = [];
|
|
manager.watchNavigation((state) => navigated.push(state));
|
|
|
|
tab.navigate(SHARE_B);
|
|
assert.equal(navigated.length, 1);
|
|
assert.equal(navigated[0].lat, 48.8584);
|
|
assert.equal(navigated[0].style, 'normal');
|
|
assert.deepEqual(navigated[0].layerState.enabledLayerIds, ['flights']);
|
|
assert.equal(manager._initialRestorePending, true);
|
|
// Nothing from the previous view may overwrite the incoming link.
|
|
manager._updateHash();
|
|
assert.equal(window.location.hash, `#${SHARE_B}`);
|
|
manager.completeInitialRestore(navigated[0].restoreToken);
|
|
assert.equal(manager._initialRestorePending, false);
|
|
manager.destroy();
|
|
});
|
|
|
|
test("the app's own hash writes never trigger a restore", (t) => {
|
|
installTab(t, SHARE_A);
|
|
const manager = new ShareLinkManager(fakeViewer());
|
|
manager.completeInitialRestore(manager.parseInitialHash().restoreToken);
|
|
let restores = 0;
|
|
manager.watchNavigation(() => {
|
|
restores += 1;
|
|
});
|
|
manager._updateHash();
|
|
assert.notEqual(window.location.hash, `#${SHARE_A}`);
|
|
// replaceState fires nothing, but a stray event for our own hash is ignored.
|
|
window.dispatchEvent(new Event('hashchange'));
|
|
window.dispatchEvent(new Event('popstate'));
|
|
assert.equal(restores, 0);
|
|
assert.equal(manager._initialRestorePending, false);
|
|
manager.destroy();
|
|
});
|
|
|
|
test('a non-share hash is ignored and Back/Forward restores each entry once', (t) => {
|
|
const tab = installTab(t, SHARE_A);
|
|
const manager = new ShareLinkManager(fakeViewer());
|
|
manager.completeInitialRestore(manager.parseInitialHash().restoreToken);
|
|
// The app rewrites entry 0 with its own state; Back must return to that.
|
|
manager._updateHash();
|
|
const ownHash = window.location.hash.slice(1);
|
|
const navigated = [];
|
|
manager.watchNavigation((state) => navigated.push(state));
|
|
|
|
tab.navigate('not-a-share');
|
|
assert.equal(navigated.length, 0);
|
|
assert.equal(manager._initialRestorePending, false);
|
|
|
|
tab.navigate(SHARE_B);
|
|
assert.equal(navigated.length, 1);
|
|
manager.completeInitialRestore(navigated[0].restoreToken);
|
|
|
|
tab.back(); // not-a-share
|
|
tab.back(); // the app's own rewritten first entry
|
|
assert.equal(navigated.length, 2, 'popstate and hashchange restore once');
|
|
assert.equal(window.location.hash.slice(1), ownHash);
|
|
assert.equal(navigated[1].lat, 0);
|
|
manager.completeInitialRestore(navigated[1].restoreToken);
|
|
|
|
tab.forward();
|
|
tab.forward();
|
|
assert.equal(navigated.length, 3);
|
|
assert.equal(navigated[2].lat, 48.8584);
|
|
manager.destroy();
|
|
});
|
|
|
|
test('a newer navigation supersedes an older restore and its pending write', (t) => {
|
|
const tab = installTab(t, SHARE_A);
|
|
const timers = installManualTimers(t);
|
|
|
|
const manager = new ShareLinkManager(fakeViewer());
|
|
manager.completeInitialRestore(manager.parseInitialHash().restoreToken);
|
|
assert.equal(timers.size, 1, 'releasing suppression schedules a write');
|
|
const navigated = [];
|
|
manager.watchNavigation((state) => navigated.push(state));
|
|
|
|
tab.navigate(SHARE_B);
|
|
assert.equal(
|
|
timers.size,
|
|
0,
|
|
'the pending write of the old view is cancelled',
|
|
);
|
|
tab.navigate(SHARE_A);
|
|
const [older, newer] = navigated;
|
|
assert.ok(newer.restoreToken > older.restoreToken);
|
|
manager.completeInitialRestore(older.restoreToken);
|
|
assert.equal(
|
|
manager._initialRestorePending,
|
|
true,
|
|
'a superseded restore cannot release writes',
|
|
);
|
|
manager.completeInitialRestore(newer.restoreToken);
|
|
assert.equal(manager._initialRestorePending, false);
|
|
manager.destroy();
|
|
});
|
|
|
|
test('destroy removes the navigation listeners', (t) => {
|
|
const tab = installTab(t, SHARE_A);
|
|
const manager = new ShareLinkManager(fakeViewer());
|
|
manager.parseInitialHash();
|
|
let restores = 0;
|
|
manager.watchNavigation(() => {
|
|
restores += 1;
|
|
});
|
|
assert.deepEqual(tab.listeners, { hashchange: 1, popstate: 1 });
|
|
manager.watchNavigation(() => {
|
|
restores += 1;
|
|
});
|
|
assert.deepEqual(
|
|
tab.listeners,
|
|
{ hashchange: 1, popstate: 1 },
|
|
'rewatching replaces',
|
|
);
|
|
manager.destroy();
|
|
assert.deepEqual(tab.listeners, { hashchange: 0, popstate: 0 });
|
|
tab.navigate(SHARE_B);
|
|
assert.equal(restores, 0);
|
|
});
|
|
|
|
function deferred() {
|
|
let resolve;
|
|
const promise = new Promise((res) => {
|
|
resolve = res;
|
|
});
|
|
return { promise, resolve };
|
|
}
|
|
|
|
/** A ShareRestoration over a real ShareLinkManager with a stub restore sink. */
|
|
function restorationHarness(t, initialHash, navigationOverrides = {}) {
|
|
const tab = installTab(t, initialHash);
|
|
const timers = installManualTimers(t);
|
|
|
|
const restores = [];
|
|
let navigationGeneration = 0;
|
|
const manager = new ShareLinkManager(fakeViewer(), {
|
|
onRestore: (state) => {
|
|
const gate = deferred();
|
|
restores.push({ state, gate });
|
|
return gate.promise;
|
|
},
|
|
isNavigationCurrent: () => false,
|
|
});
|
|
const layerCalls = [];
|
|
const handoffs = [];
|
|
const owner = new ShareRestoration({
|
|
viewer: { canvas: new EventTarget() },
|
|
navigation: {
|
|
_beginDeferredNavigation: () => ++navigationGeneration,
|
|
_reassertNavigationHandoff: (generation, options) => {
|
|
handoffs.push({ generation, options });
|
|
return true;
|
|
},
|
|
_stampNavigation() {},
|
|
...navigationOverrides,
|
|
},
|
|
syncShareState() {},
|
|
syncModels3d() {},
|
|
showStatus() {},
|
|
feedback: {},
|
|
updateFeedback() {},
|
|
});
|
|
owner.attachLinks(manager);
|
|
owner._layerStateCoordinator = {
|
|
applyShareLayerState(options) {
|
|
layerCalls.push(options);
|
|
return Promise.resolve([]);
|
|
},
|
|
getDurableState: () => null,
|
|
cancelPendingShareTracking: () => false,
|
|
destroy() {},
|
|
};
|
|
owner.start();
|
|
t.after(() => owner.destroy());
|
|
return { tab, timers, manager, owner, restores, layerCalls, handoffs };
|
|
}
|
|
|
|
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
|
|
|
test('a navigated share runs the start-up restore pipeline and supersedes the delayed one', async (t) => {
|
|
const { tab, timers, manager, owner, restores, layerCalls } =
|
|
restorationHarness(t, SHARE_A);
|
|
assert.equal(timers.size, 1, 'start-up restore waits for its delayed flight');
|
|
|
|
tab.navigate(SHARE_B);
|
|
assert.equal(timers.size, 0, 'the delayed start-up restore never runs');
|
|
assert.equal((await owner.initialRestorePromise).status, 'superseded');
|
|
assert.equal(restores.length, 1);
|
|
assert.equal(restores[0].state.style, 'normal');
|
|
assert.equal(restores[0].state.mapStack, 'photoreal');
|
|
assert.deepEqual(layerCalls[0].shareLayerState.enabledLayerIds, ['flights']);
|
|
|
|
restores[0].gate.resolve();
|
|
await flush();
|
|
assert.equal(manager._initialRestorePending, false);
|
|
});
|
|
|
|
test('a navigated share releases the old Follow passively, never as a saved preference', (t) => {
|
|
const { tab, handoffs } = restorationHarness(t, SHARE_A);
|
|
tab.navigate(SHARE_B);
|
|
assert.equal(handoffs.length, 1);
|
|
assert.deepEqual(handoffs[0].options, { trackingOrigin: 'programmatic' });
|
|
});
|
|
|
|
test('a navigated share that fails to start never freezes the address bar', async (t) => {
|
|
const settled = [];
|
|
let calls = 0;
|
|
const { tab, manager } = restorationHarness(t, SHARE_A, {
|
|
// Start-up reserves the camera; the navigated share then fails to.
|
|
_beginDeferredNavigation: () => {
|
|
calls += 1;
|
|
if (calls > 1) throw new Error('navigation unavailable');
|
|
return calls;
|
|
},
|
|
});
|
|
window.addEventListener('gev:share-navigation-restore-settled', (event) =>
|
|
settled.push(event.detail),
|
|
);
|
|
tab.navigate(SHARE_B);
|
|
assert.equal(manager._initialRestorePending, false);
|
|
assert.equal(settled[0]?.status, 'failed');
|
|
});
|
|
|
|
test('a second navigation during a restore supersedes it end to end', async (t) => {
|
|
const { tab, manager, restores, layerCalls } = restorationHarness(t, SHARE_A);
|
|
tab.navigate(SHARE_B);
|
|
tab.navigate(SHARE_A);
|
|
assert.equal(restores.length, 2);
|
|
assert.equal(layerCalls.length, 2);
|
|
assert.deepEqual(layerCalls[1].shareLayerState.enabledLayerIds, [
|
|
'earthquakes',
|
|
]);
|
|
|
|
// The newer restore finishes first; the older one settling late must not
|
|
// release or reopen anything.
|
|
restores[1].gate.resolve();
|
|
await flush();
|
|
assert.equal(manager._initialRestorePending, false);
|
|
manager._initialRestorePending = true; // observe any late release
|
|
restores[0].gate.resolve();
|
|
await flush();
|
|
assert.equal(manager._initialRestorePending, true);
|
|
});
|
|
|
|
test('the layer coordinator replaces in-flight share layers with the navigated share', async () => {
|
|
const enableGates = new Map();
|
|
const manager = new DataLayerManager({});
|
|
for (const id of REGISTERED_LAYER_IDS) {
|
|
manager.register({
|
|
id,
|
|
name: id,
|
|
icon: '',
|
|
source: 'test',
|
|
async init() {
|
|
return true;
|
|
},
|
|
async enable() {
|
|
const gate = enableGates.get(id);
|
|
if (gate) await gate.promise;
|
|
return true;
|
|
},
|
|
async update() {
|
|
return true;
|
|
},
|
|
async disable() {
|
|
return true;
|
|
},
|
|
});
|
|
}
|
|
manager.finalizeRegistrations(LAYER_STATE_REGISTRY);
|
|
const quakeGate = deferred();
|
|
enableGates.set('earthquakes', quakeGate);
|
|
const writes = [];
|
|
const storage = {
|
|
getItem: () => null,
|
|
setItem: (...args) => writes.push(args),
|
|
};
|
|
const sink = {
|
|
provider: null,
|
|
setLayerStateProvider(provider) {
|
|
this.provider = provider;
|
|
},
|
|
onLayerStateChange() {},
|
|
};
|
|
const coordinator = new LayerStateCoordinator(manager, sink, { storage });
|
|
const first = coordinator.start({
|
|
shareLayerState: decodeLayerStateParams(new URLSearchParams(SHARE_A)),
|
|
allowLocalState: false,
|
|
});
|
|
const second = coordinator.applyShareLayerState({
|
|
shareLayerState: decodeLayerStateParams(new URLSearchParams(SHARE_B)),
|
|
});
|
|
quakeGate.resolve();
|
|
await Promise.all([first, second]);
|
|
const enabled = (id) => manager.getLayerLifecycleState(id)?.enabled === true;
|
|
assert.equal(enabled('earthquakes'), false);
|
|
assert.equal(enabled('flights'), true);
|
|
assert.deepEqual(sink.provider().enabledLayerIds, ['flights']);
|
|
assert.equal(coordinator.source, 'share');
|
|
assert.deepEqual(
|
|
writes,
|
|
[],
|
|
'a passive share restore never writes local preferences',
|
|
);
|
|
|
|
// A share without a layer payload restores the defaults, as a reload does.
|
|
await coordinator.applyShareLayerState({ shareLayerState: null });
|
|
assert.equal(coordinator.source, 'legacy-share');
|
|
assert.deepEqual(
|
|
sink.provider().enabledLayerIds,
|
|
createDefaultLayerState().enabledLayerIds,
|
|
);
|
|
assert.equal(
|
|
enabled('flights'),
|
|
createDefaultLayerState().enabledLayerIds.includes('flights'),
|
|
);
|
|
coordinator.destroy();
|
|
});
|