mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
test(street-level): coverage race, camera framing and panel-control tests
From the test-engineering review of #768: the rendering and panel code had thin unit coverage (coverage.js 29% of lines, sequences/viewer/selection ~30%, cameraFollow 50%, streetLevelControls only source-text checks), and two shipped bugs were caught only by the manual browser gate. - coverage.test.mjs drives the real createCoverage against a fake scene and a source whose tile requests the test resolves. It reproduces gekh's P0 #2: a request superseded by retire() finishes after a newer one for the same tile. Without the ownership checks it drops the newer request's key and a refresh starts a duplicate whose lines nothing can remove (fails on the old loadTile, passes now). It also covers the draped-to-cast swap keeping both line sets on the globe until the cast one is built and clear() removing both mid-swap, and a tile with nothing castable not being rebuilt. - coverageFixture.mjs encodes Mapillary-shaped coverage tiles (sequence lines, overview points) from lon/lat; the tests and the hermetic gate use it. - cameraFollow.test.mjs pins the underground-camera fix: a -14,886 m sample is ignored for bare earth, a plausible sample wins, and following stands at eye height. Three of the four fail on the pre-fix module (the first reports "centre at -14884 m"). - streetLevelControls.test.mjs is a contract test through the real DataLayerManager and LayerStateCoordinator: a 360 deg click and a SINCE release reach the durable layer state and the encoded share link under Street Level's token. Against a stand-in layer it also covers the chip rule (darkening the last lit chip turns the layer off, provider kept on; lighting a dark chip is a user params request, then enable), the legend rebuilding on a same-count swap (review #9) and the viewer resizing only on open (review #8). With the old direct-to-layer routing, three of these fail, including both share-link tests. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
5a7ce5785e
commit
7ae5f52c89
6 files changed
+974
No files matched your search
@@ -0,0 +1,243 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createCoverage } from './coverage.js';
|
||||
import { encodeCoverageTile } from './coverageFixture.mjs';
|
||||
import { lonToTileX, latToTileY, tileBounds } from '../../tileMath.js';
|
||||
|
||||
const RAD = Math.PI / 180;
|
||||
// Node has no WebGL context to report line-width limits; a browser's
|
||||
// context sets these on startup, and appearances validate against them.
|
||||
Cesium.ContextLimits._minimumAliasedLineWidth = 1;
|
||||
Cesium.ContextLimits._maximumAliasedLineWidth = 10;
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
/** A scene collection that remembers what is on the globe. */
|
||||
function collection() {
|
||||
const items = new Set();
|
||||
return {
|
||||
items,
|
||||
add(primitive) {
|
||||
items.add(primitive);
|
||||
return primitive;
|
||||
},
|
||||
remove(primitive) {
|
||||
return items.delete(primitive);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The centre of the z14 tile holding a point, so a small view is one tile. */
|
||||
function tileCentre(lon, lat) {
|
||||
const x = lonToTileX(lon, 14);
|
||||
const y = latToTileY(lat, 14);
|
||||
const { west, east, south, north } = tileBounds(x, y, 14);
|
||||
return {
|
||||
tile: { x, y, z: 14 },
|
||||
lon: (west + east) / 2,
|
||||
lat: (south + north) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
/** A viewer looking straight down on a 0.004° square around `view`. */
|
||||
function fakeViewer(view) {
|
||||
const postRender = new Set();
|
||||
return {
|
||||
view,
|
||||
postRender,
|
||||
scene: {
|
||||
canvas: { clientWidth: 100, clientHeight: 100 },
|
||||
globe: { show: false, ellipsoid: Cesium.Ellipsoid.WGS84 },
|
||||
groundPrimitives: collection(),
|
||||
primitives: collection(),
|
||||
postRender: {
|
||||
addEventListener(listener) {
|
||||
postRender.add(listener);
|
||||
return () => postRender.delete(listener);
|
||||
},
|
||||
},
|
||||
},
|
||||
camera: {
|
||||
get positionCartographic() {
|
||||
return {
|
||||
longitude: view.lon * RAD,
|
||||
latitude: view.lat * RAD,
|
||||
height: view.height,
|
||||
};
|
||||
},
|
||||
pickEllipsoid(point) {
|
||||
return Cesium.Cartesian3.fromDegrees(
|
||||
view.lon - 0.002 + (point.x / 100) * 0.004,
|
||||
view.lat + 0.002 - (point.y / 100) * 0.004,
|
||||
);
|
||||
},
|
||||
computeViewRectangle: () => undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A source whose tile requests the test resolves, one call at a time. */
|
||||
function deferredSource() {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
getTile(layer, z, x, y, { signal } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
calls.push({ key: `${z}/${x}/${y}`, z, signal, resolve, reject });
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setup({ surface = 'draped', groundCaster = null } = {}) {
|
||||
const centre = tileCentre(-121.4944, 38.5816);
|
||||
const viewer = fakeViewer({ lon: centre.lon, lat: centre.lat, height: 900 });
|
||||
const source = deferredSource();
|
||||
const state = {
|
||||
viewer,
|
||||
services: {},
|
||||
filter: { pano: 'all', sinceMs: null },
|
||||
keyRequired: false,
|
||||
context: {
|
||||
isActive: () => true,
|
||||
notify() {},
|
||||
getSurface: () => surface,
|
||||
groundCaster,
|
||||
meshSampler: null,
|
||||
},
|
||||
coverage: {
|
||||
zoom: null,
|
||||
kind: null,
|
||||
tiles: new Map(),
|
||||
stale: new Map(),
|
||||
staleTimer: null,
|
||||
pending: new Map(),
|
||||
loading: 0,
|
||||
lastError: null,
|
||||
debounceTimer: null,
|
||||
removeCameraListener: null,
|
||||
// Skip Cesium's one-time terrain table download.
|
||||
terrainReady: Promise.resolve(),
|
||||
hint: '',
|
||||
},
|
||||
sequence: { selectedId: null },
|
||||
};
|
||||
const coverage = createCoverage({ state, source });
|
||||
const bytes = encodeCoverageTile(centre.tile, {
|
||||
sequences: [
|
||||
{
|
||||
id: 'seq-1',
|
||||
parts: [
|
||||
[
|
||||
[centre.lon - 0.001, centre.lat],
|
||||
[centre.lon + 0.001, centre.lat],
|
||||
],
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const tileKey = `14/${centre.tile.x}/${centre.tile.y}`;
|
||||
const onGlobe = () =>
|
||||
viewer.scene.groundPrimitives.items.size +
|
||||
viewer.scene.primitives.items.size;
|
||||
return { viewer, source, state, coverage, bytes, tileKey, onGlobe };
|
||||
}
|
||||
|
||||
test('a superseded tile request cannot strand lines on the globe (review P0 #2)', async () => {
|
||||
const { viewer, source, state, coverage, bytes, tileKey, onGlobe } = setup();
|
||||
const forTile = () => source.calls.filter((call) => call.key === tileKey);
|
||||
|
||||
coverage.refresh(); // street zoom: request #1 for the tile
|
||||
assert.equal(forTile().length, 1);
|
||||
viewer.view.height = 100_000; // zoom out: retire() drops request #1
|
||||
coverage.refresh();
|
||||
viewer.view.height = 900; // back in before #1 settles: request #2
|
||||
coverage.refresh();
|
||||
assert.equal(forTile().length, 2);
|
||||
await settle();
|
||||
|
||||
// The superseded request finishes late. It must not settle request #2's
|
||||
// key, or the next refresh would start a duplicate load.
|
||||
forTile()[0].resolve(bytes);
|
||||
await settle();
|
||||
assert.equal(
|
||||
state.coverage.pending.has(tileKey),
|
||||
true,
|
||||
'#2 still owns the key',
|
||||
);
|
||||
assert.equal(state.coverage.loading, 1);
|
||||
coverage.refresh();
|
||||
assert.equal(forTile().length, 2, 'no duplicate request');
|
||||
|
||||
forTile()[1].resolve(bytes);
|
||||
await settle();
|
||||
assert.equal(state.coverage.tiles.size, 1);
|
||||
assert.ok(onGlobe() > 0, 'the tile drew its lines');
|
||||
|
||||
// Everything drawn can be removed: nothing is orphaned.
|
||||
coverage.clear();
|
||||
assert.equal(onGlobe(), 0);
|
||||
});
|
||||
|
||||
test('in terrain mode a tile draws draped, then swaps to cast lines without a blank frame', async () => {
|
||||
let heightsReady = false;
|
||||
const groundCaster = {
|
||||
prepareLines: async () => {
|
||||
heightsReady = true;
|
||||
return true;
|
||||
},
|
||||
castLine: (coords) =>
|
||||
heightsReady ? coords.flatMap(([lon, lat]) => [lon, lat, 30]) : null,
|
||||
};
|
||||
const { viewer, source, coverage, bytes, onGlobe } = setup({
|
||||
surface: 'terrain',
|
||||
groundCaster,
|
||||
});
|
||||
coverage.refresh();
|
||||
source.calls[0].resolve(bytes);
|
||||
await settle();
|
||||
await settle();
|
||||
const { groundPrimitives, primitives } = viewer.scene;
|
||||
// Both are on the globe while the cast lines build: no blink.
|
||||
assert.equal(groundPrimitives.items.size, 1, 'draped lines kept');
|
||||
assert.equal(primitives.items.size, 1, 'cast lines added');
|
||||
assert.ok(
|
||||
[...primitives.items][0] instanceof Cesium.Primitive,
|
||||
'cast lines are plain polylines at terrain height',
|
||||
);
|
||||
// Leaving mid-swap removes both sets, not just the new one.
|
||||
coverage.clear();
|
||||
assert.equal(onGlobe(), 0);
|
||||
assert.equal(viewer.postRender.size, 0, 'the swap listener is gone');
|
||||
});
|
||||
|
||||
test('a tile whose lines cannot be cast is not rebuilt for nothing', async () => {
|
||||
let prepares = 0;
|
||||
const groundCaster = {
|
||||
prepareLines: async () => {
|
||||
prepares++;
|
||||
return false; // terrain proxy down, or tile too big
|
||||
},
|
||||
castLine: () => null,
|
||||
};
|
||||
const { viewer, source, coverage, bytes } = setup({
|
||||
surface: 'terrain',
|
||||
groundCaster,
|
||||
});
|
||||
const added = [];
|
||||
const add = viewer.scene.groundPrimitives.add.bind(
|
||||
viewer.scene.groundPrimitives,
|
||||
);
|
||||
viewer.scene.groundPrimitives.add = (primitive) => {
|
||||
added.push(primitive);
|
||||
return add(primitive);
|
||||
};
|
||||
coverage.refresh();
|
||||
source.calls[0].resolve(bytes);
|
||||
await settle();
|
||||
await settle();
|
||||
assert.equal(prepares, 1);
|
||||
assert.equal(added.length, 1, 'drawn once, draped; no second draped build');
|
||||
assert.equal(viewer.scene.primitives.items.size, 0);
|
||||
coverage.clear();
|
||||
});
|
||||
Reference in new issue
Block a user