mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
test(mapillary): provider, source, presentation, markup and QA-harness coverage
scripts/qa-mapillary.mjs drives a real browser through the rail panel, the keyless gate, coverage, a ready-made feature plan, the viewer and its expanded dialog at 1440×900 and 1280×800. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
1 parent
70ebdeb783
commit
f6f8fd8fa8
11 files changed
+1558
-97
No files matched your search
@@ -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</a> 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);
|
||||
});
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
/<div id="mapillary-panel" class="panel-collapsible collapsed" data-panel-id="mapillary-panel">/,
|
||||
);
|
||||
assert.match(
|
||||
html,
|
||||
/<button class="panel-collapse-btn" data-collapse-target="mapillary-panel"/,
|
||||
);
|
||||
assert.match(html, /<span class="panel-title">STREET LEVEL<\/span>/);
|
||||
assert.match(html, /<svg class="mly-mark"/);
|
||||
assert.doesNotMatch(
|
||||
html,
|
||||
/mapillary-dock|mly-minimized|mly-3d-btn|mly-photoreal-btn/,
|
||||
);
|
||||
});
|
||||
|
||||
test('the panel is registered with panel chrome, cockpit entry and the right rail', () => {
|
||||
assert.match(panelChrome, /\{ id: 'mapillary-panel' \}/);
|
||||
assert.match(
|
||||
panelChrome,
|
||||
/COCKPIT_ENTRY_COLLAPSE_PANEL_IDS = Object\.freeze\(\[[\s\S]*'mapillary-panel'/,
|
||||
);
|
||||
assert.match(panelChrome, /const isRightRail = \[[\s\S]*'mapillary-panel'/);
|
||||
assert.match(
|
||||
layoutController,
|
||||
/stack\.insertBefore\(this\._mapillaryPanel, globalContextPanel\)/,
|
||||
);
|
||||
for (const rule of [
|
||||
'#right-context-rail > #mapillary-panel',
|
||||
'#right-context-rail #mapillary-panel.collapsed',
|
||||
'#right-context-rail.layout-focus > #mapillary-panel:not(.collapsed)',
|
||||
])
|
||||
assert.ok(css.includes(rule), `layers.css names ${rule}`);
|
||||
});
|
||||
|
||||
test('panel styles stay inside GEV conventions: no !important, no fixed panel', () => {
|
||||
assert.equal((mapillaryCss.match(/!important/g) || []).length, 0);
|
||||
const fixed = mapillaryCss
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.split('}')
|
||||
.filter((block) => /position:\s*fixed/.test(block))
|
||||
.map((block) => block.slice(0, block.indexOf('{')).trim());
|
||||
assert.deepEqual(fixed, ['.mly-viewer-wrap-expanded']);
|
||||
});
|
||||
|
||||
test('keyless and no-planner states are real, gated UI rather than dead buttons', () => {
|
||||
assert.match(html, /id="mly-keyless"[^>]*hidden/);
|
||||
assert.match(html, /MAPILLARY_CLIENT_TOKEN/);
|
||||
assert.match(html, /<fieldset id="mly-controls"/);
|
||||
assert.match(html, /<ul id="mly-legend"/);
|
||||
assert.match(html, /id="mly-error"[^>]*role="alert"/);
|
||||
assert.match(html, /id="mly-status"[^>]*role="status"/);
|
||||
});
|
||||
|
||||
test('the expanded viewer is a modal dialog that restores focus', () => {
|
||||
assert.match(controls, /setAttribute\('role', 'dialog'\)/);
|
||||
assert.match(controls, /setAttribute\('aria-modal', 'true'\)/);
|
||||
assert.match(controls, /_expandReturnFocus/);
|
||||
assert.match(
|
||||
controls,
|
||||
/if \(event\.key !== 'Escape'\) event\.stopPropagation\(\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test('labels say what the buttons do', () => {
|
||||
for (const label of [
|
||||
'OPEN NEAREST PHOTO',
|
||||
'CAMERA FOLLOWS VIEW',
|
||||
'ZOOM TO RESULTS',
|
||||
])
|
||||
assert.ok(html.includes(label), label);
|
||||
assert.doesNotMatch(html, /LOOK HERE|STREET COCKPIT|>FRAME</);
|
||||
});
|
||||
+141
-91
@@ -26,21 +26,31 @@ test('doctor distinguishes supported, usable EOL, and unsupported Node versions'
|
||||
// A FUTURE Node is a warning, never an install-bricking refusal: the
|
||||
// no-terminal user it would stop cannot act on "install Node 24".
|
||||
assert.equal(classifyNodeVersion('27.0.0').level, 'warn');
|
||||
assert.match(classifyNodeVersion('27.0.0').summary, /newer than this release has verified/);
|
||||
assert.match(
|
||||
classifyNodeVersion('27.0.0').summary,
|
||||
/newer than this release has verified/,
|
||||
);
|
||||
});
|
||||
|
||||
test('doctor rejects an empty node_modules and requires every direct package', () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'gev-doctor-deps-'));
|
||||
try {
|
||||
writeFileSync(path.join(root, 'package.json'), JSON.stringify({
|
||||
dependencies: { vite: '1.0.0' },
|
||||
devDependencies: { '@scope/tool': '1.0.0' },
|
||||
}));
|
||||
writeFileSync(
|
||||
path.join(root, 'package.json'),
|
||||
JSON.stringify({
|
||||
dependencies: { vite: '1.0.0' },
|
||||
devDependencies: { '@scope/tool': '1.0.0' },
|
||||
}),
|
||||
);
|
||||
mkdirSync(path.join(root, 'node_modules'));
|
||||
assert.equal(hasRequiredDependencies(root), false);
|
||||
|
||||
for (const packagePath of ['vite', '@scope/tool']) {
|
||||
const directory = path.join(root, 'node_modules', ...packagePath.split('/'));
|
||||
const directory = path.join(
|
||||
root,
|
||||
'node_modules',
|
||||
...packagePath.split('/'),
|
||||
);
|
||||
mkdirSync(directory, { recursive: true });
|
||||
writeFileSync(path.join(directory, 'package.json'), '{}');
|
||||
}
|
||||
@@ -58,36 +68,33 @@ test('placeholder values are never counted as configured credentials', () => {
|
||||
});
|
||||
|
||||
test('doctor selects a Windows-safe npm process without changing Unix behavior', () => {
|
||||
assert.deepEqual(npmProcessSpec('win32'), { command: 'npm.cmd', shell: true });
|
||||
assert.deepEqual(npmProcessSpec('win32'), {
|
||||
command: 'npm.cmd',
|
||||
shell: true,
|
||||
});
|
||||
assert.deepEqual(npmProcessSpec('darwin'), { command: 'npm', shell: false });
|
||||
assert.deepEqual(npmProcessSpec('linux'), { command: 'npm', shell: false });
|
||||
});
|
||||
|
||||
test('doctor recognizes every OpenSky OAuth keychain alias used by dev-fresh', () => {
|
||||
assert.deepEqual(
|
||||
credential('OPENSKY_CLIENT_ID').keychain,
|
||||
[
|
||||
['opensky-network', 'client_id'],
|
||||
['opensky-network', 'client-id'],
|
||||
['opensky-network', 'client'],
|
||||
['opensky-network', 'api-key'],
|
||||
['opensky', 'client_id'],
|
||||
['opensky', 'client-id'],
|
||||
['opensky', 'client'],
|
||||
['opensky', 'api-key'],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
credential('OPENSKY_CLIENT_SECRET').keychain,
|
||||
[
|
||||
['opensky-network', 'client_secret'],
|
||||
['opensky-network', 'client-secret'],
|
||||
['opensky-network', 'secret'],
|
||||
['opensky', 'client_secret'],
|
||||
['opensky', 'client-secret'],
|
||||
['opensky', 'secret'],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(credential('OPENSKY_CLIENT_ID').keychain, [
|
||||
['opensky-network', 'client_id'],
|
||||
['opensky-network', 'client-id'],
|
||||
['opensky-network', 'client'],
|
||||
['opensky-network', 'api-key'],
|
||||
['opensky', 'client_id'],
|
||||
['opensky', 'client-id'],
|
||||
['opensky', 'client'],
|
||||
['opensky', 'api-key'],
|
||||
]);
|
||||
assert.deepEqual(credential('OPENSKY_CLIENT_SECRET').keychain, [
|
||||
['opensky-network', 'client_secret'],
|
||||
['opensky-network', 'client-secret'],
|
||||
['opensky-network', 'secret'],
|
||||
['opensky', 'client_secret'],
|
||||
['opensky', 'client-secret'],
|
||||
['opensky', 'secret'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('Pinokio-scoped diagnosis ignores Keychain items its start path does not import', () => {
|
||||
@@ -95,17 +102,23 @@ test('Pinokio-scoped diagnosis ignores Keychain items its start path does not im
|
||||
try {
|
||||
const spec = credential('OPENAI_API_KEY');
|
||||
const keychainLookup = () => true;
|
||||
assert.deepEqual(resolveCredential(spec, {
|
||||
environment: {},
|
||||
rootDir: root,
|
||||
keychainLookup,
|
||||
}), { configured: true, source: 'macOS Keychain' });
|
||||
assert.deepEqual(resolveCredential(spec, {
|
||||
includeKeychain: false,
|
||||
environment: {},
|
||||
rootDir: root,
|
||||
keychainLookup,
|
||||
}), { configured: false, source: null });
|
||||
assert.deepEqual(
|
||||
resolveCredential(spec, {
|
||||
environment: {},
|
||||
rootDir: root,
|
||||
keychainLookup,
|
||||
}),
|
||||
{ configured: true, source: 'macOS Keychain' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolveCredential(spec, {
|
||||
includeKeychain: false,
|
||||
environment: {},
|
||||
rootDir: root,
|
||||
keychainLookup,
|
||||
}),
|
||||
{ configured: false, source: null },
|
||||
);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
@@ -115,18 +128,27 @@ test('Pinokio-scoped diagnosis does not count dotenv values shadowed by blank ap
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'gev-pinokio-doctor-env-'));
|
||||
try {
|
||||
const spec = credential('GOOGLE_MAPS_API_KEY');
|
||||
writeFileSync(path.join(root, '.env.local'), 'GOOGLE_MAPS_API_KEY=dotenv-only\n');
|
||||
assert.deepEqual(resolveCredential(spec, {
|
||||
environment: { GOOGLE_MAPS_API_KEY: '' },
|
||||
rootDir: root,
|
||||
keychainLookup: () => false,
|
||||
}), { configured: true, source: 'dotenv files' });
|
||||
assert.deepEqual(resolveCredential(spec, {
|
||||
authoritativeEnvironment: true,
|
||||
environment: { GOOGLE_MAPS_API_KEY: '' },
|
||||
rootDir: root,
|
||||
keychainLookup: () => false,
|
||||
}), { configured: false, source: null });
|
||||
writeFileSync(
|
||||
path.join(root, '.env.local'),
|
||||
'GOOGLE_MAPS_API_KEY=dotenv-only\n',
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolveCredential(spec, {
|
||||
environment: { GOOGLE_MAPS_API_KEY: '' },
|
||||
rootDir: root,
|
||||
keychainLookup: () => false,
|
||||
}),
|
||||
{ configured: true, source: 'dotenv files' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolveCredential(spec, {
|
||||
authoritativeEnvironment: true,
|
||||
environment: { GOOGLE_MAPS_API_KEY: '' },
|
||||
rootDir: root,
|
||||
keychainLookup: () => false,
|
||||
}),
|
||||
{ configured: false, source: null },
|
||||
);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
@@ -137,7 +159,10 @@ test('doctor reads the dotenv ladder without requiring Vite to be installed', ()
|
||||
try {
|
||||
writeFileSync(path.join(root, '.env'), 'GEV_TEST_KEY=base\n');
|
||||
writeFileSync(path.join(root, '.env.local'), 'GEV_TEST_KEY=local\n');
|
||||
writeFileSync(path.join(root, '.env.development.local'), 'GEV_TEST_KEY=mode-local\n');
|
||||
writeFileSync(
|
||||
path.join(root, '.env.development.local'),
|
||||
'GEV_TEST_KEY=mode-local\n',
|
||||
);
|
||||
assert.equal(readDoctorDotenvValue('GEV_TEST_KEY', root), 'mode-local');
|
||||
assert.equal(readDoctorDotenvValue('not valid', root), '');
|
||||
} finally {
|
||||
@@ -157,13 +182,21 @@ test('doctor describes the credential ladder without exposing values', () => {
|
||||
OPENSKY_CLIENT_ID: { configured: false },
|
||||
OPENSKY_CLIENT_SECRET: { configured: false },
|
||||
LL2_API_TOKEN: { configured: true, source: 'environment' },
|
||||
MAPILLARY_CLIENT_TOKEN: { configured: false },
|
||||
ANTHROPIC_API_KEY: { configured: false },
|
||||
};
|
||||
const capabilities = buildCapabilitySummary(credentials);
|
||||
assert.match(capabilities.map, /Google Photorealistic 3D Tiles through Cesium ion/);
|
||||
assert.match(
|
||||
capabilities.map,
|
||||
/Google Photorealistic 3D Tiles through Cesium ion/,
|
||||
);
|
||||
assert.match(capabilities.map, /Bing and world-terrain stacks/);
|
||||
assert.equal(capabilities.voice, 'available');
|
||||
assert.match(capabilities.missions, /token allowance/);
|
||||
assert.equal(capabilities.flights, 'OpenSky OAuth credentials not configured');
|
||||
assert.equal(
|
||||
capabilities.flights,
|
||||
'OpenSky OAuth credentials not configured',
|
||||
);
|
||||
|
||||
const report = formatSetupReport({
|
||||
ready: true,
|
||||
@@ -177,34 +210,47 @@ test('doctor describes the credential ladder without exposing values', () => {
|
||||
assert.match(report, /Cesium ion \(environment\)/);
|
||||
assert.match(report, /Launch Library 2 \(environment\)/);
|
||||
|
||||
const pinokioReport = formatSetupReport({
|
||||
ready: true,
|
||||
node: { version: '24.14.0', level: 'ok', summary: 'supported' },
|
||||
npm: { available: true, version: '11.0.0' },
|
||||
dependenciesInstalled: true,
|
||||
credentials,
|
||||
capabilities,
|
||||
}, { readyMessage: 'Ready. Return to Pinokio and choose Start.' });
|
||||
const pinokioReport = formatSetupReport(
|
||||
{
|
||||
ready: true,
|
||||
node: { version: '24.14.0', level: 'ok', summary: 'supported' },
|
||||
npm: { available: true, version: '11.0.0' },
|
||||
dependenciesInstalled: true,
|
||||
credentials,
|
||||
capabilities,
|
||||
},
|
||||
{ readyMessage: 'Ready. Return to Pinokio and choose Start.' },
|
||||
);
|
||||
assert.match(pinokioReport, /Return to Pinokio and choose Start/);
|
||||
assert.doesNotMatch(pinokioReport, /npm run dev/);
|
||||
});
|
||||
|
||||
test('doctor sends Keychain-backed reports to dev-fresh and describes OpenSky as presence only', () => {
|
||||
const credentials = Object.fromEntries([
|
||||
'GOOGLE_MAPS_API_KEY',
|
||||
'GOOGLE_MAPS_SERVER_API_KEY',
|
||||
'CESIUM_ION_TOKEN',
|
||||
'OPENAI_API_KEY',
|
||||
'AISSTREAM_API_KEY',
|
||||
'FIRMS_MAP_KEY',
|
||||
'TOMTOM_API_KEY',
|
||||
'OPENSKY_CLIENT_ID',
|
||||
'OPENSKY_CLIENT_SECRET',
|
||||
'LL2_API_TOKEN',
|
||||
].map((name) => [name, { configured: false }]));
|
||||
credentials.GOOGLE_MAPS_API_KEY = { configured: true, source: 'macOS Keychain' };
|
||||
const credentials = Object.fromEntries(
|
||||
[
|
||||
'GOOGLE_MAPS_API_KEY',
|
||||
'GOOGLE_MAPS_SERVER_API_KEY',
|
||||
'CESIUM_ION_TOKEN',
|
||||
'OPENAI_API_KEY',
|
||||
'AISSTREAM_API_KEY',
|
||||
'FIRMS_MAP_KEY',
|
||||
'TOMTOM_API_KEY',
|
||||
'OPENSKY_CLIENT_ID',
|
||||
'OPENSKY_CLIENT_SECRET',
|
||||
'LL2_API_TOKEN',
|
||||
'MAPILLARY_CLIENT_TOKEN',
|
||||
'ANTHROPIC_API_KEY',
|
||||
].map((name) => [name, { configured: false }]),
|
||||
);
|
||||
credentials.GOOGLE_MAPS_API_KEY = {
|
||||
configured: true,
|
||||
source: 'macOS Keychain',
|
||||
};
|
||||
credentials.OPENSKY_CLIENT_ID = { configured: true, source: 'environment' };
|
||||
credentials.OPENSKY_CLIENT_SECRET = { configured: true, source: 'environment' };
|
||||
credentials.OPENSKY_CLIENT_SECRET = {
|
||||
configured: true,
|
||||
source: 'environment',
|
||||
};
|
||||
const capabilities = buildCapabilitySummary(credentials);
|
||||
const output = formatSetupReport({
|
||||
ready: true,
|
||||
@@ -222,18 +268,22 @@ test('doctor sends Keychain-backed reports to dev-fresh and describes OpenSky as
|
||||
});
|
||||
|
||||
test('doctor never calls a dependency-missing setup ready', () => {
|
||||
const credentials = Object.fromEntries([
|
||||
'GOOGLE_MAPS_API_KEY',
|
||||
'GOOGLE_MAPS_SERVER_API_KEY',
|
||||
'CESIUM_ION_TOKEN',
|
||||
'OPENAI_API_KEY',
|
||||
'AISSTREAM_API_KEY',
|
||||
'FIRMS_MAP_KEY',
|
||||
'TOMTOM_API_KEY',
|
||||
'OPENSKY_CLIENT_ID',
|
||||
'OPENSKY_CLIENT_SECRET',
|
||||
'LL2_API_TOKEN',
|
||||
].map((name) => [name, { configured: false }]));
|
||||
const credentials = Object.fromEntries(
|
||||
[
|
||||
'GOOGLE_MAPS_API_KEY',
|
||||
'GOOGLE_MAPS_SERVER_API_KEY',
|
||||
'CESIUM_ION_TOKEN',
|
||||
'OPENAI_API_KEY',
|
||||
'AISSTREAM_API_KEY',
|
||||
'FIRMS_MAP_KEY',
|
||||
'TOMTOM_API_KEY',
|
||||
'OPENSKY_CLIENT_ID',
|
||||
'OPENSKY_CLIENT_SECRET',
|
||||
'LL2_API_TOKEN',
|
||||
'MAPILLARY_CLIENT_TOKEN',
|
||||
'ANTHROPIC_API_KEY',
|
||||
].map((name) => [name, { configured: false }]),
|
||||
);
|
||||
const output = formatSetupReport({
|
||||
ready: false,
|
||||
node: { level: 'ok', version: '24.14.0', summary: 'supported' },
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { promises as fsp } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { PbfWriter } from 'pbf';
|
||||
import { mapillaryProxy } from 'gods-eye-view/server/providers/mapillary';
|
||||
import {
|
||||
buildPlannerUserMessage,
|
||||
finalizePlan,
|
||||
planQuery,
|
||||
PLANNER_SYSTEM_PROMPT,
|
||||
reconcilePlanValues,
|
||||
} from '../../server/providers/mapillary/planner.js';
|
||||
import { normalizeFeatureQuery } from '../../server/providers/mapillary/features.js';
|
||||
import {
|
||||
listTileLayers,
|
||||
stripTileLayers,
|
||||
} from '../../server/providers/mapillary/trim.js';
|
||||
import {
|
||||
fetchTile,
|
||||
normalizeTileAddress,
|
||||
TileRequestError,
|
||||
_resetTileMemoryForTest,
|
||||
} from '../../server/providers/mapillary/tiles.js';
|
||||
import { handleSprite } from '../../server/providers/mapillary/sprites.js';
|
||||
|
||||
/** Mount the plugin and return a caller keyed by route. */
|
||||
function install(plugin, mode = 'configureServer') {
|
||||
const routes = new Map();
|
||||
plugin[mode]({
|
||||
middlewares: {
|
||||
use(route, handler) {
|
||||
routes.set(route, handler);
|
||||
},
|
||||
},
|
||||
});
|
||||
const call = async (route, url = '/', method = 'GET', body) => {
|
||||
const handler = routes.get(route);
|
||||
assert.ok(handler, `route ${route} is mounted`);
|
||||
const headers = {};
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
headersSent: false,
|
||||
writableEnded: false,
|
||||
setHeader(name, value) {
|
||||
headers[name.toLowerCase()] = value;
|
||||
},
|
||||
getHeader(name) {
|
||||
return headers[name.toLowerCase()];
|
||||
},
|
||||
writeHead(status, extra) {
|
||||
this.statusCode = status;
|
||||
Object.assign(headers, extra || {});
|
||||
},
|
||||
end(payload) {
|
||||
this.body = payload;
|
||||
this.writableEnded = true;
|
||||
},
|
||||
on() {},
|
||||
};
|
||||
const listeners = {};
|
||||
const req = {
|
||||
url,
|
||||
method,
|
||||
headers: {},
|
||||
on(event, fn) {
|
||||
listeners[event] = fn;
|
||||
},
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
if (body) yield Buffer.from(body);
|
||||
},
|
||||
};
|
||||
await handler(req, res);
|
||||
return { ...res, headers };
|
||||
};
|
||||
return { routes, call };
|
||||
}
|
||||
|
||||
const json = (res) => JSON.parse(String(res.body));
|
||||
|
||||
test('the plugin mounts the five Mapillary routes for dev and preview servers', () => {
|
||||
for (const mode of ['configureServer', 'configurePreviewServer']) {
|
||||
const { routes } = install(mapillaryProxy(), mode);
|
||||
assert.deepEqual([...routes.keys()].sort(), [
|
||||
'/api/mapillary/features',
|
||||
'/api/mapillary/plan',
|
||||
'/api/mapillary/sprite',
|
||||
'/api/mapillary/status',
|
||||
'/api/mapillary/tiles',
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test('status reports capabilities, never values, and rejects non-GET', async () => {
|
||||
const saved = {
|
||||
MAPILLARY_CLIENT_TOKEN: process.env.MAPILLARY_CLIENT_TOKEN,
|
||||
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
|
||||
};
|
||||
delete process.env.MAPILLARY_CLIENT_TOKEN;
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
try {
|
||||
const { call } = install(mapillaryProxy());
|
||||
const res = await call('/api/mapillary/status');
|
||||
assert.equal(res.statusCode, 200);
|
||||
const payload = json(res);
|
||||
assert.equal(payload.configured, false);
|
||||
assert.equal(payload.planner, false);
|
||||
assert.equal(payload.plannerModel, null);
|
||||
assert.equal(payload.limits.featureZoom, 14);
|
||||
assert.doesNotMatch(String(res.body), /MLY\||sk-ant/);
|
||||
const post = await call('/api/mapillary/status', '/', 'POST');
|
||||
assert.equal(post.statusCode, 405);
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(saved))
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
test('tile route validates the path and refuses to proxy without a token', async () => {
|
||||
const saved = process.env.MAPILLARY_CLIENT_TOKEN;
|
||||
delete process.env.MAPILLARY_CLIENT_TOKEN;
|
||||
try {
|
||||
const { call } = install(mapillaryProxy());
|
||||
const bad = await call('/api/mapillary/tiles', '/coverage/14/1/x');
|
||||
assert.equal(bad.statusCode, 400);
|
||||
const noKey = await call('/api/mapillary/tiles', '/coverage/14/1/2');
|
||||
assert.equal(noKey.statusCode, 503);
|
||||
assert.deepEqual(json(noKey), { error: 'no_key', keyRequired: true });
|
||||
const post = await call('/api/mapillary/tiles', '/coverage/14/1/2', 'POST');
|
||||
assert.equal(post.statusCode, 405);
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env.MAPILLARY_CLIENT_TOKEN;
|
||||
else process.env.MAPILLARY_CLIENT_TOKEN = saved;
|
||||
}
|
||||
});
|
||||
|
||||
test('normalizeTileAddress enforces layer names, zoom ranges and tile bounds', () => {
|
||||
assert.equal(
|
||||
normalizeTileAddress({ layer: 'coverage', z: 3, x: 1, y: 2 }).key,
|
||||
'coverage/3/1/2',
|
||||
);
|
||||
assert.deepEqual(
|
||||
normalizeTileAddress({ layer: 'coverage', z: '14', x: '5', y: '6' })
|
||||
.dropLayers,
|
||||
['image'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
normalizeTileAddress({ layer: 'points', z: 14, x: 5, y: 6 }).dropLayers,
|
||||
[],
|
||||
);
|
||||
for (const bad of [
|
||||
{ layer: 'image', z: 14, x: 1, y: 1 },
|
||||
{ layer: 'points', z: 13, x: 1, y: 1 },
|
||||
{ layer: 'coverage', z: 15, x: 1, y: 1 },
|
||||
{ layer: 'coverage', z: 2, x: 4, y: 0 },
|
||||
{ layer: 'coverage', z: 2, x: 1.5, y: 0 },
|
||||
{ layer: 'coverage', z: -1, x: 0, y: 0 },
|
||||
])
|
||||
assert.throws(
|
||||
() => normalizeTileAddress(bad),
|
||||
TileRequestError,
|
||||
JSON.stringify(bad),
|
||||
);
|
||||
});
|
||||
|
||||
test('fetchTile serves from memory after one upstream fetch and strips the image layer', async () => {
|
||||
const savedFetch = globalThis.fetch;
|
||||
const savedToken = process.env.MAPILLARY_CLIENT_TOKEN;
|
||||
process.env.MAPILLARY_CLIENT_TOKEN = 'MLY|test|token';
|
||||
_resetTileMemoryForTest();
|
||||
const tile = (() => {
|
||||
const writer = new PbfWriter();
|
||||
for (const name of ['sequence', 'image']) {
|
||||
writer.writeMessage(
|
||||
3,
|
||||
(layer, pbf) => {
|
||||
pbf.writeVarintField(15, 2);
|
||||
pbf.writeStringField(1, layer.name);
|
||||
if (layer.name === 'image')
|
||||
pbf.writeBytesField(4, Buffer.alloc(4000, 1));
|
||||
},
|
||||
{ name },
|
||||
);
|
||||
}
|
||||
return Buffer.from(writer.finish());
|
||||
})();
|
||||
let upstreamCalls = 0;
|
||||
globalThis.fetch = async (url) => {
|
||||
upstreamCalls++;
|
||||
assert.match(
|
||||
String(url),
|
||||
/tiles\.mapillary\.com\/maps\/vtp\/mly1_public\/2\/10\/0\/0\?access_token=/,
|
||||
);
|
||||
return new Response(tile, {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/x-protobuf' },
|
||||
});
|
||||
};
|
||||
try {
|
||||
// z10 is never requested by the app (overview z0–5, sequences z11–14), and
|
||||
// the file is removed first so an earlier test run cannot leave a disk hit.
|
||||
await fsp
|
||||
.rm(
|
||||
path.join(
|
||||
process.cwd(),
|
||||
'.gev-cache/mapillary/tiles/coverage/10/0-0.pbf',
|
||||
),
|
||||
)
|
||||
.catch(() => {});
|
||||
const first = await fetchTile({ layer: 'coverage', z: 10, x: 0, y: 0 });
|
||||
assert.equal(first.source, 'upstream');
|
||||
assert.deepEqual(listTileLayers(first.bytes), ['sequence']);
|
||||
assert.ok(first.bytes.length < 100, 'image layer stripped in transit');
|
||||
const second = await fetchTile({ layer: 'coverage', z: 10, x: 0, y: 0 });
|
||||
assert.equal(second.source, 'memory');
|
||||
assert.equal(upstreamCalls, 1);
|
||||
} finally {
|
||||
globalThis.fetch = savedFetch;
|
||||
if (savedToken === undefined) delete process.env.MAPILLARY_CLIENT_TOKEN;
|
||||
else process.env.MAPILLARY_CLIENT_TOKEN = savedToken;
|
||||
_resetTileMemoryForTest();
|
||||
}
|
||||
});
|
||||
|
||||
test('sprite route only accepts taxonomy values, never paths', async () => {
|
||||
const respond = async (url) => {
|
||||
const headers = {};
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
setHeader: (k, v) => {
|
||||
headers[k.toLowerCase()] = v;
|
||||
},
|
||||
end(body) {
|
||||
this.body = body;
|
||||
},
|
||||
};
|
||||
await handleSprite({ url }, res);
|
||||
return { status: res.statusCode, headers, body: res.body };
|
||||
};
|
||||
for (const bad of [
|
||||
'/..%2Fetc%2Fpasswd.svg',
|
||||
'/../x.svg',
|
||||
'/Object--Fire.svg',
|
||||
'/a..b.svg',
|
||||
'/a--.svg',
|
||||
'/.svg',
|
||||
'/x.png',
|
||||
])
|
||||
assert.equal((await respond(bad)).status, 400, bad);
|
||||
const savedFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (url) => {
|
||||
assert.match(
|
||||
String(url),
|
||||
/package_objects\/object--fire-hydrant--zz-test\.svg$/,
|
||||
);
|
||||
return new Response('<svg xmlns="http://www.w3.org/2000/svg"></svg>', {
|
||||
status: 200,
|
||||
});
|
||||
};
|
||||
try {
|
||||
const ok = await respond('/object--fire-hydrant--zz-test.svg');
|
||||
assert.equal(ok.status, 200);
|
||||
assert.match(ok.headers['content-type'], /image\/svg\+xml/);
|
||||
} finally {
|
||||
globalThis.fetch = savedFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('normalizeFeatureQuery caps values, normalises the bbox and rejects oversized areas', () => {
|
||||
const missing = normalizeFeatureQuery({});
|
||||
assert.equal(missing.ok, false);
|
||||
assert.equal(missing.status, 400);
|
||||
const ok = normalizeFeatureQuery({
|
||||
bbox: [-121.5, 38.55, -121.45, 38.6],
|
||||
layer: 'points',
|
||||
values: ['object--fire-hydrant', '', 'object--bench'],
|
||||
seenAfter: '2020-01-01',
|
||||
});
|
||||
assert.equal(ok.ok, true);
|
||||
assert.deepEqual(ok.request.values.slice(0, 2), [
|
||||
'object--fire-hydrant',
|
||||
'object--bench',
|
||||
]);
|
||||
assert.equal(ok.request.since, Date.parse('2020-01-01'));
|
||||
assert.ok(ok.request.tiles.length > 0);
|
||||
const huge = normalizeFeatureQuery({
|
||||
bbox: [-125, 32, -114, 42],
|
||||
layer: 'points',
|
||||
values: ['object--bench'],
|
||||
});
|
||||
assert.equal(huge.ok, false);
|
||||
assert.equal(huge.error, 'area_too_large');
|
||||
assert.ok(huge.detail.tiles > huge.detail.limit);
|
||||
});
|
||||
|
||||
// ── Planner (relocated from src/layers/mapillary/planner.test.mjs) ──────────
|
||||
const basePlan = {
|
||||
intent: 'map_features',
|
||||
place: 'Sacramento, California',
|
||||
use_current_view: false,
|
||||
values: ['object--fire-hydrant'],
|
||||
seen_after: null,
|
||||
seen_before: null,
|
||||
prefer_pano: false,
|
||||
visualise: 'icons',
|
||||
title: 'Fire hydrants · Sacramento',
|
||||
answer: 'Showing fire hydrants in Sacramento.',
|
||||
};
|
||||
|
||||
test('the system prompt is frozen text carrying the taxonomy', () => {
|
||||
assert.match(PLANNER_SYSTEM_PROMPT, /object--fire-hydrant/);
|
||||
assert.match(PLANNER_SYSTEM_PROMPT, /regulatory--stop\b/);
|
||||
assert.doesNotMatch(PLANNER_SYSTEM_PROMPT, /Today is/);
|
||||
});
|
||||
|
||||
test('reconcilePlanValues keeps taxonomy values, expands families, drops junk', () => {
|
||||
const result = reconcilePlanValues([
|
||||
'object--fire-hydrant',
|
||||
'regulatory--stop',
|
||||
'regulatory--stop--*',
|
||||
'object--unicorn',
|
||||
'warning--nothing-like-this--*',
|
||||
]);
|
||||
assert.deepEqual(result.values, [
|
||||
'object--fire-hydrant',
|
||||
'regulatory--stop--*',
|
||||
]);
|
||||
assert.deepEqual(result.dropped, [
|
||||
'object--unicorn',
|
||||
'warning--nothing-like-this--*',
|
||||
]);
|
||||
});
|
||||
|
||||
test('finalizePlan derives the tile layer from the values', () => {
|
||||
assert.equal(finalizePlan(basePlan).layer, 'points');
|
||||
const signs = finalizePlan({
|
||||
...basePlan,
|
||||
intent: 'map_features',
|
||||
values: ['regulatory--stop--*'],
|
||||
});
|
||||
assert.equal(signs.layer, 'signs');
|
||||
assert.equal(signs.intent, 'traffic_signs');
|
||||
});
|
||||
|
||||
test('finalizePlan downgrades to unsupported when nothing usable survives', () => {
|
||||
const plan = finalizePlan({ ...basePlan, values: ['object--unicorn'] });
|
||||
assert.equal(plan.intent, 'unsupported');
|
||||
assert.match(plan.answer, /object--unicorn/);
|
||||
});
|
||||
|
||||
test('finalizePlan keeps objects and reports signs on a mixed request', () => {
|
||||
const plan = finalizePlan({
|
||||
...basePlan,
|
||||
values: ['object--fire-hydrant', 'regulatory--stop--*'],
|
||||
});
|
||||
assert.equal(plan.layer, 'points');
|
||||
assert.deepEqual(plan.values, ['object--fire-hydrant']);
|
||||
assert.deepEqual(plan.dropped, ['regulatory--stop--*']);
|
||||
assert.match(plan.answer, /different layers/);
|
||||
});
|
||||
|
||||
test('buildPlannerUserMessage carries the date, view and previous plan', () => {
|
||||
const text = buildPlannerUserMessage({
|
||||
query: 'only after 2024',
|
||||
today: '2026-09-15',
|
||||
context: {
|
||||
view: { lat: 38.58, lon: -121.49, heightM: 1234.6, label: 'Sacramento' },
|
||||
previousPlan: basePlan,
|
||||
},
|
||||
});
|
||||
assert.match(text, /Today is 2026-09-15/);
|
||||
assert.match(
|
||||
text,
|
||||
/38\.5800, -121\.4900, camera 1235 m above ground, near Sacramento/,
|
||||
);
|
||||
assert.match(text, /Previous plan: \{"intent":"map_features"/);
|
||||
assert.match(text, /Request: only after 2024$/);
|
||||
});
|
||||
|
||||
test('planQuery sends a cached system block and structured output config', async () => {
|
||||
const calls = [];
|
||||
const anthropic = {
|
||||
messages: {
|
||||
async create(params) {
|
||||
calls.push(params);
|
||||
return {
|
||||
stop_reason: 'end_turn',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
...basePlan,
|
||||
values: ['object--fire-hydrant', 'bogus'],
|
||||
}),
|
||||
},
|
||||
],
|
||||
usage: { input_tokens: 10, output_tokens: 5 },
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = await planQuery(
|
||||
{ query: 'show me all fire hydrants in Sacramento', context: {} },
|
||||
{
|
||||
anthropic,
|
||||
model: 'claude-opus-5',
|
||||
now: new Date('2026-09-15T12:00:00Z'),
|
||||
},
|
||||
);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].model, 'claude-opus-5');
|
||||
assert.deepEqual(calls[0].system[0].cache_control, { type: 'ephemeral' });
|
||||
assert.equal(calls[0].output_config.effort, 'low');
|
||||
assert.equal(calls[0].output_config.format.type, 'json_schema');
|
||||
assert.equal(calls[0].output_config.format.schema.type, 'object');
|
||||
assert.ok(!('$schema' in calls[0].output_config.format.schema));
|
||||
assert.ok(calls[0].output_config.format.schema.properties.intent);
|
||||
assert.deepEqual(result.plan.values, ['object--fire-hydrant']);
|
||||
assert.deepEqual(result.plan.dropped, ['bogus']);
|
||||
assert.equal(result.plan.layer, 'points');
|
||||
});
|
||||
|
||||
test('planQuery turns a refusal into an unsupported plan instead of throwing', async () => {
|
||||
const anthropic = {
|
||||
messages: {
|
||||
async create() {
|
||||
return { stop_reason: 'refusal', usage: null };
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = await planQuery(
|
||||
{ query: 'x', context: {} },
|
||||
{ anthropic, model: 'm' },
|
||||
);
|
||||
assert.equal(result.plan.intent, 'unsupported');
|
||||
});
|
||||
|
||||
test('normalizeFeatureQuery validates bbox, layer, values and the tile cap', () => {
|
||||
const ok = normalizeFeatureQuery({
|
||||
bbox: [-121.56, 38.44, -121.36, 38.69],
|
||||
layer: 'points',
|
||||
values: ['Object--Fire-Hydrant', ''],
|
||||
seenAfter: '2024-01-01',
|
||||
});
|
||||
assert.equal(ok.ok, true);
|
||||
assert.equal(ok.request.tiles.length, 160);
|
||||
assert.deepEqual(ok.request.values, ['object--fire-hydrant']);
|
||||
assert.equal(ok.request.since, Date.parse('2024-01-01'));
|
||||
assert.equal(ok.request.until, null);
|
||||
assert.equal(normalizeFeatureQuery({ bbox: 'nope' }).status, 400);
|
||||
const huge = normalizeFeatureQuery({ bbox: [-125, 32, -114, 42] });
|
||||
assert.equal(huge.ok, false);
|
||||
assert.equal(huge.status, 413);
|
||||
assert.equal(huge.error, 'area_too_large');
|
||||
});
|
||||
|
||||
// ── Tile trimming (relocated from server/providers/mapillary/trim.test.mjs) ──
|
||||
/** Build a minimal MVT: layers with a name, a version and one opaque feature. */
|
||||
function tile(layers) {
|
||||
const writer = new PbfWriter();
|
||||
for (const { name, payload } of layers) {
|
||||
writer.writeMessage(
|
||||
3,
|
||||
(layer, pbf) => {
|
||||
pbf.writeVarintField(15, 2); // version
|
||||
pbf.writeStringField(1, layer.name);
|
||||
pbf.writeMessage(2, (_f, p) => p.writeVarintField(1, 7), null); // feature
|
||||
pbf.writeVarintField(5, 4096); // extent
|
||||
if (layer.payload) pbf.writeBytesField(4, layer.payload); // a big key
|
||||
},
|
||||
{ name, payload },
|
||||
);
|
||||
}
|
||||
return Buffer.from(writer.finish());
|
||||
}
|
||||
|
||||
test('dropping a layer keeps the others byte-for-byte', () => {
|
||||
const big = Buffer.alloc(50_000, 7);
|
||||
const bytes = tile([
|
||||
{ name: 'sequence' },
|
||||
{ name: 'image', payload: big },
|
||||
{ name: 'overview' },
|
||||
]);
|
||||
const trimmed = stripTileLayers(bytes, ['image']);
|
||||
assert.deepEqual(listTileLayers(trimmed), ['sequence', 'overview']);
|
||||
assert.ok(trimmed.length < 200, `trimmed to ${trimmed.length} bytes`);
|
||||
assert.deepEqual(trimmed, tile([{ name: 'sequence' }, { name: 'overview' }]));
|
||||
});
|
||||
|
||||
test('a tile without the layer is returned untouched', () => {
|
||||
const bytes = tile([{ name: 'sequence' }]);
|
||||
assert.equal(stripTileLayers(bytes, ['image']), bytes);
|
||||
assert.equal(stripTileLayers(bytes, []), bytes);
|
||||
assert.equal(stripTileLayers(Buffer.alloc(0), ['image']).length, 0);
|
||||
});
|
||||
|
||||
test('layer names are read without decoding features', () => {
|
||||
assert.deepEqual(listTileLayers(tile([{ name: 'a' }, { name: 'b' }])), [
|
||||
'a',
|
||||
'b',
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
hydrantPlan,
|
||||
isCollapsed,
|
||||
RAIL_ORDER,
|
||||
VIEWPORTS,
|
||||
} from '../../scripts/qa-mapillary.mjs';
|
||||
|
||||
test('the QA harness exercises both review viewports', () => {
|
||||
assert.deepEqual(
|
||||
VIEWPORTS.map((v) => `${v.width}x${v.height}`),
|
||||
['1440x900', '1280x800'],
|
||||
);
|
||||
});
|
||||
|
||||
test('the ready-made plan is executable without the planner', () => {
|
||||
const plan = hydrantPlan();
|
||||
assert.equal(plan.intent, 'map_features');
|
||||
assert.equal(plan.layer, 'points');
|
||||
assert.equal(plan.use_current_view, true);
|
||||
assert.deepEqual(plan.values, ['object--fire-hydrant']);
|
||||
});
|
||||
|
||||
test('rail order puts Street Level between CCTV and Context', () => {
|
||||
assert.deepEqual(RAIL_ORDER, [
|
||||
'pp-toggles',
|
||||
'cctv-panel',
|
||||
'mapillary-panel',
|
||||
'global-context-panel',
|
||||
]);
|
||||
assert.equal(isCollapsed(['panel-collapsible', 'collapsed']), true);
|
||||
assert.equal(isCollapsed(['panel-collapsible']), false);
|
||||
});
|
||||
|
||||
test('the harness only runs its browser flow when executed directly', () => {
|
||||
const source = fs.readFileSync(
|
||||
new URL('../../scripts/qa-mapillary.mjs', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/import\.meta\.url === pathToFileURL\(process\.argv\[1\]\)\.href/,
|
||||
);
|
||||
assert.match(source, /PUPPETEER_EXECUTABLE_PATH/);
|
||||
assert.match(source, /--url/);
|
||||
});
|
||||
@@ -675,7 +675,7 @@ test('public transit documentation uses courtesy attribution and the approved gr
|
||||
);
|
||||
assert.ok(
|
||||
readme.includes(
|
||||
'Fifteen layers and map sources. **Thirteen have a keyless path.**',
|
||||
'Sixteen layers and map sources. **Thirteen have a keyless path.**',
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
|
||||
@@ -30,7 +30,14 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop
|
||||
assert.deepEqual(config.define, {
|
||||
'import.meta.env.GOOGLE_MAPS_API_KEY': '"browser-fixture"',
|
||||
'import.meta.env.CESIUM_ION_TOKEN': '"ion-fixture"',
|
||||
'import.meta.env.MAPILLARY_CLIENT_TOKEN': '""',
|
||||
});
|
||||
assert.equal(
|
||||
createBrowserViteConfig({ mapillaryToken: 'MLY|1|abc' }).define[
|
||||
'import.meta.env.MAPILLARY_CLIENT_TOKEN'
|
||||
],
|
||||
'"MLY|1|abc"',
|
||||
);
|
||||
assert.equal(
|
||||
createBrowserViteConfig({ host: '0.0.0.0', port: '4800' }).server
|
||||
.allowedHosts,
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
mapillaryImageUrl,
|
||||
presentMapillaryPanel,
|
||||
} from './mapillaryPresentation.js';
|
||||
|
||||
function snapshot(overrides = {}) {
|
||||
const base = {
|
||||
enabled: false,
|
||||
keyRequired: false,
|
||||
planner: true,
|
||||
plannerModel: 'claude-opus-5',
|
||||
coverage: {
|
||||
zoom: null,
|
||||
kind: null,
|
||||
filter: { pano: 'all', sinceMs: null },
|
||||
legend: [
|
||||
{ key: 'recent', label: 'Recent (≤2 yr)', color: '#05cb63' },
|
||||
{ key: 'older', label: 'Older', color: '#2e7d5b' },
|
||||
{ key: 'pano', label: '360°', color: '#ff4fd8' },
|
||||
{ key: 'selected', label: 'Selected', color: '#00d4ff' },
|
||||
],
|
||||
loading: false,
|
||||
sequences: 0,
|
||||
hint: '',
|
||||
error: null,
|
||||
},
|
||||
sequence: { selectedId: null, images: 0, loading: false },
|
||||
features: {
|
||||
total: 0,
|
||||
title: '',
|
||||
loading: false,
|
||||
progress: { done: 0, tiles: 0 },
|
||||
counts: [],
|
||||
truncated: false,
|
||||
failed: 0,
|
||||
iconMode: false,
|
||||
hasBbox: false,
|
||||
selectedId: null,
|
||||
bbox: null,
|
||||
},
|
||||
street: {
|
||||
open: false,
|
||||
follow: false,
|
||||
loading: false,
|
||||
error: null,
|
||||
imageId: null,
|
||||
position: null,
|
||||
bearing: null,
|
||||
isPano: false,
|
||||
capturedAt: null,
|
||||
sequenceId: null,
|
||||
creator: null,
|
||||
renderMode: 'letterbox',
|
||||
feature: null,
|
||||
highlight: null,
|
||||
},
|
||||
query: {
|
||||
busy: false,
|
||||
stage: 'idle',
|
||||
prompt: '',
|
||||
place: null,
|
||||
answer: '',
|
||||
error: null,
|
||||
title: '',
|
||||
usage: null,
|
||||
history: [],
|
||||
},
|
||||
};
|
||||
return deepMerge(base, overrides);
|
||||
}
|
||||
|
||||
function deepMerge(target, source) {
|
||||
const out = { ...target };
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
out[key] =
|
||||
value &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
target[key] &&
|
||||
typeof target[key] === 'object'
|
||||
? deepMerge(target[key], value)
|
||||
: value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
test('a missing Mapillary token gates every control and names the variable', () => {
|
||||
const view = presentMapillaryPanel(snapshot({ keyRequired: true }));
|
||||
assert.equal(view.keyless.visible, true);
|
||||
assert.match(view.keyless.text, /MAPILLARY_CLIENT_TOKEN/);
|
||||
assert.equal(view.controlsDisabled, true);
|
||||
assert.deepEqual(view.status, { text: 'KEY REQUIRED', tone: 'warn' });
|
||||
});
|
||||
|
||||
test('without an Anthropic key the query box explains itself and stays disabled', () => {
|
||||
const view = presentMapillaryPanel(
|
||||
snapshot({ planner: false, enabled: true }),
|
||||
);
|
||||
assert.equal(view.query.inputDisabled, true);
|
||||
assert.equal(view.query.suggestionsDisabled, true);
|
||||
assert.equal(view.query.hintWarn, true);
|
||||
assert.match(view.query.hint, /Anthropic key/);
|
||||
assert.ok(view.query.placeholder.length < 40, 'placeholder fits the field');
|
||||
assert.equal(view.keyless.visible, false);
|
||||
assert.equal(view.controlsDisabled, false);
|
||||
});
|
||||
|
||||
test('a running query turns ASK into STOP and shows indeterminate progress per stage', () => {
|
||||
const planning = presentMapillaryPanel(
|
||||
snapshot({ enabled: true, query: { busy: true, stage: 'planning' } }),
|
||||
);
|
||||
assert.equal(planning.query.submitLabel, 'STOP');
|
||||
assert.equal(planning.query.submitIsStop, true);
|
||||
assert.deepEqual(planning.progress, {
|
||||
visible: true,
|
||||
indeterminate: true,
|
||||
percent: 0,
|
||||
label: 'Planning…',
|
||||
});
|
||||
assert.equal(planning.status.text, 'AI · PLANNING');
|
||||
|
||||
const resolving = presentMapillaryPanel(
|
||||
snapshot({
|
||||
enabled: true,
|
||||
query: { busy: true, stage: 'resolving', place: 'Sacramento, CA' },
|
||||
}),
|
||||
);
|
||||
assert.equal(resolving.progress.label, 'Finding Sacramento, CA…');
|
||||
|
||||
const fetching = presentMapillaryPanel(
|
||||
snapshot({
|
||||
enabled: true,
|
||||
query: { busy: true, stage: 'fetching' },
|
||||
features: { loading: true, progress: { done: 3, tiles: 12 }, total: 410 },
|
||||
}),
|
||||
);
|
||||
assert.equal(fetching.progress.indeterminate, false);
|
||||
assert.equal(fetching.progress.percent, 25);
|
||||
assert.equal(fetching.progress.label, '3/12 tiles · 410 found');
|
||||
});
|
||||
|
||||
test('idle copy depends on whether the layer is on', () => {
|
||||
assert.match(
|
||||
presentMapillaryPanel(snapshot()).answer,
|
||||
/Turn on Street Level/,
|
||||
);
|
||||
assert.match(
|
||||
presentMapillaryPanel(snapshot({ enabled: true })).answer,
|
||||
/Click a green line/,
|
||||
);
|
||||
const view = presentMapillaryPanel(
|
||||
snapshot({ enabled: true, query: { error: 'Planner unavailable' } }),
|
||||
);
|
||||
assert.equal(view.error, 'Planner unavailable');
|
||||
assert.equal(view.answer, '');
|
||||
});
|
||||
|
||||
test('result chips cap at eight and describe the overflow', () => {
|
||||
const counts = Array.from({ length: 11 }, (_, i) => ({
|
||||
value: `object--v${i}`,
|
||||
label: `Class ${i}`,
|
||||
color: '#fff',
|
||||
count: 100 - i,
|
||||
}));
|
||||
const view = presentMapillaryPanel(
|
||||
snapshot({ enabled: true, features: { total: 900, counts } }),
|
||||
);
|
||||
assert.equal(view.results.visible, true);
|
||||
assert.equal(view.results.chips.length, 8);
|
||||
assert.equal(view.results.more.count, 3);
|
||||
assert.match(view.results.more.title, /Class 8 92, Class 9 91, Class 10 90/);
|
||||
assert.equal(view.results.all.length, 11);
|
||||
});
|
||||
|
||||
test('legend passes through in the layer’s order', () => {
|
||||
const view = presentMapillaryPanel(snapshot());
|
||||
assert.deepEqual(
|
||||
view.legend.map((entry) => entry.key),
|
||||
['recent', 'older', 'pano', 'selected'],
|
||||
);
|
||||
});
|
||||
|
||||
test('the meta line never mixes the visible-sequence count with the selected sequence', () => {
|
||||
const browsing = presentMapillaryPanel(
|
||||
snapshot({ enabled: true, coverage: { zoom: 14, sequences: 812 } }),
|
||||
);
|
||||
assert.match(browsing.meta, /^812 sequences in view · click a line/);
|
||||
const selected = presentMapillaryPanel(
|
||||
snapshot({
|
||||
enabled: true,
|
||||
coverage: { zoom: 14, sequences: 812 },
|
||||
sequence: { selectedId: 'abc', images: 33 },
|
||||
}),
|
||||
);
|
||||
assert.equal(selected.meta, '33 images in this sequence · Esc clears');
|
||||
assert.doesNotMatch(selected.meta, /sequences in view/);
|
||||
});
|
||||
|
||||
test('viewer caption reads "Image by" left and date right, with a deep link', () => {
|
||||
const view = presentMapillaryPanel(
|
||||
snapshot({
|
||||
enabled: true,
|
||||
street: {
|
||||
open: true,
|
||||
imageId: '1814275685699406',
|
||||
creator: 'mapfool',
|
||||
capturedAt: Date.UTC(2023, 9, 8),
|
||||
bearing: 93.4,
|
||||
isPano: true,
|
||||
feature: { label: 'Fire hydrant', imageCount: 5 },
|
||||
highlight: { count: 2 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.equal(
|
||||
view.viewer.captionLeft,
|
||||
'Fire hydrant · 5 sightings · 2 detections outlined · Image by mapfool',
|
||||
);
|
||||
assert.equal(view.viewer.captionRight, '360° · 93° · 2023-10-08');
|
||||
assert.equal(
|
||||
view.viewer.link,
|
||||
'https://www.mapillary.com/app/?pKey=1814275685699406&focus=photo',
|
||||
);
|
||||
assert.equal(view.viewer.follow.disabled, false);
|
||||
assert.equal(view.wantsOpen, true);
|
||||
});
|
||||
|
||||
test('image deep links match the mapillary.com share format', () => {
|
||||
assert.equal(
|
||||
mapillaryImageUrl(1814275685699406),
|
||||
'https://www.mapillary.com/app/?pKey=1814275685699406&focus=photo',
|
||||
);
|
||||
assert.equal(mapillaryImageUrl(' '), 'https://www.mapillary.com/app/');
|
||||
});
|
||||
Reference in new issue
Block a user