mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 15:52:21 +00:00
The probe exposes settled() and the first-frame outcome; scripts/qa/ready.mjs waits for drawn frames with the camera at rest and for a pick at the click position, with a deadline and its reason in the error. The strict scenarios use these instead of fixed pauses and loading-screen polling, and the hidden-switch scenario checks how the loading screen was lifted instead of how long it took. Each switch scenario now records a draw ledger per engine and runs a contract monitor; both write to the scenario's evidence file only, with an empty allowlist until browser runs show what differs on purpose.
280 lines
9.3 KiB
JavaScript
280 lines
9.3 KiB
JavaScript
import { whenSettled } from './qa/ready.mjs';
|
|
import sharp from 'sharp';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
createStrict,
|
|
localPreview,
|
|
openPage,
|
|
waitForEngine,
|
|
} from './qa-strict-harness.mjs';
|
|
|
|
// Drone airspace, OSM places, sea marks and ski pistes on both engines, over
|
|
// Navara → Cesium → Navara: each draws over the view centre, and the switch
|
|
// keeps the same layer models with their data (no refetch, the places and
|
|
// the overlays still on).
|
|
//
|
|
// node scripts/qa-open-layers-switch.mjs http://127.0.0.1:4208
|
|
|
|
const base = localPreview(process.argv[2]);
|
|
base.search = '?engine=navara&welcome=0';
|
|
base.hash =
|
|
'v=2&lat=38.9000&lon=-77.0300&alt=30000&heading=0&pitch=-90&roll=0&map=sentinel2&l=';
|
|
|
|
// One prohibited zone over the view centre (P-56's place, enlarged).
|
|
const RING = [
|
|
[-77.06, 38.88],
|
|
[-77.0, 38.88],
|
|
[-77.0, 38.92],
|
|
[-77.06, 38.92],
|
|
[-77.06, 38.88],
|
|
];
|
|
const PLACES = [
|
|
{ id: 'node/1', name: 'QA Clinic', lat: 38.9, lon: -77.03, tags: {} },
|
|
{ id: 'node/2', name: 'QA Hospital', lat: 38.905, lon: -77.02, tags: {} },
|
|
{ id: 'node/3', name: '', lat: 38.895, lon: -77.04, tags: {} },
|
|
];
|
|
// The overlays' tiles: half-transparent tints, one per overlay, so the
|
|
// view centre takes the colour of whichever is on.
|
|
const tint = (r, g, b) =>
|
|
sharp({
|
|
create: {
|
|
width: 256,
|
|
height: 256,
|
|
channels: 4,
|
|
background: { r, g, b, alpha: 0.6 },
|
|
},
|
|
})
|
|
.png()
|
|
.toBuffer();
|
|
const TILES = {
|
|
'sea-marks': await tint(255, 0, 255),
|
|
'ski-pistes': await tint(0, 255, 0),
|
|
};
|
|
const tileRoute = (pattern, id) => ({
|
|
pattern,
|
|
respond: () => ({ status: 200, contentType: 'image/png', body: TILES[id] }),
|
|
});
|
|
const LAYERS = ['drone-airspace', 'osm-places', 'sea-marks', 'ski-pistes'];
|
|
const strict = createStrict({
|
|
origin: base.origin,
|
|
layers: LAYERS,
|
|
routes: [
|
|
tileRoute('https://tiles.openseamap.org/*', 'sea-marks'),
|
|
tileRoute('https://www.opensnowmap.org/*', 'ski-pistes'),
|
|
],
|
|
});
|
|
await strict.run({}, async (b) => {
|
|
const { page: p, errors } = await openPage(b);
|
|
await p.evaluateOnNewDocument((ring) => {
|
|
const fetch = window.fetch.bind(window);
|
|
window.qaZoneReads = 0;
|
|
window.fetch = (input, options) => {
|
|
const { pathname } = new URL(input.url ?? input, document.baseURI);
|
|
if (pathname === '/api/drone-airspace') {
|
|
window.qaZoneReads++;
|
|
return Promise.resolve(
|
|
Response.json({
|
|
rows: [
|
|
{
|
|
stableId: '310',
|
|
name: 'QA Zone',
|
|
typeCode: 'P',
|
|
type: 'Prohibited',
|
|
lowerAltitude: 'SFC',
|
|
upperAltitude: '18,000 ft MSL',
|
|
timesOfUse: 'CONTINUOUS',
|
|
agency: null,
|
|
city: 'WASHINGTON',
|
|
state: 'DC',
|
|
polygons: [[ring]],
|
|
},
|
|
],
|
|
stale: false,
|
|
fetchedAt: Date.now(),
|
|
}),
|
|
);
|
|
}
|
|
// OSM Places asks whether an Overpass is configured.
|
|
if (pathname === '/api/overpass/status')
|
|
return Promise.resolve(Response.json({ configured: true }));
|
|
return fetch(input, options);
|
|
};
|
|
}, RING);
|
|
await p.goto(base.href);
|
|
const setEnabled = (id, on) =>
|
|
p.evaluate(
|
|
(layerId, show) =>
|
|
window.__godsEyeView.dataManager.setEnabled(layerId, show, {
|
|
origin: 'user',
|
|
}),
|
|
id,
|
|
on,
|
|
);
|
|
// The view centre once the globe has settled and drawn a frame.
|
|
const sample = async () => {
|
|
await p.waitForFunction(
|
|
() => {
|
|
const d = window.__godsEyeView;
|
|
if (!d?.engine) return false;
|
|
d.engine.render.request('qa-open-settle');
|
|
if (!d.engine.surface.tilesLoaded()) {
|
|
window.__qaSettledSince = null;
|
|
return false;
|
|
}
|
|
window.__qaSettledSince ??= performance.now();
|
|
return performance.now() - window.__qaSettledSince > 1200;
|
|
},
|
|
{ polling: 100, timeout: 30000 },
|
|
);
|
|
await p.evaluate(() => {
|
|
window.__qaSettledSince = null;
|
|
return new Promise((resolve) => {
|
|
const { engine } = window.__godsEyeView;
|
|
const remove = engine.frames.postRender.addEventListener(() => {
|
|
remove();
|
|
requestAnimationFrame(() => resolve());
|
|
});
|
|
engine.render.request('qa-open');
|
|
});
|
|
});
|
|
// stats() reads the whole input, so the patch is cut out first.
|
|
const patch = await sharp(await p.screenshot())
|
|
.extract({ left: 492, top: 369, width: 40, height: 30 })
|
|
.removeAlpha()
|
|
.png()
|
|
.toBuffer();
|
|
const { channels } = await sharp(patch).stats();
|
|
return channels.map((channel) => channel.mean);
|
|
};
|
|
// Until the layer shows over `off` by `test`, for at most 20 s (the
|
|
// engines draw clamped polygons and imagery a few frames after their data).
|
|
const shows = async (off, test) => {
|
|
const deadline = Date.now() + 20000;
|
|
let on = await sample();
|
|
while (!test(on, off) && Date.now() < deadline) {
|
|
await whenSettled(p);
|
|
on = await sample();
|
|
}
|
|
return on;
|
|
};
|
|
const redder = (on, off) => on[0] - on[2] > off[0] - off[2] + 5;
|
|
const magenta = (on, off) =>
|
|
on[0] + on[2] - 2 * on[1] > off[0] + off[2] - 2 * off[1] + 10;
|
|
const greener = (on, off) =>
|
|
2 * on[1] - on[0] - on[2] > 2 * off[1] - off[0] - off[2] + 10;
|
|
|
|
await waitForEngine(p, 'navara');
|
|
await p.evaluate(() => {
|
|
const { dataManager } = window.__godsEyeView;
|
|
window.qaModels = Object.fromEntries(
|
|
['drone-airspace', 'osm-places', 'sea-marks', 'ski-pistes'].map((id) => [
|
|
id,
|
|
dataManager.layers.get(id).module,
|
|
]),
|
|
);
|
|
});
|
|
for (const [leg, kind] of ['navara', 'cesium', 'navara'].entries()) {
|
|
const readsBeforeSwitch = await p.evaluate(() => window.qaZoneReads);
|
|
if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) {
|
|
await strict.switchTo(p, kind);
|
|
const same = await p.evaluate(() =>
|
|
Object.entries(window.qaModels).every(
|
|
([id, model]) =>
|
|
window.__godsEyeView.dataManager.layers.get(id).module === model,
|
|
),
|
|
);
|
|
await strict.check(p, LAYERS, 'same layer models after the switch', same);
|
|
// The zone stayed on across the switch: the next globe draws the rows
|
|
// it already had, with no new request.
|
|
const kept = await p.evaluate(() => ({
|
|
reads: window.qaZoneReads,
|
|
records: window.qaModels['drone-airspace'].getAnalystRecords().length,
|
|
}));
|
|
assert.deepEqual(
|
|
kept,
|
|
{ reads: readsBeforeSwitch, records: 1 },
|
|
`${kind}: the switch keeps the zone rows without a refetch`,
|
|
);
|
|
}
|
|
|
|
// Drone airspace: the zone tints the centre red; after a switch it is
|
|
// drawn from the rows already fetched.
|
|
await setEnabled('drone-airspace', false);
|
|
const zoneOff = await sample();
|
|
await setEnabled('drone-airspace', true);
|
|
if (leg === 0)
|
|
await p.evaluate(() => window.qaModels['drone-airspace'].update());
|
|
const zoneOn = await shows(zoneOff, redder);
|
|
assert.ok(redder(zoneOn, zoneOff), `${kind} must draw the zone`);
|
|
const zone = await p.evaluate(() => ({
|
|
reads: window.qaZoneReads,
|
|
records: window.qaModels['drone-airspace'].getAnalystRecords(),
|
|
}));
|
|
assert.equal(zone.records.length, 1);
|
|
await strict.passed(p, 'drone-airspace', 'zone drawn over the centre', {
|
|
off: zoneOff,
|
|
on: zoneOn,
|
|
reads: zone.reads,
|
|
});
|
|
console.log('PASS', kind, 'drone airspace zone');
|
|
|
|
// The zone stays on for the switch; the overlays sample without it.
|
|
// OSM places: the results stay with the model across the switch, and
|
|
// the engine draws a point for each.
|
|
if (leg === 0)
|
|
await p.evaluate((places) => {
|
|
window.qaModels['osm-places'].setResults(places, {
|
|
kind: 'clinic',
|
|
label: 'Clinics',
|
|
area: 'Washington',
|
|
});
|
|
}, PLACES);
|
|
await setEnabled('osm-places', true);
|
|
const places = await p.evaluate(() => {
|
|
const d = window.__godsEyeView;
|
|
// Both engines draw the places as one movers point set.
|
|
const drawn =
|
|
d.engine.movers.list().find((set) => set.id === 'osm-places')?.count ??
|
|
0;
|
|
return {
|
|
records: window.qaModels['osm-places'].getAnalystRecords().length,
|
|
drawn,
|
|
};
|
|
});
|
|
assert.deepEqual(places, { records: 3, drawn: 3 }, `${kind} places`);
|
|
await strict.passed(
|
|
p,
|
|
'osm-places',
|
|
'three places drawn as points',
|
|
places,
|
|
);
|
|
console.log('PASS', kind, 'OSM places', JSON.stringify(places));
|
|
await setEnabled('osm-places', false);
|
|
|
|
// The overlays tint the centre with their tile; each on alone.
|
|
await setEnabled('drone-airspace', false);
|
|
for (const [id, test] of [
|
|
['sea-marks', magenta],
|
|
['ski-pistes', greener],
|
|
]) {
|
|
const off = await sample();
|
|
await setEnabled(id, true);
|
|
const on = await shows(off, test);
|
|
assert.ok(test(on, off), `${kind} must drape ${id}`);
|
|
const stats = await p.evaluate(
|
|
(layerId) => window.qaModels[layerId].getStats(),
|
|
id,
|
|
);
|
|
assert.equal(stats.error, null);
|
|
await strict.passed(p, id, 'overlay tiles draped over the centre', {
|
|
off,
|
|
on,
|
|
});
|
|
console.log('PASS', kind, id);
|
|
await setEnabled(id, false);
|
|
}
|
|
await setEnabled('drone-airspace', true);
|
|
}
|
|
assert.deepEqual(errors, []);
|
|
});
|