Files
gods-eye-view/src/ui/cockpitDisplayPortal.test.mjs
T
Sameh Khamis fd4b69ff52 feat(cockpit): teleport the shared Display groups into Cockpit's popover
The Cockpit display portal publishes the Display popover's slots to the
gev-display store while Cockpit is on, and the Display panel teleports its
HUD, Detection, Parameters and 3D groups into them and back, each only
while its own slot is published. The portal no longer moves the groups:
it keeps each surface's scroll position, puts back focus the move
dropped, and lays the surfaces out once Vue has moved them. The popover's
uses-shared-display-controls class renders from the store, and the right
rail no longer re-homes the parameters panel the markup already places.
2026-11-01 15:01:41 -08:00

260 lines
7.3 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { CockpitDisplayPortal } from './cockpitDisplayPortal.js';
function fixture() {
const prior = Object.fromEntries(
['document', 'window', 'requestAnimationFrame', 'cancelAnimationFrame'].map(
(key) => [key, globalThis[key]],
),
);
const frames = new Map();
let nextFrame = 0;
let focusCalls = 0;
let layouts = 0;
class Node extends EventTarget {
constructor(name) {
super();
this.name = name;
this.children = [];
this.parentNode = null;
this.scrollTop = 0;
this.classList = { toggle() {}, remove() {} };
}
append(node) {
node.remove();
this.children.push(node);
node.parentNode = this;
}
remove() {
if (this.parentNode)
this.parentNode.children.splice(
this.parentNode.children.indexOf(this),
1,
);
this.parentNode = null;
}
before(node) {
node.remove();
const parent = this.parentNode;
parent.children.splice(parent.children.indexOf(this), 0, node);
node.parentNode = parent;
}
after(node) {
node.remove();
const parent = this.parentNode;
parent.children.splice(parent.children.indexOf(this) + 1, 0, node);
node.parentNode = parent;
}
contains(node) {
return (
this === node || this.children.some((child) => child.contains(node))
);
}
focus() {
focusCalls += 1;
document.activeElement = this;
}
}
const standard = new Node('standard');
standard.scrollTop = 71;
const cockpit = new Node('cockpit');
cockpit.scrollTop = 19;
const group = new Node('group');
const sibling = new Node('sibling');
const slot = new Node('slot');
standard.append(group);
standard.append(sibling);
cockpit.append(slot);
cockpit.querySelector = () => slot;
globalThis.document = {
createComment: (name) => new Node(name),
activeElement: group,
body: { classList: { contains: () => false } },
};
globalThis.window = new EventTarget();
globalThis.requestAnimationFrame = (fn) => {
const id = ++nextFrame;
frames.set(id, fn);
return id;
};
globalThis.cancelAnimationFrame = (id) => frames.delete(id);
const published = [];
// The Display panel's Teleport: the group moves to the published slot and
// back to its home ahead of its sibling.
const publish = (slots) => {
published.push(slots);
if (slots) slots.hud.append(group);
else if (group.parentNode !== standard) standard.children[0].before(group);
};
const create = () =>
new CockpitDisplayPortal({
standardPanel: standard,
cockpitPanel: cockpit,
groups: [['hud', group]],
layout: () => {
layouts += 1;
},
publish,
});
return {
standard,
cockpit,
group,
sibling,
slot,
frames,
create,
published,
get focusCalls() {
return focusCalls;
},
get layouts() {
return layouts;
},
flush() {
while (frames.size) {
const batch = [...frames.values()];
frames.clear();
batch.forEach((fn) => fn());
}
},
restore() {
Object.assign(globalThis, prior);
},
};
}
test('Display groups return to their exact home with independent scroll positions', () => {
const f = fixture();
try {
const owner = f.create();
owner.setActive(true);
assert.deepEqual(f.published, [{ hud: f.slot }]);
assert.equal(f.group.parentNode, f.slot);
f.cockpit.scrollTop = 0;
f.flush();
assert.equal(f.cockpit.scrollTop, 19);
assert.equal(f.focusCalls, 1);
owner.setActive(false);
f.standard.scrollTop = 0;
f.flush();
assert.equal(f.standard.scrollTop, 71);
owner.destroy();
assert.deepEqual(f.published, [{ hud: f.slot }, null, null]);
assert.deepEqual(f.standard.children, [f.group, f.sibling]);
assert.equal(f.frames.size, 0);
} finally {
f.restore();
}
});
test('the surfaces are laid out and scrolled once the groups have rendered', () => {
const f = fixture();
try {
const rendered = [];
const owner = new CockpitDisplayPortal({
standardPanel: f.standard,
cockpitPanel: f.cockpit,
groups: [['hud', f.group]],
layout: () => rendered.push('layout'),
publish: (slots) => rendered.push(slots ? 'cockpit' : 'standard'),
afterRender: (callback) => rendered.push(callback),
});
owner.setActive(true, { settle: false });
// Published first; the scroll waits for the render.
assert.deepEqual(rendered.slice(0, 1), ['cockpit']);
f.cockpit.scrollTop = 0;
rendered.splice(1).forEach((callback) => callback());
assert.equal(f.cockpit.scrollTop, 19);
owner.setActive(false);
const pending = rendered.filter((item) => typeof item === 'function');
assert.equal(rendered.includes('layout'), false);
pending.forEach((callback) => callback());
assert.equal(rendered.at(-1), 'layout');
owner.destroy();
} finally {
f.restore();
}
});
test('focus moved elsewhere before the frame is not taken back', () => {
const f = fixture();
try {
const owner = f.create();
owner.setActive(true);
// Cockpit entry focuses its exit button before the portal's frame runs.
document.activeElement = f.sibling;
f.flush();
assert.equal(f.focusCalls, 0);
assert.equal(document.activeElement === f.sibling, true);
owner.destroy();
} finally {
f.restore();
}
});
test('superseded portal frames cannot restore obsolete focus or scroll', () => {
const f = fixture();
try {
const owner = f.create();
owner.setActive(true);
const obsolete = [...f.frames.values()];
owner.setActive(false);
f.cockpit.scrollTop = 93;
obsolete.forEach((callback) => callback());
assert.equal(f.focusCalls, 0);
assert.equal(f.cockpit.scrollTop, 93);
f.flush();
assert.equal(f.focusCalls, 1);
assert.equal(owner.restoreOwner, null);
owner.destroy();
} finally {
f.restore();
}
});
test('disposal restores groups and revokes mode listeners and queued focus work', () => {
const f = fixture();
try {
const owner = f.create();
window.dispatchEvent(
new CustomEvent('gev:cockpit-mode-changed', { detail: { active: true } }),
);
assert.equal(f.group.parentNode, f.slot);
const obsolete = [...f.frames.values()];
owner.destroy();
owner.destroy();
obsolete.forEach((callback) => callback());
window.dispatchEvent(
new CustomEvent('gev:cockpit-mode-changed', { detail: { active: true } }),
);
owner.setActive(true);
assert.deepEqual(f.standard.children, [f.group, f.sibling]);
assert.equal(f.frames.size, 0);
assert.equal(f.focusCalls, 0);
assert.equal(f.layouts, 1);
} finally {
f.restore();
}
});
test('a stopped portal waits for cleanup without running queued focus work', () => {
const f = fixture();
try {
const owner = f.create();
owner.setActive(true);
const queued = [...f.frames.values()];
owner.stop();
queued.forEach((callback) => callback());
owner.setActive(false);
assert.equal(f.group.parentNode, f.slot);
assert.equal(f.frames.size, 0);
assert.equal(f.focusCalls, 0);
owner.destroy();
assert.deepEqual(f.standard.children, [f.group, f.sibling]);
} finally {
f.restore();
}
});