Files
gods-eye-view/src/ui/shellLifecycle.test.mjs
T
German KhokhlovandClaude Opus 5.5 28e0ba7104 fix(street-level): /revmain cleanup after the slim rebuild
- Google 3D: the OPEN button re-measures a ground whose tiles are still
  loading (a few times, a second apart) and on a map change, not only
  after the next camera move.
- Mapillary: two mounts on one host during the first download share one
  build instead of leaking a second WebGL viewer.
- Retired floating-panel keys (v8 panelPos, panelFloatHintShown) are
  dropped from localStorage; dead inline-position resets, the drag JSDoc
  and the dead qa-ui-disposal observer branch are gone.
- Docs: z ladder, panel chrome, position storage and the fixture gate's
  actual coverage; changelog fragment for #974 (old .gev-cache/mapillary
  can be deleted).
- ru/es: the layer is named as everywhere else in the UI.
- Fixtures and test fakes: unused exports removed; a fake credit removal
  of an absent credit no longer drops another one; the tile test reset
  frees upstream slots.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 14:02:09 +04:00

354 lines
11 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { UiLifetime } from './uiLifetime.js';
import { PanelChrome } from './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',
'toast',
'global-loading-status',
'global-loading-detail',
'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() {},
syncPanelCollapseButton() {},
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 owner = new ShellFeedback({ readLayers: () => [] });
owner.observeVisibility();
owner._startTrafficChipTicker();
owner._showToast('Saved');
assert.equal(f.timers.size, 2);
owner.destroy();
owner._showToast('Late');
owner._showGlobalStatusNotice('Late');
owner._startTrafficChipTicker();
document.dispatchEvent(new Event('visibilitychange'));
assert.equal(f.timers.size, 0);
assert.equal(f.nodes.get('toast').textContent, 'Saved');
assert.equal(f.nodes.get('toast').classList.contains('visible'), false);
assert.equal(f.nodes.get('global-loading-status').hidden, true);
} 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 owner = new RecordingControls({ syncShareState() {} });
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.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 syncs 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');
const button = f.element();
button.closest = () => panel;
const attributes = new Map();
button.setAttribute = (name, value) => attributes.set(name, value);
button.getAttribute = (name) => attributes.get(name);
panel.button = button;
panel.querySelectorAll = () => [button];
panel.querySelector = (selector) =>
selector.startsWith('.panel-title')
? { textContent: id === 'weather-panel' ? 'WEATHER' : id }
: null;
f.nodes.set(id, panel);
}
const weather = f.nodes.get('weather-panel');
stack.children = [weather, f.nodes.get('global-context-panel')];
const synced = [];
const chrome = { _syncContextRadioLauncherState() {} };
const owner = new PanelLayoutController({
readHud: () => ({}),
scheduleCockpitLayout() {},
readDisplayScrollTop: () => 0,
syncPanelCollapseButton(panel) {
synced.push(panel.id);
PanelChrome.prototype._syncPanelCollapseButton.call(chrome, panel);
},
});
owner._initRightPanelAdaptiveLayout();
assert.deepEqual(
stack.children.map((panel) => panel.id),
['pp-toggles', 'cctv-panel', 'weather-panel', 'global-context-panel'],
);
assert.ok(synced.includes('weather-panel'));
assert.ok(observed.has(weather));
assert.equal(weather.button.textContent, '◀');
assert.equal(weather.button.getAttribute('aria-expanded'), 'false');
assert.equal(weather.button.title, 'Expand WEATHER');
weather.classList.remove('collapsed');
PanelChrome.prototype._syncPanelCollapseButton.call(chrome, weather);
assert.equal(weather.button.textContent, '▶');
assert.equal(weather.button.getAttribute('aria-expanded'), 'true');
assert.equal(weather.button.getAttribute('aria-label'), 'Collapse WEATHER');
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();
}
});