mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-12 06:12:18 +00:00
Hand the session off on Back/Forward and roll failed globe switches back to the last working engine
The engine switch moves out of main.js into src/standalone/engineSwitch.js: every engine change in the page hands the session off (layers, Director, Contacts, directions and satellite cameras), whether it came from the picker or from history. Each attempt has a generation; a failure rolls back to the last engine that started, requests made during a switch or recovery are reconciled once it settles, and a picked engine adds its history entry only after it starts. The first-frame wait only lifts the cover and never fails a started engine; a hidden tab does not wait. Nothing starts after pagehide, and a failed recovery leaves the picker usable.
This commit is contained in:
1 parent
64c5cd25e6
commit
4d2b061e3b
5 files changed
+1320
-201
No files matched your search
+145
-21
@@ -27,19 +27,49 @@ try {
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
|
||||
const fetch = window.fetch.bind(window);
|
||||
window.fetch = (url, options) =>
|
||||
String(url).startsWith('https://earthquake.usgs.gov/earthquakes/feed/')
|
||||
? Promise.resolve(
|
||||
Response.json({
|
||||
type: 'FeatureCollection',
|
||||
features: [{
|
||||
type: 'Feature', id: 'qa-boston',
|
||||
geometry: { type: 'Point', coordinates: [-71.0605, 42.3554, 8] },
|
||||
// Two Boston vessels for the Contacts session; empty air and site feeds.
|
||||
const fixtures = {
|
||||
'/api/vessels': () => ({
|
||||
status: 'live',
|
||||
rows: ['123456789', '223456789'].map((mmsi, i) => ({
|
||||
mmsi,
|
||||
name: 'Boston ' + i,
|
||||
lat: 42.3554,
|
||||
lon: -71.0605 + i * 0.05,
|
||||
last_position_epoch: Date.now() / 1000,
|
||||
})),
|
||||
}),
|
||||
'/api/vessels/track': () => ({ samples: [] }),
|
||||
'/api/flights': () => ({ time: Date.now() / 1000, states: [] }),
|
||||
'/api/military': () => ({ now: Date.now(), ac: [] }),
|
||||
'/api/military-installations': () => ({ elements: [], status: 'ok' }),
|
||||
};
|
||||
window.fetch = (url, options) => {
|
||||
if (
|
||||
String(url).startsWith('https://earthquake.usgs.gov/earthquakes/feed/')
|
||||
)
|
||||
return Promise.resolve(
|
||||
Response.json({
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
{
|
||||
type: 'Feature',
|
||||
id: 'qa-boston',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [-71.0605, 42.3554, 8],
|
||||
},
|
||||
properties: { mag: 4, time: Date.now(), place: 'QA Boston' },
|
||||
}],
|
||||
}),
|
||||
)
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const fixture =
|
||||
fixtures[new URL(url.url ?? url, document.baseURI).pathname];
|
||||
return fixture
|
||||
? Promise.resolve(Response.json(fixture()))
|
||||
: fetch(url, options);
|
||||
};
|
||||
});
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
const errors = [];
|
||||
@@ -65,19 +95,43 @@ try {
|
||||
const meanRed = async (enabled) => {
|
||||
await page.evaluate(async (enabled) => {
|
||||
const a = window.__godsEyeView;
|
||||
await a.dataManager.setEnabled('earthquakes', enabled, { origin: 'user' });
|
||||
await a.dataManager.setEnabled('earthquakes', enabled, {
|
||||
origin: 'user',
|
||||
});
|
||||
a.engine.render.request('qa-quake-surface');
|
||||
}, enabled);
|
||||
if (enabled) await page.waitForFunction(() =>
|
||||
window.__godsEyeView.dataManager.layers.get('earthquakes').module.getAnalystRecords().length === 1);
|
||||
await new Promise(resolve => setTimeout(resolve, 1800));
|
||||
const bytes = await page.screenshot({ clip: { x: 675, y: 403, width: 20, height: 20 } });
|
||||
if (enabled)
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('earthquakes')
|
||||
.module.getAnalystRecords().length === 1,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1800));
|
||||
const bytes = await page.screenshot({
|
||||
clip: { x: 675, y: 403, width: 20, height: 20 },
|
||||
});
|
||||
const { channels } = await sharp(bytes).stats();
|
||||
return channels[0].mean - channels[1].mean;
|
||||
};
|
||||
const off = await meanRed(false), on = await meanRed(true);
|
||||
assert.ok(on > off + 8, kind + ' must draw the shallow red earthquake footprint: ' + off + ' -> ' + on);
|
||||
console.log('PASS', kind, 'earthquake footprint red minus green:', off, '->', on);
|
||||
const off = await meanRed(false),
|
||||
on = await meanRed(true);
|
||||
assert.ok(
|
||||
on > off + 8,
|
||||
kind +
|
||||
' must draw the shallow red earthquake footprint: ' +
|
||||
off +
|
||||
' -> ' +
|
||||
on,
|
||||
);
|
||||
console.log(
|
||||
'PASS',
|
||||
kind,
|
||||
'earthquake footprint red minus green:',
|
||||
off,
|
||||
'->',
|
||||
on,
|
||||
);
|
||||
}
|
||||
const stored = () => page.evaluate(() => localStorage.getItem('gev.engine'));
|
||||
async function switchTo(kind) {
|
||||
@@ -93,7 +147,8 @@ try {
|
||||
await page.waitForSelector('#globe-engine', { visible: true });
|
||||
await page.evaluate(() => {
|
||||
window.__switchDocument = document;
|
||||
window.__quakeEntry = window.__godsEyeView.dataManager.layers.get('earthquakes');
|
||||
window.__quakeEntry =
|
||||
window.__godsEyeView.dataManager.layers.get('earthquakes');
|
||||
window.__quakeTimer = window.__quakeEntry.intervalId;
|
||||
window.__renderers ??= new Map();
|
||||
window.__renderers.set(
|
||||
@@ -121,7 +176,8 @@ try {
|
||||
const previous = window.__renderers.get(engine.kind);
|
||||
return (
|
||||
!window.__switchSplash &&
|
||||
window.__quakeEntry === window.__godsEyeView.dataManager.layers.get('earthquakes') &&
|
||||
window.__quakeEntry ===
|
||||
window.__godsEyeView.dataManager.layers.get('earthquakes') &&
|
||||
window.__quakeTimer === window.__quakeEntry.intervalId &&
|
||||
(!previous || previous === engine) &&
|
||||
!window.__outgoingEngine.isDestroyed() &&
|
||||
@@ -199,12 +255,80 @@ try {
|
||||
),
|
||||
);
|
||||
assert.equal(await stored(), 'navara');
|
||||
// Back/Forward must hand the session off like the picker: the Contacts
|
||||
// subject and its navigation history, the layers it owns and the Director
|
||||
// survive, although the earlier entry carries an older view.
|
||||
await page.evaluate(async () => {
|
||||
const { dataManager, styleManager, sceneDirector } = window.__godsEyeView;
|
||||
window.qaLayersBefore = [...dataManager.getEnabledLayerIds()].sort();
|
||||
await dataManager.setEnabled('ais-live-vessels', true, { origin: 'user' });
|
||||
const outcome = await styleManager.setContextMode('contacts');
|
||||
if (!outcome.ok) throw Error(JSON.stringify(outcome));
|
||||
window.qaSession = {
|
||||
director: sceneDirector,
|
||||
context: dataManager.layers.get('military-awareness').module,
|
||||
vessels: dataManager.layers.get('ais-live-vessels'),
|
||||
};
|
||||
if (!window.qaSession.vessels.module.selectById('123456789'))
|
||||
throw Error('Missing first vessel');
|
||||
if (
|
||||
!window.qaSession.context.navigateNext({
|
||||
targetLayer: 'ais-live-vessels',
|
||||
})
|
||||
)
|
||||
throw Error('Cannot navigate to the second vessel');
|
||||
});
|
||||
const session = () =>
|
||||
page.evaluate(() => {
|
||||
const { dataManager, styleManager, sceneDirector } = window.__godsEyeView;
|
||||
const { director, context, vessels } = window.qaSession;
|
||||
return {
|
||||
director: director === sceneDirector && !director._destroyed,
|
||||
owners:
|
||||
context === dataManager.layers.get('military-awareness').module &&
|
||||
vessels === dataManager.layers.get('ais-live-vessels'),
|
||||
mode: styleManager.getContextModeState().mode,
|
||||
subject: context.getContextSnapshot()?.subject?.id,
|
||||
history: context._getAwarenessNavigationStateForTest().historyKeys,
|
||||
enabled: [...dataManager.getEnabledLayerIds()].sort(),
|
||||
};
|
||||
});
|
||||
const live = await session();
|
||||
assert.equal(live.mode, 'flights');
|
||||
assert.equal(live.subject, '223456789');
|
||||
assert.ok(live.history.includes('ais-live-vessels:123456789'));
|
||||
await page.goBack({ waitUntil: 'domcontentloaded' });
|
||||
await ready('cesium');
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('.engine-switch-frame'),
|
||||
);
|
||||
assert.equal((await sharpen()).intensityPct, 49);
|
||||
assert.deepEqual(await session(), live, 'Back keeps the session');
|
||||
await page.goForward();
|
||||
await ready('navara');
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('.engine-switch-frame'),
|
||||
);
|
||||
assert.equal((await sharpen()).intensityPct, 80);
|
||||
assert.deepEqual(await session(), live, 'Forward keeps the session');
|
||||
assert.ok(
|
||||
await page.evaluate(async () => {
|
||||
const { dataManager, styleManager } = window.__godsEyeView;
|
||||
const result = await styleManager.setContextMode('off');
|
||||
await dataManager.setEnabled('ais-live-vessels', false, {
|
||||
origin: 'user',
|
||||
});
|
||||
return (
|
||||
result.ok &&
|
||||
JSON.stringify([...dataManager.getEnabledLayerIds()].sort()) ===
|
||||
JSON.stringify(window.qaLayersBefore)
|
||||
);
|
||||
}),
|
||||
'leaving Contacts after Back/Forward restores the layers it took',
|
||||
);
|
||||
console.log(
|
||||
'PASS Back/Forward keep Contacts subject, history, layers and Director',
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.styleManager.setSharpen({
|
||||
enabled: false,
|
||||
|
||||
+185
-74
@@ -27,66 +27,91 @@ try {
|
||||
const errors = [];
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
await page.setViewport({ width: 1280, height: 720 });
|
||||
await page.evaluateOnNewDocument((layerId, aircraftId) => {
|
||||
window.__qaAircraft = {layerId, aircraftId};
|
||||
const fetch = window.fetch.bind(window);
|
||||
window.__flightPolls = 0;
|
||||
const interval = window.setInterval.bind(window);
|
||||
window.setInterval = function (callback, delay, ...args) {
|
||||
if (typeof callback !== 'function') return interval(callback, delay, ...args);
|
||||
const id = interval(function (...values) {
|
||||
if (window.__handoff?.timer === id) window.__handoff.ticks++;
|
||||
return callback.apply(this, values);
|
||||
}, delay, ...args);
|
||||
return id;
|
||||
};
|
||||
window.fetch = (input, options) => {
|
||||
const path = new URL(input.url ?? input, document.baseURI).pathname;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (path === '/api/flights') {
|
||||
if (layerId === 'flights') window.__flightPolls++;
|
||||
return Promise.resolve(
|
||||
Response.json({
|
||||
time: now,
|
||||
states: [
|
||||
[
|
||||
'71be26',
|
||||
'GEV123',
|
||||
'QA',
|
||||
now,
|
||||
now,
|
||||
-125.5779,
|
||||
40.2539,
|
||||
9700,
|
||||
false,
|
||||
200,
|
||||
45,
|
||||
0,
|
||||
null,
|
||||
9700,
|
||||
null,
|
||||
false,
|
||||
0,
|
||||
],
|
||||
],
|
||||
}),
|
||||
await page.evaluateOnNewDocument(
|
||||
(layerId, aircraftId) => {
|
||||
window.__qaAircraft = { layerId, aircraftId };
|
||||
const fetch = window.fetch.bind(window);
|
||||
window.__flightPolls = 0;
|
||||
const interval = window.setInterval.bind(window);
|
||||
window.setInterval = function (callback, delay, ...args) {
|
||||
if (typeof callback !== 'function')
|
||||
return interval(callback, delay, ...args);
|
||||
const id = interval(
|
||||
function (...values) {
|
||||
if (window.__handoff?.timer === id) window.__handoff.ticks++;
|
||||
return callback.apply(this, values);
|
||||
},
|
||||
delay,
|
||||
...args,
|
||||
);
|
||||
}
|
||||
if (path === '/api/military') {
|
||||
if (layerId === 'military') window.__flightPolls++;
|
||||
return Promise.resolve(Response.json({now: Date.now(), ac: [{
|
||||
hex: 'aaa052', flight: 'GEVMIL', lon: -125.5779, lat: 40.2539,
|
||||
alt_geom: 31825, alt_baro: 31825, gs: 389, track: 45,
|
||||
seen: 0, seen_pos: 0, dbFlags: 1, type: 'adsb_icao',
|
||||
}]}));
|
||||
}
|
||||
if (path === '/api/flights/track')
|
||||
return Promise.resolve(Response.json({ path: [] }));
|
||||
if (path.startsWith('/api/flights/') || path.startsWith('/api/openai/'))
|
||||
return Promise.resolve(Response.json({}));
|
||||
return fetch(input, options);
|
||||
};
|
||||
}, layerId, aircraftId);
|
||||
return id;
|
||||
};
|
||||
window.fetch = (input, options) => {
|
||||
const path = new URL(input.url ?? input, document.baseURI).pathname;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (path === '/api/flights') {
|
||||
if (layerId === 'flights') window.__flightPolls++;
|
||||
return Promise.resolve(
|
||||
Response.json({
|
||||
time: now,
|
||||
states: [
|
||||
[
|
||||
'71be26',
|
||||
'GEV123',
|
||||
'QA',
|
||||
now,
|
||||
now,
|
||||
-125.5779,
|
||||
40.2539,
|
||||
9700,
|
||||
false,
|
||||
200,
|
||||
45,
|
||||
0,
|
||||
null,
|
||||
9700,
|
||||
null,
|
||||
false,
|
||||
0,
|
||||
],
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (path === '/api/military') {
|
||||
if (layerId === 'military') window.__flightPolls++;
|
||||
return Promise.resolve(
|
||||
Response.json({
|
||||
now: Date.now(),
|
||||
ac: [
|
||||
{
|
||||
hex: 'aaa052',
|
||||
flight: 'GEVMIL',
|
||||
lon: -125.5779,
|
||||
lat: 40.2539,
|
||||
alt_geom: 31825,
|
||||
alt_baro: 31825,
|
||||
gs: 389,
|
||||
track: 45,
|
||||
seen: 0,
|
||||
seen_pos: 0,
|
||||
dbFlags: 1,
|
||||
type: 'adsb_icao',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (path === '/api/flights/track')
|
||||
return Promise.resolve(Response.json({ path: [] }));
|
||||
if (path.startsWith('/api/flights/') || path.startsWith('/api/openai/'))
|
||||
return Promise.resolve(Response.json({}));
|
||||
return fetch(input, options);
|
||||
};
|
||||
},
|
||||
layerId,
|
||||
aircraftId,
|
||||
);
|
||||
await page.goto(url.href, { waitUntil: 'domcontentloaded' });
|
||||
const ready = (kind) =>
|
||||
page.waitForFunction(
|
||||
@@ -116,7 +141,9 @@ try {
|
||||
window.__handoff = {
|
||||
dataManager,
|
||||
entry,
|
||||
row: document.querySelector(`[data-layer-id="${window.__qaAircraft.layerId}"] .data-toggle-btn`),
|
||||
row: document.querySelector(
|
||||
`[data-layer-id="${window.__qaAircraft.layerId}"] .data-toggle-btn`,
|
||||
),
|
||||
timer: entry.intervalId,
|
||||
ticks: 0,
|
||||
model: entry.module,
|
||||
@@ -139,19 +166,40 @@ try {
|
||||
HTMLCanvasElement.prototype.getContext = original;
|
||||
};
|
||||
});
|
||||
const historyLength = await page.evaluate(() => history.length);
|
||||
await page.select('#globe-engine', 'cesium');
|
||||
await page.waitForFunction(() => window.__refusedContext === true);
|
||||
await ready('navara');
|
||||
assert.ok(await page.evaluate(() => {
|
||||
window.__restoreContext();
|
||||
const {dataManager, styleManager} = window.__godsEyeView;
|
||||
return new URL(location.href).searchParams.get('engine') === 'navara' &&
|
||||
dataManager === window.__handoff.dataManager &&
|
||||
dataManager.layers.get(window.__qaAircraft.layerId) === window.__handoff.entry &&
|
||||
styleManager.getCockpitState().active &&
|
||||
styleManager.getAircraftTrackingTarget()?.id === window.__qaAircraft.aircraftId;
|
||||
}), 'a failed scene must leave the live session usable');
|
||||
console.log('PASS failed renderer rolled back to the same flight and cockpit');
|
||||
// A failed switch leaves the session usable on the engine that worked and
|
||||
// adds no history entry.
|
||||
const survived = (kind) =>
|
||||
page.evaluate(
|
||||
(kind, length) => {
|
||||
const { dataManager, engine, styleManager } = window.__godsEyeView;
|
||||
return (
|
||||
engine.kind === kind &&
|
||||
new URL(location.href).searchParams.get('engine') === kind &&
|
||||
history.length === length &&
|
||||
dataManager === window.__handoff.dataManager &&
|
||||
dataManager.layers.get(window.__qaAircraft.layerId) ===
|
||||
window.__handoff.entry &&
|
||||
styleManager.getCockpitState().active &&
|
||||
styleManager.getCockpitState().visionMode === 'thermal' &&
|
||||
styleManager.getAircraftTrackingTarget()?.id ===
|
||||
window.__qaAircraft.aircraftId
|
||||
);
|
||||
},
|
||||
kind,
|
||||
historyLength,
|
||||
);
|
||||
await page.evaluate(() => window.__restoreContext());
|
||||
assert.ok(
|
||||
await survived('navara'),
|
||||
'a failed scene must leave the live session usable',
|
||||
);
|
||||
console.log(
|
||||
'PASS cold Cesium failure rolled back to Navara with the same flight, cockpit and history',
|
||||
);
|
||||
|
||||
for (const kind of ['cesium', 'navara']) {
|
||||
await page.select('#globe-engine', kind);
|
||||
@@ -166,8 +214,12 @@ try {
|
||||
before.dataManager === dataManager &&
|
||||
before.entry === entry &&
|
||||
before.model === entry.module,
|
||||
sameRow: Boolean(before.row) &&
|
||||
before.row === document.querySelector(`[data-layer-id="${window.__qaAircraft.layerId}"] .data-toggle-btn`),
|
||||
sameRow:
|
||||
Boolean(before.row) &&
|
||||
before.row ===
|
||||
document.querySelector(
|
||||
`[data-layer-id="${window.__qaAircraft.layerId}"] .data-toggle-btn`,
|
||||
),
|
||||
sameTimer:
|
||||
before.timer === entry.intervalId && entry.intervalId != null,
|
||||
sourcePolls: window.__flightPolls - before.pollCount,
|
||||
@@ -178,7 +230,9 @@ try {
|
||||
active: cockpit.active,
|
||||
mode: cockpit.visionMode,
|
||||
hud: !document.querySelector('#cockpit-hud').hidden,
|
||||
drawn: entry.module.qaDrawn().some((row) => row.id === window.__qaAircraft.aircraftId),
|
||||
drawn: entry.module
|
||||
.qaDrawn()
|
||||
.some((row) => row.id === window.__qaAircraft.aircraftId),
|
||||
height: engine.camera.height(),
|
||||
};
|
||||
});
|
||||
@@ -193,7 +247,10 @@ try {
|
||||
);
|
||||
assert.equal(result.id, aircraftId);
|
||||
assert.equal(result.mode, 'thermal');
|
||||
assert.ok(result.sourcePolls <= result.timerTicks, 'switch must not add a source request');
|
||||
assert.ok(
|
||||
result.sourcePolls <= result.timerTicks,
|
||||
'switch must not add a source request',
|
||||
);
|
||||
if (result.timerTicks === 0) {
|
||||
assert.equal(result.stamp, result.initialStamp);
|
||||
}
|
||||
@@ -204,6 +261,60 @@ try {
|
||||
`PASS ${kind} cockpit, aircraft, HUD, manager and timer (${result.sourcePolls} polls since entry)`,
|
||||
);
|
||||
}
|
||||
// The other direction, warm: the parked Navara renderer fails to resume on
|
||||
// the way back from Cesium. The session rolls back to Cesium, then an
|
||||
// ordinary retry starts Navara cold.
|
||||
await page.evaluate(() => {
|
||||
window.__parkedNavara = window.__godsEyeView.engine;
|
||||
});
|
||||
await page.select('#globe-engine', 'cesium');
|
||||
await ready('cesium');
|
||||
const lengthOnCesium = await page.evaluate(() => {
|
||||
const request = window.__parkedNavara.render.request;
|
||||
window.__parkedNavara.render.request = function (...args) {
|
||||
window.__parkedNavara.render.request = request;
|
||||
window.__refusedResume = true;
|
||||
throw new Error('QA parked renderer failure');
|
||||
};
|
||||
return history.length;
|
||||
});
|
||||
await page.select('#globe-engine', 'navara');
|
||||
await page.waitForFunction(() => window.__refusedResume === true);
|
||||
await ready('cesium');
|
||||
assert.ok(
|
||||
(await page.evaluate(
|
||||
(length) => history.length === length,
|
||||
lengthOnCesium,
|
||||
)) &&
|
||||
(await page.evaluate(() => {
|
||||
const { styleManager } = window.__godsEyeView;
|
||||
return (
|
||||
styleManager.getCockpitState().active &&
|
||||
styleManager.getAircraftTrackingTarget()?.id ===
|
||||
window.__qaAircraft.aircraftId &&
|
||||
new URL(location.href).searchParams.get('engine') === 'cesium'
|
||||
);
|
||||
})),
|
||||
'a failed warm Navara resume must leave Cesium usable without a history entry',
|
||||
);
|
||||
console.log(
|
||||
'PASS warm Navara failure rolled back to Cesium with the same flight and cockpit',
|
||||
);
|
||||
await page.select('#globe-engine', 'navara');
|
||||
await ready('navara');
|
||||
assert.ok(
|
||||
await page.evaluate(() => {
|
||||
const { engine, styleManager } = window.__godsEyeView;
|
||||
return (
|
||||
engine !== window.__parkedNavara &&
|
||||
styleManager.getCockpitState().active &&
|
||||
styleManager.getAircraftTrackingTarget()?.id ===
|
||||
window.__qaAircraft.aircraftId
|
||||
);
|
||||
}),
|
||||
'the retry starts a new Navara renderer on the same session',
|
||||
);
|
||||
console.log('PASS retry after the warm failure started Navara cold');
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.styleManager.controlCockpit('exit'),
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user