mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24: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);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user