mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-13 14:52:19 +00:00
Show a recoverable notice when the globe stops drawing
Cesium stops its render loop after a render error and offers only a panel that dismisses itself, and a lost WebGL context never draws again, so the globe froze or went black while the controls kept responding. The scene chrome now shows an alert dialog with the error. Retry restarts the render loop and keeps the notice until a frame renders without an error; after a lost context it reloads the page, whose address keeps the view. Reset view opens the app root without the restored hash or share path. An inline conversation panel never reloads its host page. The application viewer turns off Cesium's own panel; other callers keep it.
This commit is contained in:
1 parent
7d8b80db0a
commit
fbec1488ac
23 files changed
+658
-16
No files matched your search
@@ -0,0 +1,184 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { installRenderFailureNotice } from './renderFailure.js';
|
||||
|
||||
function event() {
|
||||
const listeners = new Set();
|
||||
return {
|
||||
listeners,
|
||||
addEventListener(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
raise(...args) {
|
||||
for (const listener of [...listeners]) listener(...args);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fixture(t, options = {}) {
|
||||
const canvasListeners = new Map();
|
||||
const scene = {
|
||||
renderError: event(),
|
||||
preUpdate: event(),
|
||||
postRender: event(),
|
||||
requestRender() {
|
||||
this.requests = (this.requests ?? 0) + 1;
|
||||
},
|
||||
};
|
||||
const viewer = {
|
||||
scene,
|
||||
useDefaultRenderLoop: true,
|
||||
canvas: {
|
||||
addEventListener: (name, listener) => canvasListeners.set(name, listener),
|
||||
removeEventListener: (name, listener) => {
|
||||
if (canvasListeners.get(name) === listener)
|
||||
canvasListeners.delete(name);
|
||||
},
|
||||
},
|
||||
};
|
||||
const store = {
|
||||
renderFailure: null,
|
||||
published: [],
|
||||
publishRenderFailure(next) {
|
||||
this.renderFailure = next;
|
||||
this.published.push(next);
|
||||
},
|
||||
};
|
||||
const visits = [];
|
||||
const locationRef = {
|
||||
href: 'https://gev.example/share/AQc?lang=ru#lat=48.8584&lon=2.2945',
|
||||
reload: () => visits.push('reload'),
|
||||
assign: (url) => visits.push(String(url)),
|
||||
};
|
||||
const logged = [];
|
||||
const uninstall = installRenderFailureNotice(viewer, {
|
||||
store,
|
||||
locationRef,
|
||||
logError: (error) => logged.push(error),
|
||||
...options,
|
||||
});
|
||||
t.after(uninstall);
|
||||
// One Cesium frame: preUpdate, an optional render error, then postRender,
|
||||
// which Cesium raises after a failed frame too.
|
||||
const frame = (error) => {
|
||||
scene.preUpdate.raise(scene);
|
||||
if (error) {
|
||||
viewer.useDefaultRenderLoop = false;
|
||||
scene.renderError.raise(scene, error);
|
||||
}
|
||||
scene.postRender.raise(scene);
|
||||
};
|
||||
return {
|
||||
viewer,
|
||||
scene,
|
||||
store,
|
||||
visits,
|
||||
logged,
|
||||
uninstall,
|
||||
frame,
|
||||
loseContext: () => canvasListeners.get('webglcontextlost')?.({}),
|
||||
canvasListeners,
|
||||
};
|
||||
}
|
||||
|
||||
test('a render error shows a notice that survives the failed frame', (t) => {
|
||||
const f = fixture(t);
|
||||
assert.equal(f.store.renderFailure, null);
|
||||
const error = new Error('Expected width to be greater than 0');
|
||||
f.frame(error);
|
||||
const notice = f.store.renderFailure;
|
||||
assert.equal(notice.reason, 'render-error');
|
||||
assert.equal(notice.detail, 'Expected width to be greater than 0');
|
||||
assert.equal(notice.shown, 1);
|
||||
assert.equal(typeof notice.retry, 'function');
|
||||
assert.equal(typeof notice.reset, 'function');
|
||||
assert.deepEqual(f.logged, [error]);
|
||||
});
|
||||
|
||||
test('Retry restarts the render loop and a clean frame clears the notice', (t) => {
|
||||
const f = fixture(t);
|
||||
f.frame(new Error('boom'));
|
||||
f.store.renderFailure.retry();
|
||||
assert.equal(f.viewer.useDefaultRenderLoop, true);
|
||||
assert.equal(f.scene.requests, 1);
|
||||
assert.ok(f.store.renderFailure, 'the notice waits for a frame');
|
||||
f.frame();
|
||||
assert.equal(f.store.renderFailure, null);
|
||||
// Clearing stops the per-frame listeners.
|
||||
assert.equal(f.scene.preUpdate.listeners.size, 0);
|
||||
assert.equal(f.scene.postRender.listeners.size, 0);
|
||||
});
|
||||
|
||||
test('a loop restarted into the same error keeps one notice', (t) => {
|
||||
const f = fixture(t);
|
||||
f.frame(new Error('boom'));
|
||||
const first = f.store.renderFailure;
|
||||
f.store.renderFailure.retry();
|
||||
f.frame(new Error('boom'));
|
||||
assert.equal(f.store.renderFailure, first);
|
||||
assert.equal(f.store.published.length, 1);
|
||||
f.frame(new Error('other'));
|
||||
assert.equal(f.store.renderFailure.detail, 'other');
|
||||
assert.equal(f.store.renderFailure.shown, first.shown, 'no new appearance');
|
||||
});
|
||||
|
||||
test('a new failure after recovery is a new appearance', (t) => {
|
||||
const f = fixture(t);
|
||||
f.frame(new Error('boom'));
|
||||
f.store.renderFailure.retry();
|
||||
f.frame();
|
||||
f.frame(new Error('boom'));
|
||||
assert.equal(f.store.renderFailure.shown, 2);
|
||||
});
|
||||
|
||||
test('Reset opens the app root without the restored view', (t) => {
|
||||
const f = fixture(t);
|
||||
f.frame(new Error('boom'));
|
||||
f.store.renderFailure.reset();
|
||||
assert.deepEqual(f.visits, ['https://gev.example/?lang=ru']);
|
||||
});
|
||||
|
||||
test('a lost context offers a reload and no frame clears it', (t) => {
|
||||
const f = fixture(t);
|
||||
f.loseContext();
|
||||
const notice = f.store.renderFailure;
|
||||
assert.equal(notice.reason, 'context-lost');
|
||||
assert.equal(notice.detail, '');
|
||||
// Cesium's next frame then fails on the dead drawing buffer.
|
||||
f.frame(new Error('Expected width to be greater than 0'));
|
||||
assert.equal(f.store.renderFailure.reason, 'context-lost');
|
||||
f.frame();
|
||||
assert.ok(f.store.renderFailure, 'a lost context never draws again');
|
||||
f.store.renderFailure.retry();
|
||||
assert.deepEqual(f.visits, ['reload']);
|
||||
});
|
||||
|
||||
test('a page that must not reload offers neither reload nor reset', (t) => {
|
||||
const f = fixture(t, { allowReload: false });
|
||||
f.frame(new Error('boom'));
|
||||
assert.equal(typeof f.store.renderFailure.retry, 'function');
|
||||
assert.equal(f.store.renderFailure.reset, null);
|
||||
f.loseContext();
|
||||
assert.equal(f.store.renderFailure.retry, null);
|
||||
assert.equal(f.store.renderFailure.reset, null);
|
||||
});
|
||||
|
||||
test('uninstall removes the listeners and the notice', (t) => {
|
||||
const f = fixture(t);
|
||||
f.frame(new Error('boom'));
|
||||
f.uninstall();
|
||||
assert.equal(f.store.renderFailure, null);
|
||||
assert.equal(f.scene.renderError.listeners.size, 0);
|
||||
assert.equal(f.scene.postRender.listeners.size, 0);
|
||||
assert.equal(f.canvasListeners.size, 0);
|
||||
f.frame(new Error('later'));
|
||||
assert.equal(f.store.renderFailure, null);
|
||||
});
|
||||
|
||||
test('a viewer without a scene installs nothing', () => {
|
||||
const uninstall = installRenderFailureNotice(null, {
|
||||
store: { publishRenderFailure: () => assert.fail('nothing to publish') },
|
||||
});
|
||||
uninstall();
|
||||
});
|
||||
Reference in new issue
Block a user