mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
main moved the panels to Vue views and a one-open-panel rail, and removed
the two templates this branch edited. This merge carries the branch onto
that architecture; it adds no behaviour of its own beyond the port.
- The Utilities panel is a RailPanel in LayerPanelsView with its own rail
item (handyman). LayerPanel renders the Directions rows into its list with
LayerRows, and falls back to Data Layers without it.
- Draw leaves DisplayPanel for DrawControls, DrawMarkList and DrawSwatches,
which render what the draw tool publishes to the display store and call
its actions, as GeoImport does. drawMarks.js words the rows; the DOM-built
drawMarkList.js is gone. A row's fly-to is an immediate navigation like
Import's ('drawing' gets its Cockpit refusal), the name field keeps a
draft through v-field-value, and the colours are one radio group with a
single tab stop.
- UiSegmented draws an option's icon, named by its label, for the shape
buttons.
- The catalogs take main's languages with this branch's strings, Arabic
included, and the tests follow the views to their new homes.
833 lines
29 KiB
JavaScript
833 lines
29 KiB
JavaScript
import { readStylesheet } from '../testSupport/readStylesheet.mjs';
|
||
import { displayPanelScroller } from './displayPanelScroll.js';
|
||
import assert from 'node:assert/strict';
|
||
import test from 'node:test';
|
||
import {
|
||
layoutLeftPanelRail,
|
||
layoutRightPanelRail,
|
||
measurePanelNaturalHeight,
|
||
} from './panelRails.js';
|
||
import { applyPanelState, readPanelState } from './rail/panelState.js';
|
||
|
||
test('Display uses an inner scroll body only in Cyber', () => {
|
||
const body = { scrollTop: 95 };
|
||
const panel = {
|
||
ownerDocument: { documentElement: { dataset: { uiTheme: 'cyber' } } },
|
||
querySelector: () => body,
|
||
};
|
||
assert.equal(displayPanelScroller(panel), body);
|
||
panel.ownerDocument.documentElement.dataset.uiTheme = 'tactical';
|
||
assert.equal(displayPanelScroller(panel), panel);
|
||
assert.equal(displayPanelScroller(null), null);
|
||
});
|
||
|
||
for (const side of ['left', 'right']) {
|
||
test(`${side} desktop rail keeps one expanded owner in every HUD layout`, () => {
|
||
for (const variant of ['tactical', 'minimal', 'cyber']) {
|
||
const f = fixture(side, { hud: { visible: true, variant } });
|
||
f.expand(f.first, 250);
|
||
f.expand(f.second, 250);
|
||
f.options.preferredPanelId = f.second.id;
|
||
f.run();
|
||
assert.equal(f.first.classList.contains('accordion-collapsed'), true);
|
||
assert.equal(f.second.classList.contains('collapsed'), false);
|
||
assert.equal(f.stack.classList.contains('layout-exclusive'), true);
|
||
assert.equal(f.first.getAttribute('aria-hidden'), 'true');
|
||
assert.equal(f.retries(), 0, 'the accordion needs no auto-collapse pass');
|
||
f.run();
|
||
assert.equal(
|
||
f.first.classList.contains('collapsed'),
|
||
true,
|
||
'layout does not reopen a peer',
|
||
);
|
||
// Phone sheets restore the presentation-only collapse.
|
||
f.options.windowRef.matchMedia = () => ({ matches: true });
|
||
f.run();
|
||
assert.equal(f.first.classList.contains('collapsed'), false);
|
||
assert.equal(f.first.classList.contains('accordion-collapsed'), false);
|
||
assert.equal(f.stack.classList.contains('layout-exclusive'), false);
|
||
}
|
||
});
|
||
|
||
test(`${side} rail keeps no accordion on phones, where it is presented as tab sheets`, () => {
|
||
// A sheet (for example Cameras: CCTV with Street Level) shows several of the
|
||
// rail's panels at once; the accordion would collapse all but one.
|
||
const f = fixture(side, { mobile: true });
|
||
f.expand(f.first, 250);
|
||
f.expand(f.second, 250);
|
||
f.options.preferredPanelId = f.second.id;
|
||
f.run();
|
||
assert.equal(f.first.classList.contains('collapsed'), false);
|
||
assert.equal(f.first.classList.contains('accordion-collapsed'), false);
|
||
assert.equal(f.second.classList.contains('collapsed'), false);
|
||
});
|
||
|
||
test(`${side} restored accordion prefers keyboard focus when no explicit owner exists`, () => {
|
||
const f = fixture(side);
|
||
f.expand(f.first, 250);
|
||
f.expand(f.second, 250);
|
||
f.options.documentRef.activeElement = f.second;
|
||
f.run();
|
||
assert.equal(f.first.classList.contains('collapsed'), true);
|
||
assert.equal(f.second.classList.contains('collapsed'), false);
|
||
});
|
||
}
|
||
|
||
function element(
|
||
id,
|
||
{ height = 42, top = 234, left = 20, width = 272, collapsed = false } = {},
|
||
) {
|
||
const classes = new Set(collapsed ? ['collapsed'] : []);
|
||
const properties = new Map();
|
||
const attributes = new Map();
|
||
const writes = [];
|
||
const node = {
|
||
id,
|
||
children: [],
|
||
dataset: {},
|
||
parentElement: null,
|
||
scrollHeight: height,
|
||
clientHeight: height,
|
||
scrollTop: 0,
|
||
computed: {
|
||
display: 'block',
|
||
visibility: 'visible',
|
||
opacity: '1',
|
||
rowGap: '12px',
|
||
},
|
||
rect: {
|
||
top,
|
||
left,
|
||
width,
|
||
height,
|
||
right: left + width,
|
||
bottom: top + height,
|
||
},
|
||
classList: {
|
||
contains: (name) => classes.has(name),
|
||
add: (...names) => names.forEach((name) => classes.add(name)),
|
||
remove: (...names) => names.forEach((name) => classes.delete(name)),
|
||
toggle(name, value) {
|
||
if (value) classes.add(name);
|
||
else classes.delete(name);
|
||
},
|
||
},
|
||
style: {
|
||
setProperty(name, value) {
|
||
writes.push(['set', name, value]);
|
||
properties.set(name, value);
|
||
},
|
||
getPropertyValue: (name) => properties.get(name) || '',
|
||
removeProperty(name) {
|
||
writes.push(['remove', name]);
|
||
properties.delete(name);
|
||
},
|
||
},
|
||
getBoundingClientRect() {
|
||
return this.rect;
|
||
},
|
||
matches: (selector) => selector === '[data-panel-id]',
|
||
contains(target) {
|
||
return (
|
||
target === this || this.children.some((child) => child.contains(target))
|
||
);
|
||
},
|
||
querySelectorAll() {
|
||
return this.children;
|
||
},
|
||
setAttribute: (name, value) => attributes.set(name, value),
|
||
removeAttribute: (name) => attributes.delete(name),
|
||
getAttribute: (name) => attributes.get(name),
|
||
writes,
|
||
};
|
||
return node;
|
||
}
|
||
function fixture(
|
||
side,
|
||
{ mobile = false, hud = { visible: true, variant: 'tactical' } } = {},
|
||
) {
|
||
const first = element('first', { height: 42, collapsed: true });
|
||
const second = element('second', { height: 42, collapsed: true });
|
||
const stack = element('rail', {
|
||
height: 96,
|
||
left: side === 'left' ? 20 : 1100,
|
||
});
|
||
stack.children = [first, second];
|
||
first.parentElement = second.parentElement = stack;
|
||
const documentRef = { activeElement: null };
|
||
stack.ownerDocument = documentRef;
|
||
const collapsed = [];
|
||
let retries = 0,
|
||
aligned = 0;
|
||
const options = {
|
||
stack,
|
||
hud,
|
||
documentRef,
|
||
obstacles: [],
|
||
collapsedHeights: new Map(),
|
||
windowRef: {
|
||
innerHeight: 900,
|
||
matchMedia: () => ({ matches: mobile }),
|
||
getComputedStyle: (node) => node.computed,
|
||
},
|
||
// The owner presents the patch; the panel's root draws it at once.
|
||
onCollapse: (panel, patch) => {
|
||
collapsed.push(panel.id);
|
||
applyPanelState(panel, { ...readPanelState(panel), ...patch });
|
||
},
|
||
onRetry: () => {
|
||
retries += 1;
|
||
},
|
||
onAligned: () => {
|
||
aligned += 1;
|
||
},
|
||
displayPanel: null,
|
||
readDisplayScrollTop: () => 0,
|
||
leftStack: element('left'),
|
||
};
|
||
const run = () =>
|
||
(side === 'left' ? layoutLeftPanelRail : layoutRightPanelRail)(options);
|
||
const expand = (panel, height) => {
|
||
panel.classList.remove('collapsed');
|
||
panel.scrollHeight = height;
|
||
panel.rect.height = height;
|
||
panel.rect.bottom = panel.rect.top + height;
|
||
};
|
||
return {
|
||
options,
|
||
first,
|
||
second,
|
||
stack,
|
||
run,
|
||
expand,
|
||
collapsed,
|
||
retries: () => retries,
|
||
aligned: () => aligned,
|
||
};
|
||
}
|
||
|
||
test('missing rails are inert without browser globals', () => {
|
||
layoutLeftPanelRail({});
|
||
layoutRightPanelRail({});
|
||
});
|
||
|
||
test('left layout records collapsed heights and aligns the right rail without changing disclosure', () => {
|
||
const f = fixture('left');
|
||
f.run();
|
||
assert.equal(f.options.collapsedHeights.get('first'), 42);
|
||
assert.equal(f.first.classList.contains('collapsed'), true);
|
||
assert.equal(f.aligned(), 1);
|
||
assert.equal(f.retries(), 0);
|
||
assert.equal(f.stack.dataset.layoutMode, 'normal');
|
||
});
|
||
|
||
test('left corridor respects a lower obstacle but ignores an obstacle hidden by its parent', () => {
|
||
const f = fixture('left');
|
||
const blocker = element('blocker', { top: 600, height: 80 });
|
||
const hidden = element('hidden', { top: 300, height: 80 });
|
||
hidden.parentElement = element('hidden-parent');
|
||
hidden.parentElement.computed.opacity = '0';
|
||
f.options.obstacles = [blocker, hidden];
|
||
f.run();
|
||
assert.equal(Number(f.stack.dataset.safeBottomPct), 65.47);
|
||
});
|
||
|
||
for (const side of ['left', 'right']) {
|
||
test(`${side} mobile layout releases desktop height/position styles and labels`, () => {
|
||
const f = fixture(side, { mobile: true });
|
||
f.stack.classList.add('layout-focus');
|
||
f.stack.style.setProperty(`--${side}-stack-safe-top`, '300px');
|
||
f.first.style.setProperty(`--${side}-panel-allocated-height`, '99px');
|
||
f.first.setAttribute('aria-hidden', 'true');
|
||
f.run();
|
||
assert.equal(f.stack.dataset.layoutMode, 'mobile');
|
||
assert.equal(
|
||
f.stack.style.getPropertyValue(`--${side}-stack-safe-top`),
|
||
'',
|
||
);
|
||
assert.equal(
|
||
f.first.style.getPropertyValue(`--${side}-panel-allocated-height`),
|
||
'',
|
||
);
|
||
assert.equal(f.first.getAttribute('aria-hidden'), undefined);
|
||
});
|
||
test(`${side} constrained layout keeps the preferred panel without another pass`, () => {
|
||
const f = fixture(side);
|
||
f.expand(f.first, 900);
|
||
f.expand(f.second, 900);
|
||
f.options.preferredPanelId = 'second';
|
||
f.run();
|
||
assert.equal(f.second.classList.contains('collapsed'), false);
|
||
assert.equal(f.first.classList.contains('accordion-collapsed'), true);
|
||
assert.deepEqual(f.collapsed, ['first']);
|
||
assert.equal(f.retries(), 0);
|
||
});
|
||
test(`${side} hidden HUD restores automatic collapse without altering manual collapse`, () => {
|
||
const f = fixture(side, { hud: { visible: false, variant: 'tactical' } });
|
||
f.first.classList.add('layout-auto-collapsed');
|
||
f.run();
|
||
assert.equal(f.first.classList.contains('collapsed'), false);
|
||
assert.equal(f.second.classList.contains('collapsed'), true);
|
||
assert.deepEqual(f.collapsed, ['first']);
|
||
});
|
||
}
|
||
|
||
test('right layout uses keyboard focus when there is no preferred panel', () => {
|
||
const f = fixture('right');
|
||
f.expand(f.first, 900);
|
||
f.expand(f.second, 900);
|
||
f.options.documentRef.activeElement = f.second;
|
||
f.run();
|
||
assert.equal(f.first.classList.contains('accordion-collapsed'), true);
|
||
assert.equal(f.second.classList.contains('collapsed'), false);
|
||
});
|
||
|
||
for (const collapsed of [true, false]) {
|
||
test(`right layout ignores a hidden ${collapsed ? 'collapsed' : 'expanded'} panel`, () => {
|
||
const baseline = fixture('right');
|
||
baseline.stack.children = [baseline.first];
|
||
baseline.expand(baseline.first, 600);
|
||
baseline.run();
|
||
|
||
const f = fixture('right');
|
||
f.expand(f.first, 600);
|
||
f.second.id = 'weather-panel';
|
||
f.second.hidden = true;
|
||
if (!collapsed) f.expand(f.second, 900);
|
||
f.options.preferredPanelId = 'weather-panel';
|
||
f.run();
|
||
|
||
assert.deepEqual(f.stack.dataset, baseline.stack.dataset);
|
||
assert.equal(
|
||
f.first.style.getPropertyValue('--right-panel-allocated-height'),
|
||
baseline.first.style.getPropertyValue('--right-panel-allocated-height'),
|
||
);
|
||
assert.equal(f.second.classList.contains('collapsed'), collapsed);
|
||
assert.deepEqual(f.second.writes, []);
|
||
assert.deepEqual(f.collapsed, []);
|
||
assert.equal(f.retries(), 0);
|
||
assert.equal(f.stack.classList.contains('layout-focus'), false);
|
||
});
|
||
}
|
||
|
||
test('right layout retains Display allocation during measurement and caps restored scroll', () => {
|
||
const f = fixture('right');
|
||
f.first.id = 'pp-toggles';
|
||
f.expand(f.first, 900);
|
||
f.first.clientHeight = 400;
|
||
f.options.displayPanel = f.first;
|
||
f.options.readDisplayScrollTop = () => 800;
|
||
f.first.style.setProperty('--right-panel-allocated-height', '600px');
|
||
f.first.writes.length = 0;
|
||
f.run();
|
||
assert.equal(f.first.scrollTop, 500);
|
||
assert.equal(
|
||
f.first.writes.some(
|
||
([op, name]) =>
|
||
op === 'remove' && name === '--right-panel-allocated-height',
|
||
),
|
||
false,
|
||
);
|
||
f.first.writes.length = 0;
|
||
f.run();
|
||
assert.equal(
|
||
f.first.writes.some(
|
||
([, name]) => name === '--right-panel-allocated-height',
|
||
),
|
||
false,
|
||
'stable allocation must not churn the style attribute',
|
||
);
|
||
});
|
||
|
||
test('right rail aligns to the current left rail and excludes hidden obstacles', () => {
|
||
const f = fixture('right');
|
||
f.options.leftStack.rect.top = 200;
|
||
const hidden = element('hidden', { left: 1100, top: 100, height: 200 });
|
||
hidden.computed.display = 'none';
|
||
f.options.obstacles = [hidden];
|
||
f.run();
|
||
assert.equal(f.stack.dataset.safeTop, '200.0');
|
||
});
|
||
|
||
// An opened panel moves the left stack up, but its rail buttons hold still:
|
||
// the right rail lines up with those buttons, not with the moved stack.
|
||
test('right rail aligns to the left rail buttons while the left stack is offset', () => {
|
||
const f = fixture('right');
|
||
f.options.leftStack.rect.top = 100;
|
||
f.options.leftStack.style.setProperty('--panel-rail-offset', '186.7px');
|
||
f.run();
|
||
assert.equal(f.stack.dataset.safeTop, '286.7');
|
||
});
|
||
|
||
test('left rail buttons hold still while a tall panel moves the stack', () => {
|
||
const f = fixture('left');
|
||
f.expand(f.first, 900);
|
||
f.run();
|
||
assert.equal(f.stack.dataset.layoutMode, 'focus');
|
||
assert.ok(
|
||
parseFloat(f.stack.style.getPropertyValue('--panel-rail-offset')) > 0,
|
||
);
|
||
|
||
// A theme that pins the stack's top (Cyber) leaves nothing to offset.
|
||
f.stack.computed.getPropertyValue = (name) =>
|
||
name === '--panel-stack-top-pinned' ? ' 1' : '';
|
||
f.run();
|
||
assert.equal(f.stack.style.getPropertyValue('--panel-rail-offset'), '0.0px');
|
||
});
|
||
|
||
// The left rail's `top` animates after the right pass runs, and nothing
|
||
// re-runs it when the transition lands: the right rail must read the end value.
|
||
function animatingLeftRail({ keyframes = true } = {}) {
|
||
const f = fixture('right');
|
||
const left = f.options.leftStack;
|
||
left.rect.top = 250;
|
||
left.computed.top = '250px';
|
||
left.dataset.safeTopPct = '29.60';
|
||
left.getAnimations = () => [
|
||
{ transitionProperty: 'bottom', effect: null },
|
||
{
|
||
transitionProperty: 'top',
|
||
effect: {
|
||
getKeyframes: () =>
|
||
keyframes ? [{ top: '234px' }, { top: '266.4px' }] : [],
|
||
},
|
||
},
|
||
];
|
||
return f;
|
||
}
|
||
|
||
test('right rail aligns to where a mid-transition left rail settles', () => {
|
||
const f = animatingLeftRail();
|
||
f.run();
|
||
assert.equal(f.stack.dataset.safeTop, '266.4');
|
||
assert.equal(
|
||
f.stack.style.getPropertyValue('--right-stack-safe-top'),
|
||
'266.4px',
|
||
);
|
||
});
|
||
|
||
test('right rail falls back to the committed left target when the transition end is unreadable', () => {
|
||
const f = animatingLeftRail({ keyframes: false });
|
||
f.run();
|
||
assert.equal(f.stack.dataset.safeTop, '266.4');
|
||
});
|
||
|
||
test('right rail follows a settled left rail whose CSS overrides the committed target', () => {
|
||
// Cyber pins the left rail with `top: ... !important`, so the committed
|
||
// percentage is not where it renders and no `top` transition runs.
|
||
const f = fixture('right', { hud: { visible: true, variant: 'cyber' } });
|
||
f.options.leftStack.rect.top = 220;
|
||
f.options.leftStack.dataset.safeTopPct = '26.00';
|
||
f.options.leftStack.getAnimations = () => [];
|
||
f.run();
|
||
assert.equal(f.stack.dataset.safeTop, '220.0');
|
||
});
|
||
|
||
test('natural height includes visible content, margins and wrapper chrome, excluding hidden rows', () => {
|
||
const panel = element('panel');
|
||
const inner = element('inner', { top: 100 });
|
||
inner.computed.paddingTop = '10px';
|
||
inner.computed.paddingBottom = '5px';
|
||
const row = element('row', { top: 110, height: 40 });
|
||
row.scrollHeight = 80;
|
||
row.computed.marginBottom = '3px';
|
||
const hidden = element('hidden', { top: 1000, height: 900 });
|
||
hidden.computed.visibility = 'hidden';
|
||
panel.computed.borderTopWidth = '1px';
|
||
panel.computed.borderBottomWidth = '1px';
|
||
panel.children = [inner];
|
||
inner.children = [row, hidden];
|
||
assert.equal(
|
||
measurePanelNaturalHeight(panel, (node) => node.computed),
|
||
100,
|
||
);
|
||
});
|
||
|
||
// Reproduce the allocation-dependent readings seen beside the weather card.
|
||
// Outside measurement, CCTV is 391 px in focus and 629 px in normal mode;
|
||
// Removing only the allocation leaves focus CSS active: its next reading is
|
||
// 422 px, so the old decision exits focus and then re-enters on the 629 px read.
|
||
function thrashingRightRail({ focused = false, weatherOpen = false } = {}) {
|
||
const f = fixture('right');
|
||
f.options.windowRef.innerHeight = 920;
|
||
f.options.leftStack.rect.top = 439.6;
|
||
f.stack.computed.rowGap = '8px';
|
||
const display = element('pp-toggles', { collapsed: true });
|
||
const cctv = element('cctv-panel', { height: 629 });
|
||
const weather = element('weather-panel', {
|
||
height: 444,
|
||
collapsed: !weatherOpen,
|
||
});
|
||
const context = element('global-context-panel', { collapsed: true });
|
||
if (!weatherOpen) weather.classList.add('layout-auto-collapsed');
|
||
f.stack.children = [display, cctv, weather, context];
|
||
f.stack.classList.toggle('layout-focus', focused);
|
||
f.options.preferredPanelId = cctv.id;
|
||
f.options.displayPanel = display;
|
||
const allocation = '--right-panel-allocated-height';
|
||
const measuring = () =>
|
||
f.stack.getAttribute('data-rail-measuring') !== undefined;
|
||
for (const panel of f.stack.children) {
|
||
panel.parentElement = f.stack;
|
||
panel.intrinsicHeight = panel.rect.height;
|
||
panel.style.setProperty(allocation, '391px');
|
||
panel.getBoundingClientRect = () => {
|
||
let height;
|
||
if (panel.classList.contains('collapsed')) {
|
||
height =
|
||
measuring() || f.stack.classList.contains('layout-focus') ? 44 : 0;
|
||
} else if (measuring()) {
|
||
height = panel.intrinsicHeight;
|
||
} else if (f.stack.classList.contains('layout-focus')) {
|
||
height = 391;
|
||
} else {
|
||
height = panel.intrinsicHeight;
|
||
}
|
||
return { ...panel.rect, height, bottom: panel.rect.top + height };
|
||
};
|
||
Object.defineProperty(panel, 'scrollHeight', {
|
||
get: () => {
|
||
if (measuring())
|
||
return panel.classList.contains('collapsed')
|
||
? 44
|
||
: panel.intrinsicHeight;
|
||
if (panel.classList.contains('collapsed'))
|
||
return panel.getBoundingClientRect().height;
|
||
return f.stack.classList.contains('layout-focus') &&
|
||
panel.style.getPropertyValue(allocation)
|
||
? 603
|
||
: 422;
|
||
},
|
||
});
|
||
}
|
||
return { ...f, cctv, weather };
|
||
}
|
||
|
||
for (const focused of [false, true]) {
|
||
test(`right rail settles mode-dependent CCTV and weather heights within two passes from ${focused ? 'focus' : 'normal'}`, () => {
|
||
const f = thrashingRightRail({ focused });
|
||
assert.equal(f.cctv.getBoundingClientRect().height, focused ? 391 : 629);
|
||
assert.equal(f.weather.getBoundingClientRect().height, focused ? 44 : 0);
|
||
const modes = [];
|
||
const needs = [];
|
||
for (let pass = 0; pass < 10; pass++) {
|
||
f.run();
|
||
modes.push(f.stack.classList.contains('layout-focus'));
|
||
needs.push(f.stack.dataset.requiredHeight);
|
||
assert.equal(f.stack.dataset.availableHeight, '443.6');
|
||
assert.equal(f.stack.getAttribute('data-rail-measuring'), undefined);
|
||
}
|
||
assert.deepEqual(modes.slice(1), Array(9).fill(true));
|
||
assert.deepEqual(needs, Array(10).fill('681.0'));
|
||
assert.equal(f.retries(), 0);
|
||
assert.equal(f.cctv.writes.filter(([op]) => op === 'remove').length, 0);
|
||
});
|
||
}
|
||
|
||
test('right rail keeps focus through 30 px content growth inside the hysteresis band', () => {
|
||
const f = thrashingRightRail();
|
||
f.run();
|
||
// Header + gap contribute 52 px: need moves 420 -> 450 -> 420.
|
||
for (const height of [368, 398, 368, 398, 368]) {
|
||
f.cctv.intrinsicHeight = height;
|
||
f.run();
|
||
assert.equal(f.stack.classList.contains('layout-focus'), true);
|
||
}
|
||
f.cctv.intrinsicHeight = 335; // Need 387 < 443.6 - 55.2.
|
||
f.run();
|
||
assert.equal(f.stack.classList.contains('layout-focus'), false);
|
||
f.cctv.intrinsicHeight = 365;
|
||
f.run();
|
||
assert.equal(f.stack.classList.contains('layout-focus'), false);
|
||
});
|
||
|
||
test('right rail enters focus only above available height and leaves below the full deadband', () => {
|
||
const f = thrashingRightRail();
|
||
f.options.windowRef.innerHeight = 1000;
|
||
f.options.leftStack.rect.top = 500;
|
||
f.cctv.intrinsicHeight = 408; // Need equals available (460 px).
|
||
f.run();
|
||
assert.equal(f.stack.classList.contains('layout-focus'), false);
|
||
f.cctv.intrinsicHeight = 409;
|
||
f.run();
|
||
assert.equal(f.stack.classList.contains('layout-focus'), true);
|
||
f.cctv.intrinsicHeight = 348; // Need equals the 400 px exit boundary.
|
||
f.run();
|
||
assert.equal(f.stack.classList.contains('layout-focus'), true);
|
||
f.cctv.intrinsicHeight = 347;
|
||
f.run();
|
||
assert.equal(f.stack.classList.contains('layout-focus'), false);
|
||
});
|
||
|
||
test('right rail settles a second tall panel through the accordion in one pass', () => {
|
||
const f = thrashingRightRail({ weatherOpen: true });
|
||
const modes = [];
|
||
for (let pass = 0; pass < 10; pass++) {
|
||
f.run();
|
||
modes.push(f.stack.dataset.layoutMode);
|
||
}
|
||
assert.deepEqual(f.collapsed, ['weather-panel']);
|
||
assert.equal(f.weather.classList.contains('accordion-collapsed'), true);
|
||
assert.equal(f.retries(), 0);
|
||
assert.deepEqual(modes.slice(1), Array(9).fill(modes[0]));
|
||
});
|
||
|
||
test('right rail reopening a peer outside the rail keeps one owner without retrying', () => {
|
||
const f = thrashingRightRail({ weatherOpen: true });
|
||
f.run();
|
||
f.weather.classList.remove('collapsed', 'accordion-collapsed');
|
||
f.run();
|
||
assert.equal(f.retries(), 0);
|
||
assert.equal(f.weather.classList.contains('collapsed'), true);
|
||
assert.equal(f.cctv.classList.contains('collapsed'), false);
|
||
});
|
||
|
||
test('right rail restores presentation even when intrinsic measurement throws', () => {
|
||
const f = thrashingRightRail();
|
||
f.cctv.getBoundingClientRect = () => {
|
||
throw new Error('measurement failed');
|
||
};
|
||
assert.throws(() => f.run(), /measurement failed/);
|
||
assert.equal(f.stack.getAttribute('data-rail-measuring'), undefined);
|
||
});
|
||
|
||
test('right rail measuring pass keeps an opted-in scroller position', () => {
|
||
const f = fixture('right');
|
||
f.expand(f.first, 300);
|
||
let scrollTop = 240;
|
||
let writes = 0;
|
||
let clamp = true;
|
||
const body = {
|
||
get scrollTop() {
|
||
return scrollTop;
|
||
},
|
||
set scrollTop(value) {
|
||
writes++;
|
||
scrollTop = value;
|
||
},
|
||
};
|
||
f.stack.querySelectorAll = (selector) =>
|
||
selector === '[data-rail-scroller]' ? [body] : [];
|
||
const setAttribute = f.stack.setAttribute;
|
||
f.stack.setAttribute = (name, value) => {
|
||
setAttribute(name, value);
|
||
// The lifted max-height removes the overflow and clamps the offset.
|
||
if (clamp && name === 'data-rail-measuring') scrollTop = 0;
|
||
};
|
||
f.run();
|
||
assert.equal(scrollTop, 240);
|
||
assert.equal(writes, 1);
|
||
clamp = false;
|
||
f.run();
|
||
assert.equal(writes, 1, 'an unclamped offset is not rewritten');
|
||
});
|
||
|
||
for (const variant of ['minimal', 'full']) {
|
||
test(`right rail settles two tall panels without retrying in the ${variant} HUD`, () => {
|
||
const f = fixture('right', { hud: { visible: true, variant } });
|
||
f.expand(f.first, 900);
|
||
f.expand(f.second, 900);
|
||
for (let pass = 0; pass < 10; pass++) f.run();
|
||
assert.equal(f.retries(), 0);
|
||
assert.equal(f.first.classList.contains('collapsed'), false);
|
||
assert.equal(f.second.classList.contains('accordion-collapsed'), true);
|
||
});
|
||
}
|
||
// ── Narrow-screen rail overflow pin ──────────────────────────────────────────
|
||
// At ≤720px both panel stacks become scroll containers (overflow-y: auto),
|
||
// which also makes their overflow-x compute to auto. Each panel's decorative
|
||
// .panel-glow is absolutely positioned with a negative inset, so inside a
|
||
// scroll container that overhang is no longer harmless paint: it becomes
|
||
// 18–20px of scrollable overflow on both axes, drawing a horizontal scrollbar
|
||
// band under the expanded CCTV/Context/Data panel plus a vertical scrollbar
|
||
// that scrolls nothing but glow. The narrow block therefore pins every hosted
|
||
// glow to its panel box.
|
||
|
||
/** Strip comments and return the bodies of every ≤720px media block. */
|
||
function narrowScreenBlocks(css) {
|
||
const source = css.replace(/\/\*[\s\S]*?\*\//g, '');
|
||
const blocks = [];
|
||
let from = 0;
|
||
for (;;) {
|
||
const start = source.indexOf('@media (max-width: 720px) {', from);
|
||
if (start === -1) break;
|
||
const open = source.indexOf('{', start);
|
||
let depth = 0;
|
||
let close = -1;
|
||
for (let index = open; index < source.length; index += 1) {
|
||
if (source[index] === '{') depth += 1;
|
||
if (source[index] === '}' && (depth -= 1) === 0) {
|
||
close = index;
|
||
break;
|
||
}
|
||
}
|
||
assert.notEqual(close, -1, 'unterminated narrow-screen media block');
|
||
blocks.push(source.slice(open + 1, close));
|
||
from = close + 1;
|
||
}
|
||
assert.ok(blocks.length, 'narrow-screen media block is missing');
|
||
return blocks;
|
||
}
|
||
|
||
/** Split a flat (non-nested) rule list into [selectors, declarations] pairs. */
|
||
function flatRules(block) {
|
||
assert.doesNotMatch(block, /@media/, 'nested media queries are not modelled');
|
||
return [...block.matchAll(/([^{}]+)\{([^{}]*)\}/g)].map(
|
||
([, selectors, declarations]) => [
|
||
selectors.split(',').map((part) => part.trim().replace(/\s+/g, ' ')),
|
||
declarations,
|
||
],
|
||
);
|
||
}
|
||
|
||
test('narrow-screen rails pin every hosted panel glow inside its panel box', () => {
|
||
const css = readStylesheet(new URL('../../style.css', import.meta.url));
|
||
for (const panel of [
|
||
'data-panel',
|
||
'utilities-panel',
|
||
'scene-panel',
|
||
'cctv-panel',
|
||
]) {
|
||
// The glow rule may be shared: `#data-panel .panel-glow, #utilities-panel .panel-glow {`.
|
||
assert.match(
|
||
css,
|
||
new RegExp(
|
||
`#${panel} \\.panel-glow(?:,[^{]*)? \\{[^}]*\\binset: -\\d+px;`,
|
||
),
|
||
`${panel} glow no longer overhangs its panel; revisit this pin`,
|
||
);
|
||
}
|
||
const narrow = narrowScreenBlocks(css).flatMap(flatRules);
|
||
const scrollingRails = [
|
||
...new Set(
|
||
narrow
|
||
.filter(([, declarations]) =>
|
||
/\boverflow(?:-y)?:\s*(?:auto|scroll)\b/.test(declarations),
|
||
)
|
||
.flatMap(([selectors]) => selectors)
|
||
.filter((selector) =>
|
||
/^#(?:left-panel-stack|right-context-rail)$/.test(selector),
|
||
),
|
||
),
|
||
].sort();
|
||
assert.deepEqual(scrollingRails, [
|
||
'#left-panel-stack',
|
||
'#right-context-rail',
|
||
]);
|
||
for (const rail of scrollingRails) {
|
||
assert.ok(
|
||
narrow.some(
|
||
([selectors, declarations]) =>
|
||
selectors.includes(`${rail} .panel-glow`) &&
|
||
/(?:^|;)\s*inset:\s*0\s*;/.test(declarations),
|
||
),
|
||
`${rail} scrolls at ≤720px but does not pin its panel glows (inset: 0)`,
|
||
);
|
||
}
|
||
});
|
||
|
||
test('right layout ignores a CSS-hidden expanded panel when deciding exclusivity', () => {
|
||
const f = fixture('right');
|
||
const weather = element('weather-panel', { height: 300 });
|
||
weather.computed.display = 'none';
|
||
f.stack.children.push(weather);
|
||
weather.parentElement = f.stack;
|
||
|
||
f.run();
|
||
|
||
// The rail lists the collapsed panels; the CSS-hidden one claims nothing.
|
||
assert.equal(f.stack.classList.contains('layout-exclusive'), true);
|
||
assert.equal(f.stack.dataset.expandedCount, '0');
|
||
assert.equal(weather.classList.contains('collapsed'), false);
|
||
assert.equal(weather.getAttribute('aria-hidden'), undefined);
|
||
assert.equal(
|
||
weather.style.getPropertyValue('--right-panel-allocated-height'),
|
||
'',
|
||
);
|
||
});
|
||
|
||
test('right layout still restores panels that exclusive presentation hides', () => {
|
||
const f = fixture('right');
|
||
// Model layers.css: an exclusive rail hides its collapsed panels.
|
||
f.options.windowRef.getComputedStyle = (node) => ({
|
||
...node.computed,
|
||
display:
|
||
f.stack.classList.contains('layout-exclusive') &&
|
||
node.classList.contains('collapsed')
|
||
? 'none'
|
||
: node.computed.display,
|
||
});
|
||
f.expand(f.first, 250);
|
||
f.second.classList.add('layout-auto-collapsed');
|
||
f.stack.classList.add('layout-exclusive');
|
||
|
||
f.run();
|
||
assert.equal(f.stack.classList.contains('layout-exclusive'), true);
|
||
assert.equal(f.second.getAttribute('aria-hidden'), 'true');
|
||
|
||
f.options.hud.visible = false;
|
||
f.run();
|
||
// Restored from the space-based collapse, it yields to the accordion owner.
|
||
assert.equal(f.second.classList.contains('layout-auto-collapsed'), false);
|
||
assert.equal(f.second.classList.contains('accordion-collapsed'), true);
|
||
assert.equal(f.first.classList.contains('collapsed'), false);
|
||
});
|
||
|
||
test('right layout ignores a hidden panel: no lane, no gap, no auto-collapse', () => {
|
||
const f = fixture('right');
|
||
const imagery = element('recent-imagery-panel', { height: 0 });
|
||
imagery.hidden = true;
|
||
imagery.scrollHeight = 0;
|
||
imagery.rect.height = 0;
|
||
f.stack.children.push(imagery);
|
||
imagery.parentElement = f.stack;
|
||
// A tall panel expands into focus mode: the hidden sibling is not among the
|
||
// later panels that focus mode collapses, and it counts for nothing.
|
||
f.expand(f.first, 900);
|
||
f.run();
|
||
assert.equal(f.stack.dataset.layoutMode, 'focus');
|
||
assert.equal(imagery.classList.contains('collapsed'), false);
|
||
assert.equal(imagery.classList.contains('layout-auto-collapsed'), false);
|
||
assert.equal(f.stack.dataset.expandedCount, '1');
|
||
assert.equal(
|
||
imagery.style.getPropertyValue('--right-panel-allocated-height'),
|
||
'',
|
||
);
|
||
assert.equal(imagery.getAttribute('aria-hidden'), undefined);
|
||
const alone = parseFloat(
|
||
f.first.style.getPropertyValue('--right-panel-allocated-height'),
|
||
);
|
||
// Shown (and expanded) it joins the allocation like any other panel.
|
||
imagery.hidden = false;
|
||
imagery.scrollHeight = 300;
|
||
imagery.rect.height = 300;
|
||
const retriesBefore = f.retries();
|
||
f.run();
|
||
assert.ok(
|
||
imagery.classList.contains('accordion-collapsed'),
|
||
'the accordion keeps the first panel and collapses the later one',
|
||
);
|
||
assert.equal(f.retries(), retriesBefore);
|
||
f.run();
|
||
assert.equal(f.stack.dataset.expandedCount, '1');
|
||
assert.ok(
|
||
parseFloat(
|
||
f.first.style.getPropertyValue('--right-panel-allocated-height'),
|
||
) <= alone,
|
||
);
|
||
});
|
||
|
||
test('left rail keeps collapsed panels shown beside Cockpit, still one open', () => {
|
||
const f = fixture('left');
|
||
f.options.documentRef.body = {
|
||
classList: { contains: (name) => name === 'cockpit-mode' },
|
||
};
|
||
f.expand(f.first, 250);
|
||
f.expand(f.second, 250);
|
||
f.options.preferredPanelId = f.second.id;
|
||
f.run();
|
||
assert.equal(f.first.classList.contains('accordion-collapsed'), true);
|
||
assert.equal(f.stack.classList.contains('layout-exclusive'), false);
|
||
assert.equal(f.first.getAttribute('aria-hidden'), undefined);
|
||
});
|