Files
gods-eye-view/src/ui/cockpitUtilityControls.test.mjs
T
Sameh Khamis 4c92f517db feat(cockpit): render the utility strip's measured layout from the store
The Cockpit layout pass publishes the utility strip's fit to the
gev-cockpit store: the HUD's --cockpit-utility-* lengths, whether only the
expanded control fits, and whether a collapsed control beside an expanded
one is hidden from assistive technology. CockpitView renders the lengths
on the HUD and CockpitUtilityControls the strip's class and each control's
aria-hidden, which now also follows a popover opened between passes. Focus
leaves a control before the strip hides it. Exit publishes no layout,
which also clears the expanded height, class and aria-hidden the old exit
left behind. The briefing and Contact cards' positions stay imperative:
the same pass measures them straight after writing them.
2026-11-01 14:36:51 -08:00

453 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// src/ui/cockpitUtilityControls.test.mjs — the Cockpit's Display and Radio
// launchers and the compact Radio controls render what RadioControls
// presents, over happy-dom with the real controls bound to them.
import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createPinia } from 'pinia';
import { createApp, nextTick } from 'vue';
import { setLocale } from '../i18n/index.js';
import { onKeyDown as cockpitKeyDown } from './cockpitInput.js';
import { RadioControls } from './radioControls.js';
import { renderRadioState } from './radioPresentation.js';
import { syncSignalLayout } from './cockpitLayout.js';
import { useCockpitStore } from './stores/cockpit.js';
import { useRadioStore } from './stores/radio.js';
import CockpitUtilityControls from './views/panels/CockpitUtilityControls.vue';
function fixture(t) {
document.body.replaceChildren();
const host = document.body.appendChild(document.createElement('div'));
const outside = document.body.appendChild(document.createElement('button'));
const pinia = createPinia();
const app = createApp(CockpitUtilityControls).use(pinia);
app.mount(host);
const $ = (id) => document.getElementById(id);
const calls = [];
let enabled = false;
let toggles = 0;
let finishToggle = null;
const ui = { presentationActive: true, filter: 'all' };
const actions = {
isRegistered: () => true,
isEnabled: () => enabled,
runUserAction: (operation) => operation(),
setEnabled: (next) => {
toggles += 1;
return new Promise((resolve) => {
finishToggle = () => {
enabled = next;
resolve(true);
};
});
},
setParams: (params) => {
calls.push(['params', params]);
return params.filter !== 'refused';
},
setPanelCollapsed() {},
setSignalCollapsed: (collapsed) => calls.push(['signal', collapsed]),
revealStyleParameters: () => calls.push(['reveal']),
layoutCockpit() {},
isCockpitActive: () => true,
signalUserCollapsed: () => false,
scheduleLayout() {},
getLifecycle: () => null,
preservePanelStateDuringClear: () => true,
};
const controls = new RadioControls({
elements: {
_radioPanel: document.createElement('section'),
// A bare tuner slider: the panel's tuner has its own tests.
_radioTunerSlider: Object.assign(document.createElement('input'), {
type: 'range',
}),
_cockpitUtilityControls: $('cockpit-utility-controls'),
_cockpitDisplayToggleBtn: $('cockpit-display-toggle-btn'),
_cockpitRadioToggleBtn: $('cockpit-radio-toggle-btn'),
_cockpitRadioEnableBtn: $('cockpit-radio-enable-btn'),
},
radio: {
subscribe: () => () => {},
endTuning() {},
getTunerStations: () => [],
getUIState: () => ui,
cycleStation: (direction, { rotate }) =>
calls.push(['cycle', direction, rotate]),
togglePlayback: () => calls.push(['playback']),
},
actions,
canvas: document.createElement('canvas'),
store: useRadioStore(pinia),
});
t.after(() => {
controls.destroy();
app.unmount();
});
const render = async (state) => {
renderRadioState.call(controls, {
enabled: true,
categories: [{ id: 'all', label: 'All', count: 3, color: '' }],
filter: 'all',
filteredCount: 3,
stationCount: 3,
volume: 0.8,
audioState: 'stopped',
...state,
});
await nextTick();
};
return {
$,
controls,
calls,
ui,
render,
outside,
finishToggle: () => finishToggle(),
toggles: () => toggles,
};
}
const control = (button) => button.closest('.cockpit-utility-control');
test('before RadioControls presents, both popovers are closed and the controls start idle', (t) => {
const { $ } = fixture(t);
for (const kind of ['display', 'radio']) {
const toggle = $(`cockpit-${kind}-toggle-btn`);
assert.equal(toggle.getAttribute('aria-expanded'), 'false');
assert.equal(toggle.textContent, '◀');
assert.equal($(`cockpit-${kind}-panel`).hidden, true);
assert.equal(control(toggle).classList.contains('is-expanded'), false);
}
assert.equal(
$('cockpit-display-toggle-btn').getAttribute('aria-label'),
'Expand Cockpit display options',
);
assert.equal(
$('cockpit-radio-toggle-btn').title,
'Expand Cockpit Radio controls',
);
assert.equal($('cockpit-radio-station').textContent, 'READY');
const enable = $('cockpit-radio-enable-btn');
assert.equal(enable.textContent, 'ENABLE');
assert.equal(enable.getAttribute('aria-pressed'), 'false');
assert.equal(enable.hasAttribute('aria-busy'), false);
assert.equal($('cockpit-radio-play-btn').disabled, true);
assert.equal($('cockpit-radio-play-btn').textContent, '▶');
assert.equal($('cockpit-radio-volume').value, '80');
assert.equal($('cockpit-radio-volume').disabled, true);
assert.equal($('cockpit-radio-volume-value').textContent, '80%');
assert.equal(
document.querySelectorAll('[data-cockpit-display-slot]').length,
4,
);
});
test('a launcher opens its popover, closes the other, and Escape returns focus', async (t) => {
const { $, controls, calls } = fixture(t);
// The application shell takes the Display launcher's clicks.
controls._setCockpitDisclosure('display', true);
await nextTick();
assert.equal(controls._cockpitDisclosureOpen('display'), true);
assert.equal($('cockpit-display-panel').hidden, false);
assert.equal($('cockpit-display-toggle-btn').textContent, '▶');
assert.equal(
$('cockpit-display-toggle-btn').getAttribute('aria-label'),
'Collapse Cockpit display options',
);
assert.equal(
control($('cockpit-display-toggle-btn')).classList.contains('is-expanded'),
true,
);
assert.deepEqual(calls.slice(0, 2), [['signal', true], ['reveal']]);
$('cockpit-radio-toggle-btn').click();
await nextTick();
assert.equal($('cockpit-display-panel').hidden, true);
assert.equal($('cockpit-radio-panel').hidden, false);
assert.equal(
$('cockpit-radio-toggle-btn').getAttribute('aria-expanded'),
'true',
);
$('cockpit-radio-play-btn').dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
);
await nextTick();
assert.equal($('cockpit-radio-panel').hidden, true);
assert.equal(document.activeElement === $('cockpit-radio-toggle-btn'), true);
assert.deepEqual(calls.at(-1), ['signal', false]);
});
test('a press outside the Cockpit controls closes the open popover', async (t) => {
const { $, controls, outside } = fixture(t);
controls._setCockpitDisclosure('radio', true);
await nextTick();
$('cockpit-radio-volume').dispatchEvent(
new PointerEvent('pointerdown', { bubbles: true }),
);
await nextTick();
assert.equal($('cockpit-radio-panel').hidden, false);
outside.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
await nextTick();
assert.equal($('cockpit-radio-panel').hidden, true);
assert.equal(controls._cockpitDisclosureOpen('radio'), false);
});
test('the compact controls render the presented Radio state', async (t) => {
const { $, render } = fixture(t);
await render({
selected: { id: 's1', name: 'KUTX' },
audioState: 'playing',
volume: 0.35,
});
assert.equal($('cockpit-radio-station').textContent, 'KUTX');
const enable = $('cockpit-radio-enable-btn');
assert.equal(enable.textContent, 'DISABLE');
assert.equal(enable.classList.contains('active'), true);
assert.equal(enable.classList.contains('panel-layer-toggle'), true);
assert.equal(enable.getAttribute('aria-pressed'), 'true');
assert.equal(enable.getAttribute('aria-label'), 'Disable Radio');
assert.equal(enable.getAttribute('aria-busy'), 'false');
assert.equal(enable.disabled, false);
const play = $('cockpit-radio-play-btn');
assert.equal(play.disabled, false);
assert.equal(play.textContent, 'Ⅱ');
assert.equal(play.classList.contains('active'), true);
assert.equal(play.getAttribute('aria-label'), 'Pause selected radio station');
assert.equal(play.title, 'Pause');
assert.equal($('cockpit-radio-prev-btn').disabled, false);
assert.equal($('cockpit-radio-volume').value, '35');
assert.equal($('cockpit-radio-volume').disabled, false);
assert.equal($('cockpit-radio-volume-value').textContent, '35%');
await render({ enabled: false, selected: null, loading: true });
assert.equal($('cockpit-radio-station').textContent, 'SYNCING');
assert.equal(enable.textContent, 'ENABLE');
assert.equal(enable.classList.contains('active'), false);
assert.equal(play.disabled, true);
assert.equal(play.textContent, '▶');
assert.equal($('cockpit-radio-volume').disabled, true);
});
test('dragging the volume shows the value at once and a render leaves the held slider alone', async (t) => {
const { $, calls, render } = fixture(t);
await render({ volume: 0.8 });
const volume = $('cockpit-radio-volume');
volume.focus();
volume.value = '42';
volume.dispatchEvent(new Event('input', { bubbles: true }));
await nextTick();
assert.equal($('cockpit-radio-volume-value').textContent, '42%');
assert.deepEqual(calls.at(-1), ['params', { volume: 0.42 }]);
// A render reporting an older volume while the slider is held.
await render({ volume: 0.8 });
assert.equal(volume.value, '42');
});
test('the compact transport steps stations without rotating the camera', (t) => {
const { $, calls } = fixture(t);
for (const id of [
'cockpit-radio-prev-btn',
'cockpit-radio-play-btn',
'cockpit-radio-next-btn',
]) {
$(id).disabled = false;
$(id).click();
}
assert.deepEqual(calls, [
['cycle', -1, false],
['playback'],
['cycle', 1, false],
]);
});
test('the station filter answers with the tag the directory keeps', (t) => {
const { controls, calls, ui } = fixture(t);
assert.equal(controls.store.act('setRadioFilter', 'jazz'), 'jazz');
assert.equal(controls.store.act('setRadioFilter', 'refused'), 'all');
ui.presentationActive = false;
assert.equal(controls.store.act('setRadioFilter', 'rock'), 'all');
assert.deepEqual(calls, [
['params', { filter: 'jazz' }],
['params', { filter: 'refused' }],
]);
});
test('a destroyed RadioControls answers no controls', (t) => {
const { controls, calls } = fixture(t);
controls.destroy();
assert.equal(controls.store.act('setRadioFilter', 'jazz'), undefined);
controls.store.act('cycleRadio', 1);
assert.deepEqual(calls, []);
});
test('the Cockpit Enable button is marked busy while its own toggle runs', async (t) => {
const { $, render, finishToggle, toggles } = fixture(t);
await render({ enabled: false });
const enable = $('cockpit-radio-enable-btn');
// A double press lands before the busy state renders; only one toggle runs.
enable.click();
enable.click();
assert.equal(toggles(), 1);
await nextTick();
assert.equal(enable.getAttribute('aria-busy'), 'true');
assert.equal(enable.getAttribute('aria-disabled'), 'true');
assert.equal(enable.disabled, false);
// A second press while busy starts nothing.
enable.click();
assert.equal(toggles(), 1);
finishToggle();
await new Promise((resolve) => setTimeout(resolve, 0));
await nextTick();
assert.equal(enable.getAttribute('aria-busy'), 'false');
});
test('the launchers read in the UI language', async (t) => {
t.after(() => setLocale('en'));
setLocale('ru');
const { $, controls } = fixture(t);
const before = $('cockpit-radio-toggle-btn').getAttribute('aria-label');
controls._setCockpitDisclosure('radio', true);
await nextTick();
const after = $('cockpit-radio-toggle-btn').getAttribute('aria-label');
assert.notEqual(before, 'Expand Cockpit Radio controls');
assert.notEqual(after, before);
assert.notEqual(after, 'Collapse Cockpit Radio controls');
});
test('Cockpit Escape defers to a popover the moment its disclosure opens', async (t) => {
const { controls } = fixture(t);
let exits = 0;
const cockpit = {
active: true,
isUtilityDisclosureOpen: () =>
controls._cockpitDisclosureOpen('display') ||
controls._cockpitDisclosureOpen('radio'),
exit: () => (exits += 1),
};
const escape = () =>
cockpitKeyDown.call(
cockpit,
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
);
// Dispatched in the same task, before Vue renders the open popover.
controls._setCockpitDisclosure('radio', true);
escape();
assert.equal(exits, 0);
controls._setCockpitDisclosure('radio', false);
escape();
assert.equal(exits, 1);
await nextTick();
});
test('the layout pass’s measured fit hides the collapsed control and marks the strip', async () => {
document.body.replaceChildren();
const host = document.body.appendChild(document.createElement('div'));
const pinia = createPinia();
const app = createApp(CockpitUtilityControls).use(pinia);
app.mount(host);
const radio = useRadioStore(pinia);
const cockpit = useCockpitStore(pinia);
const strip = document.getElementById('cockpit-utility-controls');
const [display, radioControl] = strip.querySelectorAll(
'.cockpit-utility-control',
);
const layout = (fit) => ({
top: '96.0px',
maxHeight: '300.00px',
expandedMaxHeight: '300.00px',
primaryOnly: false,
hideCollapsed: false,
...fit,
});
// Until the layout pass runs, neither control carries aria-hidden.
assert.equal(display.hasAttribute('aria-hidden'), false);
radio.publishUtility({ display: false, radio: true });
cockpit.publishUtilityLayout(
layout({ primaryOnly: true, hideCollapsed: true }),
);
await nextTick();
assert.equal(strip.classList.contains('layout-primary-only'), true);
assert.equal(display.getAttribute('aria-hidden'), 'true');
assert.equal(radioControl.getAttribute('aria-hidden'), 'false');
// With nothing expanded there is no collapsed sibling to hide.
radio.publishUtility({ display: false, radio: false });
await nextTick();
assert.equal(display.getAttribute('aria-hidden'), 'false');
cockpit.publishUtilityLayout(layout());
await nextTick();
assert.equal(strip.classList.contains('layout-primary-only'), false);
app.unmount();
});
test('the real layout pass drives the mounted controls: compact hides the collapsed sibling, Cyber shows both', async (t) => {
document.body.replaceChildren();
const host = document.body.appendChild(document.createElement('div'));
const pinia = createPinia();
const app = createApp(CockpitUtilityControls).use(pinia);
app.mount(host);
const radio = useRadioStore(pinia);
const cockpit = useCockpitStore(pinia);
const signalStream = document.body.appendChild(
document.createElement('aside'),
);
const owner = {
signalStream,
signalCollapsed: false,
contextCollapsed: false,
context: null,
publishUtilityLayout: (layout) => cockpit.publishUtilityLayout(layout),
syncContextLayout() {},
};
const originalMatchMedia = window.matchMedia;
let compact = true;
window.matchMedia = (query) => ({ matches: compact, media: query });
t.after(() => {
window.matchMedia = originalMatchMedia;
delete document.documentElement.dataset.uiTheme;
document.body.style.removeProperty('opacity');
document.documentElement.style.removeProperty('opacity');
app.unmount();
});
const strip = document.getElementById('cockpit-utility-controls');
const [display, radioControl] = strip.querySelectorAll(
'.cockpit-utility-control',
);
radio.publishUtility({ display: false, radio: true });
await nextTick();
syncSignalLayout.call(owner);
await nextTick();
assert.equal(display.getAttribute('aria-hidden'), 'true');
assert.equal(radioControl.getAttribute('aria-hidden'), 'false');
assert.equal(cockpit.utilityLayout.top, null);
// Compact hides the sibling without marking the strip primary-only.
assert.equal(strip.classList.contains('layout-primary-only'), false);
// Cyber aligns the strip with the Data panel and keeps both controls.
compact = false;
document.documentElement.dataset.uiTheme = 'cyber';
const stack = document.body.appendChild(document.createElement('div'));
stack.id = 'left-panel-stack';
const dataPanel = stack.appendChild(document.createElement('section'));
dataPanel.id = 'data-panel';
for (const node of [
dataPanel,
stack,
document.body,
document.documentElement,
])
node.style.opacity = '1';
dataPanel.getBoundingClientRect = () => ({
top: 120,
bottom: 400,
left: 0,
right: 200,
width: 200,
height: 280,
});
syncSignalLayout.call(owner);
await nextTick();
assert.equal(display.getAttribute('aria-hidden'), 'false');
assert.equal(radioControl.getAttribute('aria-hidden'), 'false');
assert.equal(cockpit.utilityLayout.top, '120.00px');
assert.equal(strip.classList.contains('layout-primary-only'), false);
});