Merge branch 'german/refactor-e12-script-regressions-2' into german/navara-refactor

This commit is contained in:
German Khokhlov committed 2026-11-10 22:05:58 +04:00
commit ab41c765cc
4 files changed
+271 -31

No files matched your search

+2
View File
@@ -279,6 +279,8 @@ try {
.classList.contains('has-frame'),
);
await page.setCacheEnabled(false);
// Keep both options while selecting A flies the view away from B.
await page.focus('#cctv-camera-select');
delayCameraA = true;
await page.select('#cctv-camera-select', 'qa-camera-a');
await page.waitForFunction(
+107
View File
@@ -0,0 +1,107 @@
import '../src/testSupport/registerDom.mjs';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { createPinia } from 'pinia';
import { createApp, nextTick } from 'vue';
import { CctvControls } from '../src/ui/cctvControls.js';
import { useCctvStore } from '../src/ui/stores/cctv.js';
import CctvPanel from '../src/ui/views/panels/CctvPanel.vue';
const source = readFileSync(
new URL('./qa-camera-controls.mjs', import.meta.url),
'utf8',
);
test('the delayed-frame scenario keeps camera B selectable after the flight to A', async (t) => {
document.body.replaceChildren();
const host = document.body.appendChild(document.createElement('div'));
const pinia = createPinia();
const app = createApp(CctvPanel).use(pinia);
app.mount(host);
const select = document.getElementById('cctv-camera-select');
const controls = new CctvControls({
elements: {
_cctvPanel: document.getElementById('cctv-panel'),
_cctvSelect: select,
},
cctv: {},
actions: {
isEnabled: () => true,
setPanelCollapsed() {},
syncViewport() {},
},
store: useCctvStore(pinia),
});
t.after(() => {
controls.destroy();
app.unmount();
});
const [a, b] = ['a', 'b'].map((id) => ({
id: `qa-camera-${id}`,
name: id,
city: 'Austin',
}));
const render = async (activeCamera, cameraIndex) => {
controls._renderCctvState({
enabled: true,
count: 2,
activeCameraId: activeCamera.id,
activeCamera,
cameraIndex,
});
await nextTick();
};
await render(b, [a, b]);
const prepare = source.slice(
source.indexOf(' await page.setCacheEnabled(false);'),
source.indexOf(' delayCameraA = true;'),
);
await new Function('page', `return (async () => { ${prepare} })();`)({
setCacheEnabled: async () => {},
focus: async (selector) => document.querySelector(selector).focus(),
});
await render(a, [a]);
assert.ok([...select.options].some((option) => option.value === b.id));
select.value = b.id;
assert.equal(select.value, b.id);
select.blur();
await nextTick();
assert.ok(
![...select.options].some((option) => option.value === b.id),
'an unfocused picker drops B once the flight takes it out of view',
);
});
test('camera B fixtures respond while camera A frames remain held', async () => {
let route;
const delayedFrames = new Set();
const routing = source.slice(
source.indexOf(" page.on('request',"),
source.indexOf(' await page.goto('),
);
new Function('page', 'origin', 'delayCameraA', 'delayedFrames', routing)(
{ on: (event, handler) => (route = handler) },
'http://localhost:4321',
true,
delayedFrames,
);
const replies = [];
for (const id of ['a', 'b']) {
route({
url: () => `http://localhost:4321/api/cctv/frame/qa-camera-${id}?ts=1`,
respond: async (response) => replies.push({ id, response }),
});
}
assert.equal(delayedFrames.size, 1);
assert.deepEqual(
replies.map(({ id }) => id),
['b'],
);
assert.match(replies[0].response.body, /QA CAMERA B/);
await Promise.all([...delayedFrames].map((respond) => respond()));
assert.deepEqual(
replies.map(({ id }) => id),
['b', 'a'],
);
});
+56 -24
View File
@@ -38,32 +38,64 @@ export function moveTrafficCamera(page, view, seconds) {
);
}
/** Compare the layer's camera-event sample with the view currently on screen. */
export function trafficReadiness() {
const { engine, dataManager } = window.__godsEyeView;
const pose = engine.camera.pose();
const view = [
pose.cartographic.latitude,
pose.cartographic.longitude,
pose.cartographic.height / 10000,
pose.heading,
pose.pitch,
];
const stats = dataManager.layers.get('traffic').module.getStats();
const coverageViewKey = stats.roadBounds?.coverage?.viewKey ?? null;
const coverage = coverageViewKey?.split(',').map(Number);
// Allow rounding and sub-metre settling, not another altitude or camera view.
// Angles are radians; scaled height has a one-metre tolerance.
const tolerances = [1e-7, 1e-7, 1e-4, 1e-6, 1e-6];
const viewMatches = Boolean(
coverage?.length === view.length &&
view.every((value, i) => {
if (!Number.isFinite(value) || !Number.isFinite(coverage[i]))
return false;
const delta = value - coverage[i];
const distance =
i === 1 || i === 3
? Math.abs(Math.atan2(Math.sin(delta), Math.cos(delta)))
: Math.abs(delta);
return distance <= tolerances[i];
}),
);
return {
viewKey: view.map((value) => Number(value).toFixed(7)).join(','),
coverageViewKey,
count: stats.count,
loading: stats.loading,
surfacePending: stats.surfacePending,
settled:
viewMatches && stats.count > 0 && !stats.loading && !stats.surfacePending,
};
}
/** A quiet loader can still be waiting for its camera-change debounce. */
export async function whenTrafficSettled(page) {
await whenTilesLoaded(page);
await whenSettled(page);
const handle = await page.waitForFunction(
() => {
const { engine, dataManager } = window.__godsEyeView;
const pose = engine.camera.pose();
const viewKey = [
pose.cartographic.latitude,
pose.cartographic.longitude,
pose.cartographic.height / 10000,
pose.heading,
pose.pitch,
]
.map((value) => Number(value || 0).toFixed(7))
.join(',');
const stats = dataManager.layers.get('traffic').module.getStats();
return (
stats.roadBounds?.coverage?.viewKey === viewKey &&
stats.count > 0 &&
!stats.loading &&
!stats.surfacePending
);
},
{ timeout: 60000, polling: 100 },
);
await handle.dispose();
let handle;
try {
handle = await page.waitForFunction(`(${trafficReadiness})().settled`, {
timeout: 60000,
polling: 100,
});
} catch (error) {
if (error.name !== 'TimeoutError') throw error;
const observed = await page.evaluate(trafficReadiness);
throw new Error(`Readiness timeout: traffic; ${JSON.stringify(observed)}`, {
cause: error,
});
} finally {
await handle?.dispose();
}
}
+106 -7
View File
@@ -1,7 +1,12 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createFakeEngine } from '../../src/globe/testing/fakeEngine.js';
import { moveTrafficCamera, whenTrafficSettled } from './traffic.mjs';
import { createReticleFootprint } from '../../src/layers/traffic/footprint.js';
import {
moveTrafficCamera,
trafficReadiness,
whenTrafficSettled,
} from './traffic.mjs';
function fixture(t) {
const engine = createFakeEngine({ kind: 'cesium' });
@@ -69,7 +74,9 @@ test('traffic readiness rejects old coverage, pending debounce and partial popul
let disposed = 0;
await whenTrafficSettled({
evaluate: async () => {},
async waitForFunction(predicate) {
async waitForFunction(expression) {
const predicate = () =>
typeof expression === 'function' ? expression() : eval(expression);
if (++calls < 3) assert.equal(predicate(), true);
else {
assert.equal(
@@ -100,17 +107,109 @@ test('traffic readiness rejects old coverage, pending debounce and partial popul
assert.equal(disposed, 3);
});
test('traffic loading timeouts fail the scenario', async (t) => {
fixture(t);
test('traffic readiness accepts the layer footprint after rounding and tiny pose drift', (t) => {
const app = fixture(t);
app.engine.fake.setPose({
latitude: (30.2672 * Math.PI) / 180,
longitude: (-97.7431 * Math.PI) / 180,
height: 300,
heading: 2 * Math.PI - 1e-8,
pitch: -Math.PI / 12,
});
const stats = {
count: 100,
loading: false,
surfacePending: 0,
roadBounds: createReticleFootprint()(app.engine, 300),
};
app.dataManager = {
layers: new Map([['traffic', { module: { getStats: () => stats } }]]),
};
assert.equal(trafficReadiness().settled, true);
app.engine.fake.setPose({
latitude: (30.2672 * Math.PI) / 180 + 2e-8,
longitude: (-97.7431 * Math.PI) / 180 - 2e-8,
height: 300.1,
heading: 1e-8,
pitch: -Math.PI / 12 + 2e-8,
});
const observed = trafficReadiness();
assert.notEqual(observed.viewKey, observed.coverageViewKey);
assert.equal(observed.settled, true);
});
test('traffic readiness rejects changed views and malformed coverage keys', (t) => {
const app = fixture(t);
const stats = {
count: 100,
loading: false,
surfacePending: 0,
roadBounds: { coverage: {} },
};
app.dataManager = {
layers: new Map([['traffic', { module: { getStats: () => stats } }]]),
};
for (const key of [
null,
'old view',
'0,0,0.1,0',
'0,0,0.1,0,NaN',
'0,0,Infinity,0,-0.2617994',
'0.01,0,0.1,0,-0.2617994',
'0,0.01,0.1,0,-0.2617994',
'0,0,0.3,0,-0.2617994',
'0,0,0.1,0.01,-0.2617994',
'0,0,0.1,0,-0.7853982',
]) {
stats.roadBounds.coverage.viewKey = key;
assert.equal(trafficReadiness().settled, false, String(key));
}
});
test('traffic loading timeouts include both keys and pending work', async (t) => {
const app = fixture(t);
const coverageViewKey = '0.01,0,0.1,0,-0.2617994';
app.dataManager = {
layers: new Map([
[
'traffic',
{
module: {
getStats: () => ({
count: 12,
loading: true,
surfacePending: 2,
roadBounds: { coverage: { viewKey: coverageViewKey } },
}),
},
},
],
]),
};
const timeout = new Error('traffic loading timeout');
timeout.name = 'TimeoutError';
let calls = 0;
await assert.rejects(
whenTrafficSettled({
evaluate: async () => {},
evaluate: async (fn) => (typeof fn === 'function' ? fn() : undefined),
async waitForFunction() {
if (++calls === 3) throw new Error('traffic loading timeout');
if (++calls === 3) throw timeout;
return { dispose: async () => {} };
},
}),
/traffic loading timeout/,
(error) => {
assert.equal(error.cause, timeout);
assert.match(error.message, /Readiness timeout: traffic/);
assert.ok(
error.message.includes(`"coverageViewKey":"${coverageViewKey}"`),
);
assert.ok(
error.message.includes(
'"viewKey":"0.0000000,0.0000000,0.1000000,0.0000000,-0.2617994"',
),
);
assert.match(error.message, /"loading":true,"surfacePending":2/);
return true;
},
);
});