Cover drone airspace, OSM places, sea marks and ski pistes on both engines in the strict acceptance

This commit is contained in:
German Khokhlov committed 2026-11-05 01:27:43 +04:00
1 parent e1d3a588fc
commit 8bea1419f6
2 files changed
+301

No files matched your search

+5
View File
@@ -297,6 +297,11 @@ const runs = [
], ],
['local-adsb-engine-switch', 'local-adsb-engine-switch', ['local-adsb']], ['local-adsb-engine-switch', 'local-adsb-engine-switch', ['local-adsb']],
['ghostmaps-switch', 'ghostmaps-switch', ['s2-ghostmaps']], ['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',
'weather-engine-switch', 'weather-engine-switch',
+296
View File
@@ -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, []);
});