mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
331 lines
11 KiB
JavaScript
331 lines
11 KiB
JavaScript
/** Camera-jump regression against an existing server; never starts a server. */
|
|
import puppeteer from 'puppeteer';
|
|
import { chromeExecutablePath, captureQaScreenshot } from './qa-chrome.mjs';
|
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
import { reduceTrailPixels } from '../src/layers/transit/qaMetrics.js';
|
|
|
|
const base = process.env.QA_BASE_URL || 'http://localhost:4305';
|
|
const shots = process.env.QA_SHOTS || 'qa-shots/transit-recovery';
|
|
await mkdir(shots, { recursive: true });
|
|
const browser = await puppeteer.launch({
|
|
headless: false,
|
|
executablePath: chromeExecutablePath(),
|
|
args: [
|
|
'--disable-background-timer-throttling',
|
|
'--disable-renderer-backgrounding',
|
|
],
|
|
});
|
|
try {
|
|
const page = await browser.newPage();
|
|
await page.setViewport({ width: 1920, height: 1080 });
|
|
const errors = [];
|
|
page.on('pageerror', (error) => errors.push(error.message));
|
|
await page.goto(
|
|
`${base}/?welcome=0#lat=30.271&lon=-97.7431&alt=600&heading=0&pitch=-60`,
|
|
);
|
|
await page.waitForFunction(() =>
|
|
window.__godsEyeView?.dataManager?.layers.has('transit'),
|
|
);
|
|
await page.evaluate(async () => {
|
|
const app = window.__godsEyeView;
|
|
await app.dataManager.setEnabled('transit', true, { source: 'qa' });
|
|
});
|
|
await page.waitForFunction(
|
|
() => {
|
|
const scene = window.__godsEyeView.viewer.scene;
|
|
return scene.globe.tilesLoaded && scene.globe.show;
|
|
},
|
|
{ timeout: 60000 },
|
|
);
|
|
await page.waitForFunction(
|
|
() => {
|
|
const cover = document.getElementById('loading-screen');
|
|
return !cover || cover.classList.contains('hidden');
|
|
},
|
|
{ timeout: 60000 },
|
|
);
|
|
await page.evaluate(() => {
|
|
const app = window.__godsEyeView,
|
|
camera = app.viewer.camera;
|
|
const C = camera.positionCartographic.constructor,
|
|
lat = 30.267 + 90 / 111320;
|
|
const floor =
|
|
app.viewer.scene.globe.getHeight(C.fromDegrees(-97.7431, lat)) || 0;
|
|
camera.setView({
|
|
destination: C.toCartesian(
|
|
C.fromDegrees(
|
|
-97.7431,
|
|
lat - 600 / Math.tan(Math.PI / 3) / 111320,
|
|
floor + 600,
|
|
),
|
|
),
|
|
orientation: { heading: 0, pitch: -Math.PI / 3, roll: 0 },
|
|
});
|
|
});
|
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
await page.waitForFunction(
|
|
() => window.__godsEyeView.viewer.scene.globe.tilesLoaded,
|
|
{ timeout: 60000 },
|
|
);
|
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
const result = await page.evaluate(async () => {
|
|
const app = window.__godsEyeView,
|
|
scene = app.viewer.scene,
|
|
camera = app.viewer.camera;
|
|
const layer = app.dataManager.layers.get('transit').module;
|
|
const state = layer._transitStateForTest(),
|
|
parts = layer._transitPartsForTest();
|
|
const { getRegisteredTransitFeed } =
|
|
await import('/src/data/transitFeeds.js');
|
|
const { setRate } = await import('/src/data/contactPlayback.js');
|
|
const C = camera.positionCartographic.constructor;
|
|
const feed = {
|
|
...getRegisteredTransitFeed('capmetro-austin'),
|
|
id: 'qa-camera-recovery',
|
|
};
|
|
const aim = (lat, height = 600) =>
|
|
camera.setView({
|
|
destination: C.toCartesian(
|
|
C.fromDegrees(
|
|
-97.7431,
|
|
lat - 600 / Math.tan(Math.PI / 3) / 111320,
|
|
height,
|
|
),
|
|
),
|
|
orientation: { heading: 0, pitch: -Math.PI / 3, roll: 0 },
|
|
});
|
|
parts.selection.clearSelection();
|
|
parts.ingestion.abortAllInFlight();
|
|
state._activeFeeds.clear();
|
|
for (const key of state._vehicles.keys())
|
|
parts.ingestion.removeVehicle(key);
|
|
// Model an outstanding floor lookup while reports arrive off screen.
|
|
// Only acquisition is controlled; selection, visibility, paths and rendering
|
|
// use their real owners. Release to the loaded terrain at the camera jump.
|
|
const requestHeight = parts.height.requestHeight;
|
|
parts.height.requestHeight = (lat, lon) => ({
|
|
cell: `${lat},${lon}`,
|
|
height: null,
|
|
prior: null,
|
|
});
|
|
aim(30.28);
|
|
const now = Date.now();
|
|
for (let i = 0; i < 9; i++) {
|
|
const timestamp = now - 135000 + i * 15000;
|
|
parts.ingestion.applySnapshot(
|
|
feed,
|
|
{
|
|
fetchedAt: timestamp,
|
|
vehicles: [
|
|
{
|
|
id: 'moving-bus',
|
|
routeId: '1',
|
|
tripId: 'qa',
|
|
lat: 30.267 + (i * 90) / 111320,
|
|
lon: -97.7431,
|
|
timestamp: timestamp / 1000,
|
|
timestampSource: 'vehicle',
|
|
bearing: 0,
|
|
},
|
|
],
|
|
},
|
|
{ stale: false },
|
|
);
|
|
}
|
|
const entry = state._vehicles.get(`${feed.id}:moving-bus`);
|
|
entry.qaFixture = true; // Protect this disclosed fixture from live feed eviction.
|
|
parts.rendering.sampleIdle(entry);
|
|
const before = {
|
|
shown: entry.marker.show,
|
|
heightPending: entry.heightPending,
|
|
phase: entry.sample.phase,
|
|
fixes: entry.track.count,
|
|
};
|
|
clearTimeout(state._floorTimer);
|
|
state._floorTimer = null;
|
|
const started = performance.now();
|
|
parts.selection.selectVehicle(entry.key);
|
|
const preparedAtSelection = !!parts.trails.diagnostics()?.body;
|
|
parts.height.requestHeight = requestHeight;
|
|
const floor =
|
|
scene.globe.getHeight(
|
|
C.fromDegrees(entry.sample.lon, entry.sample.lat),
|
|
) || 0;
|
|
aim(entry.sample.lat, floor + 600);
|
|
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
let trail;
|
|
while (performance.now() - started < 1400) {
|
|
trail = parts.trails.diagnostics();
|
|
if (entry.marker.show && trail?.body?.show && trail.body.ready) break;
|
|
scene.requestRender();
|
|
await wait(25);
|
|
}
|
|
const status = {
|
|
before,
|
|
preparedAtSelection,
|
|
markerShow: entry.marker.show,
|
|
body: !!trail?.body,
|
|
bodyShow: trail?.body?.show === true,
|
|
visibility: layer
|
|
._transitVisibilityForTest()
|
|
.find((e) => e.key === entry.key),
|
|
recoveryMs: performance.now() - started,
|
|
};
|
|
if (!entry.marker.show || !trail?.body?.show || !trail.body.ready)
|
|
return {
|
|
...status,
|
|
reason: 'marker or body did not recover before pixel deadline',
|
|
};
|
|
setRate(entry.track, 0, {
|
|
wallNowMs: Date.now(),
|
|
monoNowMs: performance.now(),
|
|
});
|
|
parts.rendering.sampleIdle(entry);
|
|
parts.rendering.schedulePlayback(entry);
|
|
const V = entry.marker.position.constructor;
|
|
const points = entry.trailSegments
|
|
.filter((s) => s.toT <= entry.sample.displayT)
|
|
.flatMap((s) => s.positions);
|
|
const lengths = [0];
|
|
for (let i = 1; i < points.length; i++)
|
|
lengths.push(lengths.at(-1) + V.distance(points[i - 1], points[i]));
|
|
const canvas = scene.canvas,
|
|
gl = scene.context._gl,
|
|
scale = canvas.width / canvas.clientWidth;
|
|
const mul = (m, v) =>
|
|
[0, 1, 2, 3].map(
|
|
(i) =>
|
|
m[i] * v[0] + m[i + 4] * v[1] + m[i + 8] * v[2] + m[i + 12] * v[3],
|
|
);
|
|
const project = (p) => {
|
|
const q = mul(
|
|
camera.frustum.projectionMatrix,
|
|
mul(camera.viewMatrix, [p.x, p.y, p.z, 1]),
|
|
);
|
|
return {
|
|
x: ((q[0] / q[3]) * 0.5 + 0.5) * canvas.clientWidth,
|
|
y: (0.5 - (q[1] / q[3]) * 0.5) * canvas.clientHeight,
|
|
inFront: q[3] > 0,
|
|
};
|
|
};
|
|
const samples = Array.from({ length: 8 }, (_, i) => {
|
|
const d = (lengths.at(-1) * (i + 1)) / 9;
|
|
let j = 1;
|
|
while (j < lengths.length - 1 && lengths[j] < d) j++;
|
|
const p = V.lerp(
|
|
points[j - 1],
|
|
points[j],
|
|
(d - lengths[j - 1]) / Math.max(0.001, lengths[j] - lengths[j - 1]),
|
|
new V(),
|
|
);
|
|
const cart = C.fromCartesian(p);
|
|
cart.height = scene.globe.getHeight(cart) || 0;
|
|
return {
|
|
...project(C.toCartesian(cart)),
|
|
radius: (Math.SQRT2 * Math.ceil(3 * scale)) / scale,
|
|
};
|
|
});
|
|
const sprite = {
|
|
...project(entry.marker.position),
|
|
radius: Math.hypot(entry.marker.width, entry.marker.height) / 2 + 4,
|
|
};
|
|
const read = () =>
|
|
samples.map(({ x, y }) => {
|
|
const r = Math.ceil(3 * scale),
|
|
px = Math.round(x * scale),
|
|
py = Math.round(canvas.height - 1 - y * scale);
|
|
if (
|
|
px < r ||
|
|
py < r ||
|
|
px + r >= canvas.width ||
|
|
py + r >= canvas.height
|
|
)
|
|
return null;
|
|
const data = new Uint8Array((2 * r + 1) ** 2 * 4);
|
|
gl.readPixels(
|
|
px - r,
|
|
py - r,
|
|
2 * r + 1,
|
|
2 * r + 1,
|
|
gl.RGBA,
|
|
gl.UNSIGNED_BYTE,
|
|
data,
|
|
);
|
|
return Array.from(data);
|
|
});
|
|
const frame = () =>
|
|
new Promise((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
remove();
|
|
reject(new Error('No recovery frame'));
|
|
}, 500);
|
|
const remove = scene.postRender.addEventListener(() => {
|
|
remove();
|
|
clearTimeout(timer);
|
|
resolve(read());
|
|
});
|
|
scene.requestRender();
|
|
});
|
|
let visible = true;
|
|
const remove = scene.preRender.addEventListener(() => {
|
|
trail.body.show = visible;
|
|
if (trail.headPrimitive)
|
|
trail.headPrimitive.show = visible && trail.head.show;
|
|
});
|
|
const captures = [];
|
|
try {
|
|
for (const show of [false, true, false, true]) {
|
|
visible = show;
|
|
await frame();
|
|
captures.push(await frame());
|
|
}
|
|
} finally {
|
|
remove();
|
|
parts.trails.update();
|
|
}
|
|
const [off, on, offAgain, onAgain] = captures;
|
|
return {
|
|
...status,
|
|
samples,
|
|
sprite,
|
|
off,
|
|
on,
|
|
offAgain,
|
|
onAgain,
|
|
tilesReady:
|
|
scene.globe.tilesLoaded &&
|
|
parts.trails.diagnostics().body === trail.body,
|
|
elapsedMs: performance.now() - started,
|
|
attributes: Array.from({ length: 4 }, (_, i) => {
|
|
const a = trail.body.getGeometryInstanceAttributes(`${i}:1`);
|
|
return { show: Array.from(a.show), color: Array.from(a.color) };
|
|
}),
|
|
};
|
|
});
|
|
const pixels = reduceTrailPixels(result.on, result.off, result);
|
|
result.present = pixels.present;
|
|
result.pixelPass = pixels.pass;
|
|
result.pixelDetails = pixels;
|
|
result.errors = errors;
|
|
await captureQaScreenshot(page, { path: `${shots}/recovery.png` });
|
|
await writeFile(`${shots}/recovery.json`, JSON.stringify(result, null, 2));
|
|
for (const key of ['on', 'off', 'onAgain', 'offAgain']) delete result[key];
|
|
const pass =
|
|
result.before?.shown === false &&
|
|
result.before?.phase === 'playing' &&
|
|
result.preparedAtSelection &&
|
|
result.markerShow &&
|
|
result.body &&
|
|
result.bodyShow &&
|
|
result.elapsedMs <= 2000 &&
|
|
pixels.pass &&
|
|
!errors.length;
|
|
console.log(
|
|
`${pass ? 'PASS' : 'FAIL'} select moving bus, setView, marker + body + >=6/8 trail pixels within 2 s`,
|
|
);
|
|
console.log(JSON.stringify(result));
|
|
process.exitCode = pass ? 0 : 1;
|
|
} finally {
|
|
await browser.close();
|
|
}
|