mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 13:52:19 +00:00
Check that an engine switch draws what a cold start from the share link draws
qa-switch-equals-cold-start switches engines in both directions with each layer on deterministic fixtures, then opens the share link of the same state in a new page on the target engine, and compares the draw ledgers and the probe's view of the layer. It runs as 19 acceptance groups covering all 38 layers. State a layer keeps through a switch but the share link does not carry is declared as sessionState (the directions route, Radio's selected station and audio state, the wind reading, local ADS-B's and OSM Places' local toggles and results); a test checks each declaration against what the layer really retains, so a declaration cannot hide a regression.
This commit is contained in:
1 parent
59ff0b38a2
commit
7c5ebf4814
12 files changed
+2020
No files matched your search
@@ -286,6 +286,125 @@ const soakRuns = quick
|
||||
const longRun = quick ? '--swaps=100' : '--swaps=500';
|
||||
// label, script, layers, extra arguments, timeout in seconds.
|
||||
const runs = [
|
||||
[
|
||||
'switch-equals-cold-start--aircraft',
|
||||
'switch-equals-cold-start',
|
||||
['flights', 'military'],
|
||||
['--group=aircraft'],
|
||||
],
|
||||
[
|
||||
'switch-equals-cold-start--local',
|
||||
'switch-equals-cold-start',
|
||||
['local-adsb', 'local-dams', 'local-datacenters'],
|
||||
['--group=local'],
|
||||
],
|
||||
[
|
||||
'switch-equals-cold-start--ground',
|
||||
'switch-equals-cold-start',
|
||||
['ais-live-vessels', 'air-quality', 'gnss-integrity', 'fire-perimeters'],
|
||||
['--group=ground'],
|
||||
],
|
||||
[
|
||||
'switch-equals-cold-start--contacts',
|
||||
'switch-equals-cold-start',
|
||||
['military-awareness', 'military-installations'],
|
||||
['--group=contacts'],
|
||||
],
|
||||
[
|
||||
'switch-equals-cold-start--open',
|
||||
'switch-equals-cold-start',
|
||||
['drone-airspace', 'osm-places', 'sea-marks', 'ski-pistes'],
|
||||
['--group=open'],
|
||||
],
|
||||
[
|
||||
'switch-equals-cold-start--weather',
|
||||
'switch-equals-cold-start',
|
||||
[
|
||||
'weather-radar',
|
||||
'weather-satellite',
|
||||
'weather-lightning',
|
||||
'weather-cyclones',
|
||||
],
|
||||
['--group=weather'],
|
||||
],
|
||||
[
|
||||
'switch-equals-cold-start--wind',
|
||||
'switch-equals-cold-start',
|
||||
['wind'],
|
||||
['--group=wind'],
|
||||
],
|
||||
[
|
||||
'switch-equals-cold-start--ghostmaps',
|
||||
'switch-equals-cold-start',
|
||||
['s2-ghostmaps'],
|
||||
['--group=ghostmaps'],
|
||||
],
|
||||
[
|
||||
'switch-equals-cold-start--wildlife',
|
||||
'switch-equals-cold-start',
|
||||
['wildlife-sightings'],
|
||||
['--group=wildlife'],
|
||||
],
|
||||
[
|
||||
'switch-equals-cold-start--urban',
|
||||
'switch-equals-cold-start',
|
||||
['bikeshare', 'transit', 'earthquakes'],
|
||||
['--group=urban'],
|
||||
],
|
||||
[
|
||||
'switch-equals-cold-start--cameras',
|
||||
'switch-equals-cold-start',
|
||||
['cctv', 'alpr-cameras'],
|
||||
['--group=cameras'],
|
||||
],
|
||||
[
|
||||
'switch-equals-cold-start--radio',
|
||||
'switch-equals-cold-start',
|
||||
['radio'],
|
||||
['--group=radio'],
|
||||
],
|
||||
[
|
||||
'switch-equals-cold-start--space',
|
||||
'switch-equals-cold-start',
|
||||
['satellites', 'rocket-launches'],
|
||||
['--group=space'],
|
||||
],
|
||||
[
|
||||
'switch-equals-cold-start--traffic',
|
||||
'switch-equals-cold-start',
|
||||
['traffic'],
|
||||
['--group=traffic'],
|
||||
],
|
||||
[
|
||||
'switch-equals-cold-start--street',
|
||||
'switch-equals-cold-start',
|
||||
['street-level'],
|
||||
['--group=street'],
|
||||
],
|
||||
[
|
||||
'switch-equals-cold-start--imagery',
|
||||
'switch-equals-cold-start',
|
||||
['recent-imagery'],
|
||||
['--group=imagery'],
|
||||
],
|
||||
[
|
||||
'switch-equals-cold-start--nepal',
|
||||
'switch-equals-cold-start',
|
||||
['bhote-koshi-locator', 'bhote-koshi-2026'],
|
||||
['--group=nepal'],
|
||||
],
|
||||
[
|
||||
'switch-equals-cold-start--cables',
|
||||
'switch-equals-cold-start',
|
||||
['telegeography-submarine-cables', 'directions'],
|
||||
['--group=cables'],
|
||||
],
|
||||
[
|
||||
'switch-equals-cold-start--fires',
|
||||
'switch-equals-cold-start',
|
||||
['local-firms'],
|
||||
['--group=fires'],
|
||||
],
|
||||
['engine-switch', 'engine-switch', ['earthquakes']],
|
||||
['director-engine-switch', 'director-engine-switch', []],
|
||||
['layer-handoff', 'layer-handoff', ['flights']],
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
createStrict,
|
||||
localPreview,
|
||||
openPage,
|
||||
waitForEngine,
|
||||
} from './qa-strict-harness.mjs';
|
||||
import { whenSettled } from './qa/ready.mjs';
|
||||
import { drawLedger } from './qa/ledger.mjs';
|
||||
import {
|
||||
COLD_START_GROUPS,
|
||||
coldStartUrl,
|
||||
compareColdStart,
|
||||
probeSnapshot,
|
||||
sessionExclusion,
|
||||
} from './qa/coldStart.mjs';
|
||||
import { coldStartFixtures } from './qa/coldStartFixtures.mjs';
|
||||
import { stations } from './fixtures/layers/radio.mjs';
|
||||
|
||||
// Each registered group compares both directions, with a fresh storage context
|
||||
// for every cold page. The URL writer and restore path supply the reference.
|
||||
// node scripts/qa-switch-equals-cold-start.mjs http://127.0.0.1:4309
|
||||
// --group=ground --evidence=tmp_switch_cold_ground.json
|
||||
const base = localPreview(process.argv[2]);
|
||||
const group = process.argv.find((arg) => arg.startsWith('--group='))?.slice(8);
|
||||
assert.ok(
|
||||
COLD_START_GROUPS[group],
|
||||
`Supply --group=${Object.keys(COLD_START_GROUPS).join('|')}`,
|
||||
);
|
||||
const { layers, view } = COLD_START_GROUPS[group];
|
||||
const fixture = await coldStartFixtures(base.origin, group);
|
||||
const allow = JSON.parse(
|
||||
readFileSync(new URL('./qa-ledger-allow.json', import.meta.url), 'utf8'),
|
||||
);
|
||||
const strict = createStrict({
|
||||
origin: base.origin,
|
||||
layers,
|
||||
routes: fixture.routes,
|
||||
fixtures: fixture.fixtures,
|
||||
name: `switch-equals-cold-start--${group}`,
|
||||
time: new Date(fixture.epoch).toISOString(),
|
||||
});
|
||||
const evidence = (strict.extra.coldStart = {
|
||||
group,
|
||||
layers,
|
||||
samples: [],
|
||||
differences: [],
|
||||
skipped: [],
|
||||
});
|
||||
|
||||
async function settle(page, cold = false) {
|
||||
await whenSettled(page, { timeoutMs: 30000 });
|
||||
await page.waitForFunction(
|
||||
(layers, cold) => {
|
||||
const { dataManager, engine } = window.__godsEyeView;
|
||||
engine.render.request('qa-cold-start-readiness');
|
||||
return layers.every((id) => {
|
||||
const entry = dataManager.layers.get(id),
|
||||
module = entry?.module;
|
||||
if (!entry) return false;
|
||||
if (cold && module.sessionState?.includes('enabled'))
|
||||
return !entry.enabled;
|
||||
if (
|
||||
!entry.enabled ||
|
||||
!entry.initialized ||
|
||||
entry.sceneDetached ||
|
||||
entry.refreshing
|
||||
)
|
||||
return false;
|
||||
const stats = module.getStats?.() ?? {};
|
||||
if (stats.loading || stats.refreshing) return false;
|
||||
// These layers can be enabled with no search, route or selected subject.
|
||||
if (
|
||||
[
|
||||
'directions',
|
||||
'military-awareness',
|
||||
'osm-places',
|
||||
'sea-marks',
|
||||
'ski-pistes',
|
||||
].includes(id)
|
||||
)
|
||||
return true;
|
||||
return stats.count > 0;
|
||||
});
|
||||
},
|
||||
{ timeout: 30000, polling: 100 },
|
||||
layers,
|
||||
cold,
|
||||
);
|
||||
await whenSettled(page, { frames: 5, timeoutMs: 30000 });
|
||||
}
|
||||
|
||||
async function prepare(page, owners) {
|
||||
for (const id of layers) {
|
||||
const before = await drawLedger(page);
|
||||
assert.equal(
|
||||
await page.evaluate(async (id) => {
|
||||
const manager = window.__godsEyeView.dataManager;
|
||||
return manager.setEnabled(id, true, { origin: 'user' });
|
||||
}, id),
|
||||
true,
|
||||
`${id} enabled`,
|
||||
);
|
||||
for (const row of await drawLedger(page)) {
|
||||
if (!before.some((old) => old.family === row.family && old.id === row.id))
|
||||
owners[`${row.family}:${row.id}`] = id;
|
||||
}
|
||||
}
|
||||
await page.evaluate(
|
||||
async (group, stationId) => {
|
||||
const manager = window.__godsEyeView.dataManager;
|
||||
const module = (id) => manager.layers.get(id).module;
|
||||
if (group === 'open')
|
||||
module('osm-places').setResults(
|
||||
[
|
||||
{
|
||||
id: 'node/1',
|
||||
name: 'Fixture Clinic',
|
||||
lat: 38.9,
|
||||
lon: -77.03,
|
||||
tags: {},
|
||||
},
|
||||
{
|
||||
id: 'node/2',
|
||||
name: 'Fixture Hospital',
|
||||
lat: 38.905,
|
||||
lon: -77.02,
|
||||
tags: {},
|
||||
},
|
||||
],
|
||||
{ kind: 'clinic', label: 'Clinics', area: 'Washington' },
|
||||
);
|
||||
if (group === 'street')
|
||||
manager.setLayerParams(
|
||||
'street-level',
|
||||
{ kartaview: true, mapillary: false },
|
||||
{ origin: 'user' },
|
||||
);
|
||||
if (group === 'wind')
|
||||
await manager.setLayerParams(
|
||||
'wind',
|
||||
{ paused: true, overlay: 'temperature', units: 'm/s' },
|
||||
{ origin: 'user' },
|
||||
);
|
||||
if (group === 'radio') {
|
||||
module('radio').selectStation(stationId, {
|
||||
autoplay: true,
|
||||
focus: false,
|
||||
origin: 'user',
|
||||
});
|
||||
await manager.setLayerParams(
|
||||
'radio',
|
||||
{ volume: 0.36 },
|
||||
{ origin: 'user' },
|
||||
);
|
||||
}
|
||||
if (group === 'imagery')
|
||||
module('recent-imagery').setBox({
|
||||
west: -97.77,
|
||||
east: -97.71,
|
||||
south: 30.25,
|
||||
north: 30.29,
|
||||
});
|
||||
if (group === 'cables') {
|
||||
module('directions').placeEndpoint('a', { lat: 55.32, lon: 10.78 });
|
||||
module('directions').placeEndpoint('b', { lat: 55.33, lon: 10.82 });
|
||||
}
|
||||
},
|
||||
group,
|
||||
stations[10].id,
|
||||
);
|
||||
await settle(page);
|
||||
if (group === 'cables')
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('directions')
|
||||
.module.getStats().count > 0,
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
if (group === 'wind')
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('wind')
|
||||
.module.setParams({ inspect: true }),
|
||||
);
|
||||
if (group === 'imagery') {
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('recent-imagery')
|
||||
.module.getSnapshot()
|
||||
.candidates.some(
|
||||
(row) =>
|
||||
row.product === 'S30' && row.thumbnail?.status === 'present',
|
||||
),
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
const module =
|
||||
window.__godsEyeView.dataManager.layers.get('recent-imagery').module;
|
||||
const row = module
|
||||
.getSnapshot()
|
||||
.candidates.find(
|
||||
(row) => row.product === 'S30' && row.thumbnail?.status === 'present',
|
||||
);
|
||||
module.setAssignment('a', row.key);
|
||||
module.setMode('image');
|
||||
});
|
||||
}
|
||||
await settle(page);
|
||||
// Asynchronous feed completion can create registries after enable resolves.
|
||||
for (const row of await drawLedger(page)) {
|
||||
const owner = layers.find((id) => row.id.startsWith(id));
|
||||
if (owner) owners[`${row.family}:${row.id}`] ??= owner;
|
||||
}
|
||||
}
|
||||
|
||||
await strict.run({}, async (browser) => {
|
||||
for (const [from, target] of [
|
||||
['cesium', 'navara'],
|
||||
['navara', 'cesium'],
|
||||
]) {
|
||||
const direction = `${from} -> ${target}`;
|
||||
const sample = { direction, owners: {} };
|
||||
evidence.samples.push(sample);
|
||||
let context, page;
|
||||
const close = async () => {
|
||||
const unexpected = await page
|
||||
?.evaluate(() => window.__coldStartUnexpectedApi ?? [])
|
||||
.catch(() => []);
|
||||
for (const path of unexpected ?? [])
|
||||
evidence.differences.push({
|
||||
layer: '[page]',
|
||||
direction,
|
||||
family: 'fixture',
|
||||
id: path,
|
||||
field: 'unstubbed API',
|
||||
switchValue: 'blocked',
|
||||
coldStartValue: 'fixture required',
|
||||
});
|
||||
await page?.evaluate(() => window.__qaFinishMonitors?.()).catch(() => {});
|
||||
await context?.close();
|
||||
context = page = null;
|
||||
};
|
||||
const open = async (url) => {
|
||||
context = await browser.createBrowserContext();
|
||||
const opened = await openPage(context, { shaderErrors: true });
|
||||
page = opened.page;
|
||||
sample.errors ??= [];
|
||||
sample.errors.push(opened.errors);
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
if (localStorage.length || sessionStorage.length)
|
||||
throw new Error('Cold-start storage is not empty');
|
||||
});
|
||||
await fixture.install(page);
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
return opened;
|
||||
};
|
||||
try {
|
||||
const url = new URL(base.origin);
|
||||
url.search = `?engine=${from}&welcome=0&look=cinematic`;
|
||||
const [lat, lon, alt] = view;
|
||||
url.hash = `v=2&lat=${lat}&lon=${group === 'street' ? fixture.streetLongitude : lon}&alt=${alt}&heading=0&pitch=-90&roll=0&map=osm&l=`;
|
||||
await open(url.href);
|
||||
await waitForEngine(page, from, { timeout: 60000 });
|
||||
await prepare(page, sample.owners);
|
||||
sample.before = await probeSnapshot(page, layers);
|
||||
await strict.switchTo(page, target);
|
||||
await settle(page);
|
||||
sample.switched = await probeSnapshot(page, layers);
|
||||
sample.url = await page.evaluate(() => {
|
||||
window.__godsEyeView.styleManager.shareLinkManager.flush();
|
||||
return location.href;
|
||||
});
|
||||
const retention = compareColdStart(sample.before, sample.switched, {
|
||||
direction,
|
||||
owners: sample.owners,
|
||||
sessionState: false,
|
||||
});
|
||||
sample.retention = retention.differences.filter((row) =>
|
||||
sessionExclusion(row, sample.before),
|
||||
);
|
||||
evidence.differences.push(
|
||||
...sample.retention.map((row) => ({
|
||||
...row,
|
||||
family: `handoff:${row.family}`,
|
||||
})),
|
||||
);
|
||||
await close();
|
||||
sample.coldUrl = coldStartUrl(sample.url, target);
|
||||
await open(sample.coldUrl);
|
||||
await waitForEngine(page, target, { timeout: 60000 });
|
||||
await settle(page, true);
|
||||
sample.cold = await probeSnapshot(page, layers);
|
||||
sample.comparison = compareColdStart(sample.switched, sample.cold, {
|
||||
direction,
|
||||
owners: sample.owners,
|
||||
allow,
|
||||
});
|
||||
evidence.differences.push(...sample.comparison.differences);
|
||||
evidence.skipped.push(...sample.comparison.skipped);
|
||||
for (const id of layers) {
|
||||
if (
|
||||
![...sample.retention, ...sample.comparison.differences].some(
|
||||
(row) => row.layer === id || row.layer === '[page]',
|
||||
)
|
||||
)
|
||||
strict.record(
|
||||
id,
|
||||
target,
|
||||
'engine switch equals a fresh share-link restore; declared session fields survive the switch',
|
||||
);
|
||||
}
|
||||
for (const error of sample.errors.flat())
|
||||
evidence.differences.push({
|
||||
layer: '[page]',
|
||||
direction,
|
||||
family: 'error',
|
||||
id: '',
|
||||
field: 'pageerror',
|
||||
switchValue: error,
|
||||
coldStartValue: null,
|
||||
});
|
||||
} catch (error) {
|
||||
sample.error = String(error.stack ?? error);
|
||||
sample.failureSnapshot = await probeSnapshot(page, layers).catch(
|
||||
() => null,
|
||||
);
|
||||
evidence.differences.push({
|
||||
layer: layers.join(', '),
|
||||
direction,
|
||||
family: 'readiness',
|
||||
id: '',
|
||||
field: 'error',
|
||||
switchValue: error.message,
|
||||
coldStartValue: '<not captured>',
|
||||
});
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
`switch-equals-cold-start ${group}: ${evidence.differences.length} differences, ${evidence.skipped.length} declared session differences`,
|
||||
);
|
||||
if (evidence.differences.length)
|
||||
console.table(
|
||||
evidence.differences.map((row) => ({
|
||||
...row,
|
||||
switchValue: JSON.stringify(row.switchValue),
|
||||
coldStartValue: JSON.stringify(row.coldStartValue),
|
||||
})),
|
||||
);
|
||||
assert.equal(
|
||||
evidence.differences.length,
|
||||
0,
|
||||
'See coldStart.differences in the evidence file',
|
||||
);
|
||||
console.log(
|
||||
`PASS switch-equals-cold-start ${group}: 2 directions, ${layers.length} layers, 0 differences`,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,293 @@
|
||||
import { createQaProbe, createSettledState } from '../../src/app/qaProbe.js';
|
||||
import { compareLedgers, drawLedger } from './ledger.mjs';
|
||||
|
||||
// Each group needs four page starts and two picker switches within 300 seconds.
|
||||
export const COLD_START_GROUPS = {
|
||||
aircraft: {
|
||||
layers: ['flights', 'military'],
|
||||
view: [40.2539, -125.5779, 21130],
|
||||
},
|
||||
local: {
|
||||
layers: ['local-adsb', 'local-dams', 'local-datacenters'],
|
||||
view: [41.534, -90.507, 20000],
|
||||
},
|
||||
ground: {
|
||||
layers: [
|
||||
'ais-live-vessels',
|
||||
'air-quality',
|
||||
'gnss-integrity',
|
||||
'fire-perimeters',
|
||||
],
|
||||
view: [43.65, -79.38, 46000],
|
||||
},
|
||||
contacts: {
|
||||
layers: ['military-awareness', 'military-installations'],
|
||||
view: [43.65, -79.38, 46000],
|
||||
},
|
||||
open: {
|
||||
layers: ['drone-airspace', 'osm-places', 'sea-marks', 'ski-pistes'],
|
||||
view: [38.9, -77.03, 30000],
|
||||
},
|
||||
weather: {
|
||||
layers: [
|
||||
'weather-radar',
|
||||
'weather-satellite',
|
||||
'weather-lightning',
|
||||
'weather-cyclones',
|
||||
],
|
||||
view: [43.65, -79.38, 46000],
|
||||
},
|
||||
wind: { layers: ['wind'], view: [43.65, -79.38, 2000000] },
|
||||
ghostmaps: { layers: ['s2-ghostmaps'], view: [43.65, -79.38, 46000] },
|
||||
wildlife: { layers: ['wildlife-sightings'], view: [48.4, -123.1, 200000] },
|
||||
urban: {
|
||||
layers: ['bikeshare', 'transit', 'earthquakes'],
|
||||
view: [42.3554, -71.0605, 6000],
|
||||
},
|
||||
cameras: { layers: ['cctv', 'alpr-cameras'], view: [30.262, -97.7431, 5500] },
|
||||
radio: { layers: ['radio'], view: [30.27, -97.74, 12000] },
|
||||
space: { layers: ['satellites', 'rocket-launches'], view: [5, 5, 18000000] },
|
||||
traffic: { layers: ['traffic'], view: [30.267, -97.744, 1800] },
|
||||
street: { layers: ['street-level'], view: [38.5831, -121.504, 850] },
|
||||
imagery: { layers: ['recent-imagery'], view: [30.27, -97.74, 12000] },
|
||||
nepal: {
|
||||
layers: ['bhote-koshi-locator', 'bhote-koshi-2026'],
|
||||
view: [28.3326, 85.4848, 18000],
|
||||
},
|
||||
cables: {
|
||||
layers: ['telegeography-submarine-cables', 'directions'],
|
||||
view: [55.3241, 10.7995, 25000],
|
||||
},
|
||||
fires: { layers: ['local-firms'], view: [37.77, -122.42, 100000] },
|
||||
};
|
||||
|
||||
/** Change only the engine query of the address written by the application. */
|
||||
export function coldStartUrl(href, engine) {
|
||||
const url = new URL(href);
|
||||
url.searchParams.set('engine', engine);
|
||||
return url.href;
|
||||
}
|
||||
|
||||
// Share addresses round the camera to 0.0001 degrees and whole metres/degrees.
|
||||
// World positions must still match within a metre; projected positions allow
|
||||
// two CSS pixels for that camera quantization. Counts and identities are exact.
|
||||
export function numericTolerance(field) {
|
||||
if (/(?:^|\.)screen\.[xy]$/.test(field)) return 2;
|
||||
if (/(?:^|\.)position\.[xyz]$/.test(field)) return 1;
|
||||
return 1e-6;
|
||||
}
|
||||
|
||||
/** Compare JSON without losing absent fields, nulls or identities in arrays. */
|
||||
export function compareProbe(before, after, { skip = [], field = '' } = {}) {
|
||||
const differences = [];
|
||||
const visit = (left, right, path) => {
|
||||
if (skip.includes(path)) return;
|
||||
if (Object.is(left, right)) return;
|
||||
if (typeof left === 'number' && typeof right === 'number') {
|
||||
const tolerance = /(?:count|capacity|length|id)$/.test(path)
|
||||
? 0
|
||||
: numericTolerance(path);
|
||||
if (
|
||||
Number.isFinite(left) &&
|
||||
Number.isFinite(right) &&
|
||||
Math.abs(left - right) <= tolerance
|
||||
)
|
||||
return;
|
||||
} else if (
|
||||
left &&
|
||||
right &&
|
||||
typeof left === 'object' &&
|
||||
typeof right === 'object' &&
|
||||
Array.isArray(left) === Array.isArray(right)
|
||||
) {
|
||||
if (Array.isArray(left)) {
|
||||
if (left.length !== right.length)
|
||||
visit(left.length, right.length, `${path}.length`);
|
||||
const identified = [...left, ...right].every(
|
||||
(row) => row && typeof row === 'object' && row.id !== undefined,
|
||||
);
|
||||
if (identified) {
|
||||
const a = new Map(left.map((row) => [String(row.id), row]));
|
||||
const b = new Map(right.map((row) => [String(row.id), row]));
|
||||
if (a.size !== left.length || b.size !== right.length)
|
||||
differences.push({
|
||||
field: path,
|
||||
switchValue: left,
|
||||
coldStartValue: right,
|
||||
error: 'duplicate ids',
|
||||
});
|
||||
for (const id of new Set([...a.keys(), ...b.keys()]))
|
||||
visit(a.get(id), b.get(id), `${path}[${JSON.stringify(id)}]`);
|
||||
} else
|
||||
for (let i = 0; i < Math.max(left.length, right.length); i++)
|
||||
visit(left[i], right[i], `${path}[${i}]`);
|
||||
} else
|
||||
for (const key of new Set([
|
||||
...Object.keys(left),
|
||||
...Object.keys(right),
|
||||
]))
|
||||
visit(left[key], right[key], path ? `${path}.${key}` : key);
|
||||
return;
|
||||
}
|
||||
differences.push({
|
||||
field: path,
|
||||
switchValue: left === undefined ? '<absent>' : left,
|
||||
coldStartValue: right === undefined ? '<absent>' : right,
|
||||
});
|
||||
};
|
||||
visit(before, after, field);
|
||||
return differences;
|
||||
}
|
||||
|
||||
/** The production build drops its dev probe; inject the same implementation. */
|
||||
export async function probeSnapshot(page, layers) {
|
||||
const probe = await page.evaluate(`(() => {
|
||||
const plain = (p) => p ? { x: p.x, y: p.y, z: p.z } : null;
|
||||
const createSettledState = ${createSettledState};
|
||||
const probe = (${createQaProbe})(window.__godsEyeView);
|
||||
const layers = ${JSON.stringify(layers)};
|
||||
try {
|
||||
return {
|
||||
layers: Object.fromEntries(layers.map((id) => {
|
||||
const module = window.__godsEyeView.dataManager.layers.get(id).module;
|
||||
const session = id === 'radio'
|
||||
? { selected: module.getUIState().selected, audioState: module.getUIState().audioState }
|
||||
: id === 'wind' ? { reading: module.getRowControls().summary.reading }
|
||||
: id === 'osm-places' ? { results: module.getAnalystRecords(1000000) }
|
||||
: id === 'directions' ? { route: module.getRowControls().list } : {};
|
||||
return [id, {
|
||||
enabled: window.__godsEyeView.dataManager.layers.get(id).enabled,
|
||||
objects: probe.objects(id, 1000000), selected: probe.selected(id),
|
||||
session, sessionState: module.sessionState ?? [],
|
||||
}];
|
||||
})),
|
||||
following: probe.following(), movers: probe.movers(),
|
||||
};
|
||||
} finally { probe.destroy(); }
|
||||
})()`);
|
||||
return { ...probe, ledger: await drawLedger(page) };
|
||||
}
|
||||
|
||||
/** Keep raw samples and declared exclusions beside the complete difference list. */
|
||||
export function compareColdStart(
|
||||
switched,
|
||||
cold,
|
||||
{ direction, owners = {}, allow = [], sessionState = true },
|
||||
) {
|
||||
const differences = [],
|
||||
skipped = [];
|
||||
const add = (layer, family, id, rows) =>
|
||||
differences.push(
|
||||
...rows.map((row) => ({ layer, direction, family, id, ...row })),
|
||||
);
|
||||
const layerOf = (family, id) => owners[`${family}:${id}`] ?? '[page]';
|
||||
const ledger = compareLedgers(switched.ledger, cold.ledger, allow);
|
||||
for (const [side, rows] of [
|
||||
['switch', ledger.onlyBefore],
|
||||
['cold', ledger.onlyAfter],
|
||||
])
|
||||
for (const { family, id } of rows)
|
||||
add(layerOf(family, id), family, id, [
|
||||
{
|
||||
field: 'present',
|
||||
switchValue: side === 'switch',
|
||||
coldStartValue: side === 'cold',
|
||||
},
|
||||
]);
|
||||
for (const { family, id, before, after } of ledger.shown)
|
||||
add(layerOf(family, id), family, id, [
|
||||
{ field: 'shown', switchValue: before, coldStartValue: after },
|
||||
]);
|
||||
for (const row of switched.ledger) {
|
||||
const next = cold.ledger.find(
|
||||
(other) => other.family === row.family && other.id === row.id,
|
||||
);
|
||||
if (next)
|
||||
add(
|
||||
layerOf(row.family, row.id),
|
||||
row.family,
|
||||
row.id,
|
||||
compareProbe(
|
||||
{ kind: row.kind, count: row.count },
|
||||
{ kind: next.kind, count: next.count },
|
||||
),
|
||||
);
|
||||
}
|
||||
for (const layer of new Set([
|
||||
...Object.keys(switched.layers),
|
||||
...Object.keys(cold.layers),
|
||||
])) {
|
||||
const before = switched.layers[layer],
|
||||
after = cold.layers[layer];
|
||||
for (const row of compareProbe(before, after)) {
|
||||
const id = /^objects\[("(?:[^"\\]|\\.)*")\]/.exec(row.field)?.[1];
|
||||
add(layer, 'probe', id ? JSON.parse(id) : layer, [row]);
|
||||
}
|
||||
}
|
||||
add(
|
||||
'[page]',
|
||||
'probe',
|
||||
'following',
|
||||
compareProbe(switched.following, cold.following, { field: 'following' }),
|
||||
);
|
||||
// Keep all mover registry fields, including capacity, beyond the draw ledger.
|
||||
const a = new Map(switched.movers.map((row) => [String(row.id), row]));
|
||||
const b = new Map(cold.movers.map((row) => [String(row.id), row]));
|
||||
for (const id of new Set([...a.keys(), ...b.keys()]))
|
||||
add(
|
||||
layerOf('movers', id),
|
||||
'probe-movers',
|
||||
id,
|
||||
compareProbe(a.get(id), b.get(id)),
|
||||
);
|
||||
return {
|
||||
differences: differences.filter((row) => {
|
||||
const field = sessionState && sessionExclusion(row, switched);
|
||||
if (!field) return true;
|
||||
skipped.push({ ...row, sessionState: field });
|
||||
return false;
|
||||
}),
|
||||
skipped,
|
||||
ledger,
|
||||
};
|
||||
}
|
||||
|
||||
/** Only declared fields and their drawn derivatives can differ from a link. */
|
||||
export function sessionExclusion(row, snapshot) {
|
||||
const declared = snapshot.layers[row.layer]?.sessionState ?? [];
|
||||
for (const field of declared) {
|
||||
if (
|
||||
row.family === 'probe' &&
|
||||
(row.field === `session.${field}` ||
|
||||
row.field.startsWith(`session.${field}.`) ||
|
||||
row.field.startsWith(`session.${field}[`))
|
||||
)
|
||||
return field;
|
||||
// Session-only layers are absent from the link. Their drawing must still
|
||||
// survive the switch; the scenario checks these same rows against S first.
|
||||
if (
|
||||
field === 'enabled' &&
|
||||
(row.family !== 'probe' ||
|
||||
/^(enabled|objects|selected)(\.|\[|$)/.test(row.field))
|
||||
)
|
||||
return field;
|
||||
if (
|
||||
row.layer === 'radio' &&
|
||||
field === 'selected' &&
|
||||
['radio-selected', 'radio-bracket'].includes(row.id)
|
||||
)
|
||||
return field;
|
||||
if (
|
||||
row.layer === 'directions' &&
|
||||
field === 'route' &&
|
||||
[
|
||||
'directions-route',
|
||||
'directions-steps',
|
||||
'directions-markers',
|
||||
'directions-marker-letters',
|
||||
].includes(row.id)
|
||||
)
|
||||
return field;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
import { createFakeEngine } from '../../src/globe/testing/fakeEngine.js';
|
||||
import { APPLICATION_LAYER_DIRECTORY } from '../../src/app/layers/directory.js';
|
||||
import {
|
||||
COLD_START_GROUPS,
|
||||
coldStartUrl,
|
||||
compareProbe,
|
||||
compareColdStart,
|
||||
sessionExclusion,
|
||||
probeSnapshot,
|
||||
} from './coldStart.mjs';
|
||||
|
||||
const snapshot = () => ({
|
||||
layers: {
|
||||
radio: {
|
||||
enabled: true,
|
||||
objects: [],
|
||||
selected: null,
|
||||
session: { selected: null, audioState: 'idle' },
|
||||
sessionState: ['selected', 'audioState'],
|
||||
},
|
||||
},
|
||||
ledger: [
|
||||
{
|
||||
family: 'movers',
|
||||
id: 'radio-selected',
|
||||
shown: true,
|
||||
count: 1,
|
||||
kind: 'point',
|
||||
},
|
||||
],
|
||||
movers: [{ id: 'radio-selected', count: 1, kind: 'point', capacity: 1 }],
|
||||
following: null,
|
||||
});
|
||||
const options = {
|
||||
direction: 'cesium -> navara',
|
||||
owners: { 'movers:radio-selected': 'radio' },
|
||||
};
|
||||
|
||||
test('groups cover every strict catalog layer once and are registered with flags', () => {
|
||||
const ids = Object.values(COLD_START_GROUPS).flatMap((group) => group.layers);
|
||||
assert.deepEqual(
|
||||
ids.sort(),
|
||||
APPLICATION_LAYER_DIRECTORY.map(({ id }) => id).sort(),
|
||||
);
|
||||
assert.ok(
|
||||
Object.values(COLD_START_GROUPS).every(({ layers }) => layers.length <= 4),
|
||||
);
|
||||
const runner = readFileSync(
|
||||
new URL('../qa-all-layer-switches.mjs', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const registered = JSON.parse(
|
||||
JSON.stringify(
|
||||
vm.runInNewContext(runner.match(/const runs = (\[[\s\S]*?\n\]);/)[1], {
|
||||
soakRuns: [],
|
||||
longRun: '',
|
||||
}),
|
||||
),
|
||||
);
|
||||
for (const [group, { layers }] of Object.entries(COLD_START_GROUPS)) {
|
||||
assert.deepEqual(
|
||||
registered.find(
|
||||
([label]) => label === `switch-equals-cold-start--${group}`,
|
||||
),
|
||||
[
|
||||
`switch-equals-cold-start--${group}`,
|
||||
'switch-equals-cold-start',
|
||||
layers,
|
||||
[`--group=${group}`],
|
||||
],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('cold URL preserves the app-written hash, path and other query settings', () => {
|
||||
const from =
|
||||
'http://127.0.0.1:4309/share/ABC?engine=cesium&lang=de#v=2&l=f&lo=f.t.71be26';
|
||||
const result = new URL(coldStartUrl(from, 'navara'));
|
||||
assert.equal(result.pathname, '/share/ABC');
|
||||
assert.equal(result.hash, new URL(from).hash);
|
||||
assert.equal(result.searchParams.get('engine'), 'navara');
|
||||
assert.equal(result.searchParams.get('lang'), 'de');
|
||||
});
|
||||
|
||||
test('probe comparison tolerates camera quantization but rejects lost objects and count changes', () => {
|
||||
const a = {
|
||||
objects: [
|
||||
{
|
||||
id: 'a',
|
||||
position: { x: 6378000, y: 0, z: 0 },
|
||||
screen: { x: 500, y: 400 },
|
||||
},
|
||||
],
|
||||
};
|
||||
const b = structuredClone(a);
|
||||
b.objects[0].position.x += 0.5;
|
||||
b.objects[0].screen.x += 1.5;
|
||||
assert.deepEqual(compareProbe(a, b), []);
|
||||
b.objects[0].position.x += 1;
|
||||
assert.equal(compareProbe(a, b)[0].field, 'objects["a"].position.x');
|
||||
assert.ok(
|
||||
compareProbe(a, { objects: [] }).some(
|
||||
({ field }) => field === 'objects["a"]',
|
||||
),
|
||||
);
|
||||
assert.equal(compareProbe({ count: 1 }, { count: 1.0000001 }).length, 1);
|
||||
assert.equal(compareProbe({ selected: null }, {}).length, 1);
|
||||
assert.equal(
|
||||
compareProbe({ position: { x: NaN } }, { position: { x: 0 } }).length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test('probe arrays compare identities without hiding duplicate ids or reordered paths', () => {
|
||||
assert.deepEqual(
|
||||
compareProbe([{ id: 'b' }, { id: 'a' }], [{ id: 'a' }, { id: 'b' }]),
|
||||
[],
|
||||
);
|
||||
assert.ok(
|
||||
compareProbe([{ id: 'a' }, { id: 'a' }], [{ id: 'a' }, { id: 'a' }]).some(
|
||||
(row) => row.error === 'duplicate ids',
|
||||
),
|
||||
);
|
||||
assert.equal(compareProbe([1, 2], [2, 1]).length, 2);
|
||||
assert.equal(
|
||||
compareProbe(
|
||||
{ selected: { id: 'a' }, objects: [] },
|
||||
{ selected: null, objects: [1] },
|
||||
{ skip: ['selected'] },
|
||||
).length,
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
test('comparison returns every ledger family and field difference, with attribution', () => {
|
||||
const a = snapshot(),
|
||||
b = snapshot();
|
||||
b.ledger[0].count = 2;
|
||||
b.ledger[0].shown = false;
|
||||
b.ledger.push({ family: 'lines', id: 'extra', shown: true });
|
||||
b.layers.radio.objects = [{ id: 'new' }];
|
||||
b.following = { label: 'lost', native: null };
|
||||
const result = compareColdStart(a, b, { ...options, sessionState: false });
|
||||
assert.ok(
|
||||
result.differences.some(
|
||||
(row) => row.field === 'count' && row.layer === 'radio',
|
||||
),
|
||||
);
|
||||
assert.ok(result.differences.some((row) => row.field === 'shown'));
|
||||
assert.ok(
|
||||
result.differences.some(
|
||||
(row) => row.family === 'lines' && row.id === 'extra',
|
||||
),
|
||||
);
|
||||
assert.ok(result.differences.some((row) => row.id === 'following'));
|
||||
assert.ok(
|
||||
result.differences.some(
|
||||
(row) => row.field === 'objects["new"]' && row.id === 'new',
|
||||
),
|
||||
);
|
||||
assert.equal(result.differences.length, 6);
|
||||
});
|
||||
|
||||
test('declared session fields do not hide other probe fields or other layers', () => {
|
||||
const a = snapshot(),
|
||||
b = snapshot();
|
||||
a.layers.radio.session.selected = { id: 'station' };
|
||||
a.layers.radio.session.audioState = 'playing';
|
||||
b.movers[0].count = b.ledger[0].count = 0;
|
||||
b.layers.radio.enabled = false;
|
||||
const result = compareColdStart(a, b, options);
|
||||
assert.equal(result.skipped.length, 4);
|
||||
assert.deepEqual(
|
||||
result.differences.map((row) => row.field),
|
||||
['enabled'],
|
||||
);
|
||||
b.layers.radio.sessionState.push('objects');
|
||||
assert.ok(
|
||||
compareColdStart(a, b, options).differences.some((row) =>
|
||||
row.field.startsWith('sessionState'),
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
sessionExclusion(
|
||||
{ layer: 'wind', family: 'movers', id: 'radio-selected', field: 'count' },
|
||||
a,
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('a lost session field still fails the source-to-switch retention check', () => {
|
||||
const a = snapshot(),
|
||||
b = snapshot();
|
||||
a.layers.radio.session.selected = { id: 'station' };
|
||||
b.movers[0].count = b.ledger[0].count = 0;
|
||||
const retention = compareColdStart(a, b, {
|
||||
...options,
|
||||
sessionState: false,
|
||||
}).differences.filter((row) => sessionExclusion(row, a));
|
||||
assert.equal(retention.length, 3);
|
||||
});
|
||||
|
||||
test('allowlisted absences keep their reason and never hide changed visibility or counts', () => {
|
||||
const a = snapshot(),
|
||||
b = snapshot();
|
||||
a.ledger.push({ family: 'lines', id: 'overlay', shown: true });
|
||||
b.ledger[0].shown = false;
|
||||
const result = compareColdStart(a, b, {
|
||||
...options,
|
||||
sessionState: false,
|
||||
allow: [{ family: 'lines', id: 'overlay', reason: 'Engine overlay' }],
|
||||
});
|
||||
assert.equal(result.ledger.allowed[0].reason, 'Engine overlay');
|
||||
assert.deepEqual(
|
||||
result.differences.map((row) => row.field),
|
||||
['shown'],
|
||||
);
|
||||
});
|
||||
|
||||
test('the production probe captures all requested fields and releases its frame listeners', async () => {
|
||||
const engine = createFakeEngine();
|
||||
const position = { x: 6378137, y: 0, z: 0 };
|
||||
const dataManager = {
|
||||
layers: new Map([
|
||||
[
|
||||
'fixture',
|
||||
{
|
||||
enabled: true,
|
||||
module: {
|
||||
getAllPositions: () => [{ id: 'one', position }],
|
||||
getSelectedInfo: () => ({ id: 'one' }),
|
||||
},
|
||||
},
|
||||
],
|
||||
]),
|
||||
};
|
||||
const context = vm.createContext({
|
||||
window: { __godsEyeView: { engine, dataManager } },
|
||||
});
|
||||
const page = {
|
||||
evaluate: async (code) =>
|
||||
vm.runInContext(typeof code === 'string' ? code : `(${code})()`, context),
|
||||
};
|
||||
const listeners = engine.frames.postRender.numberOfListeners;
|
||||
const snapshot = await probeSnapshot(page, ['fixture']);
|
||||
assert.equal(snapshot.layers.fixture.objects[0].id, 'one');
|
||||
assert.equal(snapshot.layers.fixture.selected.id, 'one');
|
||||
assert.equal(snapshot.following, null);
|
||||
assert.equal(snapshot.movers.length, 0);
|
||||
assert.equal(snapshot.ledger.length, 0);
|
||||
assert.equal(engine.frames.postRender.numberOfListeners, listeners);
|
||||
engine.destroy();
|
||||
});
|
||||
@@ -0,0 +1,622 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import sharp from 'sharp';
|
||||
import { installAlprFixture } from '../fixtures/layers/alpr.mjs';
|
||||
import { installCctvFixture } from '../fixtures/layers/cctv.mjs';
|
||||
import { installCombinedFixture } from '../fixtures/layers/combined.mjs';
|
||||
import { installRadioFixture } from '../fixtures/layers/radio.mjs';
|
||||
import {
|
||||
installTransitFixture,
|
||||
TRANSIT_EPOCH,
|
||||
} from '../fixtures/layers/transit.mjs';
|
||||
import { firmsFixture } from '../fixtures/layers/firms.mjs';
|
||||
import { wildlifeRoutes } from '../fixtures/wildlife.mjs';
|
||||
import { serveFixtures, newFixture } from '../qa-street-level.mjs';
|
||||
import { KV_LINE } from '../fixtures/street-level/kartaView.mjs';
|
||||
import { trafficFixtureResponse } from '../traffic-fixtures.mjs';
|
||||
import {
|
||||
satelliteFixture,
|
||||
SATELLITE_FIXTURE_EPOCH,
|
||||
} from '../../src/bench/satelliteFixture.js';
|
||||
import { launchFixture } from '../../src/bench/launchFixture.js';
|
||||
|
||||
// These are the feeds used by the strict switching scenarios, with motion and
|
||||
// timestamps held fixed so page startup duration cannot change the sample.
|
||||
export async function coldStartFixtures(origin, group) {
|
||||
const epoch =
|
||||
group === 'space'
|
||||
? SATELLITE_FIXTURE_EPOCH
|
||||
: group === 'urban'
|
||||
? TRANSIT_EPOCH
|
||||
: Date.now();
|
||||
const png = await sharp({
|
||||
create: { width: 256, height: 256, channels: 4, background: '#40506a' },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
const json = (body) => ({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const image = () => ({ contentType: 'image/png', body: png });
|
||||
const routes = [];
|
||||
const fixtures = [];
|
||||
const route = (pattern, respond) => routes.push({ pattern, respond });
|
||||
if (group === 'wildlife') routes.push(...wildlifeRoutes(origin).routes);
|
||||
if (group === 'open') {
|
||||
route('https://tiles.openseamap.org/*', image);
|
||||
route('https://www.opensnowmap.org/*', image);
|
||||
}
|
||||
if (group === 'cameras') fixtures.push('https://tiles.dontgetflocked.com/*');
|
||||
if (group === 'street')
|
||||
fixtures.push(
|
||||
'*://*.mapillary.com/*',
|
||||
'https://api.kartaview.org/*',
|
||||
'https://cdn.kartaview.org/*',
|
||||
'*://*.openstreetcam.org/*',
|
||||
'https://api.panoramax.xyz/*',
|
||||
'https://panoramax.openstreetmap.fr/*',
|
||||
);
|
||||
if (group === 'nepal') {
|
||||
route('https://i.ytimg.com/*', image);
|
||||
for (const pattern of [
|
||||
'https://www.youtube.com/iframe_api*',
|
||||
'https://connect.facebook.net/*',
|
||||
])
|
||||
route(pattern, () => ({ contentType: 'text/javascript', body: '' }));
|
||||
for (const pattern of [
|
||||
'https://www.youtube-nocookie.com/*',
|
||||
'https://www.facebook.com/*',
|
||||
])
|
||||
route(pattern, () => ({
|
||||
contentType: 'text/html',
|
||||
body: '<!doctype html><title>Fixture media</title>',
|
||||
}));
|
||||
}
|
||||
if (group === 'traffic') {
|
||||
for (const pattern of [
|
||||
'https://tiles.openfreemap.org/*',
|
||||
origin + '/api/tomtom/*',
|
||||
])
|
||||
route(
|
||||
pattern,
|
||||
(req) =>
|
||||
trafficFixtureResponse({ url: () => req.url }) ??
|
||||
json({ hasKey: false }),
|
||||
);
|
||||
}
|
||||
if (group === 'imagery') {
|
||||
route('https://cmr.earthdata.nasa.gov/*', () =>
|
||||
json({
|
||||
hits: 2,
|
||||
items: ['2026-10-28', '2026-10-27'].map((day) => ({
|
||||
umm: {
|
||||
TemporalExtent: {
|
||||
RangeDateTime: { BeginningDateTime: `${day}T12:00:00Z` },
|
||||
},
|
||||
AdditionalAttributes: [{ Name: 'CLOUD_COVERAGE', Values: ['0'] }],
|
||||
SpatialExtent: {
|
||||
HorizontalSpatialDomain: {
|
||||
Geometry: {
|
||||
BoundingRectangles: [
|
||||
{
|
||||
WestBoundingCoordinate: -98,
|
||||
EastBoundingCoordinate: -97,
|
||||
SouthBoundingCoordinate: 30,
|
||||
NorthBoundingCoordinate: 31,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})),
|
||||
}),
|
||||
);
|
||||
route('https://*.earthdata.nasa.gov/*', image);
|
||||
}
|
||||
if (group === 'weather') {
|
||||
const times = ['2026-11-02T12:00:00.000Z', '2026-11-02T12:05:00.000Z'];
|
||||
route(origin + '/api/weather/*', (req) => {
|
||||
const url = new URL(req.url),
|
||||
product = url.searchParams.get('product');
|
||||
return url.pathname.endsWith('manifest')
|
||||
? json({
|
||||
schemaVersion: 1,
|
||||
product,
|
||||
times,
|
||||
latest: times.at(-1),
|
||||
maxLevel: 6,
|
||||
tileSize: 256,
|
||||
tilingScheme: 'geographic',
|
||||
bounds: { west: -130, south: 20, east: -50, north: 60 },
|
||||
display: { credit: 'Weather fixture' },
|
||||
})
|
||||
: image();
|
||||
});
|
||||
route(origin + '/api/cyclones*', () =>
|
||||
json({
|
||||
schemaVersion: 1,
|
||||
source: 'NOAA fixture',
|
||||
attribution: 'NOAA fixture',
|
||||
coverage: 'Atlantic',
|
||||
fetchedAt: Date.parse(times[1]),
|
||||
stale: false,
|
||||
unavailable: false,
|
||||
reason: null,
|
||||
storms: [
|
||||
{
|
||||
id: 'al012026',
|
||||
name: 'Fixture storm',
|
||||
basin: 'AL',
|
||||
classification: 'TS',
|
||||
position: { longitude: -79.38, latitude: 43.65 },
|
||||
advisoryNumber: '10',
|
||||
issuedAt: times[1],
|
||||
positionAt: times[1],
|
||||
windKt: 40,
|
||||
pressureHpa: 1000,
|
||||
movement: { directionDegrees: 45, speedKt: 10 },
|
||||
advisoryUrl: null,
|
||||
outlookUrl: null,
|
||||
geometryStatus: 'current',
|
||||
geometryAdvisoryNumber: '10',
|
||||
track: {
|
||||
type: 'LineString',
|
||||
coordinates: [
|
||||
[-79.38, 43.65],
|
||||
[-79.31, 43.68],
|
||||
],
|
||||
},
|
||||
cone: {
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[-79.35, 43.63],
|
||||
[-79.28, 43.63],
|
||||
[-79.28, 43.7],
|
||||
[-79.35, 43.63],
|
||||
],
|
||||
],
|
||||
},
|
||||
forecastPoints: [
|
||||
{
|
||||
tauHours: 24,
|
||||
position: { longitude: -79.31, latitude: 43.68 },
|
||||
windKt: 50,
|
||||
gustKt: 60,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (group === 'wind')
|
||||
route(origin + '/api/wind/*', (req) => {
|
||||
const url = new URL(req.url),
|
||||
model = url.searchParams.get('model'),
|
||||
kind = url.searchParams.get('overlay');
|
||||
if (url.pathname.endsWith('/manifest'))
|
||||
return json({
|
||||
model,
|
||||
overlay: kind ?? 'none',
|
||||
grid: { nx: 36, ny: 19, lo1: 0, la1: 90, dx: 10, dy: 10 },
|
||||
gridUrl: '/api/wind/grid/' + model + '-fixture.bin' + url.search,
|
||||
cycle: {
|
||||
validIso: '2026-11-02T12:00:00Z',
|
||||
runIso: '2026-11-02T06:00:00Z',
|
||||
},
|
||||
...(kind
|
||||
? {
|
||||
scalar: {
|
||||
kind,
|
||||
units: kind === 'temperature' ? '°C' : 'hPa',
|
||||
level: '2 m above ground',
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
const count = 36 * 19,
|
||||
values = new Float32Array(count * (kind ? 3 : 2));
|
||||
values.fill(8, 0, count);
|
||||
values.fill(6, count, 2 * count);
|
||||
if (kind) values.fill(kind === 'temperature' ? 40 : 1020, 2 * count);
|
||||
return {
|
||||
contentType: 'application/octet-stream',
|
||||
body: Buffer.from(values.buffer),
|
||||
};
|
||||
});
|
||||
const groups =
|
||||
group === 'space'
|
||||
? Object.fromEntries(
|
||||
[
|
||||
'stations',
|
||||
'visual',
|
||||
'gps-ops',
|
||||
'glo-ops',
|
||||
'galileo',
|
||||
'geo',
|
||||
'starlink',
|
||||
'resource',
|
||||
'weather',
|
||||
'planet',
|
||||
'sar',
|
||||
'science',
|
||||
].map((id) => [id, satelliteFixture(id)]),
|
||||
)
|
||||
: {};
|
||||
const ghost =
|
||||
group === 'ghostmaps'
|
||||
? [
|
||||
...readFileSync(
|
||||
new URL('../fixtures/ghostmaps-scenes.kmz', import.meta.url),
|
||||
),
|
||||
]
|
||||
: [];
|
||||
const firms = firmsFixture();
|
||||
return {
|
||||
routes,
|
||||
fixtures,
|
||||
epoch,
|
||||
streetLongitude: KV_LINE.lon,
|
||||
async install(page) {
|
||||
if (group === 'urban') {
|
||||
await installCombinedFixture(page);
|
||||
const transit = await installTransitFixture(page);
|
||||
transit.setStep(9);
|
||||
}
|
||||
if (group === 'radio') await installRadioFixture(page);
|
||||
if (group === 'cameras') {
|
||||
await installCctvFixture(page);
|
||||
await installAlprFixture(page);
|
||||
}
|
||||
if (group === 'street')
|
||||
await serveFixtures(page, newFixture({ configured: true }));
|
||||
await page.evaluateOnNewDocument(installFeeds, {
|
||||
group,
|
||||
epoch,
|
||||
groups,
|
||||
ghost,
|
||||
launches: launchFixture(),
|
||||
firms,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function installFeeds({ group, epoch, groups, ghost, launches, firms }) {
|
||||
const RealDate = Date;
|
||||
globalThis.Date = class extends RealDate {
|
||||
constructor(...args) {
|
||||
super(...(args.length ? args : [epoch]));
|
||||
}
|
||||
static now() {
|
||||
return epoch;
|
||||
}
|
||||
};
|
||||
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
|
||||
window.__coldStartUnexpectedApi = [];
|
||||
const original = window.fetch.bind(window);
|
||||
const json = (body) => Promise.resolve(Response.json(body));
|
||||
const EventSource = window.EventSource;
|
||||
window.EventSource = class extends EventTarget {
|
||||
constructor(url, options) {
|
||||
super();
|
||||
if (!url.includes('/api/ghostmaps/events'))
|
||||
return new EventSource(url, options);
|
||||
this.readyState = 1;
|
||||
}
|
||||
close() {}
|
||||
};
|
||||
window.fetch = (input, options) => {
|
||||
const url = new URL(input.url ?? input, location.href),
|
||||
path = url.pathname;
|
||||
if (path === '/api/flights')
|
||||
return json({
|
||||
time: epoch / 1000,
|
||||
states:
|
||||
group === 'aircraft'
|
||||
? [
|
||||
[
|
||||
'71be26',
|
||||
'GEV123',
|
||||
'Fixture',
|
||||
epoch / 1000,
|
||||
epoch / 1000,
|
||||
-125.5779,
|
||||
40.2539,
|
||||
9700,
|
||||
false,
|
||||
0,
|
||||
45,
|
||||
0,
|
||||
null,
|
||||
9700,
|
||||
null,
|
||||
false,
|
||||
0,
|
||||
],
|
||||
]
|
||||
: [],
|
||||
});
|
||||
if (path === '/api/military')
|
||||
return json({
|
||||
now: epoch,
|
||||
ac:
|
||||
group === 'aircraft'
|
||||
? [
|
||||
{
|
||||
hex: 'aaa052',
|
||||
flight: 'GEVMIL',
|
||||
lon: -125.58,
|
||||
lat: 40.26,
|
||||
alt_geom: 31825,
|
||||
alt_baro: 31825,
|
||||
gs: 0,
|
||||
track: 45,
|
||||
seen: 0,
|
||||
seen_pos: 0,
|
||||
dbFlags: 1,
|
||||
type: 'adsb_icao',
|
||||
},
|
||||
]
|
||||
: [],
|
||||
});
|
||||
if (path.startsWith('/api/flights/')) return json({ path: [] });
|
||||
if (path.startsWith('/api/adsbdb/')) return json({ response: {} });
|
||||
if (path === '/api/vessels')
|
||||
return json({
|
||||
status: 'live',
|
||||
rows: ['ground', 'contacts'].includes(group)
|
||||
? [
|
||||
{
|
||||
mmsi: '123456789',
|
||||
name: 'Fixture Toronto',
|
||||
lat: 43.65,
|
||||
lon: -79.38,
|
||||
last_position_epoch: epoch / 1000,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
});
|
||||
if (path === '/api/vessels/track') return json({ samples: [] });
|
||||
if (path === '/api/local-receivers/aircraft')
|
||||
return json({
|
||||
configured: true,
|
||||
generatedAt: epoch,
|
||||
records: [
|
||||
{
|
||||
icao: 'abc123',
|
||||
callsign: 'LOCAL1',
|
||||
lat: 41.534,
|
||||
lon: -90.507,
|
||||
altitudeFt: 11000,
|
||||
groundSpeedKt: 0,
|
||||
trackDeg: 5,
|
||||
lastPositionAt: epoch,
|
||||
lastMessageAt: epoch,
|
||||
messageCount: 12,
|
||||
band: '1090',
|
||||
source: 'decoder',
|
||||
},
|
||||
],
|
||||
});
|
||||
if (url.hostname === 'earthquake.usgs.gov')
|
||||
return json({
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
{
|
||||
type: 'Feature',
|
||||
id: 'fixture-boston',
|
||||
geometry: { type: 'Point', coordinates: [-71.0605, 42.3554, 8] },
|
||||
properties: { mag: 4, time: epoch, place: 'Fixture Boston' },
|
||||
},
|
||||
],
|
||||
});
|
||||
if (path === '/api/military-installations')
|
||||
return json({
|
||||
status: 'ok',
|
||||
elements:
|
||||
group === 'contacts'
|
||||
? [
|
||||
{
|
||||
type: 'way',
|
||||
id: 424242,
|
||||
tags: { military: 'naval_base', name: 'Fixture Harbour' },
|
||||
center: { lat: 43.6725, lon: -79.33 },
|
||||
geometry: [
|
||||
[43.665, -79.345],
|
||||
[43.665, -79.315],
|
||||
[43.68, -79.315],
|
||||
[43.68, -79.345],
|
||||
[43.665, -79.345],
|
||||
].map(([lat, lon]) => ({ lat, lon })),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
});
|
||||
if (path.startsWith('/api/fire-perimeters/inciweb'))
|
||||
return json({ rows: [] });
|
||||
if (path === '/api/fire-perimeters')
|
||||
return json({
|
||||
rows: [
|
||||
{
|
||||
stableId: 'fire:fixture',
|
||||
name: 'Fixture Fire',
|
||||
acres: 500,
|
||||
containedPct: 40,
|
||||
polygons: [
|
||||
[
|
||||
[
|
||||
[-79.47, 43.56],
|
||||
[-79.29, 43.56],
|
||||
[-79.29, 43.74],
|
||||
[-79.47, 43.74],
|
||||
[-79.47, 43.56],
|
||||
],
|
||||
],
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (path === '/api/gnss-integrity')
|
||||
return json({
|
||||
ageMs: 0,
|
||||
rows: ['a1', 'a2', 'a3'].map((hex) => ({
|
||||
hex,
|
||||
lat: 43.65,
|
||||
lon: -79.38,
|
||||
nic: 0,
|
||||
nacp: 0,
|
||||
degraded: true,
|
||||
gpsLost: false,
|
||||
})),
|
||||
});
|
||||
if (url.hostname === 'api.weather.gc.ca')
|
||||
return json({
|
||||
type: 'FeatureCollection',
|
||||
features:
|
||||
group !== 'ground'
|
||||
? []
|
||||
: [
|
||||
{
|
||||
type: 'Feature',
|
||||
...(path.includes('aqhi-stations')
|
||||
? {
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [-79.38, 43.65],
|
||||
},
|
||||
properties: {
|
||||
location_id: 'QA1',
|
||||
location_name_en: 'Fixture Toronto',
|
||||
},
|
||||
}
|
||||
: {
|
||||
properties: {
|
||||
location_id: 'QA1',
|
||||
aqhi: 9,
|
||||
observation_datetime: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
if (path === '/api/drone-airspace')
|
||||
return json({
|
||||
rows: [
|
||||
{
|
||||
stableId: '310',
|
||||
name: 'Fixture Zone',
|
||||
typeCode: 'P',
|
||||
type: 'Prohibited',
|
||||
lowerAltitude: 'SFC',
|
||||
upperAltitude: '18,000 ft MSL',
|
||||
timesOfUse: 'CONTINUOUS',
|
||||
agency: null,
|
||||
city: 'WASHINGTON',
|
||||
state: 'DC',
|
||||
polygons: [
|
||||
[
|
||||
[
|
||||
[-77.06, 38.88],
|
||||
[-77, 38.88],
|
||||
[-77, 38.92],
|
||||
[-77.06, 38.92],
|
||||
[-77.06, 38.88],
|
||||
],
|
||||
],
|
||||
],
|
||||
},
|
||||
],
|
||||
stale: false,
|
||||
fetchedAt: epoch,
|
||||
});
|
||||
if (path === '/api/overpass/status') return json({ configured: true });
|
||||
if (path.startsWith('/api/overpass/'))
|
||||
return json({
|
||||
elements: [
|
||||
{
|
||||
type: 'node',
|
||||
id: 1,
|
||||
lat: 38.9,
|
||||
lon: -77.03,
|
||||
tags: { name: 'Fixture Clinic', amenity: 'clinic' },
|
||||
},
|
||||
],
|
||||
});
|
||||
if (path === '/api/ghostmaps/catalog')
|
||||
return json({
|
||||
commit: 'fixture',
|
||||
products: [
|
||||
{ id: 'cip', version: 'fixture', title: 'Fixture', defaultOn: true },
|
||||
],
|
||||
});
|
||||
if (path.startsWith('/api/ghostmaps/file/'))
|
||||
return Promise.resolve(new Response(new Uint8Array(ghost)));
|
||||
if (path.startsWith('/api/celestrak/'))
|
||||
return Promise.resolve(
|
||||
new Response(groups[path.split('/').pop()] ?? groups.stations),
|
||||
);
|
||||
if (path === '/api/launches') return json(launches);
|
||||
if (path === '/api/firms') return json(firms);
|
||||
if (path === '/api/route') {
|
||||
const [a, b] = url.searchParams
|
||||
.get('coords')
|
||||
.split(';')
|
||||
.map((point) => point.split(',').map(Number));
|
||||
const geometry = [a, [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2], b];
|
||||
return json({
|
||||
ok: true,
|
||||
geometry,
|
||||
distanceM: 9000,
|
||||
durationS: 800,
|
||||
steps: geometry.map(([lon, lat], i) => ({
|
||||
lon,
|
||||
lat,
|
||||
instruction: 'Route step ' + i,
|
||||
distanceM: 3000,
|
||||
durationS: 120,
|
||||
})),
|
||||
});
|
||||
}
|
||||
if (path === '/api/terrain/heights')
|
||||
return json({
|
||||
results: (url.searchParams.get('points') ?? '')
|
||||
.split(';')
|
||||
.map(() => ({ ellipsoid: 0 })),
|
||||
});
|
||||
// Same-origin proxies must not silently turn this into a live-feed test.
|
||||
// Delegate only routes answered by the fixtures above or the strict harness.
|
||||
if (path.startsWith('/api/')) {
|
||||
if (path === '/api/google/nearby-places') return json({ places: [] });
|
||||
if (path === '/api/setup/status') return json({ ready: true });
|
||||
if (/^\/api\/(firms|mapillary)\/status$/.test(path) && group !== 'street')
|
||||
return json({ hasKey: false, configured: false });
|
||||
if (path === '/api/tomtom/status' && group !== 'traffic')
|
||||
return json({ hasKey: false });
|
||||
const delegated = {
|
||||
urban: /^\/api\/(gbfs|transit)\//,
|
||||
cameras: /^\/api\/cctv\//,
|
||||
radio: /^\/api\/radio\//,
|
||||
street: /^\/api\/mapillary\//,
|
||||
weather: /^\/api\/(weather\/|cyclones)/,
|
||||
wind: /^\/api\/wind\//,
|
||||
traffic: /^\/api\/tomtom\//,
|
||||
wildlife: /^\/api\/(inaturalist|acartia)\//,
|
||||
}[group];
|
||||
if (
|
||||
!delegated?.test(path) &&
|
||||
![
|
||||
'/api/openai/hud-summary/status',
|
||||
'/api/google/tiles-token',
|
||||
'/api/realtime/debug-log',
|
||||
].includes(path)
|
||||
) {
|
||||
window.__coldStartUnexpectedApi.push(path);
|
||||
return Promise.reject(new Error(`No cold-start fixture for ${path}`));
|
||||
}
|
||||
}
|
||||
return original(input, options);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import vm from 'node:vm';
|
||||
import { coldStartFixtures } from './coldStartFixtures.mjs';
|
||||
import { COLD_START_GROUPS } from './coldStart.mjs';
|
||||
|
||||
const origin = 'http://127.0.0.1:4309';
|
||||
test('every group has valid strict fixture routes', async () => {
|
||||
for (const group of Object.keys(COLD_START_GROUPS)) {
|
||||
const fixture = await coldStartFixtures(origin, group);
|
||||
assert.ok(Number.isFinite(fixture.epoch));
|
||||
for (const { pattern } of fixture.routes)
|
||||
assert.ok(new URLPattern(pattern));
|
||||
for (const pattern of fixture.fixtures) assert.ok(new URLPattern(pattern));
|
||||
}
|
||||
});
|
||||
|
||||
test('imagery fixtures answer catalog, thumbnails and every GIBS tile subdomain', async () => {
|
||||
const { routes } = await coldStartFixtures(origin, 'imagery');
|
||||
for (const host of ['cmr', 'wvs', 'gibs-a', 'gibs-b', 'gibs-c']) {
|
||||
const url = `https://${host}.earthdata.nasa.gov/fixture`;
|
||||
const route = routes.find(({ pattern }) =>
|
||||
new URLPattern(pattern).test(url),
|
||||
);
|
||||
assert.ok(route, host);
|
||||
const response = await route.respond({ url });
|
||||
assert.equal(
|
||||
response.contentType,
|
||||
host === 'cmr' ? 'application/json' : 'image/png',
|
||||
);
|
||||
assert.ok(response.body.length);
|
||||
}
|
||||
});
|
||||
|
||||
test('wind fixture supplies the declared grid dimensions and uniform vectors', async () => {
|
||||
const { routes } = await coldStartFixtures(origin, 'wind');
|
||||
const manifest = JSON.parse(
|
||||
routes[0].respond({
|
||||
url: origin + '/api/wind/manifest?model=gfs&overlay=temperature',
|
||||
}).body,
|
||||
);
|
||||
const response = routes[0].respond({ url: origin + manifest.gridUrl });
|
||||
const values = new Float32Array(
|
||||
response.body.buffer,
|
||||
response.body.byteOffset,
|
||||
response.body.length / 4,
|
||||
);
|
||||
const count = manifest.grid.nx * manifest.grid.ny;
|
||||
assert.equal(values.length, count * 3);
|
||||
assert.ok(values.slice(0, count).every((value) => value === 8));
|
||||
assert.ok(values.slice(count, count * 2).every((value) => value === 6));
|
||||
});
|
||||
|
||||
test('independent pages receive identical stationary aircraft and timestamps through fetch', async () => {
|
||||
const fixture = await coldStartFixtures(origin, 'aircraft');
|
||||
const scripts = [];
|
||||
await fixture.install({
|
||||
evaluateOnNewDocument: async (fn, args) => scripts.push({ fn, args }),
|
||||
});
|
||||
const page = () => {
|
||||
const context = vm.createContext({
|
||||
URL,
|
||||
Response,
|
||||
EventTarget,
|
||||
location: { href: origin },
|
||||
localStorage: { setItem() {} },
|
||||
window: {
|
||||
EventSource: class {},
|
||||
fetch() {
|
||||
throw new Error('Unstubbed network request');
|
||||
},
|
||||
},
|
||||
});
|
||||
for (const { fn, args } of scripts)
|
||||
vm.runInContext(`(${fn})(${JSON.stringify(args)})`, context);
|
||||
return context;
|
||||
};
|
||||
const a = page(),
|
||||
b = page();
|
||||
for (const path of ['/api/flights', '/api/military', '/api/flights/track']) {
|
||||
const read = async (context) =>
|
||||
(
|
||||
await vm.runInContext(`window.fetch(${JSON.stringify(path)})`, context)
|
||||
).json();
|
||||
assert.deepEqual(await read(a), await read(b));
|
||||
}
|
||||
assert.equal(vm.runInContext('Date.now()', a), fixture.epoch);
|
||||
assert.equal(vm.runInContext('+new Date()', b), fixture.epoch);
|
||||
await assert.rejects(
|
||||
vm.runInContext("window.fetch('/api/unexpected-feed')", a),
|
||||
/No cold-start fixture/,
|
||||
);
|
||||
assert.equal(
|
||||
vm.runInContext('window.__coldStartUnexpectedApi[0]', a),
|
||||
'/api/unexpected-feed',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,263 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { optionSpecs, REGISTRY_BY_ID } from '../../src/data/layerState.js';
|
||||
import { createFakeEngine } from '../../src/globe/testing/fakeEngine.js';
|
||||
import { createOsmPlacesLayer } from '../../src/layers/osmPlaces/index.js';
|
||||
import { createWindLayer } from '../../src/layers/wind/index.js';
|
||||
import { createApplicationLocalAdsb } from '../../src/app/layers/localAdsb.js';
|
||||
import { radioLayer } from '../../src/data/radio.js';
|
||||
import {
|
||||
withRadioLayer,
|
||||
radioStationRow,
|
||||
} from '../../src/testSupport/radioLayerHarness.mjs';
|
||||
import { radioTestEngine } from '../../src/testSupport/radioTestEngine.mjs';
|
||||
import { loadLayerRenderers } from '../../src/app/layers/renderers.js';
|
||||
import { createApplicationDirectionsLayer } from '../../src/data/directions.js';
|
||||
import { createDirectionsDisplay } from '../../src/layers/directions/render/display.js';
|
||||
|
||||
// Each entry has a non-default retention exercise below. A new declaration
|
||||
// needs an exercise; spelling a plausible field name alone cannot suppress QA.
|
||||
const declarations = {
|
||||
'src/layers/radio/index.js': ['selected', 'audioState'],
|
||||
'src/layers/wind/index.js': ['reading'],
|
||||
'src/layers/osmPlaces/index.js': ['enabled', 'results'],
|
||||
'src/layers/localAdsb/index.js': ['enabled'],
|
||||
'src/layers/directions/index.js': ['route'],
|
||||
};
|
||||
test('every declaration is covered by a real handoff exercise, without extra fields', () => {
|
||||
const files = execFileSync(
|
||||
'git',
|
||||
['ls-files', '--cached', '--others', '--exclude-standard', 'src'],
|
||||
{ encoding: 'utf8' },
|
||||
)
|
||||
.trim()
|
||||
.split('\n');
|
||||
const found = {};
|
||||
for (const file of files.filter((file) => file.endsWith('.js'))) {
|
||||
const text = readFileSync(file, 'utf8');
|
||||
for (const match of text.matchAll(/sessionState\s*:\s*(\[[^\]]*\])/g)) {
|
||||
assert.ok(!found[file], `Duplicate declaration in ${file}`);
|
||||
found[file] = JSON.parse(match[1].replaceAll("'", '"'));
|
||||
}
|
||||
}
|
||||
assert.deepEqual(found, declarations);
|
||||
});
|
||||
|
||||
function verifyFields(layer, before, after) {
|
||||
assert.deepEqual([...layer.sessionState].sort(), Object.keys(before).sort());
|
||||
for (const field of layer.sessionState) {
|
||||
assert.notEqual(before[field], undefined, field);
|
||||
assert.deepEqual(
|
||||
after[field],
|
||||
before[field],
|
||||
`${layer.id}.${field} survived detach/attach`,
|
||||
);
|
||||
assert.ok(
|
||||
!optionSpecs(layer.id).some((spec) => spec.key === field),
|
||||
`${field} is not a share option`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
test('radio selection and playback survive reattachment; volume is shareable', async () => {
|
||||
await loadLayerRenderers('cesium');
|
||||
const station = radioStationRow();
|
||||
await withRadioLayer({ stations: [station] }, async () => {
|
||||
radioLayer.selectStation(station.id, {
|
||||
autoplay: true,
|
||||
focus: false,
|
||||
origin: 'user',
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const state = () => {
|
||||
const { selected, audioState } = radioLayer.getUIState();
|
||||
return { selected, audioState };
|
||||
};
|
||||
const before = state();
|
||||
assert.equal(before.selected.id, station.id);
|
||||
assert.notEqual(before.audioState, 'idle');
|
||||
radioLayer.detachRenderer();
|
||||
radioLayer.attachRenderer({ engine: radioTestEngine() });
|
||||
verifyFields(radioLayer, before, state());
|
||||
assert.ok(optionSpecs('radio').some((spec) => spec.key === 'volume'));
|
||||
});
|
||||
});
|
||||
|
||||
test('wind inspection survives reattachment; pause is a share option', async () => {
|
||||
const field = {
|
||||
model: 'gfs',
|
||||
grid: { nx: 2, ny: 2, lo1: 0, la1: 90, dx: 180, dy: 180 },
|
||||
u: new Float32Array(4).fill(4),
|
||||
v: new Float32Array(4).fill(3),
|
||||
cycle: { validIso: '2026-11-02T12:00:00Z' },
|
||||
};
|
||||
const layer = createWindLayer({
|
||||
feed: { getSnapshot: async () => field },
|
||||
createNativeRendering: () => ({
|
||||
attach() {},
|
||||
start() {},
|
||||
stop() {},
|
||||
clear() {},
|
||||
destroy() {},
|
||||
setField() {},
|
||||
getDiagnostics: () => ({}),
|
||||
}),
|
||||
});
|
||||
try {
|
||||
layer.init(null, { engine: createFakeEngine() });
|
||||
layer.enable();
|
||||
await layer.update();
|
||||
layer.setParams({ inspect: true });
|
||||
const before = { reading: layer.getRowControls().summary.reading };
|
||||
assert.ok(before.reading);
|
||||
layer.detachRenderer();
|
||||
layer.attachRenderer({ engine: createFakeEngine() });
|
||||
verifyFields(layer, before, {
|
||||
reading: layer.getRowControls().summary.reading,
|
||||
});
|
||||
assert.ok(optionSpecs('wind').some((spec) => spec.key === 'paused'));
|
||||
} finally {
|
||||
layer.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('OSM results and visibility survive reattachment but have no share registry entry', () => {
|
||||
const host = { clearSource() {}, setEntries() {}, setVisible() {} };
|
||||
const layer = createOsmPlacesLayer({
|
||||
overlayHost: host,
|
||||
overpassConfigured: async () => true,
|
||||
});
|
||||
const first = createFakeEngine(),
|
||||
second = createFakeEngine();
|
||||
const records = [
|
||||
{ id: 'node/1', lat: 38.9, lon: -77.03, name: 'Fixture Clinic' },
|
||||
];
|
||||
try {
|
||||
layer.init(null, { engine: first });
|
||||
layer.setResults(records, { label: 'Clinics', area: 'Washington' });
|
||||
layer.enable();
|
||||
const before = {
|
||||
enabled: first.movers.list()[0].shown,
|
||||
results: layer.getAnalystRecords(),
|
||||
};
|
||||
assert.equal(before.enabled, true);
|
||||
assert.equal(before.results.length, 1);
|
||||
layer.detachRenderer();
|
||||
layer.attachRenderer({ engine: second });
|
||||
verifyFields(layer, before, {
|
||||
enabled: second.movers.list()[0].shown,
|
||||
results: layer.getAnalystRecords(),
|
||||
});
|
||||
assert.equal(second.movers.list()[0].count, 1);
|
||||
assert.equal(REGISTRY_BY_ID.has(layer.id), false);
|
||||
} finally {
|
||||
layer.destroy();
|
||||
first.destroy();
|
||||
second.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('local receiver visibility survives reattachment without restarting its session', async () => {
|
||||
let starts = 0,
|
||||
visible = false;
|
||||
const layer = createApplicationLocalAdsb({
|
||||
receiver: { getState: () => ({ mode: 'adsb', aircraft: [] }) },
|
||||
feeds: { getState: () => ({ records: [] }), start: () => starts++ },
|
||||
renderer: {
|
||||
create: ({ engine }) => ({
|
||||
engine,
|
||||
attach() {},
|
||||
detach() {},
|
||||
destroy() {},
|
||||
setVisible: (value) => {
|
||||
visible = value;
|
||||
},
|
||||
view: { on: () => () => {} },
|
||||
}),
|
||||
},
|
||||
});
|
||||
const first = createFakeEngine(),
|
||||
second = createFakeEngine();
|
||||
try {
|
||||
layer.init(null, { engine: first });
|
||||
await layer.enable();
|
||||
const before = { enabled: visible };
|
||||
assert.equal(visible, true);
|
||||
layer.detachRenderer();
|
||||
visible = false;
|
||||
layer.attachRenderer({ engine: second });
|
||||
verifyFields(layer, before, { enabled: visible });
|
||||
assert.equal(starts, 1);
|
||||
assert.equal(REGISTRY_BY_ID.has(layer.id), false);
|
||||
} finally {
|
||||
layer.destroy();
|
||||
first.destroy();
|
||||
second.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('directions retain the route and its markers; a cold layer has no route', async () => {
|
||||
const geometry = [
|
||||
[10.78, 55.32],
|
||||
[10.8, 55.325],
|
||||
[10.82, 55.33],
|
||||
];
|
||||
const layer = createApplicationDirectionsLayer({
|
||||
source: {
|
||||
getRoute: async () => ({
|
||||
ok: true,
|
||||
geometry,
|
||||
distanceM: 9000,
|
||||
durationS: 800,
|
||||
steps: geometry.map(([lon, lat], i) => ({
|
||||
lon,
|
||||
lat,
|
||||
instruction: `Step ${i}`,
|
||||
distanceM: 3000,
|
||||
durationS: 120,
|
||||
})),
|
||||
}),
|
||||
},
|
||||
groundFloor: {
|
||||
GROUND_FLOOR_LIFT_M: 2,
|
||||
cachedGroundFloor: () => 0,
|
||||
warmGroundFloor() {},
|
||||
},
|
||||
rendererFactory: createDirectionsDisplay,
|
||||
});
|
||||
layer._setOverlayHostForTest({
|
||||
setEntries() {},
|
||||
setVisible() {},
|
||||
clearSource() {},
|
||||
});
|
||||
const first = createFakeEngine(),
|
||||
second = createFakeEngine();
|
||||
try {
|
||||
layer.init(null, { engine: first });
|
||||
layer.enable();
|
||||
const cold = layer.getRowControls().list;
|
||||
layer.placeEndpoint('a', { lat: 55.32, lon: 10.78 });
|
||||
layer.placeEndpoint('b', { lat: 55.33, lon: 10.82 });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const before = { route: layer.getRowControls().list };
|
||||
assert.notDeepEqual(before.route, cold);
|
||||
assert.equal(layer.getStats().count, 3);
|
||||
layer.detachRenderer();
|
||||
layer.attachRenderer({ engine: second });
|
||||
verifyFields(layer, before, { route: layer.getRowControls().list });
|
||||
assert.equal(
|
||||
second.ground.list().find(({ id }) => id === 'directions-route').count,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
second.movers.list().find(({ id }) => id === 'directions-markers').count,
|
||||
2,
|
||||
);
|
||||
} finally {
|
||||
layer.destroy();
|
||||
first.destroy();
|
||||
second.destroy();
|
||||
}
|
||||
});
|
||||
@@ -1511,6 +1511,7 @@ export function createDirectionsLayer({
|
||||
};
|
||||
const directionsLayer = {
|
||||
id: 'directions',
|
||||
sessionState: ['route'],
|
||||
name: 'Directions',
|
||||
icon: '🧭',
|
||||
source: 'OSM routing',
|
||||
|
||||
@@ -606,6 +606,7 @@ export function createLocalAdsbLayer({
|
||||
const picks = { owner: LAYER_ID, predicate: (id) => markers.has(id) };
|
||||
return {
|
||||
id: LAYER_ID,
|
||||
sessionState: ['enabled'],
|
||||
name: LAYER_NAME,
|
||||
icon: '📡',
|
||||
source: LAYER_SOURCE,
|
||||
|
||||
@@ -72,6 +72,7 @@ export function createOsmPlacesLayer({
|
||||
|
||||
return {
|
||||
id: OSM_PLACES_LAYER_ID,
|
||||
sessionState: ['enabled', 'results'],
|
||||
name: 'OSM Places',
|
||||
icon: '⌖',
|
||||
source: 'OpenStreetMap',
|
||||
|
||||
@@ -52,6 +52,7 @@ export function createRadioLayer({ services, source, rendererFactory }) {
|
||||
parts.lifecycle.methods,
|
||||
parts.ingestion?.methods,
|
||||
{
|
||||
sessionState: ['selected', 'audioState'],
|
||||
radioGlobeLabel: parts.labels.radioGlobeLabel,
|
||||
createRadioSelectedOverlayEntry:
|
||||
parts.labels.createRadioSelectedOverlayEntry,
|
||||
|
||||
@@ -106,6 +106,7 @@ export function createWindLayer({
|
||||
const unsubscribeClock = clock?.subscribe(notify);
|
||||
const layer = {
|
||||
id: 'wind',
|
||||
sessionState: ['reading'],
|
||||
name: 'Wind',
|
||||
icon: '🌬',
|
||||
source: 'GFS / ECMWF IFS · FORECAST',
|
||||
|
||||
Reference in new issue
Block a user