diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 5fb980b1f..d2cce50af 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -563,6 +563,7 @@ "src/ui/renderFailureEventRetry.test.mjs", "src/app/renderFailureInlineRetry.test.mjs", "src/ui/renderFailureSecondLoop.test.mjs", + "src/ui/renderLoopFixture.mjs", "src/layers/cctv/lifecycle.js", "src/layers/cctv/controls.js", "src/layers/cctv/geometry.js", diff --git a/src/app/renderFailureInlineRetry.test.mjs b/src/app/renderFailureInlineRetry.test.mjs index 2bbc98f1e..fa84e880f 100644 --- a/src/app/renderFailureInlineRetry.test.mjs +++ b/src/app/renderFailureInlineRetry.test.mjs @@ -5,10 +5,9 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; import { keepPanelRendering } from './embed.js'; -import { installRenderFailureNotice } from '../ui/renderFailure.js'; +import { renderLoopFixture } from '../ui/renderLoopFixture.mjs'; function setup() { - const frames = []; let tick = null; let clock = 0; // The host reports the panel hidden: its own animation frames never arrive. @@ -18,8 +17,6 @@ function setup() { setInterval: (fn) => ((tick = fn), 1), clearInterval() {}, }; - const previousRaf = globalThis.requestAnimationFrame; - globalThis.requestAnimationFrame = (fn) => frames.push(fn); const previousError = console.error; console.error = () => {}; const state = { failure: null, drawn: 0 }; @@ -40,66 +37,33 @@ function setup() { this.postRender.raiseEvent(this); }, }; - const widget = Object.create(Cesium.CesiumWidget.prototype); - Object.assign(widget, { - _scene: scene, - _clock: { tick() {} }, - _canRender: true, - _useDefaultRenderLoop: false, - _renderLoopRunning: false, - _showRenderLoopErrors: false, - _targetFrameRate: undefined, - isDestroyed: () => false, - resize() {}, - }); - // CesiumWidget's own renderError handler, which marks the loop stopped. - scene.renderError.addEventListener(() => { - widget._useDefaultRenderLoop = false; - widget._renderLoopRunning = false; - }); - const viewer = { - scene, - cesiumWidget: widget, - canvas: { addEventListener() {}, removeEventListener() {} }, - get useDefaultRenderLoop() { - return widget.useDefaultRenderLoop; - }, - set useDefaultRenderLoop(value) { - widget.useDefaultRenderLoop = value; - }, - isDestroyed: () => false, - render: () => widget.render(), - resize: () => widget.resize(), - }; - const store = { - renderFailure: null, - publishRenderFailure(next) { - this.renderFailure = next; - }, - }; - const uninstall = installRenderFailureNotice(viewer, { - store, - allowReload: false, - locationRef: { href: 'https://gev.example/', reload() {}, assign() {} }, - logError() {}, - }); + const { frames, widget, viewer, store, runFrames, restore } = + renderLoopFixture(scene, { + viewer: { + isDestroyed: () => false, + render: () => widget.render(), + resize: () => widget.resize(), + }, + notice: { allowReload: false }, + }); const stop = keepPanelRendering(viewer, { windowRef, now: () => clock }); viewer.useDefaultRenderLoop = true; const timerFrame = () => { clock += 400; tick(); }; - const animationFrames = (n) => { - for (let frame = 1; frame <= n; frame += 1) - for (const render of frames.splice(0)) render(frame * 16); + return { + frames, + state, + store, + timerFrame, + animationFrames: runFrames, + restore: () => { + stop(); + restore(); + console.error = previousError; + }, }; - const restore = () => { - stop(); - uninstall(); - globalThis.requestAnimationFrame = previousRaf; - console.error = previousError; - }; - return { frames, state, store, timerFrame, animationFrames, restore }; } for (const failure of ['caught', 'thrown']) { diff --git a/src/ui/renderFailure.js b/src/ui/renderFailure.js index 9226a72dd..7600a8dc7 100644 --- a/src/ui/renderFailure.js +++ b/src/ui/renderFailure.js @@ -21,7 +21,7 @@ export function installRenderFailureNotice( } = {}, ) { const { scene } = viewer; - const canvas = viewer.canvas ?? scene.canvas; + const { canvas } = viewer; let notice = null; let contextLost = false; // With rethrowRenderErrors, Scene throws the error it has just raised; @@ -36,7 +36,7 @@ export function installRenderFailureNotice( return; } viewer.useDefaultRenderLoop = true; - scene.requestRender?.(); + scene.requestRender(); } function reset() { @@ -113,11 +113,11 @@ export function installRenderFailureNotice( raisedByScene = null; }), ); - canvas?.addEventListener?.('webglcontextlost', onContextLost); + canvas.addEventListener('webglcontextlost', onContextLost); return () => { removeRenderError(); unguard.forEach((restore) => restore()); - canvas?.removeEventListener?.('webglcontextlost', onContextLost); + canvas.removeEventListener('webglcontextlost', onContextLost); clear(); }; } @@ -147,7 +147,6 @@ function frameEvents(viewer) { tileset?.loadProgress, tileset?.allTilesLoaded, tileset?.initialTilesLoaded, - tileset?.tileLoad, ); } return events; diff --git a/src/ui/renderFailure.test.mjs b/src/ui/renderFailure.test.mjs index 565422226..43f83dff2 100644 --- a/src/ui/renderFailure.test.mjs +++ b/src/ui/renderFailure.test.mjs @@ -1,27 +1,14 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; 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(), + renderError: new Cesium.Event(), + preUpdate: new Cesium.Event(), + postRender: new Cesium.Event(), requestRender() { this.requests = (this.requests ?? 0) + 1; }, @@ -62,12 +49,12 @@ function fixture(t, options = {}) { // 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); + scene.preUpdate.raiseEvent(scene); if (error) { viewer.useDefaultRenderLoop = false; - scene.renderError.raise(scene, error); + scene.renderError.raiseEvent(scene, error); } - scene.postRender.raise(scene); + scene.postRender.raiseEvent(scene); }; return { viewer, @@ -105,8 +92,8 @@ test('Retry restarts the render loop and a clean frame clears the notice', (t) = 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); + assert.equal(f.scene.preUpdate.numberOfListeners, 0); + assert.equal(f.scene.postRender.numberOfListeners, 0); }); test('a loop restarted into the same error keeps one notice', (t) => { @@ -137,7 +124,7 @@ test('a new failure after recovery is a new appearance', (t) => { test('a render error without a value shows no detail', (t) => { const f = fixture(t); f.viewer.useDefaultRenderLoop = false; - f.scene.renderError.raise(f.scene, undefined); + f.scene.renderError.raiseEvent(f.scene, undefined); assert.equal(f.store.renderFailure.reason, 'render-error'); assert.equal(f.store.renderFailure.detail, ''); }); @@ -179,8 +166,8 @@ test('uninstall removes the listeners and the notice', (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.scene.renderError.numberOfListeners, 0); + assert.equal(f.scene.postRender.numberOfListeners, 0); assert.equal(f.canvasListeners.size, 0); f.frame(new Error('later')); assert.equal(f.store.renderFailure, null); diff --git a/src/ui/renderFailureEventRetry.test.mjs b/src/ui/renderFailureEventRetry.test.mjs index 8de8944d0..a41697493 100644 --- a/src/ui/renderFailureEventRetry.test.mjs +++ b/src/ui/renderFailureEventRetry.test.mjs @@ -5,11 +5,9 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; import { installRenderFailureNotice } from './renderFailure.js'; +import { renderLoopFixture } from './renderLoopFixture.mjs'; function setup() { - const frames = []; - const previousRaf = globalThis.requestAnimationFrame; - globalThis.requestAnimationFrame = (fn) => frames.push(fn); // Scene.render as in Scene.js:4369: preUpdate always, preRender/postRender // only when a frame is rendered (requestRenderMode idle skips them). const scene = { @@ -34,53 +32,7 @@ function setup() { this.postRender.raiseEvent(this, time); }, }; - const widget = Object.create(Cesium.CesiumWidget.prototype); - Object.assign(widget, { - _scene: scene, - _clock: { tick() {} }, - _canRender: true, - _useDefaultRenderLoop: false, - _renderLoopRunning: false, - _showRenderLoopErrors: false, - _targetFrameRate: undefined, - isDestroyed: () => false, - resize() {}, - }); - scene.renderError.addEventListener(() => { - widget._useDefaultRenderLoop = false; - widget._renderLoopRunning = false; - }); - const viewer = { - scene, - cesiumWidget: widget, - canvas: { addEventListener() {}, removeEventListener() {} }, - get useDefaultRenderLoop() { - return widget.useDefaultRenderLoop; - }, - set useDefaultRenderLoop(value) { - widget.useDefaultRenderLoop = value; - }, - }; - const store = { - renderFailure: null, - publishRenderFailure(next) { - this.renderFailure = next; - }, - }; - const uninstall = installRenderFailureNotice(viewer, { - store, - locationRef: { href: 'https://gev.example/', reload() {}, assign() {} }, - logError() {}, - }); - const runFrames = (n) => { - for (let i = 0; i < n; i += 1) - for (const fn of frames.splice(0)) fn(16 * (i + 1)); - }; - const restore = () => { - uninstall(); - globalThis.requestAnimationFrame = previousRaf; - }; - return { frames, scene, widget, viewer, store, runFrames, restore }; + return { scene, ...renderLoopFixture(scene) }; } for (const eventName of ['postRender', 'preUpdate']) { @@ -200,7 +152,6 @@ const FRAME_EVENTS = { 'tileset.allTilesLoaded': (v) => v.scene.primitives.get(0).allTilesLoaded, 'tileset.initialTilesLoaded': (v) => v.scene.primitives.get(0).initialTilesLoaded, - 'tileset.tileLoad': (v) => v.scene.primitives.get(0).tileLoad, }; for (const [name, pick] of Object.entries(FRAME_EVENTS)) { @@ -214,7 +165,6 @@ for (const [name, pick] of Object.entries(FRAME_EVENTS)) { loadProgress: new Cesium.Event(), allTilesLoaded: new Cesium.Event(), initialTilesLoaded: new Cesium.Event(), - tileLoad: new Cesium.Event(), }; const viewer = { scene: { diff --git a/src/ui/renderFailureLoopErrors.test.mjs b/src/ui/renderFailureLoopErrors.test.mjs index f8f3607c6..1446c149f 100644 --- a/src/ui/renderFailureLoopErrors.test.mjs +++ b/src/ui/renderFailureLoopErrors.test.mjs @@ -3,66 +3,21 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; -import { installRenderFailureNotice } from './renderFailure.js'; - -// What src/app/scene.js passes to the viewer (showRenderLoopErrors). -const SHOW_RENDER_LOOP_ERRORS = false; +import { renderLoopFixture } from './renderLoopFixture.mjs'; function runLoop(render) { - const frames = []; - const previousRaf = globalThis.requestAnimationFrame; - globalThis.requestAnimationFrame = (fn) => frames.push(fn); - try { - const scene = { - renderError: new Cesium.Event(), - preUpdate: new Cesium.Event(), - postRender: new Cesium.Event(), - requestRender() {}, - initializeFrame() {}, - render, - }; - const widget = Object.create(Cesium.CesiumWidget.prototype); - Object.assign(widget, { - _scene: scene, - _clock: { tick() {} }, - _canRender: true, - _useDefaultRenderLoop: false, - _renderLoopRunning: false, - _showRenderLoopErrors: SHOW_RENDER_LOOP_ERRORS, - _targetFrameRate: undefined, - isDestroyed: () => false, - resize() {}, - showErrorPanel() {}, - }); - const viewer = { - scene, - cesiumWidget: widget, - canvas: { addEventListener() {}, removeEventListener() {} }, - get useDefaultRenderLoop() { - return widget.useDefaultRenderLoop; - }, - set useDefaultRenderLoop(value) { - widget.useDefaultRenderLoop = value; - }, - }; - const store = { - renderFailure: null, - publishRenderFailure(next) { - this.renderFailure = next; - }, - }; - const logged = []; - const uninstall = installRenderFailureNotice(viewer, { - store, - locationRef: { href: 'https://gev.example/', reload() {}, assign() {} }, - logError: (error) => logged.push(error), - }); - viewer.useDefaultRenderLoop = true; - while (frames.length) frames.shift()(16); - return { widget, store, logged, uninstall }; - } finally { - globalThis.requestAnimationFrame = previousRaf; - } + const scene = { + renderError: new Cesium.Event(), + preUpdate: new Cesium.Event(), + postRender: new Cesium.Event(), + requestRender() {}, + initializeFrame() {}, + render, + }; + const fixture = renderLoopFixture(scene); + fixture.viewer.useDefaultRenderLoop = true; + fixture.runFrames(3); + return { ...fixture, uninstall: fixture.restore }; } test('an error thrown outside tryAndCatchError still shows the notice', () => { diff --git a/src/ui/renderFailureSecondLoop.test.mjs b/src/ui/renderFailureSecondLoop.test.mjs index ba36c1817..2b5c6196f 100644 --- a/src/ui/renderFailureSecondLoop.test.mjs +++ b/src/ui/renderFailureSecondLoop.test.mjs @@ -4,12 +4,9 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; -import { installRenderFailureNotice } from './renderFailure.js'; +import { renderLoopFixture } from './renderLoopFixture.mjs'; function setup({ targetFrameRate } = {}) { - const frames = []; - const previousRaf = globalThis.requestAnimationFrame; - globalThis.requestAnimationFrame = (fn) => frames.push(fn); const scene = { renderError: new Cesium.Event(), preUpdate: new Cesium.Event(), @@ -29,57 +26,7 @@ function setup({ targetFrameRate } = {}) { this.postRender.raiseEvent(this, time); }, }; - const widget = Object.create(Cesium.CesiumWidget.prototype); - Object.assign(widget, { - _scene: scene, - _clock: { tick() {} }, - _canRender: true, - _useDefaultRenderLoop: false, - _renderLoopRunning: false, - _showRenderLoopErrors: false, - _targetFrameRate: targetFrameRate, - isDestroyed: () => false, - resize() {}, - }); - // CesiumWidget's own renderError handler (CesiumWidget.js:414). - scene.renderError.addEventListener(() => { - widget._useDefaultRenderLoop = false; - widget._renderLoopRunning = false; - }); - const viewer = { - scene, - cesiumWidget: widget, - canvas: { addEventListener() {}, removeEventListener() {} }, - get useDefaultRenderLoop() { - return widget.useDefaultRenderLoop; - }, - set useDefaultRenderLoop(value) { - widget.useDefaultRenderLoop = value; - }, - }; - const store = { - renderFailure: null, - publishRenderFailure(next) { - this.renderFailure = next; - }, - }; - const uninstall = installRenderFailureNotice(viewer, { - store, - locationRef: { href: 'https://gev.example/', reload() {}, assign() {} }, - logError() {}, - }); - let time = 0; - const runFrames = (n) => { - for (let i = 0; i < n; i += 1) { - time += 17; - for (const fn of frames.splice(0)) fn(time); - } - }; - const restore = () => { - uninstall(); - globalThis.requestAnimationFrame = previousRaf; - }; - return { frames, scene, viewer, store, runFrames, restore }; + return { scene, ...renderLoopFixture(scene, { targetFrameRate }) }; } for (const targetFrameRate of [undefined, 60]) { diff --git a/src/ui/renderLoopFixture.mjs b/src/ui/renderLoopFixture.mjs new file mode 100644 index 000000000..562e2d3f7 --- /dev/null +++ b/src/ui/renderLoopFixture.mjs @@ -0,0 +1,72 @@ +// A widget on Cesium's real CesiumWidget.prototype (startRenderLoop, the +// useDefaultRenderLoop setter, render) around a scene double, with animation +// frames queued by hand and the render failure notice installed. +import * as Cesium from 'cesium'; +import { installRenderFailureNotice } from './renderFailure.js'; + +export function renderLoopFixture( + scene, + { targetFrameRate, viewer: viewerExtras = {}, notice = {} } = {}, +) { + const frames = []; + const previousRaf = globalThis.requestAnimationFrame; + globalThis.requestAnimationFrame = (fn) => frames.push(fn); + const widget = Object.create(Cesium.CesiumWidget.prototype); + Object.assign(widget, { + _scene: scene, + _clock: { tick() {} }, + _canRender: true, + _useDefaultRenderLoop: false, + _renderLoopRunning: false, + // What src/app/scene.js passes to the viewer. + _showRenderLoopErrors: false, + _targetFrameRate: targetFrameRate, + isDestroyed: () => false, + resize() {}, + }); + // CesiumWidget's own renderError handler, which marks the loop stopped. + scene.renderError.addEventListener(() => { + widget._useDefaultRenderLoop = false; + widget._renderLoopRunning = false; + }); + const viewer = Object.assign( + { + scene, + cesiumWidget: widget, + canvas: { addEventListener() {}, removeEventListener() {} }, + get useDefaultRenderLoop() { + return widget.useDefaultRenderLoop; + }, + set useDefaultRenderLoop(value) { + widget.useDefaultRenderLoop = value; + }, + }, + viewerExtras, + ); + const store = { + renderFailure: null, + publishRenderFailure(next) { + this.renderFailure = next; + }, + }; + const logged = []; + const uninstall = installRenderFailureNotice(viewer, { + store, + locationRef: { href: 'https://gev.example/', reload() {}, assign() {} }, + logError: (error) => logged.push(error), + ...notice, + }); + let time = 0; + const runFrames = (n) => { + for (let i = 0; i < n; i += 1) { + // Above a 60 fps target's interval, so every frame draws. + time += 17; + for (const fn of frames.splice(0)) fn(time); + } + }; + const restore = () => { + uninstall(); + globalThis.requestAnimationFrame = previousRaf; + }; + return { frames, widget, viewer, store, logged, runFrames, restore }; +}