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, /