Files
gods-eye-view/src/globe/cesium/movers.test.mjs
T
German Khokhlov a07d9f5d85 Retry a failed 3D model inside the engine instead of in the layers
A mover set whose glTF or atlas fails to load retries by itself after
retryAfterMs (1,500 ms by default), at most three attempts, on Cesium,
Navara and the fake engine alike: failed() is true while the latest
attempt failed and stays true after the last, ready() turns true when a
retry loads, and an instance given another pickId loads again. Healthy
models of the same class no longer load again when one fails.

The layers drop what they did instead: moverBank's rebuilds, the loops
that dropped failed handles in flights, military and local ADS-B, and the
tracked-model backoff in the aircraft display. The contract suite checks
the one behaviour on both engines, with a mutant that never retries.
2026-11-10 16:04:01 +04:00

663 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as geo from '../../geo/index.js';
import { createCesiumMovers } from './movers.js';
import { createFakeViewer } from './testing/fakeViewer.mjs';
// Cesium's own collections, billboards and points (they draw nothing
// without a renderer, but hold what the engine writes). What needs the
// network or a DOM is faked: the glTF load and the atlas crop.
function setup({ loadModel, cropCells } = {}) {
const viewer = createFakeViewer();
viewer.scene.frameState.mode = Cesium.SceneMode.SCENE3D;
const requests = [];
const crops = [];
const loads = [];
const factories = {
cropCells:
cropCells ??
(async (atlas, keys) => {
crops.push({ atlas, keys });
return new Map(keys.map((key) => [key, { width: 8, height: 8, key }]));
}),
loadModel:
loadModel ??
(async (options) => {
loads.push(options);
return fakeModel(options);
}),
};
const part = createCesiumMovers(viewer, {
factories,
requestRender: (reason) => requests.push(reason),
});
const root = () => viewer.primitiveList[0];
return { viewer, requests, crops, loads, root, ...part };
}
// What the engine reads and writes of a Cesium Model: its own model matrix
// (kept as given), a colour it copies.
function fakeModel(options) {
let color;
return {
options,
ready: false,
show: true,
modelMatrix: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY),
get color() {
return color;
},
set color(value) {
color = Cesium.Color.clone(value);
},
isDestroyed: () => false,
destroy() {
this.destroyed = true;
},
};
}
const at = (lon, lat, height = 0) =>
geo.cartesian3.fromDegrees(lon, lat, height);
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
const atlas = {
canvas: { width: 64, height: 32 },
cells: { jet: [0, 0, 0.5, 1], heli: [0.5, 0, 1, 1] },
};
const items = (collection) =>
Array.from({ length: collection.length }, (_, index) =>
collection.get(index),
);
test('creating the movers changes nothing; the first set adds one root collection', () => {
const { viewer, movers, root, destroy } = setup();
assert.equal(viewer.scene.primitives.length, 0);
const set = movers.createSet({
id: 'a',
kind: 'point',
capacity: 2,
point: { sizePx: 4 },
});
assert.equal(viewer.scene.primitives.length, 1);
assert.ok(root() instanceof Cesium.PrimitiveCollection);
const [collection] = items(root());
assert.ok(collection instanceof Cesium.PointPrimitiveCollection);
set.destroy();
assert.equal(root().length, 0);
assert.equal(collection.isDestroyed(), true, 'a destroyed set is released');
destroy();
assert.equal(viewer.scene.primitives.length, 0);
});
test('sprites are billboards with the set style once the atlas cells are ready', async () => {
const { movers, root, requests, crops } = setup();
const set = movers.createSet({
id: 'flights',
kind: 'sprite',
capacity: 3,
depthTest: 'always',
sprite: { atlas, sizePx: 20, scaleByDistance: [1e3, 1, 1e6, 0.5] },
});
const [collection] = items(root());
assert.ok(collection instanceof Cesium.BillboardCollection);
assert.deepEqual(crops[0].keys, ['jet', 'heli']);
assert.equal(crops[0].atlas, atlas);
assert.equal(set.ready(), false, 'not ready before the atlas');
set.set(0, {
position: at(1, 2),
rotation: 0.25,
cell: 'heli',
pickId: 'x1',
});
set.set(1, {
position: at(3, 4),
color: [1, 0, 0, 0.5],
scale: 2,
show: false,
});
set.setCount(2);
set.commit();
assert.equal(collection.length, 0, 'waits for the atlas');
await settle();
assert.equal(set.ready(), true);
assert.equal(collection.length, 2);
const [first, second] = items(collection);
assert.ok(first instanceof Cesium.Billboard);
assert.ok(Cesium.Cartesian3.equalsEpsilon(first.position, at(1, 2), 0, 1e-6));
assert.equal(first.width, 20);
assert.equal(first.height, 20);
assert.equal(first.rotation, 0.25);
assert.equal(first.id, 'x1');
assert.equal(first.disableDepthTestDistance, Number.POSITIVE_INFINITY);
assert.ok(
Cesium.NearFarScalar.equals(
first.scaleByDistance,
new Cesium.NearFarScalar(1e3, 1, 1e6, 0.5),
),
);
assert.equal(first.image, 'flights:heli', 'the cell image, by set and key');
assert.equal(first.show, true);
assert.equal(second.image, 'flights:jet', 'the first cell by default');
assert.equal(second.show, false);
assert.equal(second.scale, 2);
assert.ok(Cesium.Color.equals(second.color, new Cesium.Color(1, 0, 0, 0.5)));
assert.ok(requests.includes('movers'));
// The same cell is not sent again; a new one is.
const sent = [];
const setImage = first.setImage;
first.setImage = (id, image) => {
sent.push(id);
setImage.call(first, id, image);
};
set.set(0, { cell: 'heli' });
set.commit();
assert.deepEqual(sent, []);
set.set(0, { cell: 'jet' });
set.commit();
assert.deepEqual(sent, ['flights:jet']);
// Lowering the count hides the instances past it.
set.set(1, { show: true });
set.commit();
assert.equal(second.show, true);
set.setCount(1);
set.commit();
assert.equal(second.show, false);
});
test('a sprite set destroyed before its atlas arrives draws nothing', async () => {
const { movers, root } = setup();
const set = movers.createSet({
id: 'early',
kind: 'sprite',
capacity: 1,
sprite: { atlas, sizePx: 8 },
});
const [collection] = items(root());
set.set(0, { position: at(0, 0) });
set.setCount(1);
set.commit();
set.destroy();
await settle();
assert.equal(collection.isDestroyed(), true);
assert.equal(root().length, 0);
});
test('points take the size, scale, outline, translucency and depth test of the set', () => {
const { movers, root } = setup();
const set = movers.createSet({
id: 'sats',
kind: 'point',
capacity: 2,
depthTest: { disableWithinM: 5000 },
point: {
sizePx: 4,
outlinePx: 1,
outlineColor: [1, 1, 1, 0.5],
scaleByDistance: [1e3, 2, 1e7, 0.5],
translucencyByDistance: [1e6, 1, 1e8, 0],
},
});
set.set(0, {
position: at(5, 6),
scale: 1.5,
color: [0, 1, 0, 1],
pickId: 25544,
});
set.setCount(1);
set.commit();
const [collection] = items(root());
const [point] = items(collection);
assert.ok(point instanceof Cesium.PointPrimitive);
assert.equal(point.pixelSize, 6, 'sizePx × scale');
assert.equal(point.outlineWidth, 1);
assert.ok(
Cesium.Color.equals(point.outlineColor, new Cesium.Color(1, 1, 1, 0.5)),
);
assert.ok(
Cesium.NearFarScalar.equals(
point.scaleByDistance,
new Cesium.NearFarScalar(1e3, 2, 1e7, 0.5),
),
);
assert.ok(
Cesium.NearFarScalar.equals(
point.translucencyByDistance,
new Cesium.NearFarScalar(1e6, 1, 1e8, 0),
),
);
assert.equal(point.disableDepthTestDistance, 5000);
assert.equal(point.id, 25544);
assert.ok(Cesium.Color.equals(point.color, new Cesium.Color(0, 1, 0, 1)));
set.set(0, { outlineColor: [0.5, 0, 1, 0.25] });
set.commit();
assert.deepEqual(
Cesium.Color.pack(point.outlineColor, []),
[0.5, 0, 1, 0.25],
);
assert.ok(Cesium.Color.equals(point.color, new Cesium.Color(0, 1, 0, 1)));
assert.equal(point.outlineWidth, 1);
assert.throws(
() => set.set(0, { scale: 9, outlineColor: [1, 0, 0, 2] }),
TypeError,
);
set.commit();
assert.equal(
point.pixelSize,
6,
'an invalid outline update leaves the point unchanged',
);
// 'normal' keeps Cesium's depth test.
const normal = movers.createSet({
id: 'normal',
kind: 'point',
capacity: 1,
point: { sizePx: 2 },
});
normal.set(0, { position: at(0, 0) });
normal.setCount(1);
normal.commit();
const [, normalCollection] = items(root());
assert.equal(items(normalCollection)[0].disableDepthTestDistance, 0);
assert.equal(items(normalCollection)[0].outlineWidth, 0);
});
test('sets are ordered in the root by their order', () => {
const { movers, root } = setup();
const make = (id, order) =>
movers.createSet({
id,
kind: 'point',
capacity: 1,
order,
point: { sizePx: 2 },
});
make('top', 5);
make('bottom', -1);
const middle = make('middle', 0);
make('middle-later', 0);
assert.deepEqual(
items(root()).map((collection) => collection.gevMoverSet),
['bottom', 'middle', 'middle-later', 'top'],
);
middle.destroy();
make('again', 0);
assert.deepEqual(
items(root()).map((collection) => collection.gevMoverSet),
['bottom', 'middle-later', 'again', 'top'],
);
});
test('models load per instance with the set options and follow position, orientation and colour', async () => {
const { movers, root, loads } = setup();
const make = (id, model, pickable = false) =>
movers.createSet({ id, kind: 'model', capacity: 2, pickable, model });
const set = make(
'models',
{
url: '/models/airplane.glb',
minimumPixelSize: 24,
colorBlend: { mode: 'mix', amount: 0.4 },
},
true,
);
const position = at(10, 20, 3000);
// A quarter turn about z.
const orientation = { x: 0, y: 0, z: Math.SQRT1_2, w: Math.SQRT1_2 };
set.set(0, { position, orientation, color: [1, 0.5, 0, 0.8], scale: 3 });
set.set(1, { position: at(11, 20), pickId: 'second' });
set.setCount(2);
set.commit();
assert.equal(set.ready(), false);
await settle();
const [collection] = items(root());
assert.ok(collection instanceof Cesium.PrimitiveCollection);
assert.equal(collection.length, 2, 'one model per instance');
assert.equal(loads.length, 2);
const [options] = loads;
assert.equal(options.url, '/models/airplane.glb');
assert.equal(options.minimumPixelSize, 24);
assert.equal(options.colorBlendMode, Cesium.ColorBlendMode.MIX);
assert.equal(options.colorBlendAmount, 0.4);
assert.equal(options.allowPicking, true);
assert.equal(options.customShader, undefined, 'lit by default');
assert.equal(options.asynchronous, false, 'built at once');
// The glTF's own axes: no Cesium axis correction (Y_UP_TO_Z_UP ×
// Z_UP_TO_X_UP), so an orientation draws the same on every engine.
assert.equal(options.upAxis, Cesium.Axis.Z);
assert.equal(options.forwardAxis, Cesium.Axis.X);
assert.ok(
Cesium.Matrix4.equals(
Cesium.ModelUtility.getAxisCorrectionMatrix(
options.upAxis,
options.forwardAxis,
new Cesium.Matrix4(),
),
Cesium.Matrix4.IDENTITY,
),
);
assert.deepEqual(options.environmentMapOptions, { enabled: false });
const [first, second] = items(collection);
assert.notEqual(first.modelMatrix, second.modelMatrix);
const translation = Cesium.Matrix4.getTranslation(
first.modelMatrix,
new Cesium.Cartesian3(),
);
assert.ok(Cesium.Cartesian3.equalsEpsilon(translation, position, 0, 1e-6));
// The model's x axis turned onto Earth-fixed y.
assert.ok(Math.abs(first.modelMatrix[1] - 1) < 1e-12);
assert.ok(Math.abs(first.modelMatrix[0]) < 1e-12);
assert.equal(first.scale, 3);
assert.ok(first.color.equalsEpsilon(new Cesium.Color(1, 0.5, 0, 0.8), 1e-6));
assert.equal(second.id, 'second');
assert.equal(set.ready(), false, 'loaded models not yet ready');
first.ready = true;
assert.equal(set.ready(0), true, 'an instance is ready with its model');
assert.equal(set.ready(1), false);
assert.equal(set.ready(), false);
second.ready = true;
assert.equal(set.ready(), true);
assert.equal(set.ready(2), false, 'past the count');
// Shrinking hides the instance past the count; a later write moves it.
set.setCount(1);
set.commit();
assert.equal(second.show, false);
set.set(0, { show: false });
set.commit();
assert.equal(first.show, false);
set.destroy();
assert.equal(collection.isDestroyed(), true);
assert.equal(first.destroyed, true, 'the models go with the set');
});
test('model colour blends and unlit become Cesium model options', async () => {
const { movers, loads } = setup();
const make = (id, model) => {
const set = movers.createSet({ id, kind: 'model', capacity: 1, model });
set.set(0, { position: at(0, 0) });
set.setCount(1);
set.commit();
};
make('plain', { url: '/a.glb' });
make('replace', { url: '/a.glb', colorBlend: { mode: 'replace' } });
make('unlit', { url: '/a.glb', unlit: true });
await settle();
const [plain, replace, unlit] = loads;
// Without colorBlend the colour sets only the alpha.
assert.equal(plain.colorBlendMode, Cesium.ColorBlendMode.MIX);
assert.equal(plain.colorBlendAmount, 0);
assert.equal(plain.allowPicking, false);
assert.equal(plain.minimumPixelSize, 0);
assert.equal(replace.colorBlendMode, Cesium.ColorBlendMode.REPLACE);
assert.ok(unlit.customShader instanceof Cesium.CustomShader);
assert.equal(unlit.customShader.lightingModel, Cesium.LightingModel.UNLIT);
});
test('a model that loads after its set is destroyed is released', async () => {
let finish;
const pending = new Promise((resolve) => (finish = resolve));
const { movers, root } = setup({ loadModel: () => pending });
const set = movers.createSet({
id: 'late',
kind: 'model',
capacity: 1,
model: { url: '/a.glb' },
});
set.set(0, { position: at(0, 0) });
set.setCount(1);
set.commit();
set.destroy();
const model = fakeModel({});
finish(model);
await settle();
assert.equal(model.destroyed, true);
assert.equal(root().length, 0);
});
test('failed model attempts stop at three and a replacement gets a fresh budget', async (t) => {
t.mock.method(console, 'warn', () => {});
t.mock.timers.enable({ apis: ['setTimeout'] });
let loads = 0;
const { movers } = setup({
loadModel: async () => {
loads++;
throw new Error('503');
},
});
const set = movers.createSet({
id: 'broken',
kind: 'model',
capacity: 1,
model: { url: '/a.glb' },
});
set.set(0, { position: at(0, 0), pickId: 'a' });
set.setCount(1);
set.commit();
assert.equal(set.failed(), false);
await new Promise(setImmediate);
assert.equal(set.failed(0), true);
t.mock.timers.tick(1499);
assert.equal(loads, 1);
for (const delay of [1, 1500]) {
t.mock.timers.tick(delay);
await new Promise(setImmediate);
}
t.mock.timers.tick(10000);
assert.equal(loads, 3);
assert.equal(set.failed(), true);
assert.equal(set.ready(), false);
set.set(0, { pickId: 'b' });
set.commit();
await new Promise(setImmediate);
assert.equal(loads, 4);
set.destroy();
t.mock.timers.tick(10000);
assert.equal(loads, 4);
assert.equal(set.failed(), false);
});
test('a failed instance retries without reloading its healthy neighbour', async (t) => {
t.mock.method(console, 'warn', () => {});
t.mock.timers.enable({ apis: ['setTimeout'] });
let loads = 0;
let finish;
const { movers, root } = setup({
loadModel: async (options) => {
loads++;
if (loads === 1) throw new Error('503');
if (loads === 3) await new Promise((resolve) => (finish = resolve));
return { ...fakeModel(options), ready: true };
},
});
const set = movers.createSet({
id: 'fleet',
kind: 'model',
capacity: 2,
retryAfterMs: 20,
model: { url: '/a.glb' },
});
t.after(() => set.destroy());
set.set(0, { position: at(0, 0), pickId: 'a' });
set.set(1, { position: at(1, 0), pickId: 'b' });
set.setCount(2);
set.commit();
await new Promise(setImmediate);
const [collection] = items(root());
const [healthy] = items(collection);
assert.equal(set.failed(0), true);
assert.equal(set.ready(1), true);
t.mock.timers.tick(20);
assert.equal(
set.failed(0),
true,
'failure stays visible while retry is pending',
);
set.set(0, { position: at(2, 0), show: false });
set.commit();
finish();
await new Promise(setImmediate);
assert.equal(loads, 3);
assert.equal(set.ready(), true);
assert.equal(set.failed(), false);
assert.equal(collection.get(0), healthy);
assert.equal(collection.get(1).show, false);
assert.equal(collection.get(1).modelMatrix[12], at(2, 0).x);
set.set(0, { pickId: 'c' });
set.commit();
await new Promise(setImmediate);
assert.equal(loads, 4, 'a healthy index reloads for its new identity');
});
test('atlas retries apply the latest instances and cancel on destruction', async (t) => {
t.mock.method(console, 'warn', () => {});
t.mock.timers.enable({ apis: ['setTimeout'] });
let loads = 0;
const { movers, root } = setup({
cropCells: async (atlas, keys) => {
if (++loads < 3) throw new Error('503');
return new Map(keys.map((key) => [key, { width: 8, height: 8 }]));
},
});
const set = movers.createSet({
id: 'sprites',
kind: 'sprite',
capacity: 1,
retryAfterMs: 20,
sprite: { atlas, sizePx: 8 },
});
set.setCount(1);
set.commit();
await new Promise(setImmediate);
assert.equal(set.failed(), true);
for (let i = 0; i < 2; i++) {
t.mock.timers.tick(20);
await new Promise(setImmediate);
}
assert.equal(set.ready(), true);
assert.equal(set.failed(), false);
assert.equal(root().get(0).length, 1);
set.destroy();
const cancelled = movers.createSet({
id: 'cancelled',
kind: 'sprite',
capacity: 1,
sprite: { atlas, sizePx: 8 },
});
cancelled.destroy();
await new Promise(setImmediate);
t.mock.timers.tick(10000);
assert.equal(loads, 4);
});
// A layer removes an instance by moving its last one into the freed index
// (src/layers/shared/render/moverBank.js).
test('an instance moved into a failed index loads its own model', async (t) => {
t.mock.method(console, 'warn', () => {});
let loads = 0;
const { movers } = setup({
loadModel: async (options) => {
if (loads++ === 0) throw new Error('503');
return fakeModel(options);
},
});
const set = movers.createSet({
id: 'fleet',
kind: 'model',
capacity: 2,
model: { url: '/a.glb' },
});
set.set(0, { position: at(0, 0), pickId: 'a' });
set.set(1, { position: at(1, 0), pickId: 'b' });
set.setCount(2);
set.commit();
await settle();
assert.equal(set.failed(0), true);
assert.equal(set.failed(1), false);
// 'a' is removed: 'b' moves into index 0.
set.set(0, { position: at(1, 0), pickId: 'b' });
set.setCount(1);
assert.equal(set.failed(0), false, "'b' does not take the failure of 'a'");
assert.equal(set.failed(), false);
set.commit();
await settle();
assert.equal(loads, 3, 'index 0 loads again for its new instance');
assert.equal(set.failed(0), false);
set.destroy();
});
test('identify names the set, instance and pickId of a picked billboard or point', async () => {
const { movers, root, identify, viewer } = setup();
// The fake camera is over (0°, ~1°), 7000 km from the centre.
viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(0, 0, 7e5);
const set = movers.createSet({
id: 'sats',
kind: 'point',
capacity: 3,
point: { sizePx: 4 },
});
set.set(0, { position: at(0, 1) });
set.set(1, { position: at(1, 0), pickId: 25544 });
set.set(2, { position: at(180, 0), pickId: 'far' });
set.setCount(3);
set.commit();
const [, second, third] = items(items(root())[0]);
assert.deepEqual(identify({ primitive: second, id: 'stale' }), {
owner: 'sats',
instance: 1,
id: 25544,
pickable: false,
visible: true,
});
assert.equal(
identify({ primitive: third }).visible,
false,
'behind the horizon',
);
// The pickId the layer writes now, not the one Cesium holds.
set.set(1, { pickId: 'renamed' });
assert.equal(identify({ primitive: second }).id, 'renamed');
// An instance past the count is not identified.
set.setCount(1);
set.commit();
assert.equal(identify({ primitive: second }), null);
assert.equal(identify({ primitive: {}, id: 'x' }), null);
assert.equal(identify(undefined), null);
const sprites = movers.createSet({
id: 'flights',
kind: 'sprite',
capacity: 1,
pickable: true,
sprite: { atlas, sizePx: 8 },
});
sprites.set(0, { position: at(0, 0.5), pickId: 'ae01ce' });
sprites.setCount(1);
sprites.commit();
await settle();
const [billboard] = items(items(root())[1]);
assert.deepEqual(identify({ primitive: billboard }), {
owner: 'flights',
instance: 0,
id: 'ae01ce',
pickable: true,
visible: true,
});
set.destroy();
sprites.destroy();
assert.equal(identify({ primitive: billboard }), null);
});
test('primitiveOf maps a live set of this engine to its collection', () => {
const { movers, root, primitiveOf } = setup();
const set = movers.createSet({
id: 'a',
kind: 'point',
capacity: 1,
point: { sizePx: 2 },
});
assert.equal(primitiveOf(set), items(root())[0]);
assert.equal(primitiveOf({ id: 'a' }), null, 'a look-alike is not the set');
assert.equal(primitiveOf({ primitive: 1 }), null);
set.destroy();
assert.equal(primitiveOf(set), null);
});