mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
UTILITIES becomes its own panel in the left column, after Data Layers and CCTV and above Scenes. The layer panel renders the Directions row into the panel's list instead of Data Layers, and Draw and Import move there from DISPLAY with their ids unchanged. Radio, the other row under the old Utilities heading, stays in Data Layers under "Audio". The panel collapses and remembers its state like the other left panels; share links do not carry it. It hides in clean view, recording and Cockpit, takes the Cyber left-rail treatment, and sits collapsed in the phone's Layers sheet. Voice focuses the Directions row in its new list. The Directions, Draw, Import and form-field gates open UTILITIES to reach their controls.
845 lines
29 KiB
JavaScript
845 lines
29 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Browser proof of the Directions layer: real chip clicks, real globe clicks,
|
|
* a real route from the real proxy, a real camera flight.
|
|
*
|
|
* Usage: node scripts/qa-directions.mjs [--url http://localhost:4173]
|
|
* [--shots qa-shots/directions] [--label keyed] [--skip denver]
|
|
*/
|
|
import fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import puppeteer from 'puppeteer';
|
|
import { chromeExecutablePath } from './qa-chrome.mjs';
|
|
|
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
const args = process.argv.slice(2);
|
|
const arg = (name, fallback) => {
|
|
const i = args.indexOf(`--${name}`);
|
|
return i >= 0 && args[i + 1] ? args[i + 1] : fallback;
|
|
};
|
|
const BASE = arg('url', 'http://localhost:4173');
|
|
const SHOTS = arg('shots', path.join(ROOT, 'qa-shots', 'directions'));
|
|
const LABEL = arg('label', 'keyed');
|
|
const SKIP = new Set((arg('skip', '') || '').split(',').filter(Boolean));
|
|
|
|
await fs.mkdir(SHOTS, { recursive: true });
|
|
|
|
let failures = 0;
|
|
const lines = [];
|
|
const check = (name, passed, detail = '') => {
|
|
const text = `[${passed ? 'PASS' : 'FAIL'}] ${name}${detail ? ` — ${detail}` : ''}`;
|
|
console.log(text);
|
|
lines.push(text);
|
|
if (!passed) failures += 1;
|
|
};
|
|
|
|
const browser = await puppeteer.launch({
|
|
headless: false,
|
|
executablePath: chromeExecutablePath(),
|
|
args: [
|
|
'--no-sandbox',
|
|
'--disable-background-timer-throttling',
|
|
'--disable-renderer-backgrounding',
|
|
'--window-size=1280,860',
|
|
],
|
|
});
|
|
const page = await browser.newPage();
|
|
await page.setViewport({ width: 1280, height: 860 });
|
|
const pageErrors = [];
|
|
page.on('pageerror', (error) => pageErrors.push(error.message));
|
|
const consoleErrors = [];
|
|
page.on('console', (msg) => {
|
|
if (msg.type() === 'error') consoleErrors.push(msg.text());
|
|
});
|
|
|
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
const shot = async (name) => {
|
|
const file = path.join(SHOTS, name);
|
|
await page.screenshot({ path: file, type: 'jpeg', quality: 82 });
|
|
console.log(` shot: ${file}`);
|
|
};
|
|
|
|
/** Put the camera over a place, instantly, and let the scene settle. */
|
|
async function lookAt(lat, lon, height) {
|
|
await page.evaluate(
|
|
(la, lo, h) => {
|
|
const viewer = window.__godsEyeView.viewer;
|
|
const Cartesian3 = viewer.camera.positionWC.constructor;
|
|
viewer.camera.cancelFlight();
|
|
viewer.camera.setView({
|
|
destination: Cartesian3.fromDegrees(lo, la, h),
|
|
orientation: { heading: 0, pitch: -Math.PI / 2.2, roll: 0 },
|
|
});
|
|
},
|
|
lat,
|
|
lon,
|
|
height,
|
|
);
|
|
}
|
|
|
|
/** Canvas position of a lon/lat on the rendered surface, or null. */
|
|
const screenAt = (lat, lon) =>
|
|
page.evaluate(
|
|
(la, lo) => {
|
|
const viewer = window.__godsEyeView.viewer;
|
|
const scene = viewer.scene;
|
|
const Cartesian3 = viewer.camera.positionWC.constructor;
|
|
const Cartographic = viewer.camera.positionCartographic.constructor;
|
|
const carto = Cartographic.fromDegrees(lo, la);
|
|
let ground = scene.globe?.getHeight?.(carto);
|
|
if (!Number.isFinite(ground)) ground = 0;
|
|
const world = Cartesian3.fromDegrees(lo, la, ground);
|
|
const win = scene.cartesianToCanvasCoordinates(world);
|
|
if (!win || !Number.isFinite(win.x)) return null;
|
|
const x = Math.round(win.x);
|
|
const y = Math.round(win.y);
|
|
if (x < 40 || y < 40 || x > 1240 || y > 820) return null;
|
|
return { x, y };
|
|
},
|
|
lat,
|
|
lon,
|
|
);
|
|
|
|
const layerState = () =>
|
|
page.evaluate(() => {
|
|
const gev = window.__godsEyeView;
|
|
const manager = gev.dataManager;
|
|
const module = manager.layers.get('directions').module;
|
|
const row = document.querySelector(
|
|
'#utilities-toggles [data-layer-id="directions"]',
|
|
);
|
|
const controls = module.getRowControls();
|
|
const viewer = gev.viewer;
|
|
const entities = [...viewer.entities.values];
|
|
const routeEntities = entities.filter(
|
|
(entity) => String(entity.id) === 'directions:route',
|
|
);
|
|
const listNodes = row
|
|
? [...row.querySelectorAll('.data-row-list-item')]
|
|
: [];
|
|
return {
|
|
stats: module.getStats(),
|
|
chips: controls.chips.map((chip) => ({
|
|
id: chip.id,
|
|
label: chip.label,
|
|
active: !!chip.active,
|
|
disabled: !!chip.disabled,
|
|
})),
|
|
listCount: controls.list?.items?.length || 0,
|
|
listText: (controls.list?.items || []).map((item) => item.text),
|
|
domList: listNodes.map((node) => node.textContent),
|
|
domAllButtons: listNodes.every((node) => node.tagName === 'BUTTON'),
|
|
domInOl:
|
|
listNodes.length > 0 &&
|
|
listNodes[0].closest('ol')?.classList.contains('data-row-list'),
|
|
domCurrent: listNodes.findIndex((node) =>
|
|
node.classList.contains('current'),
|
|
),
|
|
routeEntities: routeEntities.length,
|
|
routePositions:
|
|
routeEntities[0]?.polyline?.positions?.getValue?.(
|
|
viewer.clock.currentTime,
|
|
)?.length || 0,
|
|
markerCount: entities.filter((entity) =>
|
|
String(entity.id).startsWith('directions:marker:'),
|
|
).length,
|
|
pointerOwner: window.__gevQa.pointerOwner(),
|
|
cameraMotion: window.__gevQa.getActiveCameraMotion(),
|
|
};
|
|
});
|
|
|
|
const clickChip = async (chipId) => {
|
|
const clicked = await page.evaluate((id) => {
|
|
const row = document.querySelector(
|
|
'#utilities-toggles [data-layer-id="directions"]',
|
|
);
|
|
const button = row?.querySelector(`button[data-chip-id="${id}"]`);
|
|
if (!button || button.disabled) return false;
|
|
button.click();
|
|
return true;
|
|
}, chipId);
|
|
await sleep(150);
|
|
return clicked;
|
|
};
|
|
|
|
const setMode = async (mode) => {
|
|
await clickChip(`mode-${mode}`);
|
|
};
|
|
|
|
const waitForRoute = () =>
|
|
page.waitForFunction(
|
|
() =>
|
|
window.__godsEyeView.dataManager.layers
|
|
.get('directions')
|
|
.module.getStats().count > 0,
|
|
{ timeout: 40000 },
|
|
);
|
|
|
|
const waitForMode = (word) =>
|
|
page.waitForFunction(
|
|
(w) =>
|
|
window.__godsEyeView.dataManager.layers
|
|
.get('directions')
|
|
.module.getStats()
|
|
.coverage?.endsWith(w),
|
|
{ timeout: 40000 },
|
|
word,
|
|
);
|
|
|
|
const cameraPose = () =>
|
|
page.evaluate(() => {
|
|
const c = window.__godsEyeView.viewer.camera.positionWC;
|
|
return { x: c.x, y: c.y, z: c.z };
|
|
});
|
|
const poseDelta = (a, b) => Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z);
|
|
|
|
/** Place A and B by clicking the globe. Returns whether both landed. */
|
|
async function placeEndpoints(a, b) {
|
|
await clickChip('set-a');
|
|
const atA = await screenAt(a.lat, a.lon);
|
|
if (!atA) return false;
|
|
await page.mouse.click(atA.x, atA.y);
|
|
await sleep(700);
|
|
await clickChip('set-b');
|
|
const atB = await screenAt(b.lat, b.lon);
|
|
if (!atB) return false;
|
|
await page.mouse.click(atB.x, atB.y);
|
|
return true;
|
|
}
|
|
|
|
try {
|
|
await page.goto(`${BASE}/?welcome=0`, {
|
|
waitUntil: 'domcontentloaded',
|
|
timeout: 90000,
|
|
});
|
|
await page.waitForFunction(() => window.__godsEyeView?.dataManager, {
|
|
timeout: 90000,
|
|
});
|
|
// Two module reads the assertions need. Both are the app's OWN module
|
|
// instances (Vite serves one copy), so this observes rather than simulates.
|
|
await page.evaluate(async () => {
|
|
const [own, verbs] = await Promise.all([
|
|
import('/src/data/inputOwnership.js'),
|
|
import('/src/cameraVerbs.js'),
|
|
]);
|
|
window.__gevQa = {
|
|
pointerOwner: own.pointerOwner,
|
|
claimPointer: own.claimPointer,
|
|
releasePointer: own.releasePointer,
|
|
getActiveCameraMotion: verbs.getActiveCameraMotion,
|
|
};
|
|
});
|
|
await sleep(3000);
|
|
|
|
// ── 1. Open the UTILITIES panel, then toggle the row on. ────────────────
|
|
const opened = await page.evaluate(() => {
|
|
const panel = document.getElementById('utilities-panel');
|
|
if (!panel) return false;
|
|
if (panel.classList.contains('collapsed')) {
|
|
panel
|
|
.querySelector(
|
|
'.panel-collapse-btn[data-collapse-target="utilities-panel"]',
|
|
)
|
|
?.click();
|
|
}
|
|
return !document
|
|
.getElementById('utilities-panel')
|
|
.classList.contains('collapsed');
|
|
});
|
|
await sleep(700);
|
|
check('the UTILITIES panel opens from its own header control', opened);
|
|
const toggled = await page.evaluate(() => {
|
|
const row = document.querySelector(
|
|
'#utilities-toggles [data-layer-id="directions"]',
|
|
);
|
|
const button = row?.querySelector('.data-toggle-btn');
|
|
if (!button) return false;
|
|
button.scrollIntoView({ block: 'center' });
|
|
button.click();
|
|
return true;
|
|
});
|
|
await sleep(1200);
|
|
check(
|
|
'the Directions row is in the Data Layers panel and toggles on',
|
|
toggled,
|
|
);
|
|
let state = await layerState();
|
|
check(
|
|
'the row guides the operator before anything is placed',
|
|
/SET A/.test(state.stats.coverage || ''),
|
|
state.stats.coverage,
|
|
);
|
|
|
|
// ── 1b. The routing service's attribution is on screen, with the link its
|
|
// usage policy asks for. ────────────────────────────────────────────
|
|
const credit = await page.evaluate(async () => {
|
|
const anchor = [...document.querySelectorAll('a')].find((a) =>
|
|
/data attribution/i.test(a.textContent),
|
|
);
|
|
anchor?.click();
|
|
await new Promise((resolve) => setTimeout(resolve, 900));
|
|
const text = document.body.innerText;
|
|
const html = document.body.innerHTML;
|
|
const open = /Data provided by/i.test(text);
|
|
const entry = [...document.querySelectorAll('li')].find((node) =>
|
|
/OSRM on the FOSSGIS servers/i.test(node.textContent),
|
|
);
|
|
entry?.scrollIntoView({ block: 'center' });
|
|
return {
|
|
open,
|
|
routing: /OSRM on the FOSSGIS servers/i.test(text),
|
|
osm: /OpenStreetMap contributors/i.test(text),
|
|
fixTheMap: /openstreetmap\.org\/fixthemap/.test(html),
|
|
};
|
|
});
|
|
check('the Data attribution popover opens', credit.open);
|
|
check('it credits the routing service', credit.routing);
|
|
check('it credits OpenStreetMap contributors', credit.osm);
|
|
check(
|
|
'it carries the "fix the map" link the usage policy asks for',
|
|
credit.fixTheMap,
|
|
);
|
|
await shot(`04-${LABEL}-attribution.jpg`);
|
|
await page.evaluate(() => {
|
|
const close = [...document.querySelectorAll('a, button')].find(
|
|
(node) => node.className && /close/i.test(String(node.className)),
|
|
);
|
|
close?.click();
|
|
});
|
|
await sleep(500);
|
|
|
|
// ── 2. Place A on the Ferry Building, B in Golden Gate Park. ─────────────
|
|
const FERRY = { lat: 37.7955, lon: -122.3937 };
|
|
const GGPARK = { lat: 37.7694, lon: -122.4862 };
|
|
await lookAt(37.782, -122.44, 16000);
|
|
await sleep(5000);
|
|
|
|
await clickChip('set-a');
|
|
state = await layerState();
|
|
check(
|
|
'arming SET A claims the shared pointer',
|
|
state.pointerOwner === 'directions',
|
|
`owner=${state.pointerOwner}`,
|
|
);
|
|
check(
|
|
'the armed chip tells the operator to click the map',
|
|
state.chips.find((chip) => chip.id === 'set-a')?.label === 'CLICK MAP',
|
|
);
|
|
|
|
let at = await screenAt(FERRY.lat, FERRY.lon);
|
|
check('the Ferry Building is on screen', !!at, JSON.stringify(at));
|
|
await page.mouse.click(at.x, at.y);
|
|
await sleep(800);
|
|
state = await layerState();
|
|
check(
|
|
'placing A releases the pointer claim',
|
|
state.pointerOwner === null,
|
|
`owner=${state.pointerOwner}`,
|
|
);
|
|
check('A is marked on the globe', state.markerCount === 1);
|
|
|
|
// A handler bound to the canvas AFTER this layer's own sees the same click.
|
|
// That is every ambient selection handler in the app, and it is the one that
|
|
// used to place B and deselect whatever was under it in the same gesture.
|
|
await page.evaluate(() => {
|
|
window.__gevQaLateHandler = [];
|
|
const canvas = window.__godsEyeView.viewer.scene.canvas;
|
|
canvas.addEventListener('pointerup', () => {
|
|
window.__gevQaLateHandler.push(window.__gevQa.pointerOwner());
|
|
});
|
|
});
|
|
|
|
await clickChip('set-b');
|
|
at = await screenAt(GGPARK.lat, GGPARK.lon);
|
|
check('Golden Gate Park is on screen', !!at, JSON.stringify(at));
|
|
await page.mouse.click(at.x, at.y);
|
|
const lateOwners = await page.evaluate(() => window.__gevQaLateHandler);
|
|
check(
|
|
'a handler later in the same click still sees the pointer as taken',
|
|
lateOwners.length > 0 &&
|
|
lateOwners.every((owner) => owner === 'directions'),
|
|
`saw ${JSON.stringify(lateOwners)}`,
|
|
);
|
|
await waitForRoute();
|
|
await sleep(2000);
|
|
state = await layerState();
|
|
check(
|
|
'once that click is over, the pointer claim is returned',
|
|
state.pointerOwner === null,
|
|
`owner=${state.pointerOwner}`,
|
|
);
|
|
check('a route polyline is drawn', state.routeEntities === 1);
|
|
check(
|
|
'the route follows streets rather than a straight line',
|
|
state.routePositions >= 50,
|
|
`${state.routePositions} vertices`,
|
|
);
|
|
check(
|
|
'the turn-by-turn list has at least 10 steps',
|
|
state.listCount >= 10,
|
|
`${state.listCount} steps`,
|
|
);
|
|
check(
|
|
'the list renders in the panel as an ordered list of focusable buttons',
|
|
state.domList.length === state.listCount &&
|
|
state.domAllButtons &&
|
|
state.domInOl,
|
|
`${state.domList.length} rendered, ol=${state.domInOl}, buttons=${state.domAllButtons}`,
|
|
);
|
|
check(
|
|
'the rendered list is in route order',
|
|
state.domList.every((text, index) => text.includes(state.listText[index])),
|
|
);
|
|
const driveSummary = state.stats.coverage;
|
|
check(
|
|
'the row summarises distance, time and mode',
|
|
/·.*·\s*Drive$/.test(driveSummary || ''),
|
|
driveSummary,
|
|
);
|
|
const focused = await page.evaluate(() => {
|
|
const row = document.querySelector(
|
|
'#utilities-toggles [data-layer-id="directions"]',
|
|
);
|
|
const items = row.querySelectorAll('.data-row-list-item');
|
|
items[2]?.focus();
|
|
const active = document.activeElement === items[2];
|
|
items[2]?.click();
|
|
return { active, tabIndex: items[2]?.tabIndex };
|
|
});
|
|
await sleep(600);
|
|
const afterListClick = await layerState();
|
|
check(
|
|
'a list row takes keyboard focus and selects its maneuver',
|
|
focused.active && afterListClick.stats.count > 0,
|
|
`focused=${focused.active}`,
|
|
);
|
|
await shot(`01-${LABEL}-sf-route-and-list.jpg`);
|
|
|
|
// ── 3. Switch profile; the route changes. ────────────────────────────────
|
|
await setMode('foot');
|
|
await waitForMode('Walk');
|
|
const walk = await layerState();
|
|
check(
|
|
'WALK reroutes and the summary follows',
|
|
walk.stats.coverage !== driveSummary,
|
|
walk.stats.coverage,
|
|
);
|
|
check(
|
|
'the walking route is its own route',
|
|
walk.listCount !== state.listCount || walk.stats.coverage !== driveSummary,
|
|
`drive ${state.listCount} steps vs walk ${walk.listCount}`,
|
|
);
|
|
await setMode('bike');
|
|
await waitForMode('Bike');
|
|
const bike = await layerState();
|
|
check(
|
|
'BIKE reroutes as well',
|
|
/Bike$/.test(bike.stats.coverage),
|
|
bike.stats.coverage,
|
|
);
|
|
await setMode('car');
|
|
await waitForMode('Drive');
|
|
|
|
// ── 4. Swap endpoints. ──────────────────────────────────────────────────
|
|
const beforeSwap = await layerState();
|
|
await clickChip('swap');
|
|
await sleep(3500);
|
|
const afterSwap = await layerState();
|
|
check(
|
|
'SWAP reverses the route',
|
|
afterSwap.routeEntities === 1 &&
|
|
afterSwap.listText[0] !== beforeSwap.listText[0],
|
|
`"${beforeSwap.listText[0]}" -> "${afterSwap.listText[0]}"`,
|
|
);
|
|
await clickChip('swap');
|
|
await sleep(3500);
|
|
|
|
// ── 5. Three rapid reroutes leave exactly one route. ─────────────────────
|
|
await page.evaluate(() => {
|
|
const manager = window.__godsEyeView.dataManager;
|
|
manager.setLayerParams('directions', { mode: 'foot' }, { origin: 'user' });
|
|
manager.setLayerParams('directions', { mode: 'bike' }, { origin: 'user' });
|
|
manager.setLayerParams('directions', { mode: 'car' }, { origin: 'user' });
|
|
});
|
|
await sleep(6000);
|
|
const rapid = await layerState();
|
|
check(
|
|
'three rapid reroutes leave exactly one route',
|
|
rapid.routeEntities === 1,
|
|
`${rapid.routeEntities} route entities`,
|
|
);
|
|
check(
|
|
'the surviving route is the last one asked for',
|
|
/Drive$/.test(rapid.stats.coverage || ''),
|
|
rapid.stats.coverage,
|
|
);
|
|
|
|
// ── 6. What the row says for each shape the proxy can answer with.
|
|
//
|
|
// These stub the proxy's RESPONSE, so they test this layer's handling of it
|
|
// and nothing about the proxy itself. What the proxy actually answers for a
|
|
// routing-service rate limit, a redirect, an over-long route and a full
|
|
// outbound queue is covered where it can be observed — the provider tests in
|
|
// src/tooling/placeProviders.test.mjs. The bodies below are the ones those
|
|
// tests pin, so the two halves meet. ─────────────────────────────────────
|
|
const cdp = await page.target().createCDPSession();
|
|
let stub = null;
|
|
cdp.on('Fetch.requestPaused', async ({ requestId }) => {
|
|
if (!stub) {
|
|
await cdp.send('Fetch.continueRequest', { requestId }).catch(() => {});
|
|
return;
|
|
}
|
|
if (stub.hold) return; // never answer: the client's own timeout must fire
|
|
await cdp
|
|
.send('Fetch.fulfillRequest', {
|
|
requestId,
|
|
responseCode: stub.status,
|
|
responseHeaders: [
|
|
{ name: 'Content-Type', value: 'application/json' },
|
|
...(stub.status === 429 ? [{ name: 'Retry-After', value: '5' }] : []),
|
|
],
|
|
body: Buffer.from(stub.body).toString('base64'),
|
|
})
|
|
.catch(() => {});
|
|
});
|
|
await cdp.send('Fetch.enable', {
|
|
patterns: [{ urlPattern: '*/api/route*', requestStage: 'Request' }],
|
|
});
|
|
|
|
const reroute = async () => {
|
|
await page.evaluate(() =>
|
|
window.__godsEyeView.dataManager.setLayerParams(
|
|
'directions',
|
|
{ mode: 'foot' },
|
|
{ origin: 'user' },
|
|
),
|
|
);
|
|
};
|
|
const failCases = [
|
|
{
|
|
name: 'no route found',
|
|
stub: { status: 200, body: '{"ok":false,"error":"no route found"}' },
|
|
wait: 3500,
|
|
expect: /No route found between A and B/,
|
|
},
|
|
{
|
|
// The exact body the proxy sends when the routing service rate-limits
|
|
// US (pinned by the provider test of the same name).
|
|
name: 'the routing service rate-limited us (429)',
|
|
stub: {
|
|
status: 429,
|
|
body: '{"ok":false,"error":"routing service is rate limited"}',
|
|
},
|
|
wait: 3500,
|
|
expect: /routing service is rate limited/i,
|
|
},
|
|
{
|
|
// And when our own outbound queue is full.
|
|
name: 'our own outbound queue is full (429)',
|
|
stub: {
|
|
status: 429,
|
|
body: '{"ok":false,"error":"routing busy — too many routes at once"}',
|
|
},
|
|
wait: 3500,
|
|
expect: /routing busy/i,
|
|
},
|
|
{
|
|
name: 'upstream never answers (timeout)',
|
|
stub: { hold: true },
|
|
wait: 19000,
|
|
expect: /Routing timed out/,
|
|
},
|
|
];
|
|
for (const failCase of failCases) {
|
|
stub = failCase.stub;
|
|
await reroute();
|
|
await sleep(failCase.wait);
|
|
const failed = await layerState();
|
|
check(
|
|
`the row reports "${failCase.name}" honestly`,
|
|
failCase.expect.test(failed.stats.error || ''),
|
|
failed.stats.error,
|
|
);
|
|
check(
|
|
`"${failCase.name}" leaves no straight line drawn as a route`,
|
|
failed.routeEntities === 0,
|
|
);
|
|
// Back to a real route before the next case.
|
|
stub = null;
|
|
await page.evaluate(() =>
|
|
window.__godsEyeView.dataManager.setLayerParams(
|
|
'directions',
|
|
{ mode: 'car' },
|
|
{ origin: 'user' },
|
|
),
|
|
);
|
|
await waitForRoute();
|
|
await sleep(1200);
|
|
}
|
|
await cdp.send('Fetch.disable').catch(() => {});
|
|
|
|
// ── 7. FLY, a mid-flight frame, and a cancel that actually stops it. ─────
|
|
const beforeFly = await cameraPose();
|
|
check('the FLY chip is offered once a route exists', await clickChip('fly'));
|
|
await sleep(3000);
|
|
const flying = await layerState();
|
|
const midFly = await cameraPose();
|
|
check(
|
|
'FLY starts a route flight',
|
|
flying.cameraMotion?.kind === 'route',
|
|
JSON.stringify(flying.cameraMotion),
|
|
);
|
|
check(
|
|
'the camera actually moved',
|
|
poseDelta(beforeFly, midFly) > 50,
|
|
`${Math.round(poseDelta(beforeFly, midFly))} m`,
|
|
);
|
|
check(
|
|
'the list highlights the step being flown',
|
|
flying.domCurrent >= 0,
|
|
`dom index ${flying.domCurrent}`,
|
|
);
|
|
await shot(`02-${LABEL}-mid-flight.jpg`);
|
|
|
|
// A reroute replaces the route the dolly is flying; the dolly has to land
|
|
// first rather than keep flying a route that no longer exists.
|
|
await setMode('foot');
|
|
await sleep(1200);
|
|
const afterReroute = await layerState();
|
|
check(
|
|
'changing profile mid-flight lands the flight it replaced',
|
|
afterReroute.cameraMotion === null,
|
|
JSON.stringify(afterReroute.cameraMotion),
|
|
);
|
|
await waitForMode('Walk');
|
|
await setMode('car');
|
|
await waitForMode('Drive');
|
|
await sleep(800);
|
|
check('FLY is offered again after the reroute', await clickChip('fly'));
|
|
await sleep(2000);
|
|
|
|
await clickChip('clear');
|
|
await sleep(500);
|
|
const stopped = await cameraPose();
|
|
await sleep(3000);
|
|
const later = await cameraPose();
|
|
const afterClear = await layerState();
|
|
check(
|
|
'CLEAR mid-flight stops the flight it started',
|
|
afterClear.cameraMotion === null,
|
|
JSON.stringify(afterClear.cameraMotion),
|
|
);
|
|
check(
|
|
'no later frame moves the camera again',
|
|
poseDelta(stopped, later) < 5,
|
|
`${poseDelta(stopped, later).toFixed(2)} m drift`,
|
|
);
|
|
check(
|
|
'CLEAR removes the route and both markers',
|
|
afterClear.routeEntities === 0 && afterClear.markerCount === 0,
|
|
);
|
|
check(
|
|
'CLEAR leaves the pointer free',
|
|
afterClear.pointerOwner === null,
|
|
`owner=${afterClear.pointerOwner}`,
|
|
);
|
|
|
|
// ── 8. A tool holding the pointer blocks placement, and says so. ─────────
|
|
await page.evaluate(() => {
|
|
// A claim answers with a lease; the release takes that lease back.
|
|
window.__gevQaDrawLease = window.__gevQa.claimPointer('draw');
|
|
});
|
|
await clickChip('set-a');
|
|
const blocked = await layerState();
|
|
check(
|
|
"another tool's pointer claim is never stolen",
|
|
blocked.pointerOwner === 'draw',
|
|
`owner=${blocked.pointerOwner}`,
|
|
);
|
|
check(
|
|
'nothing is armed, and the row says why the click would do nothing',
|
|
blocked.chips.find((chip) => chip.id === 'set-a')?.active === false &&
|
|
/Another map tool/.test(blocked.stats.error || ''),
|
|
blocked.stats.error,
|
|
);
|
|
// The row is not where the operator is looking after pressing a chip, so the
|
|
// refusal also has to reach the app's toast (owner field test).
|
|
const toast = await page.evaluate(() => {
|
|
const node = document.getElementById('toast');
|
|
return {
|
|
text: node?.textContent || '',
|
|
visible: !!node?.classList.contains('visible'),
|
|
};
|
|
});
|
|
check(
|
|
'the refusal reaches the app toast, naming the tool and how to leave it',
|
|
/Draw is active/.test(toast.text) && /then set A/.test(toast.text),
|
|
JSON.stringify(toast.text),
|
|
);
|
|
check('and that toast is actually on screen', toast.visible);
|
|
await shot(`05-${LABEL}-pointer-blocked-toast.jpg`);
|
|
const released = await page.evaluate(() =>
|
|
window.__gevQa.releasePointer(window.__gevQaDrawLease),
|
|
);
|
|
check('and it gets the pointer back when it lets go', released);
|
|
|
|
// ── 9. Denver: maneuver dots sit on the ground, not at sea level. ────────
|
|
if (!SKIP.has('denver')) {
|
|
await lookAt(39.744, -104.999, 7000);
|
|
await sleep(6000);
|
|
const placed = await placeEndpoints(
|
|
{ lat: 39.7392, lon: -104.9903 },
|
|
{ lat: 39.7487, lon: -105.0077 },
|
|
);
|
|
check('both Denver endpoints are on screen', placed);
|
|
if (placed) {
|
|
await waitForRoute();
|
|
// Let the shared ground-floor cells warm and the dots re-anchor.
|
|
await sleep(9000);
|
|
const denver = await page.evaluate(() => {
|
|
const viewer = window.__godsEyeView.viewer;
|
|
const Cartographic = viewer.camera.positionCartographic.constructor;
|
|
const scene = viewer.scene;
|
|
let collection = null;
|
|
for (let i = 0; i < scene.primitives.length; i += 1) {
|
|
const primitive = scene.primitives.get(i);
|
|
if (
|
|
primitive &&
|
|
typeof primitive.get === 'function' &&
|
|
primitive.length > 0 &&
|
|
String(primitive.get(0)?.id || '').startsWith('directions:step:')
|
|
) {
|
|
collection = primitive;
|
|
break;
|
|
}
|
|
}
|
|
if (!collection) return { dots: 0, shown: 0, heights: [] };
|
|
const heights = [];
|
|
for (let i = 0; i < collection.length; i += 1) {
|
|
const point = collection.get(i);
|
|
if (!point.show) continue;
|
|
heights.push(Cartographic.fromCartesian(point.position).height);
|
|
}
|
|
return { dots: collection.length, shown: heights.length, heights };
|
|
});
|
|
check('Denver has maneuver dots', denver.dots > 0, `${denver.dots} dots`);
|
|
check(
|
|
'every Denver dot resolved its ground cell — none left hidden',
|
|
denver.dots > 0 && denver.shown === denver.dots,
|
|
`${denver.shown} of ${denver.dots} shown`,
|
|
);
|
|
// Denver's ground is ~1.58-1.65 km ellipsoidal (~1.6 km orthometric plus
|
|
// the local geoid separation). A dot left at a few metres would fail this
|
|
// by a kilometre and a half.
|
|
check(
|
|
'every Denver dot is anchored on the ground, not at sea level',
|
|
denver.dots > 0 &&
|
|
denver.heights.length === denver.dots &&
|
|
denver.heights.every((h) => h > 1400 && h < 1900),
|
|
`heights ${denver.heights.map((h) => Math.round(h)).join(', ')}`,
|
|
);
|
|
await shot(`03-${LABEL}-denver-markers.jpg`);
|
|
}
|
|
}
|
|
|
|
// ── 10. Disable: nothing is left behind. ────────────────────────────────
|
|
const { beforeDisable, directionsBeforeDisable } = await page.evaluate(() => {
|
|
const entities = [...window.__godsEyeView.viewer.entities.values];
|
|
return {
|
|
beforeDisable: entities.length,
|
|
directionsBeforeDisable: entities.filter((entity) =>
|
|
String(entity.id).startsWith('directions:'),
|
|
).length,
|
|
};
|
|
});
|
|
await page.evaluate(() =>
|
|
window.__godsEyeView.dataManager.setEnabled('directions', false, {
|
|
origin: 'user',
|
|
}),
|
|
);
|
|
await sleep(1500);
|
|
const off = await page.evaluate(() => {
|
|
const gev = window.__godsEyeView;
|
|
const viewer = gev.viewer;
|
|
const left = [...viewer.entities.values].filter((entity) =>
|
|
String(entity.id).startsWith('directions:'),
|
|
);
|
|
let shownDots = 0;
|
|
for (let i = 0; i < viewer.scene.primitives.length; i += 1) {
|
|
const primitive = viewer.scene.primitives.get(i);
|
|
if (
|
|
primitive &&
|
|
typeof primitive.get === 'function' &&
|
|
primitive.length > 0 &&
|
|
String(primitive.get(0)?.id || '').startsWith('directions:step:')
|
|
) {
|
|
shownDots = primitive.show ? primitive.length : 0;
|
|
}
|
|
}
|
|
const row = document.querySelector(
|
|
'#utilities-toggles [data-layer-id="directions"]',
|
|
);
|
|
return {
|
|
entities: left.length,
|
|
shownDots,
|
|
controlsHidden:
|
|
row?.querySelector('.data-toggle-controls')?.hidden === true,
|
|
listHidden: row?.querySelector('.data-row-list')?.hidden === true,
|
|
pointerOwner: window.__gevQa.pointerOwner(),
|
|
motion: window.__gevQa.getActiveCameraMotion(),
|
|
totalEntities: viewer.entities.values.length,
|
|
};
|
|
});
|
|
check(
|
|
'disabling removes every Directions entity',
|
|
off.entities === 0,
|
|
`${off.entities} left`,
|
|
);
|
|
check('disabling hides the maneuver dots', off.shownDots === 0);
|
|
check(
|
|
'disabling hides the chips and the turn list',
|
|
off.controlsHidden && off.listHidden,
|
|
`controls=${off.controlsHidden} list=${off.listHidden}`,
|
|
);
|
|
check(
|
|
'disabling leaves the pointer free',
|
|
off.pointerOwner === null,
|
|
`owner=${off.pointerOwner}`,
|
|
);
|
|
check('disabling leaves no camera motion running', off.motion === null);
|
|
check(
|
|
'exactly the Directions entities went away, and nothing else did',
|
|
off.totalEntities === beforeDisable - directionsBeforeDisable,
|
|
`${beforeDisable} total (${directionsBeforeDisable} ours) -> ${off.totalEntities}`,
|
|
);
|
|
|
|
check(
|
|
'no uncaught page errors',
|
|
pageErrors.length === 0,
|
|
pageErrors.join(' | '),
|
|
);
|
|
const realConsoleErrors = consoleErrors.filter(
|
|
(text) =>
|
|
!/Failed to load resource|net::ERR|favicon|429|ERR_ABORTED|status of 4/i.test(
|
|
text,
|
|
),
|
|
);
|
|
check(
|
|
'no unexpected console errors',
|
|
realConsoleErrors.length === 0,
|
|
realConsoleErrors.slice(0, 3).join(' | '),
|
|
);
|
|
} catch (error) {
|
|
check('harness completed', false, error?.stack || String(error));
|
|
await shot(`99-${LABEL}-failure.jpg`).catch(() => {});
|
|
} finally {
|
|
await browser.close();
|
|
await fs.writeFile(
|
|
path.join(SHOTS, `qa-directions-${LABEL}.log`),
|
|
`${lines.join('\n')}\n${failures} failure(s)\n`,
|
|
);
|
|
console.log(`\n${failures} failure(s)`);
|
|
process.exit(failures ? 1 : 0);
|
|
}
|