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:
Said TurkseverandClaude Opus 5.5 committed 2026-10-04 23:50:21 +01:00
1 parent 5a7ce5785e
commit 7ae5f52c89
6 files changed
+974

No files matched your search

+4
View File
@@ -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",
+3
View File
@@ -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());
}
+503
View File
@@ -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');
}));