From ad58a0bdc4c576846ff820c48c4a4cb5a94dd896 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 7 Oct 2026 22:41:29 +0400 Subject: [PATCH] refactor(street-level): trim QA, tests, styles and docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browser gate is one pass over the remaining flows: rail strip and expand, coverage with fixtures, the ON/OFF pill, 360° and SINCE filters, KEY REJECTED, nearest photo with caption, × close, phone fit, keyless KEY REQUIRED on a second page and no page errors. Panel controls are pressed through the DOM; pick-point search, line/cone clicks, the framing-cancel, reload/share and status-route probes, the second viewport and pointer-settling retries are gone (unit tests cover them). Tests that pinned constants, copy, CSS or source text, or repeated another test, are removed or merged. CSS loses an unused variable and duplicate rules. Docs describe the slimmed feature; unused exports and the layer's dead setCoverageFilter/refreshCoverage surface are removed. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 20 +- DATA_SOURCES.md | 2 +- README.md | 2 +- docs/CURRENT-STATE.md | 76 +- .../fixtures/street-level/mapillaryGraph.mjs | 4 +- scripts/qa-browserEvidence.mjs | 4 +- scripts/qa-street-level.mjs | 1616 +++++------------ src/layers/streetLevel/index.js | 3 - src/layers/streetLevel/index.test.mjs | 49 +- .../providers/mapillary/coverage.js | 2 +- .../providers/mapillary/coverageFixture.mjs | 2 +- .../providers/mapillary/decode.test.mjs | 6 +- .../providers/mapillary/mapillary.test.mjs | 5 +- .../providers/mapillary/sequences.js | 2 +- .../providers/mapillary/source.test.mjs | 6 - src/layers/streetLevel/tileMath.test.mjs | 7 - src/layers/streetLevel/view.js | 2 +- src/layers/streetLevel/viewerHost.test.mjs | 39 - src/streetLevelMarkup.test.mjs | 128 +- src/tooling/streetLevelQa.test.mjs | 142 +- src/ui/streetLevelControls.test.mjs | 19 - src/ui/streetLevelPresentation.test.mjs | 24 +- src/ui/styles/street-level.css | 67 +- 23 files changed, 534 insertions(+), 1693 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a97c3368..8475b202c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,17 +3,15 @@ ## [Unreleased] - Street Level: Mapillary street-level imagery modelled on the iD editor's - photo overlay (free client token, CC BY-SA 4.0 imagery). One right-rail panel - holds an ON/OFF pill, 360°/flat and captured-since filters, a legend, and an - embedded viewer with full-screen EXPAND and FIT/FILL. Coverage lines (below - 60 km; from orbit the panel says to zoom in) and image cones are draped on - the terrain and on Google 3D tiles; MapillaryJS loads on the first photo, - and opening one frames it with one camera flight. - Share links carry the Mapillary switch, the filters and the panel's collapsed - state. A server proxy adds the token to Mapillary coverage tiles, drops - their unused image layer and keeps them in memory (24 h); it refuses - cross-site requests and rate-limits per IP, and the panel stops asking when - Mapillary rejects the token or rate-limits. + photo overlay (free client token, CC BY-SA 4.0 imagery). A docked right-rail + panel holds an ON/OFF pill, 360°/flat and captured-since filters, a legend + and an embedded viewer with full-screen EXPAND and FIT/FILL. Coverage lines + (below 60 km) and image cones drape on the terrain and Google 3D tiles; + opening a photo frames it with one camera flight. Share links carry the + switch, the filters and the panel's collapsed state. A server proxy keeps + the token server-side, trims coverage tiles and caches them in memory for + 24 h; it refuses cross-site requests and rate-limits per IP. The panel reads + KEY REQUIRED without a token and KEY REJECTED when Mapillary refuses it. - Add Norway's road cameras from Statens vegvesen as a CCTV pack. The camera list is one keyless GeoJSON request to the agency's OGC API view of its diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 0b8eac358..9a667c8aa 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -20,7 +20,7 @@ How to read this: | **NOAA nowCOAST observed weather** (`nowcoast.noaa.gov/geoserver/observations/{weather_radar,satellite}/ows`, WMS 1.1.1) | Rain radar (MRMS base reflectivity, contiguous US, about 4-minute updates) and Satellite clouds (GOES-19/18 Band 14 regional infrared, about 5-minute updates; global infrared mosaic, hourly). Keyless; layer metadata refreshed every 2 minutes, exact-time images cached for up to 24 hours | [NOAA disclaimer](https://oceanservice.noaa.gov/disclaimer.html) | "NOAA nowCOAST · NWS/OAR MRMS radar; NESDIS GOES and global satellite partners" | | **NOAA nowCOAST lightning density** (`nowcoast.noaa.gov/geoserver/observations/lightning_detection/ows`) | Lightning density: 15-minute accumulated strike density on an approximately 8 km grid, Americas and Pacific. Keyless; layer metadata refreshed every 10 minutes, exact-time images cached for up to 24 hours | NOAA Level-5 derived product from Vaisala NLDN/GLD360; the [product description](https://ocean.weather.gov/lightning/lightning_pdd.php) permits public distribution of this density product, not of raw Vaisala detections | "NOAA/NWS nowCOAST; derived from Vaisala NLDN/GLD360" | | **NOAA NHC / CPHC advisories** (`www.nhc.noaa.gov/CurrentStorms.json` + the `mapservices.weather.noaa.gov` tropical weather summary MapServer) | Cyclone advisories: storm positions, forecast tracks, points and cones for the Atlantic and eastern/central North Pacific. Keyless through `/api/cyclones`; cached for 5 minutes | [NWS public-data terms](https://www.weather.gov/disclaimer); no endorsement implied | "NOAA/NWS NHC / CPHC" | -| **Mapillary** (vector tiles, Graph API, MapillaryJS) | Street Level: coverage lines, image cones and the embedded viewer. Coverage tiles cached for 24 h | Imagery [CC BY-SA 4.0](https://www.mapillary.com/terms); API under the Mapillary terms with a client token | "© Mapillary contributors, CC BY-SA 4.0", shown on the globe while the provider is on | +| **Mapillary** (vector tiles, Graph API, MapillaryJS) | Street Level: coverage lines, image cones and the embedded viewer. Coverage tiles kept in server memory for 24 h | Imagery [CC BY-SA 4.0](https://www.mapillary.com/terms); API under the Mapillary terms with a client token | "© Mapillary contributors, CC BY-SA 4.0", shown on the globe while the layer is on | | **OpenStreetMap** | Map and place data (roads, military areas and names, ALPR, place search, outlines); via vector tiles, hourly ALPR extract, bundled data, Nominatim and optional self-configured Overpass | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | © OpenStreetMap contributors | | **Google Map Tiles API** (Photorealistic 3D Tiles) + Places/Geocoding | The 3D globe, voice scene context, and on-demand nearby installation search | Google Maps Platform ToS (proprietary, your own key + billing) | "Google" / "Google Maps" logo — **shown in-app**, required | | **OpenSky Network** | Primary worldwide live-flight snapshot | Non-commercial research/education license | Schäfer et al., _"Bringing Up OpenSky"_, IPSN 2014 + opensky-network.org | diff --git a/README.md b/README.md index 6a976c7a5..d4534755c 100644 --- a/README.md +++ b/README.md @@ -296,7 +296,7 @@ Twenty layers and map sources. **Seventeen have a keyless path.** Some offer add | 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds | | 📹 **CCTV Mesh** | ~3,900 public cameras projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | | 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 | -| 📷 **Street Level** | Street-level photos behind one panel: coverage on the streets, image cones and an embedded viewer with full-screen **EXPAND**. Mapillary first; Panoramax, KartaView and Google Street View planned | Mapillary | 🟡 Mapillary client token | +| 📷 **Street Level** | Mapillary street-level photos in one panel: coverage on the streets, image cones and an embedded viewer with full-screen **EXPAND** | Mapillary | 🟡 Mapillary client token | | 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 | | 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland | Operator GTFS-Realtime feeds | 🟢 | | 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 | diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 57c163299..bcc6503ab 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2934,58 +2934,44 @@ its criteria cannot be silently ignored. Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment. -Street Level is a collapsible right-rail panel (`#street-level-panel`, layer -token `0`, option owner `street-level`, panel `ui` token `t`) that starts +Street Level is a docked, collapsible right-rail panel (`#street-level-panel`, +layer token `0`, option owner `street-level`, panel `ui` token `t`) that starts collapsed. It opens itself only when the user, voice or a tool switches the layer on or a photo opens, never on a restore, and those opens are not stored. -Like the iD editor's photo overlay, it shows Mapillary with 360°/flat and +Like the iD editor's photo overlay it shows Mapillary only: 360°/flat and captured-since filters (relative days, so a link keeps its meaning), one -viewer host and an on-globe credit while the layer is on. Lines and cones are -Mapillary green; 360° cones are rings and the selected sequence is GEV cyan. -Share options: `m` (Mapillary on/off; off draws nothing), `p` (`a`/`p`/`f` -panoramas), `s` (since, days). Without `MAPILLARY_CLIENT_TOKEN` the panel -reads KEY REQUIRED, its error line says which key to add, and the filters are -disabled. Coverage is z11–14 sequence lines below 60 km; above that nothing is -drawn and the panel says to zoom in. Tiles come from the visible ground within -range of the camera (rays meet the terrain height, not the bare ellipsoid), -nearest the camera first, at most 9. The proxy serves only z11–14, strips the -unused `image` layer from z14 tiles (12 MB → ~80 KB) and shares one upstream -fetch between concurrent requests for a tile. A 429 pauses tile requests for -its Retry-After and then refreshes once; a rejected key stops them and reads -KEY REJECTED until the layer goes off. +embedded viewer and an on-globe credit while the layer is on. Share options: +`m` (Mapillary on/off; off draws nothing), `p` (`a`/`p`/`f` panoramas), `s` +(since, days). The header pill is the only layer switch and also turns a +switched-off `m` back on. -Sequence lines are `GroundPolylinePrimitive`s with -`ClassificationType.BOTH`, so they drape on the terrain globe and on Google 3D -tiles alike (on 3D tiles they can land on roofs and tree tops). Cones and the -photo marker are billboards clamped to the ground; they skip the depth test, so -a horizon cull hides the ones behind the globe. Roads on elevated decks are -drawn at ground level. +Coverage is z11–14 sequence lines below 60 km; above that nothing is drawn and +the panel says to zoom in. Tiles come from the visible ground within range of +the camera, nearest first, at most 9. Lines are draped +`GroundPolylinePrimitive`s (`ClassificationType.BOTH`), so they follow terrain +and Google 3D tiles alike; cones and the photo marker are ground-clamped +billboards with a horizon cull. Without `MAPILLARY_CLIENT_TOKEN` the panel +reads KEY REQUIRED, says which key to add and disables the filters; a rejected +key reads KEY REJECTED and stops tile requests until the layer goes off; a 429 +pauses them for its Retry-After. -The header pill is the only layer switch; switching on also turns Mapillary -back on if a share link or tool switched it off. The viewer sits under the header -with EXPAND, FIT/FILL and close above the image. EXPAND is the browser's -Fullscreen API on the viewer element, which stays in the panel (Esc or SHRINK -leaves; the button is hidden where the API is missing). MapillaryJS loads on the -first photo (no prewarm). Opening a photo flies the globe camera once to frame -it; there is no camera follow. The flight goes -through the application's deferred navigation (`attachNavigation`: `begin` / -`reassert`), which releases aircraft and satellite tracking and is refused in -the cockpit; a photo takes its ticket when it starts opening and frames only -if nothing newer took the camera while it loaded. SINCE is a stepped slider whose readout names the -cut-off date. At phone width (≤720 px) the viewer height is derived from the -rail band so the whole photo fits. +The server proxy (`server/providers/mapillary.js`) keeps the token server-side, +serves only z11–14 coverage, strips the unused `image` layer, coalesces +concurrent requests and keeps tiles in a 24 h memory cache. -The layer (`src/layers/streetLevel/index.js`) owns the Mapillary parts -directly: `providers/mapillary/` holds the source, tile decoding, coverage, -sequence cones, nearest-image lookup and the MapillaryJS viewer. +MapillaryJS loads on the first photo. Opening one frames it with a single +camera flight through the application's deferred navigation (`begin` / +`reassert`): it releases tracking, is refused in the cockpit, and is skipped +if newer navigation took the camera while the photo loaded; closing the photo +cancels it. EXPAND is the browser's Fullscreen API on the viewer element +(hidden where the API is missing). At phone width the viewer height comes from +the rail band so the whole photo fits. -Street Level has two browser gates. `npm run qa:street-level -- --url ` -runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run -qa:street-level:fixtures -- --url ` answers every Mapillary request -from fixtures (`providers/mapillary/coverageFixture.mjs`, -`scripts/fixtures/street-level/`), so it needs no network and covers the whole -photo flow. CI runs the fixture gate against a production build served by -`vite preview` with a dummy token. +Browser gates: `npm run qa:street-level -- --url ` runs against real +Mapillary with a token; `npm run qa:street-level:fixtures` answers every +Mapillary request from fixtures (`providers/mapillary/coverageFixture.mjs`, +`scripts/fixtures/street-level/`). CI runs the fixture gate with `--strict` +against a production build served by `vite preview` with a dummy token. Directions is a keyless front end to the routing the voice agent already uses. Its row chips are the whole interface: DRIVE / WALK / BIKE pick the diff --git a/scripts/fixtures/street-level/mapillaryGraph.mjs b/scripts/fixtures/street-level/mapillaryGraph.mjs index 4df96b466..9b44919ca 100644 --- a/scripts/fixtures/street-level/mapillaryGraph.mjs +++ b/scripts/fixtures/street-level/mapillaryGraph.mjs @@ -21,7 +21,7 @@ export const PHOTO_LINE = Object.freeze({ north: 38.591, }); /** Metres between consecutive photos on the line (≥ the 3 m cone thinning). */ -export const PHOTO_SPACING_M = 30; +const PHOTO_SPACING_M = 30; /** Fixture thumbnail host (answered, never reached). */ export const THUMB_HOST = 'qa-fixture.mapillary.com'; @@ -117,7 +117,7 @@ const json = (status, body) => ({ }); let photoBytes = null; -export function photoJpeg() { +function photoJpeg() { photoBytes ??= readFileSync(new URL('./street-640x320.jpg', import.meta.url)); return photoBytes; } diff --git a/scripts/qa-browserEvidence.mjs b/scripts/qa-browserEvidence.mjs index 46f889d50..6c663cae6 100644 --- a/scripts/qa-browserEvidence.mjs +++ b/scripts/qa-browserEvidence.mjs @@ -7,10 +7,10 @@ import fs from 'node:fs/promises'; import path from 'node:path'; /** Console messages and failed requests kept per page. */ -export const EVIDENCE_LIMIT = 200; +const EVIDENCE_LIMIT = 200; /** Cesium's console line when the render loop throws (CesiumWidget). */ -export const RENDER_ERROR_PATTERN = /An error occurred while rendering/i; +const RENDER_ERROR_PATTERN = /An error occurred while rendering/i; /** Mask tokens in a URL or message before it is printed or saved. */ export function redact(text) { diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index 138b73235..282450023 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -1,22 +1,18 @@ #!/usr/bin/env node /** - * Browser QA for the Street Level layer against a running server. Run with + * Browser QA for the Street Level layer against a running server: * `npm run qa:street-level -- --url http://localhost:4173`. * - * `--fixtures` (the CI mode) answers every *.mapillary.com request from - * fixtures, so nothing reaches Mapillary. The server still needs a token - * because the bundle carries it; CI uses a dummy one. Fixture runs also stage - * a rejected key and a keyless second server. - * - * `--strict` fails on any skip not in STRICT_ALLOWED_SKIPS. A failed run saves - * evidence under qa-artifacts/street-level/. + * `--fixtures` (CI) answers every *.mapillary.com request and the coverage + * tile route from fixtures, so nothing reaches Mapillary; the server still + * needs a (dummy) token because the bundle carries it. `--strict` fails on any + * skip other than "fixtures only". A failed run saves evidence under + * qa-artifacts/street-level/. */ import assert from 'node:assert/strict'; import { pathToFileURL } from 'node:url'; -import { LAYER_STATE_REGISTRY } from '../src/data/layerState.js'; import { tileBounds } from '../src/layers/streetLevel/tileMath.js'; import { encodeCoverageTile } from '../src/layers/streetLevel/providers/mapillary/coverageFixture.mjs'; -import { COVERAGE_MOVE_DEBOUNCE_MS } from '../src/layers/streetLevel/providers/mapillary/policy.js'; import { hookRenderErrors, readRenderErrors, @@ -31,170 +27,76 @@ import { photoImages, } from './fixtures/street-level/mapillaryGraph.mjs'; -const PANEL_ID = 'street-level-panel'; - -/** Where a failed run leaves its screenshots and logs. */ -export const ARTIFACT_DIR = 'qa-artifacts/street-level'; - -export const VIEWPORTS = Object.freeze([ - { width: 1440, height: 900 }, - { width: 1280, height: 800 }, -]); - -/** Legend swatches: Mapillary, then "Selected". */ -export const LEGEND_SWATCHES = 2; - -/** - * Skips no setup can avoid: live runs cannot stage fixture-only cases. - * `no Mapillary key` is not accepted: CI's dummy token runs the keyed flow, - * so a keyless strict run means that wiring broke. - */ -export const STRICT_ALLOWED_SKIPS = Object.freeze(['fixtures only']); - -export function strictViolations(skips) { - return skips.filter((skip) => !STRICT_ALLOWED_SKIPS.includes(skip.reason)); -} - -const STREET_LEVEL_TOKEN = LAYER_STATE_REGISTRY.find( - (entry) => entry.id === 'street-level', -).token; - -/** The Street Level panel's token in the share link's `ui` panel state. */ -export const PANEL_UI_TOKEN = 't'; - -/** Right-rail order once the layout controller has run. */ -export const RAIL_ORDER = Object.freeze([ - 'pp-toggles', - 'cctv-panel', - 'weather-panel', - 'recent-imagery-panel', - 'street-level-panel', - 'global-context-panel', -]); - -/** Over downtown Sacramento, looking north and down. */ -export const PARK = Object.freeze({ - lon: -121.4944, - lat: 38.5816, - height: 900, - pitch: -1.3, -}); - -export function isCollapsed(classList) { - return [...classList].includes('collapsed'); -} - -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - +const VIEWPORT = { width: 1440, height: 900 }; +const PHONE = { width: 390, height: 844 }; +/** Over downtown Sacramento, above the fixture photo line, looking down. */ +const PARK = { lon: PHOTO_LINE.lon, lat: 38.5816, height: 900, pitch: -1.3 }; const DAY_MS = 86_400_000; /** - * Deterministic coverage for any street-zoom tile: a grid of mixed 360°/flat, - * new/old sequences plus the photo sequence (PHOTO_LINE). + * Coverage for any street-zoom tile: a grid of 360°/flat, recent/old + * sequences, plus the photo sequence where PHOTO_LINE crosses the tile. * @returns {Uint8Array} empty below z11, which the layer never asks for */ export function fixtureTile(z, x, y, now = Date.now()) { - const tile = { x, y, z }; + if (z < 11) return new Uint8Array(0); const { west, east, south, north } = tileBounds(x, y, z); const at = (t, lo, hi) => lo + (hi - lo) * t; - if (z < 11) return new Uint8Array(0); const margin = (east - west) * 0.05; const sequences = []; for (let i = 1; i <= 4; i++) { const t = i / 5; const old = i > 2; - sequences.push({ - id: `fx-${z}-${x}-${y}-h${i}`, - isPano: i % 2 === 0, - capturedAt: now - (old ? 1100 : 30) * DAY_MS, - parts: [ - [ - [west + margin, at(t, south, north)], - [east - margin, at(t, south, north)], + const lat = at(t, south, north); + const lon = at(t, west, east); + sequences.push( + { + id: `fx-${z}-${x}-${y}-h${i}`, + isPano: i % 2 === 0, + capturedAt: now - (old ? 1100 : 30) * DAY_MS, + parts: [ + [ + [west + margin, lat], + [east - margin, lat], + ], ], - ], - }); - sequences.push({ - id: `fx-${z}-${x}-${y}-v${i}`, - isPano: i % 2 === 1, - capturedAt: now - (old ? 30 : 1100) * DAY_MS, - parts: [ - [ - [at(t, west, east), south + margin], - [at(t, west, east), north - margin], + }, + { + id: `fx-${z}-${x}-${y}-v${i}`, + isPano: i % 2 === 1, + capturedAt: now - (old ? 30 : 1100) * DAY_MS, + parts: [ + [ + [lon, south + margin], + [lon, north - margin], + ], ], - ], - }); + }, + ); } - const photoSouth = Math.max(PHOTO_LINE.south, south); - const photoNorth = Math.min(PHOTO_LINE.north, north); - if ( - PHOTO_LINE.lon >= west && - PHOTO_LINE.lon < east && - photoSouth < photoNorth - ) + const lineSouth = Math.max(PHOTO_LINE.south, south); + const lineNorth = Math.min(PHOTO_LINE.north, north); + if (PHOTO_LINE.lon >= west && PHOTO_LINE.lon < east && lineSouth < lineNorth) sequences.push({ id: PHOTO_SEQUENCE_ID, isPano: false, capturedAt: now - 30 * DAY_MS, parts: [ [ - [PHOTO_LINE.lon, photoSouth], - [PHOTO_LINE.lon, photoNorth], + [PHOTO_LINE.lon, lineSouth], + [PHOTO_LINE.lon, lineNorth], ], ], }); - return encodeCoverageTile(tile, { sequences }); -} - -/** - * Ask the real status route from Node, outside the page's interception, so a - * server that never registered it fails even in fixture runs. Node sends no - * Origin, so the same-site gate admits it. - * @returns {Promise<{configured: boolean}>} - */ -export async function assertRealStatusRoute(url, fetchImpl = fetch) { - const route = new URL('/api/mapillary/status', url).href; - let response; - try { - response = await fetchImpl(route, { - headers: { Accept: 'application/json' }, - }); - } catch (error) { - throw new Error(`${route} is unreachable: ${error?.message || error}`, { - cause: error, - }); - } - const type = response.headers.get('content-type') || ''; - const text = await response.text(); - assert.equal( - response.status, - 200, - `${route} must be the server's Mapillary route (HTTP ${response.status}: ${text.slice(0, 120)})`, - ); - assert.match(type, /application\/json/, `${route} answers JSON, not ${type}`); - let body; - try { - body = JSON.parse(text); - } catch { - assert.fail(`${route} answered unparseable JSON: ${text.slice(0, 120)}`); - } - assert.equal( - typeof body?.configured, - 'boolean', - `${route} reports a boolean \`configured\`: ${text.slice(0, 120)}`, - ); - return body; + return encodeCoverageTile({ x, y, z }, { sequences }); } /** * Answer the page's Mapillary requests from `fixture`, which steps may change * mid-run. `configured: undefined` lets the server's real status route answer. - * @param {{configured?: boolean, tiles: 'ok'|'rejected', tileRequests: number, images: Array, calls: Set, unknown: Array}} fixture */ -export async function serveFixtures(page, fixture) { - // CDP Fetch on just these URLs: Puppeteer's page-wide interception stalls - // Cesium's worker scripts, leaving nothing to click. +async function serveFixtures(page, fixture) { + // CDP Fetch on just these URLs: page-wide interception stalls Cesium workers. const cdp = await page.createCDPSession(); await cdp.send('Fetch.enable', { patterns: [ @@ -202,81 +104,67 @@ export async function serveFixtures(page, fixture) { { urlPattern: '*/api/mapillary/*' }, ], }); - const fulfil = (requestId, { status, headers = {}, contentType, body }) => - cdp.send('Fetch.fulfillRequest', { - requestId, - responseCode: status, - responseHeaders: Object.entries({ - ...headers, - ...(contentType ? { 'Content-Type': contentType } : {}), - }).map(([name, value]) => ({ name, value: String(value) })), - body: Buffer.from(body ?? '').toString('base64'), - }); const json = (status, payload) => ({ status, contentType: 'application/json', body: JSON.stringify(payload), }); - cdp.on('Fetch.requestPaused', ({ requestId, request }) => { - const address = new URL(request.url); - const method = request.method; - let answer = null; + const answerFor = (method, address) => { if (/(^|\.)mapillary\.com$/.test(address.hostname)) { const call = describeCall(method, address); - answer = answerMapillaryRequest({ + const answer = answerMapillaryRequest({ method, - url: request.url, + url: address.href, images: fixture.images, }); fixture.calls.add(call); if (!answer.known) fixture.unknown.push(call); - } else if ( - address.pathname === '/api/mapillary/status' && - fixture.configured !== undefined - ) { - answer = json(200, { configured: fixture.configured }); - } else { - const tile = address.pathname.match( - /^\/api\/mapillary\/tiles\/coverage\/(\d+)\/(\d+)\/(\d+)$/, - ); - if (tile) { - fixture.tileRequests++; - if (fixture.configured === false) - answer = json(503, { error: 'no_key', keyRequired: true }); - else if (fixture.tiles === 'rejected') - answer = json(403, { - error: 'Mapillary rejected the access token', - keyRejected: true, - }); - else { - const bytes = fixtureTile( - Number(tile[1]), - Number(tile[2]), - Number(tile[3]), - ); - answer = bytes.length - ? { - status: 200, - contentType: 'application/x-protobuf', - body: Buffer.from(bytes), - } - : { status: 204, body: '' }; - } - } + return answer; } + if (address.pathname === '/api/mapillary/status') + return fixture.configured === undefined + ? null + : json(200, { configured: fixture.configured }); + const tile = address.pathname.match( + /^\/api\/mapillary\/tiles\/coverage\/(\d+)\/(\d+)\/(\d+)$/, + ); + if (!tile) return null; + if (fixture.configured === false) + return json(503, { error: 'no_key', keyRequired: true }); + if (fixture.rejected) + return json(403, { + error: 'Mapillary rejected the access token', + keyRejected: true, + }); + const bytes = fixtureTile(...tile.slice(1).map(Number)); + return bytes.length + ? { status: 200, contentType: 'application/x-protobuf', body: bytes } + : { status: 204, body: '' }; + }; + cdp.on('Fetch.requestPaused', ({ requestId, request }) => { + const answer = answerFor(request.method, new URL(request.url)); const done = answer - ? fulfil(requestId, answer) + ? cdp.send('Fetch.fulfillRequest', { + requestId, + responseCode: answer.status, + responseHeaders: Object.entries({ + ...answer.headers, + ...(answer.contentType + ? { 'Content-Type': answer.contentType } + : {}), + }).map(([name, value]) => ({ name, value: String(value) })), + body: Buffer.from(answer.body ?? '').toString('base64'), + }) : cdp.send('Fetch.continueRequest', { requestId }); // A page that navigated or closed meanwhile has no request to answer. done.catch(() => {}); }); } -export function newFixture(overrides = {}) { +function newFixture(overrides = {}) { return { configured: undefined, - tiles: 'ok', - tileRequests: 0, + rejected: false, images: photoImages(), calls: new Set(), unknown: [], @@ -284,32 +172,15 @@ export function newFixture(overrides = {}) { }; } -/** Wait for `fn` in the page; false (not a throw) when it does not come true. */ -async function settle(page, fn, arg, { timeout = 10_000 } = {}) { - try { - await page.waitForFunction(fn, { timeout, polling: 100 }, arg); - return true; - } catch (error) { - if (error?.name !== 'TimeoutError') throw error; - return false; - } -} - -async function clearFirstRun(page) { +/** Load the app and dismiss the first-run dialog. */ +async function boot(page, url) { + await page.goto(url, { waitUntil: 'domcontentloaded' }); await page.waitForFunction(() => Boolean(window.__godsEyeView?.dataManager), { timeout: 150_000, }); await page.evaluate(() => document.querySelector('.first-run-explore')?.click(), ); - await settle( - page, - () => !document.querySelector('.first-run-explore'), - null, - { - timeout: 3_000, - }, - ); await page.keyboard.press('Escape'); await page.evaluate(() => { for (const el of document.querySelectorAll( @@ -319,215 +190,102 @@ async function clearFirstRun(page) { }); } +/** Run `test(uiState, arg)` in the page until it is true. */ +const uiUntil = (page, test, arg = null, timeout = 30_000) => + page.waitForFunction( + `(${test})(window.__godsEyeView.dataManager.layers.get('street-level').module.getUIState(), ${JSON.stringify(arg)})`, + { timeout, polling: 100 }, + ); + +const getUI = (page) => + page.evaluate(() => + window.__godsEyeView.dataManager.layers + .get('street-level') + .module.getUIState(), + ); + +const statusIs = (page, text) => + page.waitForFunction( + (want) => document.getElementById('sl-status').textContent === want, + { timeout: 30_000 }, + text, + ); + +const waitForCoverage = (page) => + uiUntil( + page, + (u) => + u.enabled && + u.coverage.count > 0 && + !u.coverage.loading && + !u.keyRequired, + null, + 90_000, + ); + /** - * Best effort: wait until the panel's box has held still for `stillMs`, so a - * point read from it does not move before the press. + * Press a panel control through the DOM: the rail animates panel heights, so + * a pointer aimed from one frame's layout can miss in the next. */ -async function waitForStill( - page, - panelId, - { stillMs = 300, timeout = 5000 } = {}, -) { - try { - await page.waitForFunction( - (id, ms) => { - const r = document.getElementById(id).getBoundingClientRect(); - const box = [r.left, r.top, r.width, r.height].map(Math.round).join(); - const now = performance.now(); - if (window.__qaStillBox !== box) { - window.__qaStillBox = box; - window.__qaStillSince = now; - } - return now - window.__qaStillSince >= ms; - }, - { polling: 100, timeout }, - panelId, - stillMs, +const press = (page, selector) => + page.$eval(selector, (control) => control.click()); + +/** Expand the strip; a panel state stored by an earlier page may have done so. */ +async function expandPanel(page) { + const collapsed = await page.$eval('#street-level-panel', (node) => + node.classList.contains('collapsed'), + ); + if (collapsed) + await press( + page, + '.panel-collapse-btn[data-collapse-target="street-level-panel"]', ); - return true; - } catch (error) { - if (error?.name !== 'TimeoutError') throw error; - return false; - } -} - -/** - * Click a panel control once the panel holds still and the control is what - * lies under its centre: the rail animates panel heights, so a point read - * once can miss by the time the pointer goes down. - */ -async function clickPanelControl(page, selector, { timeout = 5_000 } = {}) { - await waitForStill(page, PANEL_ID); - const deadline = Date.now() + timeout; - for (;;) { - const hit = await page.$eval(selector, (el) => { - el.scrollIntoView({ block: 'nearest' }); - const r = el.getBoundingClientRect(); - const x = r.left + r.width / 2; - const y = r.top + r.height / 2; - const top = document.elementFromPoint(x, y); - return { - x, - y, - ok: Boolean(top && (top === el || el.contains(top))), - target: top - ? `${top.tagName.toLowerCase()}${top.id ? `#${top.id}` : ''}` - : 'nothing', - }; - }); - if (hit.ok) return page.mouse.click(hit.x, hit.y); - if (Date.now() > deadline) - throw new Error(`${selector} is covered by ${hit.target}`); - await new Promise((resolve) => setTimeout(resolve, 200)); - } -} - -/** Load the app and clear the first-run dialog. */ -export async function boot(page, url) { - await page.goto(url, { waitUntil: 'domcontentloaded' }); - await clearFirstRun(page); -} - -function readPanel(page) { - return page.evaluate(() => { - const el = document.getElementById('street-level-panel'); - const rail = document.getElementById('right-context-rail'); - const box = el.getBoundingClientRect(); - return { - order: rail ? [...rail.children].map((child) => child.id) : [], - classes: [...el.classList], - width: Math.round(box.width), - right: Math.round(box.right), - status: document.getElementById('sl-status').textContent, - controlsDisabled: document.getElementById('sl-controls').disabled, - bodyDisplay: getComputedStyle(document.getElementById('sl-body')).display, - }; - }); -} - -/** Wait until Street Level is on, drawn, settled and not key-gated. */ -function waitForCoverage(page, { timeout = 90_000 } = {}) { - return page.waitForFunction( - () => { - const dm = window.__godsEyeView.dataManager; - const u = dm.layers.get('street-level').module.getUIState(); - return ( - dm.isEnabled('street-level') && - u.coverage.count > 0 && - !u.coverage.loading && - !u.keyRequired - ); - }, - { timeout }, + await page.waitForFunction( + () => + getComputedStyle(document.getElementById('sl-body')).display === 'flex', + { timeout: 10_000 }, ); } -async function assertKeylessGate(page) { +async function assertKeyRequired(page) { await page.evaluate(() => window.__godsEyeView.dataManager.setEnabled('street-level', true, { origin: 'user', }), ); - await page.waitForFunction( - () => document.getElementById('sl-status').textContent === 'KEY REQUIRED', - { timeout: 30_000 }, - ); - const info = await readPanel(page); - assert.equal(info.controlsDisabled, true); - assert.equal(info.status, 'KEY REQUIRED'); - const hint = await page.$eval('#sl-error', (node) => ({ - hidden: node.hidden, - text: node.textContent, + await statusIs(page, 'KEY REQUIRED'); + const shown = await page.evaluate(() => ({ + disabled: document.getElementById('sl-controls').disabled, + hint: document.getElementById('sl-error').hidden + ? '' + : document.getElementById('sl-error').textContent, })); - assert.equal(hint.hidden, false, 'the panel says how to add the key'); - assert.match(hint.text, /MAPILLARY_CLIENT_TOKEN/); + assert.equal(shown.disabled, true, 'controls are gated'); + assert.match(shown.hint, /MAPILLARY_CLIENT_TOKEN/, 'names the key to add'); } -/** - * Runs in the page. Find a point where a real click picks `prefix` + `id` (any - * id when null), searched around `points` ([lon, lat]), the aimed billboards, - * or else a grid mid-screen. Only points where the canvas is topmost count. - * @returns {{x: number, y: number, id: string}|null} client coordinates - */ -export function findPickPoint({ - prefix, - id, - points, - billboardIds, - billboardPrefix = null, -}) { - const viewer = window.__godsEyeView.viewer; - const scene = viewer.scene; - const canvas = scene.canvas; - const rect = canvas.getBoundingClientRect(); - const Cartographic = viewer.camera.positionCartographic.constructor; - let Cartesian2 = null; - const windowPoint = (x, y) => (Cartesian2 ? new Cartesian2(x, y) : { x, y }); - const wanted = (picked) => { - const pid = - typeof picked?.id === 'string' ? picked.id : (picked?.id?.id ?? null); - if (typeof pid !== 'string' || !pid.startsWith(prefix)) return null; - const rest = pid.slice(prefix.length).split('~')[0]; - return id === null || rest === id ? rest : null; - }; - const tryAt = (x, y) => { - if (x < 2 || y < 2 || x > rect.width - 2 || y > rect.height - 2) - return null; - const top = document.elementFromPoint(rect.left + x, rect.top + y); - if (top !== canvas) return null; - const hit = wanted(scene.pick(windowPoint(x, y))); - return hit ? { x: rect.left + x, y: rect.top + y, id: hit } : null; - }; - const centres = []; - const aimed = (billboardId) => - typeof billboardId === 'string' && - ((billboardIds || []).includes(billboardId) || - (billboardPrefix !== null && billboardId.startsWith(billboardPrefix))); - if ((billboardIds && billboardIds.length) || billboardPrefix !== null) { - for (let i = 0; i < scene.primitives.length; i++) { - const collection = scene.primitives.get(i); - if (typeof collection?.get !== 'function' || !collection.length) continue; - for (let j = 0; j < collection.length; j++) { - const billboard = collection.get(j); - if (!aimed(billboard?.id) || !billboard.show) continue; - const at = billboard.computeScreenSpacePosition?.(scene); - if (at) { - Cartesian2 ??= at.constructor; - centres.push([at.x, at.y]); - } - } - } +/** Park the camera over the photo line; a late startup flight can move it. */ +async function park(page) { + for (let attempt = 0; attempt < 5; attempt++) { + const stayed = await page.evaluate(async (at) => { + const v = window.__godsEyeView.viewer; + v.camera.cancelFlight?.(); + const C = v.camera.positionCartographic.constructor; + v.camera.setView({ + destination: v.scene.globe.ellipsoid.cartographicToCartesian( + C.fromDegrees(at.lon, at.lat, at.height), + ), + orientation: { heading: 0, pitch: at.pitch, roll: 0 }, + }); + await new Promise((resolve) => setTimeout(resolve, 1500)); + const c = v.camera.positionCartographic; + return ( + Math.abs((c.longitude * 180) / Math.PI - at.lon) < 0.001 && + Math.abs((c.latitude * 180) / Math.PI - at.lat) < 0.001 + ); + }, PARK); + if (stayed) return; } - for (const [lon, lat] of points || []) { - const at = scene.cartesianToCanvasCoordinates( - scene.globe.ellipsoid.cartographicToCartesian( - Cartographic.fromDegrees(lon, lat, 0), - ), - ); - if (at) { - Cartesian2 ??= at.constructor; - centres.push([at.x, at.y]); - } - } - for (const [cx, cy] of centres) - for (let r = 0; r <= 16; r += 2) - for (const [dx, dy] of [ - [0, 0], - [r, 0], - [-r, 0], - [0, r], - [0, -r], - ]) { - const hit = tryAt(cx + dx, cy + dy); - if (hit) return hit; - } - if (points || billboardIds) return null; - for (let y = rect.height * 0.25; y < rect.height * 0.8; y += 18) - for (let x = rect.width * 0.25; x < rect.width * 0.7; x += 6) { - const hit = tryAt(x, y); - if (hit) return hit; - } - return null; } async function main() { @@ -542,33 +300,27 @@ async function main() { executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || (await puppeteer.executablePath()), - defaultViewport: VIEWPORTS[0], + defaultViewport: VIEWPORT, protocolTimeout: 300_000, args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'], }); let count = 0; const skips = []; - /** `fn` gets `skip(reason)`: the step runs what it can but reports a skip. */ const step = async (label, fn) => { - let skipped = null; - const result = await fn({ - skip: (reason) => { - skipped = reason; - }, - }); - count++; - if (skipped) { - skips.push({ reason: skipped, label }); - console.log(`skip (${skipped}) ${count} ${label}`); - } else console.log(`ok ${count} ${label}`); - return result; + await fn(); + console.log(`ok ${++count} ${label}`); }; - /** A step this mode cannot run at all. */ - const skipStep = (reason, label) => step(label, ({ skip }) => skip(reason)); + const skip = (reason, label) => { + skips.push(reason); + console.log(`skip (${reason}) ${++count} ${label}`); + }; + const fixtureStep = (label, fn) => + fixtures ? step(label, fn) : skip('fixtures only', label); const errors = []; const monitors = []; - /** The layer only warns on listener exceptions, so count those as errors. */ - const watch = async (page, name) => { + // The layer only warns on listener exceptions, so count those as errors. + const open = async (name, fixture) => { + const page = await browser.newPage(); monitors.push( watchPage(page, { name, @@ -577,147 +329,66 @@ async function main() { }), ); await hookRenderErrors(page); - }; - const renderErrorsSeen = []; - try { - await step( - 'the server registers the real Mapillary status route', - async () => { - const real = await assertRealStatusRoute(url); - console.log(` (server status: configured=${real.configured})`); - if (fixtures) - assert.equal( - real.configured, - true, - 'fixture runs need a server started with MAPILLARY_CLIENT_TOKEN (CI uses a dummy, non-secret one): the browser bundle carries the token, and without it no photo can open', - ); - }, - ); - const page = await browser.newPage(); - const fixture = newFixture(); - await watch(page, 'main'); - if (fixtures) await serveFixtures(page, fixture); + if (fixture) await serveFixtures(page, fixture); await boot(page, url); - const panel = () => readPanel(page); - const status = await page.evaluate(() => - fetch('/api/mapillary/status').then((res) => res.json()), - ); - for (const viewport of VIEWPORTS) { - await page.setViewport(viewport); - await step( - `panel is a collapsed right-rail strip at ${viewport.width}×${viewport.height}`, - async () => { - await settle( - page, - (width) => { - const box = document - .getElementById('street-level-panel') - .getBoundingClientRect(); - return box.width <= 200 && box.right <= width; - }, - viewport.width, - ); - const info = await panel(); - assert.deepEqual(info.order, RAIL_ORDER); - assert.ok(isCollapsed(info.classes)); - assert.equal(info.bodyDisplay, 'none'); - assert.ok(info.width <= 200 && info.right <= viewport.width); - }, - ); - } - await page.setViewport(VIEWPORTS[0]); - const expandStrip = async (target = page) => { - await target.click( - '.panel-collapse-btn[data-collapse-target="street-level-panel"]', - ); - await settle( - target, - () => - getComputedStyle(document.getElementById('sl-body')).display === - 'flex', - ); - }; - await expandStrip(); - await step( - 'expanding the strip shows the body and the legend', - async () => { - const info = await panel(); - assert.ok(!isCollapsed(info.classes)); - assert.equal(info.bodyDisplay, 'flex'); - // Mapillary plus "Selected". - assert.equal( - await page.evaluate( - () => document.querySelectorAll('#sl-legend li').length, - ), - LEGEND_SWATCHES, - ); - }, - ); - if (!status.configured) { - await step( - 'keyless install gates the controls and reports KEY REQUIRED', - () => assertKeylessGate(page), - ); - await skipStep( - 'no Mapillary key', - 'the keyed steps (coverage, filters, photos, panel window)', - ); - console.log( - 'keyless run complete (no MAPILLARY_CLIENT_TOKEN on the server)', - ); - } else { - await keyedSteps(); - } - await step('no page errors and no Cesium render-loop errors', async () => { - renderErrorsSeen.push( - ...(await readRenderErrors(page)).map( - (text) => `[main] scene.renderError: ${text}`, - ), - ); - assert.deepEqual([...errors, ...renderErrorsSeen], []); - }); - if (strict) - await step('strict: no skips beyond the documented ones', () => { - assert.deepEqual( - strictViolations(skips), - [], - `--strict accepts only skips for: ${STRICT_ALLOWED_SKIPS.join(', ')}`, - ); - }); - console.log( - `${count} steps: ${count - skips.length} ok, ${skips.length} skipped`, + return page; + }; + const collectRenderErrors = async (page, name) => + errors.push( + ...(await readRenderErrors(page)).map( + (text) => `[${name}] scene.renderError: ${text}`, + ), ); - async function keyedSteps() { - // A late startup flight only shows itself by moving the camera, so park - // and check it stays put rather than reading a state. - const park = async () => { - for (let attempt = 0; attempt < 6; attempt++) { - await page.evaluate((at) => { - const v = window.__godsEyeView.viewer; - v.camera.cancelFlight?.(); - const C = v.camera.positionCartographic.constructor; - v.camera.setView({ - destination: v.scene.globe.ellipsoid.cartographicToCartesian( - C.fromDegrees(at.lon, at.lat, at.height), - ), - orientation: { heading: 0, pitch: at.pitch, roll: 0 }, - }); - }, PARK); - await sleep(1500); - const stable = await page.evaluate((at) => { - const c = window.__godsEyeView.viewer.camera.positionCartographic; - return ( - Math.abs((c.longitude * 180) / Math.PI - at.lon) < 0.001 && - Math.abs((c.latitude * 180) / Math.PI - at.lat) < 0.001 - ); - }, PARK); - if (stable) return; - } - }; - await park(); + try { + const fixture = newFixture(); + const page = await open('main', fixtures && fixture); + const { configured } = await page.evaluate(() => + fetch('/api/mapillary/status').then((res) => res.json()), + ); + if (fixtures) + assert.equal( + configured, + true, + 'fixture runs need a server started with MAPILLARY_CLIENT_TOKEN (CI uses a dummy one)', + ); + + await step('the panel starts as a collapsed right-rail strip', async () => { + const box = await page.evaluate(() => { + const panel = document.getElementById('street-level-panel'); + const r = panel.getBoundingClientRect(); + return { + collapsed: panel.classList.contains('collapsed'), + inRail: panel.parentElement.id === 'right-context-rail', + body: getComputedStyle(document.getElementById('sl-body')).display, + width: r.width, + right: r.right, + }; + }); + assert.equal(box.collapsed, true); + assert.equal(box.inRail, true); + assert.equal(box.body, 'none'); + assert.ok(box.width <= 200 && box.right <= VIEWPORT.width); + }); + + await step('expanding the strip shows the body and legend', async () => { + await expandPanel(page); + assert.equal( + await page.$$eval('#sl-legend li', (items) => items.length), + 2, + 'Mapillary and Selected', + ); + }); + + if (!configured) { + await step('keyless server: KEY REQUIRED gates the controls', () => + assertKeyRequired(page), + ); + skip('no Mapillary key', 'the keyed steps'); + } else { + await park(page); await step( - 'enabling draws coverage and registers the on-globe credit', + 'enabling draws coverage and the Mapillary credit', async () => { await page.evaluate(() => window.__godsEyeView.dataManager.setEnabled('street-level', true, { @@ -725,9 +396,11 @@ async function main() { }), ); await waitForCoverage(page); - const info = await panel(); - assert.equal(info.controlsDisabled, false); - // Cesium paints on-screen credits a frame or two after they register. + await statusIs(page, 'ON'); + assert.equal( + await page.$eval('#sl-controls', (node) => node.disabled), + false, + ); await page.waitForFunction( () => document.body.innerHTML.includes('Mapillary contributors'), @@ -735,105 +408,47 @@ async function main() { ); }, ); - const ui = () => - page.evaluate(() => - window.__godsEyeView.dataManager.layers - .get('street-level') - .module.getUIState(), - ); - /** `test(u, arg)` runs in the page, so it must be self-contained. */ - const uiUntil = (test, arg = null, options) => - settle( - page, - `(${test})(window.__godsEyeView.dataManager.layers.get('street-level').module.getUIState(), ${JSON.stringify(arg)})`, - undefined, - options, - ); - const stacks = { - active: () => - page.evaluate(() => - window.__godsEyeView.mapStackController.getActiveId(), + + await step('the header pill switches the layer off and on', async () => { + await press(page, '#sl-status'); + await statusIs(page, 'OFF'); + assert.equal( + await page.evaluate(() => + window.__godsEyeView.dataManager.isEnabled('street-level'), ), - set: async (id) => { - await page.evaluate( - (stackId) => - window.__godsEyeView.mapStackController.setStack(stackId), - id, - ); - await settle( - page, - (stackId) => - window.__godsEyeView.mapStackController.getActiveId() === stackId, - id, - ); - }, - }; + false, + ); + assert.equal((await getUI(page)).coverage.count, 0); + await press(page, '#sl-status'); + await waitForCoverage(page); + await statusIs(page, 'ON'); + }); + + await step('the 360° filter narrows coverage', async () => { + const before = (await getUI(page)).coverage.count; + await press(page, '[data-sl-pano="pano"]'); + await uiUntil( + page, + (u) => u.filter.pano === 'pano' && !u.coverage.loading, + ); + const after = (await getUI(page)).coverage.count; + // Fixtures hold flat sequences too, so the filter must drop some. + assert.ok( + fixtures ? after < before : after <= before, + `${after} vs ${before}`, + ); + await press(page, '[data-sl-pano="all"]'); + await uiUntil( + page, + (u, n) => u.filter.pano === 'all' && u.coverage.count === n, + before, + ); + }); + await step( - 'the header ON/OFF pill switches the layer off and on', + 'the SINCE slider narrows coverage and names its cut-off', async () => { - assert.equal( - await page.$eval('#sl-status', (node) => - [ - node.tagName, - node.textContent, - node.getAttribute('aria-pressed'), - ].join(':'), - ), - 'BUTTON:ON:true', - ); - await clickPanelControl(page, '#sl-status'); - await page.waitForFunction( - () => !window.__godsEyeView.dataManager.isEnabled('street-level'), - { timeout: 15_000 }, - ); - await page.waitForFunction( - () => document.getElementById('sl-status').textContent === 'OFF', - { timeout: 5_000 }, - ); - assert.equal((await ui()).coverage.count, 0); - await clickPanelControl(page, '#sl-status'); - await waitForCoverage(page); - assert.equal( - await page.$eval('#sl-status', (node) => - node.getAttribute('aria-pressed'), - ), - 'true', - ); - }, - ); - await step( - 'the 360° filter keeps at most the unfiltered sequence count', - async () => { - const before = (await ui()).coverage.count; - await clickPanelControl(page, '[data-sl-pano="pano"]'); - await uiUntil((u) => u.filter.pano === 'pano' && !u.coverage.loading); - const after = (await ui()).coverage.count; - assert.ok(after <= before, `${after} ≤ ${before}`); - // The fixtures hold flat sequences too, so the filter must drop some. - if (fixtures) assert.ok(after < before, `${after} < ${before}`); - assert.equal((await ui()).filter.pano, 'pano'); - // The click is a user params request, so the share link records it - // under Street Level's share token, whatever the ledger assigned. - await page.waitForFunction( - (option) => - (new URLSearchParams(location.hash.slice(1)).get('lo') || '') - .split('_') - .includes(option), - { timeout: 10_000 }, - `${STREET_LEVEL_TOKEN}.p.p`, - ); - await clickPanelControl(page, '[data-sl-pano="all"]'); - await uiUntil( - (u, n) => u.filter.pano === 'all' && u.coverage.count === n, - before, - ); - assert.equal((await ui()).coverage.count, before); - }, - ); - await step( - 'the SINCE slider narrows coverage and names its cut-off date', - async () => { - const before = (await ui()).coverage.count; + const before = (await getUI(page)).coverage.count; const setStop = (index) => page.$eval( '#sl-since', @@ -845,592 +460,213 @@ async function main() { index, ); await setStop(5); - await uiUntil((u) => u.filter.sinceDays === 365); - const narrowed = await ui(); - assert.equal(narrowed.filter.sinceDays, 365); - if (fixtures) - assert.ok( - narrowed.coverage.count < before, - `fixtures hold older sequences: ${narrowed.coverage.count} < ${before}`, - ); - assert.ok( - narrowed.coverage.count <= before, - `${narrowed.coverage.count} ≤ ${before}`, + await uiUntil( + page, + (u) => u.filter.sinceDays === 365 && !u.coverage.loading, ); - await settle(page, () => - /^LAST YEAR/.test( - document.getElementById('sl-since-label').textContent, - ), + const after = (await getUI(page)).coverage.count; + assert.ok( + fixtures ? after < before : after <= before, + `${after} vs ${before}`, ); assert.match( await page.$eval('#sl-since-label', (node) => node.textContent), /^LAST YEAR · SINCE \d{4}-\d{2}-\d{2}$/, ); await setStop(0); - await uiUntil((u) => u.filter.sinceDays === 0); - await settle( + await uiUntil( page, - () => - document.getElementById('sl-since-label').textContent === - 'ANY DATE', + (u, n) => u.filter.sinceDays === 0 && u.coverage.count === n, + before, ); - assert.equal((await ui()).filter.sinceDays, 0); + }, + ); + + await fixtureStep( + 'a rejected key reads KEY REJECTED and clears when switched off', + async () => { + fixture.rejected = true; + await press(page, '#sl-status'); + await statusIs(page, 'OFF'); + await press(page, '#sl-status'); + await statusIs(page, 'KEY REJECTED'); + const shown = await page.evaluate(() => ({ + error: document.getElementById('sl-error').textContent, + hidden: document.getElementById('sl-error').hidden, + disabled: document.getElementById('sl-controls').disabled, + })); + assert.match(shown.error, /rejected MAPILLARY_CLIENT_TOKEN/); + assert.equal(shown.hidden, false); + assert.equal(shown.disabled, true); + await press(page, '#sl-status'); + await statusIs(page, 'OFF'); assert.equal( - await page.$eval('#sl-since-label', (node) => node.textContent), - 'ANY DATE', + await page.$eval( + '#sl-error', + (node) => node.hidden || !node.textContent, + ), + true, + 'no stale error while off', ); - }, - ); - await step( - 'the MapillaryJS viewer loads ahead of the first photo', - async () => { - // Catches a broken dynamic import before the first click would. - await page.waitForSelector( - '#sl-viewer.mapillary-viewer .mapillary-dom', - { timeout: 60_000 }, - ); - }, - ); - const statusText = (text) => - page.waitForFunction( - (expected) => - document.getElementById('sl-status').textContent === expected, - { timeout: 30_000 }, - text, - ); - const panTo = (lon, lat) => - page.evaluate( - (at) => { - const v = window.__godsEyeView.viewer; - const C = v.camera.positionCartographic.constructor; - v.camera.setView({ - destination: v.scene.globe.ellipsoid.cartographicToCartesian( - C.fromDegrees(at.lon, at.lat, 900), - ), - orientation: { heading: 0, pitch: -1.3, roll: 0 }, - }); - window.__godsEyeView.dataManager.layers - .get('street-level') - .module.refreshCoverage(); - }, - { lon, lat }, - ); - /** Wait until no tile request has arrived for `quietMs`. */ - const tileRequestsQuiet = async (quietMs, timeout = 30_000) => { - const start = Date.now(); - let last = fixture.tileRequests; - let since = Date.now(); - while (Date.now() - start < timeout) { - await sleep(100); - if (fixture.tileRequests !== last) { - last = fixture.tileRequests; - since = Date.now(); - } else if (Date.now() - since >= quietMs) return last; - } - return last; - }; - const QUIET_MS = 2 * COVERAGE_MOVE_DEBOUNCE_MS + 200; - if (fixtures) - await step( - 'a key Mapillary rejects reads KEY REJECTED, stops asking (a pan that asks while accepted asks nothing while rejected), and clears when switched off', - async () => { - // Positive control: with the key accepted, the same pan-and-wait - // the rejected half uses does see new tile requests. - await tileRequestsQuiet(QUIET_MS); - const idle = fixture.tileRequests; - await panTo(-121.46, 38.6); - const asked = await tileRequestsQuiet(QUIET_MS); - assert.ok( - asked > idle, - `a pan while accepted requests tiles (${idle} → ${asked})`, - ); - fixture.tiles = 'rejected'; - await clickPanelControl(page, '#sl-status'); - await statusText('OFF'); - await clickPanelControl(page, '#sl-status'); - await statusText('KEY REJECTED'); - const shown = await page.evaluate(() => ({ - error: document.getElementById('sl-error').textContent, - errorHidden: document.getElementById('sl-error').hidden, - controls: document.getElementById('sl-controls').disabled, - })); - assert.match(shown.error, /rejected MAPILLARY_CLIENT_TOKEN/); - assert.equal(shown.errorHidden, false); - assert.equal(shown.controls, true); - // Panning asks for nothing more: the verdict holds for every tile. - const rejectedAt = await tileRequestsQuiet(QUIET_MS); - await panTo(-121.42, 38.63); - const after = await tileRequestsQuiet(QUIET_MS); - assert.equal(after, rejectedAt, 'no tile requests while rejected'); - // Off clears the message; with a good key it comes back as ON. - await clickPanelControl(page, '#sl-status'); - await statusText('OFF'); - assert.equal( - await page.$eval( - '#sl-error', - (node) => node.hidden || !node.textContent, - ), - true, - 'no stale error while the layer is off', - ); - fixture.tiles = 'ok'; - await clickPanelControl(page, '#sl-status'); - await waitForCoverage(page); - await statusText('ON'); - }, - ); - else - await skipStep( - 'fixtures only', - 'a key Mapillary rejects reads KEY REJECTED and stops asking', - ); - - const fixtureIds = new Map( - fixture.images.map((image, index) => [image.id, { ...image, index }]), - ); - const waitForImage = (id = null) => - page.waitForFunction( - (want) => { - const s = window.__godsEyeView.dataManager.layers - .get('street-level') - .module.getUIState().street; - return ( - (s.imageId && !s.loading && (!want || s.imageId === want)) || - s.error - ); - }, - { timeout: 90_000 }, - id, - ); - const openById = async (id) => { - await page.evaluate((imageId) => { - void window.__godsEyeView.dataManager.layers - .get('street-level') - .module.openImage(imageId); - }, id); - await waitForImage(id); - const street = (await ui()).street; - assert.equal(street.error, null, `viewer error: ${street.error}`); - return street; - }; - - await park(); - await waitForCoverage(page); - await step( - 'a click on a coverage line selects its sequence and draws its cones', - async () => { - const original = await stacks.active(); - // Draped lines on flat imagery: the line is where it is drawn. - await stacks.set('esri-imagery'); + fixture.rejected = false; + await press(page, '#sl-status'); await waitForCoverage(page); - const target = fixtures ? PHOTO_SEQUENCE_ID : null; - const along = Array.from({ length: 9 }, (_, i) => [ - PHOTO_LINE.lon, - PHOTO_LINE.south + - ((i + 2) / 13) * (PHOTO_LINE.north - PHOTO_LINE.south), - ]); - // Ground primitives are built over a few frames: poll for a pick. - const handle = await page.waitForFunction( - findPickPoint, - { timeout: 30_000, polling: 250 }, - { - prefix: 'mly:seq:', - id: target, - points: fixtures ? along : null, - billboardIds: null, - }, - ); - const at = await handle.jsonValue(); - await page.mouse.click(at.x, at.y); - const selected = await uiUntil( - (u, id) => - u.sequence.selectedId === id && - !u.sequence.loading && - u.sequence.images > 0, - at.id, - { timeout: 30_000 }, - ); - const sequence = (await ui()).sequence; - assert.ok( - selected, - `the click at ${Math.round(at.x)},${Math.round(at.y)} on ${at.id} selected it: ${JSON.stringify(sequence)}`, - ); - if (fixtures) - assert.equal(sequence.images, fixture.images.length, 'every photo'); - // The cones are on the globe: one of them picks as an image. - const cone = await page.waitForFunction( - findPickPoint, - { timeout: 15_000, polling: 250 }, - { - prefix: 'mly:img:', - id: null, - points: fixtures - ? fixture.images.map((image) => [image.lon, image.lat]) - : null, - billboardIds: null, - // Live: the cones' positions are only known from the globe. - billboardPrefix: fixtures ? null : 'mly:img:', - }, - ); - assert.ok(await cone.jsonValue(), 'an image cone is pickable'); - await page.evaluate(() => - window.__godsEyeView.dataManager.layers - .get('street-level') - .module.clearSequence(), - ); - await uiUntil((u) => u.sequence.selectedId === null); - await stacks.set(original); + await statusIs(page, 'ON'); }, ); - let firstImageId = null; - await park(); + let imageId = null; + await park(page); await waitForCoverage(page); await step( - 'opening the nearest image shows the viewer with a caption', + 'the viewer loads and the nearest photo opens with a caption', async () => { - // Fire and forget: the open can outlive one CDP call, so poll instead. await page.evaluate(() => { + // Fire and forget: the open can outlive one CDP call. void window.__godsEyeView.dataManager.layers .get('street-level') .module.openNearest(); }); - await waitForImage(); - const street = (await ui()).street; - assert.equal(street.error, null, `viewer error: ${street.error}`); - // Some images carry no creator name; the date/bearing side always fills. - const filled = await settle( + await uiUntil( page, + (u) => (u.street.imageId && !u.street.loading) || u.street.error, + null, + 90_000, + ); + const { street } = await getUI(page); + assert.equal(street.error, null, `viewer error: ${street.error}`); + imageId = street.imageId; + await page.waitForSelector('#sl-viewer .mapillary-dom', { + timeout: 60_000, + }); + await page.waitForFunction( () => document.getElementById('sl-image-when').textContent.trim() .length > 0, - null, { timeout: 30_000 }, ); - if (!filled) { - const dump = await page.evaluate(() => { - const u = window.__godsEyeView.dataManager.layers - .get('street-level') - .module.getUIState(); - return { - street: u.street, - captionNodes: - document.querySelectorAll('#sl-image-when').length, - captionNow: - document.getElementById('sl-image-when').textContent, - wrapHidden: document.getElementById('sl-viewer-wrap').hidden, - collapsed: document - .getElementById('street-level-panel') - .classList.contains('collapsed'), - }; - }); - throw new Error(`caption never filled: ${JSON.stringify(dump)}`); - } const view = await page.evaluate(() => ({ hidden: document.getElementById('sl-viewer-wrap').hidden, - when: document.getElementById('sl-image-when').textContent.trim(), - link: document.getElementById('sl-image-link').textContent.trim(), - href: document.getElementById('sl-image-link').href, - width: Math.round( - document.getElementById('sl-viewer').getBoundingClientRect() - .width, - ), + width: document.getElementById('sl-viewer').getBoundingClientRect() + .width, + link: document.getElementById('sl-image-link').href, })); assert.equal(view.hidden, false); - assert.ok(view.width > 200); - assert.ok(view.when.length > 0, 'caption shows the capture date'); - firstImageId = street.imageId; - assert.equal(view.link, 'MAPILLARY ↗'); + assert.ok(view.width > 200, `viewer width ${view.width}`); + assert.match(view.link, /mapillary\.com\/app\/\?pKey=/); if (fixtures) { - const known = fixtureIds.get(street.imageId); - assert.ok(known, `a fixture photo opened (${street.imageId})`); - assert.equal(street.isPano, known.isPano, 'pano flag from the ent'); + assert.ok( + fixture.images.some((image) => image.id === imageId), + `fixture photo ${imageId}`, + ); assert.equal(street.sequenceId, PHOTO_SEQUENCE_ID); } - const fit = await page.evaluate(() => { - const inner = document.querySelector('.street-level-panel-inner'); - const box = inner.getBoundingClientRect(); - const wrap = document - .getElementById('sl-viewer-wrap') - .getBoundingClientRect(); - return { - scrollTop: inner.scrollTop, - top: wrap.top - box.top, - overflowBottom: wrap.bottom - box.bottom, - }; - }); - assert.equal(fit.scrollTop, 0, 'panel not scrolled'); - assert.ok( - fit.top >= 0 && fit.top < 80, - `viewer right under the header (${fit.top}px)`, - ); - assert.ok( - fit.overflowBottom <= 1, - `whole viewer visible (${fit.overflowBottom}px cut)`, - ); - assert.match(view.href, /mapillary\.com\/app\/\?pKey=/); - // The open selects the photo's sequence: its cones are drawn. - assert.ok( - await uiUntil( - (u) => u.sequence.selectedId === u.street.sequenceId, - null, - { timeout: 30_000 }, - ), - 'the open photo’s sequence is selected', - ); - }, - ); - if (fixtures) - await step( - 'a click on a neighbouring cone steps the viewer to that photo (360° and flat)', - async () => { - // Let the framing flight land, so the cone does not move under the click. - await settle( - page, - () => !window.__godsEyeView.viewer.camera._currentFlight, - null, - { timeout: 10_000 }, - ); - await uiUntil((u) => u.sequence.images > 0 && !u.sequence.loading); - const current = fixtureIds.get(firstImageId); - const neighbour = [1, -1, 2, -2, 4, -4] - .map((offset) => fixture.images[current.index + offset]) - .find((image) => image && image.isPano !== current.isPano); - assert.ok(neighbour, 'a neighbour of the other camera type'); - const handle = await page.waitForFunction( - findPickPoint, - { timeout: 15_000, polling: 250 }, - { - prefix: 'mly:img:', - id: neighbour.id, - points: null, - billboardIds: [`mly:img:${neighbour.id}`], - }, - ); - const at = await handle.jsonValue(); - await page.mouse.click(at.x, at.y); - await waitForImage(neighbour.id); - const street = (await ui()).street; - assert.equal(street.error, null); - assert.equal(street.imageId, neighbour.id, 'stepped to the cone'); - assert.equal(street.isPano, neighbour.isPano); - assert.notEqual(street.isPano, current.isPano, 'both kinds shown'); - await openById(firstImageId); - }, - ); - else - await skipStep( - 'fixtures only', - 'a click on a neighbouring cone steps the viewer to that photo', - ); - await park(); - await step( - 'closing a photo stops the framing flight toward it', - async () => { - // Opening frames the photo with a 1.6 s camera flight; closing right - // away must cancel it, leaving the camera where it was. - const result = await page.evaluate(async (id) => { - const v = window.__godsEyeView.viewer; - const module = - window.__godsEyeView.dataManager.layers.get( - 'street-level', - ).module; - const opened = await module.openImage(id); - const flying = Boolean(v.camera._currentFlight); - const from = v.camera.positionCartographic.clone(); - module.closeViewer(); - const stillFlying = Boolean(v.camera._currentFlight); - const frames = (n) => - new Promise((resolve) => { - const tick = () => - --n <= 0 ? resolve() : requestAnimationFrame(tick); - requestAnimationFrame(tick); - }); - await frames(20); - const to = v.camera.positionCartographic; - return { - opened, - flying, - stillFlying, - moved: Math.hypot( - (to.longitude - from.longitude) * 6_371_000, - (to.latitude - from.latitude) * 6_371_000, - to.height - from.height, - ), - height: to.height, - }; - }, firstImageId); - assert.equal(result.opened, true, 'the photo opened'); - assert.equal(result.flying, true, 'opening started a framing flight'); - assert.equal(result.stillFlying, false, 'closing cancelled it'); - assert.ok( - result.moved < 0.5, - `the camera stopped where it was (moved ${result.moved.toFixed(2)} m)`, - ); - assert.ok( - result.height > PARK.height / 2, - `the camera never reached the photo (${Math.round(result.height)} m up)`, - ); - assert.equal((await ui()).street.open, false); - }, - ); - await step( - '× closes the image and deselects it on the globe', - async () => { - await openById(firstImageId); - await clickPanelControl(page, '#sl-viewer-close'); + // Opening selects the photo's sequence, so its cones are drawn. await uiUntil( - (u) => !u.street.open && u.sequence.selectedId === null, + page, + (u) => u.sequence.selectedId === u.street.sequenceId, ); - const after = await ui(); - assert.equal(after.street.open, false); - assert.equal(after.sequence.selectedId, null); }, ); + + await step('× closes the photo and deselects its sequence', async () => { + await press(page, '#sl-viewer-close'); + await uiUntil( + page, + (u) => !u.street.open && u.sequence.selectedId === null, + ); + assert.equal( + await page.$eval('#sl-viewer-wrap', (node) => node.hidden), + true, + ); + }); + await step('on a phone the whole photo fits in the panel', async () => { // Width alone drives the phone layout; toggling isMobile would reload. - await page.setViewport({ width: 390, height: 844 }); - await settle(page, () => innerWidth === 390); - if ( - await page.$eval('#street-level-panel', (node) => - node.classList.contains('collapsed'), - ) - ) - await expandStrip(); - await openById(firstImageId); - const measure = () => - page.evaluate(() => { - const inner = document - .querySelector('.street-level-panel-inner') - .getBoundingClientRect(); - const meta = document - .getElementById('sl-image-meta') - .getBoundingClientRect(); - const viewer = document - .getElementById('sl-viewer') - .getBoundingClientRect(); - return { - cut: Math.round(meta.bottom - inner.bottom), - top: Math.round(viewer.top - inner.top), - height: Math.round(viewer.height), - overflowX: document.documentElement.scrollWidth > innerWidth, - }; - }); + await page.setViewport(PHONE); + await page.waitForFunction((w) => innerWidth === w, {}, PHONE.width); + await expandPanel(page); + await page.evaluate((id) => { + void window.__godsEyeView.dataManager.layers + .get('street-level') + .module.openImage(id); + }, imageId); + await uiUntil( + page, + (u, id) => u.street.imageId === id && !u.street.loading, + imageId, + 60_000, + ); // The panel settles its phone layout over a frame or two. - await settle(page, () => { + const fits = () => { const inner = document .querySelector('.street-level-panel-inner') .getBoundingClientRect(); const meta = document .getElementById('sl-image-meta') .getBoundingClientRect(); - return ( - meta.bottom - inner.bottom <= 1 && - document.getElementById('sl-viewer').getBoundingClientRect() - .height >= 100 - ); - }); - const fit = await measure(); + const viewer = document + .getElementById('sl-viewer') + .getBoundingClientRect(); + return { + cut: Math.round(meta.bottom - inner.bottom), + height: Math.round(viewer.height), + overflowX: document.documentElement.scrollWidth > innerWidth, + }; + }; + await page + .waitForFunction( + `(${fits})().cut <= 1 && (${fits})().height >= 100`, + { timeout: 10_000 }, + ) + .catch(() => {}); + const fit = await page.evaluate(fits); assert.ok(fit.cut <= 1, `photo and caption fit (${fit.cut}px cut)`); assert.ok(fit.height >= 100, `viewer stays usable (${fit.height}px)`); assert.equal(fit.overflowX, false, 'no sideways scroll'); - await page.setViewport(VIEWPORTS[0]); - await settle(page, (width) => innerWidth === width, VIEWPORTS[0].width); + await page.setViewport(VIEWPORT); }); - await step( - 'a reload with the layer on keeps a collapsed panel collapsed, and the share link carries its ui token', + + await fixtureStep( + 'keyless server (second page): KEY REQUIRED gates the controls', async () => { - await page.evaluate(() => - window.__godsEyeView.dataManager.layers - .get('street-level') - .module.closeViewer(), + const keyless = await open( + 'keyless', + newFixture({ configured: false }), ); - // Press through the DOM: a toast over the button must not make this - // step about pointer aim. - await page.$eval( - '.panel-collapse-btn[data-collapse-target="street-level-panel"]', - (button) => { - const panel = document.getElementById('street-level-panel'); - if (!panel.classList.contains('collapsed')) button.click(); - }, - ); - assert.ok( - await settle(page, () => - document - .getElementById('street-level-panel') - .classList.contains('collapsed'), - ), - 'the panel collapsed', - ); - const shared = (collapsed) => - settle( - page, - (assignment) => - (new URLSearchParams(location.hash.slice(1)).get('ui') || '') - .split('_') - .includes(assignment), - `${PANEL_UI_TOKEN}.c.${collapsed ? '1' : '0'}`, - ); - assert.ok( - await shared(true), - `the share link records the collapsed panel as ui ${PANEL_UI_TOKEN}.c.1: ${await page.evaluate(() => location.hash)}`, - ); - await page.reload({ waitUntil: 'domcontentloaded' }); - await clearFirstRun(page); - await waitForCoverage(page); - // The panel would open on the enable transition, before coverage. - const after = await page.evaluate(() => ({ - enabled: window.__godsEyeView.dataManager.isEnabled('street-level'), - collapsed: document - .getElementById('street-level-panel') - .classList.contains('collapsed'), - })); - assert.deepEqual( - after, - { enabled: true, collapsed: true }, - 'the restored layer did not reopen the panel', - ); - assert.ok(await shared(true), 'still shared as collapsed'); + await expandPanel(keyless); + await assertKeyRequired(keyless); + await collectRenderErrors(keyless, 'keyless'); + await keyless.close(); + }, + ); + + await fixtureStep( + 'every Mapillary request was answered from a fixture', + () => { + console.log(` (answered: ${[...fixture.calls].sort().join(' | ')})`); + assert.deepEqual(fixture.unknown, []); }, ); - if (fixtures) - await step( - 'keyless install (a second page, no key on the server) gates the controls and reports KEY REQUIRED', - async () => { - const keyless = await browser.newPage(); - await keyless.setViewport(VIEWPORTS[0]); - await watch(keyless, 'keyless'); - await serveFixtures(keyless, newFixture({ configured: false })); - await boot(keyless, url); - await expandStrip(keyless); - await assertKeylessGate(keyless); - renderErrorsSeen.push( - ...(await readRenderErrors(keyless)).map( - (text) => `[keyless] scene.renderError: ${text}`, - ), - ); - await keyless.close(); - }, - ); - else - await skipStep( - 'fixtures only', - 'keyless install (a second page) reports KEY REQUIRED', - ); - if (fixtures) - await step( - 'every Mapillary request was answered from a fixture', - () => { - console.log( - ` (answered: ${[...fixture.calls].sort().join(' | ')})`, - ); - assert.deepEqual(fixture.unknown, [], 'no unanswered call'); - }, - ); } + + await step('no page errors and no Cesium render-loop errors', async () => { + await collectRenderErrors(page, 'main'); + assert.deepEqual(errors, []); + }); + if (strict) + await step('strict: no skips other than "fixtures only"', () => + assert.deepEqual( + skips.filter((reason) => reason !== 'fixtures only'), + [], + ), + ); + console.log( + `${count} steps: ${count - skips.length} ok, ${skips.length} skipped`, + ); } catch (error) { const dir = await saveFailureArtifacts({ - dir: ARTIFACT_DIR, + dir: 'qa-artifacts/street-level', browser, monitors, error, diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index 3eeda8bd8..e6089d34c 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -318,8 +318,6 @@ export function createStreetLevelLayer({ attachViewerHost(element) { parts.viewerHost.attach(element); }, - /** Imagery filter for coverage, cones and nearest-image lookups. */ - setCoverageFilter, openImage, /** Open the nearest image around a point (default: the view centre). */ async openNearest(point) { @@ -366,7 +364,6 @@ export function createStreetLevelLayer({ resizeViewer: () => parts.viewerHost.resize(), selectSequence: (sequenceId) => parts.sequences.select(sequenceId), clearSequence: () => parts.sequences.clearSelection(), - refreshCoverage: () => parts.coverage.refresh(), }; return layer; } diff --git a/src/layers/streetLevel/index.test.mjs b/src/layers/streetLevel/index.test.mjs index 2627efea4..069238c51 100644 --- a/src/layers/streetLevel/index.test.mjs +++ b/src/layers/streetLevel/index.test.mjs @@ -143,18 +143,6 @@ function slowLookups() { return { lookups, source: fakeMapillarySource({ nearestImages }) }; } -test('the layer is Mapillary under the codec keys it has always had', () => { - const layer = createStreetLevelLayer({ source: fakeMapillarySource() }); - assert.equal(layer.id, 'street-level'); - assert.equal(layer.requiresKeyId, 'mapillary'); - assert.deepEqual(layer.getParams(), { - mapillary: true, - pano: 'all', - sinceDays: 0, - }); - assert.throws(() => createStreetLevelLayer({ source: {} }), /source/); -}); - test('the CC BY-SA credit shows while Mapillary is on and goes with it', async (t) => { const { layer, viewer } = await startLayer(t); const shown = () => viewer.credits.map((credit) => credit.html); @@ -284,7 +272,7 @@ test('a sequence error is withdrawn with the sequence, never the viewer’s own' assert.match(layer.getUIState().street.error, /Open the Street Level panel/); }); -test('closing the photo stops the globe flying to it', async (t) => { +test('closing the photo, switching the layer off or destroying it stops the framing flight', async (t) => { const { layer, viewer } = await startLayer(t, { photoViewer: posingViewer(), }); @@ -292,35 +280,16 @@ test('closing the photo stops the globe flying to it', async (t) => { assert.equal(await layer.openImage('img1'), true); assert.equal(viewer.flights.started, 1, 'the photo is framed'); layer.closeViewer(); - assert.equal(viewer.flights.cancelled, 1); -}); + assert.equal(viewer.flights.cancelled, 1, 'closed'); -test('switching the layer off or destroying it stops the framing flight', async (t) => { - const { layer, viewer } = await startLayer(t, { - photoViewer: posingViewer(), - }); - layer.attachViewerHost({}); - await layer.openImage('img1'); + await layer.openImage('img2'); layer.disable(); - assert.equal(viewer.flights.cancelled, 1, 'layer off'); + assert.equal(viewer.flights.cancelled, 2, 'layer off'); layer.enable(viewer); - await layer.openImage('img2'); + await layer.openImage('img3'); layer.destroy(); - assert.equal(viewer.flights.cancelled, 2, 'destroyed'); -}); - -test('closing the photo leaves a newer navigation flight alone', async (t) => { - const { layer, viewer } = await startLayer(t, { - photoViewer: posingViewer(), - }); - layer.attachViewerHost({}); - await layer.openImage('img1'); - assert.equal(viewer.flights.started, 1, 'the photo is being framed'); - // A search result flies the globe elsewhere before the framing lands. - viewer.camera.flyToBoundingSphere(null, {}); - layer.closeViewer(); - assert.equal(viewer.flights.cancelled, 0, 'the search flight keeps going'); + assert.equal(viewer.flights.cancelled, 3, 'destroyed'); }); test('an older nearest lookup that answers late cannot replace a newer one', async (t) => { @@ -383,7 +352,13 @@ test('closing the viewer or switching the layer off retires a nearest lookup', a }); test('setParams takes "any date" (0 days) and the switch over the current values (share-link defaults)', async (t) => { + assert.throws(() => createStreetLevelLayer({ source: {} }), /source/); const { layer } = await startLayer(t); + assert.deepEqual(layer.getParams(), { + mapillary: true, + pano: 'all', + sinceDays: 0, + }); layer.setParams({ pano: 'flat', sinceDays: 365 }); assert.equal(layer.getParams().sinceDays, 365); layer.setParams({ sinceDays: 0 }); diff --git a/src/layers/streetLevel/providers/mapillary/coverage.js b/src/layers/streetLevel/providers/mapillary/coverage.js index 7be043755..879e628d8 100644 --- a/src/layers/streetLevel/providers/mapillary/coverage.js +++ b/src/layers/streetLevel/providers/mapillary/coverage.js @@ -57,7 +57,7 @@ export function sequenceIdFromPick(pickId) { /** Hint above the street-zoom ceiling, where no coverage is drawn. */ export const ZOOM_IN_HINT = 'Zoom in to see street-level coverage'; /** Hint when no ground is in view within range (looking at the sky). */ -export const NO_GROUND_HINT = +const NO_GROUND_HINT = 'Point the camera at the ground for street-level coverage'; /** diff --git a/src/layers/streetLevel/providers/mapillary/coverageFixture.mjs b/src/layers/streetLevel/providers/mapillary/coverageFixture.mjs index c598a9ed8..b7b738056 100644 --- a/src/layers/streetLevel/providers/mapillary/coverageFixture.mjs +++ b/src/layers/streetLevel/providers/mapillary/coverageFixture.mjs @@ -3,7 +3,7 @@ import { PbfWriter } from 'pbf'; 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) { +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; diff --git a/src/layers/streetLevel/providers/mapillary/decode.test.mjs b/src/layers/streetLevel/providers/mapillary/decode.test.mjs index d8b3cf7b2..0c0873535 100644 --- a/src/layers/streetLevel/providers/mapillary/decode.test.mjs +++ b/src/layers/streetLevel/providers/mapillary/decode.test.mjs @@ -19,7 +19,7 @@ function linePart(points) { /** * A minimal mly1_public-shaped tile: one sequence line, plus one point in the - * low-zoom `overview` layer the layer never draws. + * z14 `image` layer the decoder skips. */ function sampleTile({ geometry = null } = {}) { const writer = new PbfWriter(); @@ -73,7 +73,7 @@ function sampleTile({ geometry = null } = {}) { 3, (_, layer) => { layer.writeVarintField(15, 2); - layer.writeStringField(1, 'overview'); + layer.writeStringField(1, 'image'); layer.writeVarintField(5, 4096); layer.writeStringField(3, 'is_pano'); layer.writeMessage( @@ -97,7 +97,7 @@ function sampleTile({ geometry = null } = {}) { return new Uint8Array(writer.finish()); } -test('sequences decode into lon/lat records; overview points are ignored', () => { +test('sequences decode into lon/lat records; image points are skipped', () => { const decoded = decodeCoverageTile(sampleTile(), { x: 0, y: 0, z: 1 }); assert.equal(decoded.sequences.length, 1); const [sequence] = decoded.sequences; diff --git a/src/layers/streetLevel/providers/mapillary/mapillary.test.mjs b/src/layers/streetLevel/providers/mapillary/mapillary.test.mjs index 8e6fcb914..8b3d7fd08 100644 --- a/src/layers/streetLevel/providers/mapillary/mapillary.test.mjs +++ b/src/layers/streetLevel/providers/mapillary/mapillary.test.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { nearestImageId } from './nearest.js'; -import { COLORS, mapillaryImageUrl } from './policy.js'; +import { mapillaryImageUrl } from './policy.js'; import { sequenceIdFromPick } from './coverage.js'; import { thinImages } from './sequences.js'; import { resolveFilter } from '../../filter.js'; @@ -27,8 +27,7 @@ const at = (id, lon, lat) => ({ geometry: { type: 'Point', coordinates: [lon, lat] }, }); -test('Mapillary draws in its green; image deep links match the mapillary.com share format', () => { - assert.equal(COLORS.coverage, '#05cb63'); +test('image deep links match the mapillary.com share format', () => { assert.equal( mapillaryImageUrl(1814275685699406), 'https://www.mapillary.com/app/?pKey=1814275685699406&focus=photo', diff --git a/src/layers/streetLevel/providers/mapillary/sequences.js b/src/layers/streetLevel/providers/mapillary/sequences.js index 4cbc145a6..887145a16 100644 --- a/src/layers/streetLevel/providers/mapillary/sequences.js +++ b/src/layers/streetLevel/providers/mapillary/sequences.js @@ -15,7 +15,7 @@ const SPRITE_ID = 'street-level:mapillary-cones'; const SEQUENCE_CACHE_SIZE = 40; /** Normalize a graph image record into the shape the cones use. */ -export function normalizeSequenceImage(record) { +function normalizeSequenceImage(record) { const coordinates = record?.geometry?.coordinates; if (!Array.isArray(coordinates) || coordinates.length < 2) return null; return { diff --git a/src/layers/streetLevel/providers/mapillary/source.test.mjs b/src/layers/streetLevel/providers/mapillary/source.test.mjs index 784254185..e7927e90b 100644 --- a/src/layers/streetLevel/providers/mapillary/source.test.mjs +++ b/src/layers/streetLevel/providers/mapillary/source.test.mjs @@ -104,12 +104,6 @@ test('graph API errors carry the upstream message and status', async () => { ); }); -test('the source exposes only imagery lookups', () => { - const source = createMapillarySource({ token: 'MLY|1|abc' }); - for (const gone of ['queryFeatures', 'plan', 'geocode', 'spriteUrl']) - assert.equal(source[gone], undefined, gone); -}); - test('a rejected key and a rate limit are told apart from other tile errors', async () => { const rejectedSource = createMapillarySource({ token: 'MLY|1|abc', diff --git a/src/layers/streetLevel/tileMath.test.mjs b/src/layers/streetLevel/tileMath.test.mjs index 065b423e6..344864f93 100644 --- a/src/layers/streetLevel/tileMath.test.mjs +++ b/src/layers/streetLevel/tileMath.test.mjs @@ -41,13 +41,6 @@ test('normalizeBbox orders and clamps coordinates', () => { assert.equal(normalizeBbox([1, 2, Number.NaN, 3]), null); }); -test('city-scale boxes span many z14 tiles', () => { - const sacramento = [-121.56, 38.44, -121.36, 38.69]; - assert.equal(tilesForBbox(sacramento, 14).total, 160); - const detroit = [-83.29, 42.25, -82.91, 42.45]; - assert.equal(tilesForBbox(detroit, 14).total, 234); -}); - test('tilesForBbox orders from the centre outwards and honours the cap', () => { const result = tilesForBbox([-121.56, 38.44, -121.36, 38.69], 14, { limit: 5, diff --git a/src/layers/streetLevel/view.js b/src/layers/streetLevel/view.js index 899dc8715..c0aa6a6d4 100644 --- a/src/layers/streetLevel/view.js +++ b/src/layers/streetLevel/view.js @@ -154,7 +154,7 @@ export function cameraNadir(viewer) { * Horizon occluder depth below WGS84 (m): ground can lie below the ellipsoid * (NYC −22 m), and an occluder at 0 m would hide the cones around it. */ -export const HORIZON_CULL_DEPTH_M = 1000; +const HORIZON_CULL_DEPTH_M = 1000; const HORIZON_CULL_ELLIPSOID = new Cesium.Ellipsoid( Cesium.Ellipsoid.WGS84.radii.x - HORIZON_CULL_DEPTH_M, Cesium.Ellipsoid.WGS84.radii.y - HORIZON_CULL_DEPTH_M, diff --git a/src/layers/streetLevel/viewerHost.test.mjs b/src/layers/streetLevel/viewerHost.test.mjs index 624e361ee..59c6b9d6f 100644 --- a/src/layers/streetLevel/viewerHost.test.mjs +++ b/src/layers/streetLevel/viewerHost.test.mjs @@ -189,24 +189,6 @@ function deferred() { return { promise, resolve }; } -test('switching the layer off and on during a cold first open keeps the shared viewer', async () => { - const gate = deferred(); - const adapter = fakeAdapter({ gate }); - const { state, host } = harness(adapter); - const first = host.open('a'); // the library is still loading - host.unmount(); // layer off … - const second = host.open('b'); // … on again, and a new click - gate.resolve(); - await Promise.all([first, second]); - assert.equal(adapter.calls.unmount, 1, 'only the layer going off'); - assert.equal(adapter.calls.listeners, 1); - assert.equal(state.street.error, null); - assert.equal(state.street.imageId, 'b'); - await host.open('c'); - assert.equal(state.street.error, null, 'later opens are not stuck'); - assert.equal(state.street.imageId, 'c'); -}); - const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); test('every pose the viewer reports moves the marker; only the open frames the camera', async () => { @@ -393,15 +375,6 @@ test('a Mapillary viewer unmounted mid-download is never built; the next mount b assert.equal(viewers.live, 0, 'nothing left holding a WebGL context'); }); -test('the Mapillary viewer leaves resizing to the panel, so a hidden one never asks for z=NaN', async () => { - const { gate, viewers } = fakeLibrary(); - gate.resolve(); - const adapter = createMapillaryViewer({ source: { token: 't' } }); - await adapter.mount({}); - assert.equal(viewers.instances[0].options.trackResize, false); - adapter.unmount(); -}); - test('closing the photo stops sequence playback in the hidden viewer', async () => { const { gate, viewers } = fakeLibrary(); gate.resolve(); @@ -415,18 +388,6 @@ test('closing the photo stops sequence playback in the hidden viewer', async () assert.equal(viewers.live, 1, 'the viewer itself stays warm'); }); -test('closing the photo or switching the layer off stops its framing flight', async () => { - const adapter = fakeAdapter(); - const { framing, host } = harness(adapter); - await host.open('a'); - assert.equal(framing.started, 1, 'the open framed the photo'); - host.close(); - assert.equal(framing.cancelled, 1, 'closed: the globe stops flying to it'); - await host.open('b'); - host.unmount(); - assert.equal(framing.cancelled, 2, 'layer off: likewise'); -}); - /** The real Mapillary adapter, mounted on a ready library, and its poses. */ async function mountedMapillary() { const library = fakeLibrary(); diff --git a/src/streetLevelMarkup.test.mjs b/src/streetLevelMarkup.test.mjs index 03d2d1f9f..27246310a 100644 --- a/src/streetLevelMarkup.test.mjs +++ b/src/streetLevelMarkup.test.mjs @@ -13,10 +13,8 @@ const html = expandApplicationHtml(read('index.html')); const css = readStylesheet(path.join(ROOT, 'style.css')); const panelChrome = read('src/ui/panelChrome.js'); const layoutController = read('src/ui/panelLayoutController.js'); -const panelCss = read('src/ui/styles/street-level.css'); -const controls = read('src/ui/streetLevelControls.js'); -test('Street Level is an ordinary collapsible GEV panel that starts collapsed', () => { +test('Street Level is a collapsible right-rail panel that starts collapsed', () => { assert.match( html, /