mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Add a Global (RainViewer) option to Rain radar beside the US MRMS radar. The proxy pins RainViewer's tile host, validates frames and Web Mercator tile coordinates, and limits upstream tile requests to 80 a minute. Globe basemaps draw the tiles; on 3D Tiles the shell composes them into one equirectangular image per frame and detail window. Co-authored-by: Sandiv Dathunarachchi <[email protected]>
2420 lines
77 KiB
JavaScript
2420 lines
77 KiB
JavaScript
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { createWeatherRendering } from './rendering.js';
|
||
import { createWeatherLayer } from './index.js';
|
||
import { createWeatherClock } from './clock.js';
|
||
import * as Cesium from 'cesium';
|
||
import { Color, ImageryLayerCollection, GeographicTilingScheme } from 'cesium';
|
||
import { NO_IMAGERY_HOST } from './imageryHost.js';
|
||
import { orderWeatherImagery } from './imageryOrder.js';
|
||
import {
|
||
createShellCesium,
|
||
createShellScene,
|
||
renderShells,
|
||
} from './shellFixture.mjs';
|
||
|
||
const times = [
|
||
'2026-09-15T20:00:00.000Z',
|
||
'2026-09-15T20:05:00.000Z',
|
||
'2026-09-15T20:10:00.000Z',
|
||
];
|
||
const snapshot = {
|
||
product: 'radar',
|
||
times,
|
||
latest: times[2],
|
||
bounds: { west: -130, south: 20, east: -60, north: 55 },
|
||
};
|
||
const deferred = () => {
|
||
let resolve, reject;
|
||
const promise = new Promise((yes, no) => {
|
||
resolve = yes;
|
||
reject = no;
|
||
});
|
||
return { promise, resolve, reject };
|
||
};
|
||
const flush = async () => {
|
||
for (let i = 0; i < 30; i++) await Promise.resolve();
|
||
};
|
||
function event() {
|
||
const listeners = new Set();
|
||
return {
|
||
addEventListener(fn) {
|
||
listeners.add(fn);
|
||
return () => listeners.delete(fn);
|
||
},
|
||
emit(...args) {
|
||
for (const fn of [...listeners]) fn(...args);
|
||
},
|
||
get size() {
|
||
return listeners.size;
|
||
},
|
||
};
|
||
}
|
||
function target(extra = {}) {
|
||
const listeners = new Map();
|
||
return {
|
||
...extra,
|
||
addEventListener(name, fn) {
|
||
if (!listeners.has(name)) listeners.set(name, new Set());
|
||
listeners.get(name).add(fn);
|
||
},
|
||
removeEventListener(name, fn) {
|
||
listeners.get(name)?.delete(fn);
|
||
},
|
||
emit(name) {
|
||
for (const fn of [...(listeners.get(name) || [])]) fn();
|
||
},
|
||
get size() {
|
||
return [...listeners.values()].reduce(
|
||
(sum, entries) => sum + entries.size,
|
||
0,
|
||
);
|
||
},
|
||
};
|
||
}
|
||
function mockCanvas() {
|
||
const canvas = { width: 0, height: 0 };
|
||
canvas.getContext = () => ({
|
||
setTransform(...matrix) {
|
||
canvas.transforms = [...(canvas.transforms ?? []), matrix];
|
||
},
|
||
drawImage(source, ...crop) {
|
||
canvas.source = source;
|
||
canvas.crop = crop;
|
||
canvas.drawnWith = canvas.transforms?.at(-1) ?? null;
|
||
},
|
||
getImageData: () => ({ data: new Uint8ClampedArray([194, 100, 0, 200]) }),
|
||
putImageData(pixels) {
|
||
canvas.pixels = pixels.data;
|
||
},
|
||
});
|
||
return canvas;
|
||
}
|
||
const mockResponse = () => ({
|
||
ok: true,
|
||
headers: new Headers(),
|
||
arrayBuffer: async () => new ArrayBuffer(1),
|
||
});
|
||
function renderingHarness(options = {}) {
|
||
const layers = [];
|
||
const providers = [];
|
||
const postRender = event();
|
||
let renderRequests = 0;
|
||
class Provider {
|
||
constructor(options) {
|
||
this.options = options;
|
||
this.tilingScheme = new GeographicTilingScheme();
|
||
this.errorEvent = event();
|
||
this.response = null;
|
||
providers.push(this);
|
||
}
|
||
requestImage(...args) {
|
||
this.requestArgs = args;
|
||
return this.response?.promise;
|
||
}
|
||
}
|
||
const collection = {
|
||
add(layer) {
|
||
layers.push(layer);
|
||
return layer;
|
||
},
|
||
addImageryProvider(provider) {
|
||
if (!providers.includes(provider)) providers.push(provider);
|
||
const layer = { imageryProvider: provider, alpha: 1, show: true };
|
||
layers.push(layer);
|
||
return layer;
|
||
},
|
||
contains(layer) {
|
||
return layers.includes(layer);
|
||
},
|
||
remove(layer, destroy) {
|
||
const index = layers.indexOf(layer);
|
||
if (index < 0) return false;
|
||
layers.splice(index, 1);
|
||
layer.destroyed = destroy;
|
||
return true;
|
||
},
|
||
get length() {
|
||
return layers.length;
|
||
},
|
||
get(index) {
|
||
return layers[index];
|
||
},
|
||
lower(layer) {
|
||
const i = layers.indexOf(layer);
|
||
if (i > 0) [layers[i - 1], layers[i]] = [layers[i], layers[i - 1]];
|
||
},
|
||
raiseToTop(layer) {
|
||
const i = layers.indexOf(layer);
|
||
if (i >= 0) layers.push(...layers.splice(i, 1));
|
||
},
|
||
isDestroyed: () => false,
|
||
};
|
||
const viewer = {
|
||
clock: { untouched: true },
|
||
imageryLayers: collection,
|
||
scene: {
|
||
postRender,
|
||
primitives: createShellScene().primitives,
|
||
globe: { tilesLoaded: true },
|
||
requestRender() {
|
||
renderRequests++;
|
||
},
|
||
},
|
||
};
|
||
const cesium = {
|
||
...createShellCesium(),
|
||
Color,
|
||
UrlTemplateImageryProvider: Provider,
|
||
GeographicTilingScheme,
|
||
Event: Cesium.Event,
|
||
Credit: Cesium.Credit,
|
||
Rectangle: Cesium.Rectangle,
|
||
};
|
||
const rendering = createWeatherRendering({
|
||
viewer,
|
||
cesium,
|
||
fetchImpl: async () => mockResponse(),
|
||
decodeImage: async () => ({ width: 2048, height: 1024, close() {} }),
|
||
createCanvas: mockCanvas,
|
||
...options,
|
||
});
|
||
const settle = () => {
|
||
postRender.emit();
|
||
postRender.emit();
|
||
};
|
||
return {
|
||
rendering,
|
||
viewer,
|
||
cesium,
|
||
providers,
|
||
layers,
|
||
postRender,
|
||
settle,
|
||
shells: () => viewer.scene.primitives.items,
|
||
renderShells: () => renderShells(cesium, viewer.scene),
|
||
renders: () => renderRequests,
|
||
};
|
||
}
|
||
|
||
test('a ready weather frame does not wait for unrelated terrain, but requires successful quiet tiles', async () => {
|
||
let now = 0;
|
||
const h = renderingHarness({ now: () => now });
|
||
h.viewer.scene.globe.tilesLoaded = false;
|
||
const loaded = h.rendering.setFrame(snapshot, times[0]);
|
||
h.settle();
|
||
assert.equal(
|
||
h.rendering.getDiagnostics().loading,
|
||
true,
|
||
'no successful tiles is not ready',
|
||
);
|
||
const tile = deferred();
|
||
h.providers[0].response = tile;
|
||
const request = h.providers[0].requestImage(0, 0, 0, {});
|
||
tile.resolve({});
|
||
await request;
|
||
now = 199;
|
||
h.settle();
|
||
assert.equal(
|
||
h.rendering.getDiagnostics().loading,
|
||
true,
|
||
'scheduler quiet interval is required',
|
||
);
|
||
h.providers[0].response = null;
|
||
assert.equal(h.providers[0].requestImage(1, 0, 0, {}), undefined);
|
||
now = 500;
|
||
h.settle();
|
||
assert.equal(
|
||
h.rendering.getDiagnostics().loading,
|
||
true,
|
||
'a scheduler-deferred own tile prevents premature commit',
|
||
);
|
||
const delayed = deferred();
|
||
h.providers[0].response = delayed;
|
||
const admitted = h.providers[0].requestImage(1, 0, 0, {});
|
||
delayed.resolve({});
|
||
await admitted;
|
||
now = 701;
|
||
h.settle();
|
||
assert.equal(await loaded, true);
|
||
assert.equal(h.rendering.getDiagnostics().loadedTiles, 2);
|
||
assert.equal(h.rendering.getDiagnostics().frameLoadMs, 701);
|
||
h.rendering.clear();
|
||
});
|
||
|
||
test('camera movement drops abandoned deferred tiles but still waits for admitted requests', async () => {
|
||
let now = 0;
|
||
const h = renderingHarness({ now: () => now });
|
||
const moveEnd = event();
|
||
h.viewer.camera = { moveEnd };
|
||
h.viewer.scene.globe.tilesLoaded = false;
|
||
const stage = h.rendering.setFrame(snapshot, times[0]);
|
||
assert.equal(moveEnd.size, 1);
|
||
const provider = h.providers[0];
|
||
const first = deferred();
|
||
provider.response = first;
|
||
const firstRequest = provider.requestImage(0, 0, 0, {});
|
||
first.resolve({});
|
||
await firstRequest;
|
||
provider.response = null;
|
||
assert.equal(provider.requestImage(1, 0, 0, {}), undefined);
|
||
const pending = deferred();
|
||
provider.response = pending;
|
||
const admitted = provider.requestImage(2, 0, 0, {});
|
||
assert.equal(h.rendering.getDiagnostics().deferredTiles, 1);
|
||
assert.equal(h.rendering.getDiagnostics().pendingTiles, 1);
|
||
now = 500;
|
||
moveEnd.emit();
|
||
assert.equal(
|
||
h.rendering.getDiagnostics().deferredTiles,
|
||
0,
|
||
'an unadmitted tile abandoned by the old viewport no longer blocks the stage',
|
||
);
|
||
assert.equal(
|
||
h.rendering.getDiagnostics().pendingTiles,
|
||
1,
|
||
'camera movement does not erase admitted request ownership',
|
||
);
|
||
now = 800;
|
||
h.settle();
|
||
assert.equal(h.rendering.getDiagnostics().loading, true);
|
||
assert.equal(
|
||
h.layers[0].alpha,
|
||
0,
|
||
'the new observation stays invisible while a request is pending',
|
||
);
|
||
pending.resolve({});
|
||
await admitted;
|
||
now = 999;
|
||
h.settle();
|
||
assert.equal(
|
||
h.rendering.getDiagnostics().loading,
|
||
true,
|
||
'completion starts a new quiet interval',
|
||
);
|
||
now = 1000;
|
||
h.postRender.emit();
|
||
assert.equal(
|
||
h.rendering.getDiagnostics().loading,
|
||
true,
|
||
'commit still requires two settled renders',
|
||
);
|
||
h.postRender.emit();
|
||
assert.equal(await stage, true);
|
||
assert.equal(h.rendering.getDiagnostics().loadedTiles, 2);
|
||
assert.equal(h.rendering.getDiagnostics().time, times[0]);
|
||
assert.equal(moveEnd.size, 0, 'committing releases the stage listener');
|
||
const abandoned = h.rendering.setFrame(snapshot, times[1]);
|
||
assert.equal(moveEnd.size, 1);
|
||
h.rendering.clear();
|
||
assert.equal(await abandoned, false);
|
||
assert.equal(
|
||
moveEnd.size,
|
||
0,
|
||
'teardown also releases the stage camera listener',
|
||
);
|
||
});
|
||
|
||
test('weather stages invisibly, waits for pending tiles and render readiness, and replaces atomically', async (t) => {
|
||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||
const h = renderingHarness();
|
||
const first = h.rendering.setFrame(snapshot, times[0]);
|
||
const layer = h.layers[0];
|
||
assert.equal(layer.alpha, 0);
|
||
assert.equal(layer.show, true);
|
||
assert.equal(h.rendering.getDiagnostics().time, null);
|
||
const tile = deferred();
|
||
h.providers[0].response = tile;
|
||
const requested = h.providers[0].requestImage(0, 0, 0, { cancel() {} });
|
||
h.settle();
|
||
assert.equal(h.rendering.getDiagnostics().loading, true);
|
||
tile.resolve({});
|
||
await requested;
|
||
h.viewer.scene.globe.tilesLoaded = false;
|
||
h.settle();
|
||
assert.equal(h.rendering.getDiagnostics().loading, true);
|
||
h.viewer.scene.globe.tilesLoaded = true;
|
||
h.postRender.emit();
|
||
assert.equal(
|
||
h.rendering.getDiagnostics().time,
|
||
null,
|
||
'one settled frame is insufficient',
|
||
);
|
||
h.postRender.emit();
|
||
assert.equal(await first, true);
|
||
assert.equal(layer.alpha, 0.7);
|
||
const second = h.rendering.setFrame(snapshot, times[1]);
|
||
assert.equal(h.layers.length, 2);
|
||
assert.equal(
|
||
h.rendering.getDiagnostics().time,
|
||
times[0],
|
||
'displayed time remains old until commit',
|
||
);
|
||
h.settle();
|
||
assert.equal(await second, true);
|
||
assert.equal(h.layers.length, 2, 'old layer survives the admission render');
|
||
assert.equal(
|
||
h.layers[1].alpha,
|
||
0.7,
|
||
'new layer is visible before retirement',
|
||
);
|
||
assert.equal(layer.destroyed, undefined);
|
||
h.postRender.emit();
|
||
assert.equal(h.layers.length, 1);
|
||
assert.equal(layer.destroyed, true);
|
||
assert.equal(h.providers[0].errorEvent.size, 0);
|
||
assert.equal(h.rendering.getDiagnostics().time, times[1]);
|
||
assert.deepEqual(h.viewer.clock, { untouched: true });
|
||
h.rendering.clear();
|
||
});
|
||
|
||
test('superseded stages cancel requests and cannot resurrect after a late tile response', async (t) => {
|
||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||
const h = renderingHarness();
|
||
const first = h.rendering.setFrame(snapshot, times[0]);
|
||
const tile = deferred();
|
||
const oldProvider = h.providers[0];
|
||
oldProvider.response = tile;
|
||
let cancellations = 0;
|
||
const requested = oldProvider.requestImage(0, 0, 0, {
|
||
cancel() {
|
||
cancellations++;
|
||
},
|
||
});
|
||
const second = h.rendering.setFrame(snapshot, times[1]);
|
||
assert.equal(await first, false);
|
||
assert.equal(cancellations, 1);
|
||
assert.equal(h.layers.length, 1);
|
||
assert.equal(oldProvider.errorEvent.size, 0);
|
||
tile.resolve({});
|
||
await requested;
|
||
h.settle();
|
||
assert.equal(await second, true);
|
||
assert.equal(h.rendering.getDiagnostics().time, times[1]);
|
||
assert.equal(oldProvider.requestImage(0, 0, 0, {}), undefined);
|
||
h.rendering.clear();
|
||
assert.equal(h.layers.length, 0);
|
||
assert.equal(h.postRender.size, 0);
|
||
assert.ok(h.providers.every((provider) => provider.errorEvent.size === 0));
|
||
t.mock.timers.tick(30_000);
|
||
assert.equal(h.layers.length, 0);
|
||
});
|
||
|
||
test('failed and timed-out stages retain the previous observation; a healthy replacement clears old errors', async (t) => {
|
||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||
const h = renderingHarness();
|
||
const first = h.rendering.setFrame(snapshot, times[0]);
|
||
h.settle();
|
||
await first;
|
||
const failed = h.rendering.setFrame(snapshot, times[1]);
|
||
h.providers[1].errorEvent.emit(new Error('tile missing'));
|
||
h.postRender.emit();
|
||
assert.equal(await failed, false);
|
||
assert.equal(h.rendering.getDiagnostics().time, times[0]);
|
||
assert.equal(h.layers.length, 1);
|
||
const timeout = h.rendering.setFrame(snapshot, times[1]);
|
||
h.viewer.scene.globe.tilesLoaded = false;
|
||
t.mock.timers.tick(25_000);
|
||
assert.equal(await timeout, false);
|
||
assert.equal(h.rendering.getDiagnostics().time, times[0]);
|
||
const healthy = h.rendering.setFrame(snapshot, times[2]);
|
||
h.providers[0].errorEvent.emit(
|
||
new Error('old visible tile failed while replacement loaded'),
|
||
);
|
||
h.viewer.scene.globe.tilesLoaded = true;
|
||
h.settle();
|
||
assert.equal(await healthy, true);
|
||
assert.equal(h.rendering.getDiagnostics().error, null);
|
||
h.rendering.clear();
|
||
});
|
||
|
||
function layerHarness({
|
||
clock,
|
||
reducedMotion = false,
|
||
feed,
|
||
id,
|
||
eventTarget = target(),
|
||
} = {}) {
|
||
const stages = [];
|
||
const motion = target({ matches: reducedMotion });
|
||
const documentRef = target({ hidden: false });
|
||
const moveEnd = event();
|
||
let time = null;
|
||
let infraredMode = 'filtered';
|
||
let active = null;
|
||
let clearCount = 0;
|
||
let hidden = false;
|
||
const rendering = {
|
||
rehome() {
|
||
active?.finish(false);
|
||
},
|
||
setAlpha() {},
|
||
setHidden(value) {
|
||
hidden = value;
|
||
if (value) active?.finish(false);
|
||
},
|
||
setFrame(value, selected, { signal, infrared = 'filtered' } = {}) {
|
||
if (active) active.finish(false);
|
||
const task = deferred();
|
||
const abort = () => stage.finish(false);
|
||
const stage = {
|
||
...task,
|
||
time: selected,
|
||
infrared,
|
||
signal,
|
||
finish(ok = true) {
|
||
signal?.removeEventListener('abort', abort);
|
||
if (active === stage) {
|
||
if (ok) {
|
||
time = selected;
|
||
infraredMode = infrared;
|
||
}
|
||
active = null;
|
||
}
|
||
task.resolve(ok);
|
||
},
|
||
};
|
||
signal?.addEventListener('abort', abort, { once: true });
|
||
stages.push(stage);
|
||
active = stage;
|
||
return stage.promise;
|
||
},
|
||
clear() {
|
||
clearCount++;
|
||
active?.finish(false);
|
||
active = null;
|
||
time = null;
|
||
},
|
||
getDiagnostics: () => ({
|
||
time: hidden ? null : time,
|
||
hidden,
|
||
infrared: infraredMode,
|
||
loading: Boolean(active),
|
||
error: null,
|
||
}),
|
||
};
|
||
const viewer = { camera: { moveEnd } };
|
||
const layer = createWeatherLayer({
|
||
clock,
|
||
id,
|
||
feed: feed ?? { getSnapshot: async () => snapshot },
|
||
documentRef,
|
||
eventTarget,
|
||
matchMedia: () => motion,
|
||
createRendering: () => rendering,
|
||
});
|
||
layer.init(viewer);
|
||
layer.enable();
|
||
return {
|
||
layer,
|
||
rendering,
|
||
stages,
|
||
eventTarget,
|
||
motion,
|
||
documentRef,
|
||
moveEnd,
|
||
clears: () => clearCount,
|
||
};
|
||
}
|
||
|
||
test('history has one owned timer, stops while hidden, and releases all work on disable/destroy', async (t) => {
|
||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||
const h = layerHarness();
|
||
const update = h.layer.update();
|
||
await flush();
|
||
h.stages[0].finish();
|
||
await update;
|
||
h.layer.setParams({ play: true });
|
||
assert.equal(h.layer.getDiagnostics().timerActive, true);
|
||
t.mock.timers.tick(2000);
|
||
assert.equal(h.stages.length, 2);
|
||
assert.equal(
|
||
h.layer.getDiagnostics().timerActive,
|
||
false,
|
||
'next timer waits for stage settlement',
|
||
);
|
||
h.stages[1].finish();
|
||
await flush();
|
||
assert.equal(h.layer.getDiagnostics().timerActive, true);
|
||
h.documentRef.hidden = true;
|
||
h.documentRef.emit('visibilitychange');
|
||
assert.equal(h.layer.getDiagnostics().playing, false);
|
||
assert.equal(h.layer.getDiagnostics().timerActive, false);
|
||
t.mock.timers.tick(20_000);
|
||
assert.equal(h.stages.length, 2);
|
||
h.documentRef.hidden = false;
|
||
h.documentRef.emit('visibilitychange');
|
||
h.layer.setParams({ play: true });
|
||
h.layer.disable();
|
||
assert.equal(h.layer.getDiagnostics().timerActive, false);
|
||
assert.equal(h.layer.getDiagnostics().historyFrames, 0);
|
||
t.mock.timers.tick(20_000);
|
||
assert.equal(h.stages.length, 2);
|
||
h.layer.destroy();
|
||
assert.equal(h.documentRef.size, 0);
|
||
assert.equal(h.motion.size, 0);
|
||
assert.equal(h.moveEnd.size, 0);
|
||
assert.ok(h.clears() >= 1);
|
||
});
|
||
|
||
test('reduced motion blocks autoplay but permits manual history and stops newly suspended playback', async (t) => {
|
||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||
const h = layerHarness({ reducedMotion: true });
|
||
const update = h.layer.update();
|
||
await flush();
|
||
h.stages[0].finish();
|
||
await update;
|
||
h.layer.setParams({ play: true });
|
||
assert.equal(h.layer.getDiagnostics().timerActive, false);
|
||
assert.equal(
|
||
h.layer.getRowControls().chips.some((chip) => chip.id === 'play'),
|
||
false,
|
||
);
|
||
h.layer.setParams({ step: -1 });
|
||
assert.equal(h.stages[1].time, times[1]);
|
||
h.stages[1].finish();
|
||
await flush();
|
||
h.motion.matches = false;
|
||
h.motion.emit('change');
|
||
h.layer.setParams({ play: true });
|
||
assert.equal(h.layer.getDiagnostics().playing, true);
|
||
h.motion.matches = true;
|
||
h.motion.emit('change');
|
||
assert.equal(h.layer.getDiagnostics().playing, false);
|
||
assert.equal(h.layer.getDiagnostics().timerActive, false);
|
||
h.layer.destroy();
|
||
});
|
||
|
||
test('disable invalidates an abort-insensitive source result and an already staged frame', async (t) => {
|
||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||
const source = deferred();
|
||
let signal;
|
||
const h = layerHarness({
|
||
feed: {
|
||
getSnapshot(options) {
|
||
signal = options.signal;
|
||
return source.promise;
|
||
},
|
||
},
|
||
});
|
||
const update = h.layer.update();
|
||
h.layer.disable();
|
||
assert.equal(signal.aborted, true);
|
||
source.resolve(snapshot);
|
||
assert.equal(await update, false);
|
||
assert.equal(h.stages.length, 0);
|
||
h.layer.enable();
|
||
const next = h.layer.update();
|
||
await flush();
|
||
assert.equal(h.stages.length, 1);
|
||
h.layer.disable();
|
||
await next;
|
||
await flush();
|
||
assert.equal(h.layer.getStats().count, 0);
|
||
assert.equal(h.layer.getDiagnostics().timerActive, false);
|
||
assert.equal(h.layer.getStats().loading, false);
|
||
h.layer.destroy();
|
||
});
|
||
|
||
test('external abort cancels a staged renderer request and releases its observer and deadline', async (t) => {
|
||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||
const h = renderingHarness();
|
||
const controller = new AbortController();
|
||
const stage = h.rendering.setFrame(snapshot, times[0], {
|
||
signal: controller.signal,
|
||
});
|
||
const tile = deferred();
|
||
h.providers[0].response = tile;
|
||
let cancellations = 0;
|
||
const requested = h.providers[0].requestImage(0, 0, 0, {
|
||
cancel() {
|
||
cancellations++;
|
||
},
|
||
});
|
||
const before = h.renders();
|
||
controller.abort();
|
||
assert.equal(await stage, false);
|
||
assert.equal(cancellations, 1);
|
||
assert.equal(h.layers.length, 0);
|
||
assert.equal(h.postRender.size, 0);
|
||
assert.equal(h.providers[0].errorEvent.size, 0);
|
||
assert.ok(
|
||
h.renders() > before,
|
||
'cancellation requests a Cesium scheduler update',
|
||
);
|
||
tile.resolve({});
|
||
await requested;
|
||
t.mock.timers.tick(30_000);
|
||
assert.equal(h.rendering.getDiagnostics().time, null);
|
||
await assert.rejects(
|
||
h.rendering.setFrame(snapshot, times[1], { signal: controller.signal }),
|
||
{ name: 'AbortError' },
|
||
);
|
||
assert.equal(
|
||
h.providers.length,
|
||
1,
|
||
'already-aborted work creates no provider',
|
||
);
|
||
h.rendering.clear();
|
||
});
|
||
|
||
test('update cancellation reaches imagery after source acquisition has completed', async (t) => {
|
||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||
const h = layerHarness();
|
||
const controller = new AbortController();
|
||
const update = h.layer.update(null, { signal: controller.signal });
|
||
await flush();
|
||
assert.equal(h.stages.length, 1);
|
||
assert.equal(h.stages[0].signal.aborted, false);
|
||
controller.abort();
|
||
assert.equal(await update, false);
|
||
assert.equal(h.stages[0].signal.aborted, true);
|
||
assert.equal(h.layer.getStats().count, 0);
|
||
assert.equal(h.layer.getStats().loading, false);
|
||
assert.equal(h.layer.getDiagnostics().timerActive, false);
|
||
h.layer.destroy();
|
||
});
|
||
|
||
test('superseding a pending stage preserves the newer manifest loading state', async (t) => {
|
||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||
const later = deferred();
|
||
let fetches = 0;
|
||
const h = layerHarness({
|
||
feed: {
|
||
getSnapshot() {
|
||
return ++fetches === 1 ? Promise.resolve(snapshot) : later.promise;
|
||
},
|
||
},
|
||
});
|
||
const old = h.layer.update();
|
||
await flush();
|
||
const newer = h.layer.update();
|
||
await flush();
|
||
assert.equal(await old, false);
|
||
assert.equal(
|
||
h.layer.getStats().loading,
|
||
true,
|
||
'old stage finally must not clear a newer source loading state',
|
||
);
|
||
later.resolve(snapshot);
|
||
await flush();
|
||
h.stages.at(-1).finish();
|
||
assert.equal(await newer, true);
|
||
assert.equal(h.layer.getStats().loading, false);
|
||
h.layer.destroy();
|
||
});
|
||
|
||
test('global infrared is acquired before staging and cropped from one processed mosaic', async () => {
|
||
let fetches = 0;
|
||
const decode = deferred();
|
||
const h = renderingHarness({
|
||
fetchImpl: async () => {
|
||
fetches++;
|
||
return mockResponse();
|
||
},
|
||
decodeImage: () => decode.promise,
|
||
});
|
||
const task = h.rendering.setFrame(
|
||
{ ...snapshot, product: 'clouds' },
|
||
times[0],
|
||
);
|
||
await flush();
|
||
assert.equal(h.layers.length, 0);
|
||
assert.equal(h.rendering.getDiagnostics().loading, true);
|
||
assert.deepEqual(h.rendering.getDiagnostics().mosaic, {
|
||
fetched: true,
|
||
decodeMs: null,
|
||
cached: false,
|
||
});
|
||
decode.resolve({ width: 2048, height: 1024 });
|
||
await flush();
|
||
const provider = h.layers[0].imageryProvider;
|
||
assert.equal(provider.maximumLevel, 3);
|
||
assert.equal(provider.tileWidth, 256);
|
||
assert.equal(provider.tileHeight, 256);
|
||
assert.equal(provider.tilingScheme.getNumberOfXTilesAtLevel(0), 2);
|
||
assert.deepEqual(provider.tilingScheme.rectangle, provider.rectangle);
|
||
const first = await provider.requestImage(0, 0, 0);
|
||
const next = await provider.requestImage(1, 0, 0);
|
||
assert.equal(first.source, next.source);
|
||
assert.deepEqual(
|
||
first.source.pixels,
|
||
new Uint8ClampedArray([194, 100, 0, 98]),
|
||
);
|
||
assert.equal(fetches, 1);
|
||
h.settle();
|
||
assert.equal(await task, true);
|
||
assert.equal(h.rendering.getDiagnostics().mosaic.fetched, true);
|
||
assert.equal(typeof h.rendering.getDiagnostics().mosaic.decodeMs, 'number');
|
||
h.rendering.clear();
|
||
});
|
||
|
||
test('coverage navigation requires the shared shell handoff and detaches cleanly', async () => {
|
||
const h = layerHarness();
|
||
const update = h.layer.update();
|
||
await flush();
|
||
h.stages[0].finish();
|
||
await update;
|
||
assert.equal(
|
||
h.layer.getRowControls().chips.find((chip) => chip.id === 'coverage')
|
||
.disabled,
|
||
true,
|
||
);
|
||
let handoffs = 0;
|
||
h.layer.attachShellServices({
|
||
runNavigation(navigate) {
|
||
handoffs++;
|
||
assert.equal(typeof navigate, 'function');
|
||
},
|
||
});
|
||
h.layer.setParams({ focus: true });
|
||
assert.equal(handoffs, 1);
|
||
h.layer.attachShellServices(null);
|
||
h.layer.setParams({ focus: true });
|
||
assert.equal(handoffs, 1);
|
||
h.layer.destroy();
|
||
});
|
||
|
||
test('manifest refresh during a manual history stage does not strand loading controls', async () => {
|
||
const h = layerHarness();
|
||
const first = h.layer.update();
|
||
await flush();
|
||
h.stages[0].finish();
|
||
await first;
|
||
h.layer.setParams({ step: -1 });
|
||
assert.equal(h.layer.getStats().loading, true);
|
||
await h.layer.update();
|
||
assert.equal(
|
||
h.stages.length,
|
||
2,
|
||
'metadata refresh preserves the staged manual selection',
|
||
);
|
||
h.stages[1].finish();
|
||
await flush();
|
||
assert.equal(h.layer.getStats().loading, false);
|
||
assert.equal(h.layer.getStats().observedAt, times[1]);
|
||
assert.equal(
|
||
h.layer.getRowControls().chips.some((chip) => chip.id === 'previous'),
|
||
false,
|
||
);
|
||
h.layer.destroy();
|
||
});
|
||
|
||
test('historical observations do not relabel a fresh lightning feed as stale', async () => {
|
||
const now = Date.now();
|
||
const old = new Date(now - 90 * 60_000).toISOString();
|
||
const recent = new Date(now - 5 * 60_000).toISOString();
|
||
const h = layerHarness({
|
||
id: 'weather-lightning',
|
||
feed: {
|
||
getSnapshot: async () => ({
|
||
...snapshot,
|
||
product: 'lightning',
|
||
times: [old, recent],
|
||
latest: recent,
|
||
}),
|
||
},
|
||
});
|
||
const first = h.layer.update();
|
||
await flush();
|
||
h.stages[0].finish();
|
||
await first;
|
||
h.layer.setParams({ step: -1 });
|
||
h.stages[1].finish();
|
||
await flush();
|
||
assert.equal(h.layer.getStats().stale, false);
|
||
assert.match(h.layer.getRowControls().summary.detail, /History/);
|
||
h.layer.destroy();
|
||
});
|
||
|
||
test('history loading updates an existing info line and preserves summary status', async (t) => {
|
||
t.mock.method(Date, 'now', () => Date.parse(times[2]));
|
||
const h = layerHarness();
|
||
const update = h.layer.update();
|
||
await flush();
|
||
h.stages[0].finish();
|
||
await update;
|
||
h.layer.setParams({ step: -1 });
|
||
await flush();
|
||
const loading = h.layer.getRowControls();
|
||
assert.match(loading.info, /History: [^\n]+\n[^\n]+ · loading/);
|
||
assert.equal(loading.summary.status, 'Loading next frame…');
|
||
h.stages.at(-1).finish();
|
||
await flush();
|
||
const ready = h.layer.getRowControls();
|
||
assert.equal(ready.info.split('\n').length, loading.info.split('\n').length);
|
||
assert.doesNotMatch(ready.info, / · loading/);
|
||
h.layer.destroy();
|
||
});
|
||
|
||
test('no host cancels a globe stage, detaches the layer and restores it in weather order', async () => {
|
||
let host;
|
||
const h = renderingHarness({ getHost: () => host });
|
||
host = { collection: h.viewer.imageryLayers, kind: 'globe' };
|
||
const stage = h.rendering.setFrame(snapshot, times[0]);
|
||
h.settle();
|
||
assert.equal(await stage, true);
|
||
const radar = h.layers[0];
|
||
const cloud = h.viewer.imageryLayers.add({ name: 'cloud' });
|
||
const lightning = h.viewer.imageryLayers.add({ name: 'lightning' });
|
||
orderWeatherImagery(h.viewer.imageryLayers, lightning, 3);
|
||
orderWeatherImagery(h.viewer.imageryLayers, cloud, 1);
|
||
const incoming = h.rendering.setFrame(snapshot, times[1]);
|
||
host = { collection: null, kind: 'none' };
|
||
assert.equal(h.rendering.rehome(), true);
|
||
assert.equal(await incoming, false);
|
||
assert.deepEqual(h.layers, [cloud, lightning]);
|
||
assert.equal(await h.rendering.setFrame(snapshot, times[2]), false);
|
||
assert.equal(h.rendering.getDiagnostics().error, NO_IMAGERY_HOST);
|
||
assert.equal(h.rendering.getDiagnostics().time, times[0]);
|
||
host = { collection: h.viewer.imageryLayers, kind: 'globe' };
|
||
h.rendering.rehome();
|
||
assert.deepEqual(h.layers, [cloud, radar, lightning]);
|
||
assert.equal(h.rendering.getDiagnostics().error, null);
|
||
h.rendering.clear();
|
||
assert.deepEqual(h.layers, [cloud, lightning]);
|
||
assert.equal(radar.destroyed, true);
|
||
});
|
||
|
||
const shellDecode = async () => ({ width: 4096, height: 2048, close() {} });
|
||
|
||
test('the tileset host draws a raised shell and drapes nothing, at any camera height', async () => {
|
||
const tiles = new ImageryLayerCollection();
|
||
const h = renderingHarness({
|
||
getHost: () => ({ collection: tiles, kind: 'tileset' }),
|
||
decodeImage: shellDecode,
|
||
});
|
||
h.viewer.camera = {
|
||
moveEnd: event(),
|
||
positionCartographic: { height: 1200 },
|
||
};
|
||
const stage = h.rendering.setFrame(snapshot, times[0]);
|
||
await flush();
|
||
assert.equal(tiles.length, 0);
|
||
assert.equal(h.layers.length, 0);
|
||
assert.equal(h.providers.length, 0);
|
||
assert.equal(h.shells().length, 1);
|
||
assert.equal(h.rendering.getDiagnostics().loading, true);
|
||
h.renderShells();
|
||
assert.equal(await stage, true);
|
||
const diagnostics = h.rendering.getDiagnostics();
|
||
assert.equal(diagnostics.host, 'shell');
|
||
assert.equal(diagnostics.height, 6_200);
|
||
assert.equal(diagnostics.time, times[0]);
|
||
assert.equal(diagnostics.error, null);
|
||
assert.equal(
|
||
h.viewer.camera.moveEnd.size,
|
||
1,
|
||
'one camera listener, for the detail window only',
|
||
);
|
||
assert.equal(await h.rendering.prefetch(snapshot, times[1]), true);
|
||
assert.equal(h.rendering.getDiagnostics().cache.mosaics, 2);
|
||
h.rendering.clear();
|
||
assert.equal(h.shells().length, 0);
|
||
assert.equal(h.postRender.size, 0);
|
||
assert.equal(h.viewer.camera.moveEnd.size, 0);
|
||
assert.equal(h.rendering.getDiagnostics().cache.mosaics, 0);
|
||
});
|
||
|
||
test('a switch to 3D Tiles windows the current view; the globe host never requests a window', async () => {
|
||
let host;
|
||
const urls = [];
|
||
const h = renderingHarness({
|
||
getHost: () => host,
|
||
decodeImage: shellDecode,
|
||
fetchImpl: async (url) => {
|
||
urls.push(url);
|
||
return mockResponse();
|
||
},
|
||
});
|
||
h.viewer.camera = {
|
||
moveEnd: event(),
|
||
computeViewRectangle: () => Cesium.Rectangle.fromDegrees(-100, 35, -98, 36),
|
||
};
|
||
host = { collection: h.viewer.imageryLayers, kind: 'globe' };
|
||
const globe = h.rendering.setFrame(snapshot, times[0]);
|
||
h.settle();
|
||
assert.equal(await globe, true);
|
||
assert.equal(await h.rendering.prefetch(snapshot, times[1]), true);
|
||
h.viewer.camera.moveEnd.emit();
|
||
await flush();
|
||
assert.equal(h.shells().length, 0);
|
||
assert.equal(h.viewer.camera.moveEnd.size, 0);
|
||
assert.equal(h.rendering.getDiagnostics().shell, undefined);
|
||
assert.ok(urls.length > 0);
|
||
assert.ok(urls.every((url) => !url.includes('bbox=')));
|
||
|
||
host = { collection: new ImageryLayerCollection(), kind: 'tileset' };
|
||
assert.equal(h.rendering.rehome(), true);
|
||
await flush();
|
||
h.renderShells();
|
||
await flush();
|
||
h.renderShells();
|
||
const detail = urls.filter((url) => url.includes('bbox='));
|
||
assert.deepEqual(
|
||
detail.map((url) =>
|
||
new URL(url, 'https://example.test').searchParams.get('bbox'),
|
||
),
|
||
['-102,34,-96,37'],
|
||
);
|
||
const diagnostics = h.rendering.getDiagnostics();
|
||
assert.equal(diagnostics.time, times[0]);
|
||
assert.equal(diagnostics.shell.detail.ready, true);
|
||
assert.equal(h.shells().length, 1, 'the detail draws on the shell surface');
|
||
host = { collection: h.viewer.imageryLayers, kind: 'globe' };
|
||
assert.equal(h.rendering.rehome(), true);
|
||
assert.equal(h.shells().length, 0, 'the shell surface goes with the host');
|
||
await flush();
|
||
h.settle();
|
||
await flush();
|
||
assert.equal(h.rendering.getDiagnostics().host, 'globe');
|
||
assert.equal(h.rendering.getDiagnostics().loading, false);
|
||
assert.equal(h.viewer.camera.moveEnd.size, 0);
|
||
assert.equal(urls.filter((url) => url.includes('bbox=')).length, 1);
|
||
h.rendering.clear();
|
||
});
|
||
|
||
test('host switches tear one renderer down and restage the retained frame on the other', async () => {
|
||
let host;
|
||
const tiles = new ImageryLayerCollection();
|
||
const h = renderingHarness({
|
||
getHost: () => host,
|
||
decodeImage: async () => ({ width: 2048, height: 1024, close() {} }),
|
||
});
|
||
const clouds = { ...snapshot, product: 'clouds' };
|
||
host = { collection: h.viewer.imageryLayers, kind: 'globe' };
|
||
h.rendering.setAlpha(0.4);
|
||
const first = h.rendering.setFrame(clouds, times[1], { infrared: 'full' });
|
||
await flush();
|
||
h.settle();
|
||
assert.equal(await first, true);
|
||
const layer = h.layers[0];
|
||
host = { collection: tiles, kind: 'tileset' };
|
||
assert.equal(h.rendering.rehome(), true);
|
||
assert.equal(h.layers.length, 0);
|
||
assert.equal(layer.destroyed, true);
|
||
assert.equal(tiles.length, 0);
|
||
assert.equal(
|
||
h.rendering.getDiagnostics().time,
|
||
times[1],
|
||
'the retained time is reported while restaging',
|
||
);
|
||
assert.equal(h.rendering.getDiagnostics().loading, true);
|
||
await flush();
|
||
const [shell] = h.shells();
|
||
h.renderShells();
|
||
await flush();
|
||
let diagnostics = h.rendering.getDiagnostics();
|
||
assert.equal(diagnostics.host, 'shell');
|
||
assert.equal(diagnostics.height, 5_500);
|
||
assert.equal(diagnostics.loading, false);
|
||
assert.equal(diagnostics.time, times[1]);
|
||
assert.equal(diagnostics.infrared, 'full');
|
||
assert.equal(shell.appearance.material.uniforms.alpha, 0.4);
|
||
host = { collection: h.viewer.imageryLayers, kind: 'globe' };
|
||
assert.equal(h.rendering.rehome(), true);
|
||
assert.equal(shell.destroyed, true);
|
||
assert.equal(shell.appearance.material.destroyed, true);
|
||
assert.equal(h.shells().length, 0);
|
||
assert.equal(h.rendering.getDiagnostics().time, times[1]);
|
||
await flush();
|
||
assert.equal(h.layers.length, 1);
|
||
h.settle();
|
||
await flush();
|
||
diagnostics = h.rendering.getDiagnostics();
|
||
assert.equal(diagnostics.host, 'globe');
|
||
assert.equal(diagnostics.loading, false);
|
||
assert.equal(diagnostics.time, times[1]);
|
||
assert.equal(diagnostics.infrared, 'full');
|
||
assert.equal(h.layers[0].alpha, 0.4);
|
||
assert.equal(h.layers[0].imageryProvider.tileWidth, 256);
|
||
h.rendering.clear();
|
||
assert.equal(h.layers.length, 0);
|
||
assert.equal(h.postRender.size, 0);
|
||
});
|
||
|
||
test('a host without imagery keeps the active shell and its frame', async () => {
|
||
let host = { collection: new ImageryLayerCollection(), kind: 'tileset' };
|
||
let fetches = 0;
|
||
const h = renderingHarness({
|
||
getHost: () => host,
|
||
decodeImage: shellDecode,
|
||
fetchImpl: async () => {
|
||
fetches++;
|
||
return mockResponse();
|
||
},
|
||
});
|
||
const stage = h.rendering.setFrame(snapshot, times[0]);
|
||
await flush();
|
||
h.renderShells();
|
||
assert.equal(await stage, true);
|
||
const [shell] = h.shells();
|
||
host = { collection: null, kind: 'none' };
|
||
h.rendering.rehome();
|
||
assert.equal(shell.show, false);
|
||
assert.equal(shell.destroyed, false);
|
||
assert.equal(h.rendering.getDiagnostics().time, times[0]);
|
||
assert.equal(h.rendering.getDiagnostics().error, NO_IMAGERY_HOST);
|
||
assert.equal(await h.rendering.setFrame(snapshot, times[1]), false);
|
||
host = { collection: new ImageryLayerCollection(), kind: 'tileset' };
|
||
h.rendering.rehome();
|
||
assert.equal(shell.show, true);
|
||
assert.deepEqual(h.shells(), [shell]);
|
||
assert.equal(fetches, 1);
|
||
assert.equal(h.rendering.getDiagnostics().error, null);
|
||
h.rendering.setHidden(true);
|
||
assert.equal(shell.show, false);
|
||
assert.equal(h.rendering.getDiagnostics().time, null);
|
||
h.rendering.setHidden(false);
|
||
assert.equal(shell.show, true);
|
||
h.rendering.clear();
|
||
assert.equal(h.shells().length, 0);
|
||
});
|
||
|
||
test('each product owns its shell height; a product change replaces the shell', async () => {
|
||
let width = 2048;
|
||
const h = renderingHarness({
|
||
getHost: () => ({ collection: null, kind: 'tileset' }),
|
||
decodeImage: async () => ({ width, height: width / 2, close() {} }),
|
||
});
|
||
const global = h.rendering.setFrame(
|
||
{ ...snapshot, product: 'clouds' },
|
||
times[0],
|
||
);
|
||
await flush();
|
||
h.renderShells();
|
||
assert.equal(await global, true);
|
||
const [first] = h.shells();
|
||
assert.equal(h.rendering.getDiagnostics().height, 5_500);
|
||
width = 4096;
|
||
const lightning = h.rendering.setFrame(
|
||
{ ...snapshot, product: 'lightning' },
|
||
times[0],
|
||
);
|
||
assert.equal(first.destroyed, true);
|
||
await flush();
|
||
h.renderShells();
|
||
assert.equal(await lightning, true);
|
||
assert.equal(h.rendering.getDiagnostics().height, 6_600);
|
||
assert.equal(h.rendering.getDiagnostics().product, 'lightning');
|
||
assert.equal(h.shells().length, 1);
|
||
h.rendering.clear();
|
||
});
|
||
|
||
test('a stage without a current frame is cancelled on host change and detached frames are destroyed', async () => {
|
||
const tiles = new ImageryLayerCollection();
|
||
let host = { collection: tiles, kind: 'tileset' };
|
||
const h = renderingHarness({ getHost: () => host });
|
||
const first = h.rendering.setFrame(snapshot, times[0]);
|
||
host = { collection: h.viewer.imageryLayers, kind: 'globe' };
|
||
h.rendering.rehome();
|
||
assert.equal(await first, false);
|
||
assert.equal(tiles.length, 0);
|
||
const ready = h.rendering.setFrame(snapshot, times[1]);
|
||
h.settle();
|
||
await ready;
|
||
const layer = h.layers[0];
|
||
let destroyed = false;
|
||
layer.destroy = () => {
|
||
destroyed = true;
|
||
};
|
||
host = { collection: null, kind: 'none' };
|
||
h.rendering.rehome();
|
||
h.rendering.clear();
|
||
assert.equal(destroyed, true);
|
||
});
|
||
|
||
for (const product of ['clouds', 'clouds-regional', 'radar', 'lightning']) {
|
||
test(`${product} does not use a hard layer alpha cut on the globe host`, async () => {
|
||
const collection = new ImageryLayerCollection();
|
||
const h = renderingHarness({
|
||
getHost: () => ({ collection, kind: 'globe' }),
|
||
});
|
||
const stage = h.rendering.setFrame({ ...snapshot, product }, times[0]);
|
||
await flush();
|
||
const layer = collection.get(0);
|
||
assert.equal(layer.colorToAlpha, undefined);
|
||
assert.equal(layer.alpha, 0);
|
||
h.rendering.clear();
|
||
assert.equal(await stage, false);
|
||
});
|
||
}
|
||
|
||
for (const statusCode of [429, 503]) {
|
||
test(`${statusCode} retries up to three times without failing a healthy frame`, async () => {
|
||
let now = 0;
|
||
const h = renderingHarness({ now: () => now });
|
||
h.viewer.scene.globe.tilesLoaded = false;
|
||
const stage = h.rendering.setFrame(snapshot, times[0]);
|
||
h.providers[0].response = deferred();
|
||
const rejected = h.providers[0].requestImage(0, 0, 0, {});
|
||
h.providers[0].response.reject(new Error('throttled'));
|
||
await assert.rejects(rejected, /throttled/);
|
||
for (let timesRetried = 0; timesRetried < 3; timesRetried++) {
|
||
const error = {
|
||
error: { statusCode },
|
||
x: 0,
|
||
y: 0,
|
||
level: 0,
|
||
timesRetried,
|
||
retry: false,
|
||
};
|
||
h.providers[0].errorEvent.emit(error);
|
||
assert.equal(error.retry, true);
|
||
assert.equal(h.rendering.getDiagnostics().error, null);
|
||
}
|
||
h.settle();
|
||
assert.equal(h.rendering.getDiagnostics().loading, true);
|
||
h.providers[0].response = deferred();
|
||
const retried = h.providers[0].requestImage(0, 0, 0, {});
|
||
h.providers[0].response.resolve({});
|
||
await retried;
|
||
now = 250;
|
||
h.settle();
|
||
assert.equal(await stage, true);
|
||
const exhausted = h.rendering.setFrame(snapshot, times[1]);
|
||
const error = { error: { statusCode }, timesRetried: 3, retry: false };
|
||
for (let i = 0; i < 4; i++) h.providers[1].errorEvent.emit(error);
|
||
h.settle();
|
||
assert.equal(error.retry, false);
|
||
assert.equal(await exhausted, false);
|
||
assert.equal(h.rendering.getDiagnostics().time, times[0]);
|
||
h.rendering.clear();
|
||
});
|
||
}
|
||
|
||
test('404 fails a frame without retry', async () => {
|
||
const h = renderingHarness();
|
||
const stage = h.rendering.setFrame(snapshot, times[0]);
|
||
const error = { error: { statusCode: 404 }, timesRetried: 0, retry: false };
|
||
h.providers[0].errorEvent.emit(error);
|
||
h.settle();
|
||
assert.equal(error.retry, false);
|
||
assert.equal(await stage, false);
|
||
h.rendering.clear();
|
||
});
|
||
|
||
test('no host pauses history, refreshes metadata, and restores retained time and playback', async (t) => {
|
||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||
let refreshes = 0;
|
||
const h = layerHarness({
|
||
feed: {
|
||
getSnapshot: async () => {
|
||
refreshes++;
|
||
return snapshot;
|
||
},
|
||
},
|
||
});
|
||
let host = { collection: {}, kind: 'tileset' };
|
||
h.layer.attachShellServices({ imageryHost: () => host });
|
||
const update = h.layer.update();
|
||
await flush();
|
||
h.stages[0].finish();
|
||
await update;
|
||
h.layer.setParams({ play: true });
|
||
t.mock.timers.tick(2000);
|
||
assert.equal(h.stages.length, 2);
|
||
host = { collection: null, kind: 'none' };
|
||
h.eventTarget.emit('gev:map-stack-changed');
|
||
await flush();
|
||
assert.equal(h.layer.getDiagnostics().playing, false);
|
||
assert.equal(h.layer.getDiagnostics().timerActive, false);
|
||
assert.equal(h.layer.getRowControls().summary.status, NO_IMAGERY_HOST);
|
||
assert.equal(h.layer.getRowControls().info, NO_IMAGERY_HOST);
|
||
await h.layer.update();
|
||
assert.equal(refreshes, 2);
|
||
t.mock.timers.tick(10_000);
|
||
assert.equal(h.stages.length, 2);
|
||
assert.equal(h.layer.getDiagnostics().time, times[2]);
|
||
host = { collection: {}, kind: 'globe' };
|
||
h.eventTarget.emit('gev:map-stack-changed');
|
||
assert.equal(h.layer.getDiagnostics().playing, true);
|
||
assert.equal(h.layer.getDiagnostics().timerActive, true);
|
||
t.mock.timers.tick(2000);
|
||
assert.equal(h.stages.length, 3);
|
||
assert.equal(h.stages[2].time, times[0]);
|
||
h.layer.destroy();
|
||
assert.equal(h.eventTarget.size, 0);
|
||
});
|
||
|
||
test('host restore through update stages latest after a no-host start', async () => {
|
||
const h = layerHarness();
|
||
let host = { collection: null, kind: 'none' };
|
||
h.layer.attachShellServices({ imageryHost: () => host });
|
||
await h.layer.update();
|
||
assert.equal(h.stages.length, 0);
|
||
host = { collection: {}, kind: 'tileset' };
|
||
const update = h.layer.update();
|
||
await flush();
|
||
assert.equal(h.stages[0].time, snapshot.latest);
|
||
h.stages[0].finish();
|
||
await update;
|
||
assert.notEqual(h.layer.getRowControls().summary.status, NO_IMAGERY_HOST);
|
||
h.layer.destroy();
|
||
});
|
||
|
||
test('real Cesium global infrared provider supports the globe host', async () => {
|
||
const collection = new Cesium.ImageryLayerCollection();
|
||
const h = renderingHarness({
|
||
cesium: Cesium,
|
||
getHost: () => ({ collection, kind: 'globe' }),
|
||
});
|
||
const stage = h.rendering.setFrame(
|
||
{
|
||
...snapshot,
|
||
product: 'clouds',
|
||
bounds: { west: -180, south: -60, east: 180, north: 60 },
|
||
},
|
||
times[0],
|
||
);
|
||
await flush();
|
||
const layer = collection.get(0);
|
||
const provider = layer.imageryProvider;
|
||
assert.ok(provider.tilingScheme instanceof Cesium.GeographicTilingScheme);
|
||
assert.equal(provider.maximumLevel, 3);
|
||
assert.equal(provider.tileWidth, 256);
|
||
assert.equal(provider.tileHeight, 256);
|
||
assert.equal(provider.tilingScheme.getNumberOfXTilesAtLevel(0), 2);
|
||
assert.equal(provider.tilingScheme.getNumberOfYTilesAtLevel(0), 1);
|
||
assert.ok(
|
||
Cesium.Rectangle.equals(
|
||
provider.rectangle,
|
||
Cesium.Rectangle.fromDegrees(-180, -60, 180, 60),
|
||
),
|
||
);
|
||
assert.equal(layer.colorToAlpha, undefined);
|
||
assert.ok(provider.requestImage(0, 0, 0) instanceof Promise);
|
||
h.rendering.clear();
|
||
assert.equal(await stage, false);
|
||
});
|
||
|
||
for (const statusCode of [429, 503]) {
|
||
test(`${statusCode} retries belong to tiles, regardless of the layer-wide counter`, async () => {
|
||
const h = renderingHarness();
|
||
const stage = h.rendering.setFrame(snapshot, times[0]);
|
||
const provider = h.providers[0];
|
||
const fail = (x, timesRetried = 0) => {
|
||
const error = { x, y: 0, level: 2, timesRetried, error: { statusCode } };
|
||
provider.errorEvent.emit(error);
|
||
return error.retry;
|
||
};
|
||
// A shared counter may exceed three even though these are first failures.
|
||
for (let x = 0; x < 4; x++) assert.equal(fail(x, x + 4), true);
|
||
provider.response = { promise: Promise.resolve({}) };
|
||
await provider.requestImage(0, 0, 2);
|
||
for (let i = 0; i < 3; i++)
|
||
assert.equal(fail(0), true, 'own success reset the count');
|
||
for (let i = 0; i < 2; i++) {
|
||
await provider.requestImage(2, 0, 2);
|
||
assert.equal(fail(1), true);
|
||
}
|
||
await provider.requestImage(3, 0, 2);
|
||
assert.equal(
|
||
fail(1),
|
||
false,
|
||
'four failures exhaust this tile despite other successes',
|
||
);
|
||
h.settle();
|
||
assert.equal(await stage, false);
|
||
assert.equal(provider.errorEvent.size, 0);
|
||
h.rendering.clear();
|
||
});
|
||
}
|
||
|
||
for (const [id, product] of [
|
||
['weather-radar', 'radar'],
|
||
['weather-satellite', 'clouds-regional'],
|
||
['weather-satellite', 'clouds'],
|
||
['weather-lightning', 'lightning'],
|
||
]) {
|
||
test(`${product} shows on 3D Tiles at street level and keeps its history through host changes`, async (t) => {
|
||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||
t.mock.method(Date, 'now', () => Date.parse(times[2]));
|
||
const size =
|
||
product === 'clouds'
|
||
? { width: 2048, height: 1024 }
|
||
: { width: 4096, height: 2048 };
|
||
let host;
|
||
const h = renderingHarness({
|
||
getHost: () => host,
|
||
decodeImage: async () => ({ ...size, close() {} }),
|
||
});
|
||
host = { collection: new ImageryLayerCollection(), kind: 'tileset' };
|
||
const camera = {
|
||
moveEnd: event(),
|
||
positionCartographic: { height: 1200 },
|
||
};
|
||
h.viewer.camera = camera;
|
||
const events = target();
|
||
const layer = createWeatherLayer({
|
||
id,
|
||
feed: { getSnapshot: async () => ({ ...snapshot, product }) },
|
||
createRendering: () => h.rendering,
|
||
documentRef: target({ hidden: false }),
|
||
eventTarget: events,
|
||
matchMedia: () => target({ matches: false }),
|
||
});
|
||
layer.init(h.viewer);
|
||
layer.attachShellServices({ imageryHost: () => host });
|
||
layer.setParams({ product });
|
||
layer.enable();
|
||
const update = layer.update();
|
||
await flush();
|
||
h.renderShells();
|
||
await update;
|
||
assert.equal(layer.getDiagnostics().time, times[2]);
|
||
assert.equal(layer.getDiagnostics().host, 'shell');
|
||
assert.equal(layer.getRowControls().summary.status, null);
|
||
assert.doesNotMatch(layer.getRowControls().info, /Hidden/);
|
||
const [shell] = h.shells();
|
||
assert.equal(shell.show, true);
|
||
|
||
layer.setParams({ play: true });
|
||
t.mock.timers.tick(2000);
|
||
await flush();
|
||
assert.equal(
|
||
layer.getDiagnostics().time,
|
||
times[0],
|
||
'history advances at street level',
|
||
);
|
||
camera.positionCartographic.height = 59_999;
|
||
camera.moveEnd.emit();
|
||
assert.equal(shell.show, true, 'no height gate');
|
||
assert.equal(layer.getDiagnostics().playing, true);
|
||
assert.equal(layer.getDiagnostics().timerActive, true);
|
||
|
||
host = { collection: null, kind: 'none' };
|
||
events.emit('gev:map-stack-changed');
|
||
assert.equal(shell.show, false);
|
||
assert.equal(layer.getRowControls().summary.status, NO_IMAGERY_HOST);
|
||
assert.equal(layer.getDiagnostics().timerActive, false);
|
||
host = { collection: h.viewer.imageryLayers, kind: 'globe' };
|
||
events.emit('gev:map-stack-changed');
|
||
assert.equal(shell.destroyed, true);
|
||
assert.equal(
|
||
layer.getDiagnostics().time,
|
||
times[0],
|
||
'the switch restages the shown history frame',
|
||
);
|
||
await flush();
|
||
h.settle();
|
||
await flush();
|
||
assert.equal(layer.getDiagnostics().host, 'globe');
|
||
assert.equal(layer.getDiagnostics().time, times[0]);
|
||
assert.equal(layer.getDiagnostics().loading, false);
|
||
layer.destroy();
|
||
assert.equal(camera.moveEnd.size, 0);
|
||
assert.equal(events.size, 0);
|
||
assert.equal(h.shells().length, 0);
|
||
assert.equal(h.postRender.size, 0);
|
||
});
|
||
}
|
||
|
||
for (const outcome of [
|
||
'fetch failure',
|
||
'decode failure',
|
||
'timeout',
|
||
'abort',
|
||
'supersede',
|
||
'rehome',
|
||
]) {
|
||
test(`mosaic ${outcome} retains the displayed frame and never installs a late decode`, async (t) => {
|
||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||
const decode = deferred();
|
||
let signal,
|
||
closed = 0,
|
||
host;
|
||
const h = renderingHarness({
|
||
getHost: () => host,
|
||
fetchImpl: async (_url, options) => {
|
||
signal = options.signal;
|
||
if (outcome === 'fetch failure') throw new Error('offline');
|
||
return mockResponse();
|
||
},
|
||
decodeImage: () => decode.promise,
|
||
});
|
||
host = { collection: h.viewer.imageryLayers, kind: 'globe' };
|
||
const first = h.rendering.setFrame(snapshot, times[0]);
|
||
h.settle();
|
||
await first;
|
||
const original = h.layers[0];
|
||
const controller = new AbortController();
|
||
const pending = h.rendering.setFrame(
|
||
{ ...snapshot, product: 'clouds' },
|
||
times[1],
|
||
{ signal: controller.signal },
|
||
);
|
||
await flush();
|
||
assert.deepEqual(h.layers, [original]);
|
||
if (outcome === 'decode failure') decode.reject(new Error('bad png'));
|
||
if (outcome === 'timeout') t.mock.timers.tick(25_000);
|
||
if (outcome === 'abort') controller.abort();
|
||
if (outcome === 'supersede')
|
||
assert.equal(await h.rendering.setFrame(snapshot, times[0]), true);
|
||
if (outcome === 'rehome') {
|
||
host = {
|
||
collection: renderingHarness().viewer.imageryLayers,
|
||
kind: 'globe',
|
||
};
|
||
h.rendering.rehome();
|
||
}
|
||
assert.equal(await pending, false);
|
||
assert.equal(signal.aborted, true);
|
||
assert.equal(h.rendering.getDiagnostics().time, times[0]);
|
||
assert.equal(original.alpha, 0.7);
|
||
if (outcome !== 'decode failure') {
|
||
decode.resolve({
|
||
width: 2048,
|
||
height: 1024,
|
||
close() {
|
||
closed++;
|
||
},
|
||
});
|
||
await flush();
|
||
if (outcome !== 'fetch failure') assert.equal(closed, 1);
|
||
}
|
||
assert.equal(host.collection.length, 1);
|
||
h.rendering.clear();
|
||
assert.equal(h.postRender.size, 0);
|
||
});
|
||
}
|
||
|
||
test('regional transfer preserves deferrals, original Request, rejection and cancellation', async () => {
|
||
const h = renderingHarness();
|
||
const stage = h.rendering.setFrame(
|
||
{ ...snapshot, product: 'clouds-regional' },
|
||
times[0],
|
||
);
|
||
const provider = h.providers[0];
|
||
let cancelled = 0;
|
||
const request = {
|
||
cancel() {
|
||
cancelled++;
|
||
},
|
||
};
|
||
assert.equal(provider.requestImage(1, 2, 3, request), undefined);
|
||
assert.deepEqual(provider.requestArgs, [1, 2, 3, request]);
|
||
const image = { width: 256, height: 256 };
|
||
provider.response = { promise: Promise.resolve(image) };
|
||
const processed = await provider.requestImage(1, 2, 3, request);
|
||
assert.equal(processed.source, image);
|
||
assert.deepEqual([...processed.pixels], [194, 100, 0, 98]);
|
||
const error = new Error('cancelled upstream');
|
||
provider.response = { promise: Promise.reject(error) };
|
||
await assert.rejects(
|
||
provider.requestImage(0, 0, 0, request),
|
||
(cause) => cause === error,
|
||
);
|
||
provider.response = deferred();
|
||
const late = provider.requestImage(0, 0, 0, request);
|
||
h.rendering.clear();
|
||
assert.equal(cancelled, 1);
|
||
provider.response.resolve(image);
|
||
assert.equal(await late, image, 'closed frames do not process late images');
|
||
assert.equal(await stage, false);
|
||
});
|
||
|
||
test('regional tiles decoded as flipped ImageBitmaps are drawn back upright', async () => {
|
||
const hadBitmap = Object.hasOwn(globalThis, 'ImageBitmap');
|
||
const previous = globalThis.ImageBitmap;
|
||
globalThis.ImageBitmap = class ImageBitmap {
|
||
width = 256;
|
||
height = 256;
|
||
};
|
||
try {
|
||
const h = renderingHarness();
|
||
void h.rendering.setFrame(
|
||
{ ...snapshot, product: 'clouds-regional' },
|
||
times[0],
|
||
);
|
||
const provider = h.providers[0];
|
||
// Cesium decodes with imageOrientation 'flipY'; the canvas upload flips again.
|
||
provider.response = { promise: Promise.resolve(new ImageBitmap()) };
|
||
const bitmapTile = await provider.requestImage(0, 0, 0, {});
|
||
assert.deepEqual(bitmapTile.drawnWith, [1, 0, 0, -1, 0, 256]);
|
||
// An HTMLImageElement fallback is not pre-flipped.
|
||
provider.response = {
|
||
promise: Promise.resolve({ width: 256, height: 256 }),
|
||
};
|
||
const imageTile = await provider.requestImage(1, 0, 0, {});
|
||
assert.equal(imageTile.drawnWith, null);
|
||
h.rendering.clear();
|
||
} finally {
|
||
if (hadBitmap) globalThis.ImageBitmap = previous;
|
||
else delete globalThis.ImageBitmap;
|
||
}
|
||
});
|
||
|
||
for (const kind of ['globe']) {
|
||
test(`infrared mode restages the same time on ${kind}, retaining the old layer for one rendered frame`, async () => {
|
||
let host,
|
||
now = 0;
|
||
const h = renderingHarness({ getHost: () => host, now: () => now });
|
||
host = { collection: h.viewer.imageryLayers, kind };
|
||
const clouds = { ...snapshot, product: 'clouds' };
|
||
const first = h.rendering.setFrame(clouds, times[0]);
|
||
await flush();
|
||
await h.providers[0].requestImage(0, 0, 0);
|
||
now = 250;
|
||
h.settle();
|
||
await first;
|
||
const previous = h.layers[0];
|
||
assert.equal(await h.rendering.setFrame(clouds, times[0]), true);
|
||
assert.equal(h.providers.length, 1);
|
||
const full = h.rendering.setFrame(clouds, times[0], { infrared: 'full' });
|
||
await flush();
|
||
const tile = await h.providers[1].requestImage(0, 0, 0);
|
||
assert.deepEqual([...tile.source.pixels], [194, 100, 0, 200]);
|
||
now = 500;
|
||
h.settle();
|
||
assert.equal(await full, true);
|
||
assert.equal(h.layers.length, 2);
|
||
assert.equal(h.layers[1].alpha, 0.7);
|
||
assert.equal(previous.destroyed, undefined);
|
||
assert.equal(h.rendering.getDiagnostics().infrared, 'full');
|
||
h.postRender.emit();
|
||
assert.equal(h.layers.length, 1);
|
||
assert.equal(previous.destroyed, true);
|
||
h.rendering.clear();
|
||
assert.equal(h.postRender.size, 0);
|
||
});
|
||
}
|
||
|
||
test('satellite mode chips restage history without changing time or latest-follow intent', async () => {
|
||
const h = layerHarness({ id: 'weather-satellite' });
|
||
const update = h.layer.update();
|
||
await flush();
|
||
h.stages[0].finish();
|
||
await update;
|
||
h.layer.setParams({ step: -1 });
|
||
h.stages[1].finish();
|
||
await flush();
|
||
h.layer.setParams({ infrared: 'full' });
|
||
assert.equal(h.stages[2].time, times[1]);
|
||
assert.equal(h.stages[2].infrared, 'full');
|
||
assert.equal(h.layer.getParams().infrared, 'full');
|
||
assert.deepEqual(
|
||
h.layer
|
||
.getRowControls()
|
||
.chips.slice(0, 4)
|
||
.map((chip) => chip.label),
|
||
['N. America', 'Global', 'Clouds only', 'Full'],
|
||
);
|
||
h.stages[2].finish();
|
||
await flush();
|
||
assert.equal(h.layer.getDiagnostics().followLatest, false);
|
||
assert.equal(h.layer.getDiagnostics().time, times[1]);
|
||
h.layer.setParams({ infrared: 'invalid' });
|
||
assert.equal(h.layer.getParams().infrared, 'full');
|
||
assert.equal(h.stages.length, 3);
|
||
h.layer.destroy();
|
||
});
|
||
|
||
test('a rapid third selection waits for retirement before installing, and clear cancels that wait', async () => {
|
||
for (const clear of [false, true]) {
|
||
const h = renderingHarness();
|
||
const first = h.rendering.setFrame(snapshot, times[0]);
|
||
h.settle();
|
||
await first;
|
||
const second = h.rendering.setFrame(snapshot, times[1]);
|
||
h.settle();
|
||
await second;
|
||
assert.equal(h.layers.length, 2);
|
||
const third = h.rendering.setFrame(snapshot, times[2]);
|
||
assert.equal(h.layers.length, 2);
|
||
assert.equal(
|
||
h.providers.length,
|
||
2,
|
||
'third provider waits for outgoing retirement',
|
||
);
|
||
if (clear) {
|
||
h.rendering.clear();
|
||
assert.equal(await third, false);
|
||
h.postRender.emit();
|
||
assert.equal(h.layers.length, 0);
|
||
assert.equal(h.postRender.size, 0);
|
||
} else {
|
||
h.postRender.emit();
|
||
assert.equal(h.layers.length, 2);
|
||
assert.equal(h.providers.length, 3);
|
||
h.settle();
|
||
assert.equal(await third, true);
|
||
h.postRender.emit();
|
||
assert.equal(h.layers.length, 1);
|
||
h.rendering.clear();
|
||
}
|
||
}
|
||
});
|
||
|
||
test('a delayed installation failure retains the replacement and does not throw from postRender', async () => {
|
||
const h = renderingHarness();
|
||
for (const time of times.slice(0, 2)) {
|
||
const stage = h.rendering.setFrame(snapshot, time);
|
||
h.settle();
|
||
assert.equal(await stage, true);
|
||
}
|
||
const third = h.rendering.setFrame(snapshot, times[2]);
|
||
h.viewer.imageryLayers.addImageryProvider = () => {
|
||
throw new Error('upload unavailable');
|
||
};
|
||
assert.doesNotThrow(() => h.postRender.emit());
|
||
assert.equal(await third, false);
|
||
assert.equal(h.layers.length, 1);
|
||
assert.equal(h.layers[0].alpha, 0.7);
|
||
assert.equal(h.rendering.getDiagnostics().time, times[1]);
|
||
h.rendering.clear();
|
||
});
|
||
|
||
test('one row steps every registered observation; missing frames hide and latest restores each product', async (t) => {
|
||
const clock = createWeatherClock();
|
||
const radar = layerHarness({ clock });
|
||
const satellite = layerHarness({
|
||
clock,
|
||
id: 'weather-satellite',
|
||
feed: {
|
||
getSnapshot: async () => ({
|
||
...snapshot,
|
||
product: 'clouds-regional',
|
||
times: [times[1]],
|
||
latest: times[1],
|
||
}),
|
||
},
|
||
});
|
||
t.after(() => {
|
||
radar.layer.destroy();
|
||
satellite.layer.destroy();
|
||
clock.destroy();
|
||
});
|
||
const updates = [radar.layer.update(), satellite.layer.update()];
|
||
await flush();
|
||
radar.stages.at(-1).finish();
|
||
satellite.stages.at(-1).finish();
|
||
await Promise.all(updates);
|
||
let satelliteChanges = 0;
|
||
satellite.layer.setRowControlsListener(() => satelliteChanges++);
|
||
const transport = (h) =>
|
||
h.layer
|
||
.getRowControls()
|
||
.chips.filter(({ id }) =>
|
||
['previous', 'play', 'next', 'latest'].includes(id),
|
||
);
|
||
radar.layer.setParams({ step: -1 });
|
||
await flush();
|
||
assert.equal(clock.getState().target, times[1]);
|
||
assert.equal(radar.stages.at(-1).time, times[1]);
|
||
assert.equal(satellite.stages.at(-1).time, times[1]);
|
||
radar.stages.at(-1).finish();
|
||
satellite.stages.at(-1).finish();
|
||
await flush();
|
||
assert.match(satellite.layer.getRowControls().summary.detail, /synced/);
|
||
assert.ok(satelliteChanges > 0);
|
||
assert.deepEqual(transport(radar), []);
|
||
assert.deepEqual(transport(satellite), []);
|
||
satellite.layer.setParams({ step: -1 });
|
||
await flush();
|
||
radar.stages.at(-1).finish();
|
||
await flush();
|
||
assert.equal(clock.getState().target, times[0]);
|
||
assert.equal(satellite.layer.getDiagnostics().hidden, true);
|
||
assert.match(
|
||
satellite.layer.getRowControls().summary.status,
|
||
/No frame within 30 min of 09-15 20:00 UTC/,
|
||
);
|
||
radar.layer.setParams({ step: 1 });
|
||
await flush();
|
||
radar.stages.at(-1).finish();
|
||
satellite.stages.at(-1).finish();
|
||
await flush();
|
||
assert.equal(satellite.layer.getDiagnostics().hidden, false);
|
||
radar.layer.setParams({ latest: true });
|
||
await flush();
|
||
radar.stages.at(-1).finish();
|
||
satellite.stages.at(-1).finish();
|
||
await flush();
|
||
assert.deepEqual(
|
||
[radar.layer.getDiagnostics().time, satellite.layer.getDiagnostics().time],
|
||
[times[2], times[1]],
|
||
);
|
||
assert.deepEqual(radar.layer.getDiagnostics().clock, {
|
||
mode: 'latest',
|
||
target: null,
|
||
playing: false,
|
||
});
|
||
assert.deepEqual(
|
||
radar.layer.getParams(),
|
||
{ product: 'radar', opacity: 'strong' },
|
||
'history is never serialized',
|
||
);
|
||
satellite.layer.disable();
|
||
assert.equal(clock.getState().products.length, 1);
|
||
});
|
||
|
||
test('history manifest expiry hides imagery without jumping to latest; host resume keeps the target', async (t) => {
|
||
const clock = createWeatherClock();
|
||
let value = snapshot;
|
||
const h = layerHarness({ clock, feed: { getSnapshot: async () => value } });
|
||
t.after(() => {
|
||
h.layer.destroy();
|
||
clock.destroy();
|
||
});
|
||
let update = h.layer.update();
|
||
await flush();
|
||
h.stages.at(-1).finish();
|
||
await update;
|
||
const history = clock.setTarget(times[0]);
|
||
await flush();
|
||
h.stages.at(-1).finish();
|
||
await history;
|
||
value = { ...snapshot, times: [times[2]] };
|
||
await h.layer.update();
|
||
assert.equal(h.layer.getDiagnostics().hidden, true);
|
||
assert.equal(clock.getState().target, times[0]);
|
||
h.documentRef.hidden = true;
|
||
h.documentRef.emit('visibilitychange');
|
||
await flush();
|
||
assert.deepEqual(clock.getTimeline(), []);
|
||
h.documentRef.hidden = false;
|
||
h.documentRef.emit('visibilitychange');
|
||
await flush();
|
||
assert.equal(clock.getState().target, times[0]);
|
||
assert.equal(h.layer.getDiagnostics().hidden, true);
|
||
value = snapshot;
|
||
update = h.layer.update();
|
||
await flush();
|
||
h.stages.at(-1).finish();
|
||
await update;
|
||
assert.equal(h.layer.getDiagnostics().time, times[0]);
|
||
});
|
||
|
||
test('hidden renderer retains the frame without displaying it and rehome cannot reveal it', async () => {
|
||
const h = renderingHarness();
|
||
const first = h.rendering.setFrame(snapshot, times[0]);
|
||
h.settle();
|
||
await first;
|
||
const layer = h.layers[0];
|
||
const pending = h.rendering.setFrame(snapshot, times[1]);
|
||
h.rendering.setHidden(true);
|
||
assert.equal(await pending, false);
|
||
assert.equal(h.layers.length, 1);
|
||
assert.equal(h.layers[0], layer);
|
||
assert.equal(layer.show, false);
|
||
assert.equal(h.rendering.getDiagnostics().time, null);
|
||
h.rendering.rehome();
|
||
assert.equal(layer.show, false);
|
||
h.rendering.setHidden(false);
|
||
assert.equal(layer.show, true);
|
||
assert.equal(h.rendering.getDiagnostics().time, times[0]);
|
||
h.rendering.clear();
|
||
});
|
||
|
||
test('satellite global selection uses the three-hour gap and preserves the shared target on product changes', async (t) => {
|
||
const clock = createWeatherClock();
|
||
const earlier = '2026-09-15T18:00:00.000Z';
|
||
const h = layerHarness({
|
||
clock,
|
||
id: 'weather-satellite',
|
||
feed: {
|
||
getSnapshot: async ({ product }) => ({
|
||
...snapshot,
|
||
product,
|
||
times: [earlier],
|
||
latest: earlier,
|
||
}),
|
||
},
|
||
});
|
||
t.after(() => {
|
||
h.layer.destroy();
|
||
clock.destroy();
|
||
});
|
||
const update = h.layer.update();
|
||
await flush();
|
||
h.stages.at(-1).finish();
|
||
await update;
|
||
await clock.setTarget(times[0]);
|
||
assert.equal(
|
||
h.layer.getDiagnostics().hidden,
|
||
true,
|
||
'two-hour regional gap is ineligible',
|
||
);
|
||
h.layer.setParams({ product: 'clouds' });
|
||
await flush();
|
||
assert.equal(h.stages.at(-1).time, earlier);
|
||
h.stages.at(-1).finish();
|
||
await flush();
|
||
assert.equal(clock.getState().target, times[0]);
|
||
assert.equal(h.layer.getDiagnostics().hidden, false);
|
||
assert.match(h.layer.getRowControls().summary.detail, /18:00 UTC.*nearest/);
|
||
const params = h.layer.getParams();
|
||
assert.equal(params.product, 'clouds');
|
||
assert.equal(params.target, undefined);
|
||
});
|
||
|
||
test('switching to history cancels a still-loading latest refresh and a later target cancels the first history load', async (t) => {
|
||
const clock = createWeatherClock();
|
||
const h = layerHarness({ clock });
|
||
t.after(() => {
|
||
h.layer.destroy();
|
||
clock.destroy();
|
||
});
|
||
const update = h.layer.update();
|
||
await flush();
|
||
const latest = h.stages.at(-1);
|
||
const oldHistory = clock.setTarget(times[0]);
|
||
await flush();
|
||
assert.equal(latest.signal.aborted, true);
|
||
const oldStage = h.stages.at(-1);
|
||
const current = clock.setTarget(times[1]);
|
||
await flush();
|
||
assert.equal(oldStage.signal.aborted, true);
|
||
h.stages.at(-1).finish();
|
||
await current;
|
||
latest.finish();
|
||
oldStage.finish();
|
||
await oldHistory;
|
||
await update;
|
||
assert.equal(h.layer.getDiagnostics().time, times[1]);
|
||
assert.equal(h.layer.getDiagnostics().clock.target, times[1]);
|
||
});
|
||
|
||
test('observed descriptors keep configuration only and label satellite clouds by coverage', () => {
|
||
for (const [id, coverage] of [
|
||
['weather-radar', 'CONUS'],
|
||
['weather-satellite', 'North America'],
|
||
['weather-lightning', 'Americas + Pacific'],
|
||
]) {
|
||
const h = layerHarness({ id });
|
||
const controls = h.layer.getRowControls();
|
||
assert.equal(controls.readout, true);
|
||
assert.equal(controls.summary.coverage, coverage);
|
||
assert.equal(controls.summary.sections, undefined);
|
||
assert.deepEqual(
|
||
controls.summary.settings.map(({ label }) => label),
|
||
id === 'weather-satellite'
|
||
? ['REGION', 'IMAGE', 'OPACITY']
|
||
: id === 'weather-radar'
|
||
? ['REGION', 'OPACITY']
|
||
: ['OPACITY'],
|
||
);
|
||
assert.deepEqual(
|
||
controls.summary.settings.flatMap(({ chips }) => chips),
|
||
controls.chips.filter(({ id }) => id !== 'coverage'),
|
||
);
|
||
assert.deepEqual(controls.summary.actions, [
|
||
controls.chips.find(({ id }) => id === 'coverage'),
|
||
]);
|
||
assert.deepEqual(controls.summary.actions[0].params, { focus: true });
|
||
assert.equal(
|
||
controls.chips.some(({ id }) =>
|
||
['previous', 'play', 'next', 'latest'].includes(id),
|
||
),
|
||
false,
|
||
);
|
||
if (id === 'weather-satellite') {
|
||
assert.equal(h.layer.name, 'Satellite clouds');
|
||
assert.equal(controls.summary.label, 'Satellite clouds');
|
||
assert.match(
|
||
controls.infoTitle,
|
||
/GOES-19\/18 longwave infrared Band 14 regional; NESDIS global longwave mosaic/,
|
||
);
|
||
assert.equal(
|
||
controls.chips.find(({ id }) => id === 'filtered').title,
|
||
'Dim everything but the bright, cold cloud tops; a brightness filter, not a cloud mask',
|
||
);
|
||
h.layer.setParams({ product: 'clouds' });
|
||
assert.equal(
|
||
h.layer.getRowControls().summary.coverage,
|
||
'Global · 60°S–60°N',
|
||
);
|
||
assert.equal(h.layer.getRowControls().summary.maxGapMinutes, 180);
|
||
}
|
||
h.layer.destroy();
|
||
}
|
||
});
|
||
|
||
const globalSnapshot = { ...snapshot, product: 'clouds' };
|
||
async function showMosaic(h, time, infrared = 'filtered') {
|
||
const pending = h.rendering.setFrame(globalSnapshot, time, { infrared });
|
||
await flush();
|
||
h.settle();
|
||
assert.equal(await pending, true);
|
||
h.settle();
|
||
}
|
||
|
||
test('mosaic cache skips fetch and decode, separates modes, evicts LRU at 6 and clears per instance', async (t) => {
|
||
let fetches = 0,
|
||
decodes = 0;
|
||
const h = renderingHarness({
|
||
fetchImpl: async () => {
|
||
fetches++;
|
||
return mockResponse();
|
||
},
|
||
decodeImage: async () => {
|
||
decodes++;
|
||
return { width: 2048, height: 1024 };
|
||
},
|
||
});
|
||
t.after(() => h.rendering.clear());
|
||
await showMosaic(h, times[0]);
|
||
await showMosaic(h, times[1]);
|
||
await showMosaic(h, times[0]);
|
||
assert.equal(fetches, 2);
|
||
assert.equal(decodes, 2);
|
||
assert.deepEqual(h.rendering.getDiagnostics().mosaic, {
|
||
fetched: false,
|
||
cached: true,
|
||
decodeMs: 0,
|
||
});
|
||
await showMosaic(h, times[0], 'full');
|
||
assert.equal(fetches, 3);
|
||
assert.equal(h.rendering.getDiagnostics().mosaic.cached, false);
|
||
await showMosaic(h, times[0]);
|
||
const extra = Array.from({ length: 4 }, (_, i) =>
|
||
new Date(Date.parse(times[2]) + i * 3600_000).toISOString(),
|
||
);
|
||
for (const time of extra) await showMosaic(h, time);
|
||
assert.deepEqual(h.rendering.getDiagnostics().cache, {
|
||
mosaics: 6,
|
||
prefetching: false,
|
||
});
|
||
await showMosaic(h, times[0]);
|
||
assert.equal(
|
||
fetches,
|
||
7,
|
||
'touching the oldest frame retained it ahead of the second frame',
|
||
);
|
||
await showMosaic(h, times[1]);
|
||
assert.equal(fetches, 8, 'least recently used frame was evicted');
|
||
h.rendering.clear();
|
||
assert.deepEqual(h.rendering.getDiagnostics().cache, {
|
||
mosaics: 0,
|
||
prefetching: false,
|
||
});
|
||
await showMosaic(h, times[0]);
|
||
assert.equal(fetches, 9);
|
||
const other = renderingHarness({
|
||
fetchImpl: async () => {
|
||
fetches++;
|
||
return mockResponse();
|
||
},
|
||
});
|
||
t.after(() => other.rendering.clear());
|
||
await showMosaic(other, times[0]);
|
||
assert.equal(fetches, 10, 'instances do not share canvases');
|
||
});
|
||
|
||
test('global prefetch warms a decoded frame without staging imagery and hits on selection', async (t) => {
|
||
let fetches = 0;
|
||
const h = renderingHarness({
|
||
fetchImpl: async () => {
|
||
fetches++;
|
||
return mockResponse();
|
||
},
|
||
});
|
||
t.after(() => h.rendering.clear());
|
||
await showMosaic(h, times[0]);
|
||
const warm = h.rendering.prefetch(globalSnapshot, times[1]);
|
||
assert.equal(h.rendering.getDiagnostics().cache.prefetching, true);
|
||
assert.equal(h.rendering.getDiagnostics().time, times[0]);
|
||
assert.equal(h.layers.length, 1);
|
||
assert.equal(await warm, true);
|
||
await showMosaic(h, times[1]);
|
||
assert.equal(fetches, 2);
|
||
assert.equal(h.rendering.getDiagnostics().mosaic.cached, true);
|
||
assert.equal(h.rendering.getDiagnostics().mosaic.decodeMs, 0);
|
||
});
|
||
|
||
test('cancelled or cleared speculative decodes cannot repopulate the cache', async () => {
|
||
for (const cancel of ['cancelPrefetch', 'clear', 'setHidden']) {
|
||
const decoded = deferred();
|
||
let signal,
|
||
closes = 0;
|
||
const h = renderingHarness({
|
||
fetchImpl: async (_url, options) => {
|
||
signal = options.signal;
|
||
return mockResponse();
|
||
},
|
||
decodeImage: () => decoded.promise,
|
||
});
|
||
const warm = h.rendering.prefetch(globalSnapshot, times[0]);
|
||
await flush();
|
||
h.rendering[cancel](true);
|
||
assert.equal(signal.aborted, true);
|
||
decoded.resolve({
|
||
width: 2048,
|
||
height: 1024,
|
||
close() {
|
||
closes++;
|
||
},
|
||
});
|
||
assert.equal(await warm, false);
|
||
assert.equal(closes, 1);
|
||
assert.deepEqual(h.rendering.getDiagnostics().cache, {
|
||
mosaics: 0,
|
||
prefetching: false,
|
||
});
|
||
h.rendering.clear();
|
||
}
|
||
});
|
||
|
||
test('tiled prefetch covers only visible product bounds at levels 0/1, caps eight requests and drains bodies', async (t) => {
|
||
const calls = [];
|
||
let bodies = 0;
|
||
const h = renderingHarness({
|
||
fetchImpl: async (url, { signal }) => {
|
||
calls.push({ url: new URL(url, 'http://localhost'), signal });
|
||
return {
|
||
...mockResponse(),
|
||
arrayBuffer: async () => {
|
||
bodies++;
|
||
return new ArrayBuffer(1);
|
||
},
|
||
};
|
||
},
|
||
});
|
||
t.after(() => h.rendering.clear());
|
||
h.viewer.camera = {
|
||
computeViewRectangle: () =>
|
||
Cesium.Rectangle.fromDegrees(-120, 25, -100, 50),
|
||
};
|
||
assert.equal(await h.rendering.prefetch(snapshot, times[1]), true);
|
||
assert.equal(calls.length, 2);
|
||
assert.equal(bodies, 2);
|
||
assert.deepEqual(
|
||
calls.map(({ url }) =>
|
||
['z', 'x', 'y'].map((key) => url.searchParams.get(key)),
|
||
),
|
||
[
|
||
['0', '0', '0'],
|
||
['1', '0', '0'],
|
||
],
|
||
);
|
||
assert.ok(
|
||
calls.every(({ url }) => url.searchParams.get('time') === times[1]),
|
||
);
|
||
assert.equal(
|
||
await h.rendering.prefetch(snapshot, times[1]),
|
||
false,
|
||
'same completed work is not repeated',
|
||
);
|
||
h.viewer.camera.computeViewRectangle = () =>
|
||
Cesium.Rectangle.fromDegrees(100, 25, 120, 50);
|
||
await h.rendering.prefetch(snapshot, times[2]);
|
||
assert.equal(calls.length, 2, 'view outside product bounds fetches nothing');
|
||
h.viewer.camera.computeViewRectangle = () => Cesium.Rectangle.MAX_VALUE;
|
||
const world = {
|
||
...snapshot,
|
||
bounds: { west: -180, south: -90, east: 180, north: 90 },
|
||
};
|
||
await h.rendering.prefetch(world, times[2]);
|
||
assert.equal(
|
||
calls.length,
|
||
10,
|
||
'world view is capped at eight additional requests',
|
||
);
|
||
assert.equal(bodies, 10);
|
||
assert.equal(new Set(calls.slice(2).map(({ url }) => url.href)).size, 8);
|
||
});
|
||
|
||
test('prefetch is best effort, aborts on replacement or host suspension, and skips hidden frames', async (t) => {
|
||
let host = { collection: null, kind: 'globe' };
|
||
const signals = [];
|
||
const h = renderingHarness({
|
||
getHost: () => host,
|
||
fetchImpl: (_url, { signal }) =>
|
||
new Promise((_, reject) => {
|
||
signals.push(signal);
|
||
signal.addEventListener('abort', () => reject(signal.reason), {
|
||
once: true,
|
||
});
|
||
}),
|
||
});
|
||
host.collection = h.viewer.imageryLayers;
|
||
t.after(() => h.rendering.clear());
|
||
const first = h.rendering.prefetch(snapshot, times[1]);
|
||
const shown = h.rendering.setFrame(snapshot, times[0]);
|
||
h.settle();
|
||
assert.equal(
|
||
await shown,
|
||
true,
|
||
'current frame never waits for speculative work',
|
||
);
|
||
assert.equal(await first, false);
|
||
assert.ok(signals.every((signal) => signal.aborted));
|
||
const second = h.rendering.prefetch(snapshot, times[1]);
|
||
host = { collection: null, kind: 'none' };
|
||
h.rendering.rehome();
|
||
assert.equal(await second, false);
|
||
const count = signals.length;
|
||
assert.equal(await h.rendering.prefetch(snapshot, times[2]), false);
|
||
host = { collection: h.viewer.imageryLayers, kind: 'globe' };
|
||
h.rendering.setHidden(true);
|
||
assert.equal(await h.rendering.prefetch(snapshot, times[2]), false);
|
||
assert.equal(signals.length, count);
|
||
assert.equal(h.rendering.getDiagnostics().cache.prefetching, false);
|
||
});
|
||
|
||
test('failed prefetch leaves the current frame and errors unchanged and releases its deadline', async (t) => {
|
||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||
const h = renderingHarness({
|
||
fetchImpl: async () => {
|
||
throw new Error('offline');
|
||
},
|
||
});
|
||
t.after(() => h.rendering.clear());
|
||
const shown = h.rendering.setFrame(snapshot, times[0]);
|
||
h.settle();
|
||
await shown;
|
||
const before = h.rendering.getDiagnostics();
|
||
assert.equal(await h.rendering.prefetch(snapshot, times[1]), false);
|
||
assert.deepEqual(h.rendering.getDiagnostics(), before);
|
||
t.mock.timers.tick(30_000);
|
||
assert.deepEqual(h.rendering.getDiagnostics(), before);
|
||
});
|
||
|
||
test('layer warms the next advertised observation only after a successful playing frame, wrapping at the end', async (t) => {
|
||
const clock = createWeatherClock();
|
||
const h = layerHarness({ clock });
|
||
const warmed = [];
|
||
let cancels = 0;
|
||
h.rendering.prefetch = async (manifest, time, options) => {
|
||
warmed.push({ manifest, time, options });
|
||
};
|
||
h.rendering.cancelPrefetch = () => {
|
||
cancels++;
|
||
};
|
||
t.after(() => {
|
||
h.layer.destroy();
|
||
clock.destroy();
|
||
});
|
||
const updated = h.layer.update();
|
||
await flush();
|
||
h.stages.at(-1).finish();
|
||
await updated;
|
||
assert.equal(warmed.length, 0);
|
||
const play = clock.play();
|
||
await flush();
|
||
assert.equal(warmed.length, 0, 'still staging');
|
||
h.stages.at(-1).finish();
|
||
await play;
|
||
assert.deepEqual(
|
||
warmed.map(({ time }) => time),
|
||
[times[0]],
|
||
);
|
||
assert.deepEqual(warmed[0].options, { infrared: 'filtered' });
|
||
const next = clock.setTarget(times[0]);
|
||
await flush();
|
||
h.stages.at(-1).finish();
|
||
await next;
|
||
assert.deepEqual(
|
||
warmed.map(({ time }) => time),
|
||
[times[0], times[1]],
|
||
);
|
||
const failed = clock.setTarget(times[1]);
|
||
await flush();
|
||
h.stages.at(-1).finish(false);
|
||
await failed;
|
||
assert.equal(warmed.length, 2);
|
||
const priorCancels = cancels;
|
||
clock.pause();
|
||
assert.ok(cancels > priorCancels);
|
||
const paused = clock.setTarget(times[2]);
|
||
await flush();
|
||
h.stages.at(-1).finish();
|
||
await paused;
|
||
assert.equal(warmed.length, 2);
|
||
await clock.play();
|
||
h.documentRef.hidden = true;
|
||
h.documentRef.emit('visibilitychange');
|
||
await flush();
|
||
h.stages.at(-1).finish();
|
||
await flush();
|
||
assert.equal(warmed.length, 2, 'hidden products do not prefetch');
|
||
});
|
||
|
||
for (const product of ['radar', 'clouds-regional', 'lightning']) {
|
||
test(`${product} on the globe host uses 256 px tiles to level 6`, async (t) => {
|
||
const collection = new ImageryLayerCollection();
|
||
const h = renderingHarness({
|
||
getHost: () => ({ collection, kind: 'globe' }),
|
||
});
|
||
t.after(() => h.rendering.clear());
|
||
h.viewer.camera = {
|
||
moveEnd: event(),
|
||
positionCartographic: { height: 60_000 },
|
||
};
|
||
const pending = h.rendering.setFrame({ ...snapshot, product }, times[0]);
|
||
const options = h.providers[0].options;
|
||
assert.equal(options.tileWidth, 256);
|
||
assert.equal(options.tileHeight, 256);
|
||
assert.equal(options.maximumLevel, 6);
|
||
assert.equal(
|
||
new URL(options.url, 'https://example.test').searchParams.get('size'),
|
||
'256',
|
||
);
|
||
assert.equal(h.rendering.getDiagnostics().host, 'globe');
|
||
h.rendering.clear();
|
||
assert.equal(await pending, false);
|
||
assert.equal(h.viewer.camera.moveEnd.size, 0);
|
||
});
|
||
}
|
||
|
||
test('global radar uses Web Mercator provider, linked credit and square-grid prefetch', async () => {
|
||
const urls = [];
|
||
const h = renderingHarness({
|
||
fetchImpl: async (url) => {
|
||
urls.push(url);
|
||
return mockResponse();
|
||
},
|
||
});
|
||
h.cesium.WebMercatorTilingScheme = Cesium.WebMercatorTilingScheme;
|
||
const globalRadar = {
|
||
...snapshot,
|
||
product: 'radar-global',
|
||
tilingScheme: 'web-mercator',
|
||
maxLevel: 7,
|
||
bounds: { west: -180, south: -85.0511, east: 180, north: 85.0511 },
|
||
};
|
||
const pending = h.rendering.setFrame(globalRadar, times[0]);
|
||
const provider = h.providers[0].options;
|
||
assert.ok(provider.tilingScheme instanceof Cesium.WebMercatorTilingScheme);
|
||
assert.equal(provider.maximumLevel, 6);
|
||
assert.equal(provider.tileWidth, 256);
|
||
assert.match(provider.credit.html, /href="https:\/\/www.rainviewer.com"/);
|
||
h.settle();
|
||
assert.equal(await pending, true);
|
||
assert.equal(await h.rendering.prefetch(globalRadar, times[1]), true);
|
||
const xyz = urls.map((url) => {
|
||
const params = new URL(url, 'https://example.test').searchParams;
|
||
return ['z', 'x', 'y'].map((key) => Number(params.get(key)));
|
||
});
|
||
assert.deepEqual(xyz, [
|
||
[0, 0, 0],
|
||
[1, 0, 0],
|
||
[1, 1, 0],
|
||
[1, 0, 1],
|
||
[1, 1, 1],
|
||
]);
|
||
h.rendering.clear();
|
||
});
|
||
|
||
test('radar region switch cancels old work, retains clock target and advertises RainViewer semantics', async () => {
|
||
const clock = createWeatherClock();
|
||
const products = [];
|
||
const h = layerHarness({
|
||
clock,
|
||
feed: {
|
||
getSnapshot: async ({ product }) => {
|
||
products.push(product);
|
||
return {
|
||
...snapshot,
|
||
product,
|
||
bounds:
|
||
product === 'radar-global'
|
||
? { west: -180, south: -85.0511, east: 180, north: 85.0511 }
|
||
: snapshot.bounds,
|
||
};
|
||
},
|
||
},
|
||
});
|
||
const initial = h.layer.update();
|
||
await flush();
|
||
const old = h.stages.at(-1);
|
||
h.layer.setParams({ product: 'radar-global' });
|
||
await flush();
|
||
assert.equal(old.signal.aborted, true);
|
||
assert.deepEqual(products, ['radar', 'radar-global']);
|
||
h.stages.at(-1).finish();
|
||
await flush();
|
||
await initial;
|
||
assert.equal(h.layer.getParams().product, 'radar-global');
|
||
const controls = h.layer.getRowControls();
|
||
assert.equal(controls.summary.coverage, 'Global · where radars exist');
|
||
assert.equal(controls.summary.maxGapMinutes, 30);
|
||
assert.deepEqual(
|
||
controls.summary.settings[0].chips.map(({ label }) => label),
|
||
['US (NOAA)', 'Global (RainViewer)'],
|
||
);
|
||
assert.deepEqual(
|
||
controls.legend.map(({ color }) => color),
|
||
[
|
||
'#cec087',
|
||
'#00a3e0',
|
||
'#005588',
|
||
'#ffee00',
|
||
'#ffaa00',
|
||
'#ff4400',
|
||
'#c10000',
|
||
'#ffaaff',
|
||
'#ffffff',
|
||
],
|
||
);
|
||
assert.match(controls.infoTitle, /gap does not mean no rain/);
|
||
assert.equal(h.layer.getStats().source, 'RainViewer');
|
||
const history = clock.setTarget(times[1]);
|
||
await flush();
|
||
h.stages.at(-1).finish();
|
||
await history;
|
||
assert.equal(h.layer.getDiagnostics().time, times[1]);
|
||
h.layer.setParams({ product: 'radar' });
|
||
await flush();
|
||
h.stages.at(-1).finish();
|
||
await flush();
|
||
assert.equal(clock.getState().target, times[1]);
|
||
assert.equal(h.layer.getRowControls().summary.coverage, 'CONUS');
|
||
h.layer.destroy();
|
||
clock.destroy();
|
||
});
|
||
|
||
test('global radar coverage status follows the active bounds over land and ocean', async () => {
|
||
let location = Cesium.Cartesian3.fromDegrees(0, 51);
|
||
let shown = null;
|
||
const latest = new Date().toISOString();
|
||
const layer = createWeatherLayer({
|
||
documentRef: null,
|
||
eventTarget: null,
|
||
matchMedia: null,
|
||
feed: {
|
||
getSnapshot: async ({ product }) => ({
|
||
product,
|
||
times: [latest],
|
||
latest,
|
||
bounds:
|
||
product === 'radar-global'
|
||
? { west: -180, south: -85.0511, east: 180, north: 85.0511 }
|
||
: snapshot.bounds,
|
||
}),
|
||
},
|
||
createRendering: () => ({
|
||
rehome() {},
|
||
clear() {
|
||
shown = null;
|
||
},
|
||
setAlpha() {},
|
||
async setFrame(_snapshot, time) {
|
||
shown = time;
|
||
return true;
|
||
},
|
||
getDiagnostics: () => ({ time: shown }),
|
||
}),
|
||
});
|
||
layer.init({
|
||
camera: { pickEllipsoid: () => location },
|
||
scene: {
|
||
canvas: { clientWidth: 800, clientHeight: 600 },
|
||
globe: { ellipsoid: Cesium.Ellipsoid.WGS84 },
|
||
},
|
||
});
|
||
layer.enable();
|
||
await layer.update();
|
||
assert.equal(
|
||
layer.getRowControls().summary.status,
|
||
'Map center outside coverage',
|
||
);
|
||
layer.setParams({ product: 'radar-global' });
|
||
await flush();
|
||
assert.equal(layer.getRowControls().summary.status, null);
|
||
location = Cesium.Cartesian3.fromDegrees(-150, -30);
|
||
assert.equal(layer.getRowControls().summary.status, null);
|
||
layer.destroy();
|
||
});
|