Files
gods-eye-view/scripts/qa-directions.mjs
T
German Khokhlov 3907b7084e Read QA drawing evidence through the globe contract
Port the layer probes in twenty-six browser scripts to engine primitive
lists, drawn rows and layer diagnostics. Preserve Cesium as the reference
and keep the supported Navara paths on the same observations. This
changes QA observation only, with no application behaviour change.

Review the interrupted infrastructure group and retain its readiness,
drape, selection, label and floor checks. Match both installation outline
buffers. Frame each CCTV city before reading its visible coverage. Read
Radio colours and selected identities from actual mover writes. Retain
transit's GPU upload budgets using engine-owned line primitives rather
than substituting rebuild counts. Keep native inspection where a check
specifically measures GPU uploads, loaded meshes or ground readiness.

The preceding combined B/C commit owns all shared manifests, docs,
locale catalogs and the coupling baseline; this commit changes only its
own twenty-six QA scripts. Coupling remains unchanged at 344 Cesium
renderer lines, 1294 Navara renderer lines, 9 Cesium import files, 1026
viewer lines, 2 Navara kind checks, 0 engine sniff calls, 9 extension
reads, 1 ground pass, 3 SDK loads, 4 screen-handler files and 42 engine-name
lines.

The full unit suite and the boundary, format and coupling checks pass.
Browser validation remains to be run.

Browser checks to run
GOOGLE_MAPS_API_KEY= CESIUM_ION_TOKEN= npm run dev -- --port 4291
node scripts/qa-alpr-journey.mjs http://localhost:4291
node scripts/qa-alpr-journey.mjs 'http://localhost:4291/?engine=navara'
node scripts/qa-cables-overlay.mjs --url http://localhost:4291
node scripts/qa-cctv-v2.mjs --url http://localhost:4291 --fixture --terrain-globe
node scripts/qa-context-arrivals.mjs http://localhost:4291
node scripts/qa-directions.mjs --url http://localhost:4291
QA_BASE_URL=http://localhost:4291 node scripts/qa-draw-tool.mjs
QA_BASE_URL=http://localhost:4291 node scripts/qa-floor-hold.mjs
node scripts/qa-ghostmaps.mjs --url http://localhost:4291
node scripts/qa-gnss.mjs --url http://localhost:4291
node scripts/qa-height-datum.mjs --url http://localhost:4291
node scripts/qa-infra-lod.mjs --url http://localhost:4291
node scripts/qa-installation-navigation.mjs http://localhost:4291
node scripts/qa-military-names.mjs http://localhost:4291 --no-video
node scripts/qa-overpass-offload.mjs --url http://localhost:4291
node scripts/qa-radio.mjs --url http://localhost:4291
node scripts/qa-traffic-motion.mjs http://localhost:4291 --mode=osm
QA_BASE_URL=http://localhost:4291 node scripts/qa-transit-recovery.mjs
QA_BASE_URL=http://localhost:4291 node scripts/qa-transit.mjs
node scripts/qa-weather-teardown.mjs --url http://localhost:4291 --headless
for engine in cesium navara; do
  node scripts/qa-enrich-ambient.mjs --url http://localhost:4291 --engine=$engine
  QA_BASE_URL=http://localhost:4291 node scripts/qa-floor-verify.mjs --engine=$engine
  node scripts/qa-heading-b3.mjs --url http://localhost:4291 --engine=$engine
  node scripts/qa-overlay-baseline.mjs --url http://localhost:4291 --engine=$engine
  node scripts/qa-sprites-b5.mjs --url http://localhost:4291 --engine=$engine
  node scripts/qa-vessel-datum.mjs --url 'http://localhost:4291/?qaFeed=vessels:fixture' --engine $engine
done

The military-names driver exercises installation-polish; the transit
driver exercises transit-scenes. Use only the keyless server. Cases that
require photoreal tiles or unavailable providers remain unverified.
2026-11-07 12:45:21 +04:00

889 lines
31 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: process.env.QA_HEADFUL === '1' ? false : true,
executablePath:
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
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 drawn = window.__gevQaDirections.drawn();
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: drawn.routeSets,
routePositions: drawn.routeVertices,
markerCount: drawn.markersShown,
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,
};
// What Directions draws, read through the globe contract
// (src/layers/directions/render/display.js): the route is the
// `directions-route` ground corridor set, cut into dash pieces; the A/B
// discs and the maneuver dots are the `directions-markers` and
// `directions-steps` point mover sets. The route's pieces are seen as the
// display hands them to engine.ground; which instances are shown is read
// from the Cesium engine's own point collection for the set (this gate
// drives the Cesium camera).
const engine = window.__godsEyeView.engine;
let routePieces = null;
const createGroundSet = engine.ground.createSet.bind(engine.ground);
engine.ground.createSet = (options) => {
const set = createGroundSet(options);
if (options?.id === 'directions-route') {
const replace = set.replace.bind(set);
set.replace = (items) => {
routePieces = items.map((item) => item.positions.length);
return replace(items);
};
}
return set;
};
const moverPrimitive = (id) => {
const walk = (collection) => {
for (let i = 0; i < collection.length; i += 1) {
const primitive = collection.get(i);
if (!primitive) continue;
if (primitive.gevMoverSet === id) return primitive;
if (
primitive.gevMoverSet === undefined &&
typeof primitive.get === 'function' &&
typeof primitive.length === 'number'
) {
const found = walk(primitive);
if (found) return found;
}
}
return null;
};
return walk(window.__godsEyeView.viewer.scene.primitives);
};
const moverCount = (id) =>
engine.movers.list().find((set) => set.id === id)?.count ?? 0;
const shownInstances = (id) => {
const primitive = moverPrimitive(id);
if (!primitive || primitive.show === false) return [];
const shown = [];
const count = Math.min(moverCount(id), primitive.length);
for (let i = 0; i < count; i += 1) {
const instance = primitive.get(i);
if (instance.show) shown.push(instance);
}
return shown;
};
window.__gevQaDirections = {
drawn() {
const route = engine.ground
.list()
.filter((set) => set.id === 'directions-route');
// Every original vertex is kept, and each piece adds its two ends,
// which are the next piece's start: vertices = total - 2·pieces + 2.
const routeVertices =
route.length && routePieces?.length
? routePieces.reduce((sum, n) => sum + n, 0) -
2 * routePieces.length +
2
: 0;
return {
routeSets: route.filter((set) => set.shown && set.count > 0).length,
routeVertices,
markersShown: shownInstances('directions-markers').length,
stepCount: moverCount('directions-steps'),
stepsShown: shownInstances('directions-steps').map((point) =>
point.position.clone(),
),
};
},
// Every other layer's contract sets, to prove a disable touches only
// the Directions ones.
others() {
const mine = (id) => String(id).startsWith('directions');
return [
...engine.movers.list().map((set) => `movers:${set.id}`),
...engine.lines.list().map((line) => `lines:${line.id}`),
...engine.ground.list().map((set) => `ground:${set.id}`),
]
.filter((key) => !mine(key.split(':').slice(1).join(':')))
.sort();
},
};
});
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 { stepCount, stepsShown } = window.__gevQaDirections.drawn();
const heights = stepsShown.map(
(position) => Cartographic.fromCartesian(position).height,
);
return { dots: stepCount, 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 { othersBefore, drawnBefore } = await page.evaluate(() => ({
othersBefore: window.__gevQaDirections.others(),
drawnBefore: window.__gevQaDirections.drawn(),
}));
await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled('directions', false, {
origin: 'user',
}),
);
await sleep(1500);
const off = await page.evaluate(() => {
const drawn = window.__gevQaDirections.drawn();
const row = document.querySelector(
'#utilities-toggles [data-layer-id="directions"]',
);
return {
entities: drawn.routeSets + drawn.markersShown,
shownDots: drawn.stepsShown.length,
controlsHidden:
row?.querySelector('.data-toggle-controls')?.hidden === true,
listHidden: row?.querySelector('.data-row-list')?.hidden === true,
pointerOwner: window.__gevQa.pointerOwner(),
motion: window.__gevQa.getActiveCameraMotion(),
others: window.__gevQaDirections.others(),
};
});
check(
'disabling removes the route and hides both markers',
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(
"only the Directions drawing went away: every other layer's sets stay",
JSON.stringify(off.others) === JSON.stringify(othersBefore),
`ours before: route ${drawnBefore.routeSets}, markers ${drawnBefore.markersShown}, dots ${drawnBefore.stepsShown.length}; others ${othersBefore.length} -> ${off.others.length}`,
);
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);
}