mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
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.
373 lines
11 KiB
JavaScript
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();
|
|
}
|
|
});
|