From 6b0d3f467572b2ae691e240edf202a82e30c45d8 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 10 Nov 2026 21:51:54 +0400 Subject: [PATCH] Fix the camera picker and oblique traffic waits in two QA scripts The CCTV camera check lost camera B from the picker when the flight to camera A changed the cameras in view; the picker now keeps focus while A is held. The oblique traffic wait compared exact seven-decimal view keys that a settled camera never reproduced; it compares the coverage with the current view within small tolerances and prints both keys on timeout. --- scripts/qa-camera-controls.mjs | 2 + scripts/qa-camera-controls.test.mjs | 107 ++++++++++++++++++++++++++ scripts/qa/traffic.mjs | 80 ++++++++++++++------ scripts/qa/traffic.test.mjs | 113 ++++++++++++++++++++++++++-- 4 files changed, 271 insertions(+), 31 deletions(-) create mode 100644 scripts/qa-camera-controls.test.mjs diff --git a/scripts/qa-camera-controls.mjs b/scripts/qa-camera-controls.mjs index 7dbe8111f..db6b4c7e5 100644 --- a/scripts/qa-camera-controls.mjs +++ b/scripts/qa-camera-controls.mjs @@ -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( diff --git a/scripts/qa-camera-controls.test.mjs b/scripts/qa-camera-controls.test.mjs new file mode 100644 index 000000000..9b362cd99 --- /dev/null +++ b/scripts/qa-camera-controls.test.mjs @@ -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'], + ); +}); diff --git a/scripts/qa/traffic.mjs b/scripts/qa/traffic.mjs index abc603e0b..ced353a74 100644 --- a/scripts/qa/traffic.mjs +++ b/scripts/qa/traffic.mjs @@ -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(); + } } diff --git a/scripts/qa/traffic.test.mjs b/scripts/qa/traffic.test.mjs index e44a40720..684b8cca1 100644 --- a/scripts/qa/traffic.test.mjs +++ b/scripts/qa/traffic.test.mjs @@ -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; + }, ); });