Files
gods-eye-view/src/ui/panelDock.test.mjs
T
Said TurkseverandClaude Opus 5.5 f33cd54e2b fix(panels): a collapsed window keeps its chosen height; handle early tile failures
- A floating portable panel saved its measured box, so dragging a
  collapsed window stored its 50 px header strip and a reload clamped it
  to the minimum. It now saves the inline size the user chose. A unit
  test and a collapse -> drag -> reload -> expand step in the panel gate
  pin it.
- Coverage awaits the tile fetch and the one-time terrain table load
  together, so a tile that fails before the table loads is reported at
  once instead of surfacing as an unhandled rejection.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-06 23:27:04 +01:00

581 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict';
import test from 'node:test';
import { PanelPositionControls } from './panelPositionControls.js';
/** Just enough DOM for PanelPositionControls' portable-panel paths. */
function fixture() {
const saved = Object.fromEntries(
['document', 'window', 'localStorage', 'performance', 'ResizeObserver'].map(
(key) => [key, globalThis[key]],
),
);
const store = new Map();
const element = () => {
const classes = new Set();
const style = {
removeProperty(name) {
// Like CSSStyleDeclaration: 'z-index' also clears style.zIndex.
delete this[name];
delete this[name.replace(/-([a-z])/g, (_, c) => c.toUpperCase())];
},
getPropertyValue: () => '',
};
return Object.assign(new EventTarget(), {
id: '',
style,
dataset: {},
classList: {
add: (...names) => names.forEach((name) => classes.add(name)),
remove: (...names) => names.forEach((name) => classes.delete(name)),
contains: (name) => classes.has(name),
toggle: (name, on) => (on ? classes.add(name) : classes.delete(name)),
},
querySelector: () => null,
querySelectorAll: () => [],
closest: () => null,
setAttribute() {},
removeAttribute() {},
appendChild() {},
remove() {},
getBoundingClientRect: () => ({
left: 100,
top: 80,
width: 360,
height: 420,
right: 460,
bottom: 500,
}),
});
};
globalThis.document = Object.assign(new EventTarget(), {
getElementById: () => null,
querySelectorAll: () => [],
createElement: () => element(),
});
globalThis.window = Object.assign(new EventTarget(), {
innerWidth: 1440,
innerHeight: 900,
});
globalThis.localStorage = {
getItem: (key) => (store.has(key) ? store.get(key) : null),
setItem: (key, value) => store.set(key, String(value)),
removeItem: (key) => store.delete(key),
};
const calls = { layout: 0, resized: [] };
const owner = new PanelPositionControls({
syncPanelCollapseButton() {},
layoutRightPanels: () => calls.layout++,
syncCctvPanelViewport() {},
showToast() {},
onPanelResized: (id) => calls.resized.push(id),
});
return {
owner,
element,
store,
calls,
restore() {
owner.destroy();
Object.assign(globalThis, saved);
},
};
}
function floatingPanel(f, id, { dockOnCollapse = false } = {}) {
const panel = f.element();
const handle = f.element();
panel.id = id;
panel.classList.add('panel-floating', 'panel-draggable');
Object.assign(panel.style, {
left: '100px',
top: '80px',
width: '360px',
height: '420px',
});
f.store.set(`godsEyeView.v8.panelPos.${id}`, '{"floating":true}');
f.owner._portablePanels.set(id, {
panel,
min: { width: 320, height: 280 },
dockOnCollapse,
});
f.owner._makePanelDraggable(id, panel, handle);
return { panel, handle };
}
/** A left-button header press; `timeStamp` is a read-only getter on Event. */
function press(handle, { timeStamp, clientX = 200, clientY = 90 }) {
const event = new Event('pointerdown', { cancelable: true });
for (const [key, value] of Object.entries({
button: 0,
clientX,
clientY,
timeStamp,
}))
Object.defineProperty(event, key, { value });
handle.dispatchEvent(event);
}
test('dockPanel returns a floating window to its rail and forgets its place', () => {
const f = fixture();
try {
const { panel } = floatingPanel(f, 'street-level-panel');
assert.equal(f.owner.dockPanel('street-level-panel'), true);
assert.equal(panel.classList.contains('panel-floating'), false);
assert.equal(panel.style.width, undefined);
assert.equal(panel.style.height, undefined);
assert.equal(
f.store.has('godsEyeView.v8.panelPos.street-level-panel'),
false,
);
assert.deepEqual(f.calls.resized, ['street-level-panel']);
assert.equal(
f.owner.dockPanel('street-level-panel'),
false,
'already docked',
);
assert.equal(f.owner.dockPanel('unknown'), false);
} finally {
f.restore();
}
});
test('only a dockOnCollapse panel docks when collapsed, and only on collapse', () => {
const f = fixture();
try {
const street = floatingPanel(f, 'street-level-panel', {
dockOnCollapse: true,
});
const cctv = floatingPanel(f, 'cctv-panel');
f.owner.onPanelCollapsed('street-level-panel', false);
assert.equal(
street.panel.classList.contains('panel-floating'),
true,
'expanding keeps it',
);
f.owner.onPanelCollapsed('cctv-panel', true);
assert.equal(
cctv.panel.classList.contains('panel-floating'),
true,
'CCTV did not opt in',
);
f.owner.onPanelCollapsed('street-level-panel', true);
assert.equal(street.panel.classList.contains('panel-floating'), false);
} finally {
f.restore();
}
});
test('two quick header presses snap a floating panel back; slow or distant ones do not', () => {
const f = fixture();
try {
const { panel, handle } = floatingPanel(f, 'cctv-panel');
press(handle, { timeStamp: 1000 });
window.dispatchEvent(new Event('pointerup'));
press(handle, { timeStamp: 1600 });
window.dispatchEvent(new Event('pointerup'));
assert.equal(
panel.classList.contains('panel-floating'),
true,
'600 ms apart is two clicks',
);
press(handle, { timeStamp: 1700, clientX: 260 });
window.dispatchEvent(new Event('pointerup'));
assert.equal(
panel.classList.contains('panel-floating'),
true,
'60 px apart is two clicks',
);
press(handle, { timeStamp: 1900, clientX: 262 });
assert.equal(
panel.classList.contains('panel-floating'),
false,
'a double press docks',
);
} finally {
f.restore();
}
});
test('a restored floating window stacks above the docked DISPLAY panel (z 110)', () => {
const f = fixture();
try {
const panel = f.element();
panel.id = 'street-level-panel';
f.owner._portablePanels.set('street-level-panel', {
panel,
min: { width: 320, height: 280 },
dockOnCollapse: true,
});
f.store.set(
'godsEyeView.v8.panelPos.street-level-panel',
JSON.stringify({
left: 900,
top: 200,
width: 420,
height: 600,
floating: true,
}),
);
f.owner._restorePanelPosition('street-level-panel', panel);
assert.equal(panel.classList.contains('panel-floating'), true);
assert.ok(Number(panel.style.zIndex) > 110, `z ${panel.style.zIndex}`);
f.owner.dockPanel('street-level-panel');
assert.equal(panel.style.zIndex, undefined, 'docking drops the promotion');
} finally {
f.restore();
}
});
test('renumbering the z ladder never drops a window below the docked panels', () => {
const f = fixture();
try {
const windows = [f.element(), f.element(), f.element()];
windows.forEach((node, index) => {
node.classList.add('panel-draggable');
node.style.zIndex = String(130 + index);
});
globalThis.document.querySelectorAll = (selector) =>
selector === '.panel-draggable' ? windows : [];
f.owner._panelZCounter = 139;
const top = f.element();
top.classList.add('panel-draggable');
f.owner._promotePanelZ(top);
for (const node of [...windows, top])
assert.ok(Number(node.style.zIndex) > 110, `z ${node.style.zIndex}`);
assert.ok(Number(top.style.zIndex) > Number(windows[2].style.zIndex));
} finally {
f.restore();
}
});
/** A portable panel still in its rail, with its header and resize handles. */
function dockedPanel(f, id) {
const panel = f.element();
const handle = f.element();
panel.id = id;
const portable = { panel, min: { width: 320, height: 280 } };
f.owner._portablePanels.set(id, portable);
f.owner._makePanelDraggable(id, panel, handle);
f.owner._makePanelResizable(id, panel, portable.min);
const edges = Object.fromEntries(
f.owner._resizeHandles.map((node) => [node.dataset.dir, node]),
);
return { panel, handle, edges };
}
/** A pointer event; `pointerType` defaults to what a mouse reports. */
function pointer(target, type, { x = 200, y = 90, pointerType = 'mouse' }) {
const event = new Event(type, { cancelable: true });
for (const [key, value] of Object.entries({
button: 0,
clientX: x,
clientY: y,
pointerType,
}))
Object.defineProperty(event, key, { value });
target.dispatchEvent(event);
return event;
}
const POSITION_KEY = 'godsEyeView.v8.panelPos.street-level-panel';
test('a click on a docked panel’s resize edge leaves it docked and stores nothing', () => {
const f = fixture();
try {
const { panel, edges } = dockedPanel(f, 'street-level-panel');
pointer(edges.w, 'pointerdown', { x: 101, y: 200 });
pointer(window, 'pointermove', { x: 103, y: 201 }); // a jittery click
pointer(window, 'pointerup', { x: 103, y: 201 });
assert.equal(panel.classList.contains('panel-floating'), false);
assert.equal(panel.style.width, undefined, 'no size was pinned');
assert.equal(f.store.has(POSITION_KEY), false);
assert.deepEqual(f.calls.resized, []);
// A real resize still lifts it out and remembers the window.
pointer(edges.w, 'pointerdown', { x: 101, y: 200 });
pointer(window, 'pointermove', { x: 61, y: 200 });
pointer(window, 'pointerup', { x: 61, y: 200 });
assert.equal(panel.classList.contains('panel-floating'), true);
assert.equal(JSON.parse(f.store.get(POSITION_KEY)).floating, true);
} finally {
f.restore();
}
});
test('a touch on the header or an edge does not lift the panel, and the rail can still scroll', () => {
const f = fixture();
try {
const { panel, handle, edges } = dockedPanel(f, 'street-level-panel');
const press = pointer(handle, 'pointerdown', { pointerType: 'touch' });
assert.equal(press.defaultPrevented, false, 'the swipe can scroll');
pointer(window, 'pointermove', { y: 190, pointerType: 'touch' });
pointer(window, 'pointerup', { y: 190, pointerType: 'touch' });
pointer(edges.s, 'pointerdown', { y: 500, pointerType: 'touch' });
pointer(window, 'pointermove', { y: 600, pointerType: 'touch' });
pointer(window, 'pointerup', { y: 600, pointerType: 'touch' });
assert.equal(panel.classList.contains('panel-floating'), false);
assert.equal(f.store.has(POSITION_KEY), false);
} finally {
f.restore();
}
});
test('a pointercancel mid-drag saves nothing and puts the panel back', () => {
const f = fixture();
try {
const { panel, handle, edges } = dockedPanel(f, 'street-level-panel');
pointer(handle, 'pointerdown', {});
pointer(window, 'pointermove', { x: 260, y: 140 });
assert.equal(panel.classList.contains('panel-floating'), true, 'lifted');
pointer(window, 'pointercancel', { x: 260, y: 140 });
assert.equal(panel.classList.contains('panel-floating'), false, 'docked');
assert.equal(f.store.has(POSITION_KEY), false);
pointer(edges.e, 'pointerdown', { x: 459 });
pointer(window, 'pointermove', { x: 520 });
pointer(window, 'pointercancel', { x: 520 });
assert.equal(panel.classList.contains('panel-floating'), false);
assert.equal(f.store.has(POSITION_KEY), false);
} finally {
f.restore();
}
});
test('a pointercancel while dragging a floating window returns it to where it was', () => {
const f = fixture();
try {
const { panel, handle } = floatingPanel(f, 'cctv-panel');
const stored = f.store.get('godsEyeView.v8.panelPos.cctv-panel');
pointer(handle, 'pointerdown', {});
pointer(window, 'pointermove', { x: 400, y: 300 });
assert.notEqual(panel.style.left, '100px', 'it moved');
pointer(window, 'pointercancel', { x: 400, y: 300 });
assert.equal(panel.style.left, '100px');
assert.equal(panel.style.top, '80px');
assert.equal(panel.classList.contains('panel-floating'), true);
assert.equal(f.store.get('godsEyeView.v8.panelPos.cctv-panel'), stored);
} finally {
f.restore();
}
});
test('a header press that travels under the drag threshold is a click, not a lift', () => {
const f = fixture();
try {
const { panel, handle } = dockedPanel(f, 'street-level-panel');
pointer(handle, 'pointerdown', { x: 200, y: 90 });
pointer(window, 'pointermove', { x: 202, y: 90 });
pointer(window, 'pointerup', { x: 202, y: 90 });
assert.equal(panel.classList.contains('panel-floating'), false);
assert.equal(panel.classList.contains('panel-dragging'), false);
assert.equal(f.store.has(POSITION_KEY), false);
assert.equal(f.calls.layout, 0, 'the rail was not re-laid out');
assert.deepEqual(f.calls.resized, []);
} finally {
f.restore();
}
});
/**
* Both portable panels wired through _initPanelDrag. Headers are the top
* 36 px; `obstacles` stand in for the command dock and voice pill.
* ResizeObserver callbacks run on `observe()`.
*/
function portableShell(f, { stored = {}, box = {}, obstacles = {} } = {}) {
const observers = [];
globalThis.ResizeObserver = class {
constructor(callback) {
this.callback = callback;
this.targets = [];
observers.push(this);
}
observe(target) {
this.targets.push(target);
}
disconnect() {}
};
const nodes = new Map();
const panels = {};
for (const id of ['cctv-panel', 'street-level-panel']) {
const panel = f.element();
const handle = f.element();
const natural = { width: 360, height: 420, ...box[id] };
panel.id = id;
panel.querySelector = (selector) =>
selector === '.panel-header' ? handle : null;
panel.getBoundingClientRect = () => {
const left = parseFloat(panel.style.left) || 0;
const top = parseFloat(panel.style.top) || 0;
const width = parseFloat(panel.style.width) || natural.width;
const height = parseFloat(panel.style.height) || natural.height;
return {
left,
top,
width,
height,
right: left + width,
bottom: top + height,
};
};
handle.getBoundingClientRect = () => {
const rect = panel.getBoundingClientRect();
return { ...rect, height: 36, bottom: rect.top + 36 };
};
if (stored[id])
f.store.set(
`godsEyeView.v8.panelPos.${id}`,
JSON.stringify({ floating: true, ...stored[id] }),
);
nodes.set(id, panel);
panels[id] = { panel, handle, natural };
}
const obstacle = (id, rect) =>
nodes.set(id, {
getBoundingClientRect: () => ({
...rect,
width: rect.right - rect.left,
height: rect.bottom - rect.top,
}),
});
for (const [id, rect] of Object.entries(obstacles)) obstacle(id, rect);
globalThis.document.getElementById = (id) => nodes.get(id) ?? null;
f.owner._initPanelDrag();
return {
...panels,
obstacle,
observed: (node) => observers.some(({ targets }) => targets.includes(node)),
contentResized: () => observers.forEach(({ callback }) => callback([])),
};
}
test('a floating window whose content grows is pulled back on-screen', () => {
const f = fixture();
try {
const shell = portableShell(f, {
stored: { 'cctv-panel': { left: 900, top: 380 } },
});
const { panel, natural } = shell['cctv-panel'];
assert.equal(panel.style.top, '380px');
assert.equal(shell.observed(panel), true, 'its size is watched');
assert.equal(shell.observed(shell['street-level-panel'].panel), true);
// Expanding the collapsed strip, or an image opening in Street Level.
natural.height = 800;
shell.contentResized();
assert.equal(panel.style.top, `${900 - 800 - 6}px`);
assert.equal(panel.style.left, '900px');
} finally {
f.restore();
}
});
test('a live window resize shrinks a floating window larger than the viewport', () => {
const f = fixture();
try {
const shell = portableShell(f, {
stored: {
'cctv-panel': { left: 100, top: 80, width: 1300, height: 700 },
},
});
const { panel } = shell['cctv-panel'];
assert.equal(panel.style.width, '1300px');
Object.assign(window, { innerWidth: 1000, innerHeight: 500 });
window.dispatchEvent(new Event('resize'));
assert.equal(panel.style.width, '988px');
assert.equal(panel.style.height, '488px');
assert.equal(panel.style.left, '6px');
assert.equal(panel.style.top, '6px');
// A window that already fits keeps the size the user gave it.
Object.assign(window, { innerWidth: 1440, innerHeight: 900 });
window.dispatchEvent(new Event('resize'));
assert.equal(panel.style.width, '988px');
} finally {
f.restore();
}
});
const DOCK = { left: 300, top: 820, right: 1140, bottom: 882 };
test('a floating header never lands under the command dock or voice pill', () => {
const f = fixture();
try {
const shell = portableShell(f, {
stored: { 'cctv-panel': { left: 100, top: 80 } },
// A collapsed CCTV window is just its header strip.
box: { 'cctv-panel': { height: 36 } },
obstacles: { 'command-dock': DOCK },
});
const { panel, handle, natural } = shell['cctv-panel'];
const above = DOCK.top - 36 - 6;
// Drag: the strip dropped at the bottom centre stops above the dock.
pointer(handle, 'pointerdown', { x: 200, y: 90 });
pointer(window, 'pointermove', { x: 720, y: 880 });
assert.equal(panel.style.top, `${above}px`);
pointer(window, 'pointerup', { x: 720, y: 880 });
const key = 'godsEyeView.v8.panelPos.cctv-panel';
assert.equal(JSON.parse(f.store.get(key)).top, above);
// Restore: a place saved under the dock (older build) comes back clear.
f.store.set(key, JSON.stringify({ left: 620, top: 858, floating: true }));
f.owner._restorePanelPosition('cctv-panel', panel);
assert.equal(panel.style.top, `${above}px`);
// Re-clamp: the voice pill measured when the window resizes.
Object.assign(panel.style, { left: '1000px', top: '700px' });
shell.obstacle('gev-voice-control', {
left: 1190,
top: 690,
right: 1404,
bottom: 740,
});
window.dispatchEvent(new Event('resize'));
assert.equal(panel.style.top, `${690 - 36 - 6}px`);
// Only the header has to stay clear: a body over the dock is fine.
natural.height = 200;
Object.assign(panel.style, { left: '620px', top: '650px' });
window.dispatchEvent(new Event('resize'));
assert.equal(panel.style.top, '650px');
} finally {
f.restore();
}
});
test('a collapsed floating window keeps its chosen height through a drag and a reload', () => {
const f = fixture();
try {
const { panel, handle } = floatingPanel(f, 'cctv-panel');
panel.style.height = '500px';
panel.classList.add('collapsed');
// Collapsed, the window measures only its header strip.
panel.getBoundingClientRect = () => {
const left = parseFloat(panel.style.left);
const top = parseFloat(panel.style.top);
return {
left,
top,
width: 360,
height: 50,
right: left + 360,
bottom: top + 50,
};
};
pointer(handle, 'pointerdown', { x: 200, y: 90 });
pointer(window, 'pointermove', { x: 260, y: 140 });
pointer(window, 'pointerup', { x: 260, y: 140 });
const record = JSON.parse(
f.store.get('godsEyeView.v8.panelPos.cctv-panel'),
);
assert.equal(record.height, 500, 'the chosen height, not the strip');
assert.equal(record.width, 360);
// Reload: a fresh panel restores the size it expands to.
const reloaded = f.element();
reloaded.id = 'cctv-panel';
f.owner._restorePanelPosition('cctv-panel', reloaded);
assert.equal(reloaded.style.height, '500px');
} finally {
f.restore();
}
});