mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Merge remote-tracking branch 'origin/main' into german/hal-623-hud-summary-locale
# Conflicts: # src/hud.js # src/hudAltitudeDatum.test.mjs
This commit is contained in:
commit
1a6687632d
660 files changed
+45113
-19572
No files matched your search
@@ -1,6 +1,8 @@
|
||||
import { readFile, realpath } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import { VUE_PLUGIN_OPTIONS } from '../build/vueOptions.js';
|
||||
import { build, normalizePath } from 'vite';
|
||||
|
||||
/** Build each browser or Node export group and reject imports outside its declared ownership. */
|
||||
@@ -99,15 +101,20 @@ export async function checkPackageBoundaries(root) {
|
||||
ssr: node ? { noExternal: true } : undefined,
|
||||
logLevel: 'silent',
|
||||
plugins: [
|
||||
vue(VUE_PLUGIN_OPTIONS),
|
||||
{
|
||||
name: 'check-package-ownership',
|
||||
moduleParsed(info) {
|
||||
if (!allowed.has(info.id)) {
|
||||
// Plugin helpers are virtual; a single-file component's style and
|
||||
// template blocks belong to the component file itself.
|
||||
if (info.id.startsWith('\0')) return;
|
||||
const id = info.id.split('?')[0];
|
||||
if (!allowed.has(id)) {
|
||||
throw new Error(
|
||||
`Package boundary ${name} imports an unowned module: ${path.relative(root, info.id)}`,
|
||||
`Package boundary ${name} imports an unowned module: ${path.relative(root, id)}`,
|
||||
);
|
||||
}
|
||||
seen.add(info.id);
|
||||
seen.add(id);
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
"server/standalone/vite.config.js",
|
||||
"vite.config.js",
|
||||
"src/tooling/viteBuild.test.mjs",
|
||||
"src/tooling/vueLoader.test.mjs",
|
||||
"src/tooling/allowedHosts.test.mjs",
|
||||
"server/providers/common/http.js",
|
||||
"server/providers/common/query.js",
|
||||
@@ -180,6 +181,8 @@
|
||||
"src/ui/panelDisclosure.js",
|
||||
"src/ui/imagerySplit.js",
|
||||
"src/ui/imagerySplit.test.mjs",
|
||||
"src/ui/imagerySplitView.test.mjs",
|
||||
"src/ui/stores/imagerySplit.js",
|
||||
"src/ui/surfaceKeyboard.js",
|
||||
"src/ui/surfaceKeyboard.test.mjs",
|
||||
"src/ui/panelRails.js",
|
||||
@@ -197,9 +200,7 @@
|
||||
"scripts/qa-scope-hud.mjs",
|
||||
"scripts/qa-segmented-controls.mjs",
|
||||
"src/ui/displayControls.js",
|
||||
"src/ui/segmentedControl.test.mjs",
|
||||
"src/ui/styles/segmented-control.css",
|
||||
"src/ui/displayControls.test.mjs",
|
||||
"src/ui/cameraOrientationControls.js",
|
||||
"src/ui/cameraOrientationControls.test.mjs",
|
||||
"src/ui/effects.js",
|
||||
@@ -297,6 +298,8 @@
|
||||
"src/ui/styles.js",
|
||||
"src/ui/shellLifecycle.test.mjs",
|
||||
"src/testSupport/readStylesheet.mjs",
|
||||
"src/testSupport/voiceUi.mjs",
|
||||
"src/testSupport/voiceView.mjs",
|
||||
"src/testSupport/streetLevelFakes.mjs",
|
||||
"src/splitFlap.js",
|
||||
"style.css",
|
||||
@@ -526,6 +529,7 @@
|
||||
"scripts/qa-traffic-navigation.mjs",
|
||||
"src/ui/frameRateMonitor.js",
|
||||
"src/ui/frameRateMonitor.test.mjs",
|
||||
"src/ui/frameRateView.test.mjs",
|
||||
"src/layers/cctv/lifecycle.js",
|
||||
"src/layers/cctv/controls.js",
|
||||
"src/layers/cctv/geometry.js",
|
||||
@@ -561,6 +565,7 @@
|
||||
"src/data/cctvCataloniaSource.test.mjs",
|
||||
"src/data/cctvHk.test.mjs",
|
||||
"src/data/cctvVegvesen.test.mjs",
|
||||
"src/data/cctvA22.test.mjs",
|
||||
"src/data/cctvNyc.test.mjs",
|
||||
"src/data/cctvNswSource.test.mjs",
|
||||
"src/data/cctvTxdotSource.test.mjs",
|
||||
@@ -649,7 +654,6 @@
|
||||
"src/voice/realtimeBackend.test.mjs",
|
||||
"src/agent/worldPacket.test.mjs",
|
||||
"src/voice/layerSummary.js",
|
||||
"build/application-html.js",
|
||||
"src/app/sources.js",
|
||||
"src/ui/composition.js",
|
||||
"src/services/application.js",
|
||||
@@ -739,6 +743,8 @@
|
||||
"server/providers/openai/toolDescriptions.js",
|
||||
"src/voice/session.js",
|
||||
"src/voice/sessionCommands.js",
|
||||
"src/ui/stores/pointerReticle.js",
|
||||
"src/voice/pointerReticleView.test.mjs",
|
||||
"src/voice/realtimeSession.js",
|
||||
"src/voice/realtimeEvents.js",
|
||||
"src/voice/session.test.mjs",
|
||||
@@ -896,6 +902,7 @@
|
||||
"scripts/qa-weather-swap.mjs",
|
||||
"scripts/qa-weather-teardown.mjs",
|
||||
"src/layers/weather/imageryHost.test.mjs",
|
||||
"src/layers/weather/display.test.mjs",
|
||||
"src/sdr/adsbDecoder.test.mjs",
|
||||
"src/sdr/controller.test.mjs",
|
||||
"src/sdr/decoderDiagnostics.test.mjs",
|
||||
@@ -908,6 +915,8 @@
|
||||
"src/sources/adsbRecords.test.mjs",
|
||||
"src/layers/localAdsb/index.test.mjs",
|
||||
"src/ui/localSdrControls.test.mjs",
|
||||
"src/ui/sdrCardActions.test.mjs",
|
||||
"src/ui/radioPanelActions.test.mjs",
|
||||
"src/ui/localSdrMarkup.test.mjs",
|
||||
"src/ui/localSdrPresentation.test.mjs",
|
||||
"src/data/localReceiversProxy.test.mjs",
|
||||
@@ -1093,7 +1102,6 @@
|
||||
"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/dockerTrustedPeers.test.mjs",
|
||||
@@ -1103,12 +1111,13 @@
|
||||
"src/hudSummaryGate.test.mjs",
|
||||
"src/voice/hudPlaceLabels.test.mjs",
|
||||
"src/voice/panel/preferences.test.mjs",
|
||||
"src/voice/panel/preferencesView.test.mjs",
|
||||
"src/voice/voiceControlView.test.mjs",
|
||||
"src/voice/panel/host.test.mjs",
|
||||
"src/voice/panel/manualPanel.test.mjs",
|
||||
"src/voice/panel/nativeActivity.test.mjs",
|
||||
"src/voice/panel/inactivity.test.mjs",
|
||||
"src/voice/panel/actionObserver.test.mjs",
|
||||
"src/voice/panel/testDom.mjs",
|
||||
"src/voice/realtimeCostPanel.test.mjs",
|
||||
"docs/SHARE-LINK-FORMAT.md",
|
||||
"src/share/wire.test.mjs",
|
||||
@@ -1117,5 +1126,62 @@
|
||||
"src/testSupport/shareViewFixtures.mjs",
|
||||
"src/ui/shareLinks.test.mjs",
|
||||
"src/ui/shareButton.test.mjs",
|
||||
"src/share/shareLinkPath.test.mjs"
|
||||
"src/share/shareLinkPath.test.mjs",
|
||||
"src/ui/kit/UiRail.test.mjs",
|
||||
"src/ui/kit/UiSegmented.vue",
|
||||
"src/ui/kit/UiSegmented.test.mjs",
|
||||
"src/ui/kit/UiSlider.js",
|
||||
"src/ui/kit/UiSlider.test.mjs",
|
||||
"src/ui/kit/UiButton.vue",
|
||||
"src/ui/kit/UiButton.test.mjs",
|
||||
"src/ui/kit/UiChip.vue",
|
||||
"src/ui/kit/UiChip.test.mjs",
|
||||
"src/ui/globeActions.test.mjs",
|
||||
"src/ui/safeFrameView.test.mjs",
|
||||
"src/ui/kit/UiSelect.js",
|
||||
"src/ui/kit/UiSelect.test.mjs",
|
||||
"src/ui/kit/UiBadge.vue",
|
||||
"src/ui/kit/UiBadge.test.mjs",
|
||||
"src/ui/kit/UiEmpty.vue",
|
||||
"src/ui/rail/RailChips.vue",
|
||||
"src/ui/displayPanelActions.test.mjs",
|
||||
"src/testSupport/vueDom.mjs",
|
||||
"build/vueLoader.js",
|
||||
"build/registerVue.js",
|
||||
"src/ui/panelNavigation.test.mjs",
|
||||
"src/ui/stores/owners.js",
|
||||
"src/ui/stores/owners.test.mjs",
|
||||
"src/ui/stores/panels.test.mjs",
|
||||
"src/ui/stores/status.js",
|
||||
"src/ui/statusChipsView.test.mjs",
|
||||
"src/ui/statusChipOwners.test.mjs",
|
||||
"src/ui/toast.js",
|
||||
"src/ui/toastView.test.mjs",
|
||||
"src/ui/stores/sceneInteractions.js",
|
||||
"src/scenes/dataPacks/presentationView.test.mjs",
|
||||
"src/ui/stores/dataPacks.js",
|
||||
"src/scenes/interactionsView.test.mjs",
|
||||
"src/layers/wind/inspectionMarker.js",
|
||||
"src/layers/wind/inspectionMarker.test.mjs",
|
||||
"src/ui/stores/windInspection.js",
|
||||
"src/ui/stores/missionReplay.js",
|
||||
"src/layers/launches/replayOverlay.test.mjs",
|
||||
"src/ui/stores/bhoteKoshiPanel.js",
|
||||
"src/data/bhoteKoshiPanelView.test.mjs",
|
||||
"src/ui/stores/celestialRing.js",
|
||||
"src/celestialRingView.test.mjs",
|
||||
"src/ui/stores/screenAnnotations.js",
|
||||
"src/ui/stores/worldOverlayActions.js",
|
||||
"src/overlays/worldOverlayActionsView.test.mjs",
|
||||
"src/ui/stores/awarenessDirection.js",
|
||||
"src/layers/awareness/directionOverlay.test.mjs",
|
||||
"src/hudReadouts.js",
|
||||
"src/ui/stores/hud.js",
|
||||
"src/ui/views/intelHudView.test.mjs",
|
||||
"src/testSupport/hudReadouts.mjs",
|
||||
"src/ui/stores/firstRun.js",
|
||||
"src/firstRunLauncherView.test.mjs",
|
||||
"src/firstRunCopy.js",
|
||||
"src/data/headingDegrees.test.mjs",
|
||||
"src/data/distanceFormat.test.mjs"
|
||||
]
|
||||
+1
-1
@@ -39,7 +39,7 @@ export async function discoverRuntimeFormatFiles(root) {
|
||||
const files = [];
|
||||
for (const name of new Set(candidates)) {
|
||||
if (
|
||||
!/\.[cm]?js$/.test(name) ||
|
||||
!/\.([cm]?js|vue)$/.test(name) ||
|
||||
/\.test\.[cm]?js$/.test(name) ||
|
||||
!existsSync(path.join(root, name))
|
||||
)
|
||||
|
||||
+61
-6
@@ -8,7 +8,7 @@
|
||||
// lists it.
|
||||
//
|
||||
// Messages are the English literals passed to t(), tn() and msg() in src/,
|
||||
// plus the markup opted in with data-i18n* in src/ui/templates and index.html.
|
||||
// plus the markup opted in with data-i18n* in index.html and the Vue views.
|
||||
// It also lists UI text that is not marked at all (see findUnmarked).
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
@@ -119,10 +119,10 @@ export function extractMessages() {
|
||||
}
|
||||
}
|
||||
|
||||
const markup = trackedFiles('index.html', 'src/ui/templates/*.html');
|
||||
const markup = markupFiles();
|
||||
const TAG = /<([a-zA-Z][\w-]*)(\s[^>]*?)\/?>([^<]*)/gs;
|
||||
for (const file of markup) {
|
||||
const html = readFileSync(path.join(ROOT, file), 'utf8');
|
||||
const html = readMarkup(file);
|
||||
for (const [, tag, attrs, text] of html.matchAll(TAG)) {
|
||||
const where = `${file} <${tag}>`;
|
||||
if (/\sdata-i18n(\s|=|$)/.test(attrs)) {
|
||||
@@ -156,7 +156,7 @@ const SKIP_TAGS = new Set(['script', 'style', 'svg', 'code', 'pre', 'title']);
|
||||
/**
|
||||
* User-visible markup text without data-i18n*. Skipped, with everything
|
||||
* inside: translate="no" (text that stays as is, as in HTML),
|
||||
* data-i18n-runtime (code sets this text through t()), and icons
|
||||
* data-i18n-runtime (page markup whose text code sets through t()), and icons
|
||||
* (aria-hidden, Material Symbols).
|
||||
*/
|
||||
export function unmarkedMarkup(file, html) {
|
||||
@@ -404,9 +404,12 @@ export function unmarkedScript(file, original) {
|
||||
*/
|
||||
export function findUnmarked() {
|
||||
const found = [];
|
||||
for (const file of trackedFiles('index.html', 'src/ui/templates/*.html'))
|
||||
for (const file of markupFiles()) {
|
||||
found.push(...unmarkedMarkup(file, readMarkup(file)));
|
||||
}
|
||||
for (const file of trackedFiles('src/**/*.vue'))
|
||||
found.push(
|
||||
...unmarkedMarkup(file, readFileSync(path.join(ROOT, file), 'utf8')),
|
||||
...markedTemplate(file, readFileSync(path.join(ROOT, file), 'utf8')),
|
||||
);
|
||||
for (const file of sourceFiles().filter(
|
||||
(name) => !NOT_UI.some((pattern) => pattern.test(name)),
|
||||
@@ -423,6 +426,57 @@ export function findUnmarked() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* data-i18n* in a Vue template: views translate with t() as they render, so
|
||||
* their text follows the locale; the attributes are for static page markup.
|
||||
* Reads the whole single-file component, so lines are the file's own.
|
||||
*/
|
||||
export function markedTemplate(file, source) {
|
||||
const start = source.indexOf('<template>');
|
||||
if (start === -1) return [];
|
||||
const end = source.lastIndexOf('</template>');
|
||||
// Comments and attribute values are blanked, keeping every offset.
|
||||
const blank = (text) => text.replace(/[^\n]/g, ' ');
|
||||
const template = source
|
||||
.slice(0, end === -1 ? undefined : end)
|
||||
.replace(/<!--[\s\S]*?-->/g, blank);
|
||||
const found = [];
|
||||
const TAG = /<[a-zA-Z][\w-]*((?:"[^"]*"|'[^']*'|[^>"'])*)>/g;
|
||||
TAG.lastIndex = start + '<template>'.length;
|
||||
for (let tag; (tag = TAG.exec(template));) {
|
||||
const attrs = tag[1].replace(/"[^"]*"|'[^']*'/g, blank);
|
||||
const offset = tag.index + tag[0].length - 1 - tag[1].length;
|
||||
for (const match of attrs.matchAll(
|
||||
/(?<=\s)(?::|v-bind:)?(data-i18n[\w-]*)(?=[\s=/]|$)/g,
|
||||
))
|
||||
found.push({
|
||||
where: `${file}:${lineAt(source, offset + match.index)}`,
|
||||
text: `${match[1]}: use t() in a Vue template`,
|
||||
});
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Markup checked for unmarked text: the page and its views. */
|
||||
function markupFiles() {
|
||||
return trackedFiles('index.html', 'src/ui/views/*.vue');
|
||||
}
|
||||
|
||||
/**
|
||||
* A file's markup; for a single-file component, its template block without
|
||||
* interpolations, which are code: their t() calls are read with the sources.
|
||||
*/
|
||||
function readMarkup(file) {
|
||||
const source = readFileSync(path.join(ROOT, file), 'utf8');
|
||||
if (!file.endsWith('.vue')) return source;
|
||||
const start = source.indexOf('<template>');
|
||||
const end = source.lastIndexOf('</template>');
|
||||
if (start === -1) return '';
|
||||
return source
|
||||
.slice(start + '<template>'.length, end)
|
||||
.replace(/\{\{[\s\S]*?\}\}/g, '');
|
||||
}
|
||||
|
||||
// Text that never reaches the page as UI: tool results and descriptions for
|
||||
// AI clients (MCP and the voice model).
|
||||
const NOT_UI = [/^src\/tools\//];
|
||||
@@ -433,6 +487,7 @@ function sourceFiles() {
|
||||
'src/*.mjs',
|
||||
'src/**/*.js',
|
||||
'src/**/*.mjs',
|
||||
'src/**/*.vue',
|
||||
).filter(
|
||||
(file) =>
|
||||
!file.startsWith('src/i18n/') &&
|
||||
|
||||
+450
-58
File diff suppressed because it is too large.
Load diff
@@ -1720,6 +1720,9 @@ try {
|
||||
const priorSignalMaxHeight = signal.style.maxHeight;
|
||||
signal.style.maxHeight = '150px';
|
||||
manager._setCockpitDisclosure(kind, true);
|
||||
// Let Vue render the disclosure, but not the scheduled layout frame: the
|
||||
// first syncSignalLayout below must be the first pass.
|
||||
await Promise.resolve();
|
||||
// One pass must be enough. Record the result of the FIRST layout call, then
|
||||
// confirm a second changes nothing, so a convergence regression cannot hide
|
||||
// behind a repeated call.
|
||||
@@ -1796,7 +1799,7 @@ try {
|
||||
manager._setCockpitDisplayPortalActive(false);
|
||||
document.body.classList.remove('cockpit-mode');
|
||||
await waitFrames();
|
||||
standard.classList.remove('collapsed');
|
||||
manager._presentPanel(standard, { collapsed: false });
|
||||
standard.style.height = '120px';
|
||||
standard.style.maxHeight = '120px';
|
||||
standard.style.overflowY = 'auto';
|
||||
@@ -1831,7 +1834,7 @@ try {
|
||||
cockpit.style.height = priorCockpitHeight;
|
||||
cockpit.style.maxHeight = priorCockpitMaxHeight;
|
||||
cockpit.style.overflowY = priorCockpitOverflow;
|
||||
standard.classList.toggle('collapsed', standardWasCollapsed);
|
||||
manager._presentPanel(standard, { collapsed: standardWasCollapsed });
|
||||
return {
|
||||
standardBefore,
|
||||
standardAfter,
|
||||
@@ -1851,7 +1854,7 @@ try {
|
||||
JSON.stringify(portalScroll),
|
||||
);
|
||||
|
||||
const boundary = await page.evaluate(() => {
|
||||
const boundary = await page.evaluate(async () => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
const hud = document.getElementById('cockpit-hud');
|
||||
const signal = document.getElementById('cockpit-signal-stream');
|
||||
@@ -1866,6 +1869,7 @@ try {
|
||||
signal.hidden = false;
|
||||
manager._setCockpitDisclosure('radio', false);
|
||||
manager._setCockpitDisclosure('display', true);
|
||||
await new Promise(requestAnimationFrame);
|
||||
const priorSignalMaxHeight = signal.style.maxHeight;
|
||||
const priorSignalHeight = signal.style.height;
|
||||
// The strip is pinned to the viewport ceiling once the briefing card grows
|
||||
@@ -2083,7 +2087,7 @@ try {
|
||||
}
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager._setHudVariant('tactical'));
|
||||
|
||||
const signalTransition = await page.evaluate(() => {
|
||||
const signalTransition = await page.evaluate(async () => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
const cockpit = manager.cockpitView;
|
||||
const signalToggle = document.getElementById('cockpit-signal-toggle');
|
||||
@@ -2110,6 +2114,8 @@ try {
|
||||
manager._setCockpitDisclosure('radio', true);
|
||||
manager._setCockpitDisclosure('radio', false);
|
||||
const manualCollapseRetained = cockpit.signalCollapsed && cockpit.signalUserCollapsed;
|
||||
// The toggle renders the published collapse on Vue's next flush.
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
return {
|
||||
afterExpansion,
|
||||
afterRepeatedExpansion,
|
||||
@@ -2136,7 +2142,7 @@ try {
|
||||
&& signalTransition.signalAriaExpanded === 'false',
|
||||
JSON.stringify(signalTransition),
|
||||
);
|
||||
const contextTransition = await page.evaluate(() => {
|
||||
const contextTransition = await page.evaluate(async () => {
|
||||
const cockpit = window.__godsEyeView.styleManager.cockpitView;
|
||||
const priorCollapsed = cockpit.contextCollapsed;
|
||||
const contextToggle = document.getElementById('cockpit-context-toggle');
|
||||
@@ -2148,6 +2154,8 @@ try {
|
||||
cockpit.setContextCollapsed(false);
|
||||
const afterExpansion = expandedEvents;
|
||||
cockpit.setContextCollapsed(false);
|
||||
// The toggle renders the published collapse on Vue's next flush.
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
return {
|
||||
afterExpansion,
|
||||
afterRepeatedExpansion: expandedEvents,
|
||||
@@ -2168,33 +2176,34 @@ try {
|
||||
JSON.stringify(contextTransition),
|
||||
);
|
||||
// Use the real connected DOM to catch focus loss caused by moving live rows.
|
||||
const signalFocus = await page.evaluate(() => {
|
||||
const signalFocus = await page.evaluate(async () => {
|
||||
const cockpit = window.__godsEyeView.styleManager.cockpitView;
|
||||
// The rows render on Vue's next update; a frame is past it.
|
||||
const show = async (items) => {
|
||||
cockpit.signalItems = items;
|
||||
cockpit.publishCockpitSignals();
|
||||
await new Promise(requestAnimationFrame);
|
||||
};
|
||||
const originalItems = cockpit.signalItems;
|
||||
const wasCollapsed = cockpit.signalCollapsed;
|
||||
cockpit.setSignalCollapsed(false);
|
||||
const item = (id) => ({ key: `qa-${id}`, tone: 'info', timestamp: Date.now(),
|
||||
title: `QA ${id}`, detail: 'Keyboard refresh fixture', target: { layerId: 'flights', id } });
|
||||
try {
|
||||
cockpit.signalItems = [item('focus-a'), item('focus-b')];
|
||||
cockpit.renderCockpitSignals();
|
||||
await show([item('focus-a'), item('focus-b')]);
|
||||
const button = cockpit.signalList.querySelector('[data-signal-id="focus-a"]');
|
||||
button.focus();
|
||||
const acquired = document.activeElement === button;
|
||||
cockpit.signalItems = [item('focus-c'), item('focus-b'), item('focus-a')];
|
||||
cockpit.renderCockpitSignals();
|
||||
await show([item('focus-c'), item('focus-b'), item('focus-a')]);
|
||||
const retained = document.activeElement === button && button.isConnected;
|
||||
const order = [...cockpit.signalList.querySelectorAll('[data-signal-id]')].map((node) => node.dataset.signalId);
|
||||
cockpit.signalItems = [item('focus-b')];
|
||||
cockpit.renderCockpitSignals();
|
||||
await show([item('focus-b')]);
|
||||
const continuation = cockpit.briefTabs[cockpit.briefPageIndex] || cockpit.signalToggle;
|
||||
const continued = document.activeElement === continuation;
|
||||
cockpit.signalItems = [item('focus-a'), item('focus-b')];
|
||||
cockpit.renderCockpitSignals();
|
||||
await show([item('focus-a'), item('focus-b')]);
|
||||
return { acquired, retained, order, continued, notReclaimed: document.activeElement === continuation };
|
||||
} finally {
|
||||
cockpit.signalItems = originalItems;
|
||||
cockpit.renderCockpitSignals();
|
||||
await show(originalItems);
|
||||
cockpit.setSignalCollapsed(wasCollapsed);
|
||||
}
|
||||
});
|
||||
@@ -2260,12 +2269,14 @@ try {
|
||||
signal.hidden = false;
|
||||
manager._setCockpitDisclosure('display', false);
|
||||
manager._setCockpitDisclosure('radio', false);
|
||||
await new Promise(requestAnimationFrame);
|
||||
manager.cockpitView.syncSignalLayout();
|
||||
const collapsed = {
|
||||
display: displayControl.getBoundingClientRect().toJSON(),
|
||||
radio: radioControl.getBoundingClientRect().toJSON(),
|
||||
};
|
||||
display.click();
|
||||
await new Promise(requestAnimationFrame);
|
||||
manager.cockpitView.syncSignalLayout();
|
||||
return {
|
||||
collapsed,
|
||||
|
||||
@@ -474,12 +474,22 @@ async function contacts() {
|
||||
|
||||
// SEARCH NEARBY SITES, as the user presses it.
|
||||
await page.evaluate(() => {
|
||||
document.getElementById('toast').textContent = '';
|
||||
const toast = document.getElementById('toast');
|
||||
const before = toast.firstChild;
|
||||
document.getElementById('installations-search-btn').click();
|
||||
window.__qaToastBefore = before;
|
||||
});
|
||||
// Every toast show writes a new text node, even for a repeated message.
|
||||
await page
|
||||
.waitForFunction(
|
||||
() => document.getElementById('toast').textContent.trim().length > 0,
|
||||
() => {
|
||||
const toast = document.getElementById('toast');
|
||||
return (
|
||||
toast.classList.contains('visible') &&
|
||||
toast.firstChild !== window.__qaToastBefore &&
|
||||
toast.textContent.trim().length > 0
|
||||
);
|
||||
},
|
||||
{ timeout: 20_000 },
|
||||
)
|
||||
.catch(() => {});
|
||||
|
||||
@@ -427,6 +427,9 @@ try {
|
||||
shot.interactions = [];
|
||||
fields[0].value = JSON.stringify(scene);
|
||||
fields[1].value = JSON.stringify(shot);
|
||||
// Typing fires input; the dialog reads its fields from those events.
|
||||
for (const field of fields)
|
||||
field.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
await page.click('[data-director-apply-details]');
|
||||
await page.waitForFunction(
|
||||
|
||||
@@ -25,12 +25,18 @@ import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const TESTS = ['src/firstRunExperience.test.mjs', 'src/standalone/startupChrome.test.mjs'];
|
||||
const TESTS = [
|
||||
'src/firstRunExperience.test.mjs',
|
||||
'src/standalone/startupChrome.test.mjs',
|
||||
'src/firstRunLauncherView.test.mjs',
|
||||
];
|
||||
|
||||
const FILES = {
|
||||
keyboard: path.join(ROOT, 'src', 'ui', 'surfaceKeyboard.js'),
|
||||
module: path.join(ROOT, 'src', 'firstRunExperience.js'),
|
||||
html: path.join(ROOT, 'src', 'ui', 'templates', 'welcome.html'),
|
||||
copy: path.join(ROOT, 'src', 'firstRunCopy.js'),
|
||||
surfaces: path.join(ROOT, 'src', 'exclusiveSurfaces.js'),
|
||||
html: path.join(ROOT, 'src', 'ui', 'views', 'WelcomeView.vue'),
|
||||
css: path.join(ROOT, 'src', 'ui', 'styles', 'first-run.css'),
|
||||
voiceInstructions: path.join(ROOT, 'server/providers/openai/instructions.js'),
|
||||
voiceDescriptions: path.join(
|
||||
@@ -53,7 +59,8 @@ const MUTATIONS = [
|
||||
to: "writeStored('local', sessionStorageRef, FIRST_RUN_STORAGE_KEY, 'suppressed');",
|
||||
},
|
||||
{
|
||||
defect: 'the checkbox is ignored, so nothing can ever suppress the launcher',
|
||||
defect:
|
||||
'the checkbox is ignored, so nothing can ever suppress the launcher',
|
||||
file: 'module',
|
||||
from: " ? writeStored('local', storage, FIRST_RUN_STORAGE_KEY, 'suppressed')",
|
||||
to: ' ? false',
|
||||
@@ -77,7 +84,8 @@ const MUTATIONS = [
|
||||
to: ' // durable check removed',
|
||||
},
|
||||
{
|
||||
defect: '?welcome=1 no longer outranks suppression, so demos cannot replay it',
|
||||
defect:
|
||||
'?welcome=1 no longer outranks suppression, so demos cannot replay it',
|
||||
file: 'module',
|
||||
from: "if (params.get('welcome') === '1') return true;",
|
||||
to: '// replay hatch removed',
|
||||
@@ -139,8 +147,9 @@ const MUTATIONS = [
|
||||
to: ' /* observer never attached */',
|
||||
},
|
||||
{
|
||||
defect: 'cockpit drops off the exclusive list and can stack with the launcher',
|
||||
file: 'module',
|
||||
defect:
|
||||
'cockpit drops off the exclusive list and can stack with the launcher',
|
||||
file: 'surfaces',
|
||||
from: "export const EXCLUSIVE_SURFACE_CLASSES = Object.freeze([\n 'cockpit-mode',",
|
||||
to: 'export const EXCLUSIVE_SURFACE_CLASSES = Object.freeze([',
|
||||
},
|
||||
@@ -157,19 +166,22 @@ const MUTATIONS = [
|
||||
to: 'dismiss();',
|
||||
},
|
||||
{
|
||||
defect: 'isTopmost stops hit-testing, so an unclassed overlay buries the card again',
|
||||
defect:
|
||||
'isTopmost stops hit-testing, so an unclassed overlay buries the card again',
|
||||
file: 'module',
|
||||
from: ' root.getClientRects().length > 0 &&\n !coveredByOverlay();',
|
||||
to: ' root.getClientRects().length > 0;',
|
||||
},
|
||||
{
|
||||
defect: 'an inconclusive hit test reads as COVERED, so ESC quietly stops working',
|
||||
defect:
|
||||
'an inconclusive hit test reads as COVERED, so ESC quietly stops working',
|
||||
file: 'module',
|
||||
from: ' return Boolean(hit) && !root.contains(hit);',
|
||||
to: ' return !root.contains(hit);',
|
||||
},
|
||||
{
|
||||
defect: 'the launcher stops honouring a key another surface already claimed',
|
||||
defect:
|
||||
'the launcher stops honouring a key another surface already claimed',
|
||||
file: 'keyboard',
|
||||
from: ' || event.defaultPrevented',
|
||||
to: ' /* belt removed */',
|
||||
@@ -177,25 +189,29 @@ const MUTATIONS = [
|
||||
{
|
||||
// Anchored past the call so it lands on the Cockpit utility disclosure's
|
||||
// Escape claim and nowhere else.
|
||||
defect: 'a Cockpit utility disclosure returns to stopPropagation, so later document listeners also act on its ESC',
|
||||
defect:
|
||||
'a Cockpit utility disclosure returns to stopPropagation, so later document listeners also act on its ESC',
|
||||
file: 'radio',
|
||||
from: " event.stopImmediatePropagation();\n const kind = displayOpen ? 'display' : 'radio';",
|
||||
to: " event.stopPropagation();\n const kind = displayOpen ? 'display' : 'radio';",
|
||||
},
|
||||
{
|
||||
defect: 'the "no timer" decision is deleted, so the next editor re-litigates it blind',
|
||||
defect:
|
||||
'the "no timer" decision is deleted, so the next editor re-litigates it blind',
|
||||
file: 'module',
|
||||
from: ' * ACCEPTED, DELIBERATELY NOT TIMED OUT: a surface class that never clears',
|
||||
to: ' * (note removed)',
|
||||
},
|
||||
{
|
||||
defect: 'a bounded reveal timer punches the card through a recording in progress',
|
||||
defect:
|
||||
'a bounded reveal timer punches the card through a recording in progress',
|
||||
file: 'module',
|
||||
from: ' const syncToExclusiveSurfaces = () => {\n if (closing) return;',
|
||||
to: ' const syncToExclusiveSurfaces = () => {\n if (closing) return;\n globalThis.setTimeout?.(reveal, 45000);',
|
||||
},
|
||||
{
|
||||
defect: 'the accepted no-show vanishes from CURRENT-STATE and reads as a bug',
|
||||
defect:
|
||||
'the accepted no-show vanishes from CURRENT-STATE and reads as a bug',
|
||||
file: 'docs',
|
||||
from: 'a surface class that never clears means no launcher for that page',
|
||||
to: 'the launcher always turns up eventually for that page',
|
||||
@@ -203,10 +219,11 @@ const MUTATIONS = [
|
||||
|
||||
// ── The checkbox may not promise what storage refused ─────────────────────
|
||||
{
|
||||
defect: 'a refused write leaves the box ticked, promising a suppression nobody stored',
|
||||
defect:
|
||||
'a refused write leaves the box ticked, promising a suppression nobody stored',
|
||||
file: 'module',
|
||||
from: ' if (setFirstRunSuppressed(wanted, storage)) return;',
|
||||
to: ' setFirstRunSuppressed(wanted, storage);\n return;',
|
||||
from: ' if (setFirstRunSuppressed(Boolean(wanted), storage)) return true;',
|
||||
to: ' setFirstRunSuppressed(Boolean(wanted), storage);\n return true;',
|
||||
},
|
||||
{
|
||||
defect: 'a missing storage area reports the write as saved',
|
||||
@@ -215,14 +232,16 @@ const MUTATIONS = [
|
||||
to: ' store?.setItem?.(key, value);\n return true;',
|
||||
},
|
||||
{
|
||||
defect: 'the scroll fade promises more list on a card where everything fits',
|
||||
defect:
|
||||
'the scroll fade promises more list on a card where everything fits',
|
||||
file: 'module',
|
||||
from: 'choiceList.dataset.scrollable = String(overflows);',
|
||||
to: "choiceList.dataset.scrollable = 'true'; void overflows;",
|
||||
from: 'present({ scrollable: String(overflows) });',
|
||||
to: "present({ scrollable: 'true' }); void overflows;",
|
||||
},
|
||||
|
||||
{
|
||||
defect: 'the DISPLAY rail opens on first run again, stealing the impression',
|
||||
defect:
|
||||
'the DISPLAY rail opens on first run again, stealing the impression',
|
||||
file: 'ui',
|
||||
from: "if (panelId === 'pp-toggles' && stored === null) collapsed = true;",
|
||||
to: "if (panelId === 'pp-toggles' && stored === null) collapsed = false;",
|
||||
@@ -236,13 +255,15 @@ const MUTATIONS = [
|
||||
|
||||
// ── Mission definitions ────────────────────────────────────────────────────
|
||||
{
|
||||
defect: 'the removed INFRASTRUCTURE tile comes back as a one-click globe dump',
|
||||
defect:
|
||||
'the removed INFRASTRUCTURE tile comes back as a one-click globe dump',
|
||||
file: 'module',
|
||||
from: ' environmental: Object.freeze({',
|
||||
to: " infrastructure: Object.freeze({\n kind: 'globe',\n layerIds: Object.freeze(['local-datacenters', 'local-dams', 'telegeography-submarine-cables']),\n busyText: 'Mapping global infrastructure…',\n }),\n environmental: Object.freeze({",
|
||||
},
|
||||
{
|
||||
defect: 'ENVIRONMENTAL drops the keyless earthquakes that carry it without a key',
|
||||
defect:
|
||||
'ENVIRONMENTAL drops the keyless earthquakes that carry it without a key',
|
||||
file: 'module',
|
||||
from: "layerIds: Object.freeze(['earthquakes', 'local-firms']),",
|
||||
to: "layerIds: Object.freeze(['local-firms']),",
|
||||
@@ -256,11 +277,11 @@ const MUTATIONS = [
|
||||
{
|
||||
defect: 'the tile stops promising the fires it actually turns on',
|
||||
file: 'html',
|
||||
from: '<small>Live earthquakes and active fires, from USGS and NASA</small>',
|
||||
to: '<small>Live earthquakes worldwide, straight from USGS</small>',
|
||||
from: " 'Live earthquakes and active fires, from USGS and NASA',",
|
||||
to: " 'Live earthquakes worldwide, straight from USGS',",
|
||||
},
|
||||
{
|
||||
defect: "the owner-authored first-run line is quietly rewritten",
|
||||
defect: 'the owner-authored first-run line is quietly rewritten',
|
||||
file: 'html',
|
||||
from: 'It feels like a forbidden cockpit—then you realize the sources are public and the data is real.',
|
||||
to: "It feels like a forbidden cockpit. It isn't — every feed is public, and every contact is live.",
|
||||
@@ -302,21 +323,24 @@ const MUTATIONS = [
|
||||
to: 'if (!mission) return { ok: true, choice };',
|
||||
},
|
||||
{
|
||||
defect: 'the fires/quakes tile name stops being switchable from one constant',
|
||||
file: 'module',
|
||||
from: 'return ENVIRONMENTAL_LABELS[choice] || ENVIRONMENTAL_LABELS.ENVIRONMENTAL;',
|
||||
to: 'return ENVIRONMENTAL_LABELS.ENVIRONMENTAL;',
|
||||
defect:
|
||||
'the fires/quakes tile name stops being switchable from one constant',
|
||||
file: 'copy',
|
||||
from: 'ENVIRONMENTAL_LABEL_KEYS[choice] || ENVIRONMENTAL_LABEL_KEYS.ENVIRONMENTAL;',
|
||||
to: 'ENVIRONMENTAL_LABEL_KEYS.ENVIRONMENTAL;',
|
||||
},
|
||||
|
||||
// ── Defaults interplay (the ruling this whole integration turns on) ────────
|
||||
{
|
||||
defect: "mission layer enables stop persisting, unlike the clicks they stand for",
|
||||
defect:
|
||||
'mission layer enables stop persisting, unlike the clicks they stand for',
|
||||
file: 'module',
|
||||
from: "dataManager.setEnabled(layerId, true, { origin: 'user' })",
|
||||
to: 'dataManager.setEnabled(layerId, true)',
|
||||
},
|
||||
{
|
||||
defect: 'a mission hand-edits detection and kills the style auto-preset contract',
|
||||
defect:
|
||||
'a mission hand-edits detection and kills the style auto-preset contract',
|
||||
file: 'module',
|
||||
from: ' flyToGlobe: () => styleManager.resetToGlobeView(),',
|
||||
to: ' flyToGlobe: () => { styleManager._detectionUserOverridden = true; return styleManager.resetToGlobeView(); },',
|
||||
@@ -328,13 +352,15 @@ const MUTATIONS = [
|
||||
to: ' styleManager._setModels3dEnabled?.(true);\n const result = await styleManager.setContextMode(mode);',
|
||||
},
|
||||
{
|
||||
defect: 'the Context panel is left collapsed, hiding the mode that just started',
|
||||
defect:
|
||||
'the Context panel is left collapsed, hiding the mode that just started',
|
||||
file: 'module',
|
||||
from: " styleManager.setPanelCollapsed?.('global-context-panel', false, {\n explicit: true,\n });",
|
||||
to: ' // panel reveal removed',
|
||||
},
|
||||
{
|
||||
defect: 'the decision table is deleted, so the next editor re-litigates it blind',
|
||||
defect:
|
||||
'the decision table is deleted, so the next editor re-litigates it blind',
|
||||
file: 'module',
|
||||
from: ' * MISSION → APP STATE, AND WHAT IT IS ALLOWED TO PERSIST',
|
||||
to: ' * (notes removed)',
|
||||
@@ -342,10 +368,11 @@ const MUTATIONS = [
|
||||
|
||||
// ── Accessibility and the transplant defects ──────────────────────────────
|
||||
{
|
||||
defect: 'disabling the focused tile drops the keyboard to <body> mid-flight',
|
||||
file: 'module',
|
||||
from: " for (const button of buttons)\n button.setAttribute('aria-disabled', String(next));",
|
||||
to: ' for (const button of buttons) button.disabled = next;',
|
||||
defect:
|
||||
'disabling the focused tile drops the keyboard to <body> mid-flight',
|
||||
file: 'html',
|
||||
from: ':aria-disabled="launcher?.busy ?? undefined" @click="controls?.choose(\'contacts\')"',
|
||||
to: ':disabled="launcher?.busy === \'true\'" @click="controls?.choose(\'contacts\')"',
|
||||
},
|
||||
{
|
||||
defect: 'Tab escapes into an app the visitor has not seen yet',
|
||||
@@ -384,19 +411,22 @@ const MUTATIONS = [
|
||||
to: 'body.never-matches-anything #first-run-launcher,',
|
||||
},
|
||||
{
|
||||
defect: 'the flex card defeats [hidden] and sits in the a11y tree pre-reveal',
|
||||
defect:
|
||||
'the flex card defeats [hidden] and sits in the a11y tree pre-reveal',
|
||||
file: 'css',
|
||||
from: '#first-run-launcher[hidden] {\n display: none;\n}',
|
||||
to: '/* [hidden] rule removed */',
|
||||
},
|
||||
{
|
||||
defect: 'the card loses its viewport cap and clips on a short landscape phone',
|
||||
defect:
|
||||
'the card loses its viewport cap and clips on a short landscape phone',
|
||||
file: 'css',
|
||||
from: ' max-height: calc(100dvh - 1.5rem);',
|
||||
to: ' /* cap removed */',
|
||||
},
|
||||
{
|
||||
defect: 'the mission list stops scrolling, so tiles overflow the capped card',
|
||||
defect:
|
||||
'the mission list stops scrolling, so tiles overflow the capped card',
|
||||
file: 'css',
|
||||
from: ' min-height: 0;\n overflow-y: auto;',
|
||||
to: ' /* scroll removed */',
|
||||
@@ -415,19 +445,19 @@ const MUTATIONS = [
|
||||
{
|
||||
defect: 'a mission tile is dropped from the menu',
|
||||
file: 'html',
|
||||
from: ' <button type="button" data-first-run-choice="space-missions">',
|
||||
to: ' <button type="button" data-first-run-choice-disabled="space-missions">',
|
||||
from: ' <button type="button" data-first-run-choice="space-missions"',
|
||||
to: ' <button type="button" data-first-run-choice-disabled="space-missions"',
|
||||
},
|
||||
{
|
||||
defect: 'the menu order stops matching the owner\'s',
|
||||
defect: "the menu order stops matching the owner's",
|
||||
file: 'html',
|
||||
from: '<button type="button" data-first-run-choice="environmental">',
|
||||
to: '<button type="button" data-first-run-choice="zzz-environmental">',
|
||||
from: '<button type="button" data-first-run-choice="environmental"',
|
||||
to: '<button type="button" data-first-run-choice="zzz-environmental"',
|
||||
},
|
||||
{
|
||||
defect: 'the "don\'t show again" checkbox is removed',
|
||||
file: 'html',
|
||||
from: '<input type="checkbox" data-first-run-suppress />',
|
||||
from: '<input type="checkbox" data-first-run-suppress @change="onSuppress" />',
|
||||
to: '<span data-first-run-suppress-removed></span>',
|
||||
},
|
||||
{
|
||||
@@ -439,8 +469,8 @@ const MUTATIONS = [
|
||||
{
|
||||
defect: 'the launcher is no longer announced as a dialog',
|
||||
file: 'html',
|
||||
from: '<aside id="first-run-launcher" role="dialog"',
|
||||
to: '<aside id="first-run-launcher"',
|
||||
from: 'id="first-run-launcher" role="dialog"',
|
||||
to: 'id="first-run-launcher"',
|
||||
},
|
||||
{
|
||||
defect: 'the launcher is revealed before the loading cover yields',
|
||||
@@ -449,7 +479,8 @@ const MUTATIONS = [
|
||||
to: " revealFirstRun();\n loadingScreen.classList.add('hidden');",
|
||||
},
|
||||
{
|
||||
defect: 'the globe missions lose their DataManager and cannot enable layers',
|
||||
defect:
|
||||
'the globe missions lose their DataManager and cannot enable layers',
|
||||
file: 'main',
|
||||
from: 'firstRun = initializeWelcome?.({ styleManager, dataManager });',
|
||||
to: 'firstRun = initializeWelcome?.({ styleManager });',
|
||||
@@ -463,7 +494,8 @@ const MUTATIONS = [
|
||||
to: "'Counts, lists, superlatives, and attribute filters over records loaded by ENABLED layers",
|
||||
},
|
||||
{
|
||||
defect: 'the instruction mapping is dropped, so voice cannot reach the modes',
|
||||
defect:
|
||||
'the instruction mapping is dropped, so voice cannot reach the modes',
|
||||
file: 'voiceInstructions',
|
||||
from: " 'NAMED VIEWS are shorthand",
|
||||
to: " // 'NAMED VIEWS are shorthand",
|
||||
@@ -471,21 +503,25 @@ const MUTATIONS = [
|
||||
];
|
||||
|
||||
const originals = new Map();
|
||||
for (const [key, file] of Object.entries(FILES)) originals.set(key, fs.readFileSync(file, 'utf8'));
|
||||
for (const [key, file] of Object.entries(FILES))
|
||||
originals.set(key, fs.readFileSync(file, 'utf8'));
|
||||
|
||||
/** Write only on a real change: a no-op write still wakes every file watcher. */
|
||||
const write = (file, next) => {
|
||||
if (fs.readFileSync(file, 'utf8') !== next) fs.writeFileSync(file, next);
|
||||
};
|
||||
const restoreAll = () => {
|
||||
for (const [key, file] of Object.entries(FILES)) write(file, originals.get(key));
|
||||
for (const [key, file] of Object.entries(FILES))
|
||||
write(file, originals.get(key));
|
||||
};
|
||||
process.on('exit', restoreAll);
|
||||
|
||||
let caught = 0;
|
||||
const missed = [];
|
||||
|
||||
console.log(`\nFirst-run launcher pin strength — ${MUTATIONS.length} individual reverts\n`);
|
||||
console.log(
|
||||
`\nFirst-run launcher pin strength — ${MUTATIONS.length} individual reverts\n`,
|
||||
);
|
||||
for (const { defect, file, from, to } of MUTATIONS) {
|
||||
const original = originals.get(file);
|
||||
if (!original.includes(from)) {
|
||||
@@ -497,10 +533,15 @@ for (const { defect, file, from, to } of MUTATIONS) {
|
||||
let red = false;
|
||||
let by = '';
|
||||
try {
|
||||
execFileSync('node', ['--test', ...TESTS], { cwd: ROOT, encoding: 'utf8', stdio: 'pipe' });
|
||||
execFileSync(
|
||||
'node',
|
||||
['--import=./build/registerVue.js', '--test', ...TESTS],
|
||||
{ cwd: ROOT, encoding: 'utf8', stdio: 'pipe' },
|
||||
);
|
||||
} catch (error) {
|
||||
red = true;
|
||||
const failed = String(error.stdout || '').split('\n')
|
||||
const failed = String(error.stdout || '')
|
||||
.split('\n')
|
||||
.filter((line) => line.trim().startsWith('✖') && line.includes('('))
|
||||
.map((line) => line.trim().slice(2).split(' (')[0]);
|
||||
by = [...new Set(failed)].slice(0, 2).join('; ');
|
||||
|
||||
@@ -87,17 +87,21 @@ try {
|
||||
row().querySelector('.data-toggle-btn').dataset.feedState === 'stale',
|
||||
]);
|
||||
const ui = window.__godsEyeView.styleManager;
|
||||
ui._clearSelectedLayersBtn.click();
|
||||
const clearButton = document.getElementById('clear-selected-layers');
|
||||
clearButton.click();
|
||||
// The bar renders the busy state on Vue's next flush.
|
||||
await Promise.resolve();
|
||||
result.push([
|
||||
'native clear activation presents busy state',
|
||||
ui._clearSelectedLayersBtn.getAttribute('aria-busy') === 'true' &&
|
||||
clearButton.getAttribute('aria-busy') === 'true' &&
|
||||
!!ui._contextControls._clearSelectedLayersPromise,
|
||||
]);
|
||||
await ui._contextControls._clearSelectedLayersPromise;
|
||||
await Promise.resolve();
|
||||
result.push([
|
||||
'clear settles through the existing layer transaction',
|
||||
!manager.isEnabled(id) &&
|
||||
ui._clearSelectedLayersBtn.getAttribute('aria-busy') === 'false',
|
||||
clearButton.getAttribute('aria-busy') === 'false',
|
||||
]);
|
||||
await manager.setEnabled(id, true);
|
||||
const final = row().querySelector('.data-toggle-btn');
|
||||
|
||||
@@ -62,13 +62,17 @@ try {
|
||||
);
|
||||
check(
|
||||
'orbit controls and indicator agree',
|
||||
await page.evaluate(() => {
|
||||
await page.evaluate(async () => {
|
||||
const ui = window.__godsEyeView.styleManager;
|
||||
// Vue renders on a microtask; a task later the indicator shows it.
|
||||
const rendered = () => new Promise((resolve) => setTimeout(resolve));
|
||||
ui.setOrbit(true);
|
||||
await rendered();
|
||||
const active =
|
||||
ui.orbitController.active &&
|
||||
document.getElementById('orbit-indicator').classList.contains('active');
|
||||
ui.setOrbit(false);
|
||||
await rendered();
|
||||
return (
|
||||
active &&
|
||||
!ui.orbitController.active &&
|
||||
@@ -120,8 +124,11 @@ try {
|
||||
const ui = window.__godsEyeView.styleManager;
|
||||
return (
|
||||
ui._searchedLocationLabel === '39.7392° N, 104.9903° W' &&
|
||||
ui._locationMiniCity.textContent.includes('39.7392° N') &&
|
||||
ui._locationMiniPoi.textContent === '104.9903° W'
|
||||
document
|
||||
.getElementById('location-mini-city')
|
||||
.textContent.includes('39.7392° N') &&
|
||||
document.getElementById('location-mini-poi').textContent ===
|
||||
'104.9903° W'
|
||||
);
|
||||
}),
|
||||
);
|
||||
@@ -194,8 +201,11 @@ try {
|
||||
const ui = window.__godsEyeView.styleManager;
|
||||
return (
|
||||
ui._searchedLocationLabel === 'Second landmark, Test city' &&
|
||||
ui._locationMiniCity.textContent.includes('Second landmark') &&
|
||||
ui._locationMiniPoi.textContent === 'Test city' &&
|
||||
document
|
||||
.getElementById('location-mini-city')
|
||||
.textContent.includes('Second landmark') &&
|
||||
document.getElementById('location-mini-poi').textContent ===
|
||||
'Test city' &&
|
||||
ui.activeStyle === 'normal' &&
|
||||
!ui._locationSearch.classList.contains('searching')
|
||||
);
|
||||
@@ -227,8 +237,10 @@ try {
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve)),
|
||||
);
|
||||
return (
|
||||
!ui._poiRow.classList.contains('expanded') &&
|
||||
!ui._locationBarDivider.classList.contains('visible')
|
||||
!document.getElementById('poi-row').classList.contains('expanded') &&
|
||||
!document
|
||||
.getElementById('location-bar-divider')
|
||||
.classList.contains('visible')
|
||||
);
|
||||
}, cityId),
|
||||
);
|
||||
@@ -284,7 +296,7 @@ try {
|
||||
'native reset returns through the existing camera handoff',
|
||||
await page.evaluate(async () => {
|
||||
const ui = window.__godsEyeView.styleManager;
|
||||
ui._resetGlobeBtn.click();
|
||||
document.getElementById('reset-globe-view').click();
|
||||
const pending = ui._globeResetPromise;
|
||||
if (!pending) return false;
|
||||
await pending;
|
||||
@@ -301,8 +313,11 @@ try {
|
||||
ui._locationControls.destroy();
|
||||
ui._locationLookup.destroy();
|
||||
const before = ui._navigationGeneration;
|
||||
ui._locationPills.querySelector('button').click();
|
||||
ui._resetGlobeBtn.click();
|
||||
document
|
||||
.getElementById('location-pills')
|
||||
.querySelector('button')
|
||||
?.click();
|
||||
document.getElementById('reset-globe-view').click();
|
||||
ui._locationSearch.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
|
||||
);
|
||||
|
||||
@@ -69,9 +69,10 @@ try {
|
||||
await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
return (
|
||||
manager._mapStackChips.querySelector('[aria-pressed="true"]')?.dataset
|
||||
.stackId === 'osm' &&
|
||||
manager._mapStackStatus.textContent ===
|
||||
document
|
||||
.getElementById('map-stack-chips')
|
||||
.querySelector('[aria-pressed="true"]')?.dataset.stackId === 'osm' &&
|
||||
document.getElementById('map-stack-status').textContent ===
|
||||
manager.mapStackController.getState().activeStack.shortLabel
|
||||
);
|
||||
}),
|
||||
@@ -81,32 +82,38 @@ try {
|
||||
await page.evaluate(async () => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
const result = await manager.setMapStack('esri-imagery');
|
||||
// The dock renders the published row on Vue's next flush.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const active = manager.mapStackController.getActiveId();
|
||||
return (
|
||||
window.__qaMapSelections.at(-1) === 'esri-imagery' &&
|
||||
manager._mapStackChips.querySelector('[aria-pressed="true"]')?.dataset
|
||||
.stackId === active &&
|
||||
document
|
||||
.getElementById('map-stack-chips')
|
||||
.querySelector('[aria-pressed="true"]')?.dataset.stackId === active &&
|
||||
(result.ok || !!manager.mapStackController.getState().lastError)
|
||||
);
|
||||
}),
|
||||
);
|
||||
check(
|
||||
'refresh revokes retained old chips',
|
||||
await page.evaluate(() => {
|
||||
'refresh re-presents the chips without duplicating them',
|
||||
await page.evaluate(async () => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
const old = manager._mapStackChips.querySelector('[data-stack-id="osm"]');
|
||||
manager._mapSourceControls.refresh();
|
||||
const before = window.__qaMapSelections.length;
|
||||
old.click();
|
||||
return window.__qaMapSelections.length === before;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
return (
|
||||
document.querySelectorAll('#map-stack-chips [data-stack-id="osm"]')
|
||||
.length === 1
|
||||
);
|
||||
}),
|
||||
);
|
||||
check(
|
||||
'refreshed chips still select once',
|
||||
await page.evaluate(async () => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
const before = window.__qaMapSelections.length;
|
||||
manager._mapStackChips.querySelector('[data-stack-id="osm"]').click();
|
||||
document
|
||||
.getElementById('map-stack-chips')
|
||||
.querySelector('[data-stack-id="osm"]')
|
||||
.click();
|
||||
await Promise.resolve();
|
||||
return window.__qaMapSelections.length === before + 1;
|
||||
}),
|
||||
@@ -188,7 +195,10 @@ try {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
const before = window.__qaMapSelections.length;
|
||||
manager._mapSourceControls.destroy();
|
||||
manager._mapStackChips.querySelector('[data-stack-id="osm"]').click();
|
||||
document
|
||||
.getElementById('map-stack-chips')
|
||||
.querySelector('[data-stack-id="osm"]')
|
||||
.click();
|
||||
const result = await manager._mapSourceControls.select('esri-imagery');
|
||||
return result === null && window.__qaMapSelections.length === before;
|
||||
}),
|
||||
|
||||
@@ -871,6 +871,9 @@ try {
|
||||
return originalSetStack(stackId);
|
||||
};
|
||||
const switchPromise = styleManager._setMapStack('osm', { syncShare: false });
|
||||
// The dock renders the published row on Vue's next flush.
|
||||
const rendered = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await rendered();
|
||||
const during = {
|
||||
status: document.getElementById('map-stack-status').textContent,
|
||||
active: [...document.querySelectorAll('.map-stack-chip')]
|
||||
@@ -879,6 +882,7 @@ try {
|
||||
};
|
||||
release();
|
||||
await switchPromise;
|
||||
await rendered();
|
||||
const after = {
|
||||
status: document.getElementById('map-stack-status').textContent,
|
||||
active: [...document.querySelectorAll('.map-stack-chip')]
|
||||
|
||||
+1
-1
@@ -310,7 +310,7 @@ try {
|
||||
countFrames(2_500),
|
||||
page.evaluate(() => new Promise((resolve) => setTimeout(() => {
|
||||
// Drive the actual sharpen slider: input event → handler →
|
||||
// _applySharpenIntensity → governorRequestRender. Proves the wiring,
|
||||
// _setSharpenIntensity → governorRequestRender. Proves the wiring,
|
||||
// not just the facade.
|
||||
const slider = document.getElementById('sharpen-intensity-slider');
|
||||
if (!slider) { resolve({ ok: false }); return; }
|
||||
|
||||
+44
-12
@@ -1100,7 +1100,7 @@ async function main() {
|
||||
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;
|
||||
const installationFalseReleased = document.getElementById('installations-search-btn').getAttribute('aria-busy') === 'false';
|
||||
|
||||
dataManager.setEnabled = async (layerId, shouldEnable) => {
|
||||
if (layerId === 'military-installations' && shouldEnable) {
|
||||
@@ -1111,7 +1111,7 @@ async function main() {
|
||||
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;
|
||||
const installationEnableRejectionReleased = document.getElementById('installations-search-btn').getAttribute('aria-busy') === 'false';
|
||||
|
||||
dataManager.setEnabled = async (layerId, shouldEnable) => {
|
||||
if (layerId === 'military-installations' && shouldEnable) enabled.add(layerId);
|
||||
@@ -1123,13 +1123,13 @@ async function main() {
|
||||
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;
|
||||
const installationRejectionReleased = document.getElementById('installations-search-btn').getAttribute('aria-busy') === 'false';
|
||||
|
||||
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;
|
||||
const installationSearchFalseReleased = document.getElementById('installations-search-btn').getAttribute('aria-busy') === 'false';
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
return {
|
||||
semanticFalseMessage,
|
||||
@@ -1716,7 +1716,14 @@ async function main() {
|
||||
else panel.setAttribute('style', state.style);
|
||||
if (state.ariaHidden === null) panel.removeAttribute('aria-hidden');
|
||||
else panel.setAttribute('aria-hidden', state.ariaHidden);
|
||||
manager._syncPanelCollapseButton(panel);
|
||||
// The restored classes are the panel's state again.
|
||||
const has = (name) => panel.classList.contains(name);
|
||||
manager._presentPanel(panel, {
|
||||
collapsed: has('collapsed'),
|
||||
pinned: has('dock-pinned'),
|
||||
pinnedTop: has('dock-pinned-top'),
|
||||
auto: has('layout-auto-collapsed') ? 'layout' : has('accordion-collapsed') ? 'accordion' : null,
|
||||
});
|
||||
}
|
||||
for (const [id, state] of Object.entries(prior.stacks)) {
|
||||
const stack = document.getElementById(id);
|
||||
@@ -1974,7 +1981,7 @@ async function main() {
|
||||
manager._setCockpitDisplayPortalActive(true);
|
||||
hud.hidden = false;
|
||||
signal.hidden = false;
|
||||
dataPanel.classList.remove('collapsed');
|
||||
manager._presentPanel(dataPanel, { collapsed: false });
|
||||
window.__godsEyeView.styleManager._syncLeftPanelAdaptiveLayout();
|
||||
window.__godsEyeView.styleManager.cockpitView.syncSignalLayout();
|
||||
if (display.getAttribute('aria-expanded') === 'true') display.click();
|
||||
@@ -2059,8 +2066,7 @@ async function main() {
|
||||
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._presentPanel(dataPanel, { collapsed: false, auto: null });
|
||||
manager._syncLeftPanelAdaptiveLayout();
|
||||
await waitForLayout();
|
||||
const panelRect = dataPanel.getBoundingClientRect();
|
||||
@@ -2154,16 +2160,20 @@ async function main() {
|
||||
.every((control) => control.closest('.pp-toggle-group')?.parentElement
|
||||
?.closest('#cockpit-display-panel'));
|
||||
display.click();
|
||||
await waitForLayout();
|
||||
|
||||
manager.cockpitView.syncSignalLayout();
|
||||
radio.click();
|
||||
await waitForLayout();
|
||||
const radioOpened = radio.getAttribute('aria-expanded') === 'true'
|
||||
&& !document.getElementById('cockpit-radio-panel').hidden;
|
||||
const mutuallyExclusive = display.getAttribute('aria-expanded') === 'false';
|
||||
radio.click();
|
||||
await waitForLayout();
|
||||
|
||||
manager.cockpitView.syncSignalLayout();
|
||||
radio.click();
|
||||
await waitForLayout();
|
||||
|
||||
const radioNavigationAvailable = [
|
||||
document.getElementById('cockpit-radio-prev-btn'),
|
||||
@@ -2283,7 +2293,14 @@ async function main() {
|
||||
else panel.setAttribute('style', state.style);
|
||||
if (state.ariaHidden === null) panel.removeAttribute('aria-hidden');
|
||||
else panel.setAttribute('aria-hidden', state.ariaHidden);
|
||||
manager._syncPanelCollapseButton(panel);
|
||||
// The restored classes are the panel's state again.
|
||||
const has = (name) => panel.classList.contains(name);
|
||||
manager._presentPanel(panel, {
|
||||
collapsed: has('collapsed'),
|
||||
pinned: has('dock-pinned'),
|
||||
pinnedTop: has('dock-pinned-top'),
|
||||
auto: has('layout-auto-collapsed') ? 'layout' : has('accordion-collapsed') ? 'accordion' : null,
|
||||
});
|
||||
}
|
||||
manager._panelLayout._leftStackPreferredPanelId = prior.preferredLeftPanelId;
|
||||
localStorage.clear();
|
||||
@@ -2700,14 +2717,20 @@ async function main() {
|
||||
};
|
||||
const playsBefore = window.__qaRadioPlayCalls.length;
|
||||
const frozenSignature = style._radioControls._radioTunerBandSignature;
|
||||
// The tuner renders from the Radio store; let Vue patch before reading.
|
||||
const rendered = () => Promise.resolve();
|
||||
pointer('pointerdown', xFor(0));
|
||||
await rendered();
|
||||
const left = snapshot();
|
||||
const centerCoordinate = (count - 1) / 2;
|
||||
pointer('pointermove', xFor(centerCoordinate));
|
||||
await rendered();
|
||||
const center = snapshot();
|
||||
pointer('pointermove', xFor(centerCoordinate + 2));
|
||||
await rendered();
|
||||
const shifted = snapshot();
|
||||
pointer('pointermove', xFor(count - 1));
|
||||
await rendered();
|
||||
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));
|
||||
@@ -2764,7 +2787,7 @@ async function main() {
|
||||
tapeDelta: absoluteTuner.tapeDelta,
|
||||
}),
|
||||
);
|
||||
const tunerCancelRestore = await page.evaluate(() => {
|
||||
const tunerCancelRestore = await page.evaluate(async () => {
|
||||
const gev = window.__godsEyeView;
|
||||
const style = gev.styleManager;
|
||||
const radio = gev.dataManager.layers.get('radio').module;
|
||||
@@ -2797,11 +2820,14 @@ async function main() {
|
||||
const targetIndex = before.slot < beforeStations.length - 2 ? before.slot + 2 : before.slot - 2;
|
||||
pointer('pointerdown', before.slot);
|
||||
pointer('pointermove', targetIndex);
|
||||
// The tuner renders from the Radio store; let Vue patch before reading.
|
||||
await Promise.resolve();
|
||||
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);
|
||||
await Promise.resolve();
|
||||
const afterStations = style._radioControls._radioTunerStations;
|
||||
const afterState = radio.getUIState();
|
||||
const after = {
|
||||
@@ -2957,7 +2983,7 @@ async function main() {
|
||||
await page.evaluate(async () => {
|
||||
await window.__godsEyeView.dataManager.layers.get('radio').module.update();
|
||||
});
|
||||
const tunerRefreshRelease = await page.evaluate((target) => {
|
||||
const tunerRefreshRelease = await page.evaluate(async (target) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const slider = document.getElementById('radio-tuner-slider');
|
||||
const displayedBeforeRelease = document.getElementById('radio-tuner-station').textContent;
|
||||
@@ -2970,6 +2996,10 @@ async function main() {
|
||||
clientY: target.clientY,
|
||||
}));
|
||||
const state = gev.dataManager.layers.get('radio').module.getUIState();
|
||||
// The playback line renders from the Radio store once Vue flushes.
|
||||
const message = await Promise.resolve().then(
|
||||
() => document.getElementById('radio-playback-state').textContent,
|
||||
);
|
||||
return {
|
||||
displayedBeforeRelease,
|
||||
frozenGeneration,
|
||||
@@ -2978,7 +3008,7 @@ async function main() {
|
||||
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,
|
||||
message,
|
||||
targetId: target.id,
|
||||
};
|
||||
}, tunerRefreshTarget);
|
||||
@@ -3025,6 +3055,8 @@ async function main() {
|
||||
pointer('pointermove', targetIndex);
|
||||
const previewPlays = window.__qaRadioPlayCalls.length;
|
||||
pointer('pointerup', targetIndex);
|
||||
// The tuner renders from the Radio store; sample once Vue has patched.
|
||||
await Promise.resolve();
|
||||
const samples = [needle.getBoundingClientRect().left];
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
samples.push(needle.getBoundingClientRect().left);
|
||||
|
||||
@@ -106,6 +106,39 @@ try {
|
||||
),
|
||||
),
|
||||
);
|
||||
// A long rename keeps its editor visible inside the shot row.
|
||||
await page.click('.scene-shot-label', { count: 2 });
|
||||
await page.waitForSelector('.scene-shot-rename', { visible: true });
|
||||
await page.type(
|
||||
'.scene-shot-rename',
|
||||
' with a much longer title that runs well past the shot row',
|
||||
);
|
||||
await page.keyboard.press('End');
|
||||
check(
|
||||
'A long rename keeps the editor visible inside its shot row',
|
||||
await page.evaluate(() => {
|
||||
// The label clips with an ellipsis, which hides an editor that
|
||||
// overflows it, so its content must fit its width.
|
||||
const input = document.querySelector('.scene-shot-rename');
|
||||
const label = input?.closest('.scene-shot-label');
|
||||
const box = input?.getBoundingClientRect();
|
||||
const style = input && getComputedStyle(input);
|
||||
return (
|
||||
!!box &&
|
||||
box.width > 120 &&
|
||||
box.height > 10 &&
|
||||
label.scrollWidth <= label.clientWidth + 1 &&
|
||||
style.visibility === 'visible' &&
|
||||
style.borderTopWidth !== '0px' &&
|
||||
document.activeElement === input
|
||||
);
|
||||
}),
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('.scene-shot-rename'),
|
||||
);
|
||||
|
||||
await page.click('#scene-capture-btn');
|
||||
await page.waitForFunction(
|
||||
() => document.querySelectorAll('.scene-shot-row').length === 2,
|
||||
@@ -409,12 +442,12 @@ try {
|
||||
return '{"scenes":[]}';
|
||||
},
|
||||
});
|
||||
// A shot row held across disposal must not act once it is revoked.
|
||||
const retainedLabel = document.querySelector('.scene-shot-label');
|
||||
const stopping = director.destroy();
|
||||
const notificationsAtStop = window.__qaSceneState.length;
|
||||
const stoppedSynchronously =
|
||||
controls.destroyed &&
|
||||
controls.removers.length === 0 &&
|
||||
controls.rowRemovers.length === 0;
|
||||
const stoppedSynchronously = controls.destroyed;
|
||||
retainedLabel?.click();
|
||||
document.getElementById('scene-new-btn').click();
|
||||
controls.run('capture');
|
||||
finish();
|
||||
@@ -423,6 +456,7 @@ try {
|
||||
await director.destroy();
|
||||
return {
|
||||
stoppedSynchronously,
|
||||
heldRowAction: Boolean(retainedLabel),
|
||||
stateStopped: window.__qaSceneState.length === notificationsAtStop,
|
||||
sameProject: director._project === project,
|
||||
noLateStatus:
|
||||
@@ -432,7 +466,9 @@ try {
|
||||
});
|
||||
check(
|
||||
'Disposal revokes fixed and row actions before waiting for scene work',
|
||||
teardown.stoppedSynchronously && teardown.rowsGone,
|
||||
teardown.stoppedSynchronously &&
|
||||
teardown.heldRowAction &&
|
||||
teardown.rowsGone,
|
||||
);
|
||||
check(
|
||||
'Late import completion cannot replace the disposed project or status',
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* file dropped from the desktop, proved in a real browser.
|
||||
*
|
||||
* The dialog draws a translated row over the native file input
|
||||
* (src/ui/sceneSharing.js, fileChooser). The input must still cover the row,
|
||||
* (src/ui/views/panels/SceneDialog.vue). The input must still cover the row,
|
||||
* or drops miss it. Drops a file on the row's centre via CDP and expects
|
||||
* `input.files` to hold it.
|
||||
*
|
||||
@@ -15,31 +15,34 @@
|
||||
* drop selects the file, 1 otherwise.
|
||||
*/
|
||||
import http from 'node:http';
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { createServer } from 'vite';
|
||||
import { VUE_PLUGIN_OPTIONS } from '../build/vueOptions.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const TYPES = {
|
||||
'.js': 'text/javascript',
|
||||
'.mjs': 'text/javascript',
|
||||
'.json': 'application/json',
|
||||
'.html': 'text/html',
|
||||
};
|
||||
|
||||
// The scene panels view draws the dialog; Vite compiles it for the page.
|
||||
const PAGE = `<!doctype html><html lang="en"><body>
|
||||
<div id="view"></div>
|
||||
<script type="module">
|
||||
import { createApp, nextTick } from 'vue';
|
||||
import { applicationPinia } from '/src/ui/vueRuntime.js';
|
||||
import LayerPanelsView from '/src/ui/views/LayerPanelsView.vue';
|
||||
import { createSceneDialog } from '/src/ui/sceneSharing.js';
|
||||
window.openDialog = () => {
|
||||
createApp(LayerPanelsView).use(applicationPinia()).mount('#view');
|
||||
window.openDialog = async () => {
|
||||
const dialog = createSceneDialog('Share selected scene', () => {});
|
||||
const files = dialog.input('Choose data-pack files', '', { type: 'file' });
|
||||
files.multiple = true;
|
||||
window.filesInput = files;
|
||||
// Centre of the visible row (the wrapper's last child).
|
||||
const row = files.parentElement.lastElementChild;
|
||||
const r = row.getBoundingClientRect();
|
||||
dialog.input('Choose data-pack files', '', { type: 'file', multiple: true });
|
||||
await nextTick();
|
||||
const input = document.querySelector('[data-director-dialog] input[type=file]');
|
||||
window.filesInput = input;
|
||||
// Centre of the visible row (the label's last child).
|
||||
const r = input.parentElement.lastElementChild.getBoundingClientRect();
|
||||
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
|
||||
};
|
||||
window.ready = true;
|
||||
@@ -56,21 +59,21 @@ async function main() {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'qa-scene-dialog-drop-'));
|
||||
const pack = path.join(dir, 'pack.json');
|
||||
await writeFile(pack, '{}');
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const vite = await createServer({
|
||||
root: ROOT,
|
||||
configFile: false,
|
||||
logLevel: 'error',
|
||||
plugins: [vue(VUE_PLUGIN_OPTIONS)],
|
||||
server: { middlewareMode: true, hmr: false },
|
||||
appType: 'custom',
|
||||
});
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === '/') {
|
||||
res.writeHead(200, { 'content-type': 'text/html' });
|
||||
res.end(PAGE);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const body = await readFile(path.join(ROOT, req.url));
|
||||
res.writeHead(200, {
|
||||
'content-type': TYPES[path.extname(req.url)] || 'text/plain',
|
||||
});
|
||||
res.end(body);
|
||||
} catch {
|
||||
res.writeHead(404).end();
|
||||
}
|
||||
vite.middlewares(req, res, () => res.writeHead(404).end());
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const url = `http://127.0.0.1:${server.address().port}/`;
|
||||
@@ -99,6 +102,7 @@ async function main() {
|
||||
} finally {
|
||||
await browser.close();
|
||||
server.close();
|
||||
await vite.close();
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,8 +69,6 @@ try {
|
||||
calDirty: false,
|
||||
},
|
||||
});
|
||||
window.__qaBadgeText =
|
||||
document.getElementById('cctv-source-badge').textContent;
|
||||
});
|
||||
await page.waitForSelector('.weather-coverage.gev-status-badge');
|
||||
const inventory = await page.evaluate(() => {
|
||||
@@ -120,20 +118,22 @@ try {
|
||||
);
|
||||
|
||||
for (const state of ['loading', 'error', 'retained', 'ready', 'idle']) {
|
||||
const result = await page.evaluate((state) => {
|
||||
const result = await page.evaluate(async (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',
|
||||
);
|
||||
const frame = owner._frame;
|
||||
owner._frame = {
|
||||
...frame,
|
||||
loading: state === 'loading',
|
||||
error: ['error', 'retained'].includes(state),
|
||||
hasFrame: state === 'retained',
|
||||
};
|
||||
owner._syncCctvSourceBadge(
|
||||
owner._cctvState.activeCamera,
|
||||
state !== 'idle',
|
||||
);
|
||||
owner._frame = frame;
|
||||
// The panel view renders the badge on Vue's next flush.
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
const badge = document.getElementById('cctv-source-badge');
|
||||
return {
|
||||
state: badge.dataset.frameState,
|
||||
@@ -164,7 +164,7 @@ try {
|
||||
['curated', true, 'rgb(255, 208, 138)'],
|
||||
]) {
|
||||
const result = await page.evaluate(
|
||||
({ calBadge, dirty }) => {
|
||||
async ({ calBadge, dirty }) => {
|
||||
const owner = window.__godsEyeView.styleManager._cctvControls;
|
||||
document.getElementById('cctv-next-btn').focus();
|
||||
owner._renderCctvState({
|
||||
@@ -175,6 +175,8 @@ try {
|
||||
calDirty: dirty,
|
||||
},
|
||||
});
|
||||
// The panel view renders the chip on Vue's next flush.
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
const badge = document.getElementById('cctv-quality-chip');
|
||||
return {
|
||||
state: badge.dataset.calBadge,
|
||||
|
||||
@@ -40,6 +40,8 @@ try {
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
// The title bar renders the readout on Vue's next flush.
|
||||
await Promise.resolve();
|
||||
const fpsReadout = document.querySelector('.frame-rate-readout');
|
||||
const fpsWasVisible = fpsReadout && !fpsReadout.hidden;
|
||||
const watchObserver = (name) => {
|
||||
@@ -118,10 +120,18 @@ try {
|
||||
if (type === 'resize' && callback === resizeHandler) counts.resize++;
|
||||
return removeEventListener.call(this, type, callback, options);
|
||||
};
|
||||
const { setSplitFlapText } = await import('/src/splitFlap.js');
|
||||
// Through the owner, so its copy of the label and the store agree.
|
||||
const feedbackLabel = document.getElementById('global-loading-label');
|
||||
setSplitFlapText(feedbackLabel, 'CHECKING LIVE DATA');
|
||||
setSplitFlapText(feedbackLabel, 'LOAD COMPLETE');
|
||||
for (const label of ['CHECKING LIVE DATA', 'LOAD COMPLETE']) {
|
||||
ui._feedback._publishLoading({
|
||||
hidden: false,
|
||||
state: 'loading',
|
||||
label,
|
||||
detail: '',
|
||||
});
|
||||
// Vue renders on a microtask; a task later the label shows it.
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
}
|
||||
const permanentText =
|
||||
feedbackLabel.querySelector('.gev-flap-text')?.firstChild;
|
||||
let stateNotifications = 0;
|
||||
@@ -202,6 +212,7 @@ try {
|
||||
feedbackLabel.querySelector('.gev-flap-text')?.firstChild ===
|
||||
permanentText &&
|
||||
permanentText.data === 'LOAD COMPLETE' &&
|
||||
document.getElementById('global-loading-status').hidden &&
|
||||
!feedbackLabel.classList.contains('gev-flap-active') &&
|
||||
feedbackLabel.querySelector('.gev-flap-cells')?.childNodes.length ===
|
||||
0,
|
||||
@@ -211,7 +222,9 @@ try {
|
||||
ui._lifetime.timers.size === 0 &&
|
||||
ui._lifetime.removers.size === 0,
|
||||
frameRateReleased:
|
||||
fpsWasVisible && !fpsReadout.isConnected && fpsReadout.hidden,
|
||||
fpsWasVisible &&
|
||||
!fpsReadout.isConnected &&
|
||||
!document.querySelector('.frame-rate-readout'),
|
||||
stateStopped: stateNotifications === 0,
|
||||
idempotent: once === JSON.stringify(counts),
|
||||
};
|
||||
|
||||
@@ -57,7 +57,7 @@ try {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
manager._sliderPanel.classList.remove('collapsed');
|
||||
manager._presentPanel(manager._sliderPanel, { collapsed: false });
|
||||
const slider = manager._sliderContainer.querySelector('input');
|
||||
window.__qaOldSlider = slider;
|
||||
const name = 'pixelation';
|
||||
@@ -140,9 +140,11 @@ try {
|
||||
hudBefore,
|
||||
),
|
||||
);
|
||||
const displayBefore = await page.evaluate(() => {
|
||||
const displayBefore = await page.evaluate(async () => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
manager._setBloomEnabled(true);
|
||||
// The Display view shows the slider row on its next render.
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
manager._bloomSlider.value = '80';
|
||||
manager._bloomSlider.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
manager._bloomSlider.focus();
|
||||
@@ -263,7 +265,7 @@ try {
|
||||
'destroyed Display controls cannot change settings',
|
||||
await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
manager._displayControls.destroy();
|
||||
manager._displayBindings._detachDisplayControls();
|
||||
const before = manager.bloomEnabled;
|
||||
const contrast = manager._bloomStage.uniforms.contrast;
|
||||
manager._bloomBtn.click();
|
||||
|
||||
@@ -12,10 +12,14 @@ import { after, before, test } from 'node:test';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const root = new URL('../', import.meta.url);
|
||||
const template = await readFile(
|
||||
new URL('src/ui/templates/provider-settings.html', root),
|
||||
const view = await readFile(
|
||||
new URL('src/ui/views/ProviderSettingsView.vue', root),
|
||||
'utf8',
|
||||
);
|
||||
const template = view.slice(
|
||||
view.indexOf('<template>') + '<template>'.length,
|
||||
view.lastIndexOf('</template>'),
|
||||
);
|
||||
const assets = new Set([
|
||||
'/src/keySetup.js',
|
||||
'/src/voice/cloudVoiceAuth.js',
|
||||
|
||||
@@ -1023,7 +1023,7 @@ async function runDeixisBehavior(page, run, settle) {
|
||||
};
|
||||
const beginAt = (x, y, said = null) =>
|
||||
page.evaluate(
|
||||
(px, py, text) => {
|
||||
async (px, py, text) => {
|
||||
const voice = window.__gevVoiceCommands;
|
||||
if (text) {
|
||||
// What the session emits for a spoken turn: a new turn, then the caption.
|
||||
@@ -1043,6 +1043,8 @@ async function runDeixisBehavior(page, run, settle) {
|
||||
voice.pointer.hold('keydown');
|
||||
const snapshot = voice.beginPointerTurn('keydown');
|
||||
voice.pointer.releaseHold();
|
||||
// The voice card and the reticle render on Vue's next flush.
|
||||
await Promise.resolve();
|
||||
const chip = document.getElementById('gev-voice-card-pointer');
|
||||
const reticle = document.querySelector('.gev-pointer-reticle');
|
||||
return {
|
||||
|
||||
@@ -261,7 +261,7 @@ try {
|
||||
for (const [id, entry] of gev.dataManager.layers)
|
||||
if (entry.enabled)
|
||||
await gev.dataManager.setEnabled(id, false, { origin: 'user' });
|
||||
document.querySelector('#data-panel')?.classList.remove('collapsed');
|
||||
gev.styleManager._presentPanel('data-panel', { collapsed: false });
|
||||
});
|
||||
|
||||
// 1. Earth view, clean.
|
||||
|
||||
@@ -71,8 +71,14 @@ export function allocationTestArgs(file) {
|
||||
return ['--expose-gc', '--test', '--test-concurrency=1', file];
|
||||
}
|
||||
|
||||
/** Node flags every unit-test process receives. */
|
||||
export const UNIT_TEST_NODE_FLAGS = Object.freeze([
|
||||
// Single-file components compile on import.
|
||||
'--import=./build/registerVue.js',
|
||||
]);
|
||||
|
||||
function runTests(args) {
|
||||
const result = spawnSync(process.execPath, args, {
|
||||
const result = spawnSync(process.execPath, [...UNIT_TEST_NODE_FLAGS, ...args], {
|
||||
cwd: process.cwd(),
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
|
||||
@@ -612,6 +612,7 @@ async function main() {
|
||||
if (models3dButton) {
|
||||
if (!models3dButton.classList.contains('active')) models3dButton.click();
|
||||
models3dButton.click(); // → OFF: both layers must follow the one control
|
||||
await new Promise((resolve) => setTimeout(resolve)); // the view renders
|
||||
models3dOff.button = models3dButton.classList.contains('active');
|
||||
models3dOff.fl = fl.getParams().models3d;
|
||||
models3dOff.mil = mil.getParams().models3d;
|
||||
|
||||
Reference in new issue
Block a user