From 7ae5f52c89faed10918e746078b5bed23dc93ee3 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Sun, 4 Oct 2026 23:50:21 +0100 Subject: [PATCH] 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 --- scripts/format-scope.json | 4 + scripts/package-boundaries.json | 3 + src/layers/streetLevel/cameraFollow.test.mjs | 89 ++++ .../providers/mapillary/coverage.test.mjs | 243 +++++++++ .../providers/mapillary/coverageFixture.mjs | 132 +++++ src/ui/streetLevelControls.test.mjs | 503 ++++++++++++++++++ 6 files changed, 974 insertions(+) create mode 100644 src/layers/streetLevel/cameraFollow.test.mjs create mode 100644 src/layers/streetLevel/providers/mapillary/coverage.test.mjs create mode 100644 src/layers/streetLevel/providers/mapillary/coverageFixture.mjs create mode 100644 src/ui/streetLevelControls.test.mjs diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 2de04c907..ac73d6404 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index ab2bc270f..3555829bb 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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", diff --git a/src/layers/streetLevel/cameraFollow.test.mjs b/src/layers/streetLevel/cameraFollow.test.mjs new file mode 100644 index 000000000..ccc162a21 --- /dev/null +++ b/src/layers/streetLevel/cameraFollow.test.mjs @@ -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, + ); +}); diff --git a/src/layers/streetLevel/providers/mapillary/coverage.test.mjs b/src/layers/streetLevel/providers/mapillary/coverage.test.mjs new file mode 100644 index 000000000..6f88028be --- /dev/null +++ b/src/layers/streetLevel/providers/mapillary/coverage.test.mjs @@ -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(); +}); diff --git a/src/layers/streetLevel/providers/mapillary/coverageFixture.mjs b/src/layers/streetLevel/providers/mapillary/coverageFixture.mjs new file mode 100644 index 000000000..eaa1f63a9 --- /dev/null +++ b/src/layers/streetLevel/providers/mapillary/coverageFixture.mjs @@ -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>}>, 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()); +} diff --git a/src/ui/streetLevelControls.test.mjs b/src/ui/streetLevelControls.test.mjs new file mode 100644 index 000000000..1d65ee17c --- /dev/null +++ b/src/ui/streetLevelControls.test.mjs @@ -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'); + }));