feat(ui): render the Cockpit signal rows from a store, keeping focus

The Cockpit controller no longer reconciles the live signal rows by hand.
publishCockpitSignals turns its signal items into rows, keyed by the
signal and its action target as before, with repeats of a key numbered,
and publishes them to the gev-cockpit store; a new CockpitSignalList
component renders them with a keyed v-for inside the briefing card.

Rows reorder several times a second, and a browser blurs any element it
moves. The new v-focus-safe-children directive turns Vue's move of the
row holding focus into moves of the rows around it, reaching the same
order while the focused row stays connected, and before the focused row
is removed it hands focus to the shown briefing page's tab, or the
collapse toggle, as the reconciler did. Selection stays with the
controller's click listener.

The focus test now runs the real components over happy-dom and counts
any move or removal of the focused row as a loss, including across 60
random reorders. The Cockpit QA script publishes and waits a frame
before reading the rows.
This commit is contained in:
Sameh Khamis committed 2026-10-31 16:39:08 -07:00
1 parent a2bca0fb22
commit 58504e370c
12 files changed
+438 -387

No files matched your search

+264 -243
View File
@@ -1,187 +1,119 @@
// src/cockpitSignalFocus.test.mjs — the Cockpit signal rows keep keyboard
// focus while they refresh and reorder, over happy-dom with the real view.
import './testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { afterEach } from 'node:test';
import { createPinia } from 'pinia';
import { createApp, nextTick } from 'vue';
import { formatAwarenessLabel } from './data/militaryAwarenessEngine.js';
import { CockpitViewController } from './ui/cockpitController.js';
const previousDocument = globalThis.document;
afterEach(() => {
globalThis.document = previousDocument;
});
import { createBriefState } from './ui/cockpitBriefPages.js';
import {
handleSignalClick,
publishCockpitSignals,
pushCockpitSignal,
updateCockpitSignals,
} from './ui/cockpitSignals.js';
import { useCockpitStore } from './ui/stores/cockpit.js';
import CockpitBriefing from './ui/views/panels/CockpitBriefing.vue';
// Model native focus loss on removal AND on ordinary DOM moves. Keeping an
// object reference alone is insufficient if reconciliation disconnects it.
function fixture() {
const document = { activeElement: null };
let focusLosses = 0;
let focusCalls = 0;
class Node {
constructor(tagName) {
this.tagName = tagName.toUpperCase();
this.children = [];
this.parentNode = null;
this.dataset = {};
this.attributes = new Map();
this.className = '';
this._text = '';
this.classList = {
add: (...names) => {
this.className = [
...new Set([
...this.className.split(/\s+/).filter(Boolean),
...names,
]),
].join(' ');
},
};
/**
* A browser blurs an element it removes or moves. happy-dom only models the
* removal, so count both: any insert or removal of a node that holds focus
* is a loss, and a moved node gives its focus up as a browser's would.
*/
function trackFocus(t) {
const counts = { losses: 0, focusCalls: 0 };
const holdsFocus = (node) =>
node.isConnected && node.contains(document.activeElement);
const insertBefore = Node.prototype.insertBefore;
t.mock.method(Node.prototype, 'insertBefore', function (node, anchor) {
const lost = holdsFocus(node);
const result = insertBefore.call(this, node, anchor);
if (lost) {
counts.losses += 1;
document.activeElement.blur();
}
get isConnected() {
return this === document.body || Boolean(this.parentNode?.isConnected);
}
get firstElementChild() {
return this.children[0] || null;
}
get nextElementSibling() {
return (
this.parentNode?.children[this.parentNode.children.indexOf(this) + 1] ||
null
);
}
get textContent() {
return (
this._text + this.children.map((node) => node.textContent).join('')
);
}
set textContent(value) {
this.replaceChildren();
this._text = String(value);
}
setAttribute(name, value) {
this.attributes.set(name, String(value));
}
getAttribute(name) {
return this.attributes.get(name) ?? null;
}
contains(node) {
return (
this === node || this.children.some((child) => child.contains(node))
);
}
append(...nodes) {
for (const node of nodes) this.insertBefore(node, null);
}
insertBefore(node, anchor) {
assert.ok(
anchor === null || anchor.parentNode === this,
'insert anchor belongs to its parent',
);
if (node === anchor) return node;
node.remove();
const index =
anchor === null ? this.children.length : this.children.indexOf(anchor);
this.children.splice(index, 0, node);
node.parentNode = this;
return node;
}
remove() {
if (!this.parentNode) return;
if (this.contains(document.activeElement)) {
document.activeElement = document.body;
focusLosses += 1;
}
this.parentNode.children.splice(
this.parentNode.children.indexOf(this),
1,
);
this.parentNode = null;
}
replaceChildren(...nodes) {
for (const child of [...this.children]) child.remove();
this._text = '';
this.append(...nodes);
}
matches(selector) {
if (selector === 'button[data-signal-layer][data-signal-id]') {
return (
this.tagName === 'BUTTON' &&
this.dataset.signalLayer != null &&
this.dataset.signalId != null
);
}
return selector.startsWith('.')
? this.className.split(/\s+/).includes(selector.slice(1))
: this.tagName === selector.toUpperCase();
}
closest(selector) {
for (let node = this; node; node = node.parentNode)
if (node.matches(selector)) return node;
return null;
}
querySelectorAll(selector) {
return this.children.flatMap((child) => [
...(child.matches(selector) ? [child] : []),
...child.querySelectorAll(selector),
]);
}
focus() {
assert.ok(
this.isConnected,
'a focus destination must still be connected',
);
focusCalls += 1;
document.activeElement = this;
}
}
document.body = new Node('body');
document.createElement = (tag) => new Node(tag);
document.activeElement = document.body;
globalThis.document = document;
const controller = Object.create(CockpitViewController.prototype);
controller.services = { formatAwarenessLabel };
controller.signalList = new Node('ol');
controller.signalToggle = new Node('button');
controller.briefTabs = [
new Node('button'),
new Node('button'),
new Node('button'),
];
controller.briefPageIndex = 0;
controller.signalItems = [];
controller.signalSignatures = new Map();
controller.scheduleContextLayout = () => {};
const display = new Node('button');
const radio = new Node('button');
const outside = new Node('button');
document.body.append(
controller.signalToggle,
controller.signalList,
...controller.briefTabs,
display,
radio,
outside,
);
return result;
});
const removeChild = Node.prototype.removeChild;
t.mock.method(Node.prototype, 'removeChild', function (node) {
if (holdsFocus(node)) counts.losses += 1;
return removeChild.call(this, node);
});
const focus = HTMLElement.prototype.focus;
t.mock.method(HTMLElement.prototype, 'focus', function (...args) {
assert.ok(this.isConnected, 'a focus destination must still be connected');
counts.focusCalls += 1;
return focus.apply(this, args);
});
return counts;
}
function fixture(t) {
document.body.replaceChildren();
const host = document.body.appendChild(document.createElement('div'));
const pinia = createPinia();
const app = createApp(CockpitBriefing).use(pinia);
app.mount(host);
const store = useCockpitStore(pinia);
const button = () => {
const element = document.createElement('button');
element.type = 'button';
return document.body.appendChild(element);
};
const display = button();
const radio = button();
const outside = button();
const counts = trackFocus(t);
t.after(() => app.unmount());
const selected = [];
controller.services.militaryAwarenessLayer = {
focusTarget: (...args) => selected.push(args),
// The signal presenters need only this much of the controller.
const owner = {
destroyed: false,
brief: createBriefState(),
signalItems: [],
signalSignatures: new Map(),
publishSignals: (rows) => store.publishSignals(rows),
publishCockpitSignals,
scheduleContextLayout() {},
services: {
formatAwarenessLabel,
militaryAwarenessLayer: {
focusTarget: (...args) => selected.push(args),
},
},
};
const click = (event) => controller.handleSignalClick(event);
const buttons = () => controller.signalList.querySelectorAll('button');
// The controller shows the card on entering Cockpit.
document.getElementById('cockpit-signal-stream').hidden = false;
const list = document.getElementById('cockpit-signal-list');
list.addEventListener('click', (event) =>
handleSignalClick.call(owner, event),
);
const buttons = () => [...list.querySelectorAll('button')];
// Tab visits the enabled buttons no hidden ancestor removes from view.
const tab = () => {
const nodes = document.body.querySelectorAll('button');
const index = nodes.indexOf(document.activeElement);
nodes[(index + 1) % nodes.length].focus();
const nodes = [...document.querySelectorAll('button')].filter(
(node) => !node.disabled && !node.closest('[hidden]'),
);
nodes[(nodes.indexOf(document.activeElement) + 1) % nodes.length].focus();
};
const pageTab = (index) =>
document.querySelector(`[data-cockpit-brief-index="${index}"]`);
const signalTab = () => pageTab(0);
return {
controller,
document,
owner,
list,
buttons,
display,
radio,
outside,
selected,
click,
tab,
losses: () => focusLosses,
focusCalls: () => focusCalls,
pageTab,
signalTab,
store,
losses: () => counts.losses,
focusCalls: () => counts.focusCalls,
};
}
@@ -194,9 +126,14 @@ const item = (id, overrides = {}) => ({
timestamp: 1_700_000_000_000,
...overrides,
});
const render = (f, items) => {
f.controller.signalItems = items;
f.controller.renderCockpitSignals();
const render = async (f, items) => {
f.owner.signalItems = items;
publishCockpitSignals.call(f.owner);
await nextTick();
};
const refresh = async (f, distanceM) => {
updateCockpitSignals.call(f.owner, snapshot(distanceM), 0);
await nextTick();
};
const snapshot = (distanceM = 2000) => ({
evaluatedAt: 1_700_000_000_000,
@@ -210,31 +147,61 @@ const snapshot = (distanceM = 2000) => ({
],
});
test('actual Context refresh retains the focused signal button across unchanged and changed details', () => {
const f = fixture();
f.controller.updateCockpitSignals(snapshot(), 0);
test('rows render the signals the controller presents', async (t) => {
const f = fixture(t);
await render(f, [
item('a'),
item('status', { tone: 'warning', target: null, title: 'UNKNOWN' }),
]);
const [action, status] = f.list.children;
assert.equal(action.className, 'nearby actionable');
assert.equal(action.querySelector('time').textContent, '22:13:20Z');
const button = action.querySelector('button.cockpit-signal-target');
assert.equal(button.type, 'button');
assert.equal(button.dataset.signalLayer, 'flights');
assert.equal(button.dataset.signalId, 'a');
assert.equal(button.getAttribute('aria-label'), 'Select flight Flight a');
assert.equal(
button.querySelector('.cockpit-signal-target-label').textContent,
'Flight a',
);
assert.equal(
button.querySelector('.cockpit-signal-target-chevron').textContent,
'chevron_right',
);
assert.equal(
action.querySelector('div > span').textContent,
'COMMERCIAL FLIGHT · 2 KM',
);
assert.equal(status.className, 'warning');
assert.equal(status.querySelector('div > strong').textContent, 'UNKNOWN');
assert.equal(status.querySelector('button'), null);
});
test('actual Context refresh retains the focused signal button across unchanged and changed details', async (t) => {
const f = fixture(t);
await refresh(f);
const target = f.buttons()[1];
target.focus();
const calls = f.focusCalls();
for (let tick = 0; tick < 8; tick += 1)
f.controller.updateCockpitSignals(snapshot(2500), 0);
assert.equal(f.buttons()[1], target);
assert.equal(f.document.activeElement, target);
for (let tick = 0; tick < 8; tick += 1) await refresh(f, 2500);
assert.equal(f.buttons()[1] === target, true);
assert.equal(document.activeElement === target, true);
assert.equal(f.losses(), 0);
assert.equal(
f.focusCalls(),
calls,
'refresh must preserve focus without repeatedly restoring it',
);
assert.match(f.controller.signalList.textContent, /2\.5\u00a0KM/);
assert.match(f.list.textContent, /2\.5\u00a0KM/);
});
test('signal content and accessible name update without replacing the active target', () => {
const f = fixture();
render(f, [item('a')]);
test('signal content and accessible name update without replacing the active target', async (t) => {
const f = fixture(t);
await render(f, [item('a')]);
const target = f.buttons()[0];
target.focus();
render(f, [
await render(f, [
item('a', {
title: 'RENAMED',
tone: 'track',
@@ -242,21 +209,18 @@ test('signal content and accessible name update without replacing the active tar
timestamp: 1_700_000_001_000,
}),
]);
assert.equal(f.buttons()[0], target);
assert.equal(f.buttons()[0] === target, true);
assert.equal(target.getAttribute('aria-label'), 'Select flight RENAMED');
assert.match(target.textContent, /RENAMED/);
assert.match(f.controller.signalList.textContent, /CURRENT/);
assert.equal(
f.controller.signalList.children[0].className,
'track actionable',
);
assert.equal(f.document.activeElement, target);
assert.match(f.list.textContent, /CURRENT/);
assert.equal(f.list.children[0].className, 'track actionable');
assert.equal(document.activeElement === target, true);
assert.equal(f.losses(), 0);
});
test('signal ranking changes move surrounding rows without disconnecting the focused row', () => {
const f = fixture();
render(
test('signal ranking changes move surrounding rows without disconnecting the focused row', async (t) => {
const f = fixture(t);
await render(
f,
['a', 'b', 'c', 'd'].map((id) => item(id)),
);
@@ -267,8 +231,10 @@ test('signal ranking changes move surrounding rows without disconnecting the foc
['d', 'c', 'b', 'a'],
['b', 'c', 'd', 'a'],
['a', 'd', 'c', 'b'],
['c', 'a', 'b', 'd'],
['b', 'a', 'd', 'c'],
]) {
render(
await render(
f,
order.map((id) => item(id)),
);
@@ -276,97 +242,152 @@ test('signal ranking changes move surrounding rows without disconnecting the foc
f.buttons().map((button) => button.dataset.signalId),
order,
);
assert.equal(f.document.activeElement, target);
assert.equal(document.activeElement === target, true);
assert.equal(f.losses(), 0);
}
assert.equal(f.focusCalls(), calls);
});
test('a new pushed signal preserves existing focus and delegated selection reads the current target', () => {
const f = fixture();
render(f, [item('a'), item('b')]);
test('a new pushed signal preserves existing focus and delegated selection reads the current target', async (t) => {
const f = fixture(t);
await render(f, [item('a'), item('b')]);
const target = f.buttons()[1];
target.focus();
f.controller.pushCockpitSignal(
pushCockpitSignal.call(
f.owner,
'status',
'warning',
'INPUT UNKNOWN',
'SOURCE UNAVAILABLE',
);
assert.equal(f.document.activeElement, target);
await nextTick();
assert.equal(f.list.children.length, 3);
assert.equal(document.activeElement === target, true);
assert.equal(f.losses(), 0);
const event = {
target: target.children[0],
prevented: false,
preventDefault() {
this.prevented = true;
},
};
f.click(event);
assert.equal(event.prevented, true);
const click = new MouseEvent('click', { bubbles: true, cancelable: true });
target.querySelector('.cockpit-signal-target-label').dispatchEvent(click);
assert.equal(click.defaultPrevented, true);
assert.deepEqual(f.selected, [['flights', 'b', { origin: 'user' }]]);
});
test('removing the focused identity uses the existing briefing tab once and permits forward traversal', () => {
const f = fixture();
render(f, [item('a'), item('b')]);
test('removing the focused identity uses the shown briefing tab once and permits forward traversal', async (t) => {
const f = fixture(t);
await render(f, [item('a'), item('b')]);
const removed = f.buttons()[1];
removed.focus();
const calls = f.focusCalls();
render(f, [item('a')]);
await render(f, [item('a')]);
assert.equal(removed.isConnected, false);
assert.equal(f.document.activeElement, f.controller.briefTabs[0]);
assert.equal(document.activeElement === f.signalTab(), true);
assert.equal(f.focusCalls(), calls + 1);
assert.equal(f.losses(), 0);
assert.equal(f.signalTab().closest('[hidden]'), null);
// Traversal carries on forward from the footer tab.
f.tab();
f.tab();
f.tab();
assert.equal(f.document.activeElement, f.display);
render(f, [item('a', { detail: 'UPDATED' })]);
assert.equal(document.activeElement === f.pageTab(1), true);
while (document.activeElement !== f.display) f.tab();
await render(f, [item('a', { detail: 'UPDATED' })]);
assert.equal(
f.document.activeElement,
f.display,
document.activeElement === f.display,
true,
'later refresh must not pull focus back',
);
f.tab();
assert.equal(f.document.activeElement, f.radio);
assert.equal(document.activeElement === f.radio, true);
});
test('a changed target cannot reuse a focused action for another flight', () => {
const f = fixture();
render(f, [item('a', { key: 'shared-status-key' })]);
test('a changed target cannot reuse a focused action for another flight', async (t) => {
const f = fixture(t);
await render(f, [item('a', { key: 'shared-status-key' })]);
const removed = f.buttons()[0];
removed.focus();
render(f, [
await render(f, [
item('b', {
key: 'shared-status-key',
target: { layerId: 'military', id: 'b' },
}),
]);
const target = f.buttons()[0];
assert.notEqual(target, removed);
assert.equal(target === removed, false);
assert.equal(removed.isConnected, false);
assert.equal(f.document.activeElement, f.controller.briefTabs[0]);
f.click({ target, preventDefault() {} });
assert.equal(document.activeElement === f.signalTab(), true);
target.click();
assert.deepEqual(f.selected, [['military', 'b', { origin: 'user' }]]);
});
test('empty, informational and duplicate-key rows have no stale or aliased actions', () => {
const f = fixture();
render(f, [item('a'), item('a')]);
test('empty, informational and duplicate-key rows have no stale or aliased actions', async (t) => {
const f = fixture(t);
await render(f, [item('a'), item('a')]);
const [first, second] = f.buttons();
assert.notEqual(first, second);
assert.equal(first === second, false);
second.focus();
render(f, [item('a'), item('a')]);
assert.deepEqual(f.buttons(), [first, second]);
assert.equal(f.document.activeElement, second);
render(f, [item('status', { target: null, title: 'UNKNOWN' })]);
await render(f, [item('a'), item('a')]);
const again = f.buttons();
assert.equal(again[0] === first && again[1] === second, true);
assert.equal(document.activeElement === second, true);
await render(f, [item('status', { target: null, title: 'UNKNOWN' })]);
assert.equal(f.buttons().length, 0);
assert.match(f.controller.signalList.textContent, /UNKNOWN/);
assert.equal(f.document.activeElement, f.controller.briefTabs[0]);
assert.match(f.list.textContent, /UNKNOWN/);
assert.equal(document.activeElement === f.signalTab(), true);
f.outside.focus();
render(f, []);
assert.equal(f.controller.signalList.children.length, 0);
render(f, [item('a')]);
assert.notEqual(f.buttons()[0], first);
assert.equal(f.document.activeElement, f.outside);
await render(f, []);
assert.equal(f.list.children.length, 0);
await render(f, [item('a')]);
assert.equal(f.buttons()[0] === first, false);
assert.equal(document.activeElement === f.outside, true);
assert.equal(f.losses(), 0);
});
test('any reordering of five rows keeps focus wherever it sits', async (t) => {
const f = fixture(t);
const ids = ['a', 'b', 'c', 'd', 'e'];
// A fixed pseudo-random sequence, so a failure reproduces.
let seed = 7;
const random = () => {
seed = (seed * 48271) % 2147483647;
return seed / 2147483647;
};
const shuffle = (list) => {
const next = [...list];
for (let index = next.length - 1; index > 0; index -= 1) {
const swap = Math.floor(random() * (index + 1));
[next[index], next[swap]] = [next[swap], next[index]];
}
return next;
};
await render(
f,
ids.map((id) => item(id)),
);
for (let round = 0; round < 60; round += 1) {
const target = f.buttons()[round % ids.length];
target.focus();
const order = shuffle(ids);
await render(
f,
order.map((id) => item(id)),
);
assert.deepEqual(
f.buttons().map((button) => button.dataset.signalId),
order,
);
assert.equal(document.activeElement === target, true);
}
assert.equal(f.losses(), 0);
});
test('the fallback follows the shown page, and the collapse toggle without tabs', async (t) => {
const f = fixture(t);
f.store.publishBrief({ ...createBriefState(), pageIndex: 2 });
await render(f, [item('a'), item('b')]);
f.buttons()[1].focus();
await render(f, [item('a')]);
assert.equal(document.activeElement === f.pageTab(2), true);
for (const index of [0, 1, 2]) f.pageTab(index).remove();
f.buttons()[0].focus();
await render(f, []);
assert.equal(
document.activeElement === document.getElementById('cockpit-signal-toggle'),
true,
);
});