mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +00:00
- src/geo drops the ported debug argument checks and the tests that bitExact already covers; package boundaries accept a directory entry, so src/geo/ is listed once per group. - Cesium no longer carries movers and lines nobody draws with: they are optional engine capabilities. The fake engine and the Cesium camera use the shared cameraMath; unused contract tables, clock.ticked, the 'finest' surface detail and Navara click statistics are gone. - ALPR keeps one overlay core and one presentation model for both engines, bikeshare one station bookkeeping, the Navara ground effects one definition helper; image atlases, view centres, mover banks and colours are shared. - The legacy switch route, the clock offset and rate controls and other unused exports are removed. - The acceptance runs the Navara draw, geo import and scope checks; scenarios use the harness switch and page helpers; the main chunk check keeps the size budget only and runs once in CI.
284 lines
10 KiB
JavaScript
284 lines
10 KiB
JavaScript
// Contract: failure injection in both directions, cold and warm, and a slow
|
|
// first frame. Each case starts on a fresh page with a session (earthquakes
|
|
// and flights, one aircraft selected) and switches with real input on the
|
|
// picker.
|
|
//
|
|
// - A failed switch rolls back to the engine that was running, with the
|
|
// same session (manager, every layer model, enabled layers, selection),
|
|
// the address still naming it and no history entry; an ordinary pick
|
|
// afterwards succeeds and adds exactly one entry.
|
|
// cold: the incoming engine cannot get a WebGL context;
|
|
// warm: the parked incoming engine fails to resume.
|
|
// - A first frame slower than 10 s (animation frames held for 12 s) does not
|
|
// roll back: the switch completes, the cover goes after the 10 s wait, one
|
|
// history entry, the session intact, and the frame arrives.
|
|
//
|
|
// node scripts/qa-strict-failure-matrix.mjs http://127.0.0.1:4208
|
|
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
createStrict,
|
|
localPreview,
|
|
waitForEngine,
|
|
} from './qa-strict-harness.mjs';
|
|
import {
|
|
assertSession,
|
|
installFirstFrameProbe,
|
|
installSessionFixtures,
|
|
openApp,
|
|
rememberEngine,
|
|
SESSION_HASH,
|
|
sessionState,
|
|
startSession,
|
|
} from './qa-strict-session.mjs';
|
|
|
|
const base = localPreview(process.argv[2]);
|
|
const other = (kind) => (kind === 'navara' ? 'cesium' : 'navara');
|
|
const strict = createStrict({ origin: base.origin, layers: [] });
|
|
const results = [];
|
|
strict.extra.contracts = results;
|
|
await strict.run({}, async (b) => {
|
|
// A fresh page on `start` with the session; `warm` visits the other
|
|
// engine first so it is parked.
|
|
const open = async (start, warm) => {
|
|
const p = await b.newPage();
|
|
await p.setViewport({ width: 1280, height: 800 });
|
|
p.errors = [];
|
|
p.on('pageerror', (e) => p.errors.push(e.message));
|
|
await installSessionFixtures(p, { flights: 20 });
|
|
await installFirstFrameProbe(p);
|
|
// Flights on with the first fixture aircraft tracked (the address's
|
|
// layer options), so the session carries a selection.
|
|
await openApp(
|
|
p,
|
|
base.origin,
|
|
start,
|
|
SESSION_HASH.replace(/&l=$/, '&l=f&lo=f.e.1_f.t.a00000'),
|
|
);
|
|
await startSession(p, ['earthquakes', 'flights']);
|
|
await p.waitForFunction(
|
|
() =>
|
|
window.__godsEyeView.styleManager.getAircraftTrackingTarget()?.id ===
|
|
'a00000',
|
|
{ timeout: 30000 },
|
|
);
|
|
if (warm) {
|
|
await strict.pick(p, other(start));
|
|
await waitForEngine(p, other(start));
|
|
await rememberEngine(p);
|
|
await strict.pick(p, start);
|
|
await waitForEngine(p, start);
|
|
await rememberEngine(p);
|
|
// The warm-up's Navara→Cesium switch drops the tracked aircraft (a
|
|
// defect of its own, reported); track it
|
|
// again so this case tests the failure path with a selection.
|
|
await p.evaluate(() =>
|
|
window.__godsEyeView.dataManager.layers
|
|
.get('flights')
|
|
.module.trackById('a00000', { origin: 'user' }),
|
|
);
|
|
await p.waitForFunction(
|
|
() =>
|
|
window.__godsEyeView.styleManager.getAircraftTrackingTarget()?.id ===
|
|
'a00000',
|
|
{ timeout: 30000 },
|
|
);
|
|
}
|
|
return p;
|
|
};
|
|
const selection = (p) =>
|
|
p.evaluate(
|
|
() =>
|
|
window.__godsEyeView?.styleManager.getAircraftTrackingTarget()?.id ??
|
|
null,
|
|
);
|
|
|
|
for (const [start, temperature] of [
|
|
['navara', 'cold'],
|
|
['cesium', 'cold'],
|
|
['cesium', 'warm'],
|
|
['navara', 'warm'],
|
|
]) {
|
|
const target = other(start);
|
|
const label = `${temperature} ${start}→${target} failure`;
|
|
const p = await open(start, temperature === 'warm');
|
|
const before = await sessionState(p);
|
|
const selected = await selection(p);
|
|
if (temperature === 'cold')
|
|
await p.evaluate(() => {
|
|
const original = HTMLCanvasElement.prototype.getContext;
|
|
HTMLCanvasElement.prototype.getContext = function (type, ...args) {
|
|
// The running globe already holds its context; only a new
|
|
// globe asks for one.
|
|
if (/webgl/i.test(String(type))) {
|
|
window.__qaRefused = (window.__qaRefused ?? 0) + 1;
|
|
throw new Error('QA: no WebGL context for the new globe');
|
|
}
|
|
return original.call(this, type, ...args);
|
|
};
|
|
window.__qaRestore = () => {
|
|
HTMLCanvasElement.prototype.getContext = original;
|
|
};
|
|
});
|
|
else
|
|
await p.evaluate((target) => {
|
|
const parked = window.__qaEngines.get(target);
|
|
const request = parked.render.request;
|
|
parked.render.request = function (reason, ...args) {
|
|
if (reason === 'engine-resume' && !window.__qaRestored) {
|
|
window.__qaRefused = (window.__qaRefused ?? 0) + 1;
|
|
throw new Error('QA: the parked globe failed to resume');
|
|
}
|
|
return request.call(this, reason, ...args);
|
|
};
|
|
window.__qaRestore = () => {
|
|
window.__qaRestored = true;
|
|
};
|
|
}, target);
|
|
await strict.pick(p, target);
|
|
await waitForEngine(p, start);
|
|
const refused = await p.evaluate(() => window.__qaRefused ?? 0);
|
|
const rolled = await assertSession(p, start, label + ': rollback');
|
|
// The tracked aircraft, given up to 10 s to be re-acquired.
|
|
const kept = await p
|
|
.waitForFunction(
|
|
(id) =>
|
|
window.__godsEyeView?.styleManager.getAircraftTrackingTarget()?.id ===
|
|
id,
|
|
{ timeout: 10000, polling: 100 },
|
|
selected,
|
|
)
|
|
.then(() => selected)
|
|
.catch(() => selection(p));
|
|
await p.evaluate(() => window.__qaRestore());
|
|
// An ordinary pick afterwards.
|
|
await strict.pick(p, target);
|
|
await waitForEngine(p, target);
|
|
const retried = await assertSession(p, target, label + ': retry');
|
|
const afterRetry = await p
|
|
.waitForFunction(
|
|
(id) =>
|
|
window.__godsEyeView?.styleManager.getAircraftTrackingTarget()?.id ===
|
|
id,
|
|
{ timeout: 10000, polling: 100 },
|
|
selected,
|
|
)
|
|
.then(() => selected)
|
|
.catch(() => selection(p));
|
|
const row = {
|
|
case: label,
|
|
refused,
|
|
rollbackTarget: rolled.engine,
|
|
historyAfterFailure: rolled.history - before.history,
|
|
selectionKept: kept === selected,
|
|
selectionAfterRollback: kept,
|
|
historyAfterRetry: retried.history - before.history,
|
|
selectionAfterRetry: afterRetry,
|
|
errors: p.errors.length,
|
|
};
|
|
row.failed = [
|
|
refused > 0 || 'the injected failure did not happen',
|
|
row.rollbackTarget === start || 'rolled back elsewhere',
|
|
row.historyAfterFailure === 0 || 'the failed switch changed the history',
|
|
selected === 'a00000' || 'no tracked aircraft before the failure',
|
|
row.selectionKept || 'the tracked aircraft is lost after the rollback',
|
|
row.historyAfterRetry === 1 || 'the retry did not add one entry',
|
|
row.selectionAfterRetry === selected ||
|
|
'the tracked aircraft is lost after the retry',
|
|
].filter((v) => v !== true);
|
|
results.push(row);
|
|
console.log(row.failed.length ? 'FAIL' : 'PASS', JSON.stringify(row));
|
|
await p.close();
|
|
}
|
|
|
|
// Slow first frame: animation frames held for 12 s from the person's
|
|
// input (a stalled GPU or compositor), cold and warm.
|
|
for (const [start, temperature] of [
|
|
['cesium', 'cold'],
|
|
['navara', 'warm'],
|
|
]) {
|
|
const target = other(start);
|
|
const label = `${temperature} ${start}→${target} first frame after 12 s`;
|
|
const p = await open(start, temperature === 'warm');
|
|
const before = await sessionState(p);
|
|
await p.evaluate((target) => {
|
|
window.__qaSlow = { frame: window.__qaFirstFrame(target) };
|
|
window.__qaSlow.frame.then((t) => (window.__qaSlow.drawn = t));
|
|
const select = document.querySelector('#globe-engine');
|
|
// From the change on, every animation frame waits until 12 s after it.
|
|
select.addEventListener(
|
|
'change',
|
|
() => {
|
|
const until = performance.now() + 12000;
|
|
window.__qaSlow.at = performance.now();
|
|
const raf = window.requestAnimationFrame.bind(window);
|
|
window.requestAnimationFrame = (callback) =>
|
|
raf((time) => {
|
|
const wait = until - performance.now();
|
|
if (wait <= 0) return callback(time);
|
|
setTimeout(() => raf(callback), wait);
|
|
});
|
|
window.__qaSlow.release = () => {
|
|
window.requestAnimationFrame = raf;
|
|
};
|
|
},
|
|
{ once: true, capture: true },
|
|
);
|
|
new MutationObserver((_, observer) => {
|
|
if (
|
|
window.__qaSlow.at &&
|
|
!window.__qaSlow.coverGone &&
|
|
!document.querySelector('.engine-switch-frame') &&
|
|
!document.querySelector('#globe-engine')?.disabled
|
|
) {
|
|
window.__qaSlow.coverGone = performance.now();
|
|
observer.disconnect();
|
|
}
|
|
}).observe(document.body, {
|
|
childList: true,
|
|
subtree: true,
|
|
attributes: true,
|
|
attributeFilter: ['disabled'],
|
|
});
|
|
}, target);
|
|
await strict.pick(p, target, { settle: false });
|
|
await p.waitForFunction(
|
|
(target) =>
|
|
window.__godsEyeView?.engine?.kind === target &&
|
|
!document.querySelector('#globe-engine')?.disabled &&
|
|
!document.querySelector('.engine-switch-frame'),
|
|
{ polling: 200, timeout: 60000 },
|
|
target,
|
|
);
|
|
const done = await assertSession(p, target, label);
|
|
const timing = await p.evaluate(async () => {
|
|
const frame = await window.__qaSlow.frame;
|
|
return {
|
|
coverGoneMs: Math.round(window.__qaSlow.coverGone - window.__qaSlow.at),
|
|
firstFrameMs: Math.round(frame.presented - window.__qaSlow.at),
|
|
};
|
|
});
|
|
const row = {
|
|
case: label,
|
|
engine: done.engine,
|
|
history: done.history - before.history,
|
|
...timing,
|
|
errors: p.errors.length,
|
|
};
|
|
results.push(row);
|
|
assert.equal(row.history, 1, 'one history entry, no rollback');
|
|
assert.ok(
|
|
row.coverGoneMs >= 9500 && row.coverGoneMs < 12500,
|
|
'the cover goes after the 10 s first-frame wait ' + JSON.stringify(row),
|
|
);
|
|
assert.ok(row.firstFrameMs >= 12000, 'the frame arrives after the stall');
|
|
console.log('PASS', JSON.stringify(row));
|
|
await p.close();
|
|
}
|
|
assert.deepEqual(
|
|
results.filter((row) => row.failed?.length),
|
|
[],
|
|
'failure-injection cases that broke the contract',
|
|
);
|
|
});
|