mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
fix: preserve keyboard focus and separate Space activation from voice
Keep focus visible across controls and stable during Contacts, Cockpit and mission roster updates. Preserve native short Space activation, and claim push-to-talk only after a 500 ms hold. Add panel Escape handling and keyboard-accessible attribution controls. Retains the Map Source foundation contributed in https://github.com/bilawalsidhu/gods-eye-view/pull/171.
This commit is contained in:
1 parent
c82fdd7478
commit
5228ab9e40
22 files changed
+3500
-157
No files matched your search
@@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const shotsDir = path.join(repoRoot, 'qa-shots', 'map-source-tray');
|
||||
const shotsDir = process.env.QA_SHOTS_DIR || path.join(repoRoot, 'qa-shots', 'map-source-tray');
|
||||
const appUrl = process.env.QA_BASE_URL || 'http://localhost:4173';
|
||||
const headful = process.argv.includes('--headful');
|
||||
// The no-ion-token contract is a real shipped state that a normal keyed
|
||||
@@ -91,6 +91,143 @@ const trayMetrics = () => page.evaluate(() => {
|
||||
};
|
||||
});
|
||||
|
||||
// Observe rendered focus, including clipping by the scrolling style row. A
|
||||
// focus-visible match alone does not prove that the keyboard user sees a ring.
|
||||
const focusMetrics = (selector = '.style-btn') => page.evaluate((match) => {
|
||||
const button = document.activeElement;
|
||||
if (!button?.matches(match)) return { focusedStyle: null, focusedId: null };
|
||||
const rect = button.getBoundingClientRect();
|
||||
const css = getComputedStyle(button);
|
||||
const outlineWidth = parseFloat(css.outlineWidth) || 0;
|
||||
const outlineOffset = parseFloat(css.outlineOffset) || 0;
|
||||
const outset = Math.max(0, outlineWidth + outlineOffset);
|
||||
const ring = {
|
||||
left: rect.left - outset, right: rect.right + outset,
|
||||
top: rect.top - outset, bottom: rect.bottom + outset,
|
||||
};
|
||||
const clips = [];
|
||||
for (let parent = button.parentElement; parent; parent = parent.parentElement) {
|
||||
const parentCss = getComputedStyle(parent);
|
||||
const parentRect = parent.getBoundingClientRect();
|
||||
const left = parentRect.left + parent.clientLeft;
|
||||
const top = parentRect.top + parent.clientTop;
|
||||
if (/auto|scroll|hidden|clip/.test(parentCss.overflowX)
|
||||
&& (ring.left < left - 1 || ring.right > left + parent.clientWidth + 1)) {
|
||||
clips.push(`${parent.id || parent.className}:horizontal`);
|
||||
}
|
||||
if (/auto|scroll|hidden|clip/.test(parentCss.overflowY)
|
||||
&& (ring.top < top - 1 || ring.bottom > top + parent.clientHeight + 1)) {
|
||||
clips.push(`${parent.id || parent.className}:vertical`);
|
||||
}
|
||||
}
|
||||
const center = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2);
|
||||
return {
|
||||
focusedStyle: button.dataset.style, focusedId: button.id || null, tag: button.tagName, type: button.type,
|
||||
layerId: button.closest('[data-layer-id]')?.dataset.layerId || null,
|
||||
text: button.textContent.trim(), ariaLabel: button.getAttribute('aria-label'),
|
||||
controls: button.getAttribute('aria-controls'), expanded: button.getAttribute('aria-expanded'),
|
||||
selectedStyle: window.__godsEyeView.styleManager.activeStyle,
|
||||
selectedMap: window.__godsEyeView.styleManager.mapStackController.getActiveId(),
|
||||
focusVisible: button.matches(':focus-visible'),
|
||||
selected: button.classList.contains('active'),
|
||||
outlineStyle: css.outlineStyle, outlineWidth, outlineOffset, outlineColor: css.outlineColor,
|
||||
visible: rect.width > 0 && rect.height > 0 && css.visibility === 'visible'
|
||||
&& (center === button || button.contains(center)),
|
||||
insideViewport: ring.left >= 0 && ring.right <= innerWidth
|
||||
&& ring.top >= 0 && ring.bottom <= innerHeight,
|
||||
clips,
|
||||
ring,
|
||||
viewport: { width: innerWidth, height: innerHeight },
|
||||
rowScrollLeft: document.getElementById('style-buttons').scrollLeft,
|
||||
};
|
||||
}, selector);
|
||||
const styleFocusMetrics = () => focusMetrics('.style-btn');
|
||||
const hasVisibleControlFocus = (state) => state.focusVisible && state.visible && state.insideViewport && state.clips.length === 0
|
||||
&& state.outlineStyle !== 'none' && state.outlineStyle !== 'hidden' && state.outlineWidth >= 2
|
||||
&& state.outlineColor !== 'transparent' && state.outlineColor !== 'rgba(0, 0, 0, 0)';
|
||||
const hasVisibleStyleFocus = (state, style) => state.focusedStyle === style && hasVisibleControlFocus(state);
|
||||
const pressTabs = async (count, backwards = false) => {
|
||||
if (backwards) await page.keyboard.down('Shift');
|
||||
try {
|
||||
for (let index = 0; index < count; index += 1) await page.keyboard.press('Tab');
|
||||
} finally {
|
||||
if (backwards) await page.keyboard.up('Shift');
|
||||
}
|
||||
// The row scrolls smoothly when native Tab brings an edge button into view.
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
};
|
||||
const checkResponsiveStyleFocus = async (width) => {
|
||||
// The disclosure is only the starting boundary. Every style target below is
|
||||
// reached by actual Tab events, never by focusing the style under test.
|
||||
const selectedMapBefore = await page.evaluate(() => (
|
||||
window.__godsEyeView.styleManager.mapStackController.getActiveId()
|
||||
));
|
||||
await page.focus('#control-panel-toggle');
|
||||
await pressTabs(2); // disclosure -> pin -> Normal
|
||||
for (const [index, style] of ['normal', 'thermal', 'snow'].entries()) {
|
||||
if (index) await pressTabs(3);
|
||||
const state = await styleFocusMetrics();
|
||||
check(`${width} px ${style} keyboard ring is visible and unclipped`,
|
||||
hasVisibleStyleFocus(state, style) && state.selectedStyle === 'normal'
|
||||
&& state.selectedMap === selectedMapBefore, JSON.stringify(state));
|
||||
await page.screenshot({ path: path.join(shotsDir, `${width}-style-${style}-focus.png`) });
|
||||
}
|
||||
};
|
||||
|
||||
// Start from an explicit boundary, then use real Tab events for every target.
|
||||
// No layer or location action is activated merely to establish keyboard focus.
|
||||
const tabTo = async (selector, { backwards = false, limit = 160 } = {}) => {
|
||||
if (backwards) await page.keyboard.down('Shift');
|
||||
try {
|
||||
for (let step = 1; step <= limit; step += 1) {
|
||||
await page.keyboard.press('Tab');
|
||||
if (await page.evaluate((match) => document.activeElement?.matches(match), selector)) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
return { reached: true, steps: step };
|
||||
}
|
||||
}
|
||||
return { reached: false, steps: limit };
|
||||
} finally {
|
||||
if (backwards) await page.keyboard.up('Shift');
|
||||
}
|
||||
};
|
||||
const locationState = async () => ({
|
||||
...await focusMetrics('#location-bar-toggle'),
|
||||
...await page.evaluate(() => {
|
||||
const panel = document.getElementById('location-bar');
|
||||
const toggle = document.getElementById('location-bar-toggle');
|
||||
const voice = window.__godsEyeView.voiceCommands;
|
||||
return {
|
||||
locationExpanded: toggle?.getAttribute('aria-expanded'),
|
||||
locationLabel: toggle?.getAttribute('aria-label'),
|
||||
popoverId: panel.querySelector('.dock-popover-content')?.id,
|
||||
pinned: panel.classList.contains('dock-pinned'),
|
||||
searchExpanded: document.getElementById('location-search').classList.contains('expanded'),
|
||||
searchValue: document.getElementById('location-search').value,
|
||||
transitions: [...(window.__qaLocationFocus?.transitions || [])],
|
||||
voice: { status: voice.status, epoch: voice.startEpoch, held: voice.spaceKeyHeld, pushToTalk: voice.pushToTalkKeyHeld },
|
||||
};
|
||||
}),
|
||||
});
|
||||
const resetLocationTransitions = () => page.evaluate(() => { window.__qaLocationFocus.transitions = []; });
|
||||
const layerFocusState = async (id) => ({
|
||||
...await focusMetrics('.data-toggle-btn'),
|
||||
...await page.evaluate((layerId) => {
|
||||
const manager = window.__godsEyeView.dataManager;
|
||||
const button = document.querySelector(`[data-layer-id="${layerId}"] .data-toggle-btn`);
|
||||
const list = document.getElementById('data-toggles');
|
||||
return {
|
||||
lifecycle: manager.getLayerLifecycleState(layerId),
|
||||
feedState: button?.dataset.feedState, label: button?.textContent.trim(),
|
||||
buttonActive: button?.classList.contains('active'),
|
||||
disabled: button?.disabled,
|
||||
ariaDisabled: button?.getAttribute('aria-disabled'),
|
||||
ariaBusy: button?.getAttribute('aria-busy'),
|
||||
listScrollTop: list.scrollTop, listClientHeight: list.clientHeight, listScrollHeight: list.scrollHeight,
|
||||
};
|
||||
}, id),
|
||||
});
|
||||
|
||||
try {
|
||||
await page.setViewport({ width: 1000, height: 900, deviceScaleFactor: 1 });
|
||||
await page.setRequestInterception(true);
|
||||
@@ -150,6 +287,12 @@ try {
|
||||
const styleManager = window.__godsEyeView.styleManager;
|
||||
const controller = styleManager.mapStackController;
|
||||
await styleManager._setMapStack('esri-imagery', { syncShare: false });
|
||||
// Cesium updates on-screen credits on a rendered frame, after setStack
|
||||
// resolves. Observe that boundary before checking the visible source.
|
||||
const creditDeadline = performance.now() + 5000;
|
||||
while (!document.body.innerText.includes('Powered by Esri') && performance.now() < creditDeadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
const provider = controller._activeImageryProvider;
|
||||
const before = {
|
||||
activeId: controller.getActiveId(),
|
||||
@@ -229,7 +372,14 @@ try {
|
||||
wantExpanded, wantFocus,
|
||||
).catch(() => {});
|
||||
|
||||
const keyboardSource = await page.evaluate(() => window.__godsEyeView.styleManager.mapStackController.getActiveId());
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager._setMapStack('osm', { syncShare: false }));
|
||||
const keyboardSource = await page.evaluate(() => (
|
||||
window.__godsEyeView.styleManager.mapStackController.getActiveId()
|
||||
));
|
||||
check('keyboard checks start on selected OSM as the last tile',
|
||||
keyboardSource === 'osm' && await page.evaluate(() => (
|
||||
document.querySelector('#map-stack-chips .map-stack-chip:last-child')?.dataset.stackId === 'osm'
|
||||
)));
|
||||
await page.focus('#control-panel-toggle');
|
||||
await page.keyboard.press('Enter');
|
||||
await waitTray('true', keyboardSource);
|
||||
@@ -328,11 +478,221 @@ try {
|
||||
check('Tab away during the opening transition revokes delayed focus', departure && focusRetained,
|
||||
JSON.stringify({ departure, focusRetained }));
|
||||
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('control-panel', true));
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('control-panel', true, { explicit: true }));
|
||||
await page.focus('#control-panel-toggle');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.press('Escape');
|
||||
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||
check('Escape cancels the pending opening handoff', await page.evaluate(() => (
|
||||
document.activeElement === document.body
|
||||
&& document.getElementById('control-panel').classList.contains('collapsed')
|
||||
)));
|
||||
await page.focus('#control-panel-toggle');
|
||||
await page.keyboard.press('Enter');
|
||||
await waitTray('true', keyboardSource);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await page.focus('#control-panel-toggle');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.down('Shift');
|
||||
await page.keyboard.press('Tab');
|
||||
await page.keyboard.up('Shift');
|
||||
const leftBeforeReturn = await page.evaluate(() => document.activeElement?.id !== 'control-panel-toggle');
|
||||
await page.focus('#control-panel-toggle');
|
||||
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||
check('returning to the disclosure does not revive a cancelled keyboard opening',
|
||||
leftBeforeReturn && await page.evaluate(() => document.activeElement?.id === 'control-panel-toggle'));
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await page.focus('#control-panel-toggle');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.press('Escape');
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('control-panel', false));
|
||||
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||
check('programmatic reopening cannot inherit a cancelled keyboard handoff', await page.evaluate(() => (
|
||||
document.activeElement === document.body
|
||||
&& document.getElementById('control-panel-toggle').getAttribute('aria-expanded') === 'true'
|
||||
)));
|
||||
await page.focus('#control-panel-toggle');
|
||||
await page.keyboard.press('Escape');
|
||||
await page.focus('#control-panel-toggle');
|
||||
await page.keyboard.press('Enter');
|
||||
await waitTray('true', keyboardSource);
|
||||
|
||||
// Visual Styles precede the five Map Source tiles. Keep the existing opening
|
||||
// handoff to selected OSM, then use the user's real Shift+Tab path into styles.
|
||||
await pressTabs(5, true);
|
||||
const snowFocus = await styleFocusMetrics();
|
||||
check('Shift+Tab reaches Snow with a visible ring while Normal and OSM stay selected',
|
||||
hasVisibleStyleFocus(snowFocus, 'snow') && !snowFocus.selected
|
||||
&& snowFocus.selectedStyle === 'normal' && snowFocus.selectedMap === 'osm',
|
||||
JSON.stringify(snowFocus));
|
||||
await page.screenshot({ path: path.join(shotsDir, 'keyboard-style-focus.png') });
|
||||
await pressTabs(3, true);
|
||||
const middleFocus = await styleFocusMetrics();
|
||||
check('Shift+Tab traverses the middle FLIR style without activating it',
|
||||
hasVisibleStyleFocus(middleFocus, 'thermal') && !middleFocus.selected
|
||||
&& middleFocus.selectedStyle === 'normal' && middleFocus.selectedMap === 'osm',
|
||||
JSON.stringify(middleFocus));
|
||||
await pressTabs(2, true);
|
||||
await page.keyboard.press('Enter');
|
||||
const enterStyle = await styleFocusMetrics();
|
||||
check('Enter activates the focused CRT style and preserves selected OSM',
|
||||
enterStyle.focusedStyle === 'retro' && enterStyle.selectedStyle === 'retro'
|
||||
&& enterStyle.selected && enterStyle.selectedMap === 'osm', JSON.stringify(enterStyle));
|
||||
await pressTabs(1, true);
|
||||
const normalFocus = await styleFocusMetrics();
|
||||
check('the first Normal style has a focus ring independent from selected CRT',
|
||||
hasVisibleStyleFocus(normalFocus, 'normal') && !normalFocus.selected
|
||||
&& normalFocus.selectedStyle === 'retro' && normalFocus.selectedMap === 'osm',
|
||||
JSON.stringify(normalFocus));
|
||||
|
||||
// Count real native clicks and calls into the unchanged style implementation.
|
||||
// Stub only the provider start seam so timing and focus arbitration can be
|
||||
// exercised without opening a live microphone session during browser QA.
|
||||
await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
const voice = window.__godsEyeView.voiceCommands;
|
||||
const grid = document.getElementById('style-buttons');
|
||||
const root = document.getElementById('gev-voice-control');
|
||||
const originalSetStyle = manager.setStyle;
|
||||
const originalVoiceStart = voice.start;
|
||||
const probe = { events: [], activations: [], voiceMutations: [], voiceStarts: [] };
|
||||
const voiceState = () => ({
|
||||
present: Boolean(voice && root), status: voice?.status, startEpoch: voice?.startEpoch,
|
||||
spaceKeyHeld: voice?.spaceKeyHeld, pushToTalkKeyHeld: voice?.pushToTalkKeyHeld,
|
||||
pushToTalkMode: voice?.pushToTalkMode, radioVoiceDucked: voice?.radioVoiceDucked,
|
||||
visibleStatus: root?.dataset.status, visibleHold: root?.dataset.pushToTalk || null,
|
||||
});
|
||||
const record = (event) => probe.events.push({
|
||||
type: event.type, code: event.code || null, repeat: event.repeat || false,
|
||||
trusted: event.isTrusted, detail: event.detail ?? null,
|
||||
style: event.target.closest('.style-btn')?.dataset.style || null,
|
||||
});
|
||||
for (const type of ['keydown', 'keyup', 'click']) grid.addEventListener(type, record, true);
|
||||
manager.setStyle = function (...args) {
|
||||
probe.activations.push(args[0]);
|
||||
return originalSetStyle.apply(this, args);
|
||||
};
|
||||
voice.start = async function (options) {
|
||||
probe.voiceStarts.push({
|
||||
options: { ...options },
|
||||
focusedStyle: document.activeElement?.dataset.style || null,
|
||||
activeTag: document.activeElement?.tagName || null,
|
||||
});
|
||||
};
|
||||
const observer = new MutationObserver((records) => {
|
||||
for (const record of records) probe.voiceMutations.push({
|
||||
attribute: record.attributeName, oldValue: record.oldValue,
|
||||
value: root.getAttribute(record.attributeName),
|
||||
});
|
||||
});
|
||||
if (root) observer.observe(root, {
|
||||
attributes: true, attributeOldValue: true, attributeFilter: ['data-status', 'data-push-to-talk'],
|
||||
});
|
||||
probe.voiceBefore = voiceState();
|
||||
probe.snapshot = () => ({
|
||||
events: [...probe.events], activations: [...probe.activations], voiceMutations: [...probe.voiceMutations],
|
||||
voiceStarts: [...probe.voiceStarts],
|
||||
voiceBefore: probe.voiceBefore, voiceNow: voiceState(),
|
||||
selectedStyle: manager.activeStyle, selectedMap: manager.mapStackController.getActiveId(),
|
||||
focusedStyle: document.activeElement?.dataset.style || null,
|
||||
});
|
||||
probe.reset = () => {
|
||||
probe.events.length = 0;
|
||||
probe.activations.length = 0;
|
||||
probe.voiceMutations.length = 0;
|
||||
probe.voiceStarts.length = 0;
|
||||
probe.voiceBefore = voiceState();
|
||||
};
|
||||
probe.restore = () => {
|
||||
manager.setStyle = originalSetStyle;
|
||||
voice.start = originalVoiceStart;
|
||||
observer.disconnect();
|
||||
for (const type of ['keydown', 'keyup', 'click']) grid.removeEventListener(type, record, true);
|
||||
};
|
||||
window.__qaStyleKeyProbe = probe;
|
||||
});
|
||||
let shortSpaceDown;
|
||||
let shortSpaceReleased;
|
||||
let longSpaceDown;
|
||||
let longSpaceHeld;
|
||||
let longSpaceReleased;
|
||||
let styleSpaceIsDown = false;
|
||||
try {
|
||||
await page.keyboard.down('Space');
|
||||
styleSpaceIsDown = true;
|
||||
shortSpaceDown = await page.evaluate(() => window.__qaStyleKeyProbe.snapshot());
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
await page.keyboard.up('Space');
|
||||
styleSpaceIsDown = false;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
shortSpaceReleased = await page.evaluate(() => window.__qaStyleKeyProbe.snapshot());
|
||||
|
||||
await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
manager.setStyle('retro');
|
||||
window.__qaStyleKeyProbe.reset();
|
||||
document.querySelector('.style-btn[data-style="normal"]')?.focus();
|
||||
});
|
||||
await page.keyboard.down('Space');
|
||||
styleSpaceIsDown = true;
|
||||
longSpaceDown = await page.evaluate(() => window.__qaStyleKeyProbe.snapshot());
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
await page.keyboard.down('Space'); // exercise repeat without resetting the hold deadline
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
longSpaceHeld = await page.evaluate(() => window.__qaStyleKeyProbe.snapshot());
|
||||
await page.keyboard.up('Space');
|
||||
styleSpaceIsDown = false;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
longSpaceReleased = await page.evaluate(() => window.__qaStyleKeyProbe.snapshot());
|
||||
} finally {
|
||||
if (styleSpaceIsDown) await page.keyboard.up('Space');
|
||||
await page.evaluate(() => {
|
||||
window.__qaStyleKeyProbe?.restore();
|
||||
delete window.__qaStyleKeyProbe;
|
||||
window.__godsEyeView.styleManager.setStyle('normal');
|
||||
});
|
||||
}
|
||||
const voiceUntouched = (state) => state.voiceBefore.present
|
||||
&& state.voiceStarts.length === 0 && JSON.stringify(state.voiceBefore) === JSON.stringify(state.voiceNow)
|
||||
&& state.voiceMutations.length === 0;
|
||||
check('Space down on focused Normal does not activate the style or start voice',
|
||||
shortSpaceDown.focusedStyle === 'normal' && shortSpaceDown.selectedStyle === 'retro'
|
||||
&& shortSpaceDown.selectedMap === 'osm' && shortSpaceDown.activations.length === 0
|
||||
&& shortSpaceDown.events.every((event) => event.type !== 'click')
|
||||
&& shortSpaceDown.voiceStarts.length === 0 && shortSpaceDown.voiceMutations.length === 0
|
||||
&& shortSpaceDown.voiceNow.status === shortSpaceDown.voiceBefore.status
|
||||
&& shortSpaceDown.voiceNow.startEpoch === shortSpaceDown.voiceBefore.startEpoch
|
||||
&& shortSpaceDown.voiceNow.spaceKeyHeld === true
|
||||
&& shortSpaceDown.voiceNow.pushToTalkKeyHeld === false,
|
||||
JSON.stringify(shortSpaceDown));
|
||||
const releasedClicks = shortSpaceReleased.events.filter((event) => event.type === 'click');
|
||||
check('short Space activates Normal once on trusted key release and preserves OSM',
|
||||
shortSpaceReleased.focusedStyle === 'normal' && shortSpaceReleased.selectedStyle === 'normal'
|
||||
&& shortSpaceReleased.selectedMap === 'osm'
|
||||
&& JSON.stringify(shortSpaceReleased.activations) === JSON.stringify(['normal'])
|
||||
&& releasedClicks.length === 1 && releasedClicks[0].trusted && releasedClicks[0].detail === 0
|
||||
&& shortSpaceReleased.events.some((event) => event.type === 'keyup' && event.trusted)
|
||||
&& voiceUntouched(shortSpaceReleased), JSON.stringify(shortSpaceReleased));
|
||||
check('long Space blurs the focused style before requesting push-to-talk',
|
||||
longSpaceDown.focusedStyle === 'normal' && longSpaceDown.selectedStyle === 'retro'
|
||||
&& longSpaceDown.activations.length === 0 && longSpaceDown.voiceStarts.length === 0
|
||||
&& longSpaceHeld.focusedStyle === null && longSpaceHeld.selectedStyle === 'retro'
|
||||
&& longSpaceHeld.activations.length === 0
|
||||
&& longSpaceHeld.events.some((event) => event.type === 'keydown' && event.repeat && event.trusted)
|
||||
&& longSpaceHeld.events.every((event) => event.type !== 'click')
|
||||
&& longSpaceHeld.voiceStarts.length === 1
|
||||
&& longSpaceHeld.voiceStarts[0].options.pushToTalk === true
|
||||
&& longSpaceHeld.voiceStarts[0].focusedStyle === null,
|
||||
JSON.stringify({ down: longSpaceDown, held: longSpaceHeld }));
|
||||
check('releasing a claimed long Space hold does not activate the blurred style',
|
||||
longSpaceReleased.focusedStyle === null && longSpaceReleased.selectedStyle === 'retro'
|
||||
&& longSpaceReleased.selectedMap === 'osm' && longSpaceReleased.activations.length === 0
|
||||
&& longSpaceReleased.voiceStarts.length === 1
|
||||
&& longSpaceReleased.events.every((event) => event.type !== 'click'),
|
||||
JSON.stringify(longSpaceReleased));
|
||||
|
||||
if (forceKeyless) {
|
||||
await page.evaluate(async () => {
|
||||
const styleManager = window.__godsEyeView.styleManager;
|
||||
@@ -362,12 +722,29 @@ try {
|
||||
const activeBeforeIonAttempt = await page.evaluate(() => (
|
||||
window.__godsEyeView.styleManager.mapStackController.getActiveId()
|
||||
));
|
||||
// The long-Space test above deliberately blurs its style button. On a slower
|
||||
// keyless rebuild that can give the tray's pending auto-close enough time to
|
||||
// hide its chips before page.focus() runs. Focus the disclosure first to
|
||||
// clear that close timer, then ensure the tray is visibly open so this check
|
||||
// exercises the unavailable tile rather than a hidden element.
|
||||
await page.focus('#control-panel-toggle');
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed(
|
||||
'control-panel', false, { persist: false, syncShare: false },
|
||||
));
|
||||
await waitTray('true', null);
|
||||
await page.focus('[data-stack-id="bing-aerial"]');
|
||||
const ionAvailable = await page.$eval(
|
||||
'[data-stack-id="bing-aerial"]',
|
||||
(chip) => chip.getAttribute('aria-disabled') !== 'true',
|
||||
);
|
||||
await page.click('[data-stack-id="bing-aerial"]');
|
||||
const ionFocusedBeforeActivation = await page.$eval(
|
||||
'[data-stack-id="bing-aerial"]',
|
||||
(chip) => document.activeElement === chip,
|
||||
);
|
||||
// Exercise the keyboard path. Unavailable tiles intentionally reject pointer
|
||||
// hit testing while remaining focusable so their missing-key explanation is
|
||||
// available to keyboard and assistive-technology users.
|
||||
await page.keyboard.press('Space');
|
||||
if (ionAvailable) {
|
||||
// Cesium creates the imagery provider asynchronously. Wait for controller
|
||||
// truth instead of assuming a keyed switch can settle in one animation.
|
||||
@@ -393,11 +770,12 @@ try {
|
||||
.map((candidate) => candidate.dataset.stackId),
|
||||
};
|
||||
});
|
||||
ionSource.focusedBeforeActivation = ionFocusedBeforeActivation;
|
||||
if (forceKeyless || ionSource.ariaDisabled === 'true') {
|
||||
check(
|
||||
'key-required sources stay focusable, explained, and inert when no ion token is configured',
|
||||
ionSource.ariaDisabled === 'true'
|
||||
&& ionSource.focused
|
||||
&& ionSource.focusedBeforeActivation
|
||||
// #143 names the missing key: "Needs CESIUM_ION_TOKEN — add it in Provider Settings".
|
||||
&& /needs [A-Z_]+.*provider settings/i.test(ionSource.ariaLabel)
|
||||
&& ionSource.activeId === activeBeforeIonAttempt
|
||||
@@ -709,8 +1087,14 @@ try {
|
||||
await setControlPanelPinned(false);
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager
|
||||
._setMapStack('photoreal', { syncShare: false }));
|
||||
// Hand the tray back OPEN and unpinned — the responsive block below starts by
|
||||
// clicking the pin control, which is only hittable while the tray is showing.
|
||||
// Approach the disclosure before opening: the previous mouse-away case
|
||||
// intentionally leaves a pending close, so an unattended programmatic reopen
|
||||
// can disappear while the next real pointer click is being dispatched.
|
||||
const pinApproach = await page.$eval('#control-panel-toggle', (toggle) => {
|
||||
const rect = toggle.getBoundingClientRect();
|
||||
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
|
||||
});
|
||||
await page.mouse.move(pinApproach.x, pinApproach.y);
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager
|
||||
.setPanelCollapsed('control-panel', false, { explicit: true }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 240));
|
||||
@@ -736,6 +1120,8 @@ try {
|
||||
JSON.stringify(desktop),
|
||||
);
|
||||
|
||||
await checkResponsiveStyleFocus(1000);
|
||||
|
||||
await page.setViewport({ width: 620, height: 900, deviceScaleFactor: 1 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 180));
|
||||
const at620 = await trayMetrics();
|
||||
@@ -749,6 +1135,8 @@ try {
|
||||
JSON.stringify(at620),
|
||||
);
|
||||
|
||||
await checkResponsiveStyleFocus(620);
|
||||
|
||||
await page.setViewport({ width: 480, height: 900, deviceScaleFactor: 1 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 180));
|
||||
const at480 = await trayMetrics();
|
||||
@@ -767,6 +1155,7 @@ try {
|
||||
JSON.stringify(at480),
|
||||
);
|
||||
await page.screenshot({ path: path.join(shotsDir, '480-open.png') });
|
||||
await checkResponsiveStyleFocus(480);
|
||||
|
||||
await page.click('.dock-pin-btn[data-pin-target="control-panel"]');
|
||||
await page.click('#control-panel-toggle');
|
||||
@@ -844,6 +1233,382 @@ try {
|
||||
await page.screenshot({ path: path.join(shotsDir, `legacy-${legacyId}.png`) });
|
||||
}
|
||||
|
||||
// Location is a native disclosure: Enter is immediate, while Space waits for
|
||||
// key release. A repeated keydown must not toggle or claim voice early.
|
||||
await page.setViewport({ width: 1000, height: 900, deviceScaleFactor: 1 });
|
||||
await page.mouse.move(20, 20);
|
||||
await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
for (const id of ['control-panel', 'location-bar']) {
|
||||
manager._setCommandDockPanelPinState(id, false, { persist: false, syncShare: false });
|
||||
manager.setPanelCollapsed(id, true, { persist: false, syncShare: false });
|
||||
}
|
||||
const original = manager.setPanelCollapsed;
|
||||
const probe = { transitions: [], restore: () => { manager.setPanelCollapsed = original; } };
|
||||
manager.setPanelCollapsed = function (id, ...args) {
|
||||
const before = document.getElementById(id)?.classList.contains('collapsed');
|
||||
const result = original.call(this, id, ...args);
|
||||
const after = document.getElementById(id)?.classList.contains('collapsed');
|
||||
if (id === 'location-bar' && before !== after) probe.transitions.push({ before, after });
|
||||
return result;
|
||||
};
|
||||
window.__qaLocationFocus = probe;
|
||||
});
|
||||
try {
|
||||
await page.focus('#gev-voice-button');
|
||||
const locationTab = await tabTo('#location-bar-toggle', { backwards: true, limit: 8 });
|
||||
const locationClosed = await locationState();
|
||||
check('Location: native disclosure semantics and closed Tab focus without opening',
|
||||
locationTab.reached && hasVisibleControlFocus(locationClosed)
|
||||
&& locationClosed.tag === 'BUTTON' && locationClosed.type === 'button'
|
||||
&& locationClosed.controls === 'location-bar-popover' && locationClosed.popoverId === 'location-bar-popover'
|
||||
&& locationClosed.locationExpanded === 'false' && /^Expand Location$/i.test(locationClosed.locationLabel)
|
||||
&& locationClosed.transitions.length === 0, JSON.stringify({ locationTab, locationClosed }));
|
||||
|
||||
await resetLocationTransitions();
|
||||
await page.keyboard.press('Enter');
|
||||
const locationEnter = await locationState();
|
||||
check('Location: Enter opens once with visible focus and accurate name',
|
||||
hasVisibleControlFocus(locationEnter) && locationEnter.locationExpanded === 'true'
|
||||
&& /^Collapse Location$/i.test(locationEnter.locationLabel) && locationEnter.transitions.length === 1,
|
||||
JSON.stringify(locationEnter));
|
||||
await page.keyboard.press('Escape');
|
||||
const locationEscape = await locationState();
|
||||
check('Location: Escape on the disclosure closes without retaining focus',
|
||||
locationEscape.focusedId === null && locationEscape.locationExpanded === 'false'
|
||||
&& /^Expand Location$/i.test(locationEscape.locationLabel), JSON.stringify(locationEscape));
|
||||
|
||||
await page.focus('#location-bar-toggle');
|
||||
await resetLocationTransitions();
|
||||
const voiceBeforeLocationSpace = locationEscape.voice;
|
||||
let locationSpaceHeld = false;
|
||||
let locationSpaceDown;
|
||||
let locationSpaceRepeat;
|
||||
try {
|
||||
await page.keyboard.down('Space');
|
||||
locationSpaceHeld = true;
|
||||
locationSpaceDown = await locationState();
|
||||
await new Promise((resolve) => setTimeout(resolve, 320));
|
||||
await page.keyboard.down('Space');
|
||||
await page.keyboard.down('Space');
|
||||
locationSpaceRepeat = await locationState();
|
||||
await page.keyboard.up('Space');
|
||||
locationSpaceHeld = false;
|
||||
} finally {
|
||||
if (locationSpaceHeld) await page.keyboard.up('Space');
|
||||
}
|
||||
const locationSpaceUp = await locationState();
|
||||
check('Location: short repeated Space toggles once on release without starting voice',
|
||||
[locationSpaceDown, locationSpaceRepeat].every((state) => (
|
||||
state.focusedId === 'location-bar-toggle' && state.locationExpanded === 'false'
|
||||
&& state.transitions.length === 0 && state.voice.status === voiceBeforeLocationSpace.status
|
||||
&& state.voice.epoch === voiceBeforeLocationSpace.epoch
|
||||
&& state.voice.held === true && state.voice.pushToTalk === false
|
||||
))
|
||||
&& locationSpaceUp.focusedId === 'location-bar-toggle'
|
||||
&& locationSpaceUp.locationExpanded === 'true' && locationSpaceUp.transitions.length === 1
|
||||
&& JSON.stringify(locationSpaceUp.voice) === JSON.stringify(voiceBeforeLocationSpace),
|
||||
JSON.stringify({ down: locationSpaceDown, repeat: locationSpaceRepeat, up: locationSpaceUp }));
|
||||
|
||||
const searchTab = await tabTo('#search-toggle', { limit: 30 });
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.type('focus cleanup'); // no submission or navigation
|
||||
const typedSearch = await page.$eval('#location-search', (input) => ({
|
||||
focused: document.activeElement === input, expanded: input.classList.contains('expanded'), value: input.value,
|
||||
}));
|
||||
await page.keyboard.press('Escape');
|
||||
const searchEscape = await locationState();
|
||||
check('Location: Escape clears typed search and restores disclosure focus',
|
||||
searchTab.reached && typedSearch.focused && typedSearch.expanded && typedSearch.value === 'focus cleanup'
|
||||
&& searchEscape.focusedId === 'location-bar-toggle' && searchEscape.locationExpanded === 'false'
|
||||
&& !searchEscape.searchExpanded && searchEscape.searchValue === '', JSON.stringify({ typedSearch, searchEscape }));
|
||||
|
||||
await page.focus('#control-panel-toggle');
|
||||
await page.keyboard.press('Enter'); // begin the Map Source handoff
|
||||
const siblingTab = await tabTo('#location-bar-toggle', { backwards: true, limit: 30 });
|
||||
await page.keyboard.press('Enter');
|
||||
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||
const siblingState = await locationState();
|
||||
check('Location: sibling opening cancels pending Map Source focus',
|
||||
siblingTab.reached && siblingState.focusedId === 'location-bar-toggle'
|
||||
&& siblingState.locationExpanded === 'true'
|
||||
&& await page.$eval('#control-panel-toggle', (toggle) => toggle.getAttribute('aria-expanded') === 'false'),
|
||||
JSON.stringify(siblingState));
|
||||
await tabTo('.dock-pin-btn[data-pin-target="location-bar"]', { limit: 3 });
|
||||
await page.keyboard.press('Enter');
|
||||
await page.focus('#control-panel-toggle');
|
||||
await page.keyboard.press('Enter');
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
const pinnedSibling = await locationState();
|
||||
check('Location: pinned tray survives sibling opening',
|
||||
pinnedSibling.pinned && pinnedSibling.locationExpanded === 'true'
|
||||
&& await page.$eval('#control-panel-toggle', (toggle) => toggle.getAttribute('aria-expanded') === 'true'),
|
||||
JSON.stringify(pinnedSibling));
|
||||
await page.focus('#location-bar-toggle');
|
||||
await tabTo('.dock-pin-btn[data-pin-target="location-bar"]', { limit: 3 });
|
||||
await page.keyboard.press('Enter');
|
||||
check('Location: pin can be removed by keyboard', !(await locationState()).pinned);
|
||||
|
||||
for (const width of [1000, 620, 480]) {
|
||||
await page.setViewport({ width, height: 900, deviceScaleFactor: 1 });
|
||||
await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
manager.setPanelCollapsed('control-panel', true, { persist: false, syncShare: false });
|
||||
manager.setPanelCollapsed('location-bar', true, { persist: false, syncShare: false });
|
||||
});
|
||||
await page.focus('#gev-voice-button');
|
||||
const closedTab = await tabTo('#location-bar-toggle', { backwards: true, limit: 8 });
|
||||
const closed = await locationState();
|
||||
check(`Location: ${width} px closed Tab ring is visible without opening`,
|
||||
closedTab.reached && hasVisibleControlFocus(closed) && closed.locationExpanded === 'false', JSON.stringify(closed));
|
||||
await page.screenshot({ path: path.join(shotsDir, `${width}-location-closed-focus.png`) });
|
||||
await page.keyboard.press('Enter');
|
||||
await pressTabs(1); // disclosure -> pin
|
||||
const openTab = await tabTo('#location-bar-toggle', { backwards: true, limit: 3 });
|
||||
const open = await locationState();
|
||||
check(`Location: ${width} px open ShiftTab ring is visible`,
|
||||
openTab.reached && hasVisibleControlFocus(open) && open.locationExpanded === 'true', JSON.stringify(open));
|
||||
await page.screenshot({ path: path.join(shotsDir, `${width}-location-open-focus.png`) });
|
||||
await page.keyboard.press('Escape');
|
||||
}
|
||||
} finally {
|
||||
await page.evaluate(() => {
|
||||
window.__qaLocationFocus?.restore();
|
||||
delete window.__qaLocationFocus;
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
for (const id of ['control-panel', 'location-bar']) {
|
||||
manager._setCommandDockPanelPinState(id, false, { persist: false, syncShare: false });
|
||||
manager.setPanelCollapsed(id, true, { persist: false, syncShare: false });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// The existing dev-only QA registration seam supplies stable ON/STALE rows
|
||||
// through the real manager/renderer. These fixtures fetch nothing, render no
|
||||
// entities and have no periodic update; production layers are never toggled.
|
||||
let dataSetup;
|
||||
try {
|
||||
dataSetup = await page.evaluate(async () => {
|
||||
const manager = window.__godsEyeView.dataManager;
|
||||
const production = () => manager.getAll().filter((layer) => !layer.id.startsWith('qa-keyboard-focus-'))
|
||||
.map((layer) => ({
|
||||
id: layer.id, enabled: layer.enabled, phase: layer.lifecycleState, uncertain: layer.lifecycleUncertain,
|
||||
visibilityIntentEpoch: manager.layers.get(layer.id).visibilityIntentEpoch,
|
||||
paramsIntentEpoch: manager.layers.get(layer.id).paramsIntentEpoch,
|
||||
}));
|
||||
// Inspect this known layer-state key only. Keep its value inside the page;
|
||||
// evidence receives equality booleans, never stored contents.
|
||||
const durableBefore = localStorage.getItem('gev:layer-state:v2');
|
||||
const state = { ids: [], before: production(), production, durableBefore, collapsed: document.getElementById('data-panel').classList.contains('collapsed') };
|
||||
window.__qaDataFocus = state;
|
||||
if (typeof window.__gevQaRegisterLayer !== 'function' || typeof window.__gevQaUnregisterLayer !== 'function') {
|
||||
return { ready: false, reason: 'Existing dev-only QA registration seam is unavailable' };
|
||||
}
|
||||
for (const [suffix, stale] of [['on', false], ['status', true]]) {
|
||||
const id = `qa-keyboard-focus-${suffix}`;
|
||||
window.__gevQaRegisterLayer(manager, {
|
||||
id, name: `QA focus ${stale ? 'STALE' : 'ON'}`, icon: '◌', source: 'Local focus fixture',
|
||||
init() {}, enable() {}, disable() {}, destroy() {}, update() {},
|
||||
getStats: () => ({ count: 1, lastUpdate: Date.now() - (stale ? 60_000 : 0), stale, source: 'Local focus fixture' }),
|
||||
});
|
||||
state.ids.push(id);
|
||||
await manager.setEnabled(id, true, { origin: 'programmatic' });
|
||||
}
|
||||
const transitionId = 'qa-keyboard-focus-transition';
|
||||
const transition = {
|
||||
id: transitionId,
|
||||
enableCalls: 0,
|
||||
disableCalls: 0,
|
||||
releaseEnable: null,
|
||||
releaseDisable: null,
|
||||
};
|
||||
state.transition = transition;
|
||||
window.__gevQaRegisterLayer(manager, {
|
||||
id: transitionId,
|
||||
name: 'QA focus transition',
|
||||
icon: '◌',
|
||||
source: 'Local focus fixture',
|
||||
init() {},
|
||||
enable() {
|
||||
transition.enableCalls += 1;
|
||||
return new Promise((resolve) => {
|
||||
transition.releaseEnable = () => {
|
||||
transition.releaseEnable = null;
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
},
|
||||
disable() {
|
||||
transition.disableCalls += 1;
|
||||
return new Promise((resolve) => {
|
||||
transition.releaseDisable = () => {
|
||||
transition.releaseDisable = null;
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
},
|
||||
destroy() {},
|
||||
update() {},
|
||||
getStats: () => ({ count: 1, lastUpdate: Date.now(), source: 'Local focus fixture' }),
|
||||
});
|
||||
state.ids.push(transitionId);
|
||||
manager._renderToggles();
|
||||
const offId = manager.getAll().find((layer) => !state.ids.includes(layer.id) && layer.showInTogglePanel && !layer.enabled)?.id;
|
||||
return {
|
||||
ready: Boolean(offId), offId, fixtureIds: state.ids.slice(0, 2), transitionId,
|
||||
productionUnchanged: JSON.stringify(state.before) === JSON.stringify(production()),
|
||||
durableUnchanged: durableBefore === localStorage.getItem('gev:layer-state:v2'),
|
||||
};
|
||||
});
|
||||
check('Data Layers: explicit dev fixtures provide status coverage without changing production layers',
|
||||
dataSetup.ready && dataSetup.productionUnchanged && dataSetup.durableUnchanged, JSON.stringify(dataSetup));
|
||||
if (dataSetup.ready) {
|
||||
for (const width of [1000, 620, 480]) {
|
||||
await page.setViewport({ width, height: 900, deviceScaleFactor: 1 });
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('data-panel', false, { persist: false, syncShare: false }));
|
||||
await page.focus('#data-panel .panel-collapse-btn');
|
||||
const targets = [[dataSetup.offId, 'OFF', false], [dataSetup.fixtureIds[0], 'ON', true], [dataSetup.fixtureIds[1], 'STALE', true]];
|
||||
for (const [id, label, enabled] of targets) {
|
||||
const navigation = await tabTo(`[data-layer-id="${id}"] .data-toggle-btn`);
|
||||
const state = await layerFocusState(id);
|
||||
check(`Data Layers: ${width} px ${label} Tab focus ring is visible`,
|
||||
navigation.reached && state.layerId === id && hasVisibleControlFocus(state)
|
||||
&& state.label === label && state.buttonActive === enabled && state.lifecycle?.enabled === enabled
|
||||
&& state.lifecycle?.lifecycleState === (enabled ? 'enabled' : 'disabled') && !state.lifecycle?.uncertain,
|
||||
JSON.stringify({ setup: enabled ? 'dev QA fixture' : 'real production OFF row; not activated', navigation, state }));
|
||||
await page.screenshot({ path: path.join(shotsDir, `${width}-data-${label.toLowerCase()}-focus.png`) });
|
||||
if (label === 'OFF') {
|
||||
const passive = await page.evaluate((layerId) => {
|
||||
const probe = window.__qaDataFocus;
|
||||
const focusBefore = document.activeElement;
|
||||
const productionBefore = JSON.stringify(probe.production());
|
||||
const durableBefore = localStorage.getItem('gev:layer-state:v2');
|
||||
window.__godsEyeView.dataManager._refreshTogglePanel();
|
||||
return {
|
||||
focusRetained: document.activeElement === focusBefore
|
||||
&& focusBefore.matches('.data-toggle-btn')
|
||||
&& focusBefore.closest('[data-layer-id]')?.dataset.layerId === layerId,
|
||||
productionUnchanged: productionBefore === JSON.stringify(probe.production())
|
||||
&& productionBefore === JSON.stringify(probe.before),
|
||||
durableUnchanged: durableBefore === localStorage.getItem('gev:layer-state:v2')
|
||||
&& durableBefore === probe.durableBefore,
|
||||
};
|
||||
}, id);
|
||||
const refreshed = await layerFocusState(id);
|
||||
check(`Data Layers: ${width} px passive refresh preserves OFF focus and state`,
|
||||
passive.focusRetained && passive.productionUnchanged && passive.durableUnchanged
|
||||
&& hasVisibleControlFocus(refreshed) && refreshed.label === 'OFF' && !refreshed.lifecycle?.enabled,
|
||||
JSON.stringify({ passive, refreshed }));
|
||||
}
|
||||
if (label === 'STALE') check(`Data Layers: ${width} px native Tab scrolls to the lower status row`,
|
||||
state.listScrollHeight > state.listClientHeight && state.listScrollTop > 0 && hasVisibleControlFocus(state), JSON.stringify(state));
|
||||
}
|
||||
}
|
||||
check('Data Layers: focus traversal leaves production visibility unchanged', await page.evaluate(() => (
|
||||
JSON.stringify(window.__qaDataFocus.before) === JSON.stringify(window.__qaDataFocus.production())
|
||||
&& window.__qaDataFocus.durableBefore === localStorage.getItem('gev:layer-state:v2')
|
||||
)));
|
||||
|
||||
await page.setViewport({ width: 1000, height: 900, deviceScaleFactor: 1 });
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('data-panel', false, { persist: false, syncShare: false }));
|
||||
await page.focus('#data-panel .panel-collapse-btn');
|
||||
const transitionTab = await tabTo(`[data-layer-id="${dataSetup.transitionId}"] .data-toggle-btn`);
|
||||
const transitionBefore = await layerFocusState(dataSetup.transitionId);
|
||||
check('Data Layers: transition fixture receives native Tab focus',
|
||||
transitionTab.reached && transitionBefore.label === 'OFF' && hasVisibleControlFocus(transitionBefore),
|
||||
JSON.stringify({ transitionTab, transitionBefore }));
|
||||
|
||||
await page.keyboard.press('Space');
|
||||
await page.waitForFunction((layerId) => {
|
||||
const state = window.__qaDataFocus?.transition;
|
||||
const button = document.querySelector(`[data-layer-id="${layerId}"] .data-toggle-btn`);
|
||||
return state?.enableCalls === 1 && typeof state.releaseEnable === 'function'
|
||||
&& button?.textContent.trim() === 'ENABLING';
|
||||
}, { timeout: 5_000 }, dataSetup.transitionId);
|
||||
const enabling = await layerFocusState(dataSetup.transitionId);
|
||||
check('Data Layers: focused Space keeps a visible ring through ENABLING',
|
||||
hasVisibleControlFocus(enabling) && enabling.label === 'ENABLING' && !enabling.disabled
|
||||
&& enabling.ariaDisabled === 'true' && enabling.ariaBusy === 'true', JSON.stringify(enabling));
|
||||
const enablingEpoch = await page.evaluate((layerId) => (
|
||||
window.__godsEyeView.dataManager.layers.get(layerId).visibilityIntentEpoch
|
||||
), dataSetup.transitionId);
|
||||
await page.keyboard.press('Space');
|
||||
const enablingRepeat = await page.evaluate((layerId) => ({
|
||||
calls: window.__qaDataFocus.transition.enableCalls,
|
||||
epoch: window.__godsEyeView.dataManager.layers.get(layerId).visibilityIntentEpoch,
|
||||
}), dataSetup.transitionId);
|
||||
check('Data Layers: repeated Space is inert while ENABLING',
|
||||
enablingRepeat.calls === 1 && enablingRepeat.epoch === enablingEpoch, JSON.stringify(enablingRepeat));
|
||||
await page.evaluate(() => window.__qaDataFocus.transition.releaseEnable());
|
||||
await page.waitForFunction((layerId) => {
|
||||
const state = window.__godsEyeView.dataManager.getLayerLifecycleState(layerId);
|
||||
return state.enabled && state.lifecycleState === 'enabled';
|
||||
}, { timeout: 5_000 }, dataSetup.transitionId);
|
||||
const enabled = await layerFocusState(dataSetup.transitionId);
|
||||
check('Data Layers: settled ON keeps the same visible keyboard focus',
|
||||
hasVisibleControlFocus(enabled) && !enabled.disabled && enabled.ariaDisabled === 'false'
|
||||
&& enabled.ariaBusy === 'false' && enabled.lifecycle?.enabled, JSON.stringify(enabled));
|
||||
|
||||
await page.keyboard.press('Space');
|
||||
await page.waitForFunction((layerId) => {
|
||||
const state = window.__qaDataFocus?.transition;
|
||||
const button = document.querySelector(`[data-layer-id="${layerId}"] .data-toggle-btn`);
|
||||
return state?.disableCalls === 1 && typeof state.releaseDisable === 'function'
|
||||
&& button?.textContent.trim() === 'DISABLING';
|
||||
}, { timeout: 5_000 }, dataSetup.transitionId);
|
||||
const disabling = await layerFocusState(dataSetup.transitionId);
|
||||
check('Data Layers: focused Space keeps a visible ring through DISABLING',
|
||||
hasVisibleControlFocus(disabling) && disabling.label === 'DISABLING' && !disabling.disabled
|
||||
&& disabling.ariaDisabled === 'true' && disabling.ariaBusy === 'true', JSON.stringify(disabling));
|
||||
const disablingEpoch = await page.evaluate((layerId) => (
|
||||
window.__godsEyeView.dataManager.layers.get(layerId).visibilityIntentEpoch
|
||||
), dataSetup.transitionId);
|
||||
await page.keyboard.press('Space');
|
||||
const disablingRepeat = await page.evaluate((layerId) => ({
|
||||
calls: window.__qaDataFocus.transition.disableCalls,
|
||||
epoch: window.__godsEyeView.dataManager.layers.get(layerId).visibilityIntentEpoch,
|
||||
}), dataSetup.transitionId);
|
||||
check('Data Layers: repeated Space is inert while DISABLING',
|
||||
disablingRepeat.calls === 1 && disablingRepeat.epoch === disablingEpoch, JSON.stringify(disablingRepeat));
|
||||
await page.evaluate(() => window.__qaDataFocus.transition.releaseDisable());
|
||||
await page.waitForFunction((layerId) => {
|
||||
const state = window.__godsEyeView.dataManager.getLayerLifecycleState(layerId);
|
||||
return !state.enabled && state.lifecycleState === 'disabled';
|
||||
}, { timeout: 5_000 }, dataSetup.transitionId);
|
||||
const disabled = await layerFocusState(dataSetup.transitionId);
|
||||
check('Data Layers: settled OFF keeps the same visible keyboard focus',
|
||||
hasVisibleControlFocus(disabled) && !disabled.disabled && disabled.ariaDisabled === 'false'
|
||||
&& disabled.ariaBusy === 'false' && !disabled.lifecycle?.enabled, JSON.stringify(disabled));
|
||||
}
|
||||
} finally {
|
||||
const cleaned = await page.evaluate(async () => {
|
||||
const state = window.__qaDataFocus;
|
||||
if (!state) return { complete: false, reason: 'No fixture setup state' };
|
||||
const manager = window.__godsEyeView.dataManager;
|
||||
state.transition?.releaseEnable?.();
|
||||
state.transition?.releaseDisable?.();
|
||||
const removed = [];
|
||||
for (const id of state.ids) removed.push(await window.__gevQaUnregisterLayer(manager, id));
|
||||
manager._renderToggles();
|
||||
window.__godsEyeView.styleManager.setPanelCollapsed('data-panel', state.collapsed, { persist: false, syncShare: false });
|
||||
// A native user-origin toggle legitimately asks the production state
|
||||
// coordinator to persist. Restore the exact pre-fixture value so this
|
||||
// hermetic QA journey leaves the user's durable layer snapshot untouched.
|
||||
if (state.durableBefore === null) localStorage.removeItem('gev:layer-state:v2');
|
||||
else localStorage.setItem('gev:layer-state:v2', state.durableBefore);
|
||||
const result = {
|
||||
complete: removed.every(Boolean) && state.ids.every((id) => !manager.layers.has(id)),
|
||||
productionUnchanged: JSON.stringify(state.before) === JSON.stringify(state.production()),
|
||||
durableUnchanged: state.durableBefore === localStorage.getItem('gev:layer-state:v2'),
|
||||
};
|
||||
delete window.__qaDataFocus;
|
||||
return result;
|
||||
});
|
||||
check('Data Layers: fixture teardown restores the original production registry and visibility',
|
||||
cleaned.complete && cleaned.productionUnchanged && cleaned.durableUnchanged, JSON.stringify(cleaned));
|
||||
await page.setViewport({ width: 1000, height: 900, deviceScaleFactor: 1 });
|
||||
}
|
||||
|
||||
check('no new page or console errors', consoleErrors.length === 0, consoleErrors.join(' | '));
|
||||
} finally {
|
||||
await browser.close();
|
||||
|
||||
Reference in new issue
Block a user