4626 lines
225 KiB
JavaScript
4626 lines
225 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Deterministic browser proof for the Radio companion layer.
|
|
*
|
|
* Intercepts only `/api/radio/*` with a 750-station fixture and stubs the
|
|
* browser media `play()` primitive. It proves marker scale/culling, dynamic
|
|
* station-tag filtering, first-click selection, direct-action-only playback, panel/Context
|
|
* independence, restoration without autoplay, responsive UI, and a clean
|
|
* console. Screenshots are written under the gitignored `qa-shots/radio/`.
|
|
*
|
|
* Run: node scripts/qa-radio.mjs --url http://localhost:4173
|
|
*/
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import puppeteer from 'puppeteer';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const REPO_ROOT = path.resolve(__dirname, '..');
|
|
const SHOTS_DIR = path.join(REPO_ROOT, 'qa-shots', 'radio');
|
|
const args = process.argv.slice(2);
|
|
const option = (name, fallback) => {
|
|
const index = args.indexOf(name);
|
|
return index >= 0 && args[index + 1] ? args[index + 1] : fallback;
|
|
};
|
|
const APP_URL = option('--url', process.env.QA_BASE_URL || 'http://localhost:4173');
|
|
const APP_ORIGIN = new URL(APP_URL).origin;
|
|
const HEADFUL = args.includes('--headful');
|
|
|
|
const chromeCandidates = [
|
|
process.env.PUPPETEER_EXECUTABLE_PATH,
|
|
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
|
|
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
].filter(Boolean);
|
|
const chrome = chromeCandidates.find((candidate) => {
|
|
try { return fs.existsSync(candidate); } catch { return false; }
|
|
});
|
|
|
|
const tags = [
|
|
'news', 'talk', 'weather,emergency', 'public safety,scanner',
|
|
'aviation,atc', 'marine,maritime', 'traffic,transit',
|
|
'music,jazz', 'music,rock', 'community',
|
|
];
|
|
const stations = Array.from({ length: 750 }, (_, index) => {
|
|
const row = Math.floor(index / 30);
|
|
const column = index % 30;
|
|
return {
|
|
id: `00000000-0000-4000-8000-${index.toString(16).padStart(12, '0')}`,
|
|
name: index === 360
|
|
? '100.3 The River - WQRV - Meridianville/Huntsville, AL'
|
|
: index === 390
|
|
? 'La Zeta (Hermosillo) - 93.9 FM - XHHY - Uniradio - Hermosillo, Sonora'
|
|
: `QA Radio ${String(index + 1).padStart(3, '0')}`,
|
|
lat: -72 + row * 6,
|
|
lon: -174 + column * 12,
|
|
streamUrl: `https://audio.example.test/${index}.mp3`,
|
|
homepage: `https://station.example.test/${index}`,
|
|
tags: tags[index % tags.length].split(','),
|
|
languages: index === 360 ? ['Spanish'] : ['English'],
|
|
state: `Region ${row + 1}`,
|
|
country: 'United States',
|
|
countryCode: 'US',
|
|
metadataTrust: 'untrusted-community',
|
|
codec: index % 2 ? 'AAC' : 'MP3',
|
|
bitrate: 128,
|
|
};
|
|
});
|
|
|
|
const results = [];
|
|
function check(name, ok, detail = '') {
|
|
results.push({ name, ok });
|
|
const label = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
|
|
console.log(` [${label}] ${name}${detail ? ` — ${detail}` : ''}`);
|
|
}
|
|
|
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
async function main() {
|
|
const response = await fetch(APP_URL).catch(() => null);
|
|
if (!response?.ok) {
|
|
console.error(`Dev server not reachable at ${APP_URL}`);
|
|
process.exit(2);
|
|
}
|
|
fs.mkdirSync(SHOTS_DIR, { recursive: true });
|
|
const browser = await puppeteer.launch({
|
|
headless: HEADFUL ? false : 'new',
|
|
...(chrome ? { executablePath: chrome } : {}),
|
|
args: [
|
|
'--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle',
|
|
'--disable-dev-shm-usage', '--disable-background-timer-throttling',
|
|
'--disable-renderer-backgrounding', '--window-size=1440,900',
|
|
],
|
|
});
|
|
|
|
try {
|
|
const page = await browser.newPage();
|
|
await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });
|
|
await page.evaluateOnNewDocument(() => {
|
|
window.__qaRadioPlayCalls = [];
|
|
window.__qaRadioFailedNearest = false;
|
|
window.__qaRadioDelayNextPlay = false;
|
|
window.__qaRadioDelayNextFailure = false;
|
|
window.__qaRadioFailNextPlay = false;
|
|
HTMLMediaElement.prototype.play = function play() {
|
|
window.__qaRadioPlayCalls.push(this.src);
|
|
if (window.__qaRadioDelayNextPlay) {
|
|
window.__qaRadioDelayNextPlay = false;
|
|
return new Promise((resolve) => {
|
|
window.__qaReleaseRadioPlay = () => {
|
|
this.dispatchEvent(new Event('playing'));
|
|
resolve();
|
|
delete window.__qaReleaseRadioPlay;
|
|
};
|
|
});
|
|
}
|
|
if (window.__qaRadioDelayNextFailure) {
|
|
window.__qaRadioDelayNextFailure = false;
|
|
return new Promise((resolve, reject) => {
|
|
window.__qaRejectRadioPlay = () => {
|
|
reject(new DOMException('QA delayed broadcaster failure', 'NotSupportedError'));
|
|
delete window.__qaRejectRadioPlay;
|
|
};
|
|
});
|
|
}
|
|
if (window.__qaRadioFailNextPlay) {
|
|
window.__qaRadioFailNextPlay = false;
|
|
return Promise.reject(new DOMException('QA silent broadcaster', 'NotSupportedError'));
|
|
}
|
|
if (this.src.endsWith('/330.mp3') && !window.__qaRadioFailedNearest) {
|
|
window.__qaRadioFailedNearest = true;
|
|
return Promise.reject(new DOMException('QA broadcaster unavailable', 'NotSupportedError'));
|
|
}
|
|
queueMicrotask(() => this.dispatchEvent(new Event('playing')));
|
|
return Promise.resolve();
|
|
};
|
|
});
|
|
await page.setRequestInterception(true);
|
|
let catalogDegraded = false;
|
|
let catalogStations = stations;
|
|
let catalogGeneration = 1;
|
|
let catalogResponseDelayMs = 0;
|
|
page.on('request', (request) => {
|
|
const url = new URL(request.url());
|
|
if (url.origin === APP_ORIGIN && url.pathname === '/api/radio/stations') {
|
|
const response = {
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
stations: catalogStations,
|
|
updatedAt: new Date().toISOString(),
|
|
stale: false,
|
|
degraded: catalogDegraded,
|
|
acceptedGeneration: catalogGeneration,
|
|
catalogInstance: 'qa-harness-instance',
|
|
}),
|
|
};
|
|
const delayMs = catalogResponseDelayMs;
|
|
catalogResponseDelayMs = 0;
|
|
if (delayMs > 0) setTimeout(() => void request.respond(response), delayMs);
|
|
else void request.respond(response);
|
|
return;
|
|
}
|
|
if (url.origin === APP_ORIGIN && /^\/api\/radio\/click\/[^/]+$/.test(url.pathname)) {
|
|
request.respond({ status: 204 });
|
|
return;
|
|
}
|
|
if (url.origin === APP_ORIGIN && url.pathname === '/api/openai/hud-summary') {
|
|
request.respond({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ summary: 'QA globe ready' }),
|
|
});
|
|
return;
|
|
}
|
|
if (url.origin === APP_ORIGIN && url.pathname === '/api/ais-live') {
|
|
request.respond({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
rows: [],
|
|
source: 'AISStream QA',
|
|
status: 'open',
|
|
error: null,
|
|
refreshing: false,
|
|
newestPositionAt: null,
|
|
lastMessageAt: null,
|
|
}),
|
|
});
|
|
return;
|
|
}
|
|
request.continue();
|
|
});
|
|
const consoleErrors = [];
|
|
const failedResponses = [];
|
|
const externalCesiumEndpointFailures = [];
|
|
page.on('console', (message) => {
|
|
if (message.type() === 'error') {
|
|
const sourceUrl = message.location()?.url || '';
|
|
consoleErrors.push(sourceUrl ? `${message.text()} [${sourceUrl}]` : message.text());
|
|
}
|
|
});
|
|
page.on('pageerror', (error) => consoleErrors.push(error.message));
|
|
page.on('requestfailed', (request) => {
|
|
const url = request.url();
|
|
if (/^https:\/\/api\.cesium\.com\/v1\/assets\/1\/endpoint(?:\?|$)/.test(url)) {
|
|
externalCesiumEndpointFailures.push(`${request.failure()?.errorText || 'request failed'} ${url}`);
|
|
}
|
|
});
|
|
page.on('response', (response) => {
|
|
if (response.status() >= 500) {
|
|
failedResponses.push(`HTTP ${response.status()} ${response.url()}`);
|
|
}
|
|
});
|
|
|
|
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
|
await page.waitForFunction(() => window.__godsEyeView?.dataManager, { timeout: 60_000 });
|
|
await page.waitForFunction(() => window.__godsEyeView?.styleManager?._dataManager?.layers?.has('radio'), { timeout: 60_000 });
|
|
await page.waitForFunction(
|
|
() => document.getElementById('loading-screen')?.classList.contains('hidden'),
|
|
{ timeout: 60_000 },
|
|
);
|
|
await page.waitForFunction(() => (
|
|
typeof window.__gevQaRegisterLayer === 'function'
|
|
&& typeof window.__gevQaUnregisterLayer === 'function'
|
|
));
|
|
await page.evaluate(() => {
|
|
window.__godsEyeView.viewer.camera.cancelFlight();
|
|
window.__godsEyeView.styleManager.setPanelCollapsed('pp-toggles', true);
|
|
window.__godsEyeView.styleManager.setPanelCollapsed('cctv-panel', true);
|
|
window.__godsEyeView.styleManager.setPanelCollapsed('radio-panel', true);
|
|
window.__godsEyeView.styleManager.setPanelCollapsed('global-context-panel', true);
|
|
});
|
|
|
|
const enableStartedAt = Date.now();
|
|
const initialDisclosure = await page.evaluate(() => {
|
|
const button = document.getElementById('context-radio-toggle-btn');
|
|
const mini = document.getElementById('context-radio-mini');
|
|
const enabledBefore = window.__godsEyeView.dataManager.isEnabled('radio');
|
|
button.click();
|
|
return {
|
|
enabledBefore,
|
|
enabledAfterDisclosure: window.__godsEyeView.dataManager.isEnabled('radio'),
|
|
expanded: button.getAttribute('aria-expanded'),
|
|
miniHidden: mini.hidden,
|
|
};
|
|
});
|
|
check(
|
|
'Radio launcher discloses compact controls without changing power',
|
|
!initialDisclosure.enabledBefore && !initialDisclosure.enabledAfterDisclosure
|
|
&& initialDisclosure.expanded === 'true' && !initialDisclosure.miniHidden,
|
|
JSON.stringify(initialDisclosure),
|
|
);
|
|
await page.$eval('#context-radio-mini-enable-btn', (button) => button.click());
|
|
await page.waitForFunction(() => {
|
|
const state = window.__godsEyeView.dataManager.layers.get('radio').module.getUIState();
|
|
return state.enabled && !state.loading && state.stationCount === 750;
|
|
});
|
|
const enableElapsedMs = Date.now() - enableStartedAt;
|
|
const initial = await page.evaluate(() => {
|
|
const module = window.__godsEyeView.dataManager.layers.get('radio').module;
|
|
const state = module.getUIState();
|
|
const context = document.getElementById('global-context-panel');
|
|
const radio = document.getElementById('radio-panel');
|
|
return {
|
|
state,
|
|
plays: window.__qaRadioPlayCalls.length,
|
|
panelExpanded: !radio.classList.contains('collapsed'),
|
|
contextExpanded: !context.classList.contains('collapsed'),
|
|
nestedInContext: context.contains(radio),
|
|
filterOptions: document.getElementById('radio-filter').options.length,
|
|
filterPalette: Array.from(document.getElementById('radio-filter').options).map((entry) => ({
|
|
id: entry.value,
|
|
label: entry.textContent,
|
|
color: entry.dataset.radioColor,
|
|
})),
|
|
enablePressed: document.getElementById('radio-enable-btn').getAttribute('aria-pressed'),
|
|
launcherExpanded: document.getElementById('context-radio-toggle-btn').getAttribute('aria-expanded'),
|
|
launcherActive: document.getElementById('context-radio-toggle-btn').classList.contains('active'),
|
|
compactEnablePressed: document.getElementById('context-radio-mini-enable-btn').getAttribute('aria-pressed'),
|
|
fullPlayEnabled: !document.getElementById('radio-play-btn').disabled,
|
|
fullPlayLabel: document.getElementById('radio-play-btn').getAttribute('aria-label'),
|
|
tunerVisible: !document.getElementById('radio-tuner').hidden,
|
|
tunerCount: window.__godsEyeView.styleManager._radioTunerStations.length,
|
|
tunerLabel: document.getElementById('radio-tuner-band-label').textContent,
|
|
};
|
|
});
|
|
check('750-station catalog renders within the browser budget', initial.state.stationCount === 750 && enableElapsedMs < 5000, `${enableElapsedMs}ms`);
|
|
check('enable does not autoplay', initial.plays === 0 && initial.state.audioState === 'stopped');
|
|
check(
|
|
'compact and detailed Radio power controls reflect the layer manager independently of disclosure',
|
|
initial.enablePressed === 'true' && initial.compactEnablePressed === 'true'
|
|
&& initial.launcherExpanded === 'true' && initial.launcherActive,
|
|
);
|
|
check('dynamic canonical and genre categories reach the UI', initial.filterOptions >= 10, `${initial.filterOptions} options`);
|
|
const musicOption = initial.filterPalette.find((entry) => entry.id === 'music');
|
|
const newsOption = initial.filterPalette.find((entry) => entry.id === 'news');
|
|
check(
|
|
'station-tag options show category-colored circles before their labels',
|
|
initial.filterPalette.every((entry) => entry.label.startsWith('● '))
|
|
&& musicOption?.color === '#54d17a'
|
|
&& newsOption?.color === '#44adff',
|
|
JSON.stringify({ musicOption, newsOption }),
|
|
);
|
|
check('All is the initial Radio filter', initial.state.filter === 'all' && initial.state.filteredCount === 750, `${initial.state.filteredCount} stations`);
|
|
check(
|
|
'the default All filter exposes a draggable directory tuner band',
|
|
initial.tunerVisible && initial.tunerCount === 750 && initial.tunerLabel === 'DIRECTORY BAND',
|
|
JSON.stringify({ visible: initial.tunerVisible, count: initial.tunerCount, label: initial.tunerLabel }),
|
|
);
|
|
const uncertainPresentation = await page.evaluate(async () => {
|
|
const gev = window.__godsEyeView;
|
|
const style = gev.styleManager;
|
|
const entry = gev.dataManager.layers.get('radio');
|
|
const module = entry.module;
|
|
const stationId = module.getTunerStations(1)[0]?.id || null;
|
|
const selectedBefore = module.getUIState().selected?.id || null;
|
|
const playsBefore = window.__qaRadioPlayCalls.length;
|
|
const camera = gev.viewer.camera;
|
|
const originalFlyTo = camera.flyTo;
|
|
const flyToCalls = [];
|
|
camera.flyTo = (options) => flyToCalls.push(options);
|
|
const beganTuning = module.beginTuning();
|
|
module.setTuningStatic(false);
|
|
entry.lifecycleUncertain = true;
|
|
module.setLifecyclePresentation({
|
|
lifecycleState: 'enabled',
|
|
enabled: true,
|
|
uncertain: true,
|
|
});
|
|
gev.dataManager._refreshTogglePanel();
|
|
const staticResult = module.setTuningStatic(true);
|
|
const filterBefore = module.getUIState().filter;
|
|
const filterControl = document.getElementById('radio-filter');
|
|
const originalPinned = style._radioTunerBandPinnedForNavigation;
|
|
const originalPool = style._radioTunerPool;
|
|
style._radioTunerBandPinnedForNavigation = true;
|
|
filterControl.value = filterBefore === 'news' ? 'all' : 'news';
|
|
filterControl.dispatchEvent(new Event('change', { bubbles: true }));
|
|
const rejectedUIFilter = {
|
|
moduleFilter: module.getUIState().filter,
|
|
controlFilter: filterControl.value,
|
|
pinned: style._radioTunerBandPinnedForNavigation,
|
|
poolIdentityPreserved: style._radioTunerPool === originalPool,
|
|
};
|
|
style._radioTunerBandPinnedForNavigation = originalPinned;
|
|
style._radioTunerPool = originalPool;
|
|
const filterResult = module.setFilter('news');
|
|
const toggleResult = await module.togglePlayback({ origin: 'user' });
|
|
const directSelect = module.selectStation(stationId, {
|
|
autoplay: true,
|
|
origin: 'voice',
|
|
});
|
|
const directPlay = await module.play({ origin: 'voice' });
|
|
const directCycle = module.cycleStation(1, {
|
|
autoplay: false,
|
|
rotate: true,
|
|
stationIds: module.getTunerStations(3).map(({ id }) => id),
|
|
});
|
|
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
const result = {
|
|
presentationActive: module.getUIState().presentationActive,
|
|
markerSourceVisible: gev.viewer.dataSources.getByName('Radio stations')[0]?.show,
|
|
tunerHidden: document.getElementById('radio-tuner').hidden,
|
|
filterDisabled: document.getElementById('radio-filter').disabled,
|
|
prevDisabled: document.getElementById('radio-prev-btn').disabled,
|
|
playDisabled: document.getElementById('radio-play-btn').disabled,
|
|
volumeDisabled: document.getElementById('radio-volume').disabled,
|
|
disclosureLabel: document.getElementById('context-radio-toggle-btn').getAttribute('aria-label'),
|
|
compactPowerLabel: document.getElementById('context-radio-mini-enable-btn').getAttribute('aria-label'),
|
|
compactPowerText: document.getElementById('context-radio-mini-enable-btn').textContent,
|
|
fullLabel: document.getElementById('radio-enable-btn').getAttribute('aria-label'),
|
|
fullText: document.getElementById('radio-enable-btn').textContent,
|
|
layerState: document.getElementById('radio-layer-state').textContent,
|
|
playbackMessage: document.getElementById('radio-playback-state').textContent,
|
|
miniStation: document.getElementById('context-radio-mini-station').textContent,
|
|
dataRailText: document.querySelector('[data-layer-id="radio"] .data-toggle-btn')?.textContent,
|
|
dataRailState: document.querySelector('[data-layer-id="radio"] .data-toggle-btn')?.dataset.feedState,
|
|
dataRailLabel: document.querySelector('[data-layer-id="radio"] .data-toggle-btn')?.getAttribute('aria-label'),
|
|
dataRailMeta: document.querySelector('[data-layer-id="radio"] .data-toggle-meta')?.textContent,
|
|
directSelect,
|
|
directPlay,
|
|
directCycle,
|
|
beganTuning,
|
|
staticResult,
|
|
tuningActive: module.getUIState().tuningActive,
|
|
tuningStatic: module.getUIState().tuningStatic,
|
|
filterBefore,
|
|
rejectedUIFilter,
|
|
filterResult,
|
|
filterAfter: module.getUIState().filter,
|
|
toggleResult,
|
|
flyToCalls: flyToCalls.length,
|
|
selectedBefore,
|
|
selectedAfter: module.getUIState().selected?.id || null,
|
|
playDelta: window.__qaRadioPlayCalls.length - playsBefore,
|
|
};
|
|
entry.lifecycleUncertain = false;
|
|
module.setLifecyclePresentation({
|
|
lifecycleState: 'enabled',
|
|
enabled: true,
|
|
uncertain: false,
|
|
});
|
|
gev.dataManager._refreshTogglePanel();
|
|
module.cancelTuning();
|
|
camera.flyTo = originalFlyTo;
|
|
return result;
|
|
});
|
|
check(
|
|
'uncertain settled-ON Radio remains hidden and inert until manager reconciliation',
|
|
uncertainPresentation.presentationActive === false
|
|
&& uncertainPresentation.markerSourceVisible === false
|
|
&& uncertainPresentation.tunerHidden
|
|
&& uncertainPresentation.filterDisabled
|
|
&& uncertainPresentation.prevDisabled
|
|
&& uncertainPresentation.playDisabled
|
|
&& uncertainPresentation.volumeDisabled
|
|
&& /compact Radio controls/i.test(uncertainPresentation.disclosureLabel)
|
|
&& /uncertain/i.test(uncertainPresentation.compactPowerLabel)
|
|
&& uncertainPresentation.compactPowerText === 'RECONCILE'
|
|
&& /uncertain/i.test(uncertainPresentation.fullLabel)
|
|
&& uncertainPresentation.fullText === 'RECONCILE'
|
|
&& uncertainPresentation.layerState === 'UNCERTAIN'
|
|
&& /uncertain/i.test(uncertainPresentation.playbackMessage)
|
|
&& uncertainPresentation.miniStation === 'RADIO STATE UNCERTAIN'
|
|
&& uncertainPresentation.dataRailText === 'UNCERTAIN'
|
|
&& uncertainPresentation.dataRailState === 'uncertain'
|
|
&& /uncertain/i.test(uncertainPresentation.dataRailLabel)
|
|
&& /uncertain/i.test(uncertainPresentation.dataRailMeta)
|
|
&& uncertainPresentation.directSelect === false
|
|
&& uncertainPresentation.directPlay === false
|
|
&& uncertainPresentation.directCycle === false
|
|
&& uncertainPresentation.beganTuning
|
|
&& uncertainPresentation.staticResult === false
|
|
&& uncertainPresentation.tuningActive
|
|
&& uncertainPresentation.tuningStatic === false
|
|
&& uncertainPresentation.filterResult === false
|
|
&& uncertainPresentation.filterAfter === uncertainPresentation.filterBefore
|
|
&& uncertainPresentation.rejectedUIFilter.moduleFilter === uncertainPresentation.filterBefore
|
|
&& uncertainPresentation.rejectedUIFilter.controlFilter === uncertainPresentation.filterBefore
|
|
&& uncertainPresentation.rejectedUIFilter.pinned
|
|
&& uncertainPresentation.rejectedUIFilter.poolIdentityPreserved
|
|
&& uncertainPresentation.toggleResult === false
|
|
&& uncertainPresentation.flyToCalls === 0
|
|
&& uncertainPresentation.selectedAfter === uncertainPresentation.selectedBefore
|
|
&& uncertainPresentation.playDelta === 0,
|
|
JSON.stringify(uncertainPresentation),
|
|
);
|
|
await page.evaluate(() => {
|
|
const gev = window.__godsEyeView;
|
|
const module = gev.dataManager.layers.get('radio').module;
|
|
const originalDisable = module.disable.bind(module);
|
|
module.disable = async (...args) => {
|
|
window.__qaRadioDisableStarted = true;
|
|
await new Promise((resolve) => { window.__qaReleaseRadioDisable = resolve; });
|
|
module.disable = originalDisable;
|
|
return originalDisable(...args);
|
|
};
|
|
window.__qaRadioDisablePromise = gev.dataManager.setEnabled('radio', false, { origin: 'qa-setup' });
|
|
});
|
|
await page.waitForFunction(() => window.__qaRadioDisableStarted === true);
|
|
const disablingPresentation = await page.evaluate(() => ({
|
|
manager: window.__godsEyeView.dataManager.getLayerLifecycleState('radio'),
|
|
presentationActive: window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().presentationActive,
|
|
markerSourceVisible: window.__godsEyeView.viewer.dataSources.getByName('Radio stations')[0]?.show,
|
|
full: document.getElementById('radio-enable-btn').textContent,
|
|
disclosure: document.getElementById('context-radio-toggle-btn').getAttribute('aria-label'),
|
|
disclosureControls: document.getElementById('context-radio-toggle-btn').getAttribute('aria-controls'),
|
|
disclosureExpanded: document.getElementById('context-radio-toggle-btn').getAttribute('aria-expanded'),
|
|
compactPower: document.getElementById('context-radio-mini-enable-btn').textContent,
|
|
layerState: document.getElementById('radio-layer-state').textContent,
|
|
message: document.getElementById('radio-playback-state').textContent,
|
|
stopDisabled: document.getElementById('radio-stop-btn').disabled,
|
|
volumeDisabled: document.getElementById('radio-volume').disabled,
|
|
}));
|
|
await page.screenshot({ path: path.join(SHOTS_DIR, 'lifecycle-disabling.png') });
|
|
check(
|
|
'Radio teardown exposes DISABLING while settled visibility remains ON',
|
|
disablingPresentation.manager.enabled === true
|
|
&& disablingPresentation.manager.lifecycleState === 'disabling'
|
|
&& disablingPresentation.presentationActive === false
|
|
&& disablingPresentation.markerSourceVisible === false
|
|
&& disablingPresentation.full === 'DISABLING'
|
|
&& /compact Radio controls/i.test(disablingPresentation.disclosure)
|
|
&& disablingPresentation.compactPower === 'DISABLING'
|
|
&& disablingPresentation.layerState === 'DISABLING'
|
|
&& /disabling/i.test(disablingPresentation.message)
|
|
&& disablingPresentation.stopDisabled
|
|
&& disablingPresentation.volumeDisabled,
|
|
JSON.stringify(disablingPresentation),
|
|
);
|
|
await page.evaluate(async () => {
|
|
window.__qaReleaseRadioDisable();
|
|
await window.__qaRadioDisablePromise;
|
|
delete window.__qaReleaseRadioDisable;
|
|
delete window.__qaRadioDisablePromise;
|
|
delete window.__qaRadioDisableStarted;
|
|
});
|
|
const explicitRevealBefore = await page.evaluate(async () => {
|
|
const gev = window.__godsEyeView;
|
|
gev.styleManager.setPanelCollapsed('global-context-panel', false);
|
|
gev.styleManager.setPanelCollapsed('radio-panel', false);
|
|
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
const scroller = document.querySelector('#global-context-panel .global-context-panel-inner');
|
|
const radio = document.getElementById('radio-panel');
|
|
scroller.scrollTop = Math.max(0, radio.offsetTop - scroller.clientHeight + 70);
|
|
const camera = gev.viewer.camera.positionWC;
|
|
return {
|
|
scrollTop: scroller.scrollTop,
|
|
pageY: window.scrollY,
|
|
camera: { x: camera.x, y: camera.y, z: camera.z },
|
|
plays: window.__qaRadioPlayCalls.length,
|
|
};
|
|
});
|
|
await page.screenshot({ path: path.join(SHOTS_DIR, 'explicit-enable-before.png') });
|
|
// A warm accepted catalog makes re-enable legitimately synchronous: there
|
|
// may be no station request for the interception delay below to hold. Gate
|
|
// the real module enable boundary instead so the manager's ENABLING state
|
|
// is observed deterministically without slowing production behavior.
|
|
await page.evaluate(() => {
|
|
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
|
|
const originalEnable = radio.enable;
|
|
let release;
|
|
const gate = new Promise((resolve) => { release = resolve; });
|
|
radio.enable = async function qaGatedRadioEnable(...args) {
|
|
await gate;
|
|
return originalEnable.apply(this, args);
|
|
};
|
|
window.__qaReleaseRadioEnable = () => release();
|
|
window.__qaRestoreRadioEnable = () => { radio.enable = originalEnable; };
|
|
});
|
|
await page.focus('#radio-enable-btn');
|
|
await page.click('#radio-enable-btn');
|
|
await page.waitForFunction(() => (
|
|
window.__godsEyeView.dataManager.getLayerLifecycleState('radio')?.lifecycleState === 'enabling'
|
|
));
|
|
const enablingPresentation = await page.evaluate(() => ({
|
|
manager: window.__godsEyeView.dataManager.getLayerLifecycleState('radio'),
|
|
presentationActive: window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().presentationActive,
|
|
markerSourceVisible: window.__godsEyeView.viewer.dataSources.getByName('Radio stations')[0]?.show,
|
|
full: document.getElementById('radio-enable-btn').textContent,
|
|
disclosure: document.getElementById('context-radio-toggle-btn').getAttribute('aria-label'),
|
|
disclosureControls: document.getElementById('context-radio-toggle-btn').getAttribute('aria-controls'),
|
|
disclosureExpanded: document.getElementById('context-radio-toggle-btn').getAttribute('aria-expanded'),
|
|
compactPower: document.getElementById('context-radio-mini-enable-btn').textContent,
|
|
layerState: document.getElementById('radio-layer-state').textContent,
|
|
message: document.getElementById('radio-playback-state').textContent,
|
|
tunerHidden: document.getElementById('radio-tuner').hidden,
|
|
stopDisabled: document.getElementById('radio-stop-btn').disabled,
|
|
volumeDisabled: document.getElementById('radio-volume').disabled,
|
|
}));
|
|
await page.screenshot({ path: path.join(SHOTS_DIR, 'lifecycle-enabling.png') });
|
|
check(
|
|
'Radio activation exposes ENABLING while settled visibility remains OFF',
|
|
enablingPresentation.manager.enabled === false
|
|
&& enablingPresentation.manager.lifecycleState === 'enabling'
|
|
&& enablingPresentation.presentationActive === false
|
|
&& enablingPresentation.markerSourceVisible === false
|
|
&& enablingPresentation.full === 'ENABLING'
|
|
&& enablingPresentation.disclosure === 'Go to expanded Radio section'
|
|
&& enablingPresentation.disclosureControls === 'radio-panel'
|
|
&& enablingPresentation.disclosureExpanded === 'true'
|
|
&& enablingPresentation.compactPower === 'ENABLING'
|
|
&& enablingPresentation.layerState === 'ENABLING'
|
|
&& /enabling/i.test(enablingPresentation.message)
|
|
&& enablingPresentation.tunerHidden
|
|
&& enablingPresentation.stopDisabled
|
|
&& enablingPresentation.volumeDisabled,
|
|
JSON.stringify(enablingPresentation),
|
|
);
|
|
await page.evaluate(() => window.__qaReleaseRadioEnable?.());
|
|
await page.waitForFunction(() => window.__godsEyeView.dataManager.isEnabled('radio'));
|
|
await page.evaluate(() => window.__qaRestoreRadioEnable?.());
|
|
await sleep(650);
|
|
const explicitRevealAfter = await page.evaluate(() => {
|
|
const gev = window.__godsEyeView;
|
|
const scroller = document.querySelector('#global-context-panel .global-context-panel-inner');
|
|
const viewport = scroller.getBoundingClientRect();
|
|
const directory = document.querySelector('#radio-panel .radio-directory-row').getBoundingClientRect();
|
|
const play = document.getElementById('radio-play-btn').getBoundingClientRect();
|
|
const camera = gev.viewer.camera.positionWC;
|
|
return {
|
|
scrollTop: scroller.scrollTop,
|
|
pageY: window.scrollY,
|
|
camera: { x: camera.x, y: camera.y, z: camera.z },
|
|
directoryVisible: directory.top >= viewport.top && directory.bottom <= viewport.bottom,
|
|
playVisible: play.top >= viewport.top && play.bottom <= viewport.bottom,
|
|
focusId: document.activeElement?.id,
|
|
plays: window.__qaRadioPlayCalls.length,
|
|
audioState: gev.dataManager.layers.get('radio').module.getUIState().audioState,
|
|
};
|
|
});
|
|
await page.screenshot({ path: path.join(SHOTS_DIR, 'explicit-enable-after.png') });
|
|
const revealCameraDelta = Math.hypot(
|
|
explicitRevealAfter.camera.x - explicitRevealBefore.camera.x,
|
|
explicitRevealAfter.camera.y - explicitRevealBefore.camera.y,
|
|
explicitRevealAfter.camera.z - explicitRevealBefore.camera.z,
|
|
);
|
|
check(
|
|
'expanded Radio Enable reveals the directory and Play inside Context without autoplay or focus/camera theft',
|
|
explicitRevealAfter.scrollTop > explicitRevealBefore.scrollTop
|
|
&& explicitRevealAfter.directoryVisible && explicitRevealAfter.playVisible
|
|
&& explicitRevealAfter.focusId === 'radio-enable-btn'
|
|
&& explicitRevealAfter.pageY === explicitRevealBefore.pageY
|
|
&& revealCameraDelta < 0.01
|
|
&& explicitRevealAfter.plays === explicitRevealBefore.plays
|
|
&& explicitRevealAfter.audioState === 'stopped',
|
|
JSON.stringify({ before: explicitRevealBefore, after: explicitRevealAfter, cameraDelta: revealCameraDelta }),
|
|
);
|
|
await page.evaluate(() => {
|
|
window.__godsEyeView.styleManager.setPanelCollapsed('radio-panel', true);
|
|
window.__godsEyeView.styleManager.setPanelCollapsed('global-context-panel', true);
|
|
});
|
|
const singletonViews = [];
|
|
const singletonViewSpecs = [
|
|
{ category: 'news', lon: -174 },
|
|
{ category: 'public-safety', lon: -138 },
|
|
{ category: 'weather', lon: -150 },
|
|
{ category: 'all', lon: -174 },
|
|
];
|
|
for (const spec of singletonViewSpecs) {
|
|
await page.select('#radio-filter', spec.category);
|
|
for (const view of [
|
|
{ name: 'global', height: 24_200_000, limit: 16 },
|
|
{ name: 'near', height: 500_000, limit: 32 },
|
|
]) {
|
|
await page.evaluate(({ lon, height }) => {
|
|
const viewer = window.__godsEyeView.viewer;
|
|
viewer.camera.cancelFlight();
|
|
viewer.camera.setView({
|
|
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
|
|
longitude: lon * Math.PI / 180,
|
|
latitude: 6 * Math.PI / 180,
|
|
height,
|
|
}),
|
|
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
|
});
|
|
viewer.scene.requestRender();
|
|
}, { lon: spec.lon, height: view.height });
|
|
await sleep(700);
|
|
const sample = await page.evaluate(async () => {
|
|
const viewer = window.__godsEyeView.viewer;
|
|
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
|
|
const dataSource = Array.from({ length: viewer.dataSources.length }, (_, index) => viewer.dataSources.get(index))
|
|
.find((item) => item.name === 'Radio stations');
|
|
const { getOverlayPaintRect, getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js');
|
|
const { radioStationIdFromPick } = await import('/src/data/radio.js');
|
|
return new Promise((resolve) => {
|
|
let removePostRender = null;
|
|
const finish = () => {
|
|
removePostRender?.();
|
|
const overlay = radio.getOverlayDiagnostics();
|
|
const paintedSingletonIds = overlay.singletonIds.filter((entryId) => Boolean(getOverlayPaintRect('radio', entryId)));
|
|
const pickableSingletonIds = paintedSingletonIds.filter((entryId) => {
|
|
const rect = getOverlayPaintRect('radio', entryId);
|
|
const anchor = rect?.entry?.position
|
|
&& viewer.scene.cartesianToCanvasCoordinates(rect.entry.position);
|
|
if (!anchor) return false;
|
|
return (viewer.scene.drillPick(anchor, 16) || [])
|
|
.some((picked) => radioStationIdFromPick(picked) === entryId.slice('station:'.length));
|
|
});
|
|
resolve({
|
|
overlay,
|
|
host: getWorldOverlayDiagnostics(),
|
|
paintedSingletonIds,
|
|
pickableSingletonIds,
|
|
nativeLabelCount: dataSource.entities.values.filter((entity) => Boolean(entity.label)).length,
|
|
});
|
|
};
|
|
const timeout = setTimeout(finish, 500);
|
|
removePostRender = viewer.scene.postRender.addEventListener(() => {
|
|
clearTimeout(timeout);
|
|
finish();
|
|
});
|
|
viewer.scene.requestRender();
|
|
});
|
|
});
|
|
singletonViews.push({ ...spec, ...view, ...sample });
|
|
await page.screenshot({
|
|
path: path.join(SHOTS_DIR, `singleton-${spec.category}-${view.name}.png`),
|
|
});
|
|
}
|
|
}
|
|
const sparseSingletonViews = singletonViews.filter((sample) => sample.category !== 'all');
|
|
check(
|
|
'sparse Radio filters paint bounded individual labels through the shared ambient lane',
|
|
sparseSingletonViews.every((sample) => (
|
|
sample.overlay.singletonIds.length > 0
|
|
&& sample.overlay.singletonIds.length <= sample.limit
|
|
&& sample.paintedSingletonIds.length > 0
|
|
&& sample.pickableSingletonIds.length === sample.paintedSingletonIds.length
|
|
&& sample.host.entriesBySource?.radio === sample.overlay.entryCount
|
|
)),
|
|
JSON.stringify(sparseSingletonViews.map((sample) => ({
|
|
category: sample.category,
|
|
view: sample.name,
|
|
singletonCount: sample.overlay.singletonIds.length,
|
|
painted: sample.paintedSingletonIds.length,
|
|
pickable: sample.pickableSingletonIds.length,
|
|
}))),
|
|
);
|
|
check(
|
|
'Radio label allocation stays bounded with no native Cesium entity text across sparse and All views',
|
|
singletonViews.every((sample) => (
|
|
sample.overlay.entryCount <= 65
|
|
&& sample.nativeLabelCount === 0
|
|
&& sample.host.paintedBySource?.radio > 0
|
|
)),
|
|
JSON.stringify(singletonViews.map((sample) => ({
|
|
category: sample.category,
|
|
view: sample.name,
|
|
entries: sample.overlay.entryCount,
|
|
nativeLabels: sample.nativeLabelCount,
|
|
painted: sample.host.paintedBySource?.radio,
|
|
}))),
|
|
);
|
|
const userFacingFailures = await page.evaluate(async () => {
|
|
const gev = window.__godsEyeView;
|
|
const styleManager = gev.styleManager;
|
|
const dataManager = gev.dataManager;
|
|
const unhandled = [];
|
|
const onUnhandled = (event) => {
|
|
unhandled.push(String(event.reason?.message || event.reason || 'unknown'));
|
|
event.preventDefault();
|
|
};
|
|
window.addEventListener('unhandledrejection', onUnhandled);
|
|
const originalToggle = dataManager.toggle;
|
|
const originalSetEnabled = dataManager.setEnabled;
|
|
const originalRestoreEnabledLayerIds = dataManager.restoreEnabledLayerIds;
|
|
const originalIsEnabled = dataManager.isEnabled;
|
|
const originalIsEffectivelyEnabled = dataManager.isEffectivelyEnabled;
|
|
const originalClear = styleManager._clearLayersOutsideContextMode;
|
|
const originalCapture = styleManager._captureContextSessionSnapshot;
|
|
const originalContextMode = styleManager._contextMode;
|
|
const originalSnapshot = styleManager._contextSessionSnapshot;
|
|
const originalShowToast = styleManager._showToast;
|
|
const installations = dataManager.layers.get('military-installations')?.module;
|
|
const originalSearchNearby = installations?.searchNearby;
|
|
try {
|
|
dataManager.setEnabled = async () => false;
|
|
document.getElementById('radio-enable-btn').click();
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
const semanticFalseMessage = document.getElementById('toast').textContent;
|
|
|
|
const enabled = new Set();
|
|
styleManager._captureContextSessionSnapshot = () => {};
|
|
const runContextFailure = async (mode, phase, outcome) => {
|
|
enabled.clear();
|
|
enabled.add('local-datacenters');
|
|
styleManager._contextMode = null;
|
|
styleManager._contextSessionSnapshot = {
|
|
enabledLayerIds: new Set(enabled),
|
|
userAdded: new Set(),
|
|
userRemoved: new Set(),
|
|
params: {},
|
|
};
|
|
let failureUsed = false;
|
|
const entryLayerId = mode === 'flights' ? 'military-awareness' : 'rocket-launches';
|
|
dataManager.setEnabled = async (layerId, shouldEnable) => {
|
|
const failsIsolation = phase === 'isolation' && layerId === 'local-datacenters' && !shouldEnable;
|
|
const failsActivation = phase === 'activation' && layerId === entryLayerId && shouldEnable;
|
|
if (!failureUsed && (failsIsolation || failsActivation)) {
|
|
failureUsed = true;
|
|
if (outcome === 'reject') throw new Error(`QA ${phase} rejection`);
|
|
return false;
|
|
}
|
|
shouldEnable ? enabled.add(layerId) : enabled.delete(layerId);
|
|
return true;
|
|
};
|
|
const result = await styleManager._runUserFacingContextAction(
|
|
() => styleManager._selectContextMode(mode),
|
|
`${mode} QA transition failed`,
|
|
);
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
return {
|
|
mode,
|
|
phase,
|
|
outcome,
|
|
result,
|
|
visibleMode: styleManager._contextMode,
|
|
snapshotCleared: styleManager._contextSessionSnapshot === null,
|
|
priorLayerRestored: enabled.has('local-datacenters'),
|
|
entryEnabled: enabled.has(entryLayerId),
|
|
flightsButtonDisabled: document.getElementById('global-context-flights-btn').disabled,
|
|
missionsButtonDisabled: document.getElementById('global-context-missions-btn').disabled,
|
|
message: document.getElementById('toast').textContent,
|
|
};
|
|
};
|
|
dataManager.isEnabled = (layerId) => enabled.has(layerId);
|
|
// The fake manager model must answer the effective-visibility accessor
|
|
// the isolation loop consults, or real entries (all disabled) leak in.
|
|
dataManager.isEffectivelyEnabled = (layerId) => enabled.has(layerId);
|
|
dataManager.restoreEnabledLayerIds = async (enabledLayerIds, { excludeLayerIds = [] } = {}) => {
|
|
const target = new Set(enabledLayerIds || []);
|
|
const excluded = new Set(excludeLayerIds || []);
|
|
const layerIds = new Set([...enabled, ...target]);
|
|
for (const candidateId of layerIds) {
|
|
if (excluded.has(candidateId)) continue;
|
|
const restored = await dataManager.setEnabled(candidateId, target.has(candidateId));
|
|
if (restored === false) {
|
|
throw new Error(`Failed to restore layer "${candidateId}" visibility`);
|
|
}
|
|
}
|
|
};
|
|
const contextFailures = [];
|
|
for (const mode of ['flights', 'space-missions']) {
|
|
for (const phase of ['isolation', 'activation']) {
|
|
for (const outcome of ['false', 'reject']) {
|
|
contextFailures.push(await runContextFailure(mode, phase, outcome));
|
|
}
|
|
}
|
|
}
|
|
|
|
const directEntryFailures = [];
|
|
for (const layerId of ['military-awareness', 'rocket-launches']) {
|
|
for (const outcome of ['false', 'reject']) {
|
|
enabled.clear();
|
|
enabled.add('local-datacenters');
|
|
styleManager._contextMode = null;
|
|
styleManager._contextSessionSnapshot = {
|
|
enabledLayerIds: new Set(enabled),
|
|
userAdded: new Set(),
|
|
userRemoved: new Set(),
|
|
params: {},
|
|
};
|
|
let failureUsed = false;
|
|
const toastMessages = [];
|
|
styleManager._showToast = (message) => {
|
|
toastMessages.push(message);
|
|
originalShowToast.call(styleManager, message);
|
|
};
|
|
dataManager.setEnabled = async (candidateId, shouldEnable, options = {}) => {
|
|
if (!failureUsed && candidateId === 'local-datacenters' && !shouldEnable) {
|
|
failureUsed = true;
|
|
styleManager._handleContextLayerChange({
|
|
type: 'visibility-failed',
|
|
layerId: candidateId,
|
|
enabled: shouldEnable,
|
|
notificationToken: options.notificationToken,
|
|
origin: 'user',
|
|
});
|
|
if (outcome === 'reject') throw new Error('QA direct isolation rejection');
|
|
return false;
|
|
}
|
|
shouldEnable ? enabled.add(candidateId) : enabled.delete(candidateId);
|
|
return true;
|
|
};
|
|
const reason = await dataManager._visibilityBlockReason({
|
|
type: 'visibility-will-change',
|
|
layerId,
|
|
enabled: true,
|
|
origin: 'user',
|
|
});
|
|
styleManager._handleContextLayerChange({
|
|
type: 'visibility-blocked',
|
|
layerId,
|
|
enabled: true,
|
|
origin: 'user',
|
|
reason,
|
|
});
|
|
const priorLayerRestored = enabled.has('local-datacenters');
|
|
const retryReason = await dataManager._visibilityBlockReason({
|
|
type: 'visibility-will-change',
|
|
layerId,
|
|
enabled: true,
|
|
origin: 'user',
|
|
});
|
|
directEntryFailures.push({
|
|
layerId,
|
|
outcome,
|
|
reason,
|
|
retryReason,
|
|
toastMessages,
|
|
mode: styleManager._contextMode,
|
|
snapshotCleared: styleManager._contextSessionSnapshot === null,
|
|
priorLayerRestored,
|
|
modeChanging: styleManager._contextModeChanging,
|
|
});
|
|
}
|
|
}
|
|
styleManager._showToast = originalShowToast;
|
|
|
|
const directActivationFailures = [];
|
|
for (const layerId of ['military-awareness', 'rocket-launches']) {
|
|
enabled.clear();
|
|
styleManager._contextMode = null;
|
|
styleManager._contextSessionSnapshot = {
|
|
enabledLayerIds: new Set(['local-datacenters']),
|
|
userAdded: new Set(),
|
|
userRemoved: new Set(),
|
|
params: {},
|
|
};
|
|
dataManager.setEnabled = async (candidateId, shouldEnable) => {
|
|
shouldEnable ? enabled.add(candidateId) : enabled.delete(candidateId);
|
|
return true;
|
|
};
|
|
styleManager._handleContextLayerChange({
|
|
type: 'visibility-failed',
|
|
layerId,
|
|
enabled: true,
|
|
origin: 'user',
|
|
});
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
directActivationFailures.push({
|
|
layerId,
|
|
mode: styleManager._contextMode,
|
|
snapshotCleared: styleManager._contextSessionSnapshot === null,
|
|
priorLayerRestored: enabled.has('local-datacenters'),
|
|
failedLayerDisabled: !enabled.has(layerId),
|
|
});
|
|
}
|
|
|
|
const directActivationRollbackFailures = [];
|
|
for (const layerId of ['military-awareness', 'rocket-launches']) {
|
|
enabled.clear();
|
|
styleManager._contextMode = null;
|
|
styleManager._contextSessionSnapshot = {
|
|
enabledLayerIds: new Set(['local-datacenters']),
|
|
userAdded: new Set(),
|
|
userRemoved: new Set(),
|
|
params: {},
|
|
};
|
|
const toastMessages = [];
|
|
const showToastBeforeRollbackFailure = styleManager._showToast;
|
|
styleManager._showToast = (message) => {
|
|
toastMessages.push(message);
|
|
showToastBeforeRollbackFailure.call(styleManager, message);
|
|
};
|
|
dataManager.setEnabled = async (candidateId, shouldEnable) => {
|
|
if (candidateId === 'local-datacenters' && shouldEnable) return false;
|
|
shouldEnable ? enabled.add(candidateId) : enabled.delete(candidateId);
|
|
return true;
|
|
};
|
|
styleManager._handleContextLayerChange({
|
|
type: 'visibility-failed',
|
|
layerId,
|
|
enabled: true,
|
|
origin: 'user',
|
|
});
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
styleManager._showToast = showToastBeforeRollbackFailure;
|
|
directActivationRollbackFailures.push({
|
|
layerId,
|
|
toastMessages,
|
|
retryRetained: styleManager._contextSessionSnapshot?.enabledLayerIds?.has('local-datacenters') === true,
|
|
});
|
|
}
|
|
|
|
const contextExitFailures = [];
|
|
dataManager.setEnabled = originalSetEnabled;
|
|
dataManager.restoreEnabledLayerIds = originalRestoreEnabledLayerIds;
|
|
dataManager.isEnabled = originalIsEnabled;
|
|
dataManager.isEffectivelyEnabled = originalIsEffectivelyEnabled;
|
|
for (const outcome of ['false', 'reject']) {
|
|
const layerId = `qa-context-exit-${outcome}`;
|
|
let enableOutcome = outcome;
|
|
const toastMessages = [];
|
|
const qaLayer = {
|
|
id: layerId,
|
|
name: `QA Context exit ${outcome}`,
|
|
updateInterval: -1,
|
|
init: async () => true,
|
|
enable: async () => {
|
|
if (enableOutcome === 'reject') throw new Error(`QA Context exit ${outcome}`);
|
|
return enableOutcome !== 'false';
|
|
},
|
|
disable: async () => true,
|
|
update: async () => true,
|
|
destroy: async () => true,
|
|
getStats: () => ({}),
|
|
};
|
|
window.__gevQaRegisterLayer(dataManager, qaLayer);
|
|
try {
|
|
const enabledBeforeExit = dataManager.getEnabledLayerIds();
|
|
styleManager._contextMode = 'flights';
|
|
styleManager._contextSessionSnapshot = {
|
|
enabledLayerIds: new Set([...enabledBeforeExit, layerId]),
|
|
userAdded: new Set(),
|
|
userRemoved: new Set(),
|
|
params: {},
|
|
};
|
|
styleManager._showToast = (message) => {
|
|
toastMessages.push(message);
|
|
originalShowToast.call(styleManager, message);
|
|
};
|
|
const unhandledBefore = unhandled.length;
|
|
const result = await styleManager._runUserFacingContextAction(
|
|
(notificationToken) => styleManager._deactivateContextForLayerChange({ notificationToken }),
|
|
`QA Context exit ${outcome} surfaced once`,
|
|
);
|
|
const retainedForRetry = styleManager._contextSessionSnapshot?.enabledLayerIds?.has(layerId) === true;
|
|
enableOutcome = 'success';
|
|
const retryResult = await styleManager._runUserFacingContextAction(
|
|
(notificationToken) => styleManager._deactivateContextForLayerChange({ notificationToken }),
|
|
`QA Context exit ${outcome} retry failed`,
|
|
);
|
|
contextExitFailures.push({
|
|
outcome,
|
|
result,
|
|
retryResult: retryResult ?? true,
|
|
toastMessages,
|
|
unhandledDelta: unhandled.length - unhandledBefore,
|
|
retainedForRetry,
|
|
mode: styleManager._contextMode,
|
|
modeChanging: styleManager._contextModeChanging,
|
|
snapshotClearedAfterRetry: styleManager._contextSessionSnapshot === null,
|
|
enabledAfterRetry: dataManager.isEnabled(layerId),
|
|
toastRole: document.getElementById('toast').getAttribute('role'),
|
|
toastLive: document.getElementById('toast').getAttribute('aria-live'),
|
|
toastAtomic: document.getElementById('toast').getAttribute('aria-atomic'),
|
|
});
|
|
} finally {
|
|
styleManager._showToast = originalShowToast;
|
|
await window.__gevQaUnregisterLayer(dataManager, layerId);
|
|
}
|
|
}
|
|
|
|
const unrelatedLayerId = 'qa-unrelated-manager-failure';
|
|
const unrelatedToastMessages = [];
|
|
let releasePendingAction = null;
|
|
window.__gevQaRegisterLayer(dataManager, {
|
|
id: unrelatedLayerId,
|
|
name: 'QA unrelated manager failure',
|
|
updateInterval: -1,
|
|
init: async () => true,
|
|
enable: async () => false,
|
|
disable: async () => true,
|
|
update: async () => true,
|
|
destroy: async () => true,
|
|
getStats: () => ({}),
|
|
});
|
|
styleManager._showToast = (message) => {
|
|
unrelatedToastMessages.push(message);
|
|
originalShowToast.call(styleManager, message);
|
|
};
|
|
const pendingAction = styleManager._runUserFacingContextAction(() => (
|
|
new Promise((resolve) => { releasePendingAction = resolve; })
|
|
));
|
|
while (!releasePendingAction) await new Promise((resolve) => setTimeout(resolve, 0));
|
|
const unrelatedResult = await dataManager.setEnabled(unrelatedLayerId, true);
|
|
releasePendingAction(true);
|
|
await pendingAction;
|
|
styleManager._showToast = originalShowToast;
|
|
await window.__gevQaUnregisterLayer(dataManager, unrelatedLayerId);
|
|
const unrelatedManagerFailure = {
|
|
result: unrelatedResult,
|
|
toastMessages: unrelatedToastMessages,
|
|
};
|
|
|
|
dataManager.setEnabled = async (layerId, shouldEnable) => {
|
|
if (layerId === 'military-installations' && shouldEnable) return false;
|
|
return true;
|
|
};
|
|
document.getElementById('installations-search-btn').click();
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
const installationFalseMessage = document.getElementById('toast').textContent;
|
|
const installationFalseReleased = !document.getElementById('installations-search-btn').disabled;
|
|
|
|
dataManager.setEnabled = async (layerId, shouldEnable) => {
|
|
if (layerId === 'military-installations' && shouldEnable) {
|
|
throw new Error('QA installation enable rejection');
|
|
}
|
|
return true;
|
|
};
|
|
document.getElementById('installations-search-btn').click();
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
const installationEnableRejectionMessage = document.getElementById('toast').textContent;
|
|
const installationEnableRejectionReleased = !document.getElementById('installations-search-btn').disabled;
|
|
|
|
dataManager.setEnabled = async (layerId, shouldEnable) => {
|
|
if (layerId === 'military-installations' && shouldEnable) enabled.add(layerId);
|
|
return true;
|
|
};
|
|
if (installations) installations.searchNearby = async () => {
|
|
throw new Error('QA installation search rejection');
|
|
};
|
|
document.getElementById('installations-search-btn').click();
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
const installationRejectionMessage = document.getElementById('toast').textContent;
|
|
const installationRejectionReleased = !document.getElementById('installations-search-btn').disabled;
|
|
|
|
if (installations) installations.searchNearby = async () => false;
|
|
document.getElementById('installations-search-btn').click();
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
const installationSearchFalseMessage = document.getElementById('toast').textContent;
|
|
const installationSearchFalseReleased = !document.getElementById('installations-search-btn').disabled;
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
return {
|
|
semanticFalseMessage,
|
|
contextFailures,
|
|
directEntryFailures,
|
|
directActivationFailures,
|
|
directActivationRollbackFailures,
|
|
contextExitFailures,
|
|
unrelatedManagerFailure,
|
|
installationFalseMessage,
|
|
installationFalseReleased,
|
|
installationEnableRejectionMessage,
|
|
installationEnableRejectionReleased,
|
|
installationRejectionMessage,
|
|
installationRejectionReleased,
|
|
installationSearchFalseMessage,
|
|
installationSearchFalseReleased,
|
|
unhandled,
|
|
};
|
|
} finally {
|
|
dataManager.toggle = originalToggle;
|
|
dataManager.setEnabled = originalSetEnabled;
|
|
dataManager.restoreEnabledLayerIds = originalRestoreEnabledLayerIds;
|
|
dataManager.isEnabled = originalIsEnabled;
|
|
dataManager.isEffectivelyEnabled = originalIsEffectivelyEnabled;
|
|
styleManager._clearLayersOutsideContextMode = originalClear;
|
|
styleManager._captureContextSessionSnapshot = originalCapture;
|
|
styleManager._contextMode = originalContextMode;
|
|
styleManager._contextSessionSnapshot = originalSnapshot;
|
|
styleManager._showToast = originalShowToast;
|
|
if (installations) installations.searchNearby = originalSearchNearby;
|
|
window.removeEventListener('unhandledrejection', onUnhandled);
|
|
}
|
|
});
|
|
check(
|
|
'user-facing chip, real Context entry rollback, and installation Search failures settle safely',
|
|
userFacingFailures.semanticFalseMessage === 'Radio could not stop cleanly'
|
|
&& userFacingFailures.contextFailures.length === 8
|
|
&& userFacingFailures.contextFailures.every((failure) => (
|
|
failure.result === false
|
|
&& failure.visibleMode === null
|
|
&& failure.snapshotCleared
|
|
&& failure.priorLayerRestored
|
|
&& !failure.entryEnabled
|
|
&& !failure.flightsButtonDisabled
|
|
&& !failure.missionsButtonDisabled
|
|
&& failure.message.includes('QA transition failed')
|
|
))
|
|
&& userFacingFailures.directEntryFailures.length === 4
|
|
&& userFacingFailures.directEntryFailures.every((failure) => (
|
|
failure.reason.includes('could not start')
|
|
&& failure.retryReason === null
|
|
&& failure.toastMessages.length === 1
|
|
&& failure.toastMessages[0] === failure.reason
|
|
&& failure.mode === null
|
|
&& failure.snapshotCleared
|
|
&& failure.priorLayerRestored
|
|
&& !failure.modeChanging
|
|
))
|
|
&& userFacingFailures.directActivationFailures.length === 2
|
|
&& userFacingFailures.directActivationFailures.every((failure) => (
|
|
failure.mode === null
|
|
&& failure.snapshotCleared
|
|
&& failure.priorLayerRestored
|
|
&& failure.failedLayerDisabled
|
|
))
|
|
&& userFacingFailures.directActivationRollbackFailures.length === 2
|
|
&& userFacingFailures.directActivationRollbackFailures.every((failure) => (
|
|
failure.toastMessages.length === 1
|
|
&& failure.toastMessages[0] === `${failure.layerId} could not start cleanly`
|
|
&& failure.retryRetained
|
|
))
|
|
&& userFacingFailures.contextExitFailures.length === 2
|
|
&& userFacingFailures.contextExitFailures.every((failure) => (
|
|
failure.result === false
|
|
&& failure.retryResult === true
|
|
&& failure.toastMessages.length === 1
|
|
&& failure.toastMessages[0].includes('surfaced once')
|
|
&& failure.unhandledDelta === 0
|
|
&& failure.retainedForRetry
|
|
&& failure.mode === null
|
|
&& !failure.modeChanging
|
|
&& failure.snapshotClearedAfterRetry
|
|
&& failure.enabledAfterRetry
|
|
&& failure.toastRole === 'status'
|
|
&& failure.toastLive === 'polite'
|
|
&& failure.toastAtomic === 'true'
|
|
))
|
|
&& userFacingFailures.unrelatedManagerFailure.result === false
|
|
&& userFacingFailures.unrelatedManagerFailure.toastMessages.length === 1
|
|
&& userFacingFailures.unrelatedManagerFailure.toastMessages[0].includes('qa-unrelated-manager-failure could not start cleanly')
|
|
&& userFacingFailures.installationFalseMessage.includes('could not be refreshed')
|
|
&& userFacingFailures.installationFalseReleased
|
|
&& userFacingFailures.installationEnableRejectionMessage.includes('could not be refreshed')
|
|
&& userFacingFailures.installationEnableRejectionReleased
|
|
&& userFacingFailures.installationRejectionMessage.includes('could not be refreshed')
|
|
&& userFacingFailures.installationRejectionReleased
|
|
&& userFacingFailures.installationSearchFalseMessage.includes('could not be refreshed')
|
|
&& userFacingFailures.installationSearchFalseReleased
|
|
&& userFacingFailures.unhandled.length === 0,
|
|
JSON.stringify(userFacingFailures),
|
|
);
|
|
catalogDegraded = true;
|
|
const degradedCatalog = await page.evaluate(async () => {
|
|
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
|
|
await radio.update();
|
|
return {
|
|
state: radio.getUIState(),
|
|
message: document.getElementById('radio-playback-state').textContent,
|
|
};
|
|
});
|
|
check(
|
|
'degraded Radio catalog remains usable and is labeled explicitly',
|
|
degradedCatalog.state.stationCount === 750
|
|
&& degradedCatalog.state.degraded === true
|
|
&& degradedCatalog.state.stale === false
|
|
&& degradedCatalog.message.includes('degraded directory'),
|
|
JSON.stringify(degradedCatalog),
|
|
);
|
|
catalogDegraded = false;
|
|
await page.evaluate(async () => {
|
|
await window.__godsEyeView.dataManager.layers.get('radio').module.update();
|
|
});
|
|
await page.select('#radio-filter', 'all');
|
|
const allPalette = await page.evaluate(() => {
|
|
const source = Array.from({ length: window.__godsEyeView.viewer.dataSources.length }, (_, index) => (
|
|
window.__godsEyeView.viewer.dataSources.get(index)
|
|
)).find((item) => item.name === 'Radio stations');
|
|
const colorFor = (index) => {
|
|
const id = `radio:00000000-0000-4000-8000-${index.toString(16).padStart(12, '0')}`;
|
|
const color = source?.entities.getById(id)?.point?.color?.getValue?.();
|
|
return color ? [color.red, color.green, color.blue].map((value) => Math.round(value * 255)) : null;
|
|
};
|
|
return { news: colorFor(0), talk: colorFor(1), music: colorFor(7) };
|
|
});
|
|
check(
|
|
'All stations use distinct category marker colors and Music is green',
|
|
JSON.stringify(allPalette.news) !== JSON.stringify(allPalette.music)
|
|
&& JSON.stringify(allPalette.talk) !== JSON.stringify(allPalette.music)
|
|
&& allPalette.music?.[1] > allPalette.music?.[0]
|
|
&& allPalette.music?.[1] > allPalette.music?.[2],
|
|
JSON.stringify(allPalette),
|
|
);
|
|
const clusterBadge = await page.evaluate(async () => {
|
|
const viewer = window.__godsEyeView.viewer;
|
|
const { getOverlayPaintRect, getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js');
|
|
const { distanceFade } = await import('/src/overlays/worldOverlayDraw.js');
|
|
const { radioStationIdFromPick } = await import('/src/data/radio.js');
|
|
const baselineSolveRevision = getWorldOverlayDiagnostics().solveRevision;
|
|
const source = Array.from({ length: viewer.dataSources.length }, (_, index) => viewer.dataSources.get(index))
|
|
.find((item) => item.name === 'Radio stations');
|
|
const ellipsoid = viewer.scene.globe.ellipsoid;
|
|
viewer.camera.cancelFlight();
|
|
viewer.camera.setView({
|
|
destination: ellipsoid.cartographicToCartesian({
|
|
longitude: -98 * Math.PI / 180,
|
|
latitude: 38 * Math.PI / 180,
|
|
height: 18_000_000,
|
|
}),
|
|
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
|
});
|
|
source.clustering.enabled = false;
|
|
viewer.scene.requestRender();
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
return new Promise((resolve) => {
|
|
const originalPixelRange = source.clustering.pixelRange;
|
|
const timeout = setTimeout(() => {
|
|
remove();
|
|
resolve(null);
|
|
}, 2_000);
|
|
const remove = source.clustering.clusterEvent.addEventListener(async (entities, cluster) => {
|
|
clearTimeout(timeout);
|
|
remove();
|
|
const native = {
|
|
count: entities.length,
|
|
labelShow: cluster.label.show,
|
|
labelText: cluster.label.text,
|
|
pointSize: cluster.point.pixelSize,
|
|
pointMaxDistance: cluster.point.distanceDisplayCondition?.far ?? null,
|
|
};
|
|
source.clustering.pixelRange = originalPixelRange;
|
|
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
|
|
await new Promise((done) => setTimeout(done, 0));
|
|
let painted = null;
|
|
for (let attempt = 0; attempt < 20 && !painted?.rect; attempt += 1) {
|
|
painted = await new Promise((done) => {
|
|
let removePostRender = null;
|
|
const timeout = setTimeout(() => {
|
|
removePostRender?.();
|
|
done({ rect: null, host: getWorldOverlayDiagnostics() });
|
|
}, 150);
|
|
removePostRender = viewer.scene.postRender.addEventListener(() => {
|
|
clearTimeout(timeout);
|
|
removePostRender();
|
|
const host = getWorldOverlayDiagnostics();
|
|
let match = null;
|
|
for (const entryId of radio.getOverlayDiagnostics().clusterIds) {
|
|
const rect = getOverlayPaintRect('radio', entryId);
|
|
if (rect) match = { entryId, rect };
|
|
}
|
|
done({
|
|
expectedEntryId: match?.entryId || null,
|
|
rect: match ? { x: match.rect.x, y: match.rect.y, w: match.rect.w, h: match.rect.h } : null,
|
|
host,
|
|
});
|
|
});
|
|
viewer.scene.requestRender();
|
|
});
|
|
}
|
|
resolve({
|
|
native,
|
|
baselineSolveRevision,
|
|
expectedEntryId: painted?.expectedEntryId || null,
|
|
rect: painted?.rect || null,
|
|
source: radio.getOverlayDiagnostics(),
|
|
host: painted?.host || getWorldOverlayDiagnostics(),
|
|
});
|
|
});
|
|
source.clustering.enabled = true;
|
|
viewer.scene.requestRender();
|
|
});
|
|
});
|
|
check(
|
|
'Radio cluster text is native-free and painted through the bounded shared host',
|
|
clusterBadge?.native?.labelShow === false
|
|
&& clusterBadge?.native?.labelText === ''
|
|
&& clusterBadge?.native?.pointSize >= 14
|
|
&& clusterBadge?.native?.pointMaxDistance === 50_000_000
|
|
&& clusterBadge?.source?.clusterTexts?.some((text) => /^\d+\s+[A-Z]/.test(text))
|
|
&& clusterBadge?.source?.entryCount <= 65
|
|
&& clusterBadge?.host?.entriesBySource?.radio === clusterBadge?.source?.entryCount
|
|
&& clusterBadge?.host?.solveRevision > clusterBadge?.baselineSolveRevision
|
|
&& clusterBadge?.rect?.w > 0
|
|
&& clusterBadge?.rect?.h > 0,
|
|
JSON.stringify(clusterBadge),
|
|
);
|
|
const highGlobalClusterLabels = await page.evaluate(async () => {
|
|
const viewer = window.__godsEyeView.viewer;
|
|
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
|
|
const ellipsoid = viewer.scene.globe.ellipsoid;
|
|
const { getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js');
|
|
const { distanceFade } = await import('/src/overlays/worldOverlayDraw.js');
|
|
viewer.camera.cancelFlight();
|
|
viewer.camera.setView({
|
|
destination: ellipsoid.cartographicToCartesian({
|
|
longitude: 68 * Math.PI / 180,
|
|
latitude: 7 * Math.PI / 180,
|
|
height: 24_200_000,
|
|
}),
|
|
orientation: { heading: 15 * Math.PI / 180, pitch: -Math.PI / 2, roll: 0 },
|
|
});
|
|
viewer.scene.requestRender();
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
const source = radio.getOverlayDiagnostics();
|
|
const cameraPosition = viewer.camera.positionWC;
|
|
const cameraRadius = Math.hypot(cameraPosition.x, cameraPosition.y, cameraPosition.z);
|
|
const horizonDistance = Math.sqrt(Math.max(
|
|
0,
|
|
cameraRadius * cameraRadius - ellipsoid.maximumRadius * ellipsoid.maximumRadius,
|
|
));
|
|
const points = Array.from({ length: viewer.dataSources.length }, (_, index) => viewer.dataSources.get(index))
|
|
.find((item) => item.name === 'Radio stations')?.clustering?._clusterPointCollection;
|
|
let clusterPoint = null;
|
|
for (let index = 0; index < (points?.length || 0); index += 1) {
|
|
const point = points.get(index);
|
|
if (!point?.show || !Array.isArray(point.id) || !point.id.length) continue;
|
|
const anchor = viewer.scene.cartesianToCanvasCoordinates(point.position);
|
|
if (!anchor) continue;
|
|
const exactPick = (viewer.scene.drillPick(anchor, 16) || []).find((picked) => (
|
|
picked?.primitive === point && picked?.id === point.id
|
|
));
|
|
clusterPoint = {
|
|
maxDistance: point.distanceDisplayCondition?.far ?? null,
|
|
pickable: Boolean(exactPick),
|
|
};
|
|
break;
|
|
}
|
|
return {
|
|
source,
|
|
host: getWorldOverlayDiagnostics(),
|
|
altitude: viewer.camera.positionCartographic.height,
|
|
horizonDistance,
|
|
horizonDistanceAlpha: distanceFade(horizonDistance, { maxDistance: 50_000_000 }),
|
|
clusterPoint,
|
|
};
|
|
});
|
|
check(
|
|
'All-filter cluster labels remain visible at a 24,200 km global view',
|
|
highGlobalClusterLabels.altitude > 24_000_000
|
|
&& highGlobalClusterLabels.source.clusterIds.length > 0
|
|
&& highGlobalClusterLabels.host.projectedCount > 0
|
|
&& highGlobalClusterLabels.host.paintedBySource?.radio > 0
|
|
&& highGlobalClusterLabels.horizonDistanceAlpha === 1
|
|
&& highGlobalClusterLabels.clusterPoint?.maxDistance === 50_000_000
|
|
&& highGlobalClusterLabels.clusterPoint?.pickable,
|
|
JSON.stringify(highGlobalClusterLabels),
|
|
);
|
|
await page.screenshot({ path: path.join(SHOTS_DIR, 'high-global-all-labels.png') });
|
|
const clusterContinuity = await page.evaluate(async () => {
|
|
const viewer = window.__godsEyeView.viewer;
|
|
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
|
|
const ellipsoid = viewer.scene.globe.ellipsoid;
|
|
const { getOverlayPaintRect, getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js');
|
|
const paintedClusters = () => new Promise((resolve) => {
|
|
let removePostRender = null;
|
|
const timeout = setTimeout(() => {
|
|
removePostRender?.();
|
|
resolve({ clusters: [], fadingCount: -1, host: null, source: null });
|
|
}, 500);
|
|
removePostRender = viewer.scene.postRender.addEventListener(() => {
|
|
clearTimeout(timeout);
|
|
removePostRender();
|
|
const source = radio.getOverlayDiagnostics();
|
|
const host = getWorldOverlayDiagnostics();
|
|
const clusters = source.clusterIds.map((entryId, index) => {
|
|
const rect = getOverlayPaintRect('radio', entryId);
|
|
return rect ? {
|
|
entryId,
|
|
text: source.clusterTexts[index],
|
|
stateless: rect.entry?.stateless === true,
|
|
edgeFade: rect.entry?.edgeFade,
|
|
} : null;
|
|
}).filter(Boolean);
|
|
resolve({
|
|
clusters,
|
|
fadingCount: host.fadingCount,
|
|
host,
|
|
source,
|
|
});
|
|
});
|
|
viewer.scene.requestRender();
|
|
});
|
|
const longitudes = [-25, -24, -23, -22, -23, -24, -25];
|
|
const samples = [];
|
|
for (const longitude of longitudes) {
|
|
viewer.camera.setView({
|
|
destination: ellipsoid.cartographicToCartesian({
|
|
longitude: longitude * Math.PI / 180,
|
|
latitude: 38 * Math.PI / 180,
|
|
height: 10_000_000,
|
|
}),
|
|
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
|
});
|
|
viewer.scene.requestRender();
|
|
for (const delay of [50, 100, 200, 350]) {
|
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
samples.push({ longitude, delay, ...(await paintedClusters()) });
|
|
}
|
|
}
|
|
let retainedAcrossSteps = 0;
|
|
let countChangingRetained = 0;
|
|
const settled = samples.filter((sample) => sample.delay === 350);
|
|
for (let index = 1; index < settled.length; index += 1) {
|
|
const previous = new Map(settled[index - 1].clusters.map((cluster) => [cluster.entryId, cluster.text]));
|
|
for (const cluster of settled[index].clusters) {
|
|
if (!previous.has(cluster.entryId)) continue;
|
|
retainedAcrossSteps += 1;
|
|
if (previous.get(cluster.entryId) !== cluster.text) countChangingRetained += 1;
|
|
}
|
|
}
|
|
return {
|
|
sampleCount: samples.length,
|
|
paintedSamples: samples.filter((sample) => sample.clusters.length > 0).length,
|
|
unpaintedProjectedSamples: samples.filter((sample) => (
|
|
sample.clusters.length === 0 && (sample.host?.projectedCount || 0) > 0
|
|
)).length,
|
|
emptySamples: samples.filter((sample) => sample.clusters.length === 0).map((sample) => ({
|
|
longitude: sample.longitude,
|
|
delay: sample.delay,
|
|
sourceEntries: sample.source?.entryCount,
|
|
hostCandidates: sample.host?.candidateCount,
|
|
hostProjected: sample.host?.projectedCount,
|
|
hostPainted: sample.host?.paintedCount,
|
|
hostFading: sample.host?.fadingCount,
|
|
})),
|
|
fadingSamples: samples.filter((sample) => sample.fadingCount !== 0).map((sample) => ({
|
|
longitude: sample.longitude,
|
|
delay: sample.delay,
|
|
fadingCount: sample.fadingCount,
|
|
})),
|
|
allHardOpacity: samples.every((sample) => sample.clusters.every(
|
|
(cluster) => cluster.stateless && cluster.edgeFade === 'none',
|
|
)),
|
|
retainedAcrossSteps,
|
|
countChangingRetained,
|
|
};
|
|
});
|
|
check(
|
|
'Radio cluster badges remain hard-opacity through one-degree forward/reverse count changes',
|
|
clusterContinuity.sampleCount === 28
|
|
&& clusterContinuity.paintedSamples > 0
|
|
&& clusterContinuity.unpaintedProjectedSamples === 0
|
|
&& clusterContinuity.allHardOpacity
|
|
&& clusterContinuity.retainedAcrossSteps > 0
|
|
&& clusterContinuity.countChangingRetained > 0,
|
|
JSON.stringify(clusterContinuity),
|
|
);
|
|
const directoryRefreshContinuity = await page.evaluate(async () => {
|
|
const viewer = window.__godsEyeView.viewer;
|
|
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
|
|
const before = radio.getOverlayDiagnostics().clusterMemberships;
|
|
await radio.update();
|
|
viewer.scene.requestRender();
|
|
await new Promise((resolve) => setTimeout(resolve, 900));
|
|
const after = radio.getOverlayDiagnostics().clusterMemberships;
|
|
const beforeByMembership = new Map(before.map((entry) => [entry.membershipId, entry.entryId]));
|
|
const unchanged = after.filter((entry) => beforeByMembership.has(entry.membershipId));
|
|
return {
|
|
beforeCount: before.length,
|
|
afterCount: after.length,
|
|
unchangedCount: unchanged.length,
|
|
retainedCount: unchanged.filter((entry) => beforeByMembership.get(entry.membershipId) === entry.entryId).length,
|
|
};
|
|
});
|
|
check(
|
|
'unchanged directory refresh membership retains cluster overlay identity',
|
|
directoryRefreshContinuity.beforeCount > 0
|
|
&& directoryRefreshContinuity.unchangedCount > 0
|
|
&& directoryRefreshContinuity.retainedCount === directoryRefreshContinuity.unchangedCount,
|
|
JSON.stringify(directoryRefreshContinuity),
|
|
);
|
|
await page.select('#radio-filter', 'news');
|
|
check('launcher enables Radio without forcing Context open', !initial.panelExpanded && !initial.contextExpanded && initial.nestedInContext);
|
|
check('full Play is available before a station is selected', !initial.state.selected && initial.fullPlayEnabled && initial.fullPlayLabel === 'Play nearest radio station');
|
|
|
|
const hoverOnlyDisclosure = await page.evaluate(() => {
|
|
const button = document.getElementById('context-radio-toggle-btn');
|
|
if (button.getAttribute('aria-expanded') === 'true') button.click();
|
|
return {
|
|
expanded: button.getAttribute('aria-expanded'),
|
|
hidden: document.getElementById('context-radio-mini').hidden,
|
|
};
|
|
});
|
|
await page.hover('#context-radio-toggle-btn');
|
|
await sleep(100);
|
|
const hoverOnlyVisible = await page.$eval('#context-radio-mini', (mini) => (
|
|
!mini.hidden && getComputedStyle(mini).display !== 'none'
|
|
));
|
|
check(
|
|
'hover alone does not disclose compact Radio controls',
|
|
hoverOnlyDisclosure.expanded === 'false' && hoverOnlyDisclosure.hidden && !hoverOnlyVisible,
|
|
JSON.stringify({ hoverOnlyDisclosure, hoverOnlyVisible }),
|
|
);
|
|
await page.click('#context-radio-toggle-btn');
|
|
await page.waitForFunction(() => {
|
|
const button = document.getElementById('context-radio-toggle-btn');
|
|
const mini = document.getElementById('context-radio-mini');
|
|
return button.getAttribute('aria-expanded') === 'true'
|
|
&& !mini.hidden && getComputedStyle(mini).display !== 'none';
|
|
});
|
|
const compact = await page.evaluate(() => {
|
|
const mini = document.getElementById('context-radio-mini');
|
|
const toggle = document.getElementById('context-radio-toggle-btn');
|
|
const dock = document.getElementById('context-radio-dock');
|
|
const miniRect = mini.getBoundingClientRect();
|
|
const toggleRect = toggle.getBoundingClientRect();
|
|
const miniStyle = getComputedStyle(mini);
|
|
return {
|
|
visible: miniStyle.display !== 'none' && miniRect.width > 0 && miniRect.height > 0,
|
|
display: miniStyle.display,
|
|
visibility: miniStyle.visibility,
|
|
opacity: miniStyle.opacity,
|
|
miniRect: { top: miniRect.top, left: miniRect.left, width: miniRect.width, height: miniRect.height },
|
|
toggleRect: { top: toggleRect.top, left: toggleRect.left, width: toggleRect.width, height: toggleRect.height },
|
|
dockRect: (() => {
|
|
const rect = dock.getBoundingClientRect();
|
|
return { top: rect.top, left: rect.left, width: rect.width, height: rect.height };
|
|
})(),
|
|
offsetParent: mini.offsetParent?.id || mini.offsetParent?.className || null,
|
|
prevEnabled: !document.getElementById('context-radio-mini-prev-btn').disabled,
|
|
playEnabled: !document.getElementById('context-radio-mini-play-btn').disabled,
|
|
playLabel: document.getElementById('context-radio-mini-play-btn').getAttribute('aria-label'),
|
|
nextEnabled: !document.getElementById('context-radio-mini-next-btn').disabled,
|
|
volumeEnabled: !document.getElementById('context-radio-mini-volume').disabled,
|
|
offsetBelowToggle: Math.round(miniRect.top - toggleRect.bottom),
|
|
};
|
|
});
|
|
check(
|
|
'explicit launcher disclosure reveals enabled compact Radio controls',
|
|
compact.visible && compact.prevEnabled && compact.playEnabled && compact.nextEnabled && compact.volumeEnabled,
|
|
JSON.stringify(compact),
|
|
);
|
|
check('compact Play is available before a station is selected', compact.playLabel === 'Play nearest radio station');
|
|
check('compact Radio controls sit 20px below the launcher', compact.offsetBelowToggle === 20, `${compact.offsetBelowToggle}px`);
|
|
const compactCloseDisclosure = await page.evaluate(() => {
|
|
const launcher = document.getElementById('context-radio-toggle-btn');
|
|
const close = document.getElementById('context-radio-mini-close-btn');
|
|
close.focus();
|
|
close.click();
|
|
const result = {
|
|
launcherExpanded: launcher.getAttribute('aria-expanded'),
|
|
compactHidden: document.getElementById('context-radio-mini').hidden,
|
|
focusRestored: document.activeElement === launcher,
|
|
radioCollapsed: document.getElementById('radio-panel').classList.contains('collapsed'),
|
|
};
|
|
launcher.click();
|
|
return result;
|
|
});
|
|
check(
|
|
'dedicated compact close hides only the compact Radio disclosure and restores launcher focus',
|
|
compactCloseDisclosure.launcherExpanded === 'false'
|
|
&& compactCloseDisclosure.compactHidden
|
|
&& compactCloseDisclosure.focusRestored
|
|
&& compactCloseDisclosure.radioCollapsed,
|
|
JSON.stringify(compactCloseDisclosure),
|
|
);
|
|
const compactTouchDisclosure = await page.evaluate(() => {
|
|
const button = document.getElementById('context-radio-toggle-btn');
|
|
button.click();
|
|
const closed = button.getAttribute('aria-expanded') === 'false'
|
|
&& document.getElementById('context-radio-mini').hidden;
|
|
button.dispatchEvent(new PointerEvent('pointerdown', {
|
|
bubbles: true,
|
|
pointerType: 'touch',
|
|
isPrimary: true,
|
|
}));
|
|
button.click();
|
|
return {
|
|
closed,
|
|
reopened: button.getAttribute('aria-expanded') === 'true'
|
|
&& !document.getElementById('context-radio-mini').hidden,
|
|
};
|
|
});
|
|
check(
|
|
'touch/coarse-pointer activation can close and reopen compact Radio controls',
|
|
compactTouchDisclosure.closed && compactTouchDisclosure.reopened,
|
|
JSON.stringify(compactTouchDisclosure),
|
|
);
|
|
const multiExpandedPanelLayout = await page.evaluate(async () => {
|
|
const manager = window.__godsEyeView.styleManager;
|
|
const ids = ['data-panel', 'scene-panel', 'pp-toggles', 'cctv-panel', 'global-context-panel'];
|
|
const prior = {
|
|
panels: Object.fromEntries(ids.map((id) => {
|
|
const panel = document.getElementById(id);
|
|
return [id, {
|
|
className: panel.className,
|
|
style: panel.getAttribute('style'),
|
|
ariaHidden: panel.getAttribute('aria-hidden'),
|
|
}];
|
|
})),
|
|
stacks: Object.fromEntries(['left-panel-stack', 'right-context-rail'].map((id) => {
|
|
const stack = document.getElementById(id);
|
|
return [id, {
|
|
className: stack.className,
|
|
style: stack.getAttribute('style'),
|
|
dataset: { ...stack.dataset },
|
|
}];
|
|
})),
|
|
preferredLeftPanelId: manager._leftStackPreferredPanelId,
|
|
preferredRightPanelId: manager._rightStackPreferredPanelId,
|
|
hudMode: manager.hud.getMode(),
|
|
hudVariant: manager.hud.getVariant(),
|
|
focusId: document.activeElement?.id || null,
|
|
storage: Object.fromEntries(Object.entries(localStorage)),
|
|
};
|
|
const setPanelThroughInstalledControl = (id, collapsed) => {
|
|
const panel = document.getElementById(id);
|
|
if (panel.classList.contains('collapsed') === collapsed) return;
|
|
panel.querySelector(`[data-collapse-target="${id}"]`)?.click();
|
|
};
|
|
let result;
|
|
try {
|
|
manager.hud.setMode('off');
|
|
setPanelThroughInstalledControl('data-panel', true);
|
|
setPanelThroughInstalledControl('scene-panel', true);
|
|
setPanelThroughInstalledControl('data-panel', false);
|
|
setPanelThroughInstalledControl('scene-panel', false);
|
|
manager.setPanelCollapsed('global-context-panel', false, { explicit: true });
|
|
manager.setPanelCollapsed('pp-toggles', false, { explicit: true });
|
|
await new Promise((resolve) => setTimeout(resolve, 360));
|
|
manager._syncLeftPanelAdaptiveLayout();
|
|
manager._syncRightPanelAdaptiveLayout();
|
|
|
|
const left = document.getElementById('left-panel-stack');
|
|
const right = document.getElementById('right-context-rail');
|
|
const leftRect = left.getBoundingClientRect();
|
|
const rightRect = right.getBoundingClientRect();
|
|
const leftExpanded = ['data-panel', 'scene-panel'].map((id) => (
|
|
document.getElementById(id).getBoundingClientRect()
|
|
));
|
|
const rightExpanded = ['pp-toggles', 'global-context-panel'].map((id) => (
|
|
document.getElementById(id).getBoundingClientRect()
|
|
));
|
|
result = {
|
|
independentLeft: ['data-panel', 'scene-panel'].every((id) => (
|
|
!document.getElementById(id).classList.contains('collapsed')
|
|
)),
|
|
independentRight: ['pp-toggles', 'global-context-panel'].every((id) => (
|
|
!document.getElementById(id).classList.contains('collapsed')
|
|
)),
|
|
leftCountsAllExpanded: left.dataset.expandedCount === '2',
|
|
rightCountsAllExpanded: right.dataset.expandedCount === '2',
|
|
leftInsideCorridor: leftExpanded.every((rect) => (
|
|
rect.top >= leftRect.top - 1 && rect.bottom <= leftRect.bottom + 1
|
|
)),
|
|
rightInsideCorridor: rightExpanded.every((rect) => (
|
|
rect.top >= rightRect.top - 1 && rect.bottom <= rightRect.bottom + 1
|
|
)),
|
|
internalScrollOwned: getComputedStyle(document.querySelector('#data-panel .data-toggle-list')).overflowY === 'auto'
|
|
&& getComputedStyle(document.querySelector('#scene-panel .scene-panel-inner')).overflowY === 'auto'
|
|
&& getComputedStyle(document.querySelector('#global-context-panel .global-context-panel-inner')).overflowY === 'auto',
|
|
};
|
|
|
|
manager._setHudVariant('tactical');
|
|
manager.hud.setMode('on');
|
|
manager._updateHudButtonState();
|
|
await new Promise((resolve) => setTimeout(resolve, 360));
|
|
manager._syncLeftPanelAdaptiveLayout();
|
|
manager._syncRightPanelAdaptiveLayout();
|
|
const dataPanel = document.getElementById('data-panel');
|
|
const scenePanel = document.getElementById('scene-panel');
|
|
const contextPanel = document.getElementById('global-context-panel');
|
|
result.tacticalAutoCollapse = {
|
|
latestOpenedPanel: manager._leftStackPreferredPanelId,
|
|
scenesExpanded: !scenePanel.classList.contains('collapsed'),
|
|
dataCollapsed: dataPanel.classList.contains('layout-auto-collapsed'),
|
|
dataHiddenWhileScenesOwnsLane: dataPanel.getBoundingClientRect().height === 0
|
|
&& dataPanel.getAttribute('aria-hidden') === 'true',
|
|
dataAriaExpanded: dataPanel.querySelector('[data-collapse-target="data-panel"]')?.getAttribute('aria-expanded'),
|
|
displayExpanded: !document.getElementById('pp-toggles').classList.contains('collapsed'),
|
|
contextCollapsed: contextPanel.classList.contains('layout-auto-collapsed'),
|
|
contextHiddenWhileDisplayOwnsLane: contextPanel.getBoundingClientRect().height === 0
|
|
&& contextPanel.getAttribute('aria-hidden') === 'true',
|
|
contextAriaExpanded: contextPanel.querySelector('[data-collapse-target="global-context-panel"]')?.getAttribute('aria-expanded'),
|
|
};
|
|
|
|
manager.setPanelCollapsed('scene-panel', true);
|
|
manager.setPanelCollapsed('pp-toggles', true);
|
|
await new Promise((resolve) => setTimeout(resolve, 360));
|
|
manager._syncLeftPanelAdaptiveLayout();
|
|
manager._syncRightPanelAdaptiveLayout();
|
|
result.tacticalAutoRestore = {
|
|
dataExpanded: !dataPanel.classList.contains('collapsed'),
|
|
dataAutoMarkerCleared: !dataPanel.classList.contains('layout-auto-collapsed'),
|
|
dataAriaExpanded: dataPanel.querySelector('[data-collapse-target="data-panel"]')?.getAttribute('aria-expanded'),
|
|
contextExpanded: !contextPanel.classList.contains('collapsed'),
|
|
contextAutoMarkerCleared: !contextPanel.classList.contains('layout-auto-collapsed'),
|
|
contextAriaExpanded: contextPanel.querySelector('[data-collapse-target="global-context-panel"]')?.getAttribute('aria-expanded'),
|
|
};
|
|
|
|
manager.setPanelCollapsed('global-context-panel', true);
|
|
manager.setPanelCollapsed('cctv-panel', true);
|
|
manager.setPanelCollapsed('pp-toggles', false);
|
|
await new Promise((resolve) => setTimeout(resolve, 240));
|
|
manager._syncRightPanelAdaptiveLayout();
|
|
const displayPanel = document.getElementById('pp-toggles');
|
|
const cctvPanel = document.getElementById('cctv-panel');
|
|
result.tacticalDisplayExclusive = {
|
|
railExclusive: right.classList.contains('layout-exclusive'),
|
|
displayExpanded: !displayPanel.classList.contains('collapsed'),
|
|
cctvHidden: cctvPanel.getBoundingClientRect().height === 0
|
|
&& cctvPanel.getAttribute('aria-hidden') === 'true',
|
|
contextHidden: contextPanel.getBoundingClientRect().height === 0
|
|
&& contextPanel.getAttribute('aria-hidden') === 'true',
|
|
};
|
|
} finally {
|
|
manager._setHudVariant(prior.hudVariant);
|
|
manager.hud.setMode(prior.hudMode);
|
|
manager._updateHudButtonState();
|
|
await new Promise((resolve) => setTimeout(resolve, 320));
|
|
if (manager._leftStackLayoutFrame !== null) {
|
|
cancelAnimationFrame(manager._leftStackLayoutFrame);
|
|
manager._leftStackLayoutFrame = null;
|
|
}
|
|
if (manager._rightStackLayoutFrame !== null) {
|
|
cancelAnimationFrame(manager._rightStackLayoutFrame);
|
|
manager._rightStackLayoutFrame = null;
|
|
}
|
|
for (const [id, state] of Object.entries(prior.panels)) {
|
|
const panel = document.getElementById(id);
|
|
panel.className = state.className;
|
|
if (state.style === null) panel.removeAttribute('style');
|
|
else panel.setAttribute('style', state.style);
|
|
if (state.ariaHidden === null) panel.removeAttribute('aria-hidden');
|
|
else panel.setAttribute('aria-hidden', state.ariaHidden);
|
|
manager._syncPanelCollapseButton(panel);
|
|
}
|
|
for (const [id, state] of Object.entries(prior.stacks)) {
|
|
const stack = document.getElementById(id);
|
|
stack.className = state.className;
|
|
if (state.style === null) stack.removeAttribute('style');
|
|
else stack.setAttribute('style', state.style);
|
|
for (const key of Object.keys(stack.dataset)) delete stack.dataset[key];
|
|
Object.assign(stack.dataset, state.dataset);
|
|
}
|
|
manager._leftStackPreferredPanelId = prior.preferredLeftPanelId;
|
|
manager._rightStackPreferredPanelId = prior.preferredRightPanelId;
|
|
localStorage.clear();
|
|
for (const [key, value] of Object.entries(prior.storage)) localStorage.setItem(key, value);
|
|
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
if (manager._leftStackLayoutFrame !== null) {
|
|
cancelAnimationFrame(manager._leftStackLayoutFrame);
|
|
manager._leftStackLayoutFrame = null;
|
|
}
|
|
if (manager._rightStackLayoutFrame !== null) {
|
|
cancelAnimationFrame(manager._rightStackLayoutFrame);
|
|
manager._rightStackLayoutFrame = null;
|
|
}
|
|
// Disclosure restoration can wake the installed layout observers. Let
|
|
// those observers settle, then restore the captured stack presentation
|
|
// once more so this synthetic scenario cannot leak derived lane state
|
|
// into the Radio interactions that follow.
|
|
for (const [id, state] of Object.entries(prior.stacks)) {
|
|
const stack = document.getElementById(id);
|
|
stack.className = state.className;
|
|
if (state.style === null) stack.removeAttribute('style');
|
|
else stack.setAttribute('style', state.style);
|
|
for (const key of Object.keys(stack.dataset)) delete stack.dataset[key];
|
|
Object.assign(stack.dataset, state.dataset);
|
|
}
|
|
prior.focusId && document.getElementById(prior.focusId)?.focus({ preventScroll: true });
|
|
const panelsRestored = Object.entries(prior.panels).every(([id, state]) => {
|
|
const panel = document.getElementById(id);
|
|
return panel.className === state.className
|
|
&& panel.getAttribute('style') === state.style
|
|
&& panel.getAttribute('aria-hidden') === state.ariaHidden;
|
|
});
|
|
const stacksRestored = Object.entries(prior.stacks).every(([id, state]) => {
|
|
const stack = document.getElementById(id);
|
|
return stack.className === state.className
|
|
&& stack.getAttribute('style') === state.style
|
|
&& JSON.stringify({ ...stack.dataset }) === JSON.stringify(state.dataset);
|
|
});
|
|
result.restoreDetails = {
|
|
panelsRestored,
|
|
stacksRestored,
|
|
preferredPanelRestored: manager._leftStackPreferredPanelId === prior.preferredLeftPanelId,
|
|
preferredRightPanelRestored: manager._rightStackPreferredPanelId === prior.preferredRightPanelId,
|
|
hudModeRestored: manager.hud.getMode() === prior.hudMode,
|
|
hudVariantRestored: manager.hud.getVariant() === prior.hudVariant,
|
|
storageRestored: JSON.stringify(Object.fromEntries(Object.entries(localStorage))) === JSON.stringify(prior.storage),
|
|
focusRestored: !prior.focusId || document.activeElement?.id === prior.focusId,
|
|
};
|
|
result.restoredExactly = Object.values(result.restoreDetails).every(Boolean);
|
|
}
|
|
|
|
return result;
|
|
});
|
|
check(
|
|
'HUD-off desktop lanes keep Data plus Scenes and Display plus Context expanded inside the viewport corridor',
|
|
multiExpandedPanelLayout.independentLeft
|
|
&& multiExpandedPanelLayout.independentRight
|
|
&& multiExpandedPanelLayout.leftCountsAllExpanded
|
|
&& multiExpandedPanelLayout.rightCountsAllExpanded
|
|
&& multiExpandedPanelLayout.leftInsideCorridor
|
|
&& multiExpandedPanelLayout.rightInsideCorridor
|
|
&& multiExpandedPanelLayout.internalScrollOwned
|
|
&& multiExpandedPanelLayout.restoredExactly,
|
|
JSON.stringify(multiExpandedPanelLayout),
|
|
);
|
|
check(
|
|
'HUD-off to Tactical preserves the latest-opened Scenes panel and hides its collapsed Data competitor',
|
|
multiExpandedPanelLayout.tacticalAutoCollapse.latestOpenedPanel === 'scene-panel'
|
|
&& multiExpandedPanelLayout.tacticalAutoCollapse.scenesExpanded
|
|
&& multiExpandedPanelLayout.tacticalAutoCollapse.dataCollapsed
|
|
&& multiExpandedPanelLayout.tacticalAutoCollapse.dataHiddenWhileScenesOwnsLane
|
|
&& multiExpandedPanelLayout.tacticalAutoCollapse.dataAriaExpanded === 'false'
|
|
&& multiExpandedPanelLayout.tacticalAutoCollapse.displayExpanded
|
|
&& multiExpandedPanelLayout.tacticalAutoCollapse.contextCollapsed
|
|
&& multiExpandedPanelLayout.tacticalAutoCollapse.contextHiddenWhileDisplayOwnsLane
|
|
&& multiExpandedPanelLayout.tacticalAutoCollapse.contextAriaExpanded === 'false',
|
|
JSON.stringify(multiExpandedPanelLayout.tacticalAutoCollapse),
|
|
);
|
|
check(
|
|
'closing Tactical lane owners restores eligible temporary siblings without changing disclosure truth',
|
|
multiExpandedPanelLayout.tacticalAutoRestore.dataExpanded
|
|
&& multiExpandedPanelLayout.tacticalAutoRestore.dataAutoMarkerCleared
|
|
&& multiExpandedPanelLayout.tacticalAutoRestore.dataAriaExpanded === 'true'
|
|
&& multiExpandedPanelLayout.tacticalAutoRestore.contextExpanded
|
|
&& multiExpandedPanelLayout.tacticalAutoRestore.contextAutoMarkerCleared
|
|
&& multiExpandedPanelLayout.tacticalAutoRestore.contextAriaExpanded === 'true',
|
|
JSON.stringify(multiExpandedPanelLayout.tacticalAutoRestore),
|
|
);
|
|
check(
|
|
'Tactical Display hides ordinary collapsed CCTV and Context launchers',
|
|
Object.values(multiExpandedPanelLayout.tacticalDisplayExclusive).every(Boolean),
|
|
JSON.stringify(multiExpandedPanelLayout.tacticalDisplayExclusive),
|
|
);
|
|
const parameterizedDisplayScroll = await page.evaluate(async () => {
|
|
const manager = window.__godsEyeView.styleManager;
|
|
const display = document.getElementById('pp-toggles');
|
|
const parameters = document.getElementById('param-slider-panel');
|
|
const prior = {
|
|
style: manager.activeStyle,
|
|
displayCollapsed: display.classList.contains('collapsed'),
|
|
contextCollapsed: document.getElementById('global-context-panel').classList.contains('collapsed'),
|
|
cctvCollapsed: document.getElementById('cctv-panel').classList.contains('collapsed'),
|
|
hudMode: manager.hud.getMode(),
|
|
hudVariant: manager.hud.getVariant(),
|
|
focusId: document.activeElement?.id || null,
|
|
};
|
|
const waitForLayout = () => new Promise((resolve) => setTimeout(resolve, 320));
|
|
const cases = [];
|
|
|
|
manager.setPanelCollapsed('global-context-panel', true);
|
|
manager.setPanelCollapsed('cctv-panel', true);
|
|
manager.setPanelCollapsed('pp-toggles', false);
|
|
manager.setStyle('thermal', { applyPreset: false });
|
|
|
|
for (const state of [
|
|
{ name: 'tactical', visible: true, variant: 'tactical' },
|
|
{ name: 'minimal', visible: true, variant: 'minimal' },
|
|
{ name: 'hud-off', visible: false, variant: 'minimal' },
|
|
]) {
|
|
manager._setHudVariant(state.variant);
|
|
manager.hud.setMode(state.visible ? 'on' : 'off');
|
|
manager._updateHudButtonState();
|
|
await waitForLayout();
|
|
manager._syncRightPanelAdaptiveLayout();
|
|
display.scrollTop = Math.max(1, display.scrollHeight - display.clientHeight);
|
|
const before = display.scrollTop;
|
|
manager._syncRightPanelAdaptiveLayout();
|
|
const lastSlider = document.querySelector('#param-sliders .param-slider-row:last-child input');
|
|
lastSlider.focus({ preventScroll: true });
|
|
lastSlider.scrollIntoView({ block: 'nearest' });
|
|
const displayRect = display.getBoundingClientRect();
|
|
const sliderRect = lastSlider.getBoundingClientRect();
|
|
cases.push({
|
|
name: state.name,
|
|
before,
|
|
after: display.scrollTop,
|
|
finalRowReachable: sliderRect.top >= displayRect.top - 1
|
|
&& sliderRect.bottom <= displayRect.bottom + 1,
|
|
displayOwnsScroll: getComputedStyle(display).overflowY === 'auto',
|
|
parametersDoNotTrapScroll: getComputedStyle(parameters).overflowY === 'visible',
|
|
parameterRows: document.querySelectorAll('#param-sliders .param-slider-row').length,
|
|
});
|
|
}
|
|
|
|
manager.setStyle(prior.style, { applyPreset: false });
|
|
manager.setPanelCollapsed('pp-toggles', prior.displayCollapsed);
|
|
manager.setPanelCollapsed('global-context-panel', prior.contextCollapsed);
|
|
manager.setPanelCollapsed('cctv-panel', prior.cctvCollapsed);
|
|
manager._setHudVariant(prior.hudVariant);
|
|
manager.hud.setMode(prior.hudMode);
|
|
manager._updateHudButtonState();
|
|
prior.focusId && document.getElementById(prior.focusId)?.focus({ preventScroll: true });
|
|
return cases;
|
|
});
|
|
check(
|
|
'parameterized Display remains scrollable through Tactical, Minimal, and HUD Off layout passes',
|
|
parameterizedDisplayScroll.every((state) => (
|
|
state.parameterRows >= 5
|
|
&& state.before > 0
|
|
&& state.after > 0
|
|
&& state.finalRowReachable
|
|
&& state.displayOwnsScroll
|
|
&& state.parametersDoNotTrapScroll
|
|
)),
|
|
JSON.stringify(parameterizedDisplayScroll),
|
|
);
|
|
const cockpitCompactControls = await page.evaluate(async () => {
|
|
const hud = document.getElementById('cockpit-hud');
|
|
const signal = document.getElementById('cockpit-signal-stream');
|
|
const display = document.getElementById('cockpit-display-toggle-btn');
|
|
const radio = document.getElementById('cockpit-radio-toggle-btn');
|
|
const hudToggle = document.getElementById('hud-toggle');
|
|
const hudLayout = document.getElementById('hud-layout-select');
|
|
const detectionToggle = document.getElementById('detection-toggle');
|
|
const models3dToggle = document.getElementById('models3d-toggle');
|
|
const models3dAll = document.getElementById('models3d-mode-all');
|
|
const scene = document.getElementById('scene-panel');
|
|
const dataPanel = document.getElementById('data-panel');
|
|
const manager = window.__godsEyeView.styleManager;
|
|
// This block validates portaled DOM controls, while qa-cockpit-utility
|
|
// owns the real tracked-aircraft camera session. Hold the frame update so
|
|
// the intentionally synthetic Cockpit shell is not auto-exited mid-check.
|
|
const realCockpitUpdate = manager.cockpitView.update;
|
|
const waitForLayout = () => new Promise((resolve) => setTimeout(resolve, 320));
|
|
const prior = {
|
|
cockpit: document.body.classList.contains('cockpit-mode'),
|
|
cockpitActive: manager.cockpitView.active,
|
|
bodyClassName: document.body.className,
|
|
contextCollapsed: document.getElementById('global-context-panel').classList.contains('collapsed'),
|
|
hudHidden: hud.hidden,
|
|
signalHidden: signal.hidden,
|
|
dataCollapsed: dataPanel.classList.contains('collapsed'),
|
|
mapStack: manager.mapStackController?.getActiveId(),
|
|
hudMode: manager.hud.getMode(),
|
|
hudVariant: manager.hud.getVariant(),
|
|
displayExpanded: display.getAttribute('aria-expanded') === 'true',
|
|
radioExpanded: radio.getAttribute('aria-expanded') === 'true',
|
|
contextRadioExpanded: document.getElementById('context-radio-toggle-btn')?.getAttribute('aria-expanded') === 'true',
|
|
focusId: document.activeElement?.id || null,
|
|
detectionMode: manager.getDetectionState().detectionMode,
|
|
models3dEnabled: manager._models3dEnabled,
|
|
models3dMode: manager._models3dMode,
|
|
preferredLeftPanelId: manager._leftStackPreferredPanelId,
|
|
panels: Object.fromEntries([
|
|
'data-panel',
|
|
'global-context-panel',
|
|
].map((id) => {
|
|
const panel = document.getElementById(id);
|
|
return [id, {
|
|
className: panel.className,
|
|
style: panel.getAttribute('style'),
|
|
ariaHidden: panel.getAttribute('aria-hidden'),
|
|
}];
|
|
})),
|
|
storage: Object.fromEntries(Object.entries(localStorage)),
|
|
utilityTop: hud.style.getPropertyValue('--cockpit-utility-top'),
|
|
};
|
|
let result = {};
|
|
try {
|
|
manager.cockpitView.update = () => {};
|
|
document.body.classList.add('cockpit-mode');
|
|
manager.cockpitView.active = true;
|
|
manager._setCockpitDisplayPortalActive(true);
|
|
hud.hidden = false;
|
|
signal.hidden = false;
|
|
dataPanel.classList.remove('collapsed');
|
|
window.__godsEyeView.styleManager._syncLeftPanelAdaptiveLayout();
|
|
window.__godsEyeView.styleManager.cockpitView.syncSignalLayout();
|
|
if (display.getAttribute('aria-expanded') === 'true') display.click();
|
|
display.click();
|
|
manager._setHudVariant('tactical');
|
|
manager.hud.setMode('on');
|
|
manager._updateHudButtonState();
|
|
await waitForLayout();
|
|
|
|
const layoutSteps = [];
|
|
// The two Cockpit lanes are solved independently: the accordion against
|
|
// left-lane obstacles, the Display/Radio strip against the REC readout
|
|
// and the briefing card it shares the right margin with. Record both
|
|
// sets of inputs so each lane is asserted on its own terms.
|
|
const visibleRect = (selector) => {
|
|
const element = document.querySelector(selector);
|
|
if (!element || element.hidden) return null;
|
|
for (let node = element; node; node = node.parentElement) {
|
|
const style = getComputedStyle(node);
|
|
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) {
|
|
return null;
|
|
}
|
|
}
|
|
const rect = element.getBoundingClientRect();
|
|
return rect.width > 0 && rect.height > 0 ? rect : null;
|
|
};
|
|
const recordLayoutStep = () => {
|
|
const dataRect = dataPanel.getBoundingClientRect();
|
|
const utilityRect = document.getElementById('cockpit-utility-controls').getBoundingClientRect();
|
|
const displayRect = display.closest('.cockpit-utility-control').getBoundingClientRect();
|
|
const toplineRect = document.querySelector('#cockpit-hud .cockpit-topline').getBoundingClientRect();
|
|
const recRect = visibleRect('#intel-hud .hud-top-right');
|
|
const creditsRect = visibleRect('#cesium-credits .cesium-credit-textContainer');
|
|
// Left-lane surfaces an expanded Cockpit panel must remain clear of.
|
|
const passableTops = ['#cockpit-context', '#intel-hud .hud-bottom-left']
|
|
.map((selector) => visibleRect(selector))
|
|
.filter((rect) => rect && rect.right > dataRect.left && rect.left < dataRect.right)
|
|
.map((rect) => rect.top);
|
|
const stack = document.getElementById('left-panel-stack');
|
|
layoutSteps.push({
|
|
hud: manager.hud.visible ? manager.hud.getVariant().toUpperCase() : 'OFF',
|
|
dataTop: dataRect.top,
|
|
dataBottom: dataRect.bottom,
|
|
// The corridor the engine committed, independent of how tall the
|
|
// panel's own content happens to be.
|
|
layoutBottom: window.innerHeight * (Number(stack.dataset.safeBottomPct) || 0) / 100,
|
|
safeGap: window.innerHeight * 0.012,
|
|
displayTop: displayRect.top,
|
|
utilityTop: utilityRect.top,
|
|
utilityBottom: utilityRect.bottom,
|
|
utilityHeight: utilityRect.height,
|
|
toplineBottom: toplineRect.bottom,
|
|
recBottom: recRect ? recRect.bottom : 0,
|
|
signalTop: signal.getBoundingClientRect().top,
|
|
creditsTop: creditsRect ? creditsRect.top : null,
|
|
passableTop: passableTops.length ? Math.min(...passableTops) : null,
|
|
minTop: Math.max(96, window.innerHeight * 0.12),
|
|
layoutMode: document.getElementById('left-panel-stack').dataset.layoutMode,
|
|
});
|
|
};
|
|
// The Intel HUD fades over 400ms and keeps its readout rects for the
|
|
// whole transition, so both lanes are measured only once it has settled.
|
|
const waitForHudSettle = () => new Promise((resolve) => setTimeout(resolve, 560));
|
|
for (let index = 0; index < 5; index += 1) {
|
|
recordLayoutStep();
|
|
if (index < 4) {
|
|
if (index < 2) {
|
|
hudLayout.value = index === 0 ? 'operator' : 'minimal';
|
|
hudLayout.dispatchEvent(new Event('change', { bubbles: true }));
|
|
} else {
|
|
hudToggle.click();
|
|
}
|
|
await waitForHudSettle();
|
|
}
|
|
}
|
|
|
|
// Cockpit's CONTACT card shares the accordion's lane. This synthetic
|
|
// shell has no tracked contact, so reveal the card to prove an expanded
|
|
// panel stops above it while its internally scrolled controls stay live.
|
|
const contextCard = document.getElementById('cockpit-context');
|
|
const contextWasHidden = contextCard.hidden;
|
|
contextCard.hidden = false;
|
|
// Expand the panel the same way this block already does at entry, so the
|
|
// portaled Display groups measured further down keep their DOM home.
|
|
dataPanel.classList.remove('collapsed', 'layout-auto-collapsed');
|
|
manager._syncPanelCollapseButton(dataPanel);
|
|
manager._syncLeftPanelAdaptiveLayout();
|
|
await waitForLayout();
|
|
const panelRect = dataPanel.getBoundingClientRect();
|
|
const contextRect = contextCard.getBoundingClientRect();
|
|
const contactClearancePx = contextRect.top - panelRect.bottom;
|
|
const clearsContextCard = contactClearancePx >= -0.5;
|
|
const probe = document.elementFromPoint(
|
|
Math.max(panelRect.left, contextRect.left) + 8,
|
|
Math.max(panelRect.top, contextRect.top) + 6,
|
|
);
|
|
const contextOwnsItsLane = !probe?.closest('#data-panel');
|
|
// A layer toggle must remain reachable and live from inside Cockpit.
|
|
// Prefer a bundled layer so the proof needs no external network.
|
|
const toggleList = dataPanel.querySelector('.data-toggle-list');
|
|
const toggleRows = [...toggleList.querySelectorAll('.data-toggle-row')]
|
|
.filter((row) => row.querySelector('.data-toggle-btn'));
|
|
const toggleRow = toggleRows.find((row) => String(row.dataset.layerId).startsWith('local-'))
|
|
|| toggleRows.find((row) => !/flight|military|vessel|ais|radio/i.test(row.dataset.layerId || ''));
|
|
toggleRow?.scrollIntoView({ block: 'center' });
|
|
await waitForLayout();
|
|
const toggleLayerId = toggleRow?.dataset.layerId || null;
|
|
const toggleButton = toggleRow?.querySelector('.data-toggle-btn');
|
|
const toggleButtonRect = toggleButton?.getBoundingClientRect();
|
|
const listRect = toggleList.getBoundingClientRect();
|
|
const toggleInsideScroller = Boolean(toggleButtonRect
|
|
&& toggleButtonRect.top >= listRect.top - 1
|
|
&& toggleButtonRect.bottom <= listRect.bottom + 1);
|
|
const toggleReachable = Boolean(toggleButtonRect && toggleRow.contains(
|
|
document.elementFromPoint(
|
|
toggleButtonRect.left + toggleButtonRect.width / 2,
|
|
toggleButtonRect.top + toggleButtonRect.height / 2,
|
|
),
|
|
));
|
|
const toggleBefore = toggleLayerId
|
|
? window.__godsEyeView.dataManager.isEnabled(toggleLayerId) : null;
|
|
toggleButton?.click();
|
|
await waitForLayout();
|
|
const toggleAfter = toggleLayerId
|
|
? window.__godsEyeView.dataManager.isEnabled(toggleLayerId) : null;
|
|
toggleButton?.click();
|
|
await waitForLayout();
|
|
const toggleRestored = toggleLayerId
|
|
? window.__godsEyeView.dataManager.isEnabled(toggleLayerId) === toggleBefore : false;
|
|
const cockpitPanelInteraction = {
|
|
toggleLayerId,
|
|
contactClearancePx,
|
|
clearsContextCard,
|
|
contextOwnsItsLane,
|
|
toggleInsideScroller,
|
|
toggleReachable,
|
|
toggleBefore,
|
|
toggleAfter,
|
|
toggleRestored,
|
|
listScrolls: toggleList.scrollHeight > toggleList.clientHeight,
|
|
panelHeight: Math.round(panelRect.height),
|
|
};
|
|
contextCard.hidden = contextWasHidden;
|
|
// Restoring the layer destroys it, and a layer teardown during a Context
|
|
// session legitimately exits Cockpit (ui.js subscribeBeforeDestroy). Re-
|
|
// establish this block's synthetic shell exactly as it was entered, so
|
|
// the portaled-control checks below keep their precondition.
|
|
document.body.classList.add('cockpit-mode');
|
|
manager.cockpitView.active = true;
|
|
hud.hidden = false;
|
|
signal.hidden = false;
|
|
manager._setCockpitDisplayPortalActive(true);
|
|
manager._setCockpitDisclosure('display', true);
|
|
await waitForLayout();
|
|
|
|
manager._setModels3dMode('proximity');
|
|
manager._setModels3dEnabled(false);
|
|
const models3dSteps = [];
|
|
for (let index = 0; index < 3; index += 1) {
|
|
if (index === 0 || index === 2) models3dToggle.click();
|
|
else models3dAll.click();
|
|
const flightsParams = manager._dataManager.getLayerParams('flights');
|
|
const militaryParams = manager._dataManager.getLayerParams('military');
|
|
models3dSteps.push({
|
|
label: manager._models3dEnabled ? manager._models3dMode.toUpperCase() : 'OFF',
|
|
flightsEnabled: flightsParams.models3d,
|
|
flightsMode: flightsParams.models3dMode,
|
|
militaryEnabled: militaryParams.models3d,
|
|
militaryMode: militaryParams.models3dMode,
|
|
});
|
|
}
|
|
|
|
manager.cockpitView.syncSignalLayout();
|
|
const displayOpened = display.getAttribute('aria-expanded') === 'true'
|
|
&& !document.getElementById('cockpit-display-panel').hidden;
|
|
const sharedDisplayControlsPortaled = [hudToggle, detectionToggle, models3dToggle]
|
|
.every((control) => control.closest('.pp-toggle-group')?.parentElement
|
|
?.closest('#cockpit-display-panel'));
|
|
display.click();
|
|
|
|
manager.cockpitView.syncSignalLayout();
|
|
radio.click();
|
|
const radioOpened = radio.getAttribute('aria-expanded') === 'true'
|
|
&& !document.getElementById('cockpit-radio-panel').hidden;
|
|
const mutuallyExclusive = display.getAttribute('aria-expanded') === 'false';
|
|
radio.click();
|
|
|
|
manager.cockpitView.syncSignalLayout();
|
|
radio.click();
|
|
|
|
const radioNavigationAvailable = [
|
|
document.getElementById('cockpit-radio-prev-btn'),
|
|
document.getElementById('cockpit-radio-next-btn'),
|
|
].every((button) => button?.isConnected && !button.disabled);
|
|
const detailsExcluded = !document.querySelector(
|
|
'#cockpit-radio-panel #context-radio-details-btn, '
|
|
+ '#cockpit-radio-panel #radio-filter, #cockpit-radio-panel #radio-tuner',
|
|
);
|
|
const scenesHidden = getComputedStyle(scene).display === 'none';
|
|
radio.click();
|
|
|
|
manager.setPanelCollapsed('data-panel', true);
|
|
await waitForLayout();
|
|
const utility = document.getElementById('cockpit-utility-controls');
|
|
const photorealAvailable = manager.mapStackController?.getStacks()
|
|
.some((stack) => stack.id === 'photoreal' && stack.available);
|
|
let mapProviderUtilityStable = true;
|
|
if (photorealAvailable) {
|
|
await manager._setMapStack('photoreal', { syncShare: false });
|
|
await waitForLayout();
|
|
const stableTop = utility.getBoundingClientRect().top;
|
|
const transitionTops = [];
|
|
let sampling = true;
|
|
const sampleTop = () => {
|
|
transitionTops.push(utility.getBoundingClientRect().top);
|
|
if (sampling) requestAnimationFrame(sampleTop);
|
|
};
|
|
requestAnimationFrame(sampleTop);
|
|
await manager._setMapStack('osm', { syncShare: false });
|
|
await waitForLayout();
|
|
sampling = false;
|
|
mapProviderUtilityStable = transitionTops.length > 1
|
|
&& transitionTops.every((top) => Math.abs(top - stableTop) < 1);
|
|
}
|
|
result = {
|
|
cockpitPanelInteraction,
|
|
displayOpened,
|
|
sharedDisplayControlsPortaled,
|
|
radioOpened,
|
|
mutuallyExclusive,
|
|
radioNavigationAvailable,
|
|
detailsExcluded,
|
|
scenesHidden,
|
|
mapProviderUtilityStable,
|
|
hudInteractionsExact: layoutSteps.map((step) => step.hud).join('>')
|
|
=== 'TACTICAL>OPERATOR>MINIMAL>OFF>MINIMAL',
|
|
// The accordion clears the topline, Cockpit cards, and Cesium credits
|
|
// across every HUD layout. Its list owns any overflow internally.
|
|
leftLaneClearAcrossHudCycle: layoutSteps.every((step) => (
|
|
step.dataTop >= step.toplineBottom
|
|
&& (step.creditsTop === null || step.dataBottom <= step.creditsTop)
|
|
&& (step.passableTop === null || step.dataBottom <= step.passableTop + 0.5)
|
|
)),
|
|
// The strip is solved from ITS margin — under the REC readout, clamped
|
|
// clear of the briefing card, never above the viewport ceiling — and
|
|
// never from whatever corridor the accordion happened to commit.
|
|
rightStripClearAcrossHudCycle: layoutSteps.every((step) => {
|
|
const anchored = Math.max(step.minTop, step.recBottom + 12);
|
|
const expected = Math.max(
|
|
step.minTop,
|
|
Math.min(anchored, step.signalTop - 8 - step.utilityHeight),
|
|
);
|
|
return step.displayTop >= step.toplineBottom
|
|
&& step.utilityBottom <= step.signalTop - 7.9
|
|
&& Math.abs(step.utilityTop - expected) < 0.6;
|
|
}),
|
|
models3dInteractionsExact: models3dSteps.map((step) => step.label).join('>')
|
|
=== 'PROXIMITY>ALL>OFF',
|
|
models3dLayersSynchronized: models3dSteps.every((step, index) => {
|
|
const expectedEnabled = index < 2;
|
|
const expectedMode = index === 0 ? 'proximity' : 'all';
|
|
return step.flightsEnabled === expectedEnabled
|
|
&& step.militaryEnabled === expectedEnabled
|
|
&& step.flightsMode === expectedMode
|
|
&& step.militaryMode === expectedMode;
|
|
}),
|
|
};
|
|
} finally {
|
|
if (prior.mapStack) await manager._setMapStack(prior.mapStack, { syncShare: false });
|
|
manager._setDetectionMode(prior.detectionMode);
|
|
manager._setModels3dMode(prior.models3dMode);
|
|
manager._setModels3dEnabled(prior.models3dEnabled);
|
|
manager._setHudVariant(prior.hudVariant);
|
|
manager.hud.setMode(prior.hudMode);
|
|
manager._updateHudButtonState();
|
|
manager.cockpitView.active = prior.cockpitActive;
|
|
manager.cockpitView.update = realCockpitUpdate;
|
|
manager.setPanelCollapsed('data-panel', prior.dataCollapsed);
|
|
document.body.className = prior.bodyClassName;
|
|
manager._setCockpitDisplayPortalActive(prior.cockpit);
|
|
manager.setPanelCollapsed('global-context-panel', prior.contextCollapsed);
|
|
if (display.getAttribute('aria-expanded') !== String(prior.displayExpanded)) display.click();
|
|
if (radio.getAttribute('aria-expanded') !== String(prior.radioExpanded)) radio.click();
|
|
manager._setRadioDisclosure(prior.contextRadioExpanded);
|
|
hud.hidden = prior.hudHidden;
|
|
signal.hidden = prior.signalHidden;
|
|
if (prior.utilityTop) hud.style.setProperty('--cockpit-utility-top', prior.utilityTop);
|
|
else hud.style.removeProperty('--cockpit-utility-top');
|
|
await waitForLayout();
|
|
if (manager._leftStackLayoutFrame !== null) {
|
|
cancelAnimationFrame(manager._leftStackLayoutFrame);
|
|
manager._leftStackLayoutFrame = null;
|
|
}
|
|
if (manager._rightStackLayoutFrame !== null) {
|
|
cancelAnimationFrame(manager._rightStackLayoutFrame);
|
|
manager._rightStackLayoutFrame = null;
|
|
}
|
|
for (const [id, state] of Object.entries(prior.panels)) {
|
|
const panel = document.getElementById(id);
|
|
panel.className = state.className;
|
|
if (state.style === null) panel.removeAttribute('style');
|
|
else panel.setAttribute('style', state.style);
|
|
if (state.ariaHidden === null) panel.removeAttribute('aria-hidden');
|
|
else panel.setAttribute('aria-hidden', state.ariaHidden);
|
|
manager._syncPanelCollapseButton(panel);
|
|
}
|
|
manager._leftStackPreferredPanelId = prior.preferredLeftPanelId;
|
|
localStorage.clear();
|
|
for (const [key, value] of Object.entries(prior.storage)) localStorage.setItem(key, value);
|
|
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
prior.focusId && document.getElementById(prior.focusId)?.focus({ preventScroll: true });
|
|
result.restoredExactly = document.body.className === prior.bodyClassName
|
|
&& manager.cockpitView.active === prior.cockpitActive
|
|
&& manager.cockpitView.update === realCockpitUpdate
|
|
&& dataPanel.classList.contains('collapsed') === prior.dataCollapsed
|
|
&& document.getElementById('global-context-panel').classList.contains('collapsed') === prior.contextCollapsed
|
|
&& display.getAttribute('aria-expanded') === String(prior.displayExpanded)
|
|
&& radio.getAttribute('aria-expanded') === String(prior.radioExpanded)
|
|
&& document.getElementById('context-radio-toggle-btn')?.getAttribute('aria-expanded') === String(prior.contextRadioExpanded)
|
|
&& hud.hidden === prior.hudHidden
|
|
&& signal.hidden === prior.signalHidden
|
|
&& manager.hud.getMode() === prior.hudMode
|
|
&& manager.hud.getVariant() === prior.hudVariant
|
|
&& Object.entries(prior.panels).every(([id, state]) => {
|
|
const panel = document.getElementById(id);
|
|
return panel.className === state.className
|
|
&& panel.getAttribute('style') === state.style
|
|
&& panel.getAttribute('aria-hidden') === state.ariaHidden;
|
|
})
|
|
&& manager._leftStackPreferredPanelId === prior.preferredLeftPanelId
|
|
&& JSON.stringify(Object.fromEntries(Object.entries(localStorage))) === JSON.stringify(prior.storage)
|
|
&& (!prior.focusId || document.activeElement?.id === prior.focusId);
|
|
}
|
|
return result;
|
|
});
|
|
check(
|
|
'Cockpit portals the standard focused Display groups and keeps compact-only Radio',
|
|
cockpitCompactControls.displayOpened
|
|
&& cockpitCompactControls.sharedDisplayControlsPortaled
|
|
&& cockpitCompactControls.radioOpened
|
|
&& cockpitCompactControls.mutuallyExclusive
|
|
&& cockpitCompactControls.radioNavigationAvailable
|
|
&& cockpitCompactControls.detailsExcluded
|
|
&& cockpitCompactControls.scenesHidden
|
|
&& cockpitCompactControls.mapProviderUtilityStable
|
|
&& cockpitCompactControls.hudInteractionsExact
|
|
&& cockpitCompactControls.leftLaneClearAcrossHudCycle
|
|
&& cockpitCompactControls.rightStripClearAcrossHudCycle
|
|
&& cockpitCompactControls.models3dInteractionsExact
|
|
&& cockpitCompactControls.models3dLayersSynchronized
|
|
&& cockpitCompactControls.restoredExactly,
|
|
JSON.stringify(cockpitCompactControls),
|
|
);
|
|
check(
|
|
'an expanded Cockpit left panel stops above CONTACT and still toggles layers',
|
|
cockpitCompactControls.cockpitPanelInteraction?.clearsContextCard
|
|
&& cockpitCompactControls.cockpitPanelInteraction.contextOwnsItsLane
|
|
&& cockpitCompactControls.cockpitPanelInteraction.toggleInsideScroller
|
|
&& cockpitCompactControls.cockpitPanelInteraction.toggleReachable
|
|
&& cockpitCompactControls.cockpitPanelInteraction.toggleBefore
|
|
!== cockpitCompactControls.cockpitPanelInteraction.toggleAfter
|
|
&& cockpitCompactControls.cockpitPanelInteraction.toggleRestored,
|
|
JSON.stringify(cockpitCompactControls.cockpitPanelInteraction),
|
|
);
|
|
await page.screenshot({ path: path.join(SHOTS_DIR, 'compact.png') });
|
|
|
|
const firstPlayPrecondition = await page.evaluate(() => (
|
|
window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().selected?.id || null
|
|
));
|
|
check('first Play scenario starts without a leaked Radio selection', firstPlayPrecondition === null,
|
|
`selected=${firstPlayPrecondition}`);
|
|
await page.select('#radio-filter', 'all');
|
|
await page.$eval('#context-radio-toggle-btn', (button) => {
|
|
if (button.getAttribute('aria-expanded') !== 'true') button.click();
|
|
});
|
|
await page.evaluate(() => {
|
|
const camera = window.__godsEyeView.viewer.camera;
|
|
window.__qaFirstPlayFlyToCalls = [];
|
|
window.__qaFirstPlayOriginalFlyTo = camera.flyTo;
|
|
camera.flyTo = (options) => window.__qaFirstPlayFlyToCalls.push(options);
|
|
document.getElementById('context-radio-toggle-btn').focus();
|
|
});
|
|
await page.$eval('#context-radio-mini-play-btn', (button) => button.click());
|
|
await page.waitForFunction(() => {
|
|
const state = window.__godsEyeView.dataManager.layers.get('radio').module.getUIState();
|
|
return state.audioState === 'playing' || state.audioState === 'error';
|
|
});
|
|
const postMicroDragPlayback = await page.evaluate(async () => {
|
|
const mod = window.__godsEyeView.dataManager.layers.get('radio').module;
|
|
let state = mod.getUIState();
|
|
if (state.audioState !== 'playing') {
|
|
await mod.play({ origin: 'user' });
|
|
state = mod.getUIState();
|
|
}
|
|
return { audioState: state.audioState, selectedId: state.selected?.id || null };
|
|
});
|
|
check('micro-drag release leaves the selected station playable',
|
|
postMicroDragPlayback.audioState === 'playing', JSON.stringify(postMicroDragPlayback));
|
|
const firstPlay = await page.evaluate(() => {
|
|
const module = window.__godsEyeView.dataManager.layers.get('radio').module;
|
|
const state = module.getUIState();
|
|
const camera = window.__godsEyeView.viewer.camera;
|
|
const result = {
|
|
state,
|
|
calls: [...window.__qaRadioPlayCalls],
|
|
flyToCalls: window.__qaFirstPlayFlyToCalls.length,
|
|
};
|
|
camera.flyTo = window.__qaFirstPlayOriginalFlyTo;
|
|
delete window.__qaFirstPlayOriginalFlyTo;
|
|
module.stopPlayback();
|
|
return result;
|
|
});
|
|
check(
|
|
'first Play selects a viewport-ranked station from All and starts its stream',
|
|
Boolean(firstPlay.state.selected?.id) && firstPlay.state.filter === 'all'
|
|
&& firstPlay.state.audioState === 'playing' && firstPlay.calls.length >= 1,
|
|
JSON.stringify({ selected: firstPlay.state.selected?.id, calls: firstPlay.calls }),
|
|
);
|
|
check('first Play never moves the camera', firstPlay.flyToCalls === 0, `${firstPlay.flyToCalls} flyTo calls`);
|
|
|
|
await page.select('#radio-filter', 'news');
|
|
await page.evaluate(() => {
|
|
const viewer = window.__godsEyeView.viewer;
|
|
viewer.camera.cancelFlight();
|
|
viewer.camera.setView({
|
|
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
|
|
longitude: -98 * Math.PI / 180,
|
|
latitude: 38 * Math.PI / 180,
|
|
height: 18_000_000,
|
|
}),
|
|
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
|
});
|
|
});
|
|
await sleep(700);
|
|
const newsClusterTarget = await page.evaluate(() => {
|
|
const gev = window.__godsEyeView;
|
|
const source = Array.from({ length: gev.viewer.dataSources.length }, (_, index) => gev.viewer.dataSources.get(index))
|
|
.find((item) => item.name === 'Radio stations');
|
|
const points = source.clustering._clusterPointCollection;
|
|
for (let index = 0; index < (points?.length || 0); index += 1) {
|
|
const point = points.get(index);
|
|
if (!point.show || !Array.isArray(point.id) || !point.id.length) continue;
|
|
const canvasPoint = gev.viewer.scene.cartesianToCanvasCoordinates(point.position);
|
|
if (!canvasPoint) continue;
|
|
return {
|
|
x: canvasPoint.x,
|
|
y: canvasPoint.y,
|
|
firstId: String(point.id[0]?.id || '').replace(/^radio:/, ''),
|
|
pointIds: point.id.map((entity) => entity.id),
|
|
};
|
|
}
|
|
return {
|
|
missing: true,
|
|
points: Array.from({ length: points?.length || 0 }, (_, index) => ({
|
|
show: points.get(index).show,
|
|
pointIds: Array.isArray(points.get(index)?.id) ? points.get(index).id.length : 0,
|
|
})),
|
|
};
|
|
});
|
|
if (!newsClusterTarget.missing) {
|
|
await page.mouse.click(newsClusterTarget.x, newsClusterTarget.y);
|
|
await page.waitForFunction((stationId) => {
|
|
const state = window.__godsEyeView.dataManager.layers.get('radio').module.getUIState();
|
|
return state.selected?.id === stationId && state.audioState === 'playing';
|
|
}, {}, newsClusterTarget.firstId);
|
|
}
|
|
const newsClusterClick = await page.evaluate(() => {
|
|
const gev = window.__godsEyeView;
|
|
const state = gev.dataManager.layers.get('radio').module.getUIState();
|
|
gev.dataManager.layers.get('radio').module.stopPlayback();
|
|
return { selectedId: state.selected?.id, audioState: state.audioState };
|
|
});
|
|
check(
|
|
'clicking a News cluster dot plays its first station',
|
|
newsClusterTarget?.pointIds?.length > 0
|
|
&& newsClusterClick.selectedId === newsClusterTarget.firstId
|
|
&& newsClusterClick.audioState === 'playing',
|
|
JSON.stringify({ target: newsClusterTarget, result: newsClusterClick }),
|
|
);
|
|
|
|
await page.select('#radio-filter', 'weather');
|
|
await sleep(700);
|
|
const weatherCluster = await page.evaluate(() => {
|
|
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
|
|
return { texts: radio.getOverlayDiagnostics().clusterTexts };
|
|
});
|
|
check(
|
|
'Weather / Emergency view labels its cluster callouts WEATHER',
|
|
weatherCluster.texts.length > 0 && weatherCluster.texts.every((text) => /^\d+ WEATHER$/.test(text)),
|
|
JSON.stringify(weatherCluster),
|
|
);
|
|
await page.select('#radio-filter', 'news');
|
|
|
|
await page.$eval('#context-radio-mini-volume', (input) => {
|
|
input.value = '35';
|
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
});
|
|
const compactVolume = await page.evaluate(() => ({
|
|
volume: window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().volume,
|
|
full: document.getElementById('radio-volume').value,
|
|
mini: document.getElementById('context-radio-mini-volume').value,
|
|
}));
|
|
check('compact volume shares the full Radio player state', Math.abs(compactVolume.volume - 0.35) < 0.001 && compactVolume.full === '35' && compactVolume.mini === '35');
|
|
|
|
const compactDisclosureContinuity = await page.evaluate(async () => {
|
|
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
|
|
const manager = window.__godsEyeView.styleManager;
|
|
manager.setPanelCollapsed('global-context-panel', true);
|
|
manager._setRadioDisclosure(true);
|
|
await radio.play({ origin: 'user' });
|
|
const before = radio.getUIState();
|
|
const disclosure = document.getElementById('context-radio-toggle-btn');
|
|
if (disclosure.getAttribute('aria-expanded') !== 'true') disclosure.click();
|
|
disclosure.click();
|
|
const closed = {
|
|
expanded: disclosure.getAttribute('aria-expanded'),
|
|
hidden: document.getElementById('context-radio-mini').hidden,
|
|
state: radio.getUIState(),
|
|
};
|
|
disclosure.click();
|
|
return {
|
|
before,
|
|
closed,
|
|
reopened: disclosure.getAttribute('aria-expanded'),
|
|
miniHidden: document.getElementById('context-radio-mini').hidden,
|
|
after: radio.getUIState(),
|
|
};
|
|
});
|
|
check(
|
|
'compact disclosure preserves playback, station, filter, and volume continuity',
|
|
compactDisclosureContinuity.closed.expanded === 'false'
|
|
&& compactDisclosureContinuity.closed.hidden
|
|
&& compactDisclosureContinuity.reopened === 'true'
|
|
&& !compactDisclosureContinuity.miniHidden
|
|
&& compactDisclosureContinuity.before.audioState === 'playing'
|
|
&& compactDisclosureContinuity.closed.state.audioState === 'playing'
|
|
&& compactDisclosureContinuity.after.audioState === 'playing'
|
|
&& compactDisclosureContinuity.after.selected?.id === compactDisclosureContinuity.before.selected?.id
|
|
&& compactDisclosureContinuity.after.filter === compactDisclosureContinuity.before.filter
|
|
&& compactDisclosureContinuity.after.volume === compactDisclosureContinuity.before.volume,
|
|
JSON.stringify({
|
|
before: compactDisclosureContinuity.before,
|
|
closed: compactDisclosureContinuity.closed,
|
|
reopened: compactDisclosureContinuity.reopened,
|
|
after: compactDisclosureContinuity.after,
|
|
}),
|
|
);
|
|
|
|
const voicePause = await page.evaluate(async () => {
|
|
const filter = document.getElementById('radio-filter');
|
|
const firstOption = filter?.options?.[0] || null;
|
|
await window.__godsEyeView.dataManager.layers.get('radio').module.play();
|
|
const startedAt = performance.now();
|
|
window.__godsEyeView.voiceCommands.setStatus('connecting', 'QA voice connect');
|
|
const state = window.__godsEyeView.dataManager.layers.get('radio').module.getUIState();
|
|
return {
|
|
...state,
|
|
transitionMs: performance.now() - startedAt,
|
|
categoryOptionPreserved: firstOption === filter?.options?.[0],
|
|
};
|
|
});
|
|
check(
|
|
'voice activation immediately pauses Radio without changing the user volume',
|
|
voicePause.audioState === 'paused' && Math.abs(voicePause.volume - 0.35) < 0.001,
|
|
JSON.stringify({ audioState: voicePause.audioState, user: voicePause.volume }),
|
|
);
|
|
check(
|
|
'voice pause preserves Radio category DOM instead of rebuilding the dropdown',
|
|
voicePause.categoryOptionPreserved,
|
|
`${voicePause.transitionMs.toFixed(1)}ms transition`,
|
|
);
|
|
const idleHorizonScans = await page.evaluate(async () => {
|
|
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
|
|
const camera = window.__godsEyeView.viewer.camera;
|
|
camera.cancelFlight();
|
|
await new Promise((resolve) => setTimeout(resolve, 350));
|
|
const positionBefore = {
|
|
x: camera.positionWC.x,
|
|
y: camera.positionWC.y,
|
|
z: camera.positionWC.z,
|
|
};
|
|
const before = radio.getStats().horizonScans;
|
|
await new Promise((resolve) => setTimeout(resolve, 850));
|
|
const dx = camera.positionWC.x - positionBefore.x;
|
|
const dy = camera.positionWC.y - positionBefore.y;
|
|
const dz = camera.positionWC.z - positionBefore.z;
|
|
return {
|
|
before,
|
|
after: radio.getStats().horizonScans,
|
|
cameraDeltaM: Math.hypot(dx, dy, dz),
|
|
};
|
|
});
|
|
check(
|
|
'stationary Radio view skips repeated 750-marker horizon scans',
|
|
idleHorizonScans.after === idleHorizonScans.before,
|
|
JSON.stringify(idleHorizonScans),
|
|
);
|
|
const idleVoice = await page.evaluate(() => {
|
|
const voice = window.__godsEyeView.voiceCommands;
|
|
voice.setMicrophoneEnabled(true);
|
|
voice.setStatus('listening', 'QA standby');
|
|
voice.setVoiceSpeaker('idle');
|
|
return window.__godsEyeView.dataManager.layers.get('radio').module.getUIState();
|
|
});
|
|
check(
|
|
'idle voice never auto-resumes a station paused for voice',
|
|
idleVoice.audioState === 'paused',
|
|
idleVoice.audioState,
|
|
);
|
|
const manualPlayTarget = await page.evaluate(() => {
|
|
const manager = window.__godsEyeView.styleManager;
|
|
manager.setPanelCollapsed('global-context-panel', true);
|
|
manager._setRadioDisclosure(true);
|
|
const button = document.getElementById('context-radio-mini-play-btn');
|
|
const rect = button.getBoundingClientRect();
|
|
const style = getComputedStyle(button);
|
|
return {
|
|
visible: !button.hidden
|
|
&& style.display !== 'none'
|
|
&& style.visibility !== 'hidden'
|
|
&& Number(style.opacity) > 0
|
|
&& rect.width > 0
|
|
&& rect.height > 0,
|
|
enabled: !button.disabled,
|
|
rect: { width: rect.width, height: rect.height },
|
|
};
|
|
});
|
|
check(
|
|
'manual voice takeover targets the visible compact Radio Play control',
|
|
manualPlayTarget.visible && manualPlayTarget.enabled,
|
|
JSON.stringify(manualPlayTarget),
|
|
);
|
|
if (manualPlayTarget.visible && manualPlayTarget.enabled) {
|
|
await page.click('#context-radio-mini-play-btn');
|
|
await page.waitForFunction(() => (
|
|
window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing'
|
|
&& window.__godsEyeView.voiceCommands.status === 'idle'
|
|
));
|
|
}
|
|
const manualTakeover = await page.evaluate(() => ({
|
|
audioState: window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState,
|
|
voiceStatus: window.__godsEyeView.voiceCommands.status,
|
|
voiceDucked: window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().voiceDucked,
|
|
}));
|
|
check(
|
|
'confirmed manual Radio Play closes active voice and preserves playback',
|
|
manualTakeover.audioState === 'playing'
|
|
&& manualTakeover.voiceStatus === 'idle'
|
|
&& !manualTakeover.voiceDucked,
|
|
JSON.stringify(manualTakeover),
|
|
);
|
|
await page.evaluate(() => window.__godsEyeView.voiceCommands.setStatus('listening', 'QA standby'));
|
|
const explicitResume = await page.evaluate(async () => {
|
|
const voice = window.__godsEyeView.voiceCommands;
|
|
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
|
|
await radio.play();
|
|
const beforeSpace = radio.getUIState().audioState;
|
|
document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, code: 'Space', key: ' ' }));
|
|
const afterSpace = radio.getUIState().audioState;
|
|
document.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, code: 'Space', key: ' ' }));
|
|
await radio.play();
|
|
voice.setVoiceSpeaker('ai');
|
|
return { beforeSpace, afterSpace, afterAi: radio.getUIState().audioState };
|
|
});
|
|
check(
|
|
'holding Space and assistant speech each pause an explicitly resumed station',
|
|
explicitResume.beforeSpace === 'playing' && explicitResume.afterSpace === 'paused' && explicitResume.afterAi === 'paused',
|
|
JSON.stringify(explicitResume),
|
|
);
|
|
await page.evaluate(() => window.__godsEyeView.voiceCommands.setStatus('idle', 'QA complete'));
|
|
await page.evaluate(async () => {
|
|
const manager = window.__godsEyeView.styleManager;
|
|
manager._setRadioDisclosure(false);
|
|
manager.setPanelCollapsed('global-context-panel', false);
|
|
manager.setPanelCollapsed('radio-panel', false);
|
|
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
});
|
|
await page.waitForFunction(() => {
|
|
const tuner = document.getElementById('radio-tuner');
|
|
const slider = document.getElementById('radio-tuner-slider');
|
|
return !tuner.hidden
|
|
&& tuner.getBoundingClientRect().width > 0
|
|
&& slider.getBoundingClientRect().width > 0;
|
|
});
|
|
|
|
const horizon = await page.evaluate(async () => {
|
|
const viewer = window.__godsEyeView.viewer;
|
|
const ellipsoid = viewer.scene.globe.ellipsoid;
|
|
const source = Array.from({ length: viewer.dataSources.length }, (_, index) => viewer.dataSources.get(index))
|
|
.find((item) => item.name === 'Radio stations');
|
|
const visibleIds = () => source.entities.values.filter((entity) => entity.show).map((entity) => entity.id);
|
|
const setView = (lon, lat = 0) => {
|
|
viewer.camera.cancelFlight();
|
|
viewer.camera.setView({
|
|
destination: ellipsoid.cartographicToCartesian({ longitude: lon * Math.PI / 180, latitude: lat * Math.PI / 180, height: 2_000_000 }),
|
|
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
|
});
|
|
};
|
|
setView(-54);
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
const front = visibleIds();
|
|
setView(180, -2);
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
const back = visibleIds();
|
|
const frontSet = new Set(front);
|
|
return {
|
|
front: front.length,
|
|
back: back.length,
|
|
changed: back.some((id) => !frontSet.has(id)),
|
|
};
|
|
});
|
|
check('far-side markers are horizon culled', horizon.front > 0 && horizon.front < 750 && horizon.back > 0 && horizon.back < 750, `${horizon.front}/${horizon.back} visible`);
|
|
check('horizon visibility follows camera hemisphere', horizon.changed);
|
|
|
|
await page.select('#radio-filter', 'all');
|
|
const absoluteTuner = await page.evaluate(async () => {
|
|
const gev = window.__godsEyeView;
|
|
const style = gev.styleManager;
|
|
const radio = gev.dataManager.layers.get('radio').module;
|
|
const slider = document.getElementById('radio-tuner-slider');
|
|
const tunerRect = document.getElementById('radio-tuner').getBoundingClientRect();
|
|
const dialRect = document.querySelector('.radio-tuner-dial').getBoundingClientRect();
|
|
const sliderRect = slider.getBoundingClientRect();
|
|
const panelRect = document.querySelector('.radio-panel-inner').getBoundingClientRect();
|
|
const count = style._radioTunerStations.length;
|
|
const usable = sliderRect.width - 14;
|
|
const xFor = (coordinate) => sliderRect.left + 7 + usable * coordinate / Math.max(1, count - 1);
|
|
const pointer = (type, x, pointerId = 71) => slider.dispatchEvent(new PointerEvent(type, {
|
|
bubbles: true,
|
|
pointerId,
|
|
pointerType: 'mouse',
|
|
clientX: x,
|
|
clientY: sliderRect.top + sliderRect.height / 2,
|
|
buttons: type === 'pointerup' || type === 'pointercancel' ? 0 : 1,
|
|
}));
|
|
const snapshot = () => {
|
|
const markerId = gev.viewer.entities.values
|
|
.find((entity) => String(entity.id).startsWith('radio:selected:'))?.id || null;
|
|
return {
|
|
slot: Number(slider.value),
|
|
markerId,
|
|
ratio: Number(document.getElementById('radio-tuner').style.getPropertyValue('--radio-tuner-ratio')),
|
|
needleX: document.getElementById('radio-tuner-needle').getBoundingClientRect().left,
|
|
ticks: Array.from(document.querySelectorAll('.radio-tuner-tick')).map((tick) => ({
|
|
index: Number(tick.dataset.stationIndex),
|
|
x: tick.getBoundingClientRect().left,
|
|
})),
|
|
};
|
|
};
|
|
const playsBefore = window.__qaRadioPlayCalls.length;
|
|
const frozenSignature = style._radioTunerBandSignature;
|
|
pointer('pointerdown', xFor(0));
|
|
const left = snapshot();
|
|
const centerCoordinate = (count - 1) / 2;
|
|
pointer('pointermove', xFor(centerCoordinate));
|
|
const center = snapshot();
|
|
pointer('pointermove', xFor(centerCoordinate + 2));
|
|
const shifted = snapshot();
|
|
pointer('pointermove', xFor(count - 1));
|
|
const right = snapshot();
|
|
const shiftedByIndex = new Map(shifted.ticks.map((tick) => [tick.index, tick.x]));
|
|
const sharedTick = center.ticks.find((tick) => shiftedByIndex.has(tick.index));
|
|
const tapeDelta = sharedTick ? shiftedByIndex.get(sharedTick.index) - sharedTick.x : null;
|
|
const needleDelta = shifted.needleX - center.needleX;
|
|
pointer('pointercancel', xFor(count - 1));
|
|
const state = radio.getUIState();
|
|
return {
|
|
visible: !document.getElementById('radio-tuner').hidden,
|
|
stationCount: count,
|
|
sliderMax: Number(slider.max),
|
|
bandFrozen: style._radioTunerBandSignature === frozenSignature,
|
|
left,
|
|
center,
|
|
shifted,
|
|
right,
|
|
leftExpectedId: style._radioTunerStations[0]?.id || null,
|
|
centerExpectedId: style._radioTunerStations[Math.floor(centerCoordinate + 0.5)]?.id || null,
|
|
rightExpectedId: style._radioTunerStations[count - 1]?.id || null,
|
|
centerExpected: Math.floor(centerCoordinate + 0.5),
|
|
tapeDelta,
|
|
needleDelta,
|
|
maxTickCount: Math.max(left.ticks.length, center.ticks.length, shifted.ticks.length, right.ticks.length),
|
|
noPreviewPlay: window.__qaRadioPlayCalls.length === playsBefore,
|
|
tuningActiveAfterCancel: state.tuningActive,
|
|
tuningStaticAfterCancel: state.tuningStatic,
|
|
contained: tunerRect.left >= panelRect.left && tunerRect.right <= panelRect.right
|
|
&& dialRect.left >= tunerRect.left && dialRect.right <= tunerRect.right
|
|
&& sliderRect.left >= dialRect.left && sliderRect.right <= dialRect.right,
|
|
};
|
|
});
|
|
check(
|
|
'All maps the complete directory to real left, center, and right station snap points',
|
|
absoluteTuner.visible && absoluteTuner.stationCount === 750 && absoluteTuner.sliderMax === 749
|
|
&& absoluteTuner.left.slot === 0
|
|
&& absoluteTuner.left.markerId === `radio:selected:${absoluteTuner.leftExpectedId}`
|
|
&& absoluteTuner.center.slot === absoluteTuner.centerExpected
|
|
&& absoluteTuner.center.markerId === `radio:selected:${absoluteTuner.centerExpectedId}`
|
|
&& absoluteTuner.right.slot === absoluteTuner.stationCount - 1
|
|
&& absoluteTuner.right.markerId === `radio:selected:${absoluteTuner.rightExpectedId}`
|
|
&& absoluteTuner.bandFrozen && absoluteTuner.noPreviewPlay
|
|
&& !absoluteTuner.tuningActiveAfterCancel && !absoluteTuner.tuningStaticAfterCancel
|
|
&& absoluteTuner.contained,
|
|
JSON.stringify(absoluteTuner),
|
|
);
|
|
check(
|
|
'the bounded virtual tape moves left faster than the needle moves right',
|
|
absoluteTuner.maxTickCount > 0 && absoluteTuner.maxTickCount < 100
|
|
&& absoluteTuner.needleDelta > 0 && absoluteTuner.tapeDelta < 0
|
|
&& Math.abs(absoluteTuner.tapeDelta) >= absoluteTuner.needleDelta * 4,
|
|
JSON.stringify({
|
|
maxTickCount: absoluteTuner.maxTickCount,
|
|
needleDelta: absoluteTuner.needleDelta,
|
|
tapeDelta: absoluteTuner.tapeDelta,
|
|
}),
|
|
);
|
|
const tunerCancelRestore = await page.evaluate(() => {
|
|
const gev = window.__godsEyeView;
|
|
const style = gev.styleManager;
|
|
const radio = gev.dataManager.layers.get('radio').module;
|
|
const slider = document.getElementById('radio-tuner-slider');
|
|
// Capture a fresh gesture's complete presentation anchor before moving
|
|
// to a different absolute directory station.
|
|
const beforeStations = [...style._radioTunerStations];
|
|
const sliderRect = slider.getBoundingClientRect();
|
|
const xFor = (index) => sliderRect.left + 7
|
|
+ (sliderRect.width - 14) * index / Math.max(1, beforeStations.length - 1);
|
|
const pointer = (type, index, pointerId = 52) => slider.dispatchEvent(new PointerEvent(type, {
|
|
bubbles: true,
|
|
pointerId,
|
|
pointerType: 'mouse',
|
|
clientX: xFor(index),
|
|
clientY: sliderRect.top + sliderRect.height / 2,
|
|
buttons: type === 'pointercancel' || type === 'pointerup' ? 0 : 1,
|
|
}));
|
|
const camera = gev.viewer.camera;
|
|
const originalFlyTo = camera.flyTo;
|
|
const flyToCalls = [];
|
|
camera.flyTo = (options) => flyToCalls.push(options);
|
|
const before = {
|
|
signature: style._radioTunerBandSignature,
|
|
ids: beforeStations.map((station) => station.id),
|
|
slot: Number(slider.value),
|
|
selectedId: radio.getUIState().selected?.id || null,
|
|
plays: window.__qaRadioPlayCalls.length,
|
|
};
|
|
const targetIndex = before.slot < beforeStations.length - 2 ? before.slot + 2 : before.slot - 2;
|
|
pointer('pointerdown', before.slot);
|
|
pointer('pointermove', targetIndex);
|
|
const shiftedSlot = Number(slider.value);
|
|
const previewBeforeCancel = gev.viewer.entities.values
|
|
.find((entity) => String(entity.id).startsWith('radio:selected:'))?.id || null;
|
|
const flyToCallsBeforeCancel = flyToCalls.length;
|
|
pointer('pointercancel', targetIndex);
|
|
const afterStations = style._radioTunerStations;
|
|
const afterState = radio.getUIState();
|
|
const after = {
|
|
signature: style._radioTunerBandSignature,
|
|
ids: afterStations.map((station) => station.id),
|
|
slot: Number(slider.value),
|
|
selectedId: afterState.selected?.id || null,
|
|
plays: window.__qaRadioPlayCalls.length,
|
|
previewId: null,
|
|
tuningActive: afterState.tuningActive,
|
|
};
|
|
const flyToCallsAfterCancel = flyToCalls.length;
|
|
const selectedEntity = () => gev.viewer.entities.values
|
|
.find((entity) => String(entity.id).startsWith('radio:selected:')) || null;
|
|
const restoredEntity = selectedEntity();
|
|
const previewId = restoredEntity?.id || null;
|
|
after.previewId = previewId;
|
|
const filterControl = document.getElementById('radio-filter');
|
|
const changedFilter = afterState.filter === 'news' ? 'all' : 'news';
|
|
const changedFilterSnapshot = radio.getAcceptedCatalogSnapshot();
|
|
const changedFilterPlays = window.__qaRadioPlayCalls.length;
|
|
const changedFilterCameraMoves = flyToCalls.length;
|
|
filterControl.value = changedFilter;
|
|
filterControl.dispatchEvent(new Event('change', { bubbles: true }));
|
|
const changedFilterState = radio.getUIState();
|
|
const changedFilterEntity = selectedEntity();
|
|
const changedFilterCleanup = {
|
|
filter: changedFilterState.filter,
|
|
restoredStationId: changedFilterState.tuningRestoredStationId,
|
|
selectedId: changedFilterState.selected?.id || null,
|
|
entityRebuilt: Boolean(changedFilterEntity && changedFilterEntity !== restoredEntity),
|
|
entityId: changedFilterEntity?.id || null,
|
|
snapshotPreserved: radio.getAcceptedCatalogSnapshot() === changedFilterSnapshot,
|
|
playDelta: window.__qaRadioPlayCalls.length - changedFilterPlays,
|
|
cameraDelta: flyToCalls.length - changedFilterCameraMoves,
|
|
};
|
|
const sameFilterBegan = radio.beginTuning();
|
|
const sameFilterCancelled = radio.cancelTuning();
|
|
const sameFilterRestoredState = radio.getUIState();
|
|
const sameFilterRestoredEntity = selectedEntity();
|
|
const sameFilterSnapshot = radio.getAcceptedCatalogSnapshot();
|
|
const sameFilterPlays = window.__qaRadioPlayCalls.length;
|
|
const sameFilterCameraMoves = flyToCalls.length;
|
|
filterControl.value = sameFilterRestoredState.filter;
|
|
filterControl.dispatchEvent(new Event('change', { bubbles: true }));
|
|
const sameFilterState = radio.getUIState();
|
|
const sameFilterEntity = selectedEntity();
|
|
const sameFilterCleanup = {
|
|
began: sameFilterBegan,
|
|
cancelled: sameFilterCancelled,
|
|
restoredBefore: sameFilterRestoredState.tuningRestoredStationId,
|
|
restoredAfter: sameFilterState.tuningRestoredStationId,
|
|
filter: sameFilterState.filter,
|
|
selectedId: sameFilterState.selected?.id || null,
|
|
entityRebuilt: Boolean(sameFilterEntity && sameFilterEntity !== sameFilterRestoredEntity),
|
|
entityId: sameFilterEntity?.id || null,
|
|
snapshotPreserved: radio.getAcceptedCatalogSnapshot() === sameFilterSnapshot,
|
|
playDelta: window.__qaRadioPlayCalls.length - sameFilterPlays,
|
|
cameraDelta: flyToCalls.length - sameFilterCameraMoves,
|
|
};
|
|
filterControl.value = afterState.filter;
|
|
filterControl.dispatchEvent(new Event('change', { bubbles: true }));
|
|
camera.flyTo = originalFlyTo;
|
|
return {
|
|
before,
|
|
targetIndex,
|
|
shiftedSlot,
|
|
previewBeforeCancel,
|
|
flyToCallsBeforeCancel,
|
|
flyToCallsAfterCancel,
|
|
changedFilter,
|
|
changedFilterCleanup,
|
|
sameFilterCleanup,
|
|
after,
|
|
identityRestored: afterStations.length === beforeStations.length
|
|
&& afterStations.every((station, index) => station === beforeStations[index]),
|
|
};
|
|
});
|
|
check(
|
|
'pointer cancel restores the exact pre-drag full-directory tuner anchor without playback',
|
|
tunerCancelRestore.shiftedSlot === tunerCancelRestore.targetIndex
|
|
&& tunerCancelRestore.after.signature === tunerCancelRestore.before.signature
|
|
&& JSON.stringify(tunerCancelRestore.after.ids) === JSON.stringify(tunerCancelRestore.before.ids)
|
|
&& tunerCancelRestore.after.slot === tunerCancelRestore.before.slot
|
|
&& tunerCancelRestore.after.selectedId === tunerCancelRestore.before.selectedId
|
|
&& tunerCancelRestore.after.plays === tunerCancelRestore.before.plays
|
|
&& tunerCancelRestore.previewBeforeCancel !== `radio:selected:${tunerCancelRestore.before.selectedId}`
|
|
&& tunerCancelRestore.flyToCallsBeforeCancel > 0
|
|
&& tunerCancelRestore.flyToCallsAfterCancel === tunerCancelRestore.flyToCallsBeforeCancel
|
|
&& tunerCancelRestore.after.previewId === `radio:selected:${tunerCancelRestore.before.selectedId}`
|
|
&& !tunerCancelRestore.after.tuningActive
|
|
&& tunerCancelRestore.identityRestored,
|
|
JSON.stringify(tunerCancelRestore),
|
|
);
|
|
check(
|
|
'accepted changed-filter action clears and rebuilds the cancel-restored marker without side effects',
|
|
tunerCancelRestore.changedFilterCleanup.filter === tunerCancelRestore.changedFilter
|
|
&& tunerCancelRestore.changedFilterCleanup.restoredStationId === null
|
|
&& tunerCancelRestore.changedFilterCleanup.selectedId === tunerCancelRestore.before.selectedId
|
|
&& tunerCancelRestore.changedFilterCleanup.entityRebuilt
|
|
&& tunerCancelRestore.changedFilterCleanup.entityId === `radio:selected:${tunerCancelRestore.before.selectedId}`
|
|
&& tunerCancelRestore.changedFilterCleanup.snapshotPreserved
|
|
&& tunerCancelRestore.changedFilterCleanup.playDelta === 0
|
|
&& tunerCancelRestore.changedFilterCleanup.cameraDelta === 0,
|
|
JSON.stringify(tunerCancelRestore.changedFilterCleanup),
|
|
);
|
|
check(
|
|
'accepted same-filter action clears and rebuilds the cancel-restored marker without side effects',
|
|
tunerCancelRestore.sameFilterCleanup.began
|
|
&& tunerCancelRestore.sameFilterCleanup.cancelled
|
|
&& tunerCancelRestore.sameFilterCleanup.restoredBefore === tunerCancelRestore.before.selectedId
|
|
&& tunerCancelRestore.sameFilterCleanup.restoredAfter === null
|
|
&& tunerCancelRestore.sameFilterCleanup.filter === tunerCancelRestore.changedFilter
|
|
&& tunerCancelRestore.sameFilterCleanup.selectedId === tunerCancelRestore.before.selectedId
|
|
&& tunerCancelRestore.sameFilterCleanup.entityRebuilt
|
|
&& tunerCancelRestore.sameFilterCleanup.entityId === `radio:selected:${tunerCancelRestore.before.selectedId}`
|
|
&& tunerCancelRestore.sameFilterCleanup.snapshotPreserved
|
|
&& tunerCancelRestore.sameFilterCleanup.playDelta === 0
|
|
&& tunerCancelRestore.sameFilterCleanup.cameraDelta === 0,
|
|
JSON.stringify(tunerCancelRestore.sameFilterCleanup),
|
|
);
|
|
const tunerRefreshTarget = await page.evaluate(() => {
|
|
const gev = window.__godsEyeView;
|
|
const slider = document.getElementById('radio-tuner-slider');
|
|
const stations = gev.styleManager._radioTunerStations;
|
|
const targetIndex = 1;
|
|
const rect = slider.getBoundingClientRect();
|
|
const clientX = rect.left + 7 + (rect.width - 14) * targetIndex / Math.max(1, stations.length - 1);
|
|
slider.dispatchEvent(new PointerEvent('pointerdown', {
|
|
bubbles: true,
|
|
pointerId: 51,
|
|
pointerType: 'mouse',
|
|
clientX,
|
|
clientY: rect.top + rect.height / 2,
|
|
buttons: 1,
|
|
}));
|
|
const station = stations[targetIndex];
|
|
return {
|
|
id: station.id,
|
|
name: station.name,
|
|
clientX,
|
|
clientY: rect.top + rect.height / 2,
|
|
generation: gev.dataManager.layers.get('radio').module.getUIState().tuningCatalogGeneration,
|
|
plays: window.__qaRadioPlayCalls.length,
|
|
};
|
|
});
|
|
catalogStations = stations.map((station) => (
|
|
station.id === tunerRefreshTarget.id
|
|
? { ...station, name: `${station.name} B`, streamUrl: `${station.streamUrl}?generation=b` }
|
|
: station
|
|
));
|
|
catalogGeneration = 2;
|
|
await page.evaluate(async () => {
|
|
await window.__godsEyeView.dataManager.layers.get('radio').module.update();
|
|
});
|
|
const tunerRefreshRelease = await page.evaluate((target) => {
|
|
const gev = window.__godsEyeView;
|
|
const slider = document.getElementById('radio-tuner-slider');
|
|
const displayedBeforeRelease = document.getElementById('radio-tuner-station').textContent;
|
|
const frozenGeneration = gev.dataManager.layers.get('radio').module.getUIState().tuningCatalogGeneration;
|
|
slider.dispatchEvent(new PointerEvent('pointerup', {
|
|
bubbles: true,
|
|
pointerId: 51,
|
|
pointerType: 'mouse',
|
|
clientX: target.clientX,
|
|
clientY: target.clientY,
|
|
}));
|
|
const state = gev.dataManager.layers.get('radio').module.getUIState();
|
|
return {
|
|
displayedBeforeRelease,
|
|
frozenGeneration,
|
|
unavailableId: state.tuningUnavailableStationId,
|
|
selectedId: state.selected?.id || null,
|
|
plays: window.__qaRadioPlayCalls.length,
|
|
value: document.getElementById('radio-tuner-value').textContent,
|
|
stationText: document.getElementById('radio-tuner-station').textContent,
|
|
message: document.getElementById('radio-playback-state').textContent,
|
|
targetId: target.id,
|
|
};
|
|
}, tunerRefreshTarget);
|
|
await page.screenshot({ path: path.join(SHOTS_DIR, 'tuner-refresh-unavailable.png') });
|
|
check(
|
|
'mid-drag catalog replacement keeps generation A visible and reports release unavailable',
|
|
tunerRefreshTarget.generation === 1
|
|
&& tunerRefreshRelease.frozenGeneration === 1
|
|
&& tunerRefreshRelease.displayedBeforeRelease === tunerRefreshTarget.name
|
|
&& tunerRefreshRelease.unavailableId === tunerRefreshTarget.id
|
|
&& tunerRefreshRelease.selectedId !== tunerRefreshTarget.id
|
|
&& tunerRefreshRelease.plays === tunerRefreshTarget.plays
|
|
&& tunerRefreshRelease.value === 'OFF AIR'
|
|
&& tunerRefreshRelease.stationText === 'STATION UNAVAILABLE'
|
|
&& /unavailable after directory refresh/i.test(tunerRefreshRelease.message),
|
|
JSON.stringify(tunerRefreshRelease),
|
|
);
|
|
catalogStations = stations;
|
|
catalogGeneration = 3;
|
|
await page.evaluate(async () => {
|
|
await window.__godsEyeView.dataManager.layers.get('radio').module.update();
|
|
});
|
|
const tunerDirectRelease = await page.evaluate(async () => {
|
|
const gev = window.__godsEyeView;
|
|
const slider = document.getElementById('radio-tuner-slider');
|
|
const needle = document.getElementById('radio-tuner-needle');
|
|
const startSlot = Number(slider.value);
|
|
const max = Number(slider.max);
|
|
const direction = startSlot < max ? 1 : -1;
|
|
const targetIndex = startSlot + direction;
|
|
const targetId = gev.styleManager._radioTunerStations[targetIndex]?.id || null;
|
|
const rect = slider.getBoundingClientRect();
|
|
const xFor = (index) => rect.left + 7 + (rect.width - 14) * index / Math.max(1, max);
|
|
const pointer = (type, index) => slider.dispatchEvent(new PointerEvent(type, {
|
|
bubbles: true,
|
|
pointerId: 41,
|
|
pointerType: 'mouse',
|
|
clientX: xFor(index),
|
|
clientY: rect.top + rect.height / 2,
|
|
buttons: type === 'pointerup' ? 0 : 1,
|
|
}));
|
|
const playsBefore = window.__qaRadioPlayCalls.length;
|
|
pointer('pointerdown', startSlot);
|
|
pointer('pointermove', targetIndex);
|
|
const previewPlays = window.__qaRadioPlayCalls.length;
|
|
pointer('pointerup', targetIndex);
|
|
const samples = [needle.getBoundingClientRect().left];
|
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
|
samples.push(needle.getBoundingClientRect().left);
|
|
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
samples.push(needle.getBoundingClientRect().left);
|
|
await new Promise((resolve) => setTimeout(resolve, 180));
|
|
samples.push(needle.getBoundingClientRect().left);
|
|
const state = gev.dataManager.layers.get('radio').module.getUIState();
|
|
return {
|
|
direction,
|
|
targetId,
|
|
selectedId: state.selected?.id || null,
|
|
sliderSlot: Number(slider.value),
|
|
expectedSlot: targetIndex,
|
|
previewPlayDelta: previewPlays - playsBefore,
|
|
commitPlayDelta: window.__qaRadioPlayCalls.length - playsBefore,
|
|
samples,
|
|
spread: Math.max(...samples) - Math.min(...samples),
|
|
};
|
|
});
|
|
check(
|
|
'pointer preview never plays and release commits the nearest station once without a needle sweep',
|
|
tunerDirectRelease.targetId
|
|
&& tunerDirectRelease.selectedId === tunerDirectRelease.targetId
|
|
&& tunerDirectRelease.sliderSlot === tunerDirectRelease.expectedSlot
|
|
&& tunerDirectRelease.previewPlayDelta === 0
|
|
&& tunerDirectRelease.commitPlayDelta === 1
|
|
&& tunerDirectRelease.spread < 0.5,
|
|
JSON.stringify(tunerDirectRelease),
|
|
);
|
|
const tunerCommitTarget = await page.evaluate(() => {
|
|
const gev = window.__godsEyeView;
|
|
const slider = document.getElementById('radio-tuner-slider');
|
|
const targetIndex = 11;
|
|
slider.dispatchEvent(new PointerEvent('pointercancel', { bubbles: true, pointerId: 1 }));
|
|
const station = gev.styleManager._radioTunerStations[targetIndex];
|
|
const rect = slider.getBoundingClientRect();
|
|
const currentRatio = Number(slider.value) / Math.max(1, Number(slider.max));
|
|
window.__qaRadioDelayNextPlay = true;
|
|
return {
|
|
id: station.id,
|
|
targetIndex,
|
|
count: gev.styleManager._radioTunerStations.length,
|
|
frozenSignature: gev.styleManager._radioTunerBandSignature,
|
|
startX: rect.left + 7 + (rect.width - 14) * currentRatio,
|
|
targetX: rect.left + 7 + (rect.width - 14) * targetIndex
|
|
/ Math.max(1, gev.styleManager._radioTunerStations.length - 1),
|
|
y: rect.top + rect.height / 2,
|
|
};
|
|
});
|
|
await page.mouse.move(tunerCommitTarget.startX, tunerCommitTarget.y);
|
|
await page.mouse.down();
|
|
await page.mouse.move(tunerCommitTarget.targetX, tunerCommitTarget.y, { steps: 12 });
|
|
await page.mouse.up();
|
|
await sleep(650);
|
|
const tunerCommit = await page.evaluate((target) => {
|
|
const gev = window.__godsEyeView;
|
|
const slider = document.getElementById('radio-tuner-slider');
|
|
const state = gev.dataManager.layers.get('radio').module.getUIState();
|
|
return {
|
|
id: target.id,
|
|
audioState: state.audioState,
|
|
tuningStatic: state.tuningStatic,
|
|
awaiting: state.tuningAwaitingStationId,
|
|
signatureStable: gev.styleManager._radioTunerBandSignature === target.frozenSignature,
|
|
selectedIndex: gev.styleManager._radioTunerStations.findIndex((item) => item.id === state.selected?.id),
|
|
sliderSlot: Number(slider.value),
|
|
ratio: Number(document.getElementById('radio-tuner').style.getPropertyValue('--radio-tuner-ratio')),
|
|
pinned: gev.styleManager._radioTunerBandPinnedForNavigation,
|
|
};
|
|
}, tunerCommitTarget);
|
|
check(
|
|
'tuner release keeps channel 12 pinned while its broadcaster connects',
|
|
tunerCommit.audioState === 'loading' && tunerCommit.tuningStatic && tunerCommit.awaiting === tunerCommit.id
|
|
&& tunerCommit.signatureStable && tunerCommit.selectedIndex === 11
|
|
&& tunerCommit.sliderSlot === 11
|
|
&& Math.abs(tunerCommit.ratio - 11 / Math.max(1, tunerCommitTarget.count - 1)) < 0.0001
|
|
&& tunerCommit.pinned,
|
|
JSON.stringify(tunerCommit),
|
|
);
|
|
await page.evaluate(() => window.__qaReleaseRadioPlay());
|
|
await page.waitForFunction(
|
|
(id) => {
|
|
const state = window.__godsEyeView.dataManager.layers.get('radio').module.getUIState();
|
|
return state.selected?.id === id && state.audioState === 'playing' && !state.tuningActive && !state.tuningStatic;
|
|
},
|
|
{},
|
|
tunerCommit.id,
|
|
);
|
|
const broadcastIndicator = await page.evaluate(() => ({
|
|
active: document.getElementById('title-bar').classList.contains('radio-broadcasting'),
|
|
innerOpacity: getComputedStyle(document.querySelector('#title-bar .title-logo'), '::before').opacity,
|
|
outerOpacity: getComputedStyle(document.querySelector('#title-bar .title-logo'), '::after').opacity,
|
|
}));
|
|
check('actual broadcaster playback fades out the tuner static', true, tunerCommit.id);
|
|
check(
|
|
'confirmed playback shows two restrained broadcast waves above the main logo',
|
|
broadcastIndicator.active
|
|
&& Number(broadcastIndicator.innerOpacity) > 0 && Number(broadcastIndicator.innerOpacity) <= 0.28
|
|
&& Number(broadcastIndicator.outerOpacity) > 0 && Number(broadcastIndicator.outerOpacity) <= 0.17,
|
|
JSON.stringify(broadcastIndicator),
|
|
);
|
|
const microDragStart = await page.evaluate(() => {
|
|
const style = window.__godsEyeView.styleManager;
|
|
const slider = document.getElementById('radio-tuner-slider');
|
|
const rect = slider.getBoundingClientRect();
|
|
const ratio = Number(slider.value) / Math.max(1, Number(slider.max));
|
|
return {
|
|
x: rect.left + 7 + (rect.width - 14) * ratio,
|
|
y: rect.top + rect.height / 2,
|
|
ratio,
|
|
usableWidth: rect.width - 14,
|
|
signature: style._radioTunerBandSignature,
|
|
};
|
|
});
|
|
const microDragSamples = [];
|
|
await page.mouse.move(microDragStart.x, microDragStart.y);
|
|
await page.mouse.down();
|
|
for (const delta of [1, 2, 10]) {
|
|
await page.mouse.move(microDragStart.x + delta, microDragStart.y);
|
|
await sleep(80);
|
|
microDragSamples.push(await page.evaluate((pixelDelta) => {
|
|
const style = window.__godsEyeView.styleManager;
|
|
const slider = document.getElementById('radio-tuner-slider');
|
|
return {
|
|
pixelDelta,
|
|
slot: Number(slider.value),
|
|
ratio: Number(document.getElementById('radio-tuner').style.getPropertyValue('--radio-tuner-ratio')),
|
|
signature: style._radioTunerBandSignature,
|
|
};
|
|
}, delta));
|
|
}
|
|
await page.mouse.move(microDragStart.x, microDragStart.y);
|
|
await page.mouse.up();
|
|
await page.screenshot({ path: path.join(SHOTS_DIR, 'tuner-micro-drag.png') });
|
|
const microDragPlayback = await page.evaluate(async () => {
|
|
const mod = window.__godsEyeView.dataManager.layers.get('radio').module;
|
|
const deadline = Date.now() + 2_000;
|
|
let state = mod.getUIState();
|
|
while (state.audioState === 'loading' && Date.now() < deadline) {
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
state = mod.getUIState();
|
|
}
|
|
if (state.audioState !== 'playing') {
|
|
await mod.play({ origin: 'user' });
|
|
state = mod.getUIState();
|
|
}
|
|
return { audioState: state.audioState, selectedId: state.selected?.id || null };
|
|
});
|
|
check(
|
|
'native micro-drag leaves the selected station playable',
|
|
microDragPlayback.audioState === 'playing' && Boolean(microDragPlayback.selectedId),
|
|
JSON.stringify(microDragPlayback),
|
|
);
|
|
check(
|
|
'native 1px, 2px, and 10px tuner moves stay monotonic on one frozen strip',
|
|
microDragSamples.every((sample) => sample.signature === microDragStart.signature
|
|
&& Math.abs(sample.ratio - (microDragStart.ratio + sample.pixelDelta / microDragStart.usableWidth)) < 0.002)
|
|
&& microDragSamples.every((sample, index) => index === 0
|
|
|| (sample.slot >= microDragSamples[index - 1].slot
|
|
&& sample.ratio >= microDragSamples[index - 1].ratio)),
|
|
JSON.stringify(microDragSamples),
|
|
);
|
|
const viewportTuner = await page.evaluate(async (selectedId) => {
|
|
const gev = window.__godsEyeView;
|
|
const style = gev.styleManager;
|
|
const before = {
|
|
signature: style._radioTunerBandSignature,
|
|
ids: style._radioTunerStations.map((station) => station.id),
|
|
selectedIndex: style._radioTunerStations.findIndex((station) => station.id === selectedId),
|
|
needle: Number(document.getElementById('radio-tuner').style.getPropertyValue('--radio-tuner-ratio')),
|
|
};
|
|
const current = gev.viewer.camera.positionCartographic;
|
|
const oppositeLongitude = ((current.longitude + 2 * Math.PI) % (2 * Math.PI)) - Math.PI;
|
|
gev.viewer.canvas.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: 1 }));
|
|
gev.viewer.camera.setView({
|
|
destination: gev.viewer.scene.globe.ellipsoid.cartographicToCartesian({
|
|
longitude: oppositeLongitude,
|
|
latitude: -current.latitude,
|
|
height: 2_000_000,
|
|
}),
|
|
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
|
});
|
|
gev.viewer.camera.changed.raiseEvent();
|
|
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
const stations = gev.styleManager._radioTunerStations;
|
|
const selectedIndex = stations.findIndex((station) => station.id === selectedId);
|
|
return {
|
|
before,
|
|
signature: style._radioTunerBandSignature,
|
|
ids: stations.map((station) => station.id),
|
|
count: stations.length,
|
|
selectedIndex,
|
|
needle: Number(document.getElementById('radio-tuner').style.getPropertyValue('--radio-tuner-ratio')),
|
|
};
|
|
}, tunerCommit.id);
|
|
check(
|
|
'globe rotation leaves the catalog/filter directory order and absolute needle unchanged',
|
|
viewportTuner.count === 750
|
|
&& viewportTuner.signature === viewportTuner.before.signature
|
|
&& JSON.stringify(viewportTuner.ids) === JSON.stringify(viewportTuner.before.ids)
|
|
&& viewportTuner.selectedIndex === viewportTuner.before.selectedIndex
|
|
&& Math.abs(viewportTuner.needle - viewportTuner.before.needle) < 0.0001,
|
|
JSON.stringify(viewportTuner),
|
|
);
|
|
const nextNeedleBefore = await page.evaluate(() => {
|
|
const style = window.__godsEyeView.styleManager;
|
|
const selectedId = window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().selected?.id;
|
|
const selectedIndex = style._radioTunerStations.findIndex((station) => station.id === selectedId);
|
|
const selectedPoolIndex = style._radioTunerPool.findIndex((station) => station.id === selectedId);
|
|
const expectedPoolIndex = (selectedPoolIndex + 1) % style._radioTunerPool.length;
|
|
return {
|
|
signature: style._radioTunerBandSignature,
|
|
selectedIndex,
|
|
count: style._radioTunerStations.length,
|
|
expectedId: style._radioTunerPool[expectedPoolIndex]?.id || null,
|
|
};
|
|
});
|
|
await page.$eval('#radio-next-btn', (button) => button.click());
|
|
await sleep(1500);
|
|
const nextNeedleAfter = await page.evaluate(() => {
|
|
const style = window.__godsEyeView.styleManager;
|
|
const selectedId = window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().selected?.id;
|
|
const selectedIndex = style._radioTunerStations.findIndex((station) => station.id === selectedId);
|
|
return {
|
|
signature: style._radioTunerBandSignature,
|
|
selectedId,
|
|
selectedIndex,
|
|
count: style._radioTunerStations.length,
|
|
sliderSlot: Number(document.getElementById('radio-tuner-slider').value),
|
|
ratio: Number(document.getElementById('radio-tuner').style.getPropertyValue('--radio-tuner-ratio')),
|
|
pinned: style._radioTunerBandPinnedForNavigation,
|
|
};
|
|
});
|
|
check(
|
|
'Next advances one absolute station and keeps the needle on its full-directory index',
|
|
nextNeedleAfter.selectedId === nextNeedleBefore.expectedId
|
|
&& nextNeedleAfter.signature === nextNeedleBefore.signature
|
|
&& nextNeedleAfter.count === nextNeedleBefore.count
|
|
&& nextNeedleAfter.sliderSlot === nextNeedleAfter.selectedIndex
|
|
&& Math.abs(nextNeedleAfter.ratio
|
|
- nextNeedleAfter.selectedIndex / Math.max(1, nextNeedleAfter.count - 1)) < 0.0001,
|
|
JSON.stringify({ before: nextNeedleBefore, after: nextNeedleAfter }),
|
|
);
|
|
const tunerKeyboard = await page.evaluate(() => {
|
|
const gev = window.__godsEyeView;
|
|
const style = gev.styleManager;
|
|
const radio = gev.dataManager.layers.get('radio').module;
|
|
const slider = document.getElementById('radio-tuner-slider');
|
|
const key = (type, value) => slider.dispatchEvent(new KeyboardEvent(type, {
|
|
bubbles: true,
|
|
key: value,
|
|
}));
|
|
const markerId = () => gev.viewer.entities.values
|
|
.find((entity) => String(entity.id).startsWith('radio:selected:'))?.id || null;
|
|
const startIndex = Number(slider.value);
|
|
const expectedIndex = Math.min(style._radioTunerStations.length - 1, startIndex + 1);
|
|
const expectedId = style._radioTunerStations[expectedIndex]?.id || null;
|
|
const playsBefore = window.__qaRadioPlayCalls.length;
|
|
key('keydown', 'ArrowRight');
|
|
const preview = {
|
|
slot: Number(slider.value),
|
|
markerId: markerId(),
|
|
tuningActive: radio.getUIState().tuningActive,
|
|
playDelta: window.__qaRadioPlayCalls.length - playsBefore,
|
|
};
|
|
key('keyup', 'ArrowRight');
|
|
const committed = {
|
|
selectedId: radio.getUIState().selected?.id || null,
|
|
slot: Number(slider.value),
|
|
tuningActive: radio.getUIState().tuningActive,
|
|
playDelta: window.__qaRadioPlayCalls.length - playsBefore,
|
|
};
|
|
const committedPlays = window.__qaRadioPlayCalls.length;
|
|
key('keydown', 'ArrowRight');
|
|
key('keydown', 'Escape');
|
|
const cancelled = {
|
|
selectedId: radio.getUIState().selected?.id || null,
|
|
slot: Number(slider.value),
|
|
tuningActive: radio.getUIState().tuningActive,
|
|
playDelta: window.__qaRadioPlayCalls.length - committedPlays,
|
|
};
|
|
const count = style._radioTunerStations.length;
|
|
const pageStep = Math.max(1, Math.round((count - 1) / 10));
|
|
const previewAndCancel = (value, expectedSlot) => {
|
|
const beforePlays = window.__qaRadioPlayCalls.length;
|
|
key('keydown', value);
|
|
const result = {
|
|
key: value,
|
|
slot: Number(slider.value),
|
|
expectedSlot,
|
|
playDelta: window.__qaRadioPlayCalls.length - beforePlays,
|
|
};
|
|
key('keydown', 'Escape');
|
|
result.restoredSlot = Number(slider.value);
|
|
result.cancelPlayDelta = window.__qaRadioPlayCalls.length - beforePlays;
|
|
return result;
|
|
};
|
|
const base = committed.slot;
|
|
const navigation = [
|
|
previewAndCancel('Home', 0),
|
|
previewAndCancel('End', count - 1),
|
|
previewAndCancel('PageUp', Math.min(count - 1, base + pageStep)),
|
|
previewAndCancel('PageDown', Math.max(0, base - pageStep)),
|
|
];
|
|
return { startIndex, expectedIndex, expectedId, preview, committed, cancelled, navigation };
|
|
});
|
|
check(
|
|
'keyboard preview is silent, key release commits once, and Escape restores the committed station',
|
|
tunerKeyboard.preview.slot === tunerKeyboard.expectedIndex
|
|
&& tunerKeyboard.preview.markerId === `radio:selected:${tunerKeyboard.expectedId}`
|
|
&& tunerKeyboard.preview.tuningActive && tunerKeyboard.preview.playDelta === 0
|
|
&& tunerKeyboard.committed.selectedId === tunerKeyboard.expectedId
|
|
&& tunerKeyboard.committed.slot === tunerKeyboard.expectedIndex
|
|
&& !tunerKeyboard.committed.tuningActive && tunerKeyboard.committed.playDelta === 1
|
|
&& tunerKeyboard.cancelled.selectedId === tunerKeyboard.expectedId
|
|
&& tunerKeyboard.cancelled.slot === tunerKeyboard.expectedIndex
|
|
&& !tunerKeyboard.cancelled.tuningActive && tunerKeyboard.cancelled.playDelta === 0,
|
|
JSON.stringify(tunerKeyboard),
|
|
);
|
|
check(
|
|
'Home, End, PageUp, and PageDown preview their absolute targets and cancel without playback',
|
|
tunerKeyboard.navigation.every((sample) => sample.slot === sample.expectedSlot
|
|
&& sample.restoredSlot === tunerKeyboard.expectedIndex
|
|
&& sample.playDelta === 0 && sample.cancelPlayDelta === 0),
|
|
JSON.stringify(tunerKeyboard.navigation),
|
|
);
|
|
await page.select('#radio-filter', 'news');
|
|
const filteredTunerCenter = await page.evaluate(() => {
|
|
const gev = window.__godsEyeView;
|
|
const style = gev.styleManager;
|
|
const radio = gev.dataManager.layers.get('radio').module;
|
|
const slider = document.getElementById('radio-tuner-slider');
|
|
const rect = slider.getBoundingClientRect();
|
|
const count = style._radioTunerStations.length;
|
|
const centerIndex = Math.floor((count - 1) / 2 + 0.5);
|
|
const clientX = rect.left + rect.width / 2;
|
|
slider.dispatchEvent(new PointerEvent('pointerdown', {
|
|
bubbles: true,
|
|
pointerId: 88,
|
|
pointerType: 'mouse',
|
|
clientX,
|
|
clientY: rect.top + rect.height / 2,
|
|
buttons: 1,
|
|
}));
|
|
const preview = {
|
|
filter: radio.getUIState().filter,
|
|
filteredCount: radio.getUIState().filteredCount,
|
|
count,
|
|
centerIndex,
|
|
slot: Number(slider.value),
|
|
ratio: Number(document.getElementById('radio-tuner').style.getPropertyValue('--radio-tuner-ratio')),
|
|
markerId: gev.viewer.entities.values
|
|
.find((entity) => String(entity.id).startsWith('radio:selected:'))?.id || null,
|
|
expectedId: style._radioTunerStations[centerIndex]?.id || null,
|
|
};
|
|
slider.dispatchEvent(new PointerEvent('pointercancel', {
|
|
bubbles: true,
|
|
pointerId: 88,
|
|
pointerType: 'mouse',
|
|
clientX,
|
|
clientY: rect.top + rect.height / 2,
|
|
}));
|
|
return preview;
|
|
});
|
|
check(
|
|
'filtered-directory center means half of the currently available station total',
|
|
filteredTunerCenter.filter === 'news'
|
|
&& filteredTunerCenter.count === filteredTunerCenter.filteredCount
|
|
&& filteredTunerCenter.count > 1 && filteredTunerCenter.count < 750
|
|
&& filteredTunerCenter.slot === filteredTunerCenter.centerIndex
|
|
&& Math.abs(filteredTunerCenter.ratio - 0.5) < 0.0001
|
|
&& filteredTunerCenter.markerId === `radio:selected:${filteredTunerCenter.expectedId}`,
|
|
JSON.stringify(filteredTunerCenter),
|
|
);
|
|
await page.select('#radio-filter', 'all');
|
|
const fullPoolNavigation = await page.evaluate(() => {
|
|
const gev = window.__godsEyeView;
|
|
const radio = gev.dataManager.layers.get('radio').module;
|
|
const pool = gev.styleManager._radioTunerPool;
|
|
const ids = pool.map((station) => station.id);
|
|
const selectWithoutPlayback = (index) => radio.selectStation(ids[index], {
|
|
autoplay: false,
|
|
focus: false,
|
|
origin: 'programmatic',
|
|
});
|
|
selectWithoutPlayback(40);
|
|
radio.cycleStation(1, { stationIds: ids, autoplay: false });
|
|
const afterForty = radio.getUIState().selected?.id || null;
|
|
selectWithoutPlayback(0);
|
|
radio.cycleStation(-1, { stationIds: ids, autoplay: false });
|
|
const afterPreviousWrap = radio.getUIState().selected?.id || null;
|
|
selectWithoutPlayback(ids.length - 1);
|
|
radio.cycleStation(1, { stationIds: ids, autoplay: false });
|
|
const afterNextWrap = radio.getUIState().selected?.id || null;
|
|
return {
|
|
poolLength: ids.length,
|
|
afterForty,
|
|
expectedAfterForty: ids[41],
|
|
afterPreviousWrap,
|
|
expectedPreviousWrap: ids.at(-1),
|
|
afterNextWrap,
|
|
expectedNextWrap: ids[0],
|
|
};
|
|
});
|
|
check(
|
|
'Previous and Next traverse beyond channel 40 and wrap across the complete tuner pool',
|
|
fullPoolNavigation.poolLength > 41
|
|
&& fullPoolNavigation.afterForty === fullPoolNavigation.expectedAfterForty
|
|
&& fullPoolNavigation.afterPreviousWrap === fullPoolNavigation.expectedPreviousWrap
|
|
&& fullPoolNavigation.afterNextWrap === fullPoolNavigation.expectedNextWrap,
|
|
JSON.stringify(fullPoolNavigation),
|
|
);
|
|
await page.evaluate(() => {
|
|
const viewer = window.__godsEyeView.viewer;
|
|
viewer.camera.setView({
|
|
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
|
|
longitude: Math.PI,
|
|
latitude: -2 * Math.PI / 180,
|
|
height: 2_000_000,
|
|
}),
|
|
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
|
});
|
|
});
|
|
await sleep(500);
|
|
const failedTuner = await page.evaluate(async () => {
|
|
const gev = window.__godsEyeView;
|
|
const slider = document.getElementById('radio-tuner-slider');
|
|
const rect = slider.getBoundingClientRect();
|
|
const count = gev.styleManager._radioTunerStations.length;
|
|
const current = Number(slider.value);
|
|
const target = Math.min(count - 1, current + 4);
|
|
const xFor = (index) => rect.left + 7 + (rect.width - 14) * index / Math.max(1, count - 1);
|
|
const pointer = (type, index) => slider.dispatchEvent(new PointerEvent(type, {
|
|
bubbles: true,
|
|
pointerId: 2,
|
|
pointerType: 'mouse',
|
|
clientX: xFor(index),
|
|
clientY: rect.top + rect.height / 2,
|
|
buttons: type === 'pointerup' ? 0 : 1,
|
|
}));
|
|
window.__qaRadioFailNextPlay = true;
|
|
pointer('pointerdown', current);
|
|
pointer('pointermove', target);
|
|
pointer('pointerup', target);
|
|
await new Promise((resolve) => setTimeout(resolve));
|
|
const failed = gev.dataManager.layers.get('radio').module.getUIState();
|
|
gev.dataManager.layers.get('radio').module.stopPlayback();
|
|
const stopped = gev.dataManager.layers.get('radio').module.getUIState();
|
|
return {
|
|
failed: {
|
|
audioState: failed.audioState,
|
|
tuningStatic: failed.tuningStatic,
|
|
awaiting: failed.tuningAwaitingStationId,
|
|
broadcasting: document.getElementById('title-bar').classList.contains('radio-broadcasting'),
|
|
},
|
|
stopped: {
|
|
tuningStatic: stopped.tuningStatic,
|
|
awaiting: stopped.tuningAwaitingStationId,
|
|
broadcasting: document.getElementById('title-bar').classList.contains('radio-broadcasting'),
|
|
},
|
|
};
|
|
});
|
|
check(
|
|
'failed tuner stations retain the audible static hint until Stop',
|
|
failedTuner.failed.audioState === 'error' && failedTuner.failed.tuningStatic && failedTuner.failed.awaiting
|
|
&& !failedTuner.failed.broadcasting && !failedTuner.stopped.tuningStatic
|
|
&& !failedTuner.stopped.awaiting && !failedTuner.stopped.broadcasting,
|
|
JSON.stringify(failedTuner),
|
|
);
|
|
await page.select('#radio-filter', 'news');
|
|
|
|
const markerTarget = await page.evaluate(async () => {
|
|
const viewer = window.__godsEyeView.viewer;
|
|
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
|
|
const source = Array.from({ length: viewer.dataSources.length }, (_, index) => viewer.dataSources.get(index))
|
|
.find((item) => item.name === 'Radio stations');
|
|
source.clustering.enabled = false;
|
|
const selectedId = radio.getUIState().selected?.id || null;
|
|
if (selectedId) {
|
|
radio.selectStation(selectedId, {
|
|
autoplay: false,
|
|
focus: false,
|
|
origin: 'programmatic',
|
|
});
|
|
}
|
|
viewer.camera.cancelFlight();
|
|
const markerView = {
|
|
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
|
|
longitude: -174 * Math.PI / 180,
|
|
latitude: 6 * Math.PI / 180,
|
|
height: 500_000,
|
|
}),
|
|
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
|
};
|
|
viewer.camera.setView(markerView);
|
|
await new Promise((resolve) => {
|
|
let removePostRender = null;
|
|
removePostRender = viewer.scene.postRender.addEventListener(() => {
|
|
removePostRender?.();
|
|
resolve();
|
|
});
|
|
viewer.scene.requestRender();
|
|
});
|
|
// Cesium clears its private cluster collections during the rendered
|
|
// disabled-state update. Publish the same-filter singleton overlay only
|
|
// after that frame so this harness mutation cannot leak stale clusters.
|
|
radio.setFilter('news');
|
|
viewer.scene.requestRender();
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
const { getOverlayPaintRect, getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js');
|
|
return new Promise((resolve) => {
|
|
let removePostRender = null;
|
|
let timeout = null;
|
|
let lastObservation = null;
|
|
const finish = (result) => {
|
|
clearTimeout(timeout);
|
|
removePostRender?.();
|
|
resolve(result);
|
|
};
|
|
const probe = () => {
|
|
const camera = viewer.camera.positionCartographic;
|
|
const longitude = camera.longitude * 180 / Math.PI;
|
|
const latitude = camera.latitude * 180 / Math.PI;
|
|
if (Math.abs(longitude + 174) > 0.5 || Math.abs(latitude - 6) > 0.5) {
|
|
viewer.camera.cancelFlight();
|
|
viewer.camera.setView(markerView);
|
|
viewer.scene.requestRender();
|
|
return;
|
|
}
|
|
const diagnostics = radio.getOverlayDiagnostics();
|
|
const singletonIds = new Set(diagnostics.singletonIds);
|
|
let visibleEntityCount = 0;
|
|
let singletonEntityCount = 0;
|
|
let paintedSingletonCount = 0;
|
|
for (const entity of source.entities.values) {
|
|
if (!entity.show) continue;
|
|
visibleEntityCount += 1;
|
|
const id = String(entity.id).slice('radio:'.length);
|
|
const overlayId = `station:${id}`;
|
|
if (!singletonIds.has(overlayId)) continue;
|
|
singletonEntityCount += 1;
|
|
const position = entity.position?.getValue(viewer.clock.currentTime);
|
|
const canvas = position && viewer.scene.cartesianToCanvasCoordinates(position);
|
|
const paintRect = getOverlayPaintRect('radio', overlayId);
|
|
if (paintRect) paintedSingletonCount += 1;
|
|
if (paintRect && canvas?.x > 300 && canvas.x < 1100 && canvas.y > 100 && canvas.y < 800) {
|
|
finish({
|
|
found: true,
|
|
id,
|
|
x: canvas.x,
|
|
y: canvas.y,
|
|
ownsNativeLabel: Boolean(entity.label),
|
|
labelPainted: true,
|
|
singletonTexts: diagnostics.singletonTexts,
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
lastObservation = {
|
|
found: false,
|
|
filter: radio.getUIState().filter,
|
|
visibleEntityCount,
|
|
singletonEntityCount,
|
|
paintedSingletonCount,
|
|
singletonIds: diagnostics.singletonIds,
|
|
singletonTexts: diagnostics.singletonTexts,
|
|
clusterIds: diagnostics.clusterIds,
|
|
overlayEntryCount: diagnostics.entryCount,
|
|
worldOverlay: getWorldOverlayDiagnostics(),
|
|
camera: {
|
|
longitude: viewer.camera.positionCartographic.longitude * 180 / Math.PI,
|
|
latitude: viewer.camera.positionCartographic.latitude * 180 / Math.PI,
|
|
height: viewer.camera.positionCartographic.height,
|
|
},
|
|
};
|
|
viewer.scene.requestRender();
|
|
};
|
|
timeout = setTimeout(() => finish(lastObservation || { found: false, reason: 'no post-render probe' }), 8000);
|
|
removePostRender = viewer.scene.postRender.addEventListener(probe);
|
|
viewer.scene.requestRender();
|
|
});
|
|
});
|
|
check('Radio exposes a visible marker for click-tolerance QA', markerTarget?.found === true, JSON.stringify(markerTarget));
|
|
check(
|
|
'the directly pickable singleton point has painted shared-host text and no native label',
|
|
markerTarget?.labelPainted && !markerTarget?.ownsNativeLabel,
|
|
JSON.stringify(markerTarget),
|
|
);
|
|
check(
|
|
'ambient Radio globe labels use compact frequency-first text',
|
|
markerTarget?.singletonTexts?.includes('100.3 FM — The River')
|
|
&& markerTarget.singletonTexts.every((text) => text.length <= 30),
|
|
JSON.stringify(markerTarget?.singletonTexts),
|
|
);
|
|
if (markerTarget?.found) {
|
|
await page.mouse.click(markerTarget.x + 6, markerTarget.y);
|
|
await page.waitForFunction(
|
|
(id) => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().selected?.id === id,
|
|
{},
|
|
markerTarget.id,
|
|
);
|
|
const offsetSelected = await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().selected?.id);
|
|
check('an offset click within 8px reliably selects the intended Radio dot', offsetSelected === markerTarget.id, offsetSelected);
|
|
}
|
|
|
|
await page.evaluate(() => {
|
|
const gev = window.__godsEyeView;
|
|
const camera = gev.viewer.camera;
|
|
const radio = gev.dataManager.layers.get('radio').module;
|
|
camera.cancelFlight();
|
|
gev.viewer.canvas.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: 1 }));
|
|
camera.setView({
|
|
destination: gev.viewer.scene.globe.ellipsoid.cartographicToCartesian({
|
|
longitude: Math.PI,
|
|
latitude: -2 * Math.PI / 180,
|
|
height: 2_000_000,
|
|
}),
|
|
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
|
});
|
|
radio.stopPlayback();
|
|
Array.from({ length: gev.viewer.dataSources.length }, (_, index) => gev.viewer.dataSources.get(index))
|
|
.find((item) => item.name === 'Radio stations').clustering.enabled = true;
|
|
radio.selectStation('00000000-0000-4000-8000-000000000001', { autoplay: false, focus: false });
|
|
const pool = gev.styleManager._radioTunerPool;
|
|
const selectedIndex = pool.findIndex((station) => station.id === radio.getUIState().selected?.id);
|
|
window.__qaRadioExpectedPrimary = pool[(selectedIndex + 1) % pool.length];
|
|
window.__qaRadioExpectedFallback = pool[(selectedIndex + 2) % pool.length];
|
|
window.__qaRadioPlayCalls = [];
|
|
window.__qaRadioFailedNearest = false;
|
|
window.__qaRadioDelayNextFailure = true;
|
|
window.__qaRadioFlyToCalls = [];
|
|
window.__qaRadioOriginalFlyTo = camera.flyTo;
|
|
window.__qaRadioCameraHeight = camera.positionCartographic.height;
|
|
camera.flyTo = (options) => window.__qaRadioFlyToCalls.push(options);
|
|
gev.dataManager.layers.get('flights').enabled = true;
|
|
});
|
|
await sleep(500);
|
|
await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('global-context-panel', true));
|
|
await page.evaluate(() => {
|
|
const button = document.getElementById('context-radio-toggle-btn');
|
|
if (button.getAttribute('aria-expanded') !== 'true') button.click();
|
|
});
|
|
await page.waitForSelector('#context-radio-mini', { visible: true });
|
|
await page.evaluate(() => {
|
|
window.__qaRadioCameraHeight = window.__godsEyeView.viewer.camera.positionCartographic.height;
|
|
});
|
|
const radioOnlyNextDispatched = await page.evaluate(() => {
|
|
const button = document.getElementById('context-radio-mini-next-btn');
|
|
if (!button) return false;
|
|
button.click();
|
|
return true;
|
|
});
|
|
if (!radioOnlyNextDispatched) throw new Error('Radio compact next button unavailable');
|
|
await page.waitForFunction(() => typeof window.__qaRejectRadioPlay === 'function');
|
|
await page.evaluate(() => {
|
|
const viewer = window.__godsEyeView.viewer;
|
|
viewer.camera.setView({
|
|
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
|
|
longitude: Math.PI,
|
|
latitude: -2 * Math.PI / 180,
|
|
height: 2_400_000,
|
|
}),
|
|
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
|
});
|
|
window.__qaRejectRadioPlay();
|
|
});
|
|
await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing');
|
|
const playing = await page.evaluate(async () => {
|
|
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
|
|
const viewer = window.__godsEyeView.viewer;
|
|
const selectedEntity = window.__godsEyeView.viewer.entities.values
|
|
.find((entity) => String(entity.id).startsWith('radio:selected:'));
|
|
const { getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js');
|
|
const flightTargets = window.__qaRadioFlyToCalls.map(({ destination }) => {
|
|
const position = viewer.scene.globe.ellipsoid.cartesianToCartographic(destination);
|
|
return {
|
|
lat: position.latitude * 180 / Math.PI,
|
|
lon: position.longitude * 180 / Math.PI,
|
|
height: position.height,
|
|
};
|
|
});
|
|
return {
|
|
state: radio.getUIState(),
|
|
calls: [...window.__qaRadioPlayCalls],
|
|
flyToCalls: window.__qaRadioFlyToCalls.length,
|
|
flightTargets,
|
|
cameraHeight: window.__qaRadioCameraHeight,
|
|
compactPlayLabel: document.getElementById('context-radio-mini-play-btn').getAttribute('aria-label'),
|
|
regularMarkerSize: Array.from({ length: window.__godsEyeView.viewer.dataSources.length }, (_, index) => window.__godsEyeView.viewer.dataSources.get(index))
|
|
.find((item) => item.name === 'Radio stations')?.entities.values[0]?.point?.pixelSize?.getValue(),
|
|
selectedMarkerSize: selectedEntity?.point?.pixelSize?.getValue(),
|
|
selectedBracketWidth: selectedEntity?.billboard?.width?.getValue(),
|
|
selectedOwnsNativeLabel: Boolean(selectedEntity?.label),
|
|
overlay: radio.getOverlayDiagnostics(),
|
|
host: getWorldOverlayDiagnostics(),
|
|
expectedPrimary: window.__qaRadioExpectedPrimary,
|
|
expectedFallback: window.__qaRadioExpectedFallback,
|
|
};
|
|
});
|
|
check(
|
|
'compact Next follows stable directory order and retries its next available station',
|
|
playing.state.selected?.id === playing.expectedFallback?.id
|
|
&& playing.calls[0] === playing.expectedPrimary?.streamUrl
|
|
&& playing.calls[1] === playing.expectedFallback?.streamUrl,
|
|
JSON.stringify({
|
|
selected: playing.state.selected?.id,
|
|
calls: playing.calls,
|
|
primary: playing.expectedPrimary?.id,
|
|
fallback: playing.expectedFallback?.id,
|
|
}),
|
|
);
|
|
check('compact next is explicit playback and updates play/pause state', playing.calls.length === 2 && playing.compactPlayLabel.startsWith('Pause'));
|
|
check(
|
|
'compact next rotates toward its station without changing zoom, including fallback',
|
|
playing.flyToCalls === 2
|
|
&& Math.abs(playing.flightTargets[0]?.lat - playing.expectedPrimary?.lat) < 0.001
|
|
&& Math.abs(playing.flightTargets[0]?.lon - playing.expectedPrimary?.lon) < 0.001
|
|
&& Math.abs(playing.flightTargets[1]?.lat - playing.expectedFallback?.lat) < 0.001
|
|
&& Math.abs(playing.flightTargets[1]?.lon - playing.expectedFallback?.lon) < 0.001
|
|
&& playing.flightTargets.every((target) => Math.abs(target.height - playing.cameraHeight) < 1),
|
|
JSON.stringify({ calls: playing.flyToCalls, targets: playing.flightTargets, cameraHeight: playing.cameraHeight }),
|
|
);
|
|
const trackedRadioControls = await page.evaluate(async () => {
|
|
const viewer = window.__godsEyeView.viewer;
|
|
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
|
|
const run = async (ownerPrefix, buttonId) => {
|
|
const owner = viewer.entities.add({
|
|
id: `qa:${ownerPrefix}:radio-camera-owner`,
|
|
position: viewer.camera.positionWC.clone(),
|
|
});
|
|
owner.gevTrackedId = `${ownerPrefix}:qa-radio-camera-owner`;
|
|
viewer.trackedEntity = owner;
|
|
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
const beforeStationId = radio.getUIState().selected?.id || null;
|
|
const beforeFlyToCalls = window.__qaRadioFlyToCalls.length;
|
|
document.getElementById(buttonId)?.click();
|
|
await new Promise((resolve) => setTimeout(resolve));
|
|
const result = {
|
|
ownerPrefix,
|
|
buttonId,
|
|
beforeStationId,
|
|
afterStationId: radio.getUIState().selected?.id || null,
|
|
flyToDelta: window.__qaRadioFlyToCalls.length - beforeFlyToCalls,
|
|
trackedIdentityPreserved: viewer.trackedEntity === owner,
|
|
trackedId: viewer.trackedEntity?.gevTrackedId || null,
|
|
};
|
|
viewer.trackedEntity = undefined;
|
|
viewer.entities.remove(owner);
|
|
return result;
|
|
};
|
|
return [
|
|
await run('flights', 'radio-next-btn'),
|
|
await run('military', 'context-radio-mini-prev-btn'),
|
|
];
|
|
});
|
|
check(
|
|
'full and compact Radio navigation preserve Flights/Military camera ownership',
|
|
trackedRadioControls.every((sample) => sample.afterStationId
|
|
&& sample.afterStationId !== sample.beforeStationId
|
|
&& sample.flyToDelta === 0
|
|
&& sample.trackedIdentityPreserved
|
|
&& sample.trackedId === `${sample.ownerPrefix}:qa-radio-camera-owner`),
|
|
JSON.stringify(trackedRadioControls),
|
|
);
|
|
|
|
const delayedTrackingStart = await page.evaluate(() => {
|
|
const viewer = window.__godsEyeView.viewer;
|
|
viewer.trackedEntity = undefined;
|
|
window.__qaRadioDelayNextFailure = true;
|
|
const beforeFlyToCalls = window.__qaRadioFlyToCalls.length;
|
|
document.getElementById('context-radio-mini-next-btn')?.click();
|
|
return { beforeFlyToCalls };
|
|
});
|
|
await page.waitForFunction(() => typeof window.__qaRejectRadioPlay === 'function');
|
|
const delayedTrackingOwner = await page.evaluate(() => {
|
|
const viewer = window.__godsEyeView.viewer;
|
|
const owner = viewer.entities.add({
|
|
id: 'qa:flights:delayed-radio-fallback-owner',
|
|
position: viewer.camera.positionWC.clone(),
|
|
});
|
|
owner.gevTrackedId = 'flights:qa-delayed-radio-fallback-owner';
|
|
viewer.trackedEntity = owner;
|
|
window.__qaDelayedRadioTrackingOwner = owner;
|
|
window.__qaRejectRadioPlay();
|
|
return owner.gevTrackedId;
|
|
});
|
|
await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing');
|
|
const delayedFallbackOwnership = await page.evaluate((beforeFlyToCalls) => {
|
|
const viewer = window.__godsEyeView.viewer;
|
|
const owner = window.__qaDelayedRadioTrackingOwner;
|
|
const result = {
|
|
flyToDelta: window.__qaRadioFlyToCalls.length - beforeFlyToCalls,
|
|
trackedIdentityPreserved: viewer.trackedEntity === owner,
|
|
trackedId: viewer.trackedEntity?.gevTrackedId || null,
|
|
selectedId: window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().selected?.id || null,
|
|
};
|
|
viewer.trackedEntity = undefined;
|
|
viewer.entities.remove(owner);
|
|
delete window.__qaDelayedRadioTrackingOwner;
|
|
return result;
|
|
}, delayedTrackingStart.beforeFlyToCalls);
|
|
check(
|
|
'a delayed Radio fallback yields when Flights acquires the camera after the initial rotation',
|
|
delayedFallbackOwnership.flyToDelta === 1
|
|
&& delayedFallbackOwnership.trackedIdentityPreserved
|
|
&& delayedFallbackOwnership.trackedId === delayedTrackingOwner
|
|
&& Boolean(delayedFallbackOwnership.selectedId),
|
|
JSON.stringify(delayedFallbackOwnership),
|
|
);
|
|
await page.evaluate(() => {
|
|
const viewer = window.__godsEyeView.viewer;
|
|
viewer.camera.setView({
|
|
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
|
|
longitude: Math.PI,
|
|
latitude: -2 * Math.PI / 180,
|
|
height: window.__qaRadioCameraHeight,
|
|
}),
|
|
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
|
});
|
|
});
|
|
check(
|
|
'individual Radio dots stay at least half the largest cluster point',
|
|
playing.regularMarkerSize === 13
|
|
&& playing.regularMarkerSize * 2 >= 26
|
|
&& playing.selectedMarkerSize === 14,
|
|
`${playing.regularMarkerSize}px / ${playing.selectedMarkerSize}px selected`,
|
|
);
|
|
check('the current station carries a 40px four-corner selection bracket', playing.selectedBracketWidth === 40, `${playing.selectedBracketWidth}px`);
|
|
check(
|
|
'selected-station text is published to the protected shared-host lane with no native entity label',
|
|
!playing.selectedOwnsNativeLabel
|
|
&& playing.overlay.selectedCount === 1
|
|
&& playing.host.entriesBySource?.radio === playing.overlay.entryCount,
|
|
JSON.stringify({ overlay: playing.overlay, host: playing.host.entriesBySource }),
|
|
);
|
|
const closeSelectedText = await page.evaluate(async () => {
|
|
const viewer = window.__godsEyeView.viewer;
|
|
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
|
|
const selected = radio.getUIState().selected;
|
|
window.__qaRadioVisualView = {
|
|
position: viewer.camera.positionWC.clone(),
|
|
direction: viewer.camera.directionWC.clone(),
|
|
up: viewer.camera.upWC.clone(),
|
|
};
|
|
const destination = viewer.scene.globe.ellipsoid.cartographicToCartesian({
|
|
longitude: selected.lon * Math.PI / 180,
|
|
latitude: selected.lat * Math.PI / 180,
|
|
height: 5_000,
|
|
});
|
|
viewer.camera.setView({
|
|
destination,
|
|
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
|
});
|
|
const selectedEntity = viewer.entities.values
|
|
.find((entity) => String(entity.id) === `radio:selected:${selected.id}`);
|
|
const { getOverlayPaintRect, getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js');
|
|
const expectedEntryId = `selected:${selected.id}`;
|
|
let painted = null;
|
|
for (let attempt = 0; attempt < 20 && !painted?.rect; attempt += 1) {
|
|
painted = await new Promise((resolve) => {
|
|
let removePostRender = null;
|
|
const timeout = setTimeout(() => {
|
|
removePostRender?.();
|
|
resolve({ rect: null, host: getWorldOverlayDiagnostics() });
|
|
}, 150);
|
|
removePostRender = viewer.scene.postRender.addEventListener(() => {
|
|
clearTimeout(timeout);
|
|
removePostRender();
|
|
const rect = getOverlayPaintRect('radio', expectedEntryId);
|
|
resolve({
|
|
rect: rect ? { x: rect.x, y: rect.y, w: rect.w, h: rect.h } : null,
|
|
host: getWorldOverlayDiagnostics(),
|
|
});
|
|
});
|
|
viewer.scene.requestRender();
|
|
});
|
|
}
|
|
return {
|
|
expectedEntryId,
|
|
rect: painted?.rect || null,
|
|
host: painted?.host || getWorldOverlayDiagnostics(),
|
|
selectedEntityShow: selectedEntity?.show,
|
|
cameraHeight: viewer.camera.positionCartographic.height,
|
|
};
|
|
});
|
|
check(
|
|
'selected shared-host text remains in the paint pipeline in a close stationary view',
|
|
closeSelectedText?.rect?.w > 0
|
|
&& closeSelectedText?.rect?.h > 0
|
|
&& closeSelectedText?.selectedEntityShow === true,
|
|
JSON.stringify(closeSelectedText),
|
|
);
|
|
await page.screenshot({ path: path.join(SHOTS_DIR, 'selected-label-desktop.png') });
|
|
const highGlobalSelectedText = await page.evaluate(async () => {
|
|
const viewer = window.__godsEyeView.viewer;
|
|
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
|
|
const selected = radio.getUIState().selected;
|
|
const ellipsoid = viewer.scene.globe.ellipsoid;
|
|
viewer.camera.setView({
|
|
destination: ellipsoid.cartographicToCartesian({
|
|
longitude: (selected.lon + 10) * Math.PI / 180,
|
|
latitude: selected.lat * Math.PI / 180,
|
|
height: 5_000_000,
|
|
}),
|
|
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
|
});
|
|
const selectedEntity = viewer.entities.values
|
|
.find((entity) => String(entity.id) === `radio:selected:${selected.id}`);
|
|
const { getOverlayPaintRect, getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js');
|
|
const expectedEntryId = `selected:${selected.id}`;
|
|
let rect = null;
|
|
for (let attempt = 0; attempt < 20 && !rect; attempt += 1) {
|
|
rect = await new Promise((resolve) => {
|
|
let removePostRender = null;
|
|
const timeout = setTimeout(() => {
|
|
removePostRender?.();
|
|
resolve(null);
|
|
}, 150);
|
|
removePostRender = viewer.scene.postRender.addEventListener(() => {
|
|
clearTimeout(timeout);
|
|
removePostRender();
|
|
const painted = getOverlayPaintRect('radio', expectedEntryId);
|
|
resolve(painted ? {
|
|
x: painted.x,
|
|
y: painted.y,
|
|
w: painted.w,
|
|
h: painted.h,
|
|
} : null);
|
|
});
|
|
viewer.scene.requestRender();
|
|
});
|
|
}
|
|
const entityPosition = selectedEntity?.position?.getValue?.();
|
|
const entityAnchor = entityPosition
|
|
&& viewer.scene.cartesianToCanvasCoordinates(entityPosition);
|
|
let selectedNativePick = null;
|
|
let selectedPointPick = null;
|
|
let selectedBillboardPick = null;
|
|
// Retry on the STRICT anchor: waiting only for "any pick" let a
|
|
// billboard satisfy the loop while the point anchor was still missing.
|
|
for (let attempt = 0; entityAnchor && attempt < 8 && !selectedPointPick; attempt += 1) {
|
|
const picks = viewer.scene.drillPick(entityAnchor, 16) || [];
|
|
selectedNativePick = picks.find((picked) => picked?.id === selectedEntity);
|
|
selectedPointPick = picks.find((picked) => (
|
|
picked?.id === selectedEntity
|
|
&& String(picked?.primitive?.constructor?.name || '').includes('PointPrimitive')
|
|
));
|
|
selectedBillboardPick = picks.find((picked) => (
|
|
picked?.id === selectedEntity
|
|
&& String(picked?.primitive?.constructor?.name || '').includes('Billboard')
|
|
));
|
|
if (!selectedPointPick) {
|
|
await new Promise((resolve) => {
|
|
const removePostRender = viewer.scene.postRender.addEventListener(() => {
|
|
removePostRender();
|
|
resolve();
|
|
});
|
|
viewer.scene.requestRender();
|
|
});
|
|
}
|
|
}
|
|
const selectedPointDistance = selectedEntity?.point?.distanceDisplayCondition?.getValue?.();
|
|
return {
|
|
rect,
|
|
selectedNativePickable: Boolean(selectedNativePick),
|
|
selectedPointPickable: Boolean(selectedPointPick),
|
|
selectedBillboardPickable: Boolean(selectedBillboardPick),
|
|
selectedPointMaxDistance: selectedPointDistance?.far ?? null,
|
|
selectedEntityShow: selectedEntity?.show,
|
|
selectedBracketVisible: Boolean(
|
|
selectedEntity?.billboard
|
|
&& selectedEntity.billboard.show?.getValue?.() !== false
|
|
),
|
|
entityAnchor: entityAnchor ? { x: entityAnchor.x, y: entityAnchor.y } : null,
|
|
entityDistance: entityPosition
|
|
? Math.hypot(
|
|
viewer.camera.positionWC.x - entityPosition.x,
|
|
viewer.camera.positionWC.y - entityPosition.y,
|
|
viewer.camera.positionWC.z - entityPosition.z,
|
|
)
|
|
: null,
|
|
overlay: radio.getOverlayDiagnostics(),
|
|
host: getWorldOverlayDiagnostics(),
|
|
cameraHeight: viewer.camera.positionCartographic.height,
|
|
};
|
|
});
|
|
check(
|
|
'selected label, bracket, and native pick anchor share the supported high-global range',
|
|
highGlobalSelectedText.cameraHeight > 4_800_000
|
|
&& highGlobalSelectedText.rect?.w > 0
|
|
&& highGlobalSelectedText.rect?.h > 0
|
|
&& highGlobalSelectedText.selectedEntityShow === true
|
|
&& highGlobalSelectedText.selectedBracketVisible
|
|
&& highGlobalSelectedText.selectedPointMaxDistance === 50_000_000
|
|
// The POINT anchor is the invariant: it is what stays pickable out to
|
|
// the 50,000 km band. Accepting any native pick let the co-located
|
|
// billboard keep this green after the point anchor regressed.
|
|
//
|
|
// `selectedBillboardPickable` is reported for diagnostics but NOT
|
|
// asserted: measured here it is false — the selection bracket renders
|
|
// (selectedBracketVisible) without being pickable at the point anchor,
|
|
// so requiring it would pin a behavior the app does not have.
|
|
&& highGlobalSelectedText.selectedPointPickable
|
|
&& highGlobalSelectedText.selectedNativePickable,
|
|
JSON.stringify(highGlobalSelectedText),
|
|
);
|
|
await page.screenshot({ path: path.join(SHOTS_DIR, 'selected-label-high-global.png') });
|
|
await page.setViewport({ width: 560, height: 760, deviceScaleFactor: 1 });
|
|
await page.evaluate(async () => {
|
|
const viewer = window.__godsEyeView.viewer;
|
|
window.__godsEyeView.styleManager.toggleCleanView(true);
|
|
viewer.resize();
|
|
viewer.scene.requestRender();
|
|
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
});
|
|
await page.screenshot({ path: path.join(SHOTS_DIR, 'selected-label-mobile.png') });
|
|
await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });
|
|
await page.evaluate(() => {
|
|
const viewer = window.__godsEyeView.viewer;
|
|
window.__godsEyeView.styleManager.toggleCleanView(false);
|
|
viewer.resize();
|
|
viewer.scene.requestRender();
|
|
});
|
|
await page.evaluate(() => {
|
|
const viewer = window.__godsEyeView.viewer;
|
|
const view = window.__qaRadioVisualView;
|
|
viewer.camera.setView({
|
|
destination: view.position,
|
|
orientation: { direction: view.direction, up: view.up },
|
|
});
|
|
delete window.__qaRadioVisualView;
|
|
viewer.scene.requestRender();
|
|
});
|
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
|
|
await page.evaluate(() => {
|
|
window.__qaRadioFlyToCalls = [];
|
|
window.__godsEyeView.dataManager.layers.get('flights').enabled = false;
|
|
});
|
|
const radioOnlyNextClicked = await page.evaluate(() => {
|
|
const button = document.getElementById('context-radio-mini-next-btn');
|
|
if (!(button instanceof HTMLButtonElement)) return false;
|
|
button.click();
|
|
return true;
|
|
});
|
|
check(
|
|
'Radio-only next control remains available after responsive visual checks',
|
|
radioOnlyNextClicked,
|
|
`clicked=${radioOnlyNextClicked}`,
|
|
);
|
|
const radioOnlyFocusCalls = await page.evaluate(() => {
|
|
const calls = window.__qaRadioFlyToCalls.length;
|
|
const heights = window.__qaRadioFlyToCalls.map((options) => (
|
|
window.__godsEyeView.viewer.scene.globe.ellipsoid.cartesianToCartographic(options.destination).height
|
|
));
|
|
window.__godsEyeView.viewer.camera.flyTo = window.__qaRadioOriginalFlyTo;
|
|
delete window.__qaRadioOriginalFlyTo;
|
|
return { calls, heights, cameraHeight: window.__qaRadioCameraHeight };
|
|
});
|
|
check(
|
|
'Radio-only next advances the tuner and rotates without changing zoom',
|
|
radioOnlyFocusCalls.calls === 1
|
|
&& radioOnlyFocusCalls.heights.every((height) => Math.abs(height - radioOnlyFocusCalls.cameraHeight) < 1),
|
|
JSON.stringify(radioOnlyFocusCalls),
|
|
);
|
|
|
|
const stagedGlobeNavigation = await page.evaluate(async () => {
|
|
const gev = window.__godsEyeView;
|
|
const viewer = gev.viewer;
|
|
const camera = viewer.camera;
|
|
const radio = gev.dataManager.layers.get('radio').module;
|
|
const stationIds = radio.getTunerStations(3).map((station) => station.id);
|
|
if (stationIds.length < 3) throw new Error('Radio recenter QA requires three visible stations');
|
|
const originalFlyTo = camera.flyTo;
|
|
const calls = [];
|
|
camera.flyTo = (options) => calls.push(options);
|
|
const setCamera = (height, pitchDegrees, headingDegrees = 38) => {
|
|
camera.setView({
|
|
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
|
|
longitude: -133.9483 * Math.PI / 180,
|
|
latitude: 23.4741 * Math.PI / 180,
|
|
height,
|
|
}),
|
|
orientation: {
|
|
heading: headingDegrees * Math.PI / 180,
|
|
pitch: pitchDegrees * Math.PI / 180,
|
|
roll: 0,
|
|
},
|
|
});
|
|
viewer.scene.requestRender();
|
|
};
|
|
const completeFlight = (index) => {
|
|
const flight = calls[index];
|
|
camera.setView({
|
|
destination: flight.destination,
|
|
orientation: flight.orientation,
|
|
});
|
|
flight.complete?.();
|
|
};
|
|
const describe = (flight) => {
|
|
const cartographic = viewer.scene.globe.ellipsoid.cartesianToCartographic(flight.destination);
|
|
return {
|
|
lat: cartographic.latitude * 180 / Math.PI,
|
|
lon: cartographic.longitude * 180 / Math.PI,
|
|
height: cartographic.height,
|
|
heading: flight.orientation?.heading,
|
|
pitch: flight.orientation?.pitch,
|
|
roll: flight.orientation?.roll,
|
|
duration: flight.duration,
|
|
eased: Boolean(flight.easingFunction),
|
|
};
|
|
};
|
|
|
|
setCamera(2_915_647, -40);
|
|
radio.cycleStation(1, { rotate: true, autoplay: false, stationIds });
|
|
const partialInitialCalls = calls.length;
|
|
const recenter = describe(calls[0]);
|
|
completeFlight(0);
|
|
const partialAfterRecenterCalls = calls.length;
|
|
const selectedAfterRecenter = radio.getUIState().selected;
|
|
const stationFocus = describe(calls[1]);
|
|
|
|
calls.length = 0;
|
|
setCamera(2_915_647, -40);
|
|
radio.cycleStation(1, { rotate: true, autoplay: false, stationIds });
|
|
const owner = viewer.entities.add({
|
|
id: 'qa:flights:radio-recenter-stage-owner',
|
|
position: viewer.camera.positionWC.clone(),
|
|
});
|
|
owner.gevTrackedId = 'flights:qa-radio-recenter-stage-owner';
|
|
viewer.trackedEntity = owner;
|
|
completeFlight(0);
|
|
const trackingBetweenStagesCalls = calls.length;
|
|
const trackingIdentityPreserved = viewer.trackedEntity === owner;
|
|
viewer.trackedEntity = undefined;
|
|
viewer.entities.remove(owner);
|
|
|
|
calls.length = 0;
|
|
setCamera(2_915_647, -40);
|
|
radio.selectStation(stationIds[2], { autoplay: false, focus: false });
|
|
radio.cycleStation(1, { rotate: true, autoplay: false, stationIds });
|
|
const staleRecenter = calls[0];
|
|
radio.cycleStation(1, { rotate: true, autoplay: false, stationIds });
|
|
const latestRecenterIndex = calls.length - 1;
|
|
staleRecenter.complete?.();
|
|
const callsAfterStaleCompletion = calls.length;
|
|
completeFlight(latestRecenterIndex);
|
|
const latestSelected = radio.getUIState().selected;
|
|
const latestFocus = describe(calls.at(-1));
|
|
|
|
calls.length = 0;
|
|
setCamera(2_915_647, -40);
|
|
radio.cycleStation(1, { rotate: true, autoplay: false, stationIds });
|
|
const directSelectionStaleRecenter = calls[0];
|
|
const directSelectionId = stationIds.find((id) => id !== radio.getUIState().selected.id);
|
|
radio.selectStation(directSelectionId, { autoplay: false, focus: false, origin: 'user' });
|
|
const directSelectionCalls = calls.length;
|
|
directSelectionStaleRecenter.complete?.();
|
|
const directSelectionCallsAfterStale = calls.length;
|
|
const directSelectionSelectedId = radio.getUIState().selected.id;
|
|
|
|
calls.length = 0;
|
|
setCamera(2_915_647, -40);
|
|
radio.cycleStation(1, { rotate: true, autoplay: false, stationIds });
|
|
const voiceSelectionStaleRecenter = calls[0];
|
|
const voiceSelectionId = stationIds.find((id) => id !== radio.getUIState().selected.id);
|
|
const voiceSelection = radio.selectRequestedStation({
|
|
stationQuery: voiceSelectionId,
|
|
}, { autoplay: false, origin: 'voice' });
|
|
const voiceSelectionCalls = calls.length;
|
|
voiceSelectionStaleRecenter.complete?.();
|
|
const voiceSelectionCallsAfterStale = calls.length;
|
|
const voiceSelectionSelectedId = radio.getUIState().selected.id;
|
|
radio.selectStation(latestSelected.id, { autoplay: false, focus: false });
|
|
|
|
calls.length = 0;
|
|
setCamera(97_488_500, -40, 119);
|
|
radio.cycleStation(1, { rotate: true, autoplay: false, stationIds });
|
|
const extremeRecenter = describe(calls[0]);
|
|
completeFlight(0);
|
|
const extremeFocus = describe(calls[1]);
|
|
|
|
calls.length = 0;
|
|
setCamera(5_000, -35, 15);
|
|
radio.cycleStation(1, { rotate: true, autoplay: false, stationIds });
|
|
const localDirect = describe(calls[0]);
|
|
const localDirectCalls = calls.length;
|
|
|
|
calls.length = 0;
|
|
setCamera(12_000_000, -90, 15);
|
|
radio.cycleStation(1, { rotate: true, autoplay: false, stationIds });
|
|
const centeredDirect = describe(calls[0]);
|
|
const centeredDirectCalls = calls.length;
|
|
|
|
camera.flyTo = originalFlyTo;
|
|
return {
|
|
partialInitialCalls,
|
|
partialAfterRecenterCalls,
|
|
recenter,
|
|
stationFocus,
|
|
selectedAfterRecenter,
|
|
trackingBetweenStagesCalls,
|
|
trackingIdentityPreserved,
|
|
latestRecenterIndex,
|
|
callsAfterStaleCompletion,
|
|
latestSelected,
|
|
latestFocus,
|
|
directSelectionId,
|
|
directSelectionCalls,
|
|
directSelectionCallsAfterStale,
|
|
directSelectionSelectedId,
|
|
voiceSelectionId,
|
|
voiceSelectionMatchedId: voiceSelection?.id || null,
|
|
voiceSelectionCalls,
|
|
voiceSelectionCallsAfterStale,
|
|
voiceSelectionSelectedId,
|
|
extremeRecenter,
|
|
extremeFocus,
|
|
localDirectCalls,
|
|
localDirect,
|
|
centeredDirectCalls,
|
|
centeredDirect,
|
|
};
|
|
});
|
|
check(
|
|
'a below-center closer globe animates back to center before station focus',
|
|
stagedGlobeNavigation.partialInitialCalls === 1
|
|
&& stagedGlobeNavigation.partialAfterRecenterCalls === 2
|
|
&& Math.abs(stagedGlobeNavigation.recenter.height - 2_915_647) < 1
|
|
&& stagedGlobeNavigation.recenter.heading === 0
|
|
&& Math.abs(stagedGlobeNavigation.recenter.pitch + Math.PI / 2) < 1e-9
|
|
&& stagedGlobeNavigation.recenter.roll === 0
|
|
&& stagedGlobeNavigation.recenter.duration >= 0.65
|
|
&& stagedGlobeNavigation.recenter.eased
|
|
&& Math.abs(stagedGlobeNavigation.stationFocus.height - 2_915_647) < 1
|
|
&& Math.abs(stagedGlobeNavigation.stationFocus.lat - stagedGlobeNavigation.selectedAfterRecenter.lat) < 0.001
|
|
&& Math.abs(stagedGlobeNavigation.stationFocus.lon - stagedGlobeNavigation.selectedAfterRecenter.lon) < 0.001
|
|
&& Math.abs(stagedGlobeNavigation.stationFocus.pitch + Math.PI / 2) < 1e-9,
|
|
JSON.stringify(stagedGlobeNavigation),
|
|
);
|
|
check(
|
|
'tracking acquired during Radio recenter suppresses the station-focus stage',
|
|
stagedGlobeNavigation.trackingBetweenStagesCalls === 1
|
|
&& stagedGlobeNavigation.trackingIdentityPreserved,
|
|
JSON.stringify(stagedGlobeNavigation),
|
|
);
|
|
check(
|
|
'a newer Radio action makes the older recenter callback inert and owns the final focus',
|
|
stagedGlobeNavigation.latestRecenterIndex === 1
|
|
&& stagedGlobeNavigation.callsAfterStaleCompletion === 2
|
|
&& Math.abs(stagedGlobeNavigation.latestFocus.lat - stagedGlobeNavigation.latestSelected.lat) < 0.001
|
|
&& Math.abs(stagedGlobeNavigation.latestFocus.lon - stagedGlobeNavigation.latestSelected.lon) < 0.001,
|
|
JSON.stringify(stagedGlobeNavigation),
|
|
);
|
|
check(
|
|
'a newer direct station selection invalidates an older staged Radio recenter',
|
|
stagedGlobeNavigation.directSelectionCalls === 1
|
|
&& stagedGlobeNavigation.directSelectionCallsAfterStale === 1
|
|
&& stagedGlobeNavigation.directSelectionSelectedId === stagedGlobeNavigation.directSelectionId,
|
|
JSON.stringify(stagedGlobeNavigation),
|
|
);
|
|
check(
|
|
'a newer voice station selection remains non-moving after an older recenter completes',
|
|
stagedGlobeNavigation.voiceSelectionCalls === 1
|
|
&& stagedGlobeNavigation.voiceSelectionCallsAfterStale === 1
|
|
&& stagedGlobeNavigation.voiceSelectionMatchedId === stagedGlobeNavigation.voiceSelectionId
|
|
&& stagedGlobeNavigation.voiceSelectionSelectedId === stagedGlobeNavigation.voiceSelectionId,
|
|
JSON.stringify(stagedGlobeNavigation),
|
|
);
|
|
check(
|
|
'extreme zoom-out recovery caps the north-up full-globe frame',
|
|
Math.abs(stagedGlobeNavigation.extremeRecenter.height - 13_000_000) < 1
|
|
&& stagedGlobeNavigation.extremeRecenter.heading === 0
|
|
&& Math.abs(stagedGlobeNavigation.extremeRecenter.pitch + Math.PI / 2) < 1e-9
|
|
&& stagedGlobeNavigation.extremeRecenter.roll === 0
|
|
&& Math.abs(stagedGlobeNavigation.extremeFocus.height - 13_000_000) < 1,
|
|
JSON.stringify(stagedGlobeNavigation),
|
|
);
|
|
check(
|
|
'local and already-centered globe views keep the single-stage direct path',
|
|
stagedGlobeNavigation.localDirectCalls === 1
|
|
&& stagedGlobeNavigation.centeredDirectCalls === 1
|
|
&& Math.abs(stagedGlobeNavigation.localDirect.pitch + 35 * Math.PI / 180) < 1e-9
|
|
&& Math.abs(stagedGlobeNavigation.centeredDirect.pitch + Math.PI / 2) < 1e-9,
|
|
JSON.stringify(stagedGlobeNavigation),
|
|
);
|
|
|
|
await page.evaluate(() => {
|
|
window.__godsEyeView.styleManager.setPanelCollapsed('radio-panel', true);
|
|
window.__godsEyeView.styleManager.setPanelCollapsed('global-context-panel', false);
|
|
});
|
|
const companion = await page.evaluate(() => ({
|
|
radioEnabled: window.__godsEyeView.dataManager.isEnabled('radio'),
|
|
state: window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState,
|
|
radioCollapsed: document.getElementById('radio-panel').classList.contains('collapsed'),
|
|
contextCollapsed: document.getElementById('global-context-panel').classList.contains('collapsed'),
|
|
nested: document.getElementById('global-context-panel').contains(document.getElementById('radio-panel')),
|
|
}));
|
|
check(
|
|
'expanding Context preserves playback and the user-selected detailed Radio disclosure',
|
|
companion.radioEnabled && companion.state === 'playing'
|
|
&& companion.radioCollapsed && !companion.contextCollapsed && companion.nested,
|
|
);
|
|
|
|
const expandedContextRadioBefore = await page.evaluate(() => {
|
|
const gev = window.__godsEyeView;
|
|
const scroller = document.querySelector('#global-context-panel .global-context-panel-inner');
|
|
scroller.scrollTop = 0;
|
|
const camera = gev.viewer.camera.positionWC;
|
|
return {
|
|
scrollTop: scroller.scrollTop,
|
|
pageY: window.scrollY,
|
|
camera: { x: camera.x, y: camera.y, z: camera.z },
|
|
plays: window.__qaRadioPlayCalls.length,
|
|
};
|
|
});
|
|
await page.click('#context-radio-toggle-btn');
|
|
await page.waitForFunction(() => !document.getElementById('radio-panel').classList.contains('collapsed'));
|
|
await sleep(450);
|
|
const expandedContextRadioAfter = await page.evaluate(() => {
|
|
const gev = window.__godsEyeView;
|
|
gev.styleManager._renderRadioState(gev.dataManager.layers.get('radio').module.getUIState());
|
|
const launcher = document.getElementById('context-radio-toggle-btn');
|
|
const radio = document.getElementById('radio-panel');
|
|
const scroller = document.querySelector('#global-context-panel .global-context-panel-inner');
|
|
const camera = gev.viewer.camera.positionWC;
|
|
return {
|
|
ariaControls: launcher.getAttribute('aria-controls'),
|
|
expanded: launcher.getAttribute('aria-expanded'),
|
|
label: launcher.getAttribute('aria-label'),
|
|
radioCollapsed: radio.classList.contains('collapsed'),
|
|
compactHidden: document.getElementById('context-radio-mini').hidden,
|
|
scrollTop: scroller.scrollTop,
|
|
focusTarget: document.activeElement?.getAttribute('data-collapse-target'),
|
|
pageY: window.scrollY,
|
|
camera: { x: camera.x, y: camera.y, z: camera.z },
|
|
plays: window.__qaRadioPlayCalls.length,
|
|
state: gev.dataManager.layers.get('radio').module.getUIState().audioState,
|
|
};
|
|
});
|
|
const expandedContextRadioCameraDelta = Math.hypot(
|
|
expandedContextRadioAfter.camera.x - expandedContextRadioBefore.camera.x,
|
|
expandedContextRadioAfter.camera.y - expandedContextRadioBefore.camera.y,
|
|
expandedContextRadioAfter.camera.z - expandedContextRadioBefore.camera.z,
|
|
);
|
|
check(
|
|
'expanded Context Radio icon reveals and scrolls to embedded Radio without an overlay or playback/camera theft',
|
|
expandedContextRadioAfter.ariaControls === 'radio-panel'
|
|
&& expandedContextRadioAfter.expanded === 'true'
|
|
&& expandedContextRadioAfter.label === 'Go to expanded Radio section'
|
|
&& !expandedContextRadioAfter.radioCollapsed
|
|
&& expandedContextRadioAfter.compactHidden
|
|
&& expandedContextRadioAfter.scrollTop > expandedContextRadioBefore.scrollTop
|
|
&& expandedContextRadioAfter.focusTarget === 'radio-panel'
|
|
&& expandedContextRadioAfter.pageY === expandedContextRadioBefore.pageY
|
|
&& expandedContextRadioCameraDelta < 0.01
|
|
&& expandedContextRadioAfter.plays === expandedContextRadioBefore.plays
|
|
&& expandedContextRadioAfter.state === 'playing',
|
|
JSON.stringify({
|
|
before: expandedContextRadioBefore,
|
|
after: expandedContextRadioAfter,
|
|
cameraDelta: expandedContextRadioCameraDelta,
|
|
}),
|
|
);
|
|
|
|
await page.evaluate(() => {
|
|
const manager = window.__godsEyeView.styleManager;
|
|
manager.setPanelCollapsed('global-context-panel', true);
|
|
manager._setRadioDisclosure(true);
|
|
});
|
|
await page.waitForFunction(() => {
|
|
const close = document.getElementById('context-radio-mini-close-btn');
|
|
const rect = close.getBoundingClientRect();
|
|
return !document.getElementById('context-radio-mini').hidden && rect.width > 0 && rect.height > 0;
|
|
});
|
|
await page.click('#context-radio-mini-close-btn');
|
|
const closedCompact = await page.evaluate(() => {
|
|
const launcher = document.getElementById('context-radio-toggle-btn');
|
|
return {
|
|
radioCollapsed: document.getElementById('radio-panel').classList.contains('collapsed'),
|
|
compactHidden: document.getElementById('context-radio-mini').hidden,
|
|
focusRestored: document.activeElement === launcher,
|
|
};
|
|
});
|
|
await page.click('#context-radio-toggle-btn');
|
|
await page.waitForFunction(() => !document.getElementById('context-radio-mini').hidden);
|
|
await page.click('#context-radio-details-btn');
|
|
const repeatedOpen = await page.evaluate(() => ({
|
|
radioCollapsed: document.getElementById('radio-panel').classList.contains('collapsed'),
|
|
compactHidden: document.getElementById('context-radio-mini').hidden,
|
|
}));
|
|
const splitDetailActions = { closedCompact, repeatedOpen };
|
|
check(
|
|
'collapsed Context keeps compact close and full-panel open as separate stable actions',
|
|
!splitDetailActions.closedCompact.radioCollapsed
|
|
&& splitDetailActions.closedCompact.compactHidden
|
|
&& splitDetailActions.closedCompact.focusRestored
|
|
&& !splitDetailActions.repeatedOpen.radioCollapsed
|
|
&& splitDetailActions.repeatedOpen.compactHidden,
|
|
JSON.stringify(splitDetailActions),
|
|
);
|
|
|
|
await page.click('#global-context-flights-btn');
|
|
await page.waitForFunction(() => !document.getElementById('context-flights-view').hidden);
|
|
const companionStack = await page.evaluate(() => {
|
|
const contextView = document.getElementById('context-flights-view');
|
|
const awareness = document.getElementById('military-awareness-panel');
|
|
const radio = document.getElementById('radio-panel');
|
|
const scroller = document.querySelector('#global-context-panel .global-context-panel-inner');
|
|
const awarenessRect = awareness.getBoundingClientRect();
|
|
const radioRect = radio.getBoundingClientRect();
|
|
return {
|
|
contactsNaturalHeight: contextView.clientHeight === contextView.scrollHeight,
|
|
radioNaturalHeight: radio.clientHeight === radio.scrollHeight,
|
|
separated: awarenessRect.bottom <= radioRect.top,
|
|
scrollable: scroller.scrollHeight > scroller.clientHeight,
|
|
};
|
|
});
|
|
check(
|
|
'expanded Contacts and Radio remain separated in the shared Context scroller',
|
|
companionStack.contactsNaturalHeight && companionStack.radioNaturalHeight
|
|
&& companionStack.separated && companionStack.scrollable,
|
|
JSON.stringify(companionStack),
|
|
);
|
|
|
|
await page.select('#radio-filter', 'talk');
|
|
const filtered = await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState());
|
|
check('filter changes markers/navigation without stopping the selected stream', filtered.filteredCount === 75 && filtered.audioState === 'playing' && filtered.selectedIndex === -1, `${filtered.filteredCount} matches`);
|
|
|
|
const playsBeforeRestore = await page.evaluate(() => window.__qaRadioPlayCalls.length);
|
|
await page.evaluate(async () => {
|
|
const gev = window.__godsEyeView;
|
|
await gev.dataManager.setEnabled('radio', false);
|
|
await gev.dataManager.setEnabled('radio', true);
|
|
});
|
|
const restored = await page.evaluate(() => ({
|
|
state: window.__godsEyeView.dataManager.layers.get('radio').module.getUIState(),
|
|
plays: window.__qaRadioPlayCalls.length,
|
|
radioCollapsed: document.getElementById('radio-panel').classList.contains('collapsed'),
|
|
}));
|
|
check(
|
|
'programmatic/preset-style restoration does not autoplay and leaves idle Radio collapsed',
|
|
restored.state.enabled && restored.state.audioState === 'stopped'
|
|
&& restored.plays === playsBeforeRestore && restored.radioCollapsed,
|
|
JSON.stringify(restored),
|
|
);
|
|
|
|
await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('radio-panel', false));
|
|
await sleep(350);
|
|
await page.screenshot({ path: path.join(SHOTS_DIR, 'desktop.png') });
|
|
const desktop = await page.$eval('#radio-panel', (element) => {
|
|
const rect = element.getBoundingClientRect();
|
|
const contextRect = element.closest('#global-context-panel').getBoundingClientRect();
|
|
const tunerRect = element.querySelector('.radio-tuner').getBoundingClientRect();
|
|
const innerRect = element.querySelector('.radio-panel-inner').getBoundingClientRect();
|
|
return {
|
|
left: contextRect.left,
|
|
right: contextRect.right,
|
|
top: contextRect.top,
|
|
bottom: contextRect.bottom,
|
|
width: contextRect.width,
|
|
radioInside: rect.left >= contextRect.left && rect.right <= contextRect.right,
|
|
tunerInside: tunerRect.left >= innerRect.left && tunerRect.right <= innerRect.right,
|
|
};
|
|
});
|
|
check('desktop Context host keeps Radio and its tuner contained', desktop.left >= 0 && desktop.right <= 1440 && desktop.top >= 0 && desktop.bottom <= 900 && desktop.radioInside && desktop.tunerInside, JSON.stringify(desktop));
|
|
|
|
await page.click('#radio-play-btn');
|
|
await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing');
|
|
await page.setViewport({ width: 390, height: 844, deviceScaleFactor: 1 });
|
|
await sleep(500);
|
|
await page.$eval('#radio-panel', (element) => element.scrollIntoView({ block: 'nearest' }));
|
|
await page.screenshot({ path: path.join(SHOTS_DIR, 'mobile.png') });
|
|
const mobile = await page.$eval('#radio-panel', (element) => {
|
|
const rect = element.getBoundingClientRect();
|
|
const context = element.closest('#global-context-panel');
|
|
const contextRect = context.getBoundingClientRect();
|
|
const scroller = context.querySelector('.global-context-panel-inner');
|
|
const logo = document.querySelector('#title-bar .title-logo');
|
|
const logoRect = logo.getBoundingClientRect();
|
|
const tunerRect = element.querySelector('.radio-tuner').getBoundingClientRect();
|
|
const innerRect = element.querySelector('.radio-panel-inner').getBoundingClientRect();
|
|
return {
|
|
left: contextRect.left,
|
|
right: contextRect.right,
|
|
top: contextRect.top,
|
|
bottom: contextRect.bottom,
|
|
width: contextRect.width,
|
|
radioWidth: rect.width,
|
|
radioVisible: rect.bottom > contextRect.top && rect.top < contextRect.bottom,
|
|
scrollable: scroller.scrollHeight >= scroller.clientHeight,
|
|
broadcastVisible: Number(getComputedStyle(logo, '::before').opacity) > 0
|
|
&& Number(getComputedStyle(logo, '::after').opacity) > 0,
|
|
broadcastInsideViewport: logoRect.left >= 0 && logoRect.right <= innerWidth && logoRect.top - 15 >= 0,
|
|
tunerInside: tunerRect.left >= innerRect.left && tunerRect.right <= innerRect.right
|
|
&& tunerRect.left >= 0 && tunerRect.right <= innerWidth,
|
|
};
|
|
});
|
|
check('mobile Context host is full-width and keeps the tuner contained', mobile.left >= 0 && mobile.right <= 390 && mobile.width >= 350 && mobile.radioWidth < mobile.width && mobile.radioVisible && mobile.scrollable && mobile.tunerInside, JSON.stringify(mobile));
|
|
check('mobile playing state keeps both broadcast waves visible and on-screen', mobile.broadcastVisible && mobile.broadcastInsideViewport, JSON.stringify(mobile));
|
|
await page.click('#radio-stop-btn');
|
|
const actionableConsoleErrors = [...consoleErrors];
|
|
const externalFontFailures = [];
|
|
for (let i = actionableConsoleErrors.length - 1; i >= 0; i -= 1) {
|
|
const entry = actionableConsoleErrors[i];
|
|
if (entry.includes('Failed to load resource: the server responded with a status of 404')
|
|
&& entry.includes('[https://fonts.gstatic.com/')) {
|
|
externalFontFailures.push(...actionableConsoleErrors.splice(i, 1));
|
|
}
|
|
}
|
|
if (externalFontFailures.length > 0) {
|
|
console.log(`INFO external Google Fonts resource unavailable (${externalFontFailures.length}); Radio assertions continued with the local fallback font`);
|
|
}
|
|
if (externalCesiumEndpointFailures.length > 0) {
|
|
const requestEventIndex = actionableConsoleErrors.findIndex((entry) => (
|
|
entry.startsWith('[object RequestErrorEvent]')
|
|
&& entry.includes('/node_modules/.vite/deps/cesium.js')
|
|
));
|
|
if (requestEventIndex >= 0) actionableConsoleErrors.splice(requestEventIndex, 1);
|
|
for (let i = actionableConsoleErrors.length - 1; i >= 0; i -= 1) {
|
|
if (actionableConsoleErrors[i].includes('api.cesium.com/v1/assets/1/endpoint')
|
|
&& actionableConsoleErrors[i].includes('net::ERR_FAILED')) {
|
|
actionableConsoleErrors.splice(i, 1);
|
|
}
|
|
}
|
|
console.log(`INFO external Cesium ion asset endpoint unavailable (${externalCesiumEndpointFailures.length}); Radio assertions continued against the loaded app`);
|
|
}
|
|
check(
|
|
'runtime console remains clean',
|
|
actionableConsoleErrors.length === 0,
|
|
actionableConsoleErrors.slice(0, 3).join(' | '),
|
|
);
|
|
check(
|
|
'runtime has no HTTP 5xx responses',
|
|
failedResponses.length === 0,
|
|
failedResponses.slice(0, 3).join(' | '),
|
|
);
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
|
|
const failures = results.filter((result) => !result.ok).length;
|
|
console.log(`\nRadio QA: ${results.length - failures}/${results.length} passed`);
|
|
console.log(`RESULT: ${results.length - failures} passed, ${failures} failed, 0 skipped`);
|
|
process.exitCode = failures ? 1 : 0;
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error);
|
|
process.exitCode = 1;
|
|
});
|