From 8bea1419f606ddadee01a543bec97309ffc686a9 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 5 Nov 2026 01:27:42 +0400 Subject: [PATCH] Cover drone airspace, OSM places, sea marks and ski pistes on both engines in the strict acceptance --- scripts/qa-all-layer-switches.mjs | 5 + scripts/qa-open-layers-switch.mjs | 296 ++++++++++++++++++++++++++++++ 2 files changed, 301 insertions(+) create mode 100644 scripts/qa-open-layers-switch.mjs diff --git a/scripts/qa-all-layer-switches.mjs b/scripts/qa-all-layer-switches.mjs index e619b8c18..6d591d976 100644 --- a/scripts/qa-all-layer-switches.mjs +++ b/scripts/qa-all-layer-switches.mjs @@ -297,6 +297,11 @@ const runs = [ ], ['local-adsb-engine-switch', 'local-adsb-engine-switch', ['local-adsb']], ['ghostmaps-switch', 'ghostmaps-switch', ['s2-ghostmaps']], + [ + 'open-layers-switch', + 'open-layers-switch', + ['drone-airspace', 'osm-places', 'sea-marks', 'ski-pistes'], + ], [ 'weather-engine-switch', 'weather-engine-switch', diff --git a/scripts/qa-open-layers-switch.mjs b/scripts/qa-open-layers-switch.mjs new file mode 100644 index 000000000..335ca173c --- /dev/null +++ b/scripts/qa-open-layers-switch.mjs @@ -0,0 +1,296 @@ +import sharp from 'sharp'; +import assert from 'node:assert/strict'; +import { createStrict, localPreview } 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({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { + const p = await b.newPage(); + await p.setViewport({ width: 1024, height: 768 }); + const errors = []; + p.on('pageerror', (e) => { + errors.push(e.message); + console.log('error', e.message.split('?')[0]); + }); + 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 ready = async (kind) => { + await p.waitForFunction( + (k) => + window.__godsEyeView?.engine?.kind === k && + !document.querySelector('#globe-engine')?.disabled && + !document.querySelector('.engine-switch-frame') && + document.querySelector('#loading-screen.hidden'), + { timeout: 90000 }, + kind, + ); + await p.evaluate( + () => window.__godsEyeView.styleManager.initialRestorePromise, + ); + }; + 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 new Promise((resolve) => setTimeout(resolve, 500)); + 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 ready('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.pick(p, kind); + await ready(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; + const drawn = + d.engine.kind === 'navara' + ? (d.engine.movers.list().find((set) => set.id === 'osm-places') + ?.count ?? 0) + : (d.viewer.dataSources.getByName('osm-places')[0]?.entities.values + .length ?? 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, []); +});