Files
gods-eye-view/src/ui/shellLifecycle.test.mjs
T
Sameh Khamis 95523e09cd feat(panels): render each panel's state classes from the store
A panel's collapsed, dock-pinned, dock-pinned-top, layout-auto-collapsed
and accordion-collapsed classes are drawn by its root's v-panel-state from
the gev-panel-chrome store. PanelChrome, its restore, the layout
controller, both rails and the side accordion present each panel's state
there instead of writing its classes; the rails call onCollapse(panel,
{collapsed, auto}). The directive applies a change in a synchronous
watcher, since the rails measure a panel in the task that opens or closes
it.

Destroying the chrome inside Cockpit now puts back the panels Cockpit's
entry closed while it can still present them. Without a shell, the voice
tools' set_panel_open reports that there is no panel to open. QA scripts
present the states they set instead of writing the classes.
2026-10-31 23:51:47 -07:00

373 lines
11 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { UiLifetime } from './uiLifetime.js';
import { createPinia } from 'pinia';
import { PanelChrome } from './panelChrome.js';
import { followPanelState } from './rail/panelState.js';
import { usePanelChromeStore } from './stores/panelChrome.js';
import { PanelLayoutController } from './panelLayoutController.js';
import { ShellFeedback } from './shellFeedback.js';
import { RecordingControls } from './recordingControls.js';
function fixture() {
const saved = Object.fromEntries(
[
'document',
'window',
'localStorage',
'requestAnimationFrame',
'cancelAnimationFrame',
'setTimeout',
'clearTimeout',
'setInterval',
'clearInterval',
].map((key) => [key, globalThis[key]]),
);
const frames = new Map(),
timers = new Map();
let next = 0;
const element = () => {
const classes = new Set();
return Object.assign(new EventTarget(), {
style: {},
dataset: {},
hidden: false,
textContent: '',
classList: {
add: (...names) => names.forEach((name) => classes.add(name)),
remove: (...names) => names.forEach((name) => classes.delete(name)),
contains: (name) => classes.has(name),
toggle: (name, active) =>
active ? classes.add(name) : classes.delete(name),
},
querySelector: () => null,
querySelectorAll: () => [],
closest: () => null,
getBoundingClientRect: () => ({
left: 20,
top: 30,
width: 100,
height: 80,
}),
});
};
const nodes = new Map(
[
'left-panel-stack',
'right-context-rail',
'pp-toggles',
'safe-frame-overlay',
'safe-frame-box',
'hud-toggle',
'hud-layout-select',
].map((id) => [id, element()]),
);
globalThis.document = Object.assign(new EventTarget(), {
hidden: false,
getElementById: (id) => nodes.get(id) || null,
querySelectorAll: () => [],
body: element(),
});
globalThis.window = Object.assign(new EventTarget(), {
innerWidth: 600,
innerHeight: 400,
});
const writes = [];
globalThis.localStorage = {
setItem: (...args) => writes.push(args),
getItem: () => null,
};
globalThis.requestAnimationFrame = (callback) => {
frames.set(++next, callback);
return next;
};
globalThis.cancelAnimationFrame = (id) => frames.delete(id);
globalThis.setTimeout = globalThis.setInterval = (callback) => {
timers.set(++next, callback);
return next;
};
globalThis.clearTimeout = globalThis.clearInterval = (id) =>
timers.delete(id);
return {
frames,
timers,
nodes,
element,
writes,
restore() {
Object.assign(globalThis, saved);
},
};
}
test('UI lifetime revokes listeners and nested presentation work synchronously', () => {
const f = fixture();
try {
const owner = new UiLifetime(),
target = new EventTarget();
let calls = 0;
owner.listen(target, 'change', () => calls++);
owner.frame(() => {
calls++;
owner.frame(() => calls++);
});
owner.timeout(() => calls++, 100);
const retainedFrame = [...f.frames.values()][0];
owner.destroy();
owner.destroy();
target.dispatchEvent(new Event('change'));
retainedFrame();
owner.frame(() => calls++);
owner.timeout(() => calls++, 1);
assert.equal(calls, 0);
assert.equal(f.frames.size + f.timers.size + owner.removers.size, 0);
} finally {
f.restore();
}
});
test('one-time UI listeners and cancelled notice timers release their registrations', () => {
const f = fixture();
try {
const owner = new UiLifetime(),
target = new EventTarget();
let calls = 0;
owner.listen(target, 'change', () => calls++, { once: true });
target.dispatchEvent(new Event('change'));
target.dispatchEvent(new Event('change'));
owner.cancelTimeout(owner.timeout(() => calls++, 100));
assert.equal(calls, 1);
assert.equal(owner.removers.size + owner.timers.size + f.timers.size, 0);
owner.destroy();
} finally {
f.restore();
}
});
test('panel layout coalesces each rail and cannot rearm after destruction', () => {
const f = fixture();
try {
let layouts = 0;
const owner = new PanelLayoutController({
readHud: () => ({}),
scheduleCockpitLayout() {},
presentPanel() {},
readDisplayScrollTop: () => 0,
});
owner._syncLeftPanelAdaptiveLayout = () => layouts++;
owner._scheduleLeftPanelLayout();
owner._scheduleLeftPanelLayout();
assert.equal(f.frames.size, 1);
owner._scheduleAdaptivePanelLayout({ settle: true });
assert.equal(f.frames.size, 2);
assert.equal(f.timers.size, 1);
const late = [...f.frames.values(), ...f.timers.values()];
owner.destroy();
owner.destroy();
late.forEach((callback) => callback());
owner._scheduleAdaptivePanelLayout({ settle: true });
assert.equal(layouts, 0);
assert.equal(f.frames.size + f.timers.size, 0);
} finally {
f.restore();
}
});
test('feedback disposal clears toast and polling work and rejects retained notices', () => {
const f = fixture();
try {
const loading = [];
const toasts = [];
const owner = new ShellFeedback({
readLayers: () => [],
store: {
toast: null,
publishToast(view) {
this.toast = view;
toasts.push(view);
},
publishLoading: (view) => loading.push(view),
publishTraffic() {},
},
});
owner.observeVisibility();
owner._startTrafficChipTicker();
owner._showToast('Saved');
assert.equal(f.timers.size, 2);
owner._showGlobalStatusNotice('Saving');
assert.equal(loading.at(-1).hidden, false);
owner.destroy();
owner._showToast('Late');
owner._showGlobalStatusNotice('Late');
owner._startTrafficChipTicker();
document.dispatchEvent(new Event('visibilitychange'));
assert.equal(f.timers.size, 0);
assert.deepEqual(
toasts.map(({ text, visible }) => ({ text, visible })),
[
{ text: 'Saved', visible: true },
{ text: 'Saved', visible: false },
],
);
assert.equal(loading.at(-1).hidden, true);
assert.equal(loading.at(-1).label, 'Saving', 'the hidden label stays');
} finally {
f.restore();
}
});
test('recording restores the original HUD after repeated entry and revokes retained controls', () => {
const f = fixture();
try {
let mode = 'auto',
variant = 'tactical';
const hudSyncs = [];
const owner = new RecordingControls({
syncShareState() {},
syncHudControls: () => hudSyncs.push(variant),
});
owner.hud = {
getMode: () => mode,
getVariant: () => variant,
setMode: (value) => {
mode = value;
},
setVariant: (value) => {
variant = value;
},
visible: true,
};
owner.setRecordingMode(true, { hudMode: 'minimal', safeFrame: '9:16' });
owner.setRecordingMode(true, { hudMode: 'off' });
owner.destroy();
owner.setRecordingMode(true);
assert.equal(mode, 'auto');
assert.equal(variant, 'tactical');
assert.deepEqual(
hudSyncs,
['minimal', 'minimal', 'tactical'],
'each change shows the HUD layout it leaves',
);
assert.equal(document.body.classList.contains('recording-mode'), false);
assert.equal(
f.nodes.get('safe-frame-overlay').classList.contains('active'),
false,
);
} finally {
f.restore();
}
});
test('weather rail orders between CCTV and Context, observes and presents chrome', (t) => {
const f = fixture();
const observed = new Set();
const previousResizeObserver = globalThis.ResizeObserver;
globalThis.ResizeObserver = class {
observe(node) {
observed.add(node);
}
disconnect() {
observed.clear();
}
};
t.after(() => {
if (previousResizeObserver === undefined) delete globalThis.ResizeObserver;
else globalThis.ResizeObserver = previousResizeObserver;
});
try {
const stack = f.nodes.get('right-context-rail');
stack.children = [];
stack.insertBefore = (panel, before) => {
stack.children = stack.children.filter((child) => child !== panel);
const index = before
? stack.children.indexOf(before)
: stack.children.length;
stack.children.splice(index, 0, panel);
};
stack.prepend = (panel) => stack.insertBefore(panel, stack.children[0]);
for (const id of [
'pp-toggles',
'cctv-panel',
'weather-panel',
'global-context-panel',
]) {
const panel = f.nodes.get(id) || f.element();
panel.id = id;
panel.style = {};
panel.classList.add('collapsed');
f.nodes.set(id, panel);
}
const weather = f.nodes.get('weather-panel');
stack.children = [weather, f.nodes.get('global-context-panel')];
const store = usePanelChromeStore(createPinia());
const chrome = { _chromeStore: store, _chromeToken: store.connect({}) };
const presented = [];
const owner = new PanelLayoutController({
readHud: () => ({}),
scheduleCockpitLayout() {},
readDisplayScrollTop: () => 0,
presentPanel(panel, patch) {
presented.push(panel.id);
PanelChrome.prototype._presentPanel.call(chrome, panel, patch);
},
});
owner._initRightPanelAdaptiveLayout();
assert.deepEqual(
stack.children.map((panel) => panel.id),
['pp-toggles', 'cctv-panel', 'weather-panel', 'global-context-panel'],
);
assert.ok(observed.has(weather));
// Moving a panel into the rail leaves its state as it was.
assert.deepEqual(presented, []);
followPanelState(store, 'weather-panel', weather);
PanelChrome.prototype._presentPanel.call(chrome, weather, {
collapsed: false,
});
assert.equal(store.panels['weather-panel'].collapsed, false);
assert.equal(weather.classList.contains('collapsed'), false);
owner.destroy();
assert.equal(observed.size, 0);
assert.equal(f.frames.size, 0);
} finally {
f.restore();
}
});
test('busy traffic completion is polled promptly without accelerating idle or global reads', (t) => {
const f = fixture();
let now = 500;
t.mock.method(performance, 'now', () => now);
try {
const owner = new ShellFeedback({ readLayers: () => [] });
let traffic = 0,
global = 0;
owner._updateTrafficSyncChip = () => traffic++;
owner._updateGlobalLoadingFeedback = () => global++;
owner._startTrafficChipTicker();
const tick = f.timers.get(owner._trafficChipTicker);
tick();
assert.equal(traffic, 1);
assert.equal(global, 1);
now = 600;
tick();
assert.equal(traffic, 1, 'idle polling remains bounded');
owner._trafficSyncFeedbackState.busy = true;
now = 700;
tick();
assert.equal(traffic, 2, 'busy completion is observed on a 100 ms tick');
assert.equal(global, 1, 'other loading feedback keeps its prior budget');
owner._trafficSyncFeedbackState.busy = false;
now = 800;
tick();
assert.equal(traffic, 2);
document.hidden = true;
now = 1300;
tick();
assert.equal(traffic, 2, 'hidden pages do no polling');
owner.destroy();
assert.equal(f.timers.size, 0);
} finally {
f.restore();
}
});