Files
gods-eye-view/scripts/qa-strict-session.mjs
T
German Khokhlov 371d848eec Delete dead render code and consolidate QA helpers
Remove code that nothing in the app, the runners or CI uses:
- the one-off migration compare, look and bench scripts and the vector
  ground prototype they measured, with the bench tables in the docs;
- the unused Navara-named shared render modules (polygons, ground picks,
  mover bank, ground ring);
- the Cesium-shaped aircraft and vessel test renderers, and the trail,
  engineFromViewer and horizon-occluder helpers only they kept alive.
  Their tests now run on the production displays over the fake engine;
- Navara's unread diagnostics, fallback ports, dead exports and
  parameters, stale effect-chain anchors and the unread capability flags.

The QA scripts share one keyless guard and readiness wait, the session
harness reuses the strict harness's browser flags and block list, and the
harness honours QA_ANGLE and QA_RESERVED_PORTS. The bikeshare fixture
keeps only what its scenario reads. The docs name the engine picker where
it is (VISUAL PRESETS) and drop dated run results.

No behaviour changes on either engine. Navara layer renderer lines fall
from 189 to 0 and viewer lines from 891 to 849.
2026-11-08 17:05:38 +04:00

399 lines
13 KiB
JavaScript

// Shared pieces of the strict contract scenarios (qa-strict-*.mjs): a fixed
// layer session, its identity checks, the first-frame probe and the WebGL
// object counters. Everything runs on the existing test hooks
// (window.__godsEyeView); nothing here changes the application.
import assert from 'node:assert/strict';
import { waitForEngine } from './qa-strict-harness.mjs';
/** Austin, 12 km up, OpenStreetMap (answered by the tile disk cache). */
export const SESSION_HASH =
'v=2&lat=30.27&lon=-97.74&alt=12000&heading=0&pitch=-90&map=osm&l=';
/**
* In-page fixtures for the session layers: one Austin earthquake, `flights`
* aircraft in a grid around Austin, `military` aircraft, no vessels.
* The counters on window.__qaReads show every source read.
*/
export async function installSessionFixtures(
page,
{ flights = 1, military = 1 } = {},
) {
await page.evaluateOnNewDocument(
(flights, military) => {
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
window.__qaReads = { quakes: 0, flights: 0, military: 0 };
const fetch = window.fetch.bind(window);
const side = Math.ceil(Math.sqrt(flights));
window.fetch = (input, options) => {
const url = new URL(input?.url ?? input, document.baseURI);
const now = Math.floor(Date.now() / 1000);
if (url.hostname === 'earthquake.usgs.gov') {
window.__qaReads.quakes++;
return Promise.resolve(
Response.json({
type: 'FeatureCollection',
features: [
{
type: 'Feature',
id: 'qa-austin',
properties: { mag: 4.1, time: Date.now(), place: 'Austin' },
geometry: { type: 'Point', coordinates: [-97.74, 30.27, 8] },
},
],
}),
);
}
if (url.pathname === '/api/flights') {
window.__qaReads.flights++;
const states = [];
for (let i = 0; i < flights; i++)
states.push([
(0xa00000 + i).toString(16),
'QA' + i,
'QA',
now,
now,
-99.5 + ((i % side) / side) * 3.5,
28.5 + (Math.floor(i / side) / side) * 3.5,
3000 + (i % 40) * 250,
false,
180 + (i % 50),
(i * 37) % 360,
0,
null,
3000 + (i % 40) * 250,
null,
false,
0,
]);
return Promise.resolve(Response.json({ time: now, states }));
}
if (url.pathname === '/api/military') {
window.__qaReads.military++;
return Promise.resolve(
Response.json({
now: Date.now(),
ac: Array.from({ length: military }, (_, i) => ({
hex: (0xae0000 + i).toString(16),
flight: 'QAMIL' + i,
lon: -97.9 + i * 0.01,
lat: 30.1 + i * 0.01,
alt_geom: 20000,
alt_baro: 20000,
gs: 300,
track: 90,
seen: 0,
seen_pos: 0,
dbFlags: 1,
type: 'adsb_icao',
})),
}),
);
}
if (url.pathname.startsWith('/api/flights/'))
return Promise.resolve(Response.json({ path: [] }));
if (url.pathname === '/api/vessels')
return Promise.resolve(Response.json({ status: 'live', rows: [] }));
return fetch(input, options);
};
},
flights,
military,
);
}
/**
* Open the app on `engine` with the session hash (and `search`, more query
* parameters such as `&parkedMs=3000`) and wait until it runs.
*/
export async function openApp(
page,
origin,
engine,
hash = SESSION_HASH,
search = '',
) {
const url = new URL(origin);
url.search = `?engine=${engine}&welcome=0${search}`;
url.hash = hash;
await page.goto(url.href, { waitUntil: 'domcontentloaded' });
await waitForEngine(page, engine);
await page.evaluate(() => {
window.__qaEngines = new Map([
[window.__godsEyeView.engine.kind, window.__godsEyeView.engine],
]);
});
}
/** Enable `layers` and remember the session's identity. */
export async function startSession(page, layers) {
await page.evaluate(async (layers) => {
const { dataManager } = window.__godsEyeView;
for (const id of layers)
await dataManager.setEnabled(id, true, { origin: 'user' });
window.__qaSession = {
manager: dataManager,
models: new Map(
[...dataManager.layers].map(([id, entry]) => [id, entry.module]),
),
enabled: [...dataManager.getEnabledLayerIds()].sort(),
};
}, layers);
}
/**
* The session as the page has it now against the one startSession saw:
* the same manager, every layer model, the same enabled layers.
*/
export function sessionState(page) {
return page.evaluate(() => {
const app = window.__godsEyeView;
const session = window.__qaSession;
if (!app) return { running: false };
const { dataManager } = app;
return {
running: true,
engine: app.engine.kind,
manager: dataManager === session.manager,
models:
session.models.size === dataManager.layers.size &&
[...session.models].every(
([id, model]) => dataManager.layers.get(id)?.module === model,
),
enabled:
JSON.stringify([...dataManager.getEnabledLayerIds()].sort()) ===
JSON.stringify(session.enabled),
address: new URL(location.href).searchParams.get('engine'),
picker: document.querySelector('#globe-engine')?.value ?? null,
history: history.length,
};
});
}
/** Assert the session is intact on `engine`. */
export async function assertSession(page, engine, message) {
const state = await sessionState(page);
assert.deepEqual(
{
running: state.running,
engine: state.engine,
manager: state.manager,
models: state.models,
enabled: state.enabled,
address: state.address,
picker: state.picker,
},
{
running: true,
engine,
manager: true,
models: true,
enabled: true,
address: engine,
picker: engine,
},
message,
);
return state;
}
/** Remember the running engine object under its kind (for warm switches). */
export function rememberEngine(page) {
return page.evaluate(() => {
const { engine } = window.__godsEyeView;
window.__qaEngines.set(engine.kind, engine);
return engine.kind;
});
}
/**
* Install (before navigation) the first-frame probe:
* window.__qaFirstFrame(kind) resolves with the performance.now() times of
* the first frame of the `kind` engine that is drawn into its connected
* canvas with a non-blank picture (postRender, the canvas read back in the
* same task: more than 1% of a 48x48 sample differs from its first pixel),
* and of the animation frame after it (presented).
*/
export async function installFirstFrameProbe(page) {
await page.evaluateOnNewDocument(() => {
window.__qaFirstFrame = (kind) =>
new Promise((resolve) => {
const probe = document.createElement('canvas');
probe.width = probe.height = 48;
const context = probe.getContext('2d', { willReadFrequently: true });
const nonBlank = (canvas) => {
try {
context.clearRect(0, 0, 48, 48);
context.drawImage(canvas, 0, 0, 48, 48);
const d = context.getImageData(0, 0, 48, 48).data;
let differing = 0;
for (let i = 4; i < d.length; i += 4)
if (
Math.abs(d[i] - d[0]) +
Math.abs(d[i + 1] - d[1]) +
Math.abs(d[i + 2] - d[2]) >
24
)
differing++;
return differing > 23;
} catch {
return false;
}
};
let attached = null;
let remove = () => {};
const attach = (engine) => {
attached = engine;
remove = engine.frames.postRender.addEventListener(() => {
if (!engine.host.canvas.isConnected) return;
if (!nonBlank(engine.host.canvas)) return;
const drawn = performance.now();
remove();
clearInterval(timer);
requestAnimationFrame(() =>
resolve({ drawn, presented: performance.now() }),
);
});
};
const known = window.__qaEngines?.get(kind);
if (known) attach(known);
// A cold engine: attach as soon as the page exposes it.
const timer = setInterval(() => {
const engine = window.__godsEyeView?.engine;
if (!attached && engine?.kind === kind) attach(engine);
}, 2);
});
});
}
/**
* Count live WebGL objects per context (before navigation): every
* create/delete pair of buffers, textures, programs, shaders,
* framebuffers, renderbuffers and vertex arrays (WebGL2 and the OES
* extension), per context, on window.__qaGl.
*/
export async function installWebGlCounters(page) {
await page.evaluateOnNewDocument(() => {
const KINDS = {
Buffer: 'buffers',
Texture: 'textures',
Program: 'programs',
Shader: 'shaders',
Framebuffer: 'framebuffers',
Renderbuffer: 'renderbuffers',
VertexArray: 'vaos',
};
const entries = new WeakMap();
const live = new WeakSet();
const contexts = [];
window.__qaGl = { contexts };
const fresh = () =>
Object.fromEntries(Object.values(KINDS).map((key) => [key, 0]));
const getContext = HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.getContext = function (type, ...args) {
const context = getContext.call(this, type, ...args);
if (context && /webgl/i.test(type) && !entries.has(context)) {
const entry = {
canvas: this,
context,
type,
live: fresh(),
created: fresh(),
};
entries.set(context, entry);
contexts.push(entry);
}
return context;
};
const wrap = (target, create, destroy, key, owner = (self) => self) => {
const make = target[create];
const drop = target[destroy];
if (typeof make !== 'function') return;
target[create] = function (...args) {
const object = make.apply(this, args);
const entry = entries.get(owner(this));
if (object && entry) {
entry.live[key]++;
entry.created[key]++;
live.add(object);
}
return object;
};
target[destroy] = function (object, ...args) {
const entry = entries.get(owner(this));
if (object && entry && live.has(object)) {
live.delete(object);
entry.live[key]--;
}
return drop.call(this, object, ...args);
};
};
for (const proto of [
WebGLRenderingContext.prototype,
globalThis.WebGL2RenderingContext?.prototype,
].filter(Boolean))
for (const [name, key] of Object.entries(KINDS))
wrap(proto, 'create' + name, 'delete' + name, key);
// WebGL1 vertex arrays come from an extension object.
const getExtension = WebGLRenderingContext.prototype.getExtension;
const vaoOwners = new WeakMap();
WebGLRenderingContext.prototype.getExtension = function (name, ...args) {
const extension = getExtension.call(this, name, ...args);
if (
extension &&
name === 'OES_vertex_array_object' &&
!vaoOwners.has(extension)
) {
vaoOwners.set(extension, this);
wrap(
extension,
'createVertexArrayOES',
'deleteVertexArrayOES',
'vaos',
(self) => vaoOwners.get(self),
);
}
return extension;
};
window.__qaGlCounts = () => {
const engines = window.__qaEngines ?? new Map();
return contexts.map((entry) => {
let owner = 'other';
for (const [kind, engine] of engines)
if (engine.host?.canvas === entry.canvas) owner = kind;
return {
owner,
connected: entry.canvas.isConnected,
lost: entry.context.isContextLost(),
total: Object.values(entry.live).reduce((a, b) => a + b, 0),
...entry.live,
};
});
};
});
}
/** Least-squares slope of y over x. */
export function slope(points) {
const n = points.length;
if (n < 2) return 0;
const mx = points.reduce((s, p) => s + p[0], 0) / n;
const my = points.reduce((s, p) => s + p[1], 0) / n;
let num = 0,
den = 0;
for (const [x, y] of points) {
num += (x - mx) * (y - my);
den += (x - mx) ** 2;
}
return den ? num / den : 0;
}
/** The q-quantile (nearest rank) of numbers. */
export function quantile(values, q) {
const sorted = [...values].sort((a, b) => a - b);
if (!sorted.length) return null;
return sorted[Math.max(0, Math.ceil(sorted.length * q) - 1)];
}