Merge current main into HAL-522 review candidate

This commit is contained in:
Mazeyar Moeini Feizabadi committed 2026-10-14 16:53:13 +02:00
commit 4240d04642
61 files changed
+10819 -1251

No files matched your search

+371
View File
@@ -0,0 +1,371 @@
import { deflateSync } from 'node:zlib';
import { tileBounds } from '../../../src/layers/streetLevel/tileMath.js';
import { metresApart, photoJpeg } from './mapillaryGraph.mjs';
/**
* KartaView's API, image proxy and photo storage answered from fixtures, so
* the gate's KartaView steps need no network. One drive runs north along
* KV_LINE; every tenth photo is a 360° one. Photos live on storage2 while
* their `fileurl*` name storage1, as KartaView's API does, so the gate also
* proves the provider builds addresses from `storage`. Every odd-indexed
* photo is archived (storage answers 409, the image proxy still serves it),
* so the nearest photo opened is even and › must skip the one after it. The
* first nearest-photo lookup is rate-limited (429, Retry-After: 0), as
* KartaView does under load.
*
* Beside the drive runs KV_OLD_STREET, photographed flat in 2019 only: the
* imagery filter drops it. KartaView filters coverage only in its PNG
* tiles, so filtered PNG tiles are drawn here for the streets that pass.
*/
export const KV_SEQUENCE_ID = '900001';
export const KV_LINE = Object.freeze({
lon: -121.4931,
south: 38.5795,
north: 38.5875,
});
/** A street ~130 m east of the drive with only flat 2019 photos. */
export const KV_OLD_STREET = Object.freeze({
lon: -121.4916,
south: 38.58,
north: 38.587,
});
const OLD_STREET_UPLOADED = '2019-06-01';
const KV_SPACING_M = 25;
const METRES_PER_DEG_LAT = 110_540;
/** KartaView's time format for `ms`. */
const kartaViewTime = (ms) =>
new Date(ms).toISOString().replace('T', ' ').slice(0, 23);
/** The drive is a month old, whenever the gate runs: LAST YEAR keeps it. */
const SHOT = kartaViewTime(Date.now() - 30 * 86_400_000);
const CORS = Object.freeze({ 'Access-Control-Allow-Origin': '*' });
/** Lookups answered so far: the first one is rate-limited. */
let nearestLookups = 0;
/** Forget the lookups answered, so the next one is rate-limited again. */
export function resetKartaViewFixture() {
nearestLookups = 0;
}
/** Nearest lookups answered since the last reset (the first got a 429). */
export function kartaViewLookups() {
return nearestLookups;
}
/** The drive's photos, south to north; every tenth is 360°, every odd one archived. */
export function kartaViewPhotos() {
const step = KV_SPACING_M / METRES_PER_DEG_LAT;
const count = Math.floor((KV_LINE.north - KV_LINE.south) / step) + 1;
return Array.from({ length: count }, (_, i) => ({
id: String(77_000_000 + i),
index: i,
lon: KV_LINE.lon,
lat: Number((KV_LINE.south + i * step).toFixed(7)),
isPano: i % 10 === 5,
archived: i % 2 === 1,
}));
}
function record(photo) {
const name = `${KV_SEQUENCE_ID}_${photo.id}.jpg`;
const folder = photo.isPano ? 'wrapped_proc' : 'proc';
const path = (prefix) => `2024/5/2/${prefix}/${name}`;
const storage1 = (prefix) =>
`https://storage1.openstreetcam.org/files/photo/${path(prefix)}`;
return {
id: photo.id,
sequenceId: KV_SEQUENCE_ID,
sequenceIndex: String(photo.index),
lat: String(photo.lat),
lng: String(photo.lon),
matchLat: String(photo.lat),
matchLng: String(photo.lon),
// A dashcam phone's compass, disturbed by the car: the drive runs north.
heading: '200',
fieldOfView: photo.isPano ? '360' : '0',
projection: photo.isPano ? 'SPHERE' : 'PLANE',
shotDate: SHOT,
dateAdded: SHOT,
storage: 'storage2',
filepath: path('{{sizeprefix}}'),
filepathLTh: path('lth'),
filepathProc: path(folder),
fileurl: storage1('{{sizeprefix}}'),
fileurlLTh: storage1('lth'),
fileurlProc: storage1(folder),
};
}
const ok = (result) => ({
status: 200,
contentType: 'application/json',
headers: CORS,
body: JSON.stringify({ status: { apiCode: 600, httpCode: 200 }, result }),
});
const raw = (status, body) => ({
status,
contentType: 'application/json',
headers: CORS,
body: JSON.stringify(body),
});
/** The streets with photos: the drive, and the old street beside it. */
function streets() {
return [
{ line: KV_LINE, uploaded: SHOT.slice(0, 10), views: ['360', 'plane'] },
{ line: KV_OLD_STREET, uploaded: OLD_STREET_UPLOADED, views: ['plane'] },
];
}
/** A street's stretch inside tile `z/x/y`, as [from, to] latitudes, or null. */
function stretch(line, z, x, y) {
const { west, east, south, north } = tileBounds(x, y, z);
if (line.lon < west || line.lon > east) return null;
const from = Math.max(line.south, south);
const to = Math.min(line.north, north);
return from < to ? [from, to] : null;
}
/** The streets' tracks inside tile `z/x/y`, as KartaView's JSON tile. */
function tileLines(z, x, y) {
const lines = [];
for (const { line } of streets()) {
const inside = stretch(line, z, x, y);
if (!inside) continue;
const [from, to] = inside;
// Two segments meeting end to start, as KartaView splits ways.
const mid = (from + to) / 2;
const at = (lat) => ({ lat: String(lat), lng: String(line.lon) });
lines.push(
{ track: [at(from), at(mid)], matched: '1' },
{ track: [at(mid), at(to)], matched: '1' },
);
}
return lines;
}
/** Whether a street has photos passing a filtered tile's query. */
function passesTileQuery(street, query) {
const view = query.get('fieldOfView');
if (view && !street.views.includes(view)) return false;
const since = query.get('startDate');
return !since || street.uploaded >= since;
}
const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
return c >>> 0;
});
function crc32(bytes) {
let c = 0xffffffff;
for (const byte of bytes) c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function pngChunk(type, data) {
const head = Buffer.alloc(8);
head.writeUInt32BE(data.length, 0);
head.write(type, 4, 'ascii');
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data])), 0);
return Buffer.concat([head, data, crc]);
}
/**
* A filtered coverage tile as KartaView draws it: 512 px, transparent, the
* passing streets three pixels wide.
*/
export function maskTilePng(z, x, y, query) {
const size = 512;
const rgba = Buffer.alloc(size * size * 4);
const { west, east } = tileBounds(x, y, z);
const n = 2 ** z;
const row = (lat) => {
const sin = Math.sin((lat * Math.PI) / 180);
const my = 0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI);
return Math.round((my * n - y) * size);
};
for (const street of streets()) {
if (!passesTileQuery(street, query)) continue;
const inside = stretch(street.line, z, x, y);
if (!inside) continue;
const column = Math.round(
((street.line.lon - west) / (east - west)) * size,
);
const top = Math.max(0, row(inside[1]));
const bottom = Math.min(size - 1, row(inside[0]));
for (let py = top; py <= bottom; py++)
for (let px = column - 1; px <= column + 1; px++) {
if (px < 0 || px >= size) continue;
rgba.set([99, 91, 255, 255], (py * size + px) * 4);
}
}
const rows = Buffer.alloc(size * (size * 4 + 1));
for (let py = 0; py < size; py++)
rgba.copy(
rows,
py * (size * 4 + 1) + 1,
py * size * 4,
(py + 1) * size * 4,
);
const header = Buffer.alloc(13);
header.writeUInt32BE(size, 0);
header.writeUInt32BE(size, 4);
header.set([8, 6, 0, 0, 0], 8);
return Buffer.concat([
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
pngChunk('IHDR', header),
pngChunk('IDAT', deflateSync(rows)),
pngChunk('IEND', Buffer.alloc(0)),
]);
}
/** A call named by host, path shape and parameter names. */
export function describeKartaViewCall(method, url) {
const keys = [...url.searchParams.keys()].sort();
const path = url.pathname
.replace(/\/tiles\/\d+\/\d+\/\d+\.json$/, '/tiles/{x}/{y}/{z}.json')
.replace(/\/tiles\/\d+\/\d+\/\d+\.png$/, '/tiles/{x}/{y}/{z}.png')
.replace(/\/photo\/\d+$/, '/photo/{id}')
.replace(/\/sequence\/\d+\/photos$/, '/sequence/{id}/photos')
.replace(/\/sequence\/\d+$/, '/sequence/{id}')
.replace(/^\/files\/photo\/.*$/, '/files/photo/{path}')
.replace(
/^\/pr:sharp\/rs:fit:\d+:\d+\/.*$/,
'/pr:sharp/rs:fit:{w}:{h}/{address}',
);
return `${method} ${url.hostname}${path}${keys.length ? `?${keys.join('&')}` : ''}`;
}
/**
* Answer one KartaView request; `known` is false for a call no fixture
* covers, so the gate fails on a new one instead of missing it.
*/
export function answerKartaViewRequest({ method, url }) {
const address = new URL(url);
const photos = kartaViewPhotos();
if (method === 'OPTIONS')
return { status: 204, headers: CORS, body: '', known: true };
const image = {
status: 200,
contentType: 'image/jpeg',
headers: CORS,
body: photoJpeg(),
known: true,
};
if (address.hostname === 'cdn.kartaview.org')
return /^\/pr:sharp\/rs:fit:\d+:\d+\/[\w-]+$/.test(address.pathname)
? image
: { ...raw(404, {}), known: false };
if (address.hostname === 'storage2.openstreetcam.org') {
if (!address.pathname.includes(KV_SEQUENCE_ID))
return { ...raw(404, {}), known: false };
const archived = photos.some(
(photo) =>
photo.archived && address.pathname.endsWith(`_${photo.id}.jpg`),
);
// An archived photo's file is gone from storage: KartaView answers 409.
return archived ? { ...raw(409, {}), known: true } : image;
}
if (/^storage\d+\.openstreetcam\.org$/.test(address.hostname))
// The wrong host the API names: KartaView answers 404 there.
return { ...raw(404, {}), known: false };
if (address.hostname !== 'api.kartaview.org')
return { ...raw(404, {}), known: false };
const path = address.pathname;
const tile = path.match(
/^\/2\.0\/sequence\/tiles\/(\d+)\/(\d+)\/(\d+)\.json$/,
);
if (tile)
return {
...raw(200, {
lines: tileLines(Number(tile[3]), Number(tile[1]), Number(tile[2])),
}),
known: true,
};
// Filtered coverage: KartaView filters only its PNG tiles.
const mask = path.match(
/^\/2\.0\/sequence\/tiles\/(\d+)\/(\d+)\/(\d+)\.png$/,
);
if (mask)
return {
status: 200,
contentType: 'image/png',
headers: CORS,
body: maskTilePng(
Number(mask[3]),
Number(mask[1]),
Number(mask[2]),
address.searchParams,
),
known: true,
};
if (path === '/2.0/photo/') {
if (nearestLookups++ === 0)
return {
...raw(429, {}),
headers: {
...CORS,
'Retry-After': '0',
'Access-Control-Expose-Headers': 'Retry-After',
},
known: true,
};
const q = address.searchParams;
const at = { lat: Number(q.get('lat')), lon: Number(q.get('lng')) };
// Its own filter, as KartaView's: projection, and upload date.
const projection = q.get('projection');
const since = q.get('startDate');
const near = photos
.filter(
(photo) =>
(!projection || projection === (photo.isPano ? 'SPHERE' : 'PLANE')) &&
(!since || SHOT.slice(0, 10) >= since),
)
.map((photo) => ({ photo, metres: metresApart(at, photo) }))
.filter(({ metres }) => metres < 200)
.sort((a, b) => a.metres - b.metres)
.slice(0, Number(q.get('itemsPerPage')) || 10)
.map(({ photo, metres }) => ({
...record(photo),
distance: metres.toFixed(2),
}));
return { ...ok({ data: near, hasMoreData: false }), known: true };
}
const one = path.match(/^\/2\.0\/photo\/(\d+)$/);
if (one) {
const photo = photos.find((p) => p.id === one[1]);
return photo
? { ...ok({ data: record(photo) }), known: true }
: { ...raw(200, { status: { apiCode: 601 }, result: {} }), known: true };
}
const sequence = path.match(/^\/2\.0\/sequence\/(\d+)$/);
if (sequence)
return {
...ok({
data: {
id: sequence[1],
countActivePhotos:
sequence[1] === KV_SEQUENCE_ID ? String(photos.length) : '0',
},
}),
known: true,
};
const page = path.match(/^\/2\.0\/sequence\/(\d+)\/photos$/);
if (page) {
const q = address.searchParams;
const size = Number(q.get('itemsPerPage')) || 100;
const number = Number(q.get('page')) || 1;
const rows = page[1] === KV_SEQUENCE_ID ? photos : [];
return {
...ok({
data: rows.slice((number - 1) * size, number * size).map(record),
hasMoreData: number * size < rows.length,
}),
known: true,
};
}
return { ...raw(404, {}), known: false };
}
+21 -1
View File
@@ -536,6 +536,7 @@
"src/data/cctvWarendorf.test.mjs",
"src/data/cctvCalgary.test.mjs",
"src/data/cctvVegvesen.test.mjs",
"src/data/cctvNyc.test.mjs",
"src/data/cctvNswSource.test.mjs",
"src/data/cctvTxdotSource.test.mjs",
"src/layers/cctv/source.test.mjs",
@@ -921,6 +922,22 @@
"src/layers/streetLevel/providers/google/provider.test.mjs",
"src/layers/streetLevel/providers/google/viewer.test.mjs",
"src/layers/streetLevel/providers/google/mapsLoader.test.mjs",
"src/layers/streetLevel/providers/kartaview/index.js",
"src/layers/streetLevel/providers/kartaview/policy.js",
"src/layers/streetLevel/providers/kartaview/source.js",
"src/layers/streetLevel/providers/kartaview/decode.js",
"src/layers/streetLevel/providers/kartaview/viewer.js",
"src/layers/streetLevel/providers/kartaview/panorama.js",
"src/layers/streetLevel/lineCoverage.js",
"src/layers/streetLevel/sequenceCones.js",
"src/layers/streetLevel/providers/kartaview/policy.test.mjs",
"src/layers/streetLevel/providers/kartaview/decode.test.mjs",
"src/layers/streetLevel/providers/kartaview/source.test.mjs",
"src/layers/streetLevel/providers/kartaview/panorama.test.mjs",
"src/layers/streetLevel/providers/kartaview/viewer.test.mjs",
"src/layers/streetLevel/providers/kartaview/provider.test.mjs",
"src/layers/streetLevel/lineCoverage.test.mjs",
"src/testSupport/kartaViewFakes.mjs",
"src/testSupport/googleStreetViewFakes.mjs",
"server/providers/mapillary.js",
"server/providers/mapillary/routes.js",
@@ -932,6 +949,7 @@
"src/tooling/streetLevelQa.test.mjs",
"scripts/qa-browserEvidence.mjs",
"scripts/fixtures/street-level/mapillaryGraph.mjs",
"scripts/fixtures/street-level/kartaView.mjs",
"src/layers/streetLevel/filter.test.mjs",
"src/layers/streetLevel/registry.test.mjs",
"src/layers/streetLevel/params.test.mjs",
@@ -993,5 +1011,7 @@
"src/tools/views.test.mjs",
"src/app/embed.test.mjs",
"src/tools/globePanel.test.mjs",
"src/costEndpointThrottle.test.mjs"
"src/costEndpointThrottle.test.mjs",
"src/sources/gbfsStations.test.mjs",
"scripts/qa-gbfs-names.mjs"
]
+24
View File
@@ -1231,6 +1231,14 @@
"src/layers/streetLevel/providers/google/policy.js",
"src/layers/streetLevel/providers/google/viewer.js",
"src/layers/streetLevel/providers/google/mapsLoader.js",
"src/layers/streetLevel/providers/kartaview/index.js",
"src/layers/streetLevel/providers/kartaview/policy.js",
"src/layers/streetLevel/providers/kartaview/source.js",
"src/layers/streetLevel/providers/kartaview/decode.js",
"src/layers/streetLevel/providers/kartaview/viewer.js",
"src/layers/streetLevel/providers/kartaview/panorama.js",
"src/layers/streetLevel/lineCoverage.js",
"src/layers/streetLevel/sequenceCones.js",
"src/layers/streetLevel/providers/mapillary/source.js",
"scripts/qa-street-level.mjs"
],
@@ -2196,6 +2204,14 @@
"src/layers/streetLevel/providers/google/policy.js",
"src/layers/streetLevel/providers/google/viewer.js",
"src/layers/streetLevel/providers/google/mapsLoader.js",
"src/layers/streetLevel/providers/kartaview/index.js",
"src/layers/streetLevel/providers/kartaview/policy.js",
"src/layers/streetLevel/providers/kartaview/source.js",
"src/layers/streetLevel/providers/kartaview/decode.js",
"src/layers/streetLevel/providers/kartaview/viewer.js",
"src/layers/streetLevel/providers/kartaview/panorama.js",
"src/layers/streetLevel/lineCoverage.js",
"src/layers/streetLevel/sequenceCones.js",
"src/layers/streetLevel/providers/mapillary/source.js",
"src/app/layers/streetLevel.js",
"src/layers/alpr/tileRecords.js",
@@ -2732,6 +2748,14 @@
"src/layers/streetLevel/providers/google/policy.js",
"src/layers/streetLevel/providers/google/viewer.js",
"src/layers/streetLevel/providers/google/mapsLoader.js",
"src/layers/streetLevel/providers/kartaview/index.js",
"src/layers/streetLevel/providers/kartaview/policy.js",
"src/layers/streetLevel/providers/kartaview/source.js",
"src/layers/streetLevel/providers/kartaview/decode.js",
"src/layers/streetLevel/providers/kartaview/viewer.js",
"src/layers/streetLevel/providers/kartaview/panorama.js",
"src/layers/streetLevel/lineCoverage.js",
"src/layers/streetLevel/sequenceCones.js",
"src/layers/streetLevel/providers/mapillary/source.js",
"src/app/layers/streetLevel.js",
"src/layers/alpr/tileRecords.js",
+336
View File
@@ -0,0 +1,336 @@
#!/usr/bin/env node
/**
* Keyless station-name regression against the real globe and bike-share tool.
* Run with a dev or preview server: node scripts/qa-gbfs-names.mjs --url http://localhost:4173
* GBFS documents are synthetic; no operator feed or provider key is required.
*/
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
const urlIndex = process.argv.indexOf('--url');
const appUrl =
urlIndex < 0 ? 'http://localhost:4173' : process.argv[urlIndex + 1];
const shots = path.resolve('qa-shots/gbfs-names');
fs.mkdirSync(shots, { recursive: true });
const information = {
version: '3.0',
data: {
stations: [
{
station_id: 'english',
name: [
{ language: 'hu', text: 'Magyar tér' },
{ language: 'en', text: 'QA English Square' },
],
},
{
station_id: 'short',
name: [null, { language: 'en', text: ' ' }],
short_name: [{ language: 'en-GB', text: 'QA Short Name' }],
},
{ station_id: 'unnamed', name: [null, {}, { language: 42, text: [] }] },
{ station_id: 'legacy', name: ' QA Legacy Station ' },
{
station_id: 'fallback',
name: [null, { language: 'hu', text: 'QA Magyar Station' }],
},
].map((station, index) => ({
...station,
lat: 30.2681 + index * 0.001,
lon: -97.7469 + index * 0.001,
capacity: 13,
})),
},
};
const status = {
data: {
stations: information.data.stations.map(({ station_id }) => ({
station_id,
num_bikes_available: 4,
num_docks_available: 9,
is_renting: true,
is_returning: true,
})),
},
};
const browser = await puppeteer.launch({
headless: 'new',
...(process.env.PUPPETEER_EXECUTABLE_PATH
? { executablePath: process.env.PUPPETEER_EXECUTABLE_PATH }
: {}),
args:
process.platform === 'darwin'
? ['--use-angle=metal']
: ['--use-gl=angle', '--use-angle=swiftshader'],
});
let checks = 0;
const check = (name, actual, expected) => {
assert.deepEqual(actual, expected, name);
checks++;
console.log(`[PASS] ${name}`);
};
async function open(mode = 'normal') {
const page = await browser.newPage();
await page.bringToFront();
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.setViewport({ width: 1440, height: 900 });
await page.evaluateOnNewDocument(
({ information, status, mode }) => {
localStorage.clear();
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
window.__qaPainted = [];
const paint = CanvasRenderingContext2D.prototype.fillText;
CanvasRenderingContext2D.prototype.fillText = function (text, ...args) {
if (
String(text).startsWith('QA ') ||
String(text) === 'Station unnamed'
) {
window.__qaPainted.push(String(text));
if (window.__qaPainted.length > 100) window.__qaPainted.shift();
}
return paint.call(this, text, ...args);
};
const original = window.fetch.bind(window);
window.fetch = async (input, init) => {
const url = String(
typeof input === 'string' || input instanceof URL ? input : input.url,
);
if (url.includes('/api/gbfs/')) {
if (mode === 'failure') return new Response('{}', { status: 503 });
const payload =
mode === 'empty'
? { data: { stations: [] } }
: decodeURIComponent(url).includes('station_information')
? information
: status;
return new Response(JSON.stringify(payload), {
headers: { 'Content-Type': 'application/json' },
});
}
if (url.includes('/api/flights')) return new Response('{"states":[]}');
if (url.includes('/api/military')) return new Response('{"ac":[]}');
if (url.includes('/api/vessels'))
return new Response('{"connected":false,"vessels":[]}');
return original(input, init);
};
},
{ information, status, mode },
);
await page.goto(appUrl, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() =>
window.__godsEyeView?.dataManager && window.__godsEyeView?.voiceCommands,
{ timeout: 60000 },
);
await page.waitForFunction(
() =>
!document.getElementById('loading-screen') ||
(document.getElementById('loading-screen').classList.contains('hidden') &&
getComputedStyle(document.getElementById('loading-screen')).opacity ===
'0'),
{ timeout: 60000 },
);
await page.evaluate(async () => {
const { dataManager, viewer } = window.__godsEyeView;
for (const [id, layer] of dataManager.layers) {
if (layer.enabled)
await dataManager.setEnabled(id, false, { origin: 'user' });
}
const Cartesian3 = viewer.camera.position.constructor;
viewer.camera.cancelFlight();
viewer.camera.setView({
destination: Cartesian3.fromDegrees(-97.7449, 30.2701, 3000),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
await dataManager.setEnabled('bikeshare', true, { origin: 'user' });
window.__godsEyeView.requestRender('gbfs-name-qa');
});
return { page, errors };
}
try {
const { page, errors } = await open();
await page.waitForFunction(
() =>
window.__godsEyeView.dataManager.layers.get('bikeshare').module.getStats()
.count === 5,
{ timeout: 15000 },
);
const records = await page.evaluate(() =>
window.__godsEyeView.dataManager.layers
.get('bikeshare')
.module.getAnalystRecords(),
);
check(
'the layer retains all five stations and normalized names',
records.map((r) => r.name),
[
'QA English Square',
'QA Short Name',
null,
'QA Legacy Station',
'QA Magyar Station',
],
);
const tool = await page.evaluate(() =>
window.__godsEyeView.voiceCommands.runner('get_bike_share', {
area: { lat: 30.2701, lon: -97.7449, radius_km: 2 },
}),
);
check('the real bike-share tool succeeds', tool.ok, true);
check(
'the tool preserves localized, short, unnamed, legacy and non-English names',
tool.data.rows.map((r) => r.name).sort(),
[
null,
'QA English Square',
'QA Short Name',
'QA Legacy Station',
'QA Magyar Station',
].sort(),
);
check(
'availability remains attached to every station',
tool.data.rows.every(
(r) => r.bikes_available === 4 && r.docks_available === 9,
),
true,
);
const clickPoint = await page.evaluate(() => {
window.__qaPainted = [];
const { viewer } = window.__godsEyeView;
const Cartesian3 = viewer.camera.position.constructor;
const point = viewer.scene.cartesianToCanvasCoordinates(
Cartesian3.fromDegrees(-97.7469, 30.2681),
);
const rect = viewer.scene.canvas.getBoundingClientRect();
return { x: rect.left + point.x, y: rect.top + point.y };
});
await page.mouse.click(clickPoint.x, clickPoint.y);
await page.waitForFunction(
() => window.__qaPainted.includes('QA English Square'),
{ timeout: 10000 },
);
check(
'a real pointer click selects the station with its localized name',
await page.evaluate(() => window.__qaPainted.includes('QA English Square')),
true,
);
await page.keyboard.press('Escape');
for (const [id, title] of [
['english', 'QA English Square'],
['short', 'QA Short Name'],
['unnamed', 'Station unnamed'],
]) {
await page.evaluate((id) => {
window.__qaPainted = [];
const app = window.__godsEyeView;
app.dataManager.layers
.get('bikeshare')
.module._selectBikeshareStationForTest(`austin-capmetro:${id}`);
app.requestRender('gbfs-name-selection-qa');
}, id);
await page.waitForFunction(
(title) => window.__qaPainted.includes(title),
{ timeout: 10000 },
title,
);
check(
`the selected ${id} card paints the expected title`,
await page.evaluate((title) => window.__qaPainted.includes(title), title),
true,
);
await page.screenshot({ path: path.join(shots, `${id}.png`) });
await page.keyboard.press('Escape');
await page.waitForFunction(() =>
window.__godsEyeView.dataManager.layers
.get('bikeshare')
.module.getDetectableObjects()
.every((record) => !record.skipLabel),
);
check(
`Escape clears the ${id} card`,
await page.evaluate(
() =>
window.__godsEyeView.dataManager.layers
.get('bikeshare')
.module.getDetectableObjects()
.filter((record) => record.skipLabel).length,
),
0,
);
}
await page.setViewport({ width: 390, height: 844 });
await page.evaluate(() => {
window.__qaPainted = [];
const app = window.__godsEyeView;
// Use the supported clean view to inspect the mobile card clear of panels.
app.styleManager.toggleCleanView(true);
const Cartesian3 = app.viewer.camera.position.constructor;
app.viewer.camera.cancelFlight();
app.viewer.camera.setView({
destination: Cartesian3.fromDegrees(-97.7449, 30.2701, 3000),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
app.dataManager.layers
.get('bikeshare')
.module._selectBikeshareStationForTest('austin-capmetro:english');
app.requestRender('gbfs-name-mobile-qa');
});
await page.waitForFunction(
() => window.__qaPainted.includes('QA English Square'),
{ timeout: 10000 },
);
check(
'the mobile selected card paints its normalized name',
await page.evaluate(() => window.__qaPainted.includes('QA English Square')),
true,
);
await page.screenshot({ path: path.join(shots, 'mobile.png') });
await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled('bikeshare', false, {
origin: 'user',
}),
);
check(
'disabling clears selected cards and analyst records',
await page.evaluate(() => ({
count: window.__godsEyeView.dataManager.layers
.get('bikeshare')
.module.getAnalystRecords().length,
selected: window.__godsEyeView.dataManager.layers
.get('bikeshare')
.module.getDetectableObjects()
.filter((record) => record.skipLabel).length,
})),
{ count: 0, selected: 0 },
);
check('normal fixtures produce no page exceptions', errors, []);
await page.close();
for (const mode of ['empty', 'failure']) {
const { page, errors } = await open(mode);
await page.waitForFunction(
() =>
!window.__godsEyeView.dataManager.layers
.get('bikeshare')
.module.getStats().loading,
);
check(
`${mode} leaves no rendered stations`,
await page.evaluate(
() =>
window.__godsEyeView.dataManager.layers
.get('bikeshare')
.module.getStats().count,
),
0,
);
check(`${mode} produces no page exceptions`, errors, []);
await page.close();
}
console.log(`GBFS names: ${checks}/${checks} checks passed; ${appUrl}`);
} finally {
await browser.close();
}
+555 -31
View File
@@ -3,8 +3,8 @@
* Browser QA for the Street Level layer against a running server. Run with
* `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
* `--fixtures` (the CI mode) answers every *.mapillary.com and KartaView
* request from fixtures, so nothing reaches Mapillary or KartaView. 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.
*
@@ -37,6 +37,15 @@ import {
PHOTO_SEQUENCE_ID,
photoImages,
} from './fixtures/street-level/mapillaryGraph.mjs';
import {
answerKartaViewRequest,
describeKartaViewCall,
KV_LINE,
KV_SEQUENCE_ID,
kartaViewLookups,
kartaViewPhotos,
resetKartaViewFixture,
} from './fixtures/street-level/kartaView.mjs';
const PANEL_ID = 'street-level-panel';
@@ -49,7 +58,11 @@ export const VIEWPORTS = Object.freeze([
]);
/** Provider chips the panel must show, in order. */
export const EXPECTED_PROVIDERS = Object.freeze(['mapillary', 'google']);
export const EXPECTED_PROVIDERS = Object.freeze([
'mapillary',
'google',
'kartaview',
]);
/**
* Skips no setup can avoid: CI forks have no Google key (so neither Google 3D
@@ -229,6 +242,9 @@ export async function serveFixtures(page, fixture) {
patterns: [
{ urlPattern: '*://*.mapillary.com/*' },
{ urlPattern: '*/api/mapillary/*' },
{ urlPattern: '*://api.kartaview.org/*' },
{ urlPattern: '*://cdn.kartaview.org/*' },
{ urlPattern: '*://*.openstreetcam.org/*' },
],
});
const fulfil = (requestId, { status, headers = {}, contentType, body }) =>
@@ -259,6 +275,11 @@ export async function serveFixtures(page, fixture) {
});
fixture.calls.add(call);
if (!answer.known) fixture.unknown.push(call);
} else if (/(^|\.)(kartaview|openstreetcam)\.org$/.test(address.hostname)) {
const call = describeKartaViewCall(method, address);
answer = answerKartaViewRequest({ method, url: request.url });
fixture.calls.add(call);
if (!answer.known) fixture.unknown.push(call);
} else if (
address.pathname === '/api/mapillary/status' &&
fixture.configured !== undefined
@@ -443,10 +464,50 @@ async function assertKeylessGate(page) {
assert.match(chip.title, /MAPILLARY_CLIENT_TOKEN/);
}
/**
* With no Mapillary key the panel points at KartaView, which needs none:
* lit, it draws coverage and the layer is not key-gated.
*/
async function assertKeylessKartaView(page) {
assert.match(
await page.$eval('#sl-coverage-meta', (node) => node.textContent),
/light KARTAVIEW/,
'KEY REQUIRED points at KartaView',
);
for (let attempt = 0; attempt < 4; 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(1000);
}
await clickPanelControl(
page,
'#sl-provider-chips [data-chip-id="kartaview"]',
);
await waitForCoverage(page);
const state = await page.evaluate(() => ({
status: document.getElementById('sl-status').textContent,
keyRequired: window.__godsEyeView.dataManager.layers
.get('street-level')
.module.getUIState().keyRequired,
}));
assert.equal(state.keyRequired, false);
assert.doesNotMatch(state.status, /KEY/);
}
/**
* 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.
* Each call picks for at most `budgetMs`; the next call resumes the search.
* @returns {{x: number, y: number, id: string}|null} client coordinates
*/
export function findPickPoint({
@@ -455,6 +516,7 @@ export function findPickPoint({
points,
billboardIds,
billboardPrefix = null,
budgetMs = 4000,
}) {
const viewer = window.__godsEyeView.viewer;
const scene = viewer.scene;
@@ -509,24 +571,54 @@ export function findPickPoint({
centres.push([at.x, at.y]);
}
}
for (const [cx, cy] of centres)
for (let r = 0; r <= 16; r += 2)
// A pick is a render of the scene (0.5–2 s each under SwiftShader): try
// the centres the canvas is on top of first, nearest the screen centre,
// so the first pick usually hits.
const uncovered = ([x, y]) =>
document.elementFromPoint(rect.left + x, rect.top + y) === canvas;
const fromMiddle = ([x, y]) =>
(x - rect.width / 2) ** 2 + (y - rect.height / 2) ** 2;
centres.sort(
(a, b) =>
Number(uncovered(b)) - Number(uncovered(a)) ||
fromMiddle(a) - fromMiddle(b),
);
const candidates = [];
for (const [cx, cy] of centres) {
candidates.push([cx, cy]);
for (let r = 2; 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;
])
candidates.push([cx + dx, cy + dy]);
}
// Aimed at known places: never fall back to a scan of the whole screen.
if (!points && !billboardIds && billboardPrefix === 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)
candidates.push([x, y]);
// One evaluation stays well inside the caller's wait: past the budget it
// returns null, and the next poll carries on from where this one stopped.
const key = JSON.stringify([prefix, id, billboardPrefix]);
const cursors = (window.__qaPickCursors ??= {});
const deadline = performance.now() + budgetMs;
let at = cursors[key] ?? 0;
if (at >= candidates.length) at = 0;
for (; at < candidates.length; at++) {
const hit = tryAt(...candidates[at]);
if (hit) {
delete cursors[key];
return hit;
}
if (performance.now() > deadline) {
cursors[key] = at + 1;
return null;
}
}
delete cursors[key];
return null;
}
@@ -726,6 +818,419 @@ async function main() {
`${count} steps: ${count - skips.length} ok, ${skips.length} skipped`,
);
/**
* KartaView: keyless coverage from its JSON tiles, a line click opening
* the nearest photo, cones along the drive, › and a 360° photo. Runs on a
* draped map so a line is where it is drawn; leaves Mapillary lit.
*/
async function kartaViewSteps() {
// The first lookup from here on is the rate-limited one.
resetKartaViewFixture();
const ui = () =>
page.evaluate(() =>
window.__godsEyeView.dataManager.layers
.get('street-level')
.module.getUIState(),
);
const uiUntil = (test, arg = null, options) =>
settle(
page,
`(${test})(window.__godsEyeView.dataManager.layers.get('street-level').module.getUIState(), ${JSON.stringify(arg)})`,
undefined,
options,
);
const lit = (id) =>
uiUntil(
(u, want) =>
u.providers.find((p) => p.id === want)?.on === true &&
u.providers.filter((p) => p.on).length === 1,
id,
);
const stack = () =>
page.evaluate(() =>
window.__godsEyeView.mapStackController.getActiveId(),
);
const setStack = async (id) => {
await page.evaluate(
(stackId) =>
window.__godsEyeView.mapStackController.setStack(stackId),
id,
);
await settle(
page,
(stackId) =>
window.__godsEyeView.mapStackController.getActiveId() === stackId,
id,
);
};
const kvChip = '#sl-provider-chips [data-chip-id="kartaview"]';
const original = await stack();
const photos = kartaViewPhotos();
await page.evaluate(() =>
window.__godsEyeView.dataManager.layers
.get('street-level')
.module.closeViewer(),
);
await setStack('esri-imagery');
// Over the fixture drive, looking down: its line is mid-screen.
for (let attempt = 0; attempt < 4; 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(1000);
}
await step(
'KartaView: its chip draws keyless coverage, credits KartaView and offers Mapillary’s filters, SINCE noted as its upload date',
async () => {
await clickPanelControl(page, kvChip);
assert.ok(await lit('kartaview'), 'KartaView alone is lit');
await waitForCoverage(page);
const u = await ui();
assert.equal(u.keyRequired, false);
assert.deepEqual(u.filterControls, { pano: true, since: true });
// KartaView's SINCE is its upload date: the panel says so.
assert.equal(u.sinceNote, 'KartaView: by upload date');
assert.equal(
await page.$eval('#sl-since-note', (node) =>
node.hidden ? null : node.textContent,
),
'KartaView: by upload date',
);
assert.equal(
await page.$eval(kvChip, (chip) =>
chip.style.getPropertyValue('--chip-color'),
),
'#635bff',
);
assert.match(
await page.$eval('#sl-coverage-meta', (node) => node.textContent),
/streets in view · click a line for its nearest photo/,
);
await page.waitForFunction(
() =>
document.body.innerHTML.includes('KartaView</a> contributors'),
{ timeout: 15_000 },
);
assert.ok(
await settle(page, () =>
(new URLSearchParams(location.hash.slice(1)).get('lo') || '')
.split('_')
.some(
(entry) =>
entry.startsWith('0.') && entry.includes('kartaview-1'),
),
),
`the share link lights KartaView: ${await page.evaluate(() => location.hash)}`,
);
},
);
await step(
'KartaView: 360° and SINCE thin its lines through KartaView’s filtered tiles, and ALL / ANY DATE bring them back',
async () => {
const before = (await ui()).coverage.count;
const setStop = (index) =>
page.$eval(
'#sl-since',
(input, value) => {
input.value = String(value);
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
},
index,
);
// Filtered tiles are drawn by KartaView on demand: give them time.
const settled = async (test, arg, what) =>
assert.ok(
await uiUntil(test, arg, { timeout: 60_000 }),
`${what}: ${JSON.stringify((await ui()).coverage)}`,
);
await clickPanelControl(page, '[data-sl-pano="pano"]');
await settled(
(u) => u.filter.pano === 'pano' && !u.coverage.loading,
null,
'360° drawn',
);
const panos = (await ui()).coverage.count;
assert.ok(panos <= before, `${panos} ≤ ${before}`);
// Fixtures: the old street has only flat photos, the drive 360° too.
if (fixtures)
assert.ok(panos > 0 && panos < before, `0 < ${panos} < ${before}`);
await clickPanelControl(page, '[data-sl-pano="all"]');
await settled(
(u, n) =>
u.filter.pano === 'all' &&
!u.coverage.loading &&
u.coverage.count === n,
before,
'every line again',
);
await setStop(5);
await settled(
(u) => u.filter.sinceDays === 365 && !u.coverage.loading,
null,
'LAST YEAR drawn',
);
const lastYear = (await ui()).coverage.count;
assert.ok(lastYear <= before, `${lastYear} ≤ ${before}`);
// Fixtures: the drive is a month old, the old street from 2019.
if (fixtures)
assert.ok(
lastYear > 0 && lastYear < before,
`0 < ${lastYear} < ${before}`,
);
await setStop(0);
await settled(
(u, n) =>
u.filter.sinceDays === 0 &&
!u.coverage.loading &&
u.coverage.count === n,
before,
'ANY DATE: every line again',
);
assert.equal((await ui()).coverage.error, null);
},
);
await step(
'KartaView: a click on a line opens the nearest photo where the camera is, cones follow its drive, and › steps along it, past an archived photo',
async ({ skip }) => {
const along = Array.from({ length: 9 }, (_, i) => [
KV_LINE.lon,
KV_LINE.south + ((i + 2) / 13) * (KV_LINE.north - KV_LINE.south),
]);
const handle = await page
.waitForFunction(
findPickPoint,
{ timeout: 30_000, polling: 250 },
{
prefix: 'kv:line:',
id: null,
points: fixtures ? along : null,
billboardIds: null,
},
)
.catch(() => null);
if (!handle && !fixtures) return skip('no KartaView line in view');
assert.ok(handle, 'a KartaView line is pickable');
const at = await handle.jsonValue();
const camera = () =>
page.evaluate(() => {
const c = window.__godsEyeView.viewer.camera.positionCartographic;
return {
lon: (c.longitude * 180) / Math.PI,
lat: (c.latitude * 180) / Math.PI,
height: c.height,
};
});
const parked = await camera();
// The first lookup is rate-limited (fixtures): it waits and retries.
await page.mouse.click(at.x, at.y);
await page.waitForFunction(
() => {
const s = window.__godsEyeView.dataManager.layers
.get('street-level')
.module.getUIState().street;
return (s.imageId && !s.loading) || s.error;
},
{ timeout: 60_000 },
);
const street = (await ui()).street;
if (!fixtures && /No street-level imagery/.test(street.error || ''))
return skip('no KartaView photo near the line');
if (fixtures)
assert.ok(
kartaViewLookups() >= 2,
`the rate-limited lookup was asked again (${kartaViewLookups()} lookups)`,
);
assert.equal(street.error, null, `viewer error: ${street.error}`);
assert.equal(street.providerId, 'kartaview');
assert.equal(street.renderModes, true, 'FIT / FILL for a flat photo');
assert.ok(
await settle(page, () => {
const img = document.querySelector('.sl-kv-photo');
return img && !img.hidden && img.naturalWidth > 0;
}),
'the photo is on screen',
);
// A line click picks a spot: the camera stays where it was.
await sleep(1500);
const now = await camera();
assert.ok(
Math.abs(now.lon - parked.lon) < 1e-6 &&
Math.abs(now.lat - parked.lat) < 1e-6 &&
Math.abs(now.height - parked.height) < 0.5,
`the camera did not move: ${JSON.stringify({ parked, now })}`,
);
if (fixtures) {
const opened = photos.find((photo) => photo.id === street.imageId);
assert.ok(opened, `${street.imageId} is a fixture photo`);
assert.equal(
opened.archived,
false,
'the nearest photo that storage still has',
);
assert.ok(
await uiUntil(
(u, want) =>
u.sequence.selectedId === want.id &&
!u.sequence.loading &&
u.sequence.images === want.count,
{ id: KV_SEQUENCE_ID, count: photos.length },
{ timeout: 30_000 },
),
`cones for the whole drive: ${JSON.stringify((await ui()).sequence)}`,
);
// The drive runs north; the photos' compass says 200°.
assert.ok(
await uiUntil(
(u) =>
Number.isFinite(u.street.bearing) &&
Math.min(u.street.bearing, 360 - u.street.bearing) < 2,
),
`the camera looks along the drive, not where the compass points: ${(await ui()).street.bearing}`,
);
}
const before = street.imageId;
await clickPanelControl(page, '.sl-kv-step-next');
assert.ok(
await uiUntil(
(u, was) =>
u.street.imageId && u.street.imageId !== was && !u.street.error,
before,
{ timeout: 30_000 },
),
'› opened the next photo',
);
if (fixtures) {
const skipped = photos.find(
(photo) => photo.id === String(Number(before) + 1),
);
assert.equal(
skipped?.archived,
true,
'the photo after is archived',
);
assert.equal(
(await ui()).street.imageId,
String(Number(before) + 2),
'› skipped the archived photo to the next one along the drive',
);
}
},
);
if (fixtures)
await step(
'KartaView: a 360° photo opens on the panorama without FIT / FILL, dragging turns it, and it survives a lost WebGL context',
async () => {
const pano = photos.find((photo) => photo.isPano);
await page.evaluate((id) => {
void window.__godsEyeView.dataManager.layers
.get('street-level')
.module.openImage('kartaview', id);
}, pano.id);
assert.ok(
await uiUntil(
(u, want) =>
u.street.imageId === want &&
!u.street.loading &&
u.street.isPano,
pano.id,
{ timeout: 60_000 },
),
`the panorama opened: ${JSON.stringify((await ui()).street)}`,
);
const u = await ui();
assert.equal(u.street.error, null);
assert.equal(u.street.renderModes, false, 'no frame to fit');
assert.equal(
await page.$eval('.sl-kv-pano', (canvas) => canvas.hidden),
false,
);
const box = await page.$eval('.sl-kv-pano', (canvas) => {
const r = canvas.getBoundingClientRect();
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
});
const bearing = u.street.bearing;
await page.mouse.move(box.x, box.y);
await page.mouse.down();
for (let i = 1; i <= 8; i++)
await page.mouse.move(box.x - i * 15, box.y);
await page.mouse.up();
assert.ok(
await uiUntil((v, was) => v.street.bearing !== was, bearing),
'dragging the panorama turned its bearing',
);
// A GPU reset: the panorama comes back on a new canvas, and drags.
assert.ok(
await page.evaluate(() => {
const canvas = document.querySelector('.sl-kv-pano');
window.__kvLostCanvas = canvas;
const lose = canvas
.getContext('webgl')
?.getExtension('WEBGL_lose_context');
lose?.loseContext();
return Boolean(lose);
}),
'the browser can lose a WebGL context on purpose',
);
assert.ok(
await settle(
page,
() => {
const canvas = document.querySelector('.sl-kv-pano');
return (
canvas &&
canvas !== window.__kvLostCanvas &&
!canvas.hidden &&
canvas.width > 1
);
},
null,
{ timeout: 30_000 },
),
'the panorama is drawn again on a new canvas',
);
const after = await ui();
assert.equal(after.street.imageId, pano.id);
assert.equal(after.street.isPano, true);
assert.equal(after.street.error, null);
const turned = after.street.bearing;
await page.mouse.move(box.x, box.y);
await page.mouse.down();
for (let i = 1; i <= 8; i++)
await page.mouse.move(box.x + i * 15, box.y);
await page.mouse.up();
assert.ok(
await uiUntil((v, was) => v.street.bearing !== was, turned),
'the new canvas drags',
);
},
);
else
await skipStep(
'fixtures only',
'KartaView: a 360° photo opens on the panorama without FIT / FILL, dragging turns it, and it survives a lost WebGL context',
);
await page.evaluate(() =>
window.__godsEyeView.dataManager.layers
.get('street-level')
.module.closeViewer(),
);
await clickPanelControl(
page,
'#sl-provider-chips [data-chip-id="mapillary"]',
);
assert.ok(await lit('mapillary'), 'Mapillary is lit again');
await setStack(original);
}
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.
@@ -1116,9 +1621,10 @@ async function main() {
((i + 2) / 13) * (PHOTO_LINE.north - PHOTO_LINE.south),
]);
// Ground primitives are built over a few frames: poll for a pick.
// A loaded machine renders a frame in seconds: 60 s, not 30.
const handle = await page.waitForFunction(
findPickPoint,
{ timeout: 30_000, polling: 250 },
{ timeout: 60_000, polling: 250 },
{
prefix: 'mly:seq:',
id: target,
@@ -1143,22 +1649,28 @@ async function main() {
);
if (fixtures)
assert.equal(sequence.images, fixture.images.length, 'every photo');
// The cones are on the globe: one of them picks as an image.
// The cones are on the globe: one of them picks as an image. Aim at
// where each cone billboard is drawn (its ground position at
// height 0 can sit a few pixels off, and every missed pick costs a
// render); the same 60 s as the line pick above.
const cone = await page.waitForFunction(
findPickPoint,
{ timeout: 15_000, polling: 250 },
{ timeout: 60_000, polling: 250 },
{
prefix: 'mly:img:',
id: null,
points: fixtures
? fixture.images.map((image) => [image.lon, image.lat])
: null,
points: null,
billboardIds: null,
// Live: the cones' positions are only known from the globe.
billboardPrefix: fixtures ? null : 'mly:img:',
billboardPrefix: 'mly:img:',
},
);
assert.ok(await cone.jsonValue(), 'an image cone is pickable');
const picked = await cone.jsonValue();
assert.ok(picked, 'an image cone is pickable');
if (fixtures)
assert.ok(
fixture.images.some((image) => image.id === picked.id),
`the cone is a photo of the drive: ${picked.id}`,
);
await page.evaluate(() =>
window.__godsEyeView.dataManager.layers
.get('street-level')
@@ -1286,7 +1798,9 @@ async function main() {
assert.ok(neighbour, 'a neighbour of the other camera type');
const handle = await page.waitForFunction(
findPickPoint,
{ timeout: 15_000, polling: 250 },
// A pick is a render; under SwiftShader on a busy machine one
// evaluation can take seconds.
{ timeout: 30_000, polling: 250 },
{
prefix: 'mly:img:',
id: neighbour.id,
@@ -1803,16 +2317,24 @@ async function main() {
credit: false,
button: false,
});
// Street View rides the shared provider-switch field (r).
// Street View rides the shared provider-switch field (r), beside
// the other providers' switches a chip click recorded.
const option = `${STREET_LEVEL_TOKEN}.r.google-1`;
assert.ok(
await settle(
page,
(token) =>
(prefix) =>
(new URLSearchParams(location.hash.slice(1)).get('lo') || '')
.split('_')
.includes(token),
option,
.some(
(entry) =>
entry.startsWith(prefix) &&
entry
.slice(prefix.length)
.split('*')
.includes('google-1'),
),
`${STREET_LEVEL_TOKEN}.r.`,
// The link is written once the view rests for 500 ms; under a
// software renderer with a photo open that can take a while.
{ timeout: 30_000 },
@@ -2026,6 +2548,7 @@ async function main() {
}
},
);
await kartaViewSteps();
await step(
'a reload with the layer on keeps a collapsed panel collapsed, and the share link carries its ui token',
async () => {
@@ -2084,7 +2607,7 @@ async function main() {
);
if (fixtures)
await step(
'keyless install (a second page, no key on the server) gates the controls and reports KEY REQUIRED',
'keyless install (a second page, no key on the server) gates the controls and reports KEY REQUIRED, and KartaView draws there',
async () => {
const keyless = await browser.newPage();
await keyless.setViewport(VIEWPORTS[0]);
@@ -2093,6 +2616,7 @@ async function main() {
await boot(keyless, url);
await expandStrip(keyless);
await assertKeylessGate(keyless);
await assertKeylessKartaView(keyless);
renderErrorsSeen.push(
...(await readRenderErrors(keyless)).map(
(text) => `[keyless] scene.renderError: ${text}`,
@@ -2108,7 +2632,7 @@ async function main() {
);
if (fixtures)
await step(
'every Mapillary request was answered from a fixture',
'every Mapillary and KartaView request was answered from a fixture',
() => {
console.log(
` (answered: ${[...fixture.calls].sort().join(' | ')})`,