mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
2018 lines
76 KiB
JavaScript
2018 lines
76 KiB
JavaScript
#!/usr/bin/env node
|
||
import { runTransitHeadingRegression } from './qa-transit-heading.mjs';
|
||
import {
|
||
boundPageEvaluations,
|
||
reportTransitVisibility,
|
||
restoreRetainedTransit,
|
||
sampleTransitPixels,
|
||
readTransitFleetStyle,
|
||
} from './qa-transit-browser.mjs';
|
||
import {
|
||
BOSTON,
|
||
chooseLiveCities,
|
||
selectStyle,
|
||
detectOn,
|
||
} from './qa-transit-controls.mjs';
|
||
import {
|
||
runFleetBudgets,
|
||
runBostonMatrix,
|
||
runBostonLive,
|
||
runTrailVisibility,
|
||
} from './qa-transit-scenes.mjs';
|
||
/**
|
||
* Browser proof for the Transit layer against a running app.
|
||
*
|
||
* npm run qa:transit (defaults to http://localhost:4173)
|
||
* QA_BASE_URL=http://localhost:4305 node scripts/qa-transit.mjs
|
||
*
|
||
* Drives the real layer toggle and the real CONTEXT chooser, watches the real
|
||
* /api/transit traffic, proves vehicles move between polls in the SCENE (not
|
||
* just in the JSON), captures street-level shots in two cities, and proves that
|
||
* turning the layer off stops every request and leaves nothing rendered.
|
||
*
|
||
* Screenshots land in the gitignored qa-shots/ directory unless QA_SHOTS says
|
||
* otherwise. Needs a running server; the feeds are keyless, so a keyless boot
|
||
* exercises the same paths.
|
||
*/
|
||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||
import puppeteer from 'puppeteer';
|
||
import { chromeExecutablePath } from './qa-chrome.mjs';
|
||
import { createTransitHistory } from '../server/providers/transitHistory.js';
|
||
import { DETECTION_THEME_MAP } from '../src/overlays/worldOverlayTokens.js';
|
||
import { transitModeTier } from '../src/data/transitPresetStyle.js';
|
||
import { TRANSIT_POLL_MS } from '../src/layers/transit/policy.js';
|
||
|
||
import {
|
||
reduceAnchor,
|
||
reduceTrailHead,
|
||
reduceScenario,
|
||
reduceSensorContrast,
|
||
reduceScriptedMotion,
|
||
reduceMotion,
|
||
reduceFleet,
|
||
} from '../src/layers/transit/qaMetrics.js';
|
||
|
||
const BASE = process.env.QA_BASE_URL || 'http://localhost:4173';
|
||
const SHOTS = process.env.QA_SHOTS || 'qa-shots/transit';
|
||
const TAG = process.env.QA_TAG || 'keyed';
|
||
/** Which sections to run: all, or a comma list of cities,playback,disable,context. */
|
||
const SECTIONS = new Set((process.env.QA_SECTIONS || 'all').split(','));
|
||
const runs = (section) => SECTIONS.has('all') || SECTIONS.has(section);
|
||
const CITIES = chooseLiveCities();
|
||
|
||
let failures = 0;
|
||
let unexercised = 0;
|
||
/**
|
||
* A check has three outcomes. PASS and FAIL are what they say. UNEXERCISED
|
||
* means the run could not put the claim to the test — a parked fleet has no
|
||
* motion to judge — and it is reported as its own word rather than as a
|
||
* pass, because a pass that proved nothing is how a broken interpolation
|
||
* sailed through this harness once.
|
||
*/
|
||
const check = (
|
||
name,
|
||
passed,
|
||
detail = '',
|
||
{ unexercised: idle = false } = {},
|
||
) => {
|
||
const word = idle ? 'UNEXERCISED' : passed ? 'PASS' : 'FAIL';
|
||
console.log(`[${word}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||
if (idle) {
|
||
unexercised += 1;
|
||
failures += 1;
|
||
} else if (!passed) failures += 1;
|
||
};
|
||
// A focused deterministic regression can run independently of live-feed QA.
|
||
if (process.argv.includes('--heading-only')) {
|
||
try {
|
||
await runTransitHeadingRegression(BASE, check);
|
||
} catch (error) {
|
||
check('heading regression completed', false, error.stack || error.message);
|
||
}
|
||
console.log(`HEADING: ${failures ? 'FAIL' : 'PASS'} (${failures} failed)`);
|
||
process.exit(failures ? 1 : 0);
|
||
}
|
||
if (runs('heading')) await runTransitHeadingRegression(BASE, check);
|
||
|
||
const renderingSource = await readFile(
|
||
new URL('../src/layers/transit/rendering.js', import.meta.url),
|
||
'utf8',
|
||
);
|
||
const frameSource = renderingSource
|
||
.split('function onPreRender()')[1]
|
||
.split('const occluder')[0];
|
||
check(
|
||
'frame pass does not scan the fleet or copy the moving set',
|
||
!/_vehicles\.values|\[\.\.\.state\._moving\]/.test(frameSource),
|
||
);
|
||
const palette = {
|
||
bus: '#5EF08A',
|
||
tram: '#FFC24A',
|
||
subway: '#FF4538',
|
||
rail: '#D9A6FF',
|
||
ferry: '#5FD6FF',
|
||
unknown: '#D8DDE5',
|
||
};
|
||
check(
|
||
'all preset bracket themes preserve the six mode colours',
|
||
Object.values(DETECTION_THEME_MAP).every((theme) =>
|
||
Object.entries(palette).every(
|
||
([mode, color]) =>
|
||
theme.tiers[transitModeTier(mode)].toUpperCase() === color,
|
||
),
|
||
),
|
||
);
|
||
check(
|
||
'all required sections enabled',
|
||
SECTIONS.has('all'),
|
||
'Partial runs are diagnostic and cannot pass acceptance.',
|
||
);
|
||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||
|
||
/** Hex → [r,g,b]. */
|
||
const rgbOf = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
|
||
const rgbDistance = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
|
||
|
||
/**
|
||
* Rendered pixels of the scene and of the detection surface around each
|
||
* visible transit sprite: centre and darkest/brightest ring luma from the
|
||
* WebGL canvas (the app preserves its drawing buffer), and the dominant
|
||
* opaque colour painted on the detection surface in a bracket-sized window.
|
||
* Sprites closer than 44 px to another sprite are skipped so a ring is not
|
||
* read off a neighbour.
|
||
* @param {import('puppeteer').Page} page
|
||
* @returns {Promise<Array<{key:string, mode:string, centre:number, ringMin:number, ringMax:number, bracket:number[]|null, x:number, y:number}>>}
|
||
*/
|
||
const sampleRendered = (page) => sampleTransitPixels(page, palette);
|
||
|
||
/**
|
||
* The rendered position of every VISIBLE transit billboard, read back through
|
||
* Cesium. Visible only: off-screen vehicles take their newest fix directly, so
|
||
* including them would let hidden vehicles snapping stand in for motion a
|
||
* person can see.
|
||
* @param {import('puppeteer').Page} page
|
||
* @returns {Promise<Array<{key: string, lat: number, lon: number}>>}
|
||
*/
|
||
/**
|
||
* Window-space position and billboard rotation of every visible transit
|
||
* sprite. Orientation is a screen-space claim, so it has to be checked in
|
||
* screen space.
|
||
* @param {import('puppeteer').Page} page
|
||
* @returns {Promise<Array<{key: string, x: number, y: number, rotation: number}>>}
|
||
*/
|
||
async function sampleScreen(page) {
|
||
return page.evaluate(() => {
|
||
const app = window.__godsEyeView;
|
||
const layer = app.dataManager.layers.get('transit').module;
|
||
const state = layer._transitStateForTest();
|
||
const scene = app.viewer.scene;
|
||
const canvas = scene.canvas;
|
||
// The app exposes no `window.Cesium`, so the projection is done here in
|
||
// plain numbers: Cesium matrices are column-major arrays of 16 doubles,
|
||
// and window coordinates are the standard clip -> NDC -> viewport walk.
|
||
const view = scene.camera.viewMatrix;
|
||
const proj = scene.camera.frustum.projectionMatrix;
|
||
const mul = (m, v) => [
|
||
m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12] * v[3],
|
||
m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13] * v[3],
|
||
m[2] * v[0] + m[6] * v[1] + m[10] * v[2] + m[14] * v[3],
|
||
m[3] * v[0] + m[7] * v[1] + m[11] * v[2] + m[15] * v[3],
|
||
];
|
||
const width = canvas.clientWidth;
|
||
const height = canvas.clientHeight;
|
||
const out = [];
|
||
for (const entry of state._vehicles.values()) {
|
||
if (!entry.marker || entry.marker.show === false) continue;
|
||
const p = entry.marker.position;
|
||
const clip = mul(proj, mul(view, [p.x, p.y, p.z, 1]));
|
||
if (!(clip[3] > 0)) continue;
|
||
const ndcX = clip[0] / clip[3];
|
||
const ndcY = clip[1] / clip[3];
|
||
out.push({
|
||
entityCount: app.viewer.entities.values.length,
|
||
key: entry.key,
|
||
x: (ndcX * 0.5 + 0.5) * width,
|
||
y: (1 - (ndcY * 0.5 + 0.5)) * height,
|
||
rotation: entry.marker.rotation,
|
||
pollSeq: entry.pollSeq,
|
||
segment:
|
||
entry.from && entry.to ? `${entry.from.t}-${entry.to.t}` : null,
|
||
});
|
||
if (out.length >= 200) break;
|
||
}
|
||
return out;
|
||
});
|
||
}
|
||
|
||
async function sampleVisible(page) {
|
||
return page.evaluate(() => {
|
||
const app = window.__godsEyeView;
|
||
const layer = app.dataManager.layers.get('transit').module;
|
||
const state = layer._transitStateForTest();
|
||
const Cartographic = app.viewer.camera.positionCartographic.constructor;
|
||
return [...state._vehicles.values()]
|
||
.filter((entry) => entry.marker && entry.marker.show !== false)
|
||
.slice(0, 200)
|
||
.map((entry) => {
|
||
const carto = Cartographic.fromCartesian(entry.marker.position);
|
||
return {
|
||
key: entry.key,
|
||
lat: (carto.latitude * 180) / Math.PI,
|
||
lon: (carto.longitude * 180) / Math.PI,
|
||
gliding: state._moving.has(entry),
|
||
// Which poll and which displayed segment this reading belongs to:
|
||
// two readings that straddle a poll or a segment change are not
|
||
// evidence about interpolation, whatever the elapsed time says.
|
||
pollSeq: entry.pollSeq,
|
||
segment:
|
||
entry.from && entry.to ? `${entry.from.t}-${entry.to.t}` : null,
|
||
};
|
||
});
|
||
});
|
||
}
|
||
|
||
// Accelerate the storage clock without collecting a live feed in the background.
|
||
const historyProbe = createTransitHistory({
|
||
sweep: false,
|
||
storage: { bytes: 0, keys: 0, stores: new Set() },
|
||
limits: { feedBytes: 128000, processBytes: 128000 },
|
||
});
|
||
const retainedSizes = [];
|
||
for (let now = 0; now <= 7200000; now += 15000) {
|
||
historyProbe.ingest(
|
||
{ id: 'probe', historyRetention: true, defaultMode: 'bus' },
|
||
Array.from({ length: 12 }, (_, i) => ({
|
||
id: String(i),
|
||
timestamp: now / 1000,
|
||
lat: 42,
|
||
lon: -71,
|
||
})),
|
||
now,
|
||
);
|
||
if (now >= 900000)
|
||
retainedSizes.push(historyProbe.diagnostics().allocatedBytes);
|
||
}
|
||
check(
|
||
'two-hour proxy history allocation plateaus',
|
||
Math.max(...retainedSizes) === Math.min(...retainedSizes) &&
|
||
Math.max(...retainedSizes) <= 128000,
|
||
`${retainedSizes.length} warmed samples, ${Math.max(...retainedSizes)} allocated bytes`,
|
||
);
|
||
historyProbe.clear();
|
||
await mkdir(SHOTS, { recursive: true });
|
||
|
||
const browser = await puppeteer.launch({
|
||
headless: false,
|
||
protocolTimeout: 150000,
|
||
executablePath: chromeExecutablePath(),
|
||
args: [
|
||
'--no-sandbox',
|
||
'--disable-backgrounding-occluded-windows',
|
||
'--disable-background-timer-throttling',
|
||
'--disable-renderer-backgrounding',
|
||
'--window-size=1920,1080',
|
||
'--enable-precise-memory-info',
|
||
],
|
||
});
|
||
const page = await browser.newPage();
|
||
// This gate measures visible-page motion. OS minimization must not silently
|
||
// switch it into the app's intentional hidden-tab polling pause.
|
||
const focus = await page.createCDPSession();
|
||
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true });
|
||
boundPageEvaluations(page);
|
||
await page.setViewport({ width: 1920, height: 1080 });
|
||
const pageErrors = [];
|
||
page.on('pageerror', (error) => pageErrors.push(error.message));
|
||
page.on('console', (message) => {
|
||
if (message.text().startsWith('TRANSIT_SAMPLER')) console.log(message.text());
|
||
});
|
||
const transitRequests = [];
|
||
page.on('request', (request) => {
|
||
if (request.url().includes('/api/transit')) {
|
||
transitRequests.push({ at: Date.now(), url: request.url() });
|
||
}
|
||
});
|
||
|
||
const shot = (name, caption) =>
|
||
page
|
||
.screenshot({ path: `${SHOTS}/${name}`, type: 'jpeg', quality: 82 })
|
||
.then(() => {
|
||
console.log(` shot ${name} — ${caption}`);
|
||
});
|
||
|
||
const view = (city) =>
|
||
`${BASE}/?welcome=0#lat=${city.lat}&lon=${city.lon}&alt=600&heading=${city.heading}&pitch=-45`;
|
||
|
||
try {
|
||
await page.goto(view(BOSTON), { waitUntil: 'domcontentloaded' });
|
||
// Ready when the catalog is registered and its rows exist. (The manager's
|
||
// private panel handle moved into a presentation facade; waiting on it here
|
||
// was waiting for something that no longer exists.)
|
||
await page.waitForFunction(
|
||
() =>
|
||
Boolean(window.__godsEyeView?.dataManager?.layers?.size) &&
|
||
Boolean(document.querySelector('[data-layer-id="transit"]')),
|
||
{ timeout: 90_000 },
|
||
);
|
||
await wait(6_000);
|
||
|
||
// The Data Layers panel starts collapsed; a user opens it before toggling.
|
||
const expanded = await page.$eval(
|
||
'#data-panel .panel-collapse-btn',
|
||
(button) => {
|
||
if (button.getAttribute('aria-expanded') === 'true') return true;
|
||
button.click();
|
||
return false;
|
||
},
|
||
);
|
||
if (!expanded) await wait(800);
|
||
check(
|
||
'the Data Layers panel opens from its own header button',
|
||
await page.$eval(
|
||
'#data-panel .panel-collapse-btn',
|
||
(b) => b.getAttribute('aria-expanded') === 'true',
|
||
),
|
||
);
|
||
|
||
// Record what the layer hands the shared overlay host, so the card check can
|
||
// quote the operator's own words rather than assert an internal key was set.
|
||
await page.evaluate(() => {
|
||
window.__gevTransitCards = new Map();
|
||
const layer = window.__godsEyeView.dataManager.layers.get('transit').module;
|
||
const state = layer._transitStateForTest();
|
||
const host = state.DEFAULT_OVERLAY_HOST;
|
||
layer._setTransitOverlayHostForTest({
|
||
setEntries(sourceId, entries, options) {
|
||
for (const entry of entries || []) {
|
||
window.__gevTransitCards.set(entry.id, {
|
||
title: entry.title,
|
||
details: entry.details,
|
||
});
|
||
}
|
||
return host.setEntries(sourceId, entries, options);
|
||
},
|
||
setVisible: host.setVisible,
|
||
clearSource(sourceId) {
|
||
window.__gevTransitCards.clear();
|
||
return host.clearSource(sourceId);
|
||
},
|
||
});
|
||
});
|
||
|
||
const row = '[data-layer-id="transit"]';
|
||
const hasRow = await page.$(row);
|
||
check('the Transit layer has a row in the layer panel', Boolean(hasRow));
|
||
await page.$eval(row, (element) =>
|
||
element.scrollIntoView({ block: 'center' }),
|
||
);
|
||
await wait(400);
|
||
const before = transitRequests.length;
|
||
await page.click(`${row} .data-toggle-btn`);
|
||
await page.waitForFunction(
|
||
() => window.__godsEyeView.dataManager.isEnabled('transit'),
|
||
{ timeout: 20_000 },
|
||
);
|
||
check(
|
||
'clicking the row button enables the layer and starts real traffic',
|
||
transitRequests.length > before,
|
||
`${transitRequests.length - before} request(s)`,
|
||
);
|
||
|
||
await page.evaluate(() => {
|
||
window.__transitRenderErrors = [];
|
||
window.__godsEyeView.viewer.scene.renderError.addEventListener(
|
||
(scene, error) => window.__transitRenderErrors.push(error.message),
|
||
);
|
||
});
|
||
for (const city of runs('cities') ? CITIES : []) {
|
||
console.log(`\n== ${city.label} ==`);
|
||
// City-wide first: enough altitude to see the fleet as a fleet.
|
||
await page.evaluate((c) => {
|
||
const camera = window.__godsEyeView.viewer.camera;
|
||
const Cartographic = camera.positionCartographic.constructor;
|
||
camera.setView({
|
||
destination: Cartographic.toCartesian(
|
||
Cartographic.fromDegrees(c.lon, c.lat - 0.03, 3_500),
|
||
),
|
||
orientation: { heading: 0, pitch: (-40 * Math.PI) / 180, roll: 0 },
|
||
});
|
||
}, city);
|
||
const arrived = await page
|
||
.waitForFunction(
|
||
(feedId) =>
|
||
[
|
||
...window.__godsEyeView.dataManager.layers
|
||
.get('transit')
|
||
.module._transitStateForTest()
|
||
._vehicles.keys(),
|
||
].some((key) => key.startsWith(`${feedId}:`)),
|
||
{ timeout: 90_000 },
|
||
city.feedId,
|
||
)
|
||
.then(
|
||
() => true,
|
||
() => false,
|
||
);
|
||
if (!arrived) {
|
||
check(
|
||
`${city.label}: live feed arrived`,
|
||
false,
|
||
`No vehicles from ${city.feedId}; retained vehicles from other cities cannot qualify this view`,
|
||
{ unexercised: true },
|
||
);
|
||
continue;
|
||
}
|
||
await wait(9_000);
|
||
const fleet = await page.evaluate((feedId) => {
|
||
const layer =
|
||
window.__godsEyeView.dataManager.layers.get('transit').module;
|
||
return {
|
||
count: [...layer._transitStateForTest()._vehicles.keys()].filter(
|
||
(key) => key.startsWith(`${feedId}:`),
|
||
).length,
|
||
stats: layer.getStats(),
|
||
};
|
||
}, city.feedId);
|
||
check(
|
||
`${city.label}: vehicles are rendered`,
|
||
fleet.count >= 20,
|
||
`${fleet.count} vehicles, coverage "${fleet.stats.coverage}"`,
|
||
);
|
||
await shot(
|
||
`${TAG}-${city.id}-00-fleet.jpg`,
|
||
`${city.label} from 3.5 km — ${fleet.count} vehicles across the metro`,
|
||
);
|
||
|
||
// Street level: 600 m up, 45 degrees down, framed on a real vehicle so the
|
||
// shot shows what a user sees when they drop in on one.
|
||
const framed = await page.evaluate((c) => {
|
||
const app = window.__godsEyeView;
|
||
const camera = app.viewer.camera;
|
||
const Cartographic = camera.positionCartographic.constructor;
|
||
const state = app.dataManager.layers
|
||
.get('transit')
|
||
.module._transitStateForTest();
|
||
let best = null;
|
||
for (const entry of state._vehicles.values()) {
|
||
if (!entry.key.startsWith(`${c.feedId}:`)) continue;
|
||
const d = Math.hypot(entry.to.lat - c.lat, entry.to.lon - c.lon);
|
||
if (!best || d < best.d)
|
||
best = { d, lat: entry.to.lat, lon: entry.to.lon, key: entry.key };
|
||
}
|
||
if (!best) return null;
|
||
// Camera 600 m back along the heading, 600 m up, looking 45 degrees down:
|
||
// the vehicle lands in the middle of the frame.
|
||
const heading = (c.heading * Math.PI) / 180;
|
||
const lat = best.lat - (600 * Math.cos(heading)) / 111_320;
|
||
const lon =
|
||
best.lon -
|
||
(600 * Math.sin(heading)) /
|
||
(111_320 * Math.cos((best.lat * Math.PI) / 180));
|
||
camera.setView({
|
||
destination: Cartographic.toCartesian(
|
||
Cartographic.fromDegrees(lon, lat, 600),
|
||
),
|
||
orientation: { heading, pitch: (-45 * Math.PI) / 180, roll: 0 },
|
||
});
|
||
return best;
|
||
}, city);
|
||
check(
|
||
`${city.label}: a vehicle to fly to`,
|
||
Boolean(framed),
|
||
framed?.key || 'none',
|
||
);
|
||
await wait(9_000);
|
||
|
||
const first = await page.evaluate(() => {
|
||
const app = window.__godsEyeView;
|
||
const layer = app.dataManager.layers.get('transit').module;
|
||
const state = layer._transitStateForTest();
|
||
const Cartographic = app.viewer.camera.positionCartographic.constructor;
|
||
return {
|
||
count: state._vehicles.size,
|
||
stats: layer.getStats(),
|
||
// The RENDERED primitive position, read back through Cesium — not the
|
||
// fix the layer is aiming at. A layer that never moved a point would
|
||
// still show two different `entry.to` values, so comparing those would
|
||
// prove the data changed, not that anything on screen did.
|
||
// VISIBLE markers only. Off-screen vehicles take their newest fix
|
||
// directly — that is the whole point of the view gate — so sampling
|
||
// them would let a layer whose visible interpolation is broken pass
|
||
// this check on the strength of hidden vehicles snapping.
|
||
sample: [...state._vehicles.values()]
|
||
.filter((entry) => entry.marker && entry.marker.show !== false)
|
||
.slice(0, 200)
|
||
.map((entry) => {
|
||
const carto = Cartographic.fromCartesian(entry.marker.position);
|
||
return {
|
||
key: entry.key,
|
||
lat: (carto.latitude * 180) / Math.PI,
|
||
lon: (carto.longitude * 180) / Math.PI,
|
||
};
|
||
}),
|
||
};
|
||
});
|
||
|
||
// Click a vehicle that is actually on screen, recomputing its position
|
||
// immediately before each attempt — these dots are gliding.
|
||
let selected = null;
|
||
for (let attempt = 0; attempt < 6 && !selected; attempt += 1) {
|
||
const target = await page.evaluate((skip) => {
|
||
const app = window.__godsEyeView;
|
||
const state = app.dataManager.layers
|
||
.get('transit')
|
||
.module._transitStateForTest();
|
||
const scene = app.viewer.scene;
|
||
const seen = [];
|
||
for (const entry of state._vehicles.values()) {
|
||
const screen = scene.cartesianToCanvasCoordinates(
|
||
entry.marker.position,
|
||
);
|
||
if (
|
||
screen &&
|
||
screen.x > 360 &&
|
||
screen.x < scene.canvas.clientWidth - 120 &&
|
||
screen.y > 120 &&
|
||
screen.y < scene.canvas.clientHeight - 160
|
||
) {
|
||
seen.push({
|
||
key: entry.key,
|
||
x: Math.round(screen.x),
|
||
y: Math.round(screen.y),
|
||
});
|
||
}
|
||
}
|
||
return seen[skip] || null;
|
||
}, attempt);
|
||
if (!target) break;
|
||
await page.mouse.click(target.x, target.y);
|
||
await wait(900);
|
||
selected = await page.evaluate(() => {
|
||
const app = window.__godsEyeView;
|
||
const key = app.dataManager.layers
|
||
.get('transit')
|
||
.module._transitStateForTest()._selectedKey;
|
||
if (!key) return null;
|
||
// A selected key is bookkeeping. The claim is that the user SEES a
|
||
// card, and these cards are painted onto the shared overlay CANVAS
|
||
// rather than built from DOM nodes — so the proof is the operator's own
|
||
// words the layer published, plus pixels that actually landed there.
|
||
const published = window.__gevTransitCards?.get(key) || null;
|
||
const canvas = document.getElementById('world-overlay-canvas');
|
||
let painted = 0;
|
||
if (canvas?.width) {
|
||
const context = canvas.getContext('2d', { willReadFrequently: true });
|
||
const pixels = context.getImageData(
|
||
0,
|
||
0,
|
||
canvas.width,
|
||
canvas.height,
|
||
).data;
|
||
for (let i = 3; i < pixels.length; i += 64) {
|
||
if (pixels[i] > 8) painted += 1;
|
||
}
|
||
}
|
||
return { key, published, painted };
|
||
});
|
||
if (selected && (!selected.published || selected.painted === 0)) {
|
||
selected = null;
|
||
}
|
||
}
|
||
const cardLines = selected
|
||
? [selected.published.title, ...(selected.published.details || [])]
|
||
: [];
|
||
check(
|
||
`${city.label}: clicking a vehicle raises a card with its details on screen`,
|
||
cardLines.length >= 3 &&
|
||
/Route |Vehicle /.test(cardLines[0] || '') &&
|
||
cardLines.some((line) => /Reported /.test(line)) &&
|
||
selected.painted > 0,
|
||
selected
|
||
? `${selected.key} — "${cardLines.slice(0, 4).join(' / ')}" over ${selected.painted} painted overlay samples`
|
||
: 'no vehicle card became visible',
|
||
);
|
||
// DETECT is on by default at DENSE. Prove the overlay actually boxes and
|
||
// labels transit contacts rather than ignoring them, which is what the
|
||
// owner found: the layer drew dots and the sensor overlay looked past them.
|
||
const detect = await page.evaluate(() => {
|
||
const gev = window.__godsEyeView;
|
||
const layer = gev.dataManager.layers.get('transit').module;
|
||
const objects = layer.getDetectableObjects({ maxCount: 200 });
|
||
const button = document.getElementById('detection-toggle');
|
||
return {
|
||
mode: (button?.textContent || '').replace(/\s+/g, ' ').trim(),
|
||
count: objects.length,
|
||
sample: objects.slice(0, 3).map((o) => `${o.id}/${o.metric || ''}`),
|
||
types: [...new Set(objects.map((o) => o.type))],
|
||
labelled: objects.filter((o) => o.id && o.metric).length,
|
||
skipped: objects.filter((o) => o.skipLabel).length,
|
||
};
|
||
});
|
||
// What the host DOES with these is pinned by the detection-host unit test,
|
||
// which drives the real painter and reads the route and mode back off the
|
||
// canvas. Here the claim is narrower and fully attributable: the layer
|
||
// offers DETECT properly formed contacts, which is precisely what it used
|
||
// to offer none of.
|
||
check(
|
||
`${city.label}: the layer offers DETECT real contacts, not bare dots`,
|
||
detect.count > 0 &&
|
||
detect.types.length === 1 &&
|
||
detect.types[0] === 'VEH' &&
|
||
detect.labelled === detect.count,
|
||
`${detect.count} contacts, all labelled, e.g. ${detect.sample.join(', ')} (DETECT ${detect.mode})`,
|
||
);
|
||
|
||
// The glyphs themselves: modes must not all be one shape.
|
||
const sprites = await page.evaluate(() => {
|
||
const state = window.__godsEyeView.dataManager.layers
|
||
.get('transit')
|
||
.module._transitStateForTest();
|
||
const images = new Map();
|
||
for (const entry of state._vehicles.values()) {
|
||
if (!entry.marker?.show) continue;
|
||
// Compare the WHOLE data URI: every glyph shares a long base64 prefix,
|
||
// so a truncated comparison says they are all the same picture.
|
||
images.set(entry.mode, String(entry.marker.image || ''));
|
||
}
|
||
return {
|
||
modes: [...images.keys()],
|
||
distinct: new Set(images.values()).size,
|
||
glyphBytes: [...images.values()].map((uri) => uri.length),
|
||
billboards: [...state._vehicles.values()].filter((e) => e.marker)
|
||
.length,
|
||
shown: state._shownCount,
|
||
};
|
||
});
|
||
check(
|
||
`${city.label}: vehicles draw as mode sprites, not plain dots`,
|
||
sprites.distinct === sprites.modes.length && sprites.modes.length > 0,
|
||
`${sprites.modes.join(', ')} → ${sprites.distinct} distinct glyph(s) of sizes ${sprites.glyphBytes.join('/')}; ${sprites.shown} shown of ${sprites.billboards}`,
|
||
);
|
||
|
||
await shot(
|
||
`${TAG}-${city.id}-01-vehicles.jpg`,
|
||
`${city.label} at 600 m, 45° pitch — ${first.count} vehicles in the layer, selected card on the one in frame`,
|
||
);
|
||
|
||
// ---- Rendered appearance under the sensor presets (first active live city) --------
|
||
// In-scene sprites pass through the post-FX chain; what matters is what
|
||
// reaches the screen. DETECT on, then each preset the owner tested:
|
||
// pixels of the sprite core and its ring from the WebGL canvas, the
|
||
// colour painted for its bracket on the detection surface, and one
|
||
// screenshot per preset for the diary.
|
||
if (city.id === CITIES[0].id) {
|
||
console.log('\n== rendered appearance ==');
|
||
const profile = await detectOn(page);
|
||
check(
|
||
'DETECT reaches the dense profile through its own button',
|
||
profile === 'DENSE',
|
||
profile,
|
||
);
|
||
const themeFor = (style) =>
|
||
DETECTION_THEME_MAP[style] || DETECTION_THEME_MAP._default;
|
||
const presets = [
|
||
{ name: 'normal', style: 'normal', params: {}, hot: null },
|
||
{
|
||
name: 'thermal-whot',
|
||
style: 'thermal',
|
||
params: { 'WHOT/BHOT': 0, Ironbow: 0 },
|
||
hot: 'white',
|
||
},
|
||
{
|
||
name: 'thermal-bhot',
|
||
style: 'thermal',
|
||
params: { 'WHOT/BHOT': 1, Ironbow: 0 },
|
||
hot: 'black',
|
||
},
|
||
{
|
||
name: 'thermal-ironbow',
|
||
style: 'thermal',
|
||
params: { 'WHOT/BHOT': 0, Ironbow: 1 },
|
||
hot: 'white',
|
||
},
|
||
{
|
||
name: 'surveillance',
|
||
style: 'surveillance',
|
||
params: {},
|
||
hot: 'white',
|
||
},
|
||
{ name: 'noir', style: 'noir', params: {}, hot: 'white' },
|
||
{ name: 'retro', style: 'retro', params: {}, hot: null },
|
||
];
|
||
await selectStyle(page, 'normal');
|
||
const baseline = await page.evaluate(readTransitFleetStyle, {
|
||
resetSelection: true,
|
||
});
|
||
for (const preset of presets) {
|
||
const how = await selectStyle(page, preset.style, preset.params);
|
||
await wait(1_200);
|
||
const styling = await page.evaluate(() =>
|
||
window.__godsEyeView.dataManager.layers
|
||
.get('transit')
|
||
.module._transitStylingForTest(),
|
||
);
|
||
const rendered = await sampleRendered(page);
|
||
await shot(
|
||
`${TAG}-boston-style-${preset.name}.jpg`,
|
||
`${city.label} at street level under ${preset.name}, DETECT on — ${rendered.length} isolated sprites sampled (style set via ${how})`,
|
||
);
|
||
const modes = new Set(rendered.map((r) => r.mode));
|
||
if (preset.hot) {
|
||
const contrast = reduceSensorContrast(
|
||
rendered,
|
||
preset.hot,
|
||
preset.style,
|
||
);
|
||
check(
|
||
`${preset.name}: sprite cores have the expected sensor contrast`,
|
||
contrast.pass,
|
||
JSON.stringify(contrast),
|
||
{ unexercised: rendered.length < 6 },
|
||
);
|
||
check(
|
||
`${preset.name}: the layer styled its sprites for ${preset.style}`,
|
||
styling.stylePreset === preset.style,
|
||
JSON.stringify(styling),
|
||
);
|
||
}
|
||
// Brackets: the detection surface paints each contact in its tier
|
||
// colour, which must be the mode's colour for this theme.
|
||
const theme = themeFor(preset.style);
|
||
const matched = rendered.filter(
|
||
(r) =>
|
||
r.bracket &&
|
||
rgbDistance(
|
||
r.bracket,
|
||
rgbOf(theme.tiers[transitModeTier(r.mode)]),
|
||
) < 48,
|
||
);
|
||
const byMode = new Map();
|
||
for (const r of rendered) if (r.bracket) byMode.set(r.mode, r.bracket);
|
||
const modeColours = [...byMode.values()];
|
||
let distinct = true;
|
||
for (let i = 0; i < modeColours.length; i += 1)
|
||
for (let j = i + 1; j < modeColours.length; j += 1)
|
||
if (rgbDistance(modeColours[i], modeColours[j]) < 40)
|
||
distinct = false;
|
||
check(
|
||
`${preset.name}: brackets carry the mode colour (${[...modes].join(', ') || 'no modes'})`,
|
||
rendered.length >= 6 &&
|
||
matched.length >= Math.ceil(rendered.length * 0.75) &&
|
||
(byMode.size < 2 || distinct),
|
||
rendered.length === 0
|
||
? 'no isolated sprite in frame to sample'
|
||
: `${matched.length} of ${rendered.length} within tolerance of their tier; ${byMode.size} mode(s) painted${byMode.size >= 2 ? (distinct ? ', distinguishable' : ', NOT distinguishable') : ''}; misses=${JSON.stringify(
|
||
rendered
|
||
.filter((r) => !matched.includes(r))
|
||
.map((r) => ({
|
||
key: r.key,
|
||
sampled: r.bracket,
|
||
tier: transitModeTier(r.mode),
|
||
expected: rgbOf(theme.tiers[transitModeTier(r.mode)]),
|
||
})),
|
||
)}`,
|
||
{ unexercised: rendered.length < 6 },
|
||
);
|
||
const clickTarget = rendered[0];
|
||
if (clickTarget) {
|
||
await page.mouse.click(clickTarget.x, clickTarget.y);
|
||
await wait(100);
|
||
const picked = await page.evaluate((key) => {
|
||
const state = window.__godsEyeView.dataManager.layers
|
||
.get('transit')
|
||
.module._transitStateForTest();
|
||
return {
|
||
selected: state._selectedKey === key,
|
||
pickOwner: state._lastPickForTest,
|
||
};
|
||
}, clickTarget.key);
|
||
check(
|
||
`${preset.name}: mouse click selects the displayed sprite`,
|
||
picked.selected,
|
||
JSON.stringify(picked),
|
||
);
|
||
await page.keyboard.press('Escape');
|
||
} else
|
||
check(
|
||
`${preset.name}: mouse click selects the displayed sprite`,
|
||
false,
|
||
'No verified unobscured sprite',
|
||
{ unexercised: true },
|
||
);
|
||
check(
|
||
`${preset.name}: the icon raster cache stays bounded`,
|
||
styling.iconCacheSize <= 6 * 2 * 3,
|
||
`${styling.iconCacheSize} rasters`,
|
||
);
|
||
}
|
||
// Selection under FLIR keeps the sprite white; leaving FLIR restores it.
|
||
await selectStyle(page, 'thermal', { 'WHOT/BHOT': 0, Ironbow: 0 });
|
||
const target = (await sampleRendered(page))[0];
|
||
if (target) {
|
||
await page.mouse.click(target.x, target.y);
|
||
await wait(800);
|
||
const underFlir = await page.evaluate((key) => {
|
||
const layer =
|
||
window.__godsEyeView.dataManager.layers.get('transit').module;
|
||
const state = layer._transitStateForTest();
|
||
const entry = state._vehicles.get(key);
|
||
return {
|
||
selected: state._selectedKey === key,
|
||
color: entry?.marker.color.toCssColorString(),
|
||
width: entry?.marker.width,
|
||
pickOwner: (() => {
|
||
const p = window.__godsEyeView.viewer.scene.pick(
|
||
entry.marker.computeScreenSpacePosition(
|
||
window.__godsEyeView.viewer.scene,
|
||
),
|
||
);
|
||
return {
|
||
id: typeof p?.id === 'string' ? p.id : null,
|
||
primitiveId:
|
||
typeof p?.primitive?.id === 'string' ? p.primitive.id : null,
|
||
collection:
|
||
entry.markerCollection === state._animatedMarkers
|
||
? 'animated'
|
||
: 'stationary',
|
||
};
|
||
})(),
|
||
};
|
||
}, target.key);
|
||
check(
|
||
'clicking a sprite under FLIR selects it and keeps it white-hot',
|
||
underFlir.selected && underFlir.color === 'rgb(255,255,255)',
|
||
JSON.stringify(underFlir),
|
||
);
|
||
await page.keyboard.press('Escape');
|
||
await wait(500);
|
||
} else {
|
||
check(
|
||
'clicking a sprite under FLIR selects it and keeps it white-hot',
|
||
false,
|
||
'no isolated sprite to click',
|
||
{ unexercised: true },
|
||
);
|
||
}
|
||
await selectStyle(page, 'normal');
|
||
const restored = await page.evaluate(readTransitFleetStyle, {
|
||
key: baseline?.key,
|
||
});
|
||
check(
|
||
'returning to normal restores the shipped sprite exactly',
|
||
Boolean(restored && baseline) &&
|
||
!restored.selected &&
|
||
!baseline.selected &&
|
||
restored.color === baseline.color &&
|
||
restored.width === baseline.width &&
|
||
restored.image === baseline.image,
|
||
restored && baseline
|
||
? `${baseline.key}: selected ${baseline.selected}→${restored.selected}, ${baseline.color}/${baseline.width}px → ${restored.color}/${restored.width}px; raster equal ${baseline.image === restored.image}`
|
||
: 'the baseline vehicle left the fleet',
|
||
{ unexercised: !(restored && baseline) },
|
||
);
|
||
}
|
||
|
||
// Motion acceptance uses known report-time playback even when live fleets park.
|
||
await selectStyle(page, 'normal');
|
||
await page.evaluate((city) => {
|
||
const app = window.__godsEyeView,
|
||
camera = app.viewer.camera;
|
||
const C = camera.positionCartographic.constructor;
|
||
camera.setView({
|
||
destination: C.toCartesian(C.fromDegrees(city.lon, city.lat, 600)),
|
||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||
});
|
||
}, city);
|
||
await wait(2000);
|
||
await page.evaluate(
|
||
(city) =>
|
||
window.__godsEyeView.dataManager.layers
|
||
.get('transit')
|
||
.module._loadTransitFleetForTest(16, city, 25),
|
||
city,
|
||
);
|
||
await wait(1000);
|
||
const motionFirst = await sampleVisible(page);
|
||
await wait(30_000);
|
||
const second = await page.evaluate(() => {
|
||
const app = window.__godsEyeView;
|
||
const layer = app.dataManager.layers.get('transit').module;
|
||
const state = layer._transitStateForTest();
|
||
const Cartographic = app.viewer.camera.positionCartographic.constructor;
|
||
return {
|
||
count: state._vehicles.size,
|
||
// The RENDERED primitive position, read back through Cesium — not the
|
||
// fix the layer is aiming at. A layer that never moved a point would
|
||
// still show two different `entry.to` values, so comparing those would
|
||
// prove the data changed, not that anything on screen did.
|
||
// VISIBLE markers only. Off-screen vehicles take their newest fix
|
||
// directly — that is the whole point of the view gate — so sampling
|
||
// them would let a layer whose visible interpolation is broken pass
|
||
// this check on the strength of hidden vehicles snapping.
|
||
sample: [...state._vehicles.values()]
|
||
.filter((entry) => entry.marker && entry.marker.show !== false)
|
||
.slice(0, 200)
|
||
.map((entry) => {
|
||
const carto = Cartographic.fromCartesian(entry.marker.position);
|
||
return {
|
||
key: entry.key,
|
||
lat: (carto.latitude * 180) / Math.PI,
|
||
lon: (carto.longitude * 180) / Math.PI,
|
||
};
|
||
}),
|
||
};
|
||
});
|
||
const beforeById = new Map(motionFirst.map((v) => [v.key, v]));
|
||
let moved = 0;
|
||
let matched = 0;
|
||
for (const v of second.sample) {
|
||
const was = beforeById.get(v.key);
|
||
if (!was) continue;
|
||
matched += 1;
|
||
const dLat = (v.lat - was.lat) * 111_320;
|
||
const dLon =
|
||
(v.lon - was.lon) * 111_320 * Math.cos((v.lat * Math.PI) / 180);
|
||
if (Math.hypot(dLat, dLon) > 5) moved += 1;
|
||
}
|
||
check(
|
||
`${city.label} deterministic fixture: the same vehicles are still drawn 30 s later, in new places`,
|
||
matched >= 5 && moved >= 3,
|
||
`${matched} same visible ids, ${moved} whose RENDERED point moved more than 5 m`,
|
||
);
|
||
|
||
// Between polls, not across them. Positions changing over thirty seconds
|
||
// only proves new data arrived; a layer that snapped every fifteen seconds
|
||
// and drew nothing in between would pass that. Two reads two seconds apart
|
||
// fall inside one poll interval, so anything that moves is being
|
||
// INTERPOLATED on screen.
|
||
const glideA = await sampleVisible(page);
|
||
await wait(2_000);
|
||
const glideB = await sampleVisible(page);
|
||
const glideBefore = new Map(glideA.map((v) => [v.key, v]));
|
||
let glided = 0;
|
||
let glideMatched = 0;
|
||
let straddled = 0;
|
||
// Only vehicles the LAYER says are mid-segment can testify here, and only
|
||
// readings from the SAME poll and the same displayed segment: a pair that
|
||
// straddles a poll could have snapped, and would pass as motion.
|
||
let claimed = 0;
|
||
for (const v of glideB) {
|
||
const was = glideBefore.get(v.key);
|
||
if (!was) continue;
|
||
glideMatched += 1;
|
||
if (!(was.gliding || v.gliding)) continue;
|
||
if (was.pollSeq !== v.pollSeq || was.segment !== v.segment) {
|
||
straddled += 1;
|
||
continue;
|
||
}
|
||
claimed += 1;
|
||
const dLat = (v.lat - was.lat) * 111_320;
|
||
const dLon =
|
||
(v.lon - was.lon) * 111_320 * Math.cos((v.lat * Math.PI) / 180);
|
||
if (Math.hypot(dLat, dLon) > 1) glided += 1;
|
||
}
|
||
check(
|
||
`${city.label} deterministic fixture: a vehicle the layer says is mid-segment moves on screen between polls`,
|
||
claimed > 0 && glided >= 1,
|
||
claimed === 0
|
||
? `nothing was mid-segment inside one poll across ${glideMatched} visible vehicles (${straddled} pairs straddled a poll)`
|
||
: `${glided} of ${claimed} mid-segment vehicles moved within one poll and one segment (${straddled} straddling pairs discarded)`,
|
||
{ unexercised: claimed === 0 },
|
||
);
|
||
|
||
// Orientation, measured the only way that settles it: where the sprite
|
||
// points against where the sprite actually travelled on screen. The
|
||
// projection is perspective-exact, so an off-centre vehicle at an oblique
|
||
// pitch must agree too — that is precisely the case the orthographic
|
||
// approximation got wrong by twenty-odd degrees.
|
||
//
|
||
// Only sprites that visibly moved can testify, and a keyless Austin view
|
||
// holds a handful of them; the floor is what that view can honestly
|
||
// supply, and the accuracy bar does the work.
|
||
const headingA = await sampleScreen(page);
|
||
await wait(6_000);
|
||
const headingB = await sampleScreen(page);
|
||
const wasById = new Map(headingA.map((v) => [v.key, v]));
|
||
let aimed = 0;
|
||
let checked = 0;
|
||
let worstDeg = 0;
|
||
for (const now of headingB) {
|
||
const was = wasById.get(now.key);
|
||
if (!was) continue;
|
||
// A poll in between could have placed it; that is not travel.
|
||
if (was.pollSeq !== now.pollSeq) continue;
|
||
const dx = now.x - was.x;
|
||
const dy = now.y - was.y;
|
||
// Only vehicles that visibly travelled can testify about direction.
|
||
if (Math.hypot(dx, dy) < 6) continue;
|
||
checked += 1;
|
||
// The sprite is authored nose-up and rotated CCW, so at rotation r its
|
||
// nose points along (-sin r, -cos r) in window coordinates.
|
||
const noseX = -Math.sin(now.rotation);
|
||
const noseY = -Math.cos(now.rotation);
|
||
const travel = Math.hypot(dx, dy);
|
||
const cosErr = (noseX * dx + noseY * dy) / travel;
|
||
const errDeg =
|
||
(Math.acos(Math.max(-1, Math.min(1, cosErr))) * 180) / Math.PI;
|
||
if (errDeg <= 35) aimed += 1;
|
||
else worstDeg = Math.max(worstDeg, errDeg);
|
||
}
|
||
check(
|
||
`${city.label} deterministic fixture: sprites point the way they are travelling on screen`,
|
||
checked > 0 && aimed >= Math.ceil(checked * 0.8),
|
||
checked === 0
|
||
? 'no sprite travelled far enough on screen inside one poll to testify'
|
||
: `${aimed} of ${checked} moving sprites within 35° of their screen travel` +
|
||
(worstDeg ? `, worst miss ${Math.round(worstDeg)}°` : ''),
|
||
{ unexercised: checked === 0 },
|
||
);
|
||
|
||
await shot(
|
||
`${TAG}-${city.id}-02-thirty-seconds-later.jpg`,
|
||
`${city.label} deterministic fixture 30 s later — ${second.count} vehicles, ${moved}/${matched} moved`,
|
||
);
|
||
}
|
||
|
||
check(
|
||
'vehicle selection never raises a Cesium render error',
|
||
(await page.evaluate(() => window.__transitRenderErrors || [])).length ===
|
||
0,
|
||
JSON.stringify(
|
||
await page.evaluate(() => window.__transitRenderErrors || []),
|
||
),
|
||
);
|
||
|
||
if (runs('history')) {
|
||
console.log('\n== retained history reload ==');
|
||
const recovery = await browser.newPage();
|
||
boundPageEvaluations(recovery);
|
||
try {
|
||
await recovery.goto(view(BOSTON), { waitUntil: 'domcontentloaded' });
|
||
await recovery.waitForFunction(
|
||
() => window.__godsEyeView?.dataManager?.layers?.has('transit'),
|
||
{ timeout: 90000 },
|
||
);
|
||
// Leave enough time for two snapshots, priming and reload in one minute.
|
||
if (Date.now() % 60000 > 20000)
|
||
await wait(60000 - (Date.now() % 60000) + 50);
|
||
const first = await recovery.evaluate(async () =>
|
||
(
|
||
await fetch('/api/transit/vehicles/mbta', {
|
||
signal: AbortSignal.timeout(10000),
|
||
})
|
||
).json(),
|
||
);
|
||
await wait(16000);
|
||
const retained = await recovery.evaluate(async (prior) => {
|
||
const current = await (
|
||
await fetch('/api/transit/vehicles/mbta', {
|
||
signal: AbortSignal.timeout(10000),
|
||
})
|
||
).json();
|
||
const candidates = current.vehicles.filter((v) =>
|
||
prior.vehicles.some((p) => p.id === v.id),
|
||
);
|
||
// Prime the newest fixes from this exact feed, not evenly spaced stale IDs.
|
||
const primedAt = Date.now();
|
||
const picked = candidates
|
||
.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0))
|
||
.slice(0, 12);
|
||
const histories = await Promise.all(
|
||
picked.map(async (vehicle) => {
|
||
const response = await fetch(
|
||
`/api/transit/trail/mbta/${encodeURIComponent(vehicle.id)}`,
|
||
{ signal: AbortSignal.timeout(10000) },
|
||
);
|
||
if (!response.ok)
|
||
throw new Error(`Trail prime HTTP ${response.status}`);
|
||
return response.json();
|
||
}),
|
||
);
|
||
return histories
|
||
.filter((history) => history.fixes?.length >= 2)
|
||
.map((history) => ({ ...history, feedId: 'mbta', primedAt }));
|
||
}, first);
|
||
await recovery.reload({ waitUntil: 'domcontentloaded' });
|
||
await recovery.waitForFunction(
|
||
() => window.__godsEyeView?.dataManager?.layers?.has('transit'),
|
||
{ timeout: 90000 },
|
||
);
|
||
await recovery.evaluate(() => {
|
||
window.__transitRenderErrors = [];
|
||
window.__godsEyeView.viewer.scene.renderError.addEventListener(
|
||
(scene, error) => window.__transitRenderErrors.push(error.message),
|
||
);
|
||
});
|
||
const restored = await recovery.evaluate(
|
||
restoreRetainedTransit,
|
||
retained,
|
||
);
|
||
if (restored.key) {
|
||
await recovery.waitForFunction(
|
||
() =>
|
||
!window.__godsEyeView.dataManager.layers
|
||
.get('transit')
|
||
.module._transitPartsForTest()
|
||
.trails.requestDiagnostics().pending,
|
||
{ timeout: 20000 },
|
||
);
|
||
await wait(4000);
|
||
const proof = await recovery.evaluate(
|
||
({ key, oldest }) => {
|
||
const layer =
|
||
window.__godsEyeView.dataManager.layers.get('transit').module;
|
||
const entry = layer._transitStateForTest()._vehicles.get(key),
|
||
parts = layer._transitPartsForTest();
|
||
if (!entry) return { gone: true };
|
||
const segments = entry.trailSegments?.length || 0;
|
||
const renderErrors = window.__transitRenderErrors || [];
|
||
const count = entry.track.count,
|
||
restoredOldest = entry.fixes[0].t;
|
||
const before = parts.trails.requestDiagnostics().abortCount;
|
||
parts.selection.selectVehicle(key);
|
||
parts.selection.clearSelection();
|
||
return {
|
||
count,
|
||
segments,
|
||
renderErrors,
|
||
restoredOldest,
|
||
oldest,
|
||
aborted: parts.trails.requestDiagnostics().abortCount > before,
|
||
};
|
||
},
|
||
{ key: restored.key, oldest: restored.oldest },
|
||
);
|
||
check(
|
||
'reload restores observed history and selection churn aborts its request',
|
||
proof.count >= 2 &&
|
||
proof.restoredOldest <= proof.oldest &&
|
||
proof.aborted,
|
||
proof.gone
|
||
? 'Selected primed vehicle left during recovery'
|
||
: JSON.stringify(proof),
|
||
{ unexercised: proof.gone === true },
|
||
);
|
||
if (!proof.gone)
|
||
check(
|
||
'reloaded MBTA trail prepares geometry without a Cesium render error',
|
||
proof.segments > 0 && proof.renderErrors.length === 0,
|
||
JSON.stringify(proof),
|
||
{
|
||
unexercised:
|
||
proof.segments === 0 && proof.renderErrors.length === 0,
|
||
},
|
||
);
|
||
} else
|
||
check('reload restores observed history', false, restored.error, {
|
||
unexercised: restored.unexercised === true,
|
||
});
|
||
} finally {
|
||
await recovery.close();
|
||
await page.bringToFront();
|
||
}
|
||
}
|
||
|
||
// ---- Deterministic playback and card anchor (synthetic feed) -----------
|
||
// The live feeds prove the layer against reality; they cannot prove it
|
||
// against a script. For the next two minutes the MBTA response is replaced
|
||
// at the network layer by six scripted vehicles near the Boston view —
|
||
// straight travel, a corner, a genuine stop, a ninety-second silence, a
|
||
// late packet and a feed that only refreshes every thirty seconds — and
|
||
// every rendered frame is sampled.
|
||
if (runs('playback')) {
|
||
console.log('\n== deterministic playback ==');
|
||
await page.evaluate((c) => {
|
||
const camera = window.__godsEyeView.viewer.camera;
|
||
const Cartographic = camera.positionCartographic.constructor;
|
||
camera.setView({
|
||
destination: Cartographic.toCartesian(
|
||
Cartographic.fromDegrees(c.lon, c.lat + 0.005, 2400),
|
||
),
|
||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||
});
|
||
}, BOSTON);
|
||
await page.evaluate(() => {
|
||
window.__godsEyeView.dataManager.layers
|
||
.get('transit')
|
||
.module._setTransitFixtureFloorsForTest(
|
||
['straight', 'corner', 'stop', 'gap', 'late', 'dup'].map(
|
||
(id) => `mbta:sim-${id}`,
|
||
),
|
||
20,
|
||
);
|
||
});
|
||
const ORIGIN = { lat: BOSTON.lat, lon: BOSTON.lon };
|
||
const M_LAT = 1 / 111_320;
|
||
const M_LON = 1 / (111_320 * Math.cos((ORIGIN.lat * Math.PI) / 180));
|
||
const at = (eastM, northM) => ({
|
||
lat: ORIGIN.lat + northM * M_LAT,
|
||
lon: ORIGIN.lon + eastM * M_LON,
|
||
});
|
||
const simStart = Date.now();
|
||
const reportTime = (elapsedS, cadenceS) =>
|
||
Math.floor(elapsedS / cadenceS) * cadenceS;
|
||
const SIM = {
|
||
straight: { speed: 10 },
|
||
corner: { speed: 8, turnAt: 60 },
|
||
stop: { speed: 8, stopFrom: 30, stopTo: 75 },
|
||
gap: { speed: 6, silentFrom: 40, silentTo: 130 },
|
||
late: { speed: 8, lateFrom: 50, lateTo: 60 },
|
||
dup: { speed: 8, cadence: 30 },
|
||
};
|
||
const synthetic = (nowMs) => {
|
||
const elapsed = (nowMs - simStart) / 1000;
|
||
const vehicles = [];
|
||
const push = (id, pos, tS) =>
|
||
vehicles.push({
|
||
id,
|
||
lat: pos.lat,
|
||
lon: pos.lon,
|
||
bearing: null,
|
||
speedMps: null,
|
||
timestamp: Math.round(simStart / 1000 + tS),
|
||
timestampSource: 'vehicle',
|
||
routeId: id.toUpperCase(),
|
||
tripId: null,
|
||
directionId: null,
|
||
label: null,
|
||
stopId: null,
|
||
status: null,
|
||
occupancy: null,
|
||
});
|
||
{
|
||
const t = reportTime(elapsed, 10);
|
||
push('sim-straight', at(-220, SIM.straight.speed * t), t);
|
||
}
|
||
{
|
||
const t = reportTime(elapsed, 10);
|
||
const n = Math.min(t, SIM.corner.turnAt) * SIM.corner.speed;
|
||
const e = Math.max(0, t - SIM.corner.turnAt) * SIM.corner.speed;
|
||
push('sim-corner', at(110 + e, n), t);
|
||
}
|
||
{
|
||
const t = reportTime(elapsed, 10);
|
||
const moved =
|
||
t <= SIM.stop.stopFrom
|
||
? t
|
||
: t <= SIM.stop.stopTo
|
||
? SIM.stop.stopFrom
|
||
: SIM.stop.stopFrom + (t - SIM.stop.stopTo);
|
||
push('sim-stop', at(-110, moved * SIM.stop.speed), t);
|
||
}
|
||
{
|
||
const t0 = reportTime(elapsed, 10);
|
||
const t =
|
||
t0 >= SIM.gap.silentFrom && t0 < SIM.gap.silentTo
|
||
? SIM.gap.silentFrom
|
||
: t0;
|
||
push('sim-gap', at(220, t * SIM.gap.speed), t);
|
||
}
|
||
{
|
||
const t0 = reportTime(elapsed, 10);
|
||
const t =
|
||
elapsed >= SIM.late.lateFrom && elapsed < SIM.late.lateTo
|
||
? t0 - 30
|
||
: t0;
|
||
push('sim-late', at(330, t * SIM.late.speed), t);
|
||
}
|
||
{
|
||
const t = reportTime(elapsed, SIM.dup.cadence);
|
||
push('sim-dup', at(-330, t * SIM.dup.speed), t);
|
||
}
|
||
return {
|
||
feedId: 'mbta',
|
||
name: 'MBTA',
|
||
fetchedAt: nowMs,
|
||
feedTimestamp: null,
|
||
version: '2.0',
|
||
entityCount: vehicles.length,
|
||
truncated: false,
|
||
count: vehicles.length,
|
||
vehicles,
|
||
};
|
||
};
|
||
let intercepting = true;
|
||
await page.setRequestInterception(true);
|
||
const onRequest = (request) => {
|
||
if (
|
||
intercepting &&
|
||
request.url().includes('/api/transit/vehicles/mbta')
|
||
) {
|
||
const now = Date.now();
|
||
request.respond({
|
||
status: 200,
|
||
contentType: 'application/json',
|
||
headers: { 'x-transit-contact': String(now) },
|
||
body: JSON.stringify(synthetic(now)),
|
||
});
|
||
return;
|
||
}
|
||
request.continue();
|
||
};
|
||
page.on('request', onRequest);
|
||
// Wait for the scripted fleet to replace the live one.
|
||
await page.waitForFunction(
|
||
() => {
|
||
const state = window.__godsEyeView.dataManager.layers
|
||
.get('transit')
|
||
.module._transitStateForTest();
|
||
return state._vehicles.has('mbta:sim-straight');
|
||
},
|
||
{ timeout: 60_000 },
|
||
);
|
||
const simState = () =>
|
||
page.evaluate(() => {
|
||
const state = window.__godsEyeView.dataManager.layers
|
||
.get('transit')
|
||
.module._transitStateForTest();
|
||
const now = Date.now();
|
||
return ['straight', 'corner', 'stop', 'gap', 'late', 'dup']
|
||
.map((k) => {
|
||
const e = state._vehicles.get(`mbta:sim-${k}`);
|
||
if (!e) return `${k}:absent`;
|
||
return `${k}:${e.marker ? (e.marker.show === false ? 'hidden' : 'shown') : 'nomarker'}/lag${((now - e.playT) / 1000).toFixed(0)}/${state._moving.has(e) ? 'mv' : 'idle'}/fx${e.fixes.length}/r${e.resets || 0}`;
|
||
})
|
||
.join(' ');
|
||
});
|
||
console.log(` scripted fleet before trace: ${await simState()}`);
|
||
await reportTransitVisibility(page, 'scripted visibility before trace');
|
||
// Sample every rendered frame for two minutes.
|
||
const trace = await page.evaluate(
|
||
(durationMs) =>
|
||
new Promise((resolve) => {
|
||
const app = window.__godsEyeView;
|
||
const layer = app.dataManager.layers.get('transit').module;
|
||
const state = layer._transitStateForTest();
|
||
const Cartographic =
|
||
app.viewer.camera.positionCartographic.constructor;
|
||
const keys = [
|
||
'sim-straight',
|
||
'sim-corner',
|
||
'sim-stop',
|
||
'sim-gap',
|
||
'sim-late',
|
||
'sim-dup',
|
||
].map((k) => `mbta:${k}`);
|
||
const out = Object.fromEntries(keys.map((k) => [k, []]));
|
||
const words = Object.fromEntries(keys.map((k) => [k, []]));
|
||
const t0 = performance.now();
|
||
let lastWordAt = -1e9;
|
||
let remove;
|
||
const timer = setTimeout(() => {
|
||
remove?.();
|
||
resolve({ out, words });
|
||
}, durationMs + 1000);
|
||
const tick = () => {
|
||
const now = performance.now();
|
||
const wall = Date.now();
|
||
for (const key of keys) {
|
||
const entry = state._vehicles.get(key);
|
||
if (!entry?.marker?.show) continue;
|
||
const carto = Cartographic.fromCartesian(entry.marker.position);
|
||
out[key].push([
|
||
now - t0,
|
||
(carto.latitude * 180) / Math.PI,
|
||
(carto.longitude * 180) / Math.PI,
|
||
entry.playT,
|
||
entry.resets || 0,
|
||
state._moving.has(entry) ? 1 : 0,
|
||
entry.courseDeg,
|
||
wall,
|
||
entry.track.count,
|
||
entry.sample.segmentSpeedMps,
|
||
entry.sample.fromSeq,
|
||
entry.marker.position.x,
|
||
entry.marker.position.y,
|
||
entry.marker.position.z,
|
||
entry.sample.toSeq,
|
||
entry.sample.phase,
|
||
entry.clocks.monoNowMs,
|
||
]);
|
||
}
|
||
if (now - lastWordAt > 1_000) {
|
||
lastWordAt = now;
|
||
for (const contact of layer.getDetectableObjects()) {
|
||
if (words[contact.sourceId])
|
||
words[contact.sourceId].push([now - t0, contact.metric]);
|
||
}
|
||
}
|
||
if (now - t0 >= durationMs) {
|
||
clearTimeout(timer);
|
||
remove();
|
||
resolve({ out, words });
|
||
}
|
||
};
|
||
remove = app.viewer.scene.postRender.addEventListener(tick);
|
||
}),
|
||
120_000,
|
||
);
|
||
console.log(` scripted fleet after trace: ${await simState()}`);
|
||
await reportTransitVisibility(page, 'scripted visibility after trace');
|
||
const metres = (a, b) =>
|
||
Math.hypot(
|
||
(b[1] - a[1]) * 111_320,
|
||
(b[2] - a[2]) * 111_320 * Math.cos((a[1] * Math.PI) / 180),
|
||
);
|
||
const judge = (key) => reduceScenario(trace, key);
|
||
// Preserve every frame and both bracket ends; never join moving rows across a hold.
|
||
const speedEvidence = Object.entries(trace.out).map(([key, rows]) => ({
|
||
key,
|
||
...reduceScriptedMotion(key, rows),
|
||
}));
|
||
await writeFile(
|
||
`${SHOTS}/${TAG}-scripted-frames.json`,
|
||
JSON.stringify(trace),
|
||
);
|
||
for (const evidence of speedEvidence)
|
||
check(
|
||
`${evidence.key}: ordinary postRender frames follow the displayed segment within 2%`,
|
||
evidence.pass,
|
||
JSON.stringify(evidence),
|
||
);
|
||
const scenarioCheck = (name, conditions, detail) =>
|
||
check(
|
||
name,
|
||
Object.values(conditions).every(Boolean),
|
||
`${detail}; conditions=${JSON.stringify(conditions)}; failed=${JSON.stringify(Object.keys(conditions).filter((k) => !conditions[k]))}`,
|
||
);
|
||
const straight = judge('straight');
|
||
scenarioCheck(
|
||
'straight travel is played back continuously at its own speed',
|
||
{
|
||
'straight.rows > 3_000': straight.rows > 3_000,
|
||
'straight.total > 300': straight.total > 300,
|
||
'straight.moved > straight.rows * 0.4':
|
||
straight.moved > straight.rows * 0.4,
|
||
'straight.worstJump <= SIM.straight.speed * 1.02 + 0.1':
|
||
straight.worstJump <= SIM.straight.speed * 1.02 + 0.1,
|
||
'straight.speedWorst >= SIM.straight.speed * 0.98':
|
||
straight.speedWorst >= SIM.straight.speed * 0.98,
|
||
'straight.speedWorst <= SIM.straight.speed * 1.02':
|
||
straight.speedWorst <= SIM.straight.speed * 1.02,
|
||
'straight.wallFaster === 0': straight.wallFaster === 0,
|
||
'straight.resets === 0': straight.resets === 0,
|
||
},
|
||
`${straight.rows} frames (${straight.keys}), ${straight.total.toFixed(0)} m drawn, ${straight.moved} moving frames, worst per-frame ${straight.worstJump.toFixed(1)} m/s, peak segment speed ${straight.speedWorst.toFixed(2)} m/s vs ${SIM.straight.speed}, ${straight.wallFaster} frames faster than real time, ${straight.resets} resets`,
|
||
);
|
||
scenarioCheck(
|
||
'the display lag is bounded and never below the feed floor',
|
||
{
|
||
'straight.lagValid': straight.lagValid,
|
||
'straight.lagMin >= 24': straight.lagMin >= 24,
|
||
'straight.lagMax <= 215': straight.lagMax <= 215,
|
||
},
|
||
`lag ${straight.lagMin.toFixed(1)}–${straight.lagMax.toFixed(1)} s`,
|
||
);
|
||
const corner = judge('corner');
|
||
const cornerRows = trace.out['mbta:sim-corner'] || [];
|
||
const courseLate = cornerRows
|
||
.slice(-300)
|
||
.map((r) => r[6])
|
||
.filter(Number.isFinite);
|
||
const courseEarly = cornerRows
|
||
.filter((r) => r[5] === 1)
|
||
.slice(0, 300)
|
||
.map((r) => r[6])
|
||
.filter(Number.isFinite);
|
||
const mean = (xs) =>
|
||
xs.length ? xs.reduce((s, x) => s + x, 0) / xs.length : NaN;
|
||
scenarioCheck(
|
||
'a corner is turned on the displayed segment: north first, east once the turn is played',
|
||
{
|
||
'courseEarly.length > 0': courseEarly.length > 0,
|
||
'courseLate.length > 0': courseLate.length > 0,
|
||
'Math.abs(mean(courseEarly)) < 12': Math.abs(mean(courseEarly)) < 12,
|
||
'Math.abs(mean(courseLate) - 90) < 15':
|
||
Math.abs(mean(courseLate) - 90) < 15,
|
||
'corner.worstJump <= SIM.corner.speed * 1.5 + 2':
|
||
corner.worstJump <= SIM.corner.speed * 1.5 + 2,
|
||
'corner.resets === 0': corner.resets === 0,
|
||
},
|
||
`early course ${mean(courseEarly).toFixed(1)}°, late course ${mean(courseLate).toFixed(1)}°, worst per-frame ${corner.worstJump.toFixed(1)} m/s`,
|
||
);
|
||
const stopWords = (trace.words['mbta:sim-stop'] || []).map((w) => w[1]);
|
||
const stopRows = trace.out['mbta:sim-stop'] || [];
|
||
const stopStill = stopRows.filter(
|
||
(r, i) => i > 0 && metres(stopRows[i - 1], r) < 0.01,
|
||
).length;
|
||
scenarioCheck(
|
||
'a genuine stop reads STOPPED while the marker stands still, then moves on',
|
||
{
|
||
'stopWords.some((w) => /STOPPED/.test(w))': stopWords.some((w) =>
|
||
/STOPPED/.test(w),
|
||
),
|
||
'stopStill > 600': stopStill > 600,
|
||
"judge('stop').total > 200": judge('stop').total > 200,
|
||
"judge('stop').resets === 0": judge('stop').resets === 0,
|
||
},
|
||
`words seen: ${[...new Set(stopWords)].join(' | ')}; ${stopStill} still frames; ${judge('stop').total.toFixed(0)} m drawn`,
|
||
);
|
||
const gap = judge('gap');
|
||
scenarioCheck(
|
||
'a ninety-second silence holds at the newest fix — no extrapolation, no reset — and resumes',
|
||
{
|
||
'gap.resets === 0': gap.resets === 0,
|
||
'gap.worstJump <= SIM.gap.speed * 1.5 + 2':
|
||
gap.worstJump <= SIM.gap.speed * 1.5 + 2,
|
||
'gap.total > 100': gap.total > 100,
|
||
},
|
||
`${gap.total.toFixed(0)} m drawn, worst per-frame ${gap.worstJump.toFixed(1)} m/s, ${gap.resets} resets`,
|
||
);
|
||
const late = judge('late');
|
||
scenarioCheck(
|
||
'a late packet is refused: no backward motion, no reset',
|
||
{
|
||
'late.backwards === 0': late.backwards === 0,
|
||
'late.resets === 0': late.resets === 0,
|
||
'late.total > 200': late.total > 200,
|
||
},
|
||
`${late.backwards} backward frames, ${late.resets} resets, ${late.total.toFixed(0)} m drawn`,
|
||
);
|
||
const dup = judge('dup');
|
||
scenarioCheck(
|
||
'a feed that refreshes every thirty seconds plays at the fix speed, not in bursts',
|
||
{
|
||
'dup.speedWorst <= SIM.dup.speed * 1.06':
|
||
dup.speedWorst <= SIM.dup.speed * 1.06,
|
||
'dup.worstJump <= SIM.dup.speed * 1.5 + 2':
|
||
dup.worstJump <= SIM.dup.speed * 1.5 + 2,
|
||
'dup.total > 200': dup.total > 200,
|
||
'dup.resets === 0': dup.resets === 0,
|
||
},
|
||
`peak segment speed ${dup.speedWorst.toFixed(2)} m/s vs ${SIM.dup.speed}, worst per-frame ${dup.worstJump.toFixed(1)} m/s, ${dup.total.toFixed(0)} m drawn`,
|
||
);
|
||
|
||
// ---- Card anchor synchronisation ---------------------------------------
|
||
console.log('\n== card anchor ==');
|
||
await page.evaluate(() => {
|
||
const app = window.__godsEyeView,
|
||
camera = app.viewer.camera;
|
||
const entry = app.dataManager.layers
|
||
.get('transit')
|
||
.module._transitStateForTest()
|
||
._vehicles.get('mbta:sim-straight');
|
||
if (!entry) return;
|
||
const C = camera.positionCartographic.constructor;
|
||
const at = C.fromCartesian(entry.marker.position);
|
||
at.height = 600;
|
||
camera.setView({
|
||
destination: C.toCartesian(at),
|
||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||
});
|
||
});
|
||
await wait(1500);
|
||
await reportTransitVisibility(page, 'scripted visibility before click');
|
||
const target = await page.evaluate(() => {
|
||
const app = window.__godsEyeView;
|
||
const layer = app.dataManager.layers.get('transit').module;
|
||
const state = layer._transitStateForTest();
|
||
const entry = state._vehicles.get('mbta:sim-straight');
|
||
if (!entry?.marker?.show) return null;
|
||
const scene = app.viewer.scene;
|
||
const canvas = scene.canvas;
|
||
const view = scene.camera.viewMatrix;
|
||
const proj = scene.camera.frustum.projectionMatrix;
|
||
const mul = (m, v) => [
|
||
m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12] * v[3],
|
||
m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13] * v[3],
|
||
m[2] * v[0] + m[6] * v[1] + m[10] * v[2] + m[14] * v[3],
|
||
m[3] * v[0] + m[7] * v[1] + m[11] * v[2] + m[15] * v[3],
|
||
];
|
||
const p = entry.marker.position;
|
||
const clip = mul(proj, mul(view, [p.x, p.y, p.z, 1]));
|
||
const x = ((clip[0] / clip[3]) * 0.5 + 0.5) * canvas.clientWidth;
|
||
const y = (1 - ((clip[1] / clip[3]) * 0.5 + 0.5)) * canvas.clientHeight;
|
||
if (
|
||
!(clip[3] > 0) ||
|
||
x < 0 ||
|
||
y < 0 ||
|
||
x >= canvas.clientWidth ||
|
||
y >= canvas.clientHeight
|
||
)
|
||
return null;
|
||
const picked = scene.pick({ x, y });
|
||
return {
|
||
entityCount: app.viewer.entities.values.length,
|
||
key: entry.key,
|
||
x,
|
||
y,
|
||
pickKey: picked?.id ?? picked?.primitive?.id ?? null,
|
||
};
|
||
});
|
||
let anchor = null;
|
||
if (target) {
|
||
check(
|
||
'the scripted sprite owns its screen-space pick',
|
||
target.pickKey === target.key,
|
||
JSON.stringify(target),
|
||
);
|
||
await page.mouse.click(target.x, target.y);
|
||
await wait(600);
|
||
anchor = await page.evaluate(
|
||
async (key, durationMs) => {
|
||
const overlay = await import('/src/overlays/worldOverlay.js');
|
||
const app = window.__godsEyeView;
|
||
const layer = app.dataManager.layers.get('transit').module;
|
||
const state = layer._transitStateForTest();
|
||
if (state._selectedKey !== key)
|
||
return {
|
||
selected: false,
|
||
selection: {
|
||
expected: key,
|
||
actual: state._selectedKey,
|
||
handler: !!state._clickHandler,
|
||
lastPick: state._lastPickForTest ?? null,
|
||
pointerOwner: (
|
||
await import('/src/data/inputOwnership.js')
|
||
).pointerOwner(),
|
||
},
|
||
};
|
||
const scene = app.viewer.scene;
|
||
const canvas = scene.canvas;
|
||
const mul = (m, v) => [
|
||
m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12] * v[3],
|
||
m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13] * v[3],
|
||
m[2] * v[0] + m[6] * v[1] + m[10] * v[2] + m[14] * v[3],
|
||
m[3] * v[0] + m[7] * v[1] + m[11] * v[2] + m[15] * v[3],
|
||
];
|
||
const frames = [];
|
||
const observedPolls = new Set();
|
||
const publicationsBefore = state._cardPublications || 0;
|
||
let headSamples = 0,
|
||
headErrorPx = 0,
|
||
bodyMutation = 0,
|
||
previousTrail = null;
|
||
const entityCount = app.viewer.entities.values.length;
|
||
return new Promise((resolve) => {
|
||
const t0 = performance.now();
|
||
const tick = () => {
|
||
const entry = state._vehicles.get(key);
|
||
const rect = overlay.getOverlayPaintRect('transit-selected', key);
|
||
if (entry?.marker) {
|
||
const view = scene.camera.viewMatrix;
|
||
const proj = scene.camera.frustum.projectionMatrix;
|
||
const p = entry.marker.position;
|
||
const clip = mul(proj, mul(view, [p.x, p.y, p.z, 1]));
|
||
if (
|
||
entry.marker.show !== false &&
|
||
rect &&
|
||
Number.isFinite(entry.pollSeq)
|
||
) {
|
||
observedPolls.add(entry.pollSeq);
|
||
}
|
||
frames.push({
|
||
t: performance.now() - t0,
|
||
mx: ((clip[0] / clip[3]) * 0.5 + 0.5) * canvas.clientWidth,
|
||
my:
|
||
(1 - ((clip[1] / clip[3]) * 0.5 + 0.5)) *
|
||
canvas.clientHeight,
|
||
markerVisible: entry.marker.show !== false,
|
||
worldFinite: [p.x, p.y, p.z].every(Number.isFinite),
|
||
cardPresent: !!rect,
|
||
surfaceReady: entry.surfaceReady,
|
||
ax: rect?.anchorX ?? null,
|
||
ay: rect?.anchorY ?? null,
|
||
poll: entry.pollSeq,
|
||
moving: state._moving.has(entry) ? 1 : 0,
|
||
});
|
||
}
|
||
const trail = layer._transitPartsForTest().trails.diagnostics();
|
||
if (trail && entry?.marker) {
|
||
if (
|
||
previousTrail &&
|
||
previousTrail.revision === trail.revision &&
|
||
previousTrail.body !== trail.body
|
||
)
|
||
bodyMutation++;
|
||
previousTrail = trail;
|
||
if (trail.head.show && trail.head.positions.length) {
|
||
const project = (p) => {
|
||
const clip = mul(
|
||
scene.camera.frustum.projectionMatrix,
|
||
mul(scene.camera.viewMatrix, [p.x, p.y, p.z, 1]),
|
||
);
|
||
return [
|
||
((clip[0] / clip[3]) * canvas.clientWidth) / 2,
|
||
((clip[1] / clip[3]) * canvas.clientHeight) / 2,
|
||
];
|
||
};
|
||
const h = project(trail.head.positions.at(-1)),
|
||
m = project(entry.marker.position);
|
||
headErrorPx = Math.max(
|
||
headErrorPx,
|
||
Math.hypot(h[0] - m[0], h[1] - m[1]),
|
||
);
|
||
headSamples++;
|
||
}
|
||
}
|
||
const elapsed = performance.now() - t0;
|
||
// Keep at least 20 seconds of motion and require an observed poll
|
||
// transition. A fixed 20s wall-clock window can miss a 15s poll
|
||
// when an earlier refresh is still settling.
|
||
if (
|
||
(elapsed >= 20_000 && observedPolls.size >= 2) ||
|
||
elapsed >= durationMs
|
||
)
|
||
finish();
|
||
};
|
||
let remove;
|
||
const finish = () => {
|
||
clearTimeout(timer);
|
||
remove?.();
|
||
resolve({
|
||
selected: true,
|
||
elapsedMs: performance.now() - t0,
|
||
frames,
|
||
textPublishes:
|
||
(state._cardPublications || 0) - publicationsBefore,
|
||
headSamples,
|
||
headErrorPx,
|
||
bodyMutation,
|
||
entityCount,
|
||
});
|
||
};
|
||
const timer = setTimeout(finish, durationMs + 1000);
|
||
remove = scene.postRender.addEventListener(tick);
|
||
});
|
||
},
|
||
target.key,
|
||
3 * TRANSIT_POLL_MS,
|
||
);
|
||
}
|
||
check(
|
||
'selected trail head stays within 1 CSS px of the marker, without entity additions or body rebuilds between revisions',
|
||
reduceTrailHead(anchor, target).pass,
|
||
JSON.stringify(reduceTrailHead(anchor, target)),
|
||
);
|
||
if (anchor?.selected) {
|
||
await writeFile(
|
||
`${SHOTS}/${TAG}-anchor-frames.json`,
|
||
JSON.stringify(anchor.frames),
|
||
);
|
||
const evidence = reduceAnchor(anchor.frames);
|
||
check(
|
||
'actual painted host anchor stays within 1 CSS px of the marker',
|
||
evidence.pass,
|
||
JSON.stringify(evidence),
|
||
);
|
||
check(
|
||
'card text publication stays throttled while the anchor runs at frame rate',
|
||
anchor.textPublishes > 0 &&
|
||
anchor.textPublishes <= anchor.elapsedMs / 250 + 3,
|
||
`${anchor.textPublishes} text publications in ${(anchor.elapsedMs / 1000).toFixed(1)} s`,
|
||
);
|
||
} else {
|
||
check(
|
||
'the card anchor moves in lockstep with the sprite on every frame, across polls and a settle',
|
||
false,
|
||
target
|
||
? 'the click did not select the scripted vehicle'
|
||
: 'no scripted vehicle on screen',
|
||
{ unexercised: true },
|
||
);
|
||
}
|
||
// Removal: the scripted vehicle leaves the feed; after two missed polls its
|
||
// card must go with it.
|
||
intercepting = false;
|
||
await page.evaluate(() =>
|
||
window.__godsEyeView.dataManager.layers
|
||
.get('transit')
|
||
.module._setTransitFixtureFloorsForTest([], null),
|
||
);
|
||
page.off('request', onRequest);
|
||
await page.setRequestInterception(false);
|
||
await page
|
||
.waitForFunction(
|
||
() => {
|
||
const state = window.__godsEyeView.dataManager.layers
|
||
.get('transit')
|
||
.module._transitStateForTest();
|
||
return !state._vehicles.has('mbta:sim-straight');
|
||
},
|
||
{ timeout: 60_000 },
|
||
)
|
||
.catch(() => {});
|
||
const afterRemoval = await page.evaluate(() => {
|
||
const state = window.__godsEyeView.dataManager.layers
|
||
.get('transit')
|
||
.module._transitStateForTest();
|
||
return {
|
||
gone: !state._vehicles.has('mbta:sim-straight'),
|
||
selected: state._selectedKey,
|
||
};
|
||
});
|
||
check(
|
||
'a removed vehicle takes its card with it',
|
||
afterRemoval.gone && afterRemoval.selected !== 'mbta:sim-straight',
|
||
JSON.stringify(afterRemoval),
|
||
);
|
||
await page.evaluate((c) => {
|
||
const camera = window.__godsEyeView.viewer.camera;
|
||
const Cartographic = camera.positionCartographic.constructor;
|
||
camera.setView({
|
||
destination: Cartographic.toCartesian(
|
||
Cartographic.fromDegrees(c.lon, c.lat, 600),
|
||
),
|
||
orientation: {
|
||
heading: (c.heading * Math.PI) / 180,
|
||
pitch: (-45 * Math.PI) / 180,
|
||
roll: 0,
|
||
},
|
||
});
|
||
}, CITIES.at(-1));
|
||
await wait(4_000);
|
||
}
|
||
|
||
const sceneQA = {
|
||
page,
|
||
check,
|
||
wait,
|
||
shots: SHOTS,
|
||
tag: TAG,
|
||
detectOn,
|
||
selectStyle,
|
||
sampleRendered,
|
||
};
|
||
if (runs('trail-visible'))
|
||
for (const [altitude, pitch] of [
|
||
[300, 55],
|
||
[600, 45],
|
||
[900, 45],
|
||
])
|
||
await runTrailVisibility({ ...sceneQA, altitude, pitch });
|
||
if (runs('budgets')) await runFleetBudgets(sceneQA);
|
||
if (runs('matrix')) await runBostonMatrix(sceneQA);
|
||
if (runs('boston-live')) await runBostonLive(sceneQA);
|
||
|
||
// Turning it off must stop the traffic and leave nothing behind.
|
||
console.log('\n== disable ==');
|
||
await page.$eval(row, (element) =>
|
||
element.scrollIntoView({ block: 'center' }),
|
||
);
|
||
await page.click(`${row} .data-toggle-btn`);
|
||
await page.waitForFunction(
|
||
() => !window.__godsEyeView.dataManager.isEnabled('transit'),
|
||
{ timeout: 20_000 },
|
||
);
|
||
const afterDisable = transitRequests.length;
|
||
const teardown = await page.evaluate(() => {
|
||
const layer = window.__godsEyeView.dataManager.layers.get('transit').module;
|
||
const state = layer._transitStateForTest();
|
||
return {
|
||
vehicles: state._vehicles.size,
|
||
primitives:
|
||
(state._markers?.length || 0) + (state._animatedMarkers?.length || 0),
|
||
visible: state._visible.size,
|
||
moving: state._moving.size,
|
||
detect: state._detectCache,
|
||
timers: [
|
||
state._visibilityTimer,
|
||
state._maintenanceTimer,
|
||
state._floorTimer,
|
||
state._cameraDebounceTimer,
|
||
],
|
||
trailPending: layer._transitPartsForTest().trails.requestDiagnostics()
|
||
.pending,
|
||
shown: state._markers ? state._markers.show : null,
|
||
inFlight: state._inFlight.size,
|
||
activeFeeds: state._activeFeeds.size,
|
||
holds: window.__godsEyeView.getRenderGovernorDiagnostics().holds,
|
||
};
|
||
});
|
||
check(
|
||
'disable leaves no vehicles and no point primitives',
|
||
teardown.vehicles === 0 &&
|
||
teardown.primitives === 0 &&
|
||
teardown.visible === 0 &&
|
||
teardown.moving === 0 &&
|
||
teardown.detect === null &&
|
||
teardown.timers.every((t) => t === null) &&
|
||
!teardown.trailPending &&
|
||
!teardown.holds.includes('transit'),
|
||
JSON.stringify(teardown),
|
||
);
|
||
await shot(
|
||
`${TAG}-disabled.jpg`,
|
||
'Transit off — same view, nothing rendered',
|
||
);
|
||
await wait(25_000);
|
||
check(
|
||
'no further transit requests after disable',
|
||
transitRequests.length === afterDisable,
|
||
`${transitRequests.length - afterDisable} request(s) in 25 s`,
|
||
);
|
||
|
||
// ---- Context modes -------------------------------------------------------
|
||
console.log('\n== context modes ==');
|
||
// The CONTEXT panel starts collapsed; a user expands it before choosing a mode.
|
||
await page.$eval(
|
||
'[data-collapse-target="global-context-panel"]',
|
||
(button) => {
|
||
if (button.getAttribute('aria-expanded') !== 'true') button.click();
|
||
},
|
||
);
|
||
await wait(1_200);
|
||
check(
|
||
'the CONTEXT panel opens from its own header button',
|
||
await page.$eval(
|
||
'[data-collapse-target="global-context-panel"]',
|
||
(b) => b.getAttribute('aria-expanded') === 'true',
|
||
),
|
||
);
|
||
|
||
const isOn = () =>
|
||
page.evaluate(() => window.__godsEyeView.dataManager.isEnabled('transit'));
|
||
await page.evaluate(() =>
|
||
window.__godsEyeView.dataManager.setEnabled('transit', true, {
|
||
origin: 'user',
|
||
}),
|
||
);
|
||
await wait(6_000);
|
||
check('transit is on before entering a Context mode', await isOn());
|
||
|
||
// CONTACTS
|
||
await page.click('#global-context-flights-btn');
|
||
await wait(18_000);
|
||
const inContacts = {
|
||
transit: await isOn(),
|
||
vehicles: await page.evaluate(
|
||
() =>
|
||
window.__godsEyeView.dataManager.layers.get('transit').module.getStats()
|
||
.count,
|
||
),
|
||
};
|
||
// Contacts is not a passive overlay: it puts every non-dependency layer away
|
||
// for the duration, Transit included, and gives it back on exit.
|
||
check(
|
||
'Contacts puts Transit away for the duration',
|
||
inContacts.transit === false && inContacts.vehicles === 0,
|
||
JSON.stringify(inContacts),
|
||
);
|
||
await page.click('#global-context-flights-btn');
|
||
await wait(12_000);
|
||
check('leaving Contacts restores Transit', await isOn());
|
||
|
||
// SPACE MISSIONS
|
||
await page.evaluate(() =>
|
||
window.__godsEyeView.dataManager.setEnabled('transit', false, {
|
||
origin: 'user',
|
||
}),
|
||
);
|
||
await wait(2_000);
|
||
await page.click('#global-context-missions-btn');
|
||
await wait(20_000);
|
||
const blocked = await page.evaluate(async () => {
|
||
const settled = await window.__godsEyeView.dataManager.setEnabled(
|
||
'transit',
|
||
true,
|
||
{ origin: 'user' },
|
||
);
|
||
return {
|
||
settled,
|
||
enabled: window.__godsEyeView.dataManager.isEnabled('transit'),
|
||
};
|
||
});
|
||
check(
|
||
'Space Missions refuses to enable Transit while it isolates replay data',
|
||
blocked.enabled === false,
|
||
JSON.stringify(blocked),
|
||
);
|
||
await page.click('#global-context-missions-btn');
|
||
await wait(12_000);
|
||
check(
|
||
'leaving Space Missions leaves Transit off, as it was on entry',
|
||
(await isOn()) === false,
|
||
);
|
||
|
||
check(
|
||
'no uncaught page errors',
|
||
pageErrors.length === 0,
|
||
pageErrors.join(' | '),
|
||
);
|
||
console.log(
|
||
`\ntotal /api/transit requests this run: ${transitRequests.length}`,
|
||
);
|
||
check(
|
||
'scripted selections never raise a Cesium render error',
|
||
(await page.evaluate(() => window.__transitRenderErrors || [])).length ===
|
||
0,
|
||
JSON.stringify(
|
||
await page.evaluate(() => window.__transitRenderErrors || []),
|
||
),
|
||
);
|
||
} catch (error) {
|
||
check(
|
||
'browser harness completed within its bounded waits',
|
||
false,
|
||
error.stack || error.message,
|
||
);
|
||
} finally {
|
||
await browser.close();
|
||
}
|
||
console.log(
|
||
failures
|
||
? `HARNESS: FAIL (${failures} failed, ${unexercised} unexercised)`
|
||
: `HARNESS: PASS (${unexercised} unexercised)`,
|
||
);
|
||
process.exit(failures || unexercised ? 1 : 0);
|