mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +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
@@ -908,6 +908,10 @@
|
||||
"src/layers/streetLevel/view.test.mjs",
|
||||
"src/layers/streetLevel/providers/mapillary/provider.test.mjs",
|
||||
"src/layers/streetLevel/providers/mapillary/decode.test.mjs",
|
||||
"src/layers/streetLevel/providers/mapillary/coverage.test.mjs",
|
||||
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
|
||||
"src/layers/streetLevel/cameraFollow.test.mjs",
|
||||
"src/ui/streetLevelControls.test.mjs",
|
||||
"src/ui/panelDock.test.mjs",
|
||||
"src/layers/streetLevel/viewerHost.test.mjs",
|
||||
"src/layers/streetLevel/index.test.mjs",
|
||||
|
||||
@@ -1200,6 +1200,7 @@
|
||||
"src/layers/streetLevel/providers/mapillary/sequences.js",
|
||||
"src/layers/streetLevel/glyphs.js",
|
||||
"src/layers/streetLevel/providers/mapillary/decode.js",
|
||||
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
|
||||
"src/layers/streetLevel/tileMath.js",
|
||||
"src/layers/streetLevel/selection.js",
|
||||
"src/layers/streetLevel/providers/mapillary/viewer.js",
|
||||
@@ -2117,6 +2118,7 @@
|
||||
"src/layers/streetLevel/providers/mapillary/sequences.js",
|
||||
"src/layers/streetLevel/glyphs.js",
|
||||
"src/layers/streetLevel/providers/mapillary/decode.js",
|
||||
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
|
||||
"src/layers/streetLevel/tileMath.js",
|
||||
"src/layers/streetLevel/selection.js",
|
||||
"src/layers/streetLevel/providers/mapillary/viewer.js",
|
||||
@@ -2635,6 +2637,7 @@
|
||||
"src/layers/streetLevel/providers/mapillary/sequences.js",
|
||||
"src/layers/streetLevel/glyphs.js",
|
||||
"src/layers/streetLevel/providers/mapillary/decode.js",
|
||||
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
|
||||
"src/layers/streetLevel/tileMath.js",
|
||||
"src/layers/streetLevel/selection.js",
|
||||
"src/layers/streetLevel/providers/mapillary/viewer.js",
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createCameraFollow } from './cameraFollow.js';
|
||||
import { FOLLOW_EYE_HEIGHT_M } from './policy.js';
|
||||
|
||||
/** Height (m) of a Cartesian above the WGS84 ellipsoid. */
|
||||
const heightOf = (cartesian) =>
|
||||
Cesium.Cartographic.fromCartesian(cartesian).height;
|
||||
|
||||
/**
|
||||
* A viewer whose scene sample is `sampled` (the Austin case: −14,886 m before
|
||||
* the tiles under the photo had loaded) and whose bare earth is `dem`.
|
||||
*/
|
||||
function setup({ sampled, dem = null, globe = null, altitude = 149 }) {
|
||||
const flights = [];
|
||||
const views = [];
|
||||
const state = {
|
||||
services: {},
|
||||
street: {
|
||||
position: { lon: -97.7364, lat: 30.2672 },
|
||||
bearing: 105,
|
||||
tilt: -10,
|
||||
altitude,
|
||||
follow: false,
|
||||
followAvailable: true,
|
||||
},
|
||||
viewer: {
|
||||
scene: {
|
||||
sampleHeightSupported: true,
|
||||
sampleHeight: () => sampled,
|
||||
globe: { getHeight: () => globe },
|
||||
},
|
||||
camera: {
|
||||
flyToBoundingSphere: (sphere, options) =>
|
||||
flights.push({ sphere, options }),
|
||||
setView: (view) => views.push(view),
|
||||
},
|
||||
},
|
||||
};
|
||||
const parts = {
|
||||
groundCaster: dem === null ? null : { groundAt: () => dem, prepare() {} },
|
||||
};
|
||||
return {
|
||||
follow: createCameraFollow({ state, parts }),
|
||||
state,
|
||||
flights,
|
||||
views,
|
||||
};
|
||||
}
|
||||
|
||||
test('framing a photo ignores a sample kilometres underground and uses bare earth', () => {
|
||||
const { follow, flights } = setup({ sampled: -14_886, dem: 117 });
|
||||
follow.lookAtPosition();
|
||||
assert.equal(flights.length, 1);
|
||||
const centre = heightOf(flights[0].sphere.center);
|
||||
assert.ok(
|
||||
Math.abs(centre - 119) < 0.01,
|
||||
`centre at ${centre} m, not underground`,
|
||||
);
|
||||
});
|
||||
|
||||
test('a plausible mesh sample near bare earth wins (a street, or a modest roof)', () => {
|
||||
const { follow, flights } = setup({ sampled: 121, dem: 117 });
|
||||
follow.lookAtPosition();
|
||||
assert.ok(Math.abs(heightOf(flights[0].sphere.center) - 123) < 0.01);
|
||||
});
|
||||
|
||||
test('without bare earth an absurd sample falls back to the image altitude, never below the surface range', () => {
|
||||
const { follow, flights } = setup({
|
||||
sampled: -14_886,
|
||||
dem: null,
|
||||
globe: undefined,
|
||||
altitude: 149,
|
||||
});
|
||||
follow.lookAtPosition();
|
||||
assert.ok(Math.abs(heightOf(flights[0].sphere.center) - 151) < 0.01);
|
||||
});
|
||||
|
||||
test('following stands the camera at eye height above the checked ground', () => {
|
||||
const { follow, state, views } = setup({ sampled: -14_886, dem: 117 });
|
||||
state.street.follow = true;
|
||||
follow.followCamera();
|
||||
assert.equal(views.length, 1);
|
||||
assert.ok(
|
||||
Math.abs(heightOf(views[0].destination) - (117 + FOLLOW_EYE_HEIGHT_M)) <
|
||||
0.01,
|
||||
);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { PbfWriter } from 'pbf';
|
||||
|
||||
/**
|
||||
* Encode a Mapillary-shaped coverage tile (mly1_public: `sequence` lines and
|
||||
* `overview` points) from lon/lat features, for tests and the hermetic QA
|
||||
* gate. The decoder in ./decode.js reads it like a real tile.
|
||||
*/
|
||||
|
||||
const zigzag = (value) => (value << 1) ^ (value >> 31);
|
||||
|
||||
/** Tile-local [x, y] (0..extent) of a lon/lat inside tile (x, y, z). */
|
||||
export function lonLatToTileLocal([lon, lat], { x, y, z }, extent = 4096) {
|
||||
const n = 2 ** z;
|
||||
const rad = (lat * Math.PI) / 180;
|
||||
const tx = ((lon + 180) / 360) * n;
|
||||
const ty =
|
||||
((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * n;
|
||||
return [Math.round((tx - x) * extent), Math.round((ty - y) * extent)];
|
||||
}
|
||||
|
||||
/** One MVT layer writer with deduplicated keys and appended values. */
|
||||
function layerWriter(name, extent) {
|
||||
const keys = new Map();
|
||||
const values = [];
|
||||
const features = [];
|
||||
function tags(properties) {
|
||||
const out = [];
|
||||
for (const [key, value] of Object.entries(properties)) {
|
||||
if (value === undefined || value === null) continue;
|
||||
if (!keys.has(key)) keys.set(key, keys.size);
|
||||
out.push(keys.get(key), values.length);
|
||||
values.push(value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return {
|
||||
add(type, geometry, properties, id) {
|
||||
features.push({ type, geometry, tags: tags(properties), id });
|
||||
},
|
||||
write(pbf) {
|
||||
pbf.writeVarintField(15, 2);
|
||||
pbf.writeStringField(1, name);
|
||||
pbf.writeVarintField(5, extent);
|
||||
for (const feature of features)
|
||||
pbf.writeMessage(
|
||||
2,
|
||||
(f, out) => {
|
||||
if (Number.isInteger(f.id)) out.writeVarintField(1, f.id);
|
||||
out.writePackedVarint(2, f.tags);
|
||||
out.writeVarintField(3, f.type);
|
||||
out.writePackedVarint(4, f.geometry);
|
||||
},
|
||||
feature,
|
||||
);
|
||||
for (const key of keys.keys()) pbf.writeStringField(3, key);
|
||||
for (const value of values)
|
||||
pbf.writeMessage(
|
||||
4,
|
||||
(v, out) => {
|
||||
if (typeof v === 'boolean') out.writeBooleanField(7, v);
|
||||
else if (typeof v === 'number') out.writeVarintField(5, v);
|
||||
else out.writeStringField(1, String(v));
|
||||
},
|
||||
value,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** MoveTo/LineTo commands for line parts, with the cursor carried across parts. */
|
||||
function lineGeometry(parts) {
|
||||
const out = [];
|
||||
let cx = 0;
|
||||
let cy = 0;
|
||||
for (const part of parts) {
|
||||
if (part.length < 2) continue;
|
||||
const [x0, y0] = part[0];
|
||||
out.push(9, zigzag(x0 - cx), zigzag(y0 - cy));
|
||||
cx = x0;
|
||||
cy = y0;
|
||||
out.push(((part.length - 1) << 3) | 2);
|
||||
for (const [x, y] of part.slice(1)) {
|
||||
out.push(zigzag(x - cx), zigzag(y - cy));
|
||||
cx = x;
|
||||
cy = y;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{x: number, y: number, z: number}} tile
|
||||
* @param {{sequences?: Array<{id: string, capturedAt?: number, isPano?: boolean, parts: Array<Array<[number, number]>>}>, overview?: Array<{id: string, lon: number, lat: number, capturedAt?: number, isPano?: boolean}>, extent?: number}} [content]
|
||||
* @returns {Uint8Array}
|
||||
*/
|
||||
export function encodeCoverageTile(
|
||||
tile,
|
||||
{ sequences = [], overview = [], extent = 4096 } = {},
|
||||
) {
|
||||
const pbf = new PbfWriter();
|
||||
if (sequences.length) {
|
||||
const layer = layerWriter('sequence', extent);
|
||||
for (const sequence of sequences)
|
||||
layer.add(
|
||||
2,
|
||||
lineGeometry(
|
||||
sequence.parts.map((part) =>
|
||||
part.map((point) => lonLatToTileLocal(point, tile, extent)),
|
||||
),
|
||||
),
|
||||
{
|
||||
id: sequence.id,
|
||||
captured_at: sequence.capturedAt ?? Date.now(),
|
||||
is_pano: sequence.isPano === true,
|
||||
},
|
||||
);
|
||||
pbf.writeMessage(3, (_, out) => layer.write(out), null);
|
||||
}
|
||||
if (overview.length) {
|
||||
const layer = layerWriter('overview', extent);
|
||||
for (const point of overview) {
|
||||
const [px, py] = lonLatToTileLocal([point.lon, point.lat], tile, extent);
|
||||
layer.add(1, [9, zigzag(px), zigzag(py)], {
|
||||
id: point.id,
|
||||
captured_at: point.capturedAt ?? Date.now(),
|
||||
is_pano: point.isPano === true,
|
||||
});
|
||||
}
|
||||
pbf.writeMessage(3, (_, out) => layer.write(out), null);
|
||||
}
|
||||
return new Uint8Array(pbf.finish());
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { StreetLevelControls } from './streetLevelControls.js';
|
||||
import { createStreetLevelLayer } from '../layers/streetLevel/index.js';
|
||||
import { DataLayerManager } from '../data/manager.js';
|
||||
import { LayerStateCoordinator } from '../data/layerStateCoordinator.js';
|
||||
import {
|
||||
LAYER_STATE_REGISTRY,
|
||||
REGISTERED_LAYER_IDS,
|
||||
encodeLayerStateParams,
|
||||
} from '../data/layerState.js';
|
||||
|
||||
/* ── A small DOM: just what the panel controls touch ───────────────────── */
|
||||
|
||||
class FakeNode {
|
||||
constructor(document, tag, { id = null, dataset = {}, classes = [] } = {}) {
|
||||
this.listeners = new Map();
|
||||
this.ownerDocument = document;
|
||||
this.tagName = tag.toUpperCase();
|
||||
this.id = id;
|
||||
this.dataset = { ...dataset };
|
||||
this.children = [];
|
||||
this.parent = null;
|
||||
this.hidden = false;
|
||||
this.disabled = false;
|
||||
this.textContent = '';
|
||||
this.title = '';
|
||||
this.value = '';
|
||||
this.attributes = new Map();
|
||||
const names = new Set(classes);
|
||||
this.classList = {
|
||||
add: (name) => names.add(name),
|
||||
remove: (name) => names.delete(name),
|
||||
contains: (name) => names.has(name),
|
||||
toggle: (name, force) => {
|
||||
const on = force ?? !names.has(name);
|
||||
if (on) names.add(name);
|
||||
else names.delete(name);
|
||||
return on;
|
||||
},
|
||||
};
|
||||
const style = {};
|
||||
this.style = Object.assign(style, {
|
||||
setProperty: (key, value) => {
|
||||
style[key] = value;
|
||||
},
|
||||
removeProperty: (key) => {
|
||||
delete style[key];
|
||||
},
|
||||
});
|
||||
Object.defineProperty(this, 'className', {
|
||||
get: () => [...names].join(' '),
|
||||
set: (value) => {
|
||||
names.clear();
|
||||
for (const name of String(value).split(/\s+/).filter(Boolean))
|
||||
names.add(name);
|
||||
},
|
||||
});
|
||||
}
|
||||
get childElementCount() {
|
||||
return this.children.length;
|
||||
}
|
||||
setAttribute(key, value) {
|
||||
this.attributes.set(key, String(value));
|
||||
}
|
||||
getAttribute(key) {
|
||||
return this.attributes.get(key) ?? null;
|
||||
}
|
||||
appendChild(child) {
|
||||
return this.insertBefore(child, null);
|
||||
}
|
||||
insertBefore(child, reference) {
|
||||
child.remove();
|
||||
const index = reference
|
||||
? this.children.indexOf(reference)
|
||||
: this.children.length;
|
||||
this.children.splice(index < 0 ? this.children.length : index, 0, child);
|
||||
child.parent = this;
|
||||
return child;
|
||||
}
|
||||
append(...nodes) {
|
||||
for (const node of nodes) this.appendChild(node);
|
||||
}
|
||||
replaceChildren(...nodes) {
|
||||
for (const child of [...this.children]) child.remove();
|
||||
this.append(...nodes);
|
||||
}
|
||||
remove() {
|
||||
if (!this.parent) return;
|
||||
this.parent.children.splice(this.parent.children.indexOf(this), 1);
|
||||
this.parent = null;
|
||||
}
|
||||
contains(node) {
|
||||
for (let current = node; current; current = current.parent)
|
||||
if (current === this) return true;
|
||||
return false;
|
||||
}
|
||||
closest(selector) {
|
||||
const name = selector.replace(/^\./, '');
|
||||
for (let node = this; node; node = node.parent)
|
||||
if (node.classList?.contains(name)) return node;
|
||||
return null;
|
||||
}
|
||||
*walk() {
|
||||
for (const child of this.children) {
|
||||
yield child;
|
||||
yield* child.walk();
|
||||
}
|
||||
}
|
||||
querySelector(selector) {
|
||||
return this.querySelectorAll(selector)[0] || null;
|
||||
}
|
||||
querySelectorAll(selector) {
|
||||
const byId = /^#(.+)$/.exec(selector);
|
||||
const byData = /^\[data-([a-z-]+)\]$/.exec(selector);
|
||||
const key = byData?.[1].replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
||||
return [...this.walk()].filter((node) =>
|
||||
byId ? node.id === byId[1] : key ? key in node.dataset : false,
|
||||
);
|
||||
}
|
||||
addEventListener(type, listener, { signal } = {}) {
|
||||
if (signal?.aborted) return;
|
||||
if (!this.listeners.has(type)) this.listeners.set(type, new Set());
|
||||
this.listeners.get(type).add(listener);
|
||||
signal?.addEventListener('abort', () =>
|
||||
this.listeners.get(type)?.delete(listener),
|
||||
);
|
||||
}
|
||||
removeEventListener(type, listener) {
|
||||
this.listeners.get(type)?.delete(listener);
|
||||
}
|
||||
/** Deliver to this node, then bubble to its ancestors with the same target. */
|
||||
dispatchEvent(event) {
|
||||
const delivered = {
|
||||
type: event.type,
|
||||
target: this,
|
||||
bubbles: event.bubbles,
|
||||
};
|
||||
for (let node = this; node; node = delivered.bubbles ? node.parent : null)
|
||||
for (const listener of [...(node.listeners.get(event.type) || [])])
|
||||
listener(delivered);
|
||||
return true;
|
||||
}
|
||||
click() {
|
||||
this.dispatchEvent({ type: 'click', bubbles: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** The Street Level panel's body, as layer-panels.html lays it out. */
|
||||
function panelDom() {
|
||||
const document = {
|
||||
activeElement: null,
|
||||
body: null,
|
||||
createElement: (tag) => new FakeNode(document, tag),
|
||||
};
|
||||
document.body = new FakeNode(document, 'body');
|
||||
const root = new FakeNode(document, 'section', { id: 'street-level-panel' });
|
||||
const add = (parent, tag, options) =>
|
||||
parent.appendChild(new FakeNode(document, tag, options));
|
||||
for (const id of [
|
||||
'sl-status',
|
||||
'sl-provider-chips',
|
||||
'sl-error',
|
||||
'sl-error-text',
|
||||
'sl-since',
|
||||
'sl-since-label',
|
||||
'sl-legend',
|
||||
'sl-follow-btn',
|
||||
'sl-viewer-expand',
|
||||
'sl-viewer-close',
|
||||
'sl-viewer-placeholder',
|
||||
'sl-image-by',
|
||||
'sl-image-when',
|
||||
'sl-image-link',
|
||||
'sl-coverage-meta',
|
||||
])
|
||||
add(root, id === 'sl-legend' ? 'ul' : 'div', { id });
|
||||
const controls = add(root, 'fieldset', { id: 'sl-controls' });
|
||||
for (const pano of ['all', 'pano', 'flat'])
|
||||
add(controls, 'button', { dataset: { slPano: pano } });
|
||||
const wrap = add(root, 'div', { id: 'sl-viewer-wrap' });
|
||||
add(wrap, 'div', { id: 'sl-viewer' });
|
||||
return { document, root };
|
||||
}
|
||||
|
||||
/** Run `fn` with the fake document and an immediate animation frame. */
|
||||
async function withDom(fn) {
|
||||
const saved = {
|
||||
document: globalThis.document,
|
||||
requestAnimationFrame: globalThis.requestAnimationFrame,
|
||||
};
|
||||
const dom = panelDom();
|
||||
globalThis.document = dom.document;
|
||||
globalThis.requestAnimationFrame = (task) => setTimeout(task, 0);
|
||||
try {
|
||||
return await fn(dom);
|
||||
} finally {
|
||||
globalThis.document = saved.document;
|
||||
globalThis.requestAnimationFrame = saved.requestAnimationFrame;
|
||||
}
|
||||
}
|
||||
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
/* ── A Street Level layer with one stand-in provider ───────────────────── */
|
||||
|
||||
function standInProvider() {
|
||||
const filters = [];
|
||||
return {
|
||||
filters,
|
||||
def: {
|
||||
id: 'mapillary',
|
||||
name: 'Mapillary',
|
||||
label: 'MAPILLARY',
|
||||
requiresKeyId: null,
|
||||
pickPrefix: 'mly:',
|
||||
colors: { coverage: '#05cb63' },
|
||||
credit: { key: 'mapillary', html: 'Mapillary' },
|
||||
capabilities: { coverage: 'tiles', sequences: true, pano: true },
|
||||
externalUrl: (id) => `https://example.test/${id}`,
|
||||
create: () => ({
|
||||
status: async () => ({ configured: true }),
|
||||
init() {},
|
||||
activate() {},
|
||||
deactivate() {},
|
||||
destroy() {},
|
||||
refreshCoverage() {},
|
||||
setFilter: (filter) => filters.push(filter),
|
||||
coverageStats: () => ({
|
||||
count: 0,
|
||||
zoom: null,
|
||||
kind: null,
|
||||
loading: false,
|
||||
hint: '',
|
||||
error: null,
|
||||
keyRequired: false,
|
||||
}),
|
||||
handlePick: () => false,
|
||||
nearestImage: async () => null,
|
||||
viewer: {
|
||||
mount: async () => {},
|
||||
open: async () => {},
|
||||
close() {},
|
||||
unmount() {},
|
||||
resize() {},
|
||||
onPose: () => () => {},
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Every other registered layer, as a module with no options. */
|
||||
function plainLayer(id) {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
icon: '',
|
||||
source: 'test',
|
||||
async init() {
|
||||
return true;
|
||||
},
|
||||
async enable() {
|
||||
return true;
|
||||
},
|
||||
async update() {
|
||||
return true;
|
||||
},
|
||||
async disable() {
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const memoryStorage = () => {
|
||||
const values = new Map();
|
||||
return {
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* The production path a panel click takes: controls → dataManager
|
||||
* .setLayerParams → layer.setParams → LayerStateCoordinator's durable state,
|
||||
* which is what share links and saved state encode.
|
||||
*/
|
||||
async function productionPanel(dom) {
|
||||
const provider = standInProvider();
|
||||
const layer = createStreetLevelLayer({ providers: [provider.def] });
|
||||
const manager = new DataLayerManager({});
|
||||
for (const id of REGISTERED_LAYER_IDS)
|
||||
manager.register(id === 'street-level' ? layer : plainLayer(id));
|
||||
manager.finalizeRegistrations(LAYER_STATE_REGISTRY);
|
||||
const coordinator = new LayerStateCoordinator(
|
||||
manager,
|
||||
{
|
||||
setLayerStateProvider() {},
|
||||
onLayerStateChange() {},
|
||||
},
|
||||
{ storage: memoryStorage() },
|
||||
);
|
||||
await coordinator.start();
|
||||
const controls = new StreetLevelControls({
|
||||
root: dom.root,
|
||||
layer,
|
||||
actions: {
|
||||
isEnabled: () => manager.isEnabled('street-level'),
|
||||
setEnabled: (on) =>
|
||||
manager.setEnabled('street-level', on, { origin: 'user' }),
|
||||
setParams: (params, options) =>
|
||||
manager.setLayerParams('street-level', params, options),
|
||||
setPanelCollapsed() {},
|
||||
dockPanel() {},
|
||||
showToast() {},
|
||||
},
|
||||
});
|
||||
controls.connect();
|
||||
const share = () => {
|
||||
const params = new URLSearchParams([['v', '2']]);
|
||||
encodeLayerStateParams(params, coordinator.getDurableState());
|
||||
return (params.get('lo') || '').split('_');
|
||||
};
|
||||
return { provider, layer, manager, coordinator, controls, share };
|
||||
}
|
||||
|
||||
const TOKEN = LAYER_STATE_REGISTRY.find(
|
||||
(entry) => entry.id === 'street-level',
|
||||
).token;
|
||||
|
||||
test('a 360° click reaches the share link through the data manager', () =>
|
||||
withDom(async (dom) => {
|
||||
const { provider, coordinator, controls, share } =
|
||||
await productionPanel(dom);
|
||||
dom.root.querySelectorAll('[data-sl-pano]')[1].click(); // 360°
|
||||
await settle();
|
||||
assert.equal(provider.filters.at(-1).pano, 'pano', 'the layer applied it');
|
||||
assert.equal(
|
||||
coordinator.getDurableState().options['street-level'].pano,
|
||||
'pano',
|
||||
'durable state recorded it',
|
||||
);
|
||||
assert.ok(share().includes(`${TOKEN}.p.p`), `share link: ${share()}`);
|
||||
controls.destroy?.();
|
||||
coordinator.destroy();
|
||||
}));
|
||||
|
||||
test('releasing the SINCE slider records the window in the share link', () =>
|
||||
withDom(async (dom) => {
|
||||
const { coordinator, controls, share } = await productionPanel(dom);
|
||||
const since = dom.root.querySelector('#sl-since');
|
||||
since.value = '5'; // the "last year" stop
|
||||
since.dispatchEvent(new Event('change'));
|
||||
await settle();
|
||||
const days =
|
||||
coordinator.getDurableState().options['street-level'].sinceDays;
|
||||
assert.ok(days > 0, 'a window was recorded');
|
||||
assert.ok(share().includes(`${TOKEN}.s.${days}`), `share link: ${share()}`);
|
||||
controls.destroy?.();
|
||||
coordinator.destroy();
|
||||
}));
|
||||
|
||||
/* ── Chip rules and render behaviour, against a stand-in layer ─────────── */
|
||||
|
||||
function stubLayer() {
|
||||
const listeners = new Set();
|
||||
let state = null;
|
||||
const calls = { resize: 0 };
|
||||
return {
|
||||
calls,
|
||||
publish(next) {
|
||||
state = next;
|
||||
for (const listener of listeners) listener(state);
|
||||
},
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
getUIState: () => state,
|
||||
resizeViewer() {
|
||||
calls.resize++;
|
||||
},
|
||||
attachViewerHost() {},
|
||||
};
|
||||
}
|
||||
|
||||
function uiState({ enabled = true, on = true, open = false, legend } = {}) {
|
||||
return {
|
||||
enabled,
|
||||
keyRequired: false,
|
||||
filter: { pano: 'all', sinceDays: 0 },
|
||||
providers: [
|
||||
{
|
||||
id: 'mapillary',
|
||||
name: 'Mapillary',
|
||||
label: 'MAPILLARY',
|
||||
color: '#05cb63',
|
||||
on,
|
||||
configured: true,
|
||||
keyRequired: false,
|
||||
requiresKeyId: null,
|
||||
loading: false,
|
||||
count: 3,
|
||||
hint: '',
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
coverage: { count: 3, loading: false, hint: '', error: null },
|
||||
legend: legend || [
|
||||
{ key: 'mapillary', label: 'Mapillary', color: '#05cb63' },
|
||||
{ key: 'selected', label: 'Selected', color: '#00d4ff' },
|
||||
],
|
||||
sequence: { providerId: null, selectedId: null, images: 0, loading: false },
|
||||
street: {
|
||||
open,
|
||||
loading: false,
|
||||
follow: false,
|
||||
followAvailable: false,
|
||||
renderMode: 'letterbox',
|
||||
providerId: open ? 'mapillary' : null,
|
||||
imageId: open ? 'img-1' : null,
|
||||
error: null,
|
||||
},
|
||||
surface: 'draped',
|
||||
};
|
||||
}
|
||||
|
||||
function stubPanel(dom, state) {
|
||||
const layer = stubLayer();
|
||||
const calls = { setParams: [], setEnabled: [] };
|
||||
let enabled = state.enabled;
|
||||
layer.publish(state);
|
||||
const controls = new StreetLevelControls({
|
||||
root: dom.root,
|
||||
layer,
|
||||
actions: {
|
||||
isEnabled: () => enabled,
|
||||
setEnabled: async (on) => {
|
||||
calls.setEnabled.push(on);
|
||||
enabled = on;
|
||||
},
|
||||
setParams: (params, options) => calls.setParams.push([params, options]),
|
||||
setPanelCollapsed() {},
|
||||
dockPanel() {},
|
||||
showToast() {},
|
||||
},
|
||||
});
|
||||
controls.connect();
|
||||
const chip = () => dom.root.querySelector('#sl-provider-chips').children[0];
|
||||
return { layer, calls, controls, chip };
|
||||
}
|
||||
|
||||
test('darkening the only lit chip turns the layer off and keeps the provider switched on', () =>
|
||||
withDom(async (dom) => {
|
||||
const { calls, chip } = stubPanel(dom, uiState());
|
||||
assert.equal(chip().dataset.chipId, 'mapillary');
|
||||
chip().click();
|
||||
await settle();
|
||||
assert.deepEqual(calls.setEnabled, [false]);
|
||||
assert.deepEqual(calls.setParams, [], 'the provider switch is untouched');
|
||||
}));
|
||||
|
||||
test('lighting a dark chip switches the provider on as a user params request, then the layer', () =>
|
||||
withDom(async (dom) => {
|
||||
const { calls, chip } = stubPanel(
|
||||
dom,
|
||||
uiState({ enabled: false, on: false }),
|
||||
);
|
||||
chip().click();
|
||||
await settle();
|
||||
assert.deepEqual(calls.setParams, [
|
||||
[{ mapillary: true }, { origin: 'user' }],
|
||||
]);
|
||||
assert.deepEqual(calls.setEnabled, [true]);
|
||||
}));
|
||||
|
||||
test('the legend rebuilds when a swatch changes, even at the same count (review #9)', () =>
|
||||
withDom(async (dom) => {
|
||||
const { layer } = stubPanel(dom, uiState());
|
||||
const legend = dom.root.querySelector('#sl-legend');
|
||||
const swatch = () => legend.children[0].children[0].style.background;
|
||||
assert.equal(swatch(), '#05cb63');
|
||||
layer.publish(
|
||||
uiState({
|
||||
legend: [
|
||||
{ key: 'panoramax', label: 'Panoramax', color: '#a66bff' },
|
||||
{ key: 'selected', label: 'Selected', color: '#00d4ff' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
assert.equal(legend.childElementCount, 2);
|
||||
assert.equal(swatch(), '#a66bff');
|
||||
}));
|
||||
|
||||
test('the viewer is resized when it opens, not on every render (review #8)', () =>
|
||||
withDom(async (dom) => {
|
||||
const { layer } = stubPanel(dom, uiState());
|
||||
assert.equal(layer.calls.resize, 0);
|
||||
layer.publish(uiState({ open: true }));
|
||||
assert.equal(layer.calls.resize, 1, 'once on open');
|
||||
for (let i = 0; i < 5; i++) layer.publish(uiState({ open: true }));
|
||||
assert.equal(layer.calls.resize, 1, 'not again while it stays open');
|
||||
}));
|
||||
Reference in new issue
Block a user