diff --git a/scripts/qa-mapillary.mjs b/scripts/qa-mapillary.mjs new file mode 100644 index 000000000..2ce906d2e --- /dev/null +++ b/scripts/qa-mapillary.mjs @@ -0,0 +1,345 @@ +#!/usr/bin/env node +/** + * Browser QA for the Street Level (Mapillary) layer against a running dev + * server: the panel's place in the right rail, the keyless gate, coverage, + * a ready-made feature plan (no LLM), the embedded viewer and its expanded + * dialog. Run with `npm run qa:mapillary -- --url http://localhost:4173`. + * Without MAPILLARY_CLIENT_TOKEN on the server only the keyless steps run. + */ +import assert from 'node:assert/strict'; +import { pathToFileURL } from 'node:url'; + +/** Viewports every layout assertion runs at. */ +export const VIEWPORTS = Object.freeze([ + { width: 1440, height: 900 }, + { width: 1280, height: 800 }, +]); + +/** A feature plan the executor can run without the planner. */ +export function hydrantPlan() { + return { + intent: 'map_features', + layer: 'points', + place: null, + use_current_view: true, + values: ['object--fire-hydrant'], + seen_after: null, + seen_before: null, + prefer_pano: false, + visualise: 'icons', + title: 'Fire hydrants · current view', + answer: 'Showing fire hydrants in the current view.', + }; +} + +/** Expected right-rail order once the layout controller has run. */ +export const RAIL_ORDER = Object.freeze([ + 'pp-toggles', + 'cctv-panel', + 'mapillary-panel', + 'global-context-panel', +]); + +export function isCollapsed(classList) { + return [...classList].includes('collapsed'); +} + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function main() { + const { default: puppeteer } = await import('puppeteer'); + const args = process.argv.slice(2); + const urlIndex = args.indexOf('--url'); + const url = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173'; + const browser = await puppeteer.launch({ + headless: true, + executablePath: + process.env.PUPPETEER_EXECUTABLE_PATH || + (await puppeteer.executablePath()), + defaultViewport: VIEWPORTS[0], + protocolTimeout: 300_000, + args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'], + }); + let passed = 0; + const step = async (label, fn) => { + const result = await fn(); + passed++; + console.log(`ok ${passed} ${label}`); + return result; + }; + try { + const page = await browser.newPage(); + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + 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 sleep(800); + await page.keyboard.press('Escape'); + await sleep(400); + await page.evaluate(() => { + for (const el of document.querySelectorAll( + '#first-run-launcher, [class*=first-run]', + )) + el.remove(); + }); + const module = () => + window.__godsEyeView.dataManager.layers.get('mapillary').module; + const panel = () => + page.evaluate(() => { + const el = document.getElementById('mapillary-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), + keyless: !document.getElementById('mly-keyless').hidden, + controlsDisabled: document.getElementById('mly-controls').disabled, + bodyDisplay: getComputedStyle(document.getElementById('mly-body')) + .display, + }; + }); + const status = await page.evaluate(() => + fetch('/api/mapillary/status').then((res) => res.json()), + ); + for (const viewport of VIEWPORTS) { + await page.setViewport(viewport); + await sleep(600); + await step( + `panel is a collapsed right-rail strip at ${viewport.width}×${viewport.height}`, + async () => { + 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]); + await page.click( + '.panel-collapse-btn[data-collapse-target="mapillary-panel"]', + ); + await sleep(500); + await step( + 'expanding the strip shows the body and the four-swatch legend', + async () => { + const info = await panel(); + assert.ok(!isCollapsed(info.classes)); + assert.equal(info.bodyDisplay, 'flex'); + assert.equal( + await page.evaluate( + () => document.querySelectorAll('#mly-legend li').length, + ), + 4, + ); + }, + ); + if (!status.configured) { + await step( + 'keyless install gates the controls and names the token', + async () => { + await page.evaluate(() => + window.__godsEyeView.dataManager.setEnabled('mapillary', true, { + origin: 'user', + }), + ); + await sleep(800); + const info = await panel(); + assert.equal(info.keyless, true); + assert.equal(info.controlsDisabled, true); + assert.match( + await page.evaluate( + () => document.getElementById('mly-keyless').textContent, + ), + /MAPILLARY_CLIENT_TOKEN/, + ); + }, + ); + console.log( + 'keyless run complete (no MAPILLARY_CLIENT_TOKEN on the server)', + ); + return; + } + // The startup flight can still be running: cancel it, park the camera over + // downtown Sacramento and confirm it stays put before enabling the layer. + const park = () => + page.evaluate(() => { + 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(-121.4944, 38.5816, 900), + ), + orientation: { heading: 0, pitch: -1.3, roll: 0 }, + }); + }); + for (let attempt = 0; attempt < 6; attempt++) { + await park(); + await sleep(1500); + const stable = await page.evaluate(() => { + const c = window.__godsEyeView.viewer.camera.positionCartographic; + return Math.abs((c.longitude * 180) / Math.PI + 121.4944) < 0.01; + }); + if (stable) break; + } + await step( + 'enabling draws coverage and registers the on-globe credit', + async () => { + await page.evaluate(() => + window.__godsEyeView.dataManager.setEnabled('mapillary', true, { + origin: 'user', + }), + ); + await page.waitForFunction( + () => { + const u = window.__godsEyeView.dataManager.layers + .get('mapillary') + .module.getUIState(); + return u.coverage.sequences > 0 && !u.coverage.loading; + }, + { timeout: 90_000 }, + ); + const info = await panel(); + assert.equal(info.keyless, false); + // Cesium paints on-screen credits a frame or two after they register. + await page.waitForFunction( + () => document.body.innerHTML.includes('Mapillary contributors'), + { timeout: 15_000 }, + ); + }, + ); + await step( + 'a ready-made plan streams results and fills the chips', + async () => { + // Fire and forget: the fan-out can outlive a CDP call, so poll instead. + await page.evaluate((plan) => { + void window.__godsEyeView.dataManager.layers + .get('mapillary') + .module.runPlan(plan, { prompt: 'fire hydrants here' }); + }, hydrantPlan()); + await sleep(500); + await page.waitForFunction( + () => + !window.__godsEyeView.dataManager.layers + .get('mapillary') + .module.getUIState().query.busy, + { timeout: 120_000 }, + ); + const state = await page.evaluate(() => { + const u = window.__godsEyeView.dataManager.layers + .get('mapillary') + .module.getUIState(); + return { + total: u.features.total, + answer: u.query.answer, + chips: document.querySelectorAll( + '#mly-result-chips .mly-value-chip', + ).length, + }; + }); + assert.ok(state.total > 0, 'hydrants found in view'); + assert.match(state.answer, /fire hydrants?/i); + assert.ok(state.chips >= 1); + }, + ); + await step( + 'opening the nearest image shows the viewer with a caption', + async () => { + await page.evaluate(() => + window.__godsEyeView.dataManager.layers + .get('mapillary') + .module.openNearest(), + ); + await page.waitForFunction( + () => { + const s = window.__godsEyeView.dataManager.layers + .get('mapillary') + .module.getUIState().street; + return s.imageId || s.error; + }, + { timeout: 90_000 }, + ); + await sleep(2500); + const view = await page.evaluate(() => ({ + hidden: document.getElementById('mly-viewer-wrap').hidden, + by: document.getElementById('mly-image-by').textContent, + width: Math.round( + document.getElementById('mly-viewer').getBoundingClientRect().width, + ), + })); + assert.equal(view.hidden, false); + assert.ok(view.width > 200); + assert.match(view.by, /Image by/); + }, + ); + await step( + 'EXPAND opens a modal dialog and Esc returns focus to the button', + async () => { + await page.click('#mly-viewer-expand'); + await sleep(600); + const dialog = await page.evaluate(() => { + const wrap = document.getElementById('mly-viewer-wrap'); + return { + role: wrap.getAttribute('role'), + modal: wrap.getAttribute('aria-modal'), + inside: wrap.contains(document.activeElement), + width: Math.round(wrap.getBoundingClientRect().width), + }; + }); + assert.equal(dialog.role, 'dialog'); + assert.equal(dialog.modal, 'true'); + assert.equal(dialog.inside, true); + assert.ok(dialog.width > 900); + await page.keyboard.press('Escape'); + await sleep(400); + assert.equal( + await page + .evaluate(() => ({ + expanded: document + .getElementById('mly-viewer-wrap') + .classList.contains('mly-viewer-wrap-expanded'), + focus: document.activeElement?.id, + })) + .then((r) => `${r.expanded}:${r.focus}`), + 'false:mly-viewer-expand', + ); + }, + ); + await step('× closes the image and deselects it on the globe', async () => { + await page.click('#mly-viewer-close'); + await sleep(500); + const after = await page.evaluate(() => { + const u = window.__godsEyeView.dataManager.layers + .get('mapillary') + .module.getUIState(); + return { open: u.street.open, sequence: u.sequence.selectedId }; + }); + assert.equal(after.open, false); + assert.equal(after.sequence, null); + }); + await step('no page errors', () => { + assert.deepEqual(errors, []); + }); + void module; + } finally { + await browser.close(); + } +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index 577027069..1a3c62e82 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -158,8 +158,8 @@ function encode(state) { test('production registry is exact, canonical, and rejects incomplete contracts', async () => { assert.equal(validateLayerStateRegistry(), true); - assert.equal(REGISTERED_LAYER_IDS.length, 21); - assert.equal(new Set(REGISTERED_LAYER_IDS).size, 21); + assert.equal(REGISTERED_LAYER_IDS.length, 22); + assert.equal(new Set(REGISTERED_LAYER_IDS).size, 22); assert.ok(REGISTERED_LAYER_IDS.includes('transit')); assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort()); assert.throws( diff --git a/src/layers/mapillary/query.test.mjs b/src/layers/mapillary/query.test.mjs index e30ef47f1..07d7240c8 100644 --- a/src/layers/mapillary/query.test.mjs +++ b/src/layers/mapillary/query.test.mjs @@ -4,6 +4,8 @@ import { bboxFromGeocode, enforceMinimumSpan, fitBboxToTileBudget, + noResultsAnswer, + pluralLabel, summarizeResults, } from './query.js'; import { countTilesForBbox } from './tileMath.js'; @@ -54,15 +56,48 @@ test('summarizeResults reports totals, top classes and the sighting window', () }); assert.match( text, - /^1,234 features: 1,000 fire hydrant, 200 bench, 20 mailbox and 1 more classes/, + /^1,234 features: 1,000 fire hydrants, 200 benches, 20 mailboxes and 1 more class/, ); assert.match(text, /seen 2019–2026 · capped · 2 tiles failed$/); +}); + +test('summarizeResults leads with the class when there is only one', () => { + const text = summarizeResults({ + total: 834, + counts: new Map([['object--fire-hydrant', 834]]), + earliest: Date.UTC(2017, 0, 1), + latest: Date.UTC(2021, 0, 1), + failed: 1, + }); + assert.equal(text, '834 fire hydrants · seen 2017–2021 · 1 tile failed'); assert.equal( - summarizeResults({ total: 0, counts: new Map() }), - 'No matching features in this area.', + summarizeResults({ total: 1, counts: new Map([['object--bench', 1]]) }), + '1 bench', ); }); +test('zero results produce no summary so the answer can be replaced', () => { + assert.equal(summarizeResults({ total: 0, counts: new Map() }), ''); + assert.equal( + noResultsAnswer( + { title: 'Fire hydrants · Springfield' }, + { label: 'Springfield, IL' }, + ), + 'No fire hydrants mapped in Springfield, IL yet. Try a larger area, another place, or set SINCE back to any date.', + ); + assert.match( + noResultsAnswer({}, null), + /^No matching features mapped here yet/, + ); +}); + +test("pluralLabel handles the taxonomy's English labels", () => { + assert.equal(pluralLabel('Fire hydrant', 2), 'fire hydrants'); + assert.equal(pluralLabel('Bench', 3), 'benches'); + assert.equal(pluralLabel('Mailbox', 4), 'mailboxes'); + assert.equal(pluralLabel('Bench', 1), 'bench'); +}); + test('node-sized geocode bounds are grown to a queryable neighbourhood box', () => { const tiny = bboxFromGeocode({ geometry: { diff --git a/src/layers/mapillary/source.test.mjs b/src/layers/mapillary/source.test.mjs new file mode 100644 index 000000000..f76a5d495 --- /dev/null +++ b/src/layers/mapillary/source.test.mjs @@ -0,0 +1,145 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createMapillarySource, MapillarySourceError } from './source.js'; + +function fakeFetch(handler) { + const calls = []; + const fetchImpl = async (url, init = {}) => { + calls.push({ url: String(url), init }); + return handler(String(url), init); + }; + return { calls, fetchImpl }; +} + +const jsonResponse = (payload, status = 200) => + new Response(JSON.stringify(payload), { + status, + headers: { 'content-type': 'application/json' }, + }); + +test('tiles go through the local proxy and never carry the token', async () => { + const { calls, fetchImpl } = fakeFetch( + () => new Response(new Uint8Array([1, 2, 3]), { status: 200 }), + ); + const source = createMapillarySource({ token: 'MLY|1|abc', fetchImpl }); + const bytes = await source.getTile('coverage', 14, 2662, 6285); + assert.deepEqual([...bytes], [1, 2, 3]); + assert.equal(calls[0].url, '/api/mapillary/tiles/coverage/14/2662/6285'); + assert.doesNotMatch(calls[0].url, /MLY/); +}); + +test('an empty tile (204) is an empty byte array, not an error', async () => { + const { fetchImpl } = fakeFetch(() => new Response(null, { status: 204 })); + const source = createMapillarySource({ token: 'MLY|1|abc', fetchImpl }); + assert.equal((await source.getTile('coverage', 14, 1, 1)).length, 0); +}); + +test('a missing key on the proxy surfaces as keyRequired', async () => { + const { fetchImpl } = fakeFetch(() => + jsonResponse({ error: 'no_key', keyRequired: true }, 503), + ); + const source = createMapillarySource({ token: '', fetchImpl }); + assert.equal(source.hasToken(), false); + await assert.rejects( + () => source.getTile('coverage', 14, 1, 1), + (error) => + error instanceof MapillarySourceError && + error.status === 503 && + error.keyRequired === true, + ); +}); + +test('graph lookups call graph.mapillary.com with the client token and requested fields', async () => { + const { calls, fetchImpl } = fakeFetch(() => + jsonResponse({ data: [{ id: '1', geometry: { coordinates: [1, 2] } }] }), + ); + const source = createMapillarySource({ token: 'MLY|1|abc', fetchImpl }); + const images = await source.getSequenceImages('seq-1'); + assert.equal(images.length, 1); + const url = new URL(calls[0].url); + assert.equal(url.host, 'graph.mapillary.com'); + assert.equal(url.pathname, '/images'); + assert.equal(url.searchParams.get('access_token'), 'MLY|1|abc'); + assert.equal(url.searchParams.get('sequence_ids'), 'seq-1'); + assert.match(url.searchParams.get('fields'), /geometry/); + + const nearest = await source.nearestImages({ + lat: 38.5, + lon: -121.5, + radius: 500, + limit: 500, + }); + assert.equal(nearest.length, 1); + const nearestUrl = new URL(calls[1].url); + assert.equal( + nearestUrl.searchParams.get('radius'), + '50', + 'radius is clamped to the API maximum', + ); + assert.equal(nearestUrl.searchParams.get('limit'), '100'); +}); + +test('graph calls without a token fail fast as keyRequired without a request', async () => { + const { calls, fetchImpl } = fakeFetch(() => jsonResponse({})); + const source = createMapillarySource({ token: '', fetchImpl }); + await assert.rejects( + () => source.getImage('123'), + (error) => + error instanceof MapillarySourceError && error.keyRequired === true, + ); + assert.equal(calls.length, 0); +}); + +test('graph API errors carry the upstream message and status', async () => { + const { fetchImpl } = fakeFetch(() => + jsonResponse({ error: { message: 'Invalid OAuth 2.0 Access Token' } }, 400), + ); + const source = createMapillarySource({ token: 'MLY|1|abc', fetchImpl }); + await assert.rejects( + () => source.getMapFeature('999'), + (error) => + error instanceof MapillarySourceError && + error.status === 400 && + /Invalid OAuth/.test(error.message), + ); +}); + +test('feature queries stream NDJSON records and resolve with the done record', async () => { + const lines = [ + JSON.stringify({ type: 'start', tiles: 2 }), + JSON.stringify({ + type: 'tile', + rows: [[-121.5, 38.5, 'a', 'object--bench', 0, 0]], + }), + JSON.stringify({ type: 'done', total: 1 }), + ].join('\n'); + const { calls, fetchImpl } = fakeFetch( + () => + new Response(lines, { + status: 200, + headers: { 'content-type': 'application/x-ndjson' }, + }), + ); + const source = createMapillarySource({ token: 'MLY|1|abc', fetchImpl }); + const seen = []; + const done = await source.queryFeatures( + { + bbox: [-121.5, 38.5, -121.4, 38.6], + layer: 'points', + values: ['object--bench'], + }, + (record) => seen.push(record.type), + ); + assert.deepEqual(seen, ['start', 'tile', 'done']); + assert.equal(done.total, 1); + assert.equal(calls[0].url, '/api/mapillary/features'); + assert.equal(calls[0].init.method, 'POST'); +}); + +test('sprite URLs are proxied per taxonomy value', () => { + const source = createMapillarySource({ token: 'MLY|1|abc' }); + assert.equal( + source.spriteUrl('object--fire-hydrant'), + '/api/mapillary/sprite/object--fire-hydrant.svg', + ); +}); diff --git a/src/mapillaryMarkup.test.mjs b/src/mapillaryMarkup.test.mjs new file mode 100644 index 000000000..cdc8d7bed --- /dev/null +++ b/src/mapillaryMarkup.test.mjs @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { expandApplicationHtml } from '../build/application-html.js'; +import { readStylesheet } from './testSupport/readStylesheet.mjs'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const read = (file) => fs.readFileSync(path.join(ROOT, file), 'utf8'); + +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 mapillaryCss = read('src/ui/styles/mapillary.css'); +const controls = read('src/ui/mapillaryControls.js'); + +test('Street Level is an ordinary collapsible GEV panel that starts collapsed', () => { + assert.match( + html, + /