mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Merge branch 'main' into codex/fix-docker-trusted-peers
Resolve scripts/format-scope.json by keeping both sides. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
eaf14e2937
46 files changed
+2332
-279
No files matched your search
@@ -2,6 +2,8 @@
|
||||
".prettierrc.json",
|
||||
"package.json",
|
||||
"scripts/format-scope.json",
|
||||
"src/ui/styles/form-field.css",
|
||||
"scripts/qa-form-fields.mjs",
|
||||
"scripts/package-boundaries.json",
|
||||
"scripts/format.mjs",
|
||||
"scripts/next-layer-state-token.mjs",
|
||||
@@ -281,6 +283,7 @@
|
||||
"src/ui/scenePresentation.js",
|
||||
"src/ui/sceneControls.test.mjs",
|
||||
"scripts/qa-scene-controls.mjs",
|
||||
"scripts/qa-action-buttons.mjs",
|
||||
"src/ui.js",
|
||||
"src/standalone/ui.js",
|
||||
"src/ui/applicationShell.js",
|
||||
@@ -305,6 +308,7 @@
|
||||
"src/ui/styles/layers.css",
|
||||
"src/ui/styles/controls.css",
|
||||
"src/ui/styles/slider.css",
|
||||
"src/ui/styles/action-button.css",
|
||||
"src/ui/styles/cctv.css",
|
||||
"src/ui/styles/foundation.css",
|
||||
"src/ui/styles/first-run.css",
|
||||
@@ -1067,7 +1071,13 @@
|
||||
"scripts/assemble-changelog.mjs",
|
||||
"src/tooling/changelogFragments.test.mjs",
|
||||
"src/data/gbfsProxyRateLimit.test.mjs",
|
||||
"src/data/routeSegment.test.mjs",
|
||||
"src/terrainHeightsBounds.test.mjs",
|
||||
"server/providers/overpass/requestAdmission.test.mjs",
|
||||
"src/tooling/trackArrivalObservation.test.mjs",
|
||||
"src/ui/styles/status-badge.css",
|
||||
"src/ui/statusBadge.test.mjs",
|
||||
"src/ui/cctvBadges.test.mjs",
|
||||
"scripts/qa-status-badges.mjs",
|
||||
"src/data/routeSegment.test.mjs",
|
||||
"src/dockerTrustedPeers.test.mjs"
|
||||
]
|
||||
@@ -252,6 +252,7 @@
|
||||
"server/providers/common/http.js",
|
||||
"server/providers/common/rate-limit.js",
|
||||
"server/providers/common/request.js",
|
||||
"server/providers/common/same-site.js",
|
||||
"server/providers/overpass.js",
|
||||
"server/providers/overpass/cache.js",
|
||||
"server/providers/overpass/constants.js",
|
||||
@@ -261,6 +262,7 @@
|
||||
"server/providers/places/routes.js",
|
||||
"src/data/placeProviderPayloads.js",
|
||||
"src/data/routeSteps.js",
|
||||
"src/localRequestGate.mjs",
|
||||
"src/sources/httpBody.js",
|
||||
"src/i18n/marker.js",
|
||||
"src/sources/overpass.js",
|
||||
@@ -498,7 +500,8 @@
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/ui/unitLabels.js",
|
||||
"src/ui/keyGuidance.js",
|
||||
"src/ui/statusText.js"
|
||||
"src/ui/statusText.js",
|
||||
"src/ui/statusBadge.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -539,6 +542,7 @@
|
||||
"src/ui/cctv.js",
|
||||
"src/ui/cctvBindings.js",
|
||||
"src/ui/cctvCalibration.js",
|
||||
"src/ui/formField.js",
|
||||
"src/ui/cctvControls.js",
|
||||
"src/ui/cctvFrames.js",
|
||||
"src/ui/cctvVideo.js",
|
||||
@@ -547,7 +551,8 @@
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/ui/unitLabels.js"
|
||||
"src/ui/unitLabels.js",
|
||||
"src/ui/statusBadge.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -614,6 +619,7 @@
|
||||
"src/ui/scenes.js",
|
||||
"src/ui/sceneControls.js",
|
||||
"src/ui/scenePresentation.js",
|
||||
"src/ui/actionButton.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
@@ -675,6 +681,7 @@
|
||||
"src/ui/cctv.js",
|
||||
"src/ui/cctvBindings.js",
|
||||
"src/ui/cctvCalibration.js",
|
||||
"src/ui/formField.js",
|
||||
"src/ui/cctvControls.js",
|
||||
"src/ui/cctvFrames.js",
|
||||
"src/ui/cctvVideo.js",
|
||||
@@ -737,6 +744,7 @@
|
||||
"src/ui/rightPanelRail.js",
|
||||
"src/ui/sceneControls.js",
|
||||
"src/ui/scenePresentation.js",
|
||||
"src/ui/actionButton.js",
|
||||
"src/ui/scenes.js",
|
||||
"src/ui/shareRestoration.js",
|
||||
"src/ui/shellElements.js",
|
||||
@@ -771,7 +779,8 @@
|
||||
"src/ui/unitLabels.js",
|
||||
"src/ui/keyGuidance.js",
|
||||
"src/ui/layerNames.js",
|
||||
"src/ui/statusText.js"
|
||||
"src/ui/statusText.js",
|
||||
"src/ui/statusBadge.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -849,6 +858,7 @@
|
||||
"src/ui/scenes.js",
|
||||
"src/ui/sceneControls.js",
|
||||
"src/ui/scenePresentation.js",
|
||||
"src/ui/actionButton.js",
|
||||
"src/contextModePolicy.js",
|
||||
"src/director/clock.js",
|
||||
"src/director/timeline.js",
|
||||
@@ -2151,6 +2161,7 @@
|
||||
"src/ui/cctv.js",
|
||||
"src/ui/cctvBindings.js",
|
||||
"src/ui/cctvCalibration.js",
|
||||
"src/ui/formField.js",
|
||||
"src/ui/cctvControls.js",
|
||||
"src/ui/cctvFrames.js",
|
||||
"src/ui/cctvVideo.js",
|
||||
@@ -2214,6 +2225,7 @@
|
||||
"src/ui/rightPanelRail.js",
|
||||
"src/ui/sceneControls.js",
|
||||
"src/ui/scenePresentation.js",
|
||||
"src/ui/actionButton.js",
|
||||
"src/ui/scenes.js",
|
||||
"src/ui/shareRestoration.js",
|
||||
"src/ui/shellElements.js",
|
||||
@@ -2506,7 +2518,8 @@
|
||||
"src/voice/providerCapabilities.js",
|
||||
"src/voice/geminiSession.js",
|
||||
"src/voice/geminiAudio.js",
|
||||
"src/voice/geminiCaptureWorklet.js"
|
||||
"src/voice/geminiCaptureWorklet.js",
|
||||
"src/ui/statusBadge.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Scenes/CCTV action-button acceptance against dev or production preview:
|
||||
* QA_BASE_URL=http://127.0.0.1:4179 node scripts/qa-action-buttons.mjs
|
||||
* Uses the installed CCTV controller with fixture camera/action ports to prove
|
||||
* native dispatch/disabled/teardown without providers or world calibration writes.
|
||||
* Real Scene authoring/playback remains covered by qa-scene-controls.mjs.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: ['--use-gl=angle', '--use-angle=swiftshader'],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
let failures = 0;
|
||||
const check = (name, passed, detail) => {
|
||||
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
|
||||
if (!passed) {
|
||||
failures++;
|
||||
console.log(JSON.stringify(detail));
|
||||
}
|
||||
};
|
||||
const shots = 'qa-shots/action-buttons';
|
||||
fs.mkdirSync(shots, { recursive: true });
|
||||
|
||||
try {
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.goto(
|
||||
`${process.env.QA_BASE_URL || 'http://127.0.0.1:4179'}/?welcome=0`,
|
||||
{ waitUntil: 'domcontentloaded' },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.styleManager?._cctvControls &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
const inventory = await page.evaluate(() => {
|
||||
const ids = [
|
||||
'scene-new-btn',
|
||||
'scene-delete-btn',
|
||||
'scene-capture-btn',
|
||||
'scene-update-shot-btn',
|
||||
'scene-start-btn',
|
||||
'scene-stop-btn',
|
||||
'scene-next-btn',
|
||||
'scene-export-btn',
|
||||
'scene-import-btn',
|
||||
'scene-download-btn',
|
||||
'cctv-nearest-btn',
|
||||
'cctv-prev-btn',
|
||||
'cctv-next-btn',
|
||||
'cctv-focus-btn',
|
||||
'cctv-calib-save-btn',
|
||||
'cctv-calib-reset-btn',
|
||||
];
|
||||
const ordinary = ids.map((id) => document.getElementById(id));
|
||||
const generated = [
|
||||
...document.querySelectorAll(
|
||||
'.scene-shot-btn, [data-director-authoring] button',
|
||||
),
|
||||
];
|
||||
const toggles = [
|
||||
'cctv-enable-btn',
|
||||
'cctv-coverage-btn',
|
||||
'cctv-auto-hop-btn',
|
||||
'cctv-projection-btn',
|
||||
'cctv-adjust-btn',
|
||||
].map((id) => document.getElementById(id));
|
||||
return {
|
||||
shared: [...ordinary, ...generated].every(
|
||||
(node) =>
|
||||
node?.type === 'button' &&
|
||||
node.classList.contains('gev-action-button'),
|
||||
),
|
||||
generated: generated.length,
|
||||
layout: ordinary.every((node) => {
|
||||
const style = getComputedStyle(node);
|
||||
return style.minWidth === '72px' && style.flexGrow === '1';
|
||||
}),
|
||||
togglesSeparate: toggles.every(
|
||||
(node) => node && !node.classList.contains('gev-action-button'),
|
||||
),
|
||||
compactDanger: !!document.querySelector(
|
||||
'.scene-shot-danger.gev-action-button--compact.gev-action-button--danger',
|
||||
),
|
||||
};
|
||||
});
|
||||
check(
|
||||
'static and generated actions share the native component',
|
||||
inventory.shared && inventory.generated >= 4 && inventory.compactDanger,
|
||||
inventory,
|
||||
);
|
||||
check(
|
||||
'persistent CCTV toggles retain their existing controls',
|
||||
inventory.togglesSeparate,
|
||||
);
|
||||
check('ordinary actions retain their panel sizing', inventory.layout);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
const owner = manager._cctvControls;
|
||||
owner._cctvUnsubscribe?.();
|
||||
owner._cctvUnsubscribe = null;
|
||||
window.__qaButtonCalls = [];
|
||||
const record = (...args) => window.__qaButtonCalls.push(args);
|
||||
owner.actions = {
|
||||
...owner.actions,
|
||||
isEnabled: () => true,
|
||||
toggleEnabled: async () => {
|
||||
record('enable');
|
||||
return true;
|
||||
},
|
||||
runExplicitFocus: (select, focus) => focus(select()),
|
||||
setParams: (params, options) => record('params', params, options),
|
||||
showToast: () => {},
|
||||
};
|
||||
owner.cctv = {
|
||||
focusNearest: () => {
|
||||
record('nearest');
|
||||
return 'a';
|
||||
},
|
||||
cycleCamera: (direction) => {
|
||||
record('cycle', direction);
|
||||
return 'b';
|
||||
},
|
||||
focusCamera: (id, seconds) => record('focus', id, seconds),
|
||||
};
|
||||
window.__qaCameraState = {
|
||||
enabled: true,
|
||||
activeCameraId: 'a',
|
||||
cameras: [
|
||||
{ id: 'a', city: 'Fixture', name: 'A' },
|
||||
{ id: 'b', city: 'Fixture', name: 'B' },
|
||||
],
|
||||
activeCamera: { id: 'a', name: 'A', basePose: {} },
|
||||
};
|
||||
owner._renderCctvState(window.__qaCameraState);
|
||||
manager.setPanelCollapsed('cctv-panel', false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
});
|
||||
await page.focus('#cctv-next-btn');
|
||||
await page.keyboard.press('Space');
|
||||
await page.waitForFunction(() => window.__qaButtonCalls.length === 3);
|
||||
check(
|
||||
'native Space dispatches Next and its focus once',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
JSON.stringify(window.__qaButtonCalls) ===
|
||||
JSON.stringify([['enable'], ['cycle', 1], ['focus', 'b', 1.4]]),
|
||||
),
|
||||
);
|
||||
check(
|
||||
'native keyboard activation keeps a visible focus ring',
|
||||
await page.$eval(
|
||||
'#cctv-next-btn',
|
||||
(node) =>
|
||||
document.activeElement === node &&
|
||||
getComputedStyle(node).outlineStyle === 'solid',
|
||||
),
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
window.__qaButtonCalls = [];
|
||||
});
|
||||
await page.focus('#cctv-prev-btn');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForFunction(() => window.__qaButtonCalls.length === 3);
|
||||
check(
|
||||
'native Enter dispatches Previous once',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
JSON.stringify(window.__qaButtonCalls) ===
|
||||
JSON.stringify([['enable'], ['cycle', -1], ['focus', 'b', 1.4]]),
|
||||
),
|
||||
);
|
||||
|
||||
for (const [id, expected] of [
|
||||
['cctv-nearest-btn', [['enable'], ['nearest'], ['focus', 'a', 1.8]]],
|
||||
[
|
||||
'cctv-focus-btn',
|
||||
[
|
||||
['enable'],
|
||||
['focus', 'a', 1.9],
|
||||
['params', { selectedCameraId: 'a' }, { origin: 'user' }],
|
||||
],
|
||||
],
|
||||
]) {
|
||||
await page.evaluate(() => {
|
||||
window.__qaButtonCalls = [];
|
||||
});
|
||||
await page.click(`#${id}`);
|
||||
await page.waitForFunction(() => window.__qaButtonCalls.length === 3);
|
||||
const calls = await page.evaluate(() => window.__qaButtonCalls);
|
||||
check(
|
||||
`${id} retains its existing action and focus timing`,
|
||||
JSON.stringify(calls) === JSON.stringify(expected),
|
||||
calls,
|
||||
);
|
||||
}
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.__qaButtonCalls = [];
|
||||
});
|
||||
await page.click('#cctv-calib-save-btn');
|
||||
await page.click('#cctv-calib-reset-btn');
|
||||
const calibration = await page.evaluate(() => window.__qaButtonCalls);
|
||||
check(
|
||||
'Save and Reset retain camera identity and user-origin dispatch',
|
||||
JSON.stringify(calibration) ===
|
||||
JSON.stringify([
|
||||
[
|
||||
'params',
|
||||
{ selectedCameraId: 'a', calibration: { cameraId: 'a', save: true } },
|
||||
{ origin: 'user' },
|
||||
],
|
||||
[
|
||||
'params',
|
||||
{
|
||||
selectedCameraId: 'a',
|
||||
calibration: { cameraId: 'a', reset: true },
|
||||
},
|
||||
{ origin: 'user' },
|
||||
],
|
||||
]),
|
||||
calibration,
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
window.__qaButtonCalls = [];
|
||||
window.__godsEyeView.styleManager._cctvControls._renderCctvState({
|
||||
enabled: false,
|
||||
});
|
||||
});
|
||||
await page.click('#cctv-next-btn');
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const node = document.getElementById('cctv-next-btn');
|
||||
return node.disabled && Number(getComputedStyle(node).opacity) === 0.45;
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
check(
|
||||
'disabled actions stay inert and keep disabled presentation',
|
||||
await page.$eval(
|
||||
'#cctv-next-btn',
|
||||
(node) =>
|
||||
node.disabled &&
|
||||
getComputedStyle(node).opacity === '0.45' &&
|
||||
window.__qaButtonCalls.length === 0,
|
||||
),
|
||||
);
|
||||
|
||||
for (const width of [1440, 620, 390]) {
|
||||
await page.setViewport({ width, height: 900 });
|
||||
for (const panel of ['scene-panel', 'cctv-panel']) {
|
||||
await page.evaluate((panel) => {
|
||||
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
}, panel);
|
||||
const fit = await page.$eval(`#${panel}`, (root) => {
|
||||
const buttons = [...root.querySelectorAll('.gev-action-button')];
|
||||
const rect = root.getBoundingClientRect();
|
||||
return {
|
||||
count: buttons.length,
|
||||
fits:
|
||||
buttons.length > 0 &&
|
||||
buttons.every((button) => {
|
||||
const box = button.getBoundingClientRect();
|
||||
return (
|
||||
box.width > 0 &&
|
||||
box.left >= rect.left &&
|
||||
box.right <= rect.right + 1 &&
|
||||
box.left >= 0 &&
|
||||
box.right <= innerWidth + 1 &&
|
||||
button.scrollWidth <= button.clientWidth
|
||||
);
|
||||
}),
|
||||
};
|
||||
});
|
||||
check(`${panel} actions fit at ${width}px`, fit.fits, fit);
|
||||
if (width !== 620)
|
||||
await page.screenshot({ path: `${shots}/${panel}-${width}.png` });
|
||||
}
|
||||
}
|
||||
await page.evaluate(() => {
|
||||
const owner = window.__godsEyeView.styleManager._cctvControls;
|
||||
owner.destroy();
|
||||
document.getElementById('cctv-next-btn').disabled = false;
|
||||
window.__qaButtonCalls = [];
|
||||
});
|
||||
await page.click('#cctv-next-btn');
|
||||
check(
|
||||
'destroyed controllers revoke action listeners',
|
||||
await page.evaluate(() => window.__qaButtonCalls.length === 0),
|
||||
);
|
||||
const localized = new URL(page.url());
|
||||
localized.searchParams.set('lang', 'ru');
|
||||
await page.goto(localized.href, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.styleManager?._cctvControls &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
for (const panel of ['scene-panel', 'cctv-panel']) {
|
||||
await page.evaluate((panel) => {
|
||||
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
}, panel);
|
||||
const translated = await page.$eval(`#${panel}`, (root) => {
|
||||
const buttons = [...root.querySelectorAll('.gev-action-button')];
|
||||
return (
|
||||
document.documentElement.lang === 'ru' &&
|
||||
buttons.length > 0 &&
|
||||
buttons.every((node) => node.scrollWidth <= node.clientWidth)
|
||||
);
|
||||
});
|
||||
check(`Russian ${panel} action labels fit at 390px`, translated);
|
||||
await page.screenshot({ path: `${shots}/${panel}-ru-390.png` });
|
||||
}
|
||||
check('no uncaught browser errors', errors.length === 0, errors);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
console.log(`RESULT: ${failures} failures`);
|
||||
process.exitCode = failures ? 1 : 0;
|
||||
@@ -0,0 +1,465 @@
|
||||
#!/usr/bin/env node
|
||||
/** Native form-field regression gate. CCTV ports are fixtures, never live writes. */
|
||||
import fs from 'node:fs';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: ['--use-gl=angle', '--use-angle=swiftshader'],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
page.setDefaultNavigationTimeout(60000);
|
||||
let failures = 0;
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
const check = (name, passed, detail) => {
|
||||
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
|
||||
if (!passed) {
|
||||
failures++;
|
||||
console.log(JSON.stringify(detail));
|
||||
}
|
||||
};
|
||||
fs.mkdirSync('qa-shots/form-fields', { recursive: true });
|
||||
try {
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.goto(
|
||||
`${process.env.QA_BASE_URL || 'http://127.0.0.1:4183'}/?welcome=0`,
|
||||
{
|
||||
waitUntil: 'domcontentloaded',
|
||||
},
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.styleManager?._cctvControls &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
const inventory = await page.evaluate(() => {
|
||||
const selects = [
|
||||
'scene-select',
|
||||
'cctv-camera-select',
|
||||
'hud-layout-select',
|
||||
'draw-color-select',
|
||||
'radio-filter',
|
||||
'sdr-gain-select',
|
||||
].map((id) => document.getElementById(id));
|
||||
return {
|
||||
ids: selects.map((node) => node.id),
|
||||
shared:
|
||||
selects.length === 6 &&
|
||||
selects.every((node) => node.classList.contains('gev-field')),
|
||||
native: selects.every(
|
||||
(node) =>
|
||||
node instanceof HTMLSelectElement && !node.hasAttribute('role'),
|
||||
),
|
||||
dark: selects.every(
|
||||
(node) => getComputedStyle(node).colorScheme === 'dark',
|
||||
),
|
||||
};
|
||||
});
|
||||
check(
|
||||
'all six panel dropdowns retain native select semantics and shared surface',
|
||||
inventory.shared && inventory.native,
|
||||
inventory,
|
||||
);
|
||||
check('native option menus keep the dark scheme', inventory.dark);
|
||||
await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
manager.setPanelCollapsed('pp-toggles', false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
manager.setPanelCollapsed('scene-panel', false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
manager.setPanelCollapsed('cctv-panel', false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
window.__qaFormChanges = [];
|
||||
document
|
||||
.getElementById('scene-select')
|
||||
.addEventListener('change', (event) =>
|
||||
window.__qaFormChanges.push(event.target.value),
|
||||
);
|
||||
});
|
||||
const ownerSetup = await page.evaluate(() => {
|
||||
const owner = window.__godsEyeView.styleManager._cctvControls;
|
||||
owner._cctvUnsubscribe?.();
|
||||
owner._cctvUnsubscribe = null;
|
||||
window.__qaFormCalls = [];
|
||||
owner.actions = {
|
||||
...owner.actions,
|
||||
isEnabled: () => true,
|
||||
toggleEnabled: async () => true,
|
||||
setParams: (...args) => window.__qaFormCalls.push(args),
|
||||
runExplicitFocus: (select, focus) => focus(select()),
|
||||
showToast: () => {},
|
||||
};
|
||||
owner.cctv = {
|
||||
setParams() {},
|
||||
focusCamera: (id) => window.__qaFormCalls.push(['focus', id]),
|
||||
selectCamera: (id) => id,
|
||||
};
|
||||
owner._cctvState = {
|
||||
enabled: true,
|
||||
activeCameraId: 'a',
|
||||
cameras: [
|
||||
{ id: 'a', name: 'A' },
|
||||
{ id: 'b', name: 'B' },
|
||||
],
|
||||
activeCamera: {
|
||||
id: 'a',
|
||||
headingDeg: 30,
|
||||
pitchDeg: -10,
|
||||
fovDeg: 45,
|
||||
rangeM: 120,
|
||||
mountHeightM: 8,
|
||||
calibration: {},
|
||||
basePose: {
|
||||
headingDeg: 20,
|
||||
pitchDeg: -10,
|
||||
fovDeg: 45,
|
||||
rangeM: 120,
|
||||
mountHeightM: 8,
|
||||
},
|
||||
},
|
||||
};
|
||||
owner._renderCctvState(owner._cctvState);
|
||||
const input = document.getElementById('sdr-frequency-input');
|
||||
return {
|
||||
min: input.min,
|
||||
max: input.max,
|
||||
step: input.step,
|
||||
type: input.type,
|
||||
shared: input.classList.contains('gev-field'),
|
||||
};
|
||||
});
|
||||
check(
|
||||
'FM frequency retains native bounds, precision and shared surface',
|
||||
ownerSetup.min === '87.5' &&
|
||||
ownerSetup.max === '108' &&
|
||||
ownerSetup.step === '0.1' &&
|
||||
ownerSetup.type === 'number' &&
|
||||
ownerSetup.shared,
|
||||
ownerSetup,
|
||||
);
|
||||
const validity = await page.$eval('#sdr-frequency-input', (input) => {
|
||||
const original = input.value;
|
||||
input.value = '87.4';
|
||||
const below = input.validity.rangeUnderflow;
|
||||
input.value = '108.1';
|
||||
const above = input.validity.rangeOverflow;
|
||||
input.value = '98.55';
|
||||
const step = input.validity.stepMismatch;
|
||||
input.value = '';
|
||||
const empty = input.validity.valid;
|
||||
input.value = original;
|
||||
return below && above && step && empty;
|
||||
});
|
||||
check(
|
||||
'native numeric validity preserves underflow, overflow, steps and empty drafts',
|
||||
validity,
|
||||
);
|
||||
await page.select('#cctv-camera-select', 'b');
|
||||
await page.waitForFunction(() => window.__qaFormCalls.length === 2);
|
||||
const cameraSelection = await page.evaluate(() => window.__qaFormCalls);
|
||||
check(
|
||||
'camera dropdown retains selection, focus and user-origin dispatch',
|
||||
cameraSelection[0][0] === 'focus' &&
|
||||
cameraSelection[0][1] === 'b' &&
|
||||
cameraSelection[1][0].selectedCameraId === 'b' &&
|
||||
cameraSelection[1][1].origin === 'user',
|
||||
cameraSelection,
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
window.__qaFormCalls = [];
|
||||
document.getElementById('cctv-camera-select').disabled = true;
|
||||
});
|
||||
await page.focus('#scene-select');
|
||||
await page.focus('#cctv-camera-select');
|
||||
check(
|
||||
'disabled native selects cannot receive keyboard focus',
|
||||
await page.evaluate(
|
||||
() => document.activeElement.id !== 'cctv-camera-select',
|
||||
),
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
document.getElementById('cctv-camera-select').disabled = false;
|
||||
});
|
||||
const begin = async (field) => {
|
||||
await page.evaluate((field) => {
|
||||
const owner = window.__godsEyeView.styleManager._cctvControls;
|
||||
owner._beginCctvCalValueEdit(
|
||||
document.querySelector(`[data-cal-field="${field}"]`),
|
||||
);
|
||||
}, field);
|
||||
};
|
||||
for (const [field, step, value] of [
|
||||
['heading', '0.1', '30.0'],
|
||||
['pitch', '0.1', '-10.0'],
|
||||
['fov', '1', '45'],
|
||||
['range', '1', '120'],
|
||||
['height', '1', '8'],
|
||||
['north', '0.1', '0.0'],
|
||||
['east', '0.1', '0.0'],
|
||||
]) {
|
||||
await begin(field);
|
||||
const result = await page.$eval('.cctv-cal-input', (input) => ({
|
||||
step: input.step,
|
||||
value: input.value,
|
||||
native: input.type === 'number',
|
||||
shared: input.classList.contains('gev-field'),
|
||||
label: input.getAttribute('aria-label'),
|
||||
focus: document.activeElement === input,
|
||||
}));
|
||||
check(
|
||||
`${field} editor retains precision, initial value and focus`,
|
||||
result.step === step &&
|
||||
result.value === value &&
|
||||
result.native &&
|
||||
result.shared &&
|
||||
!!result.label &&
|
||||
result.focus,
|
||||
result,
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
}
|
||||
check(
|
||||
'Escape cancels each field without a calibration write',
|
||||
await page.evaluate(() => window.__qaFormCalls.length === 0),
|
||||
);
|
||||
await begin('heading');
|
||||
await page.keyboard.press('Backspace');
|
||||
await page.type('.cctv-cal-input', '100');
|
||||
await page.evaluate(() => {
|
||||
window.__godsEyeView.styleManager._cctvControls._cctvState.activeCamera.basePose.headingDeg = 60;
|
||||
});
|
||||
await page.keyboard.press('Enter');
|
||||
const commit = await page.evaluate(() => window.__qaFormCalls);
|
||||
check(
|
||||
'Enter commits once against the frozen camera base',
|
||||
commit.length === 1 &&
|
||||
commit[0][0].calibration.patch.headingDeg === 80 &&
|
||||
commit[0][0].calibration.cameraId === 'a' &&
|
||||
commit[0][1].origin === 'user',
|
||||
commit,
|
||||
);
|
||||
await begin('north');
|
||||
await page.keyboard.press('Backspace');
|
||||
await page.type('.cctv-cal-input', '12.5');
|
||||
await page.$eval('.cctv-cal-input', (input) => input.blur());
|
||||
const blur = await page.evaluate(() => window.__qaFormCalls);
|
||||
check(
|
||||
'blur commits a decimal offset exactly once',
|
||||
blur.length === 2 && blur[1][0].calibration.patch.offsetNorthM === 12.5,
|
||||
blur,
|
||||
);
|
||||
await begin('east');
|
||||
await page.keyboard.press('Backspace');
|
||||
await page.keyboard.press('Enter');
|
||||
check(
|
||||
'an empty numeric draft does not write calibration',
|
||||
await page.evaluate(() => window.__qaFormCalls.length === 2),
|
||||
);
|
||||
await begin('heading');
|
||||
await page.type('.cctv-cal-input', '90');
|
||||
await page.evaluate(() => {
|
||||
window.__godsEyeView.styleManager._cctvControls._cctvState.activeCameraId =
|
||||
'b';
|
||||
});
|
||||
await page.keyboard.press('Enter');
|
||||
check(
|
||||
'a stale camera editor cannot commit to the next camera',
|
||||
await page.evaluate(() => window.__qaFormCalls.length === 2),
|
||||
);
|
||||
|
||||
await page.evaluate(() => {
|
||||
for (const panel of ['pp-toggles', 'scene-panel', 'cctv-panel'])
|
||||
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
});
|
||||
if (
|
||||
await page.$eval(
|
||||
'#draw-toggle',
|
||||
(node) => node.getAttribute('aria-pressed') !== 'true',
|
||||
)
|
||||
)
|
||||
await page.click('#draw-toggle');
|
||||
for (const lang of ['en']) {
|
||||
await page.evaluate((lang) => {
|
||||
document.documentElement.lang = lang;
|
||||
}, lang);
|
||||
for (const width of [1440, 620, 390]) {
|
||||
await page.setViewport({ width, height: 900 });
|
||||
const layout = await page.evaluate(() => {
|
||||
const ids = [
|
||||
'scene-select',
|
||||
'cctv-camera-select',
|
||||
'hud-layout-select',
|
||||
'draw-color-select',
|
||||
];
|
||||
return ids.map((id) => {
|
||||
const node = document.getElementById(id),
|
||||
rect = node.getBoundingClientRect(),
|
||||
panel = node.closest('.panel-collapsible')?.getBoundingClientRect();
|
||||
return {
|
||||
id,
|
||||
width: rect.width,
|
||||
fits: rect.width > 0 && rect.width <= (panel?.width || innerWidth),
|
||||
radius: getComputedStyle(node).borderRadius,
|
||||
};
|
||||
});
|
||||
});
|
||||
check(
|
||||
`${lang} fields fit their panels at ${width}px`,
|
||||
layout.every((item) => item.fits),
|
||||
layout,
|
||||
);
|
||||
if (width === 390)
|
||||
await page.screenshot({
|
||||
path: `qa-shots/form-fields/${lang}-${width}.png`,
|
||||
});
|
||||
}
|
||||
}
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.lang = 'en';
|
||||
document.documentElement.dataset.uiTheme = 'cyber';
|
||||
});
|
||||
const cyber = await page.$eval(
|
||||
'#scene-select',
|
||||
(node) => getComputedStyle(node).backgroundColor,
|
||||
);
|
||||
check(
|
||||
'Cyber keeps its native field theme',
|
||||
cyber === 'rgba(26, 35, 39, 0.96)',
|
||||
cyber,
|
||||
);
|
||||
await begin('heading');
|
||||
await page.evaluate(() => {
|
||||
const owner = window.__godsEyeView.styleManager._cctvControls;
|
||||
window.__qaDetachedInput = document.querySelector('.cctv-cal-input');
|
||||
owner.destroy();
|
||||
window.__qaDetachedInput.dispatchEvent(new Event('blur'));
|
||||
});
|
||||
check(
|
||||
'disposal removes the editor and prevents late commits',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
!document.querySelector('.cctv-cal-input') &&
|
||||
window.__qaFormCalls.length === 2,
|
||||
),
|
||||
);
|
||||
// Scene selection can restore application settings. Exercise it after the
|
||||
// fixture-owned CCTV phase so those asynchronous effects cannot replace ports.
|
||||
page.once('dialog', (dialog) => dialog.accept('Baseline selection fixture'));
|
||||
await page.click('#scene-new-btn');
|
||||
await page.waitForFunction(() =>
|
||||
[...document.querySelectorAll('#scene-select option')].some(
|
||||
(option) => option.textContent === 'Baseline selection fixture',
|
||||
),
|
||||
);
|
||||
page.once('dialog', (dialog) => dialog.accept('QA selection fixture'));
|
||||
await page.click('#scene-new-btn');
|
||||
await page.waitForFunction(
|
||||
() => document.querySelectorAll('#scene-select option').length > 1,
|
||||
);
|
||||
const targetSceneId = await page.$eval(
|
||||
'#scene-select',
|
||||
(node) =>
|
||||
[...node.options].find(
|
||||
(option) => option.textContent === 'QA selection fixture',
|
||||
).value,
|
||||
);
|
||||
const baselineSceneId = await page.$eval(
|
||||
'#scene-select',
|
||||
(node) =>
|
||||
[...node.options].find(
|
||||
(option) => option.textContent === 'Baseline selection fixture',
|
||||
).value,
|
||||
);
|
||||
await page.select('#scene-select', baselineSceneId);
|
||||
await page.waitForFunction(
|
||||
(id) => window.__godsEyeView.sceneDirector._getSelectedScene().id !== id,
|
||||
{},
|
||||
targetSceneId,
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
window.__qaFormChanges = [];
|
||||
});
|
||||
await page.focus('#scene-select');
|
||||
// Typeahead is native even when headless Chrome does not drive OS popups.
|
||||
await page.keyboard.press('q');
|
||||
const sceneSelection = await page.evaluate(() => ({
|
||||
id: document.getElementById('scene-select').value,
|
||||
selected: window.__godsEyeView.sceneDirector._getSelectedScene().id,
|
||||
changes: window.__qaFormChanges,
|
||||
focus: document.activeElement.id,
|
||||
outline: getComputedStyle(document.activeElement).outlineStyle,
|
||||
}));
|
||||
check(
|
||||
'native typeahead reaches the Scene owner and keeps focus',
|
||||
sceneSelection.id === targetSceneId &&
|
||||
sceneSelection.selected === targetSceneId &&
|
||||
sceneSelection.changes.at(-1) === targetSceneId &&
|
||||
sceneSelection.focus === 'scene-select',
|
||||
sceneSelection,
|
||||
);
|
||||
check('keyboard focus remains visible', sceneSelection.outline === 'solid');
|
||||
const localized = new URL(page.url());
|
||||
localized.searchParams.set('lang', 'ru');
|
||||
await page.goto(localized.href, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.styleManager?._cctvControls &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
for (const panel of ['pp-toggles', 'scene-panel', 'cctv-panel'])
|
||||
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
});
|
||||
await page.evaluate(() => {
|
||||
for (const panel of ['pp-toggles', 'scene-panel', 'cctv-panel'])
|
||||
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
});
|
||||
if (
|
||||
await page.$eval(
|
||||
'#draw-toggle',
|
||||
(node) => node.getAttribute('aria-pressed') !== 'true',
|
||||
)
|
||||
)
|
||||
await page.click('#draw-toggle');
|
||||
const translated = await page.evaluate(() => {
|
||||
const nodes = [
|
||||
'scene-select',
|
||||
'cctv-camera-select',
|
||||
'hud-layout-select',
|
||||
'draw-color-select',
|
||||
].map((id) => document.getElementById(id));
|
||||
return (
|
||||
document.documentElement.lang === 'ru' &&
|
||||
nodes.every(
|
||||
(node) =>
|
||||
node.clientWidth > 0 &&
|
||||
node.getBoundingClientRect().width <=
|
||||
node.closest('.panel-collapsible').getBoundingClientRect().width,
|
||||
)
|
||||
);
|
||||
});
|
||||
check('real Russian labels and fields fit at 390px', translated);
|
||||
await page.screenshot({ path: 'qa-shots/form-fields/ru-390.png' });
|
||||
check('no uncaught browser errors', errors.length === 0, errors);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
process.exitCode = failures ? 1 : 0;
|
||||
@@ -306,7 +306,16 @@ try {
|
||||
await observeNetwork(page);
|
||||
page.on('pageerror', (error) => result.errors.push(error.message));
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error' && result.consoleErrors.length < 30)
|
||||
if (message.type() !== 'error') return;
|
||||
// A keyless server does not offer optional Google tokens. Keep every other
|
||||
// HTTP or JavaScript error visible, including failures of this route other
|
||||
// than its expected capability-miss 404.
|
||||
const optionalTokenMiss =
|
||||
message.location().url === new URL('/api/google/tiles-token', url).href &&
|
||||
/^Failed to load resource: the server responded with a status of 404\b/.test(
|
||||
message.text(),
|
||||
);
|
||||
if (!optionalTokenMiss && result.consoleErrors.length < 30)
|
||||
result.consoleErrors.push(message.text());
|
||||
});
|
||||
page.on('requestfailed', (request) => {
|
||||
|
||||
@@ -66,7 +66,8 @@ try {
|
||||
);
|
||||
}),
|
||||
);
|
||||
await page.click('#scene-capture-btn');
|
||||
await page.focus('#scene-capture-btn');
|
||||
await page.keyboard.press('Space');
|
||||
await page.waitForFunction(
|
||||
() => document.querySelectorAll('.scene-shot-row').length === 1,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
#!/usr/bin/env node
|
||||
/** Badge acceptance on dev/preview. Camera state is a disposable controller fixture. */
|
||||
import fs from 'node:fs';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: ['--use-gl=angle', '--use-angle=swiftshader'],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
page.setDefaultNavigationTimeout(60000);
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
let checks = 0;
|
||||
let failures = 0;
|
||||
const check = (name, passed, detail) => {
|
||||
checks++;
|
||||
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
|
||||
if (!passed) {
|
||||
failures++;
|
||||
console.log(JSON.stringify(detail));
|
||||
}
|
||||
};
|
||||
const out = 'qa-shots/status-badges';
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
try {
|
||||
for (const lang of ['en', 'ru']) {
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.goto(
|
||||
`${process.env.QA_BASE_URL || 'http://127.0.0.1:4189'}/?welcome=0&lang=${lang}`,
|
||||
{ waitUntil: 'domcontentloaded' },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.styleManager?._cctvControls &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.evaluate(async () => {
|
||||
const { dataManager, styleManager: manager } = window.__godsEyeView;
|
||||
await dataManager.setEnabled('wind', true);
|
||||
manager.setPanelCollapsed('weather-panel', false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
manager.setPanelCollapsed('cctv-panel', false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
const owner = manager._cctvControls;
|
||||
owner._cctvUnsubscribe?.();
|
||||
owner._cctvUnsubscribe = null;
|
||||
owner.actions = { ...owner.actions, isEnabled: () => true };
|
||||
owner._renderCctvState({
|
||||
enabled: true,
|
||||
activeCameraId: 'badge-fixture',
|
||||
cameras: [{ id: 'badge-fixture', city: 'QA', name: 'Badge fixture' }],
|
||||
activeCamera: {
|
||||
id: 'badge-fixture',
|
||||
city: 'QA',
|
||||
headingDeg: 30,
|
||||
pitchDeg: -10,
|
||||
fovDeg: 45,
|
||||
rangeM: 120,
|
||||
mountHeightM: 8,
|
||||
sourceKind: 'snapshot',
|
||||
sourceStatus: 'degraded',
|
||||
calBadge: 'curated',
|
||||
calDirty: false,
|
||||
},
|
||||
});
|
||||
window.__qaBadgeText =
|
||||
document.getElementById('cctv-source-badge').textContent;
|
||||
});
|
||||
await page.waitForSelector('.weather-coverage.gev-status-badge');
|
||||
const inventory = await page.evaluate(() => {
|
||||
const source = document.getElementById('cctv-source-badge');
|
||||
const quality = document.getElementById('cctv-quality-chip');
|
||||
const coverage = [...document.querySelectorAll('.weather-coverage')];
|
||||
return {
|
||||
shared: [source, quality, ...coverage].every((node) =>
|
||||
node.classList.contains('gev-status-badge'),
|
||||
),
|
||||
live:
|
||||
source.getAttribute('role') === 'status' &&
|
||||
source.getAttribute('aria-live') === 'polite',
|
||||
silent: [quality, ...coverage].every(
|
||||
(node) =>
|
||||
!node.hasAttribute('aria-live') &&
|
||||
!node.hasAttribute('role') &&
|
||||
!node.hasAttribute('tabindex'),
|
||||
),
|
||||
coverage:
|
||||
coverage.length > 0 &&
|
||||
coverage.every(
|
||||
(node) =>
|
||||
node.textContent &&
|
||||
!node.hidden &&
|
||||
node.classList.contains('gev-status-badge--plain'),
|
||||
),
|
||||
source: source.textContent,
|
||||
};
|
||||
});
|
||||
check(
|
||||
`${lang}: CCTV and populated Weather badges share the view`,
|
||||
inventory.shared && inventory.coverage,
|
||||
inventory,
|
||||
);
|
||||
check(
|
||||
`${lang}: existing announcement and read-only semantics remain intact`,
|
||||
inventory.live && inventory.silent,
|
||||
inventory,
|
||||
);
|
||||
check(
|
||||
`${lang}: source wording uses the active locale`,
|
||||
lang === 'en'
|
||||
? inventory.source === 'SNAPSHOT · DEGRADED'
|
||||
: inventory.source !== 'SNAPSHOT · DEGRADED',
|
||||
inventory,
|
||||
);
|
||||
|
||||
for (const state of ['loading', 'error', 'retained', 'ready', 'idle']) {
|
||||
const result = await page.evaluate((state) => {
|
||||
const owner = window.__godsEyeView.styleManager._cctvControls;
|
||||
owner._cctvFrame.dataset.loading = state === 'loading' ? 'true' : '';
|
||||
owner._cctvFrame.dataset.error = ['error', 'retained'].includes(state)
|
||||
? 'true'
|
||||
: '';
|
||||
owner._cctvFrameWrap.classList.toggle(
|
||||
'has-frame',
|
||||
state === 'retained',
|
||||
);
|
||||
owner._syncCctvSourceBadge(
|
||||
owner._cctvState.activeCamera,
|
||||
state !== 'idle',
|
||||
);
|
||||
const badge = document.getElementById('cctv-source-badge');
|
||||
return {
|
||||
state: badge.dataset.frameState,
|
||||
text: badge.textContent,
|
||||
color: getComputedStyle(badge).color,
|
||||
};
|
||||
}, state);
|
||||
const expected = {
|
||||
loading: ['loading', 'rgb(255, 229, 155)'],
|
||||
error: ['error', 'rgb(255, 176, 176)'],
|
||||
retained: ['ready', 'rgb(157, 239, 255)'],
|
||||
ready: ['ready', 'rgb(157, 239, 255)'],
|
||||
idle: ['idle', 'rgb(157, 239, 255)'],
|
||||
}[state];
|
||||
check(
|
||||
`${lang}: ${state} keeps its frame state and palette`,
|
||||
result.state === expected[0] &&
|
||||
result.color === expected[1] &&
|
||||
(!['ready', 'retained'].includes(state) ||
|
||||
result.text === inventory.source),
|
||||
result,
|
||||
);
|
||||
}
|
||||
for (const [calBadge, dirty, color] of [
|
||||
['raw-prior', false, 'rgb(143, 231, 255)'],
|
||||
['curated', false, 'rgb(255, 230, 162)'],
|
||||
['calibrated', false, 'rgb(173, 255, 191)'],
|
||||
['curated', true, 'rgb(255, 208, 138)'],
|
||||
]) {
|
||||
const result = await page.evaluate(
|
||||
({ calBadge, dirty }) => {
|
||||
const owner = window.__godsEyeView.styleManager._cctvControls;
|
||||
document.getElementById('cctv-next-btn').focus();
|
||||
owner._renderCctvState({
|
||||
...owner._cctvState,
|
||||
activeCamera: {
|
||||
...owner._cctvState.activeCamera,
|
||||
calBadge,
|
||||
calDirty: dirty,
|
||||
},
|
||||
});
|
||||
const badge = document.getElementById('cctv-quality-chip');
|
||||
return {
|
||||
state: badge.dataset.calBadge,
|
||||
color: getComputedStyle(badge).color,
|
||||
focus: document.activeElement.id,
|
||||
};
|
||||
},
|
||||
{ calBadge, dirty },
|
||||
);
|
||||
check(
|
||||
`${lang}: ${dirty ? 'edited' : calBadge} keeps provenance color and control focus`,
|
||||
result.state === (dirty ? 'edited' : calBadge) &&
|
||||
result.color === color &&
|
||||
result.focus === 'cctv-next-btn',
|
||||
result,
|
||||
);
|
||||
}
|
||||
for (const theme of ['tactical', 'cyber']) {
|
||||
await page.evaluate(
|
||||
(theme) => window.__godsEyeView.styleManager._setHudVariant(theme),
|
||||
theme,
|
||||
);
|
||||
for (const width of [1440, 620, 390]) {
|
||||
await page.setViewport({ width, height: 900 });
|
||||
for (const panelId of ['cctv-panel', 'weather-panel']) {
|
||||
await page.evaluate(async (panelId) => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
for (const other of ['cctv-panel', 'weather-panel']) {
|
||||
if (other !== panelId)
|
||||
manager.setPanelCollapsed(other, true, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
}
|
||||
manager.setPanelCollapsed(panelId, false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
await new Promise((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve)),
|
||||
);
|
||||
}, panelId);
|
||||
const layout = await page.evaluate((panelId) => {
|
||||
const selector =
|
||||
panelId === 'cctv-panel'
|
||||
? '#cctv-source-badge, #cctv-quality-chip'
|
||||
: '.weather-coverage';
|
||||
return [...document.querySelectorAll(selector)].map((node) => {
|
||||
const rect = node.getBoundingClientRect();
|
||||
const panel = node.closest(`#${panelId}`).getBoundingClientRect();
|
||||
return {
|
||||
id: node.id || 'weather-coverage',
|
||||
text: node.textContent,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
scroll: node.scrollWidth,
|
||||
client: node.clientWidth,
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
panelLeft: panel.left,
|
||||
panelRight: panel.right,
|
||||
fits:
|
||||
rect.width > 0 &&
|
||||
rect.height > 0 &&
|
||||
rect.left >= panel.left - 1 &&
|
||||
rect.right <= panel.right + 1 &&
|
||||
node.scrollWidth <= node.clientWidth + 1,
|
||||
};
|
||||
});
|
||||
}, panelId);
|
||||
check(
|
||||
`${lang}/${theme}: ${panelId} badges fit at ${width}px`,
|
||||
layout.length >= (panelId === 'cctv-panel' ? 2 : 1) &&
|
||||
layout.every((node) => node.fits),
|
||||
layout,
|
||||
);
|
||||
if (width === 390)
|
||||
await page.screenshot({
|
||||
path: `${out}/${lang}-${theme}-${panelId}-${width}.png`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const disposed = await page.evaluate(() => {
|
||||
const owner = window.__godsEyeView.styleManager._cctvControls;
|
||||
const badge = document.getElementById('cctv-quality-chip');
|
||||
const text = badge.textContent;
|
||||
owner.destroy();
|
||||
owner._renderCctvState(null);
|
||||
return badge.textContent === text;
|
||||
});
|
||||
check(`${lang}: disposal prevents late state painting`, disposed);
|
||||
}
|
||||
check('no uncaught browser errors', errors.length === 0, errors);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
console.log(`${checks} checks; ${failures} failures`);
|
||||
process.exitCode = failures ? 1 : 0;
|
||||
@@ -94,6 +94,7 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { arrivalRotationPassed } from '../src/tooling/trackArrivalObservation.mjs';
|
||||
import { classifyAircraft, CLASS_SCALE_3D, CLASS_MODEL_REAL } from '../src/data/aircraftClass.js';
|
||||
import { ensureGeoidReady, geoidHeight } from '../src/data/geoid.js';
|
||||
|
||||
@@ -2695,16 +2696,17 @@ async function main() {
|
||||
// reappear wearing its stale/creation-north nose for up to 1 s).
|
||||
// Staged deterministically: park the camera nadir over the synthetic
|
||||
// cluster, let rotations settle, TAMPER a billboard's rotation, then
|
||||
// trigger each path with the camera idle. The idle-drift catch-up
|
||||
// (ROTATION_REFRESH_MS = 1 s) can coincidentally mask a regression in a
|
||||
// ~300 ms SwiftShader frame window, so a regression shows as an
|
||||
// intermittent (not guaranteed) failure — never a false failure.
|
||||
// trigger each path with the camera idle. The fleet runs every 80 ms,
|
||||
// independently of rendered FPS. Observe the first completed settle and
|
||||
// reveal frames within 500 ms of forcing moveEnd, before the 1 s idle
|
||||
// rotation refresh can repair an incorrect first reveal.
|
||||
// ============================================================
|
||||
console.log('\nChange 4 — arrival rotation: moveEnd settle pass + horizon-reveal pass');
|
||||
const arrival = await evalPage(async () => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
const { screenProjectedRotation } = await import(`${window.__gevQaSourceBase || '/src'}/data/iconOrientation.js`);
|
||||
const { observeArrivalRotation } = await import(`${window.__gevQaSourceBase || '/src'}/tooling/trackArrivalObservation.mjs`);
|
||||
// 3D models OFF for this phase: a model-handed-off billboard is hidden
|
||||
// and skips rotation updates entirely — the probes need live billboards
|
||||
// (this is also the app's default state the field report came from).
|
||||
@@ -2766,39 +2768,29 @@ async function main() {
|
||||
const bb = findBB(id);
|
||||
if (!bb || !bb.show) return { error: `${id} missing/hidden` };
|
||||
const r0 = bb.rotation; // settled tamper origin; correctness uses a fresh projection below
|
||||
// (a) settle pass: tamper, then raise moveEnd with the camera IDLE —
|
||||
// the pose signature is unchanged, so only the moveEnd hook can fix
|
||||
// this before the 1 s catch-up.
|
||||
bb.rotation = r0 + 1.5;
|
||||
v.camera.moveEnd.raiseEvent();
|
||||
await nextFrames(2);
|
||||
const afterMoveEnd = bb.rotation;
|
||||
// A real-GPU fleet tick can land near the edge of the frame budget and
|
||||
// advance the contact before the probe reads it. Compare with the
|
||||
// production projection at the CURRENT position, not the now-stale r0.
|
||||
const expectedMoveEnd = screenProjectedRotation(v.scene, bb.position, course, null);
|
||||
// (b) reveal pass: tamper + hide — the next fleet tick must flip it
|
||||
// visible AND correct the nose in that same tick (camera still idle,
|
||||
// no moveEnd raised). Diagnostic fields (round 5): record WHICH frame
|
||||
// the flip landed on (flipFrame; -1 = never within the extended
|
||||
// window) and whether a 3D model owns this id — distinguishes "tick
|
||||
// slow under load" from "occluder says hidden" from "model handoff".
|
||||
bb.rotation = r0 + 1.5;
|
||||
bb.show = false;
|
||||
let flipFrame = -1;
|
||||
for (let f = 1; f <= 6; f++) {
|
||||
await nextFrames(1);
|
||||
if (bb.show && flipFrame === -1) flipFrame = f;
|
||||
if (f >= 2 && flipFrame !== -1) break;
|
||||
}
|
||||
const expectedReveal = screenProjectedRotation(v.scene, bb.position, course, null);
|
||||
const observed = await observeArrivalRotation({
|
||||
scene: v.scene,
|
||||
startMoveEnd() {
|
||||
// Only moveEnd can repair the tampered angle at the idle pose.
|
||||
bb.rotation = r0 + 1.5;
|
||||
v.camera.moveEnd.raiseEvent();
|
||||
},
|
||||
startReveal() {
|
||||
// Hide immediately after the confirmed settle frame. The next
|
||||
// eligible fleet tick must reveal AND re-aim in that same tick.
|
||||
bb.rotation = r0 + 1.5;
|
||||
bb.show = false;
|
||||
},
|
||||
read() {
|
||||
return {
|
||||
shown: bb.show,
|
||||
rotationError: angDiff(bb.rotation,
|
||||
screenProjectedRotation(v.scene, bb.position, course, null)),
|
||||
};
|
||||
},
|
||||
});
|
||||
const hasModel = !!(window.__g3dFindModel && window.__g3dFindModel(id));
|
||||
return {
|
||||
r0,
|
||||
dMoveEnd: angDiff(afterMoveEnd, expectedMoveEnd),
|
||||
dReveal: angDiff(bb.rotation, expectedReveal),
|
||||
shown: bb.show, flipFrame, hasModel,
|
||||
};
|
||||
return { r0, ...observed, hasModel };
|
||||
};
|
||||
const flightCourse = window.__SYNTH.flights.find((f) => f.icao === 'aaa002')?.track ?? 0;
|
||||
const militaryCourse = window.__SYNTH.military.find((m) => m.hex === 'bbb101')?.track ?? 0;
|
||||
@@ -2807,22 +2799,13 @@ async function main() {
|
||||
military: await probe('bbb101', militaryCourse),
|
||||
};
|
||||
});
|
||||
const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? a.error
|
||||
: `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° shown=${a.shown} flipFrame=${a.flipFrame} hasModel=${a.hasModel}`);
|
||||
const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? `${a.error}; elapsed=${Number(a.elapsedMs).toFixed(1)} ms`
|
||||
: `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° shown=${a.shown} flipFrame=${a.flipFrame} settle=${a.moveEndMs.toFixed(1)} ms reveal=${a.revealMs.toFixed(1)} ms total=${a.elapsedMs.toFixed(1)} ms hasModel=${a.hasModel}`);
|
||||
const TAMPER_DEG = 1.5 * 180 / Math.PI; // ≈ 86° — an uncorrected nose stays this far off
|
||||
// Reveal budget re-pinned 2 → 4 frames (round 5): the diagnostic probe
|
||||
// shows the military flip consistently lands on frame 2 — the exact edge
|
||||
// of the old budget — so any load hiccup flaked it (flights lands frame
|
||||
// 1). The regression this protects against is a reveal that waits for
|
||||
// the 1 s idle catch-up (~30-60 frames) or never re-aims; 4 frames still
|
||||
// catches that with a wide margin, with zero-headroom flakes gone.
|
||||
const arrOk = (a) => !!a && !a.error && a.shown
|
||||
&& (a.flipFrame === -1 ? false : a.flipFrame <= 4)
|
||||
&& a.dMoveEnd * 180 / Math.PI < 10 && a.dReveal * 180 / Math.PI < 10;
|
||||
record(`arrival: moveEnd + reveal both re-aim within 4 frames, flights (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
|
||||
arrOk(arrival.flights), fmtArr(arrival.flights));
|
||||
record(`arrival: moveEnd + reveal both re-aim within 4 frames, military (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
|
||||
arrOk(arrival.military), fmtArr(arrival.military));
|
||||
record(`arrival: moveEnd + first reveal both re-aim before idle refresh, flights (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
|
||||
arrivalRotationPassed(arrival.flights), fmtArr(arrival.flights));
|
||||
record(`arrival: moveEnd + first reveal both re-aim before idle refresh, military (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
|
||||
arrivalRotationPassed(arrival.military), fmtArr(arrival.military));
|
||||
|
||||
// ============================================================
|
||||
// DISPLAY FLOOR (2026-08-19): a grounded contact must never render below
|
||||
|
||||
Reference in new issue
Block a user