import { readShellSource } from './testSupport/readShellSource.mjs'; import { renderApplicationHtml } from './testSupport/applicationHtml.mjs'; import { readStylesheet } from './testSupport/readStylesheet.mjs'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import test from 'node:test'; const leftRail = readFileSync(new URL('./ui/leftPanelRail.js', import.meta.url), 'utf8'); const rightRail = readFileSync(new URL('./ui/rightPanelRail.js', import.meta.url), 'utf8'); const rails = leftRail + rightRail; import { allocatePanelStackHeights, panelStackAutoCollapseIndices, resolveLeftStackBottomBoundary, resolvePanelStackCorridor, } from './panelStackLayout.js'; // Measured in Cockpit at 1512x790: CONTACT card at y541, HUD corner at y659, // Cesium credits at y758, viewport inset boundary at y758.4. Every surface is // a hard boundary even when a standard map panel is reopened in Cockpit. const cockpitLane = { baseBottom: 758.4, safeGap: 9.5, obstacles: [ { top: 541.2, cockpitOverlay: true }, { top: 659, cockpitOverlay: true }, { top: 758, cockpitOverlay: false }, ], }; test('every left-lane obstacle shortens the corridor', () => { assert.equal( resolveLeftStackBottomBoundary(cockpitLane), 531.7, ); }); test('an expanded Cockpit panel remains above the Contact and HUD surfaces', () => { assert.equal( resolveLeftStackBottomBoundary(cockpitLane), 531.7, ); }); test('the Cesium credit line limits the corridor even in Cockpit', () => { const boundary = resolveLeftStackBottomBoundary({ baseBottom: 900, safeGap: 9.5, obstacles: [{ top: 758 }], }); assert.equal(boundary, 748.5); }); test('an empty or malformed obstacle set leaves the viewport inset intact', () => { assert.equal(resolveLeftStackBottomBoundary({ baseBottom: 700 }), 700); assert.equal( resolveLeftStackBottomBoundary({ baseBottom: 700, obstacles: [{ top: Number.NaN }, {}, null], safeGap: 8, }), 700, ); }); test('multiple expanded panels retain natural heights when the corridor fits', () => { assert.deepEqual(allocatePanelStackHeights({ naturalHeights: [280, 160], availableHeight: 500, }), [280, 160]); }); test('multiple expanded panels share a constrained corridor without overflow', () => { const allocated = allocatePanelStackHeights({ naturalHeights: [520, 300], availableHeight: 600, }); assert.equal(Math.round(allocated.reduce((sum, height) => sum + height, 0)), 600); assert.ok(allocated.every((height) => height >= 96)); assert.ok(allocated[0] > allocated[1]); }); test('very short corridors remain bounded with every panel represented', () => { const allocated = allocatePanelStackHeights({ naturalHeights: [420, 260, 180], availableHeight: 150, }); assert.equal(Math.round(allocated.reduce((sum, height) => sum + height, 0)), 150); assert.ok(allocated.every((height) => height > 0)); }); test('later panels below half their natural height auto-collapse while the first is preserved', () => { assert.deepEqual(panelStackAutoCollapseIndices({ naturalHeights: [520, 300, 180], allocatedHeights: [180, 149, 120], }), [1]); }); test('the half-height boundary remains expanded', () => { assert.deepEqual(panelStackAutoCollapseIndices({ naturalHeights: [520, 300], allocatedHeights: [100, 150], }), []); }); test('Tactical focus preserves the primary panel and collapses later competitors', () => { assert.deepEqual(panelStackAutoCollapseIndices({ naturalHeights: [320, 240, 180], allocatedHeights: [220, 180, 140], collapseLaterPanels: true, }), [1, 2]); }); test('viewport growth retains the aligned corridor when midpoint centering would cross Cockpit panels', () => { assert.deepEqual(resolvePanelStackCorridor({ viewportHeight: 1026, safeTop: 266.76, safeBottom: 515.24, obstacleSafeTop: 41.04, obstacleSafeBottom: 515.24, minimumHeight: 164.16, }), { safeTop: 266.76, safeBottom: 515.24, }); }); test('minimum panel corridor expands upward without crossing the lower obstacle boundary', () => { const corridor = resolvePanelStackCorridor({ viewportHeight: 1026, safeTop: 510.76, safeBottom: 515.24, obstacleSafeTop: 41.04, obstacleSafeBottom: 515.24, minimumHeight: 164.16, }); assert.equal(Number(corridor.safeTop.toFixed(2)), 351.08); assert.equal(corridor.safeBottom, 515.24); }); test('desktop panel lanes use per-panel allocations and presentation-only auto-collapse', () => { const ui = readShellSource(); const css = readStylesheet(new URL('../style.css', import.meta.url)); assert.doesNotMatch(ui, /_enforce(?:Left|Right)PanelAccordion/); assert.match(rails, /onCollapse\(panel, \{ collapsed: true, auto: 'layout' \}\)/); assert.match(readFileSync(new URL('./ui/panelLayoutController.js', import.meta.url), 'utf8'), /_presentPanel\(panel, \{ collapsed: false, auto: null \}\)/); assert.match(ui, /reconsiderAutoCollapse/); assert.match( ui, /_rightPanelStack\?\.contains\(panelEl\)[\s\S]*?_scheduleRightPanelLayout\(\{ reconsiderAutoCollapse: true \}\)/, ); assert.match( ui, /_scheduleLeftPanelLayout\(\{[\s\S]*?reconsiderAutoCollapse: this\._leftPanelStack\?\.contains\(panelEl\) === true/, ); assert.match(rails, /collapseLaterPanels: shouldFocus && hud\.variant === 'tactical'/); assert.match(ui, /this\._panelLayout\._leftStackPreferredPanelId = leftOwnerPanel\.id;/); assert.match( leftRail, /preferredExpandedPanel[\s\S]*?\[\s*preferredExpandedPanel,\s*\.\.\.expandedPanelsInDomOrder/, 'the latest explicitly opened left panel must receive primary allocation', ); assert.match(ui, /this\._panelLayout\._rightStackPreferredPanelId = rightOwnerPanel\.id;/); assert.match( rightRail, /panel\.id === preferredPanelId[\s\S]*?\[\s*preferredExpandedPanel,\s*\.\.\.expandedPanelsInDomOrder/, 'the latest explicitly opened right panel must receive primary allocation', ); assert.match(ui, /panelId === 'radio-panel'[\s\S]*?document\.getElementById\('global-context-panel'\)/); assert.match(rightRail, /focusedExpandedPanel = expandedPanelsInDomOrder\.find\(\s*\(panel\) =>\s*panel\.contains\(documentRef\.activeElement\),?\s*\)/); // Collapse buttons render aria-expanded from the state the chrome presents // (panelChromeButtons.test.mjs covers the views). assert.match(ui, /this\._presentPanel\(panelEl, \{ collapsed: nextCollapsed, auto: null \}\)/); assert.match(leftRail, /--left-panel-allocated-height/); assert.match(rightRail, /--right-panel-allocated-height/); assert.match( leftRail, /expandedPanels[\s\S]*?removeProperty\('--left-panel-allocated-height'\)[\s\S]*?measurePanelNaturalHeight/, 'left intrinsic measurement must clear the prior allocation first', ); assert.match( rightRail, /setAttribute\('data-rail-measuring', ''\)[\s\S]*?getBoundingClientRect\(\)\.height[\s\S]*?finally[\s\S]*?removeAttribute\('data-rail-measuring'\)/, 'right intrinsic measurement must neutralize allocations and always restore presentation', ); assert.match(css, /var\(\s*--left-panel-allocated-height/); assert.match(css, /var\(\s*--right-panel-allocated-height/); assert.match( css, /#left-panel-stack\.layout-focus > \[data-panel-id\]\.collapsed\s*\{\s*display:\s*none;/, 'focus mode must hide every collapsed sibling, including presentation-only auto-collapses', ); assert.doesNotMatch(css, /layout-focus > \[data-panel-id\]\.collapsed:not\(\.layout-auto-collapsed\)/); assert.match(leftRail, /if \(exclusive && panel\.classList\.contains\('collapsed'\)\)\s*panel\.setAttribute\('aria-hidden', 'true'\)/); }); test('share-panel state excludes responsive collapse and preserves recipient preferences', () => { const ui = readShellSource(); // The long form spells the share format's panel table (one source of truth). const panels = readFileSync(new URL('./share/viewCodec.js', import.meta.url), 'utf8'); assert.match( ui, /const shown = this\._shownPanel\(panelEl\);\s*const collapsed = prefersCollapsed\(shown\);/, 'responsive auto-collapse must serialize the explicit expanded preference', ); assert.match( ui, /_setCommandDockPanelPinState\(spec\.id, state\.pinned, \{\s*restore: true,\s*persist: false,\s*syncShare: false,/, 'restoring a pin must not overwrite local panel preferences or emit an intermediate hash', ); assert.match( ui, /_setCommandDockPanelPinState[\s\S]*?if \(syncShare\) this\.shareLinkManager\?\.onPanelStateChange\?\.\(\);/, 'pin and unpin must update the share hash even when collapse state is unchanged', ); assert.match(ui, /\{ id: 'param-slider-panel' \}/); assert.match(panels, /id: 'param-slider-panel',\s*token: 'm',\s*pinnable: false,/); }); test('parameterized Display presets keep one stable scroll owner', () => { const ui = readShellSource(); const css = readStylesheet(new URL('../style.css', import.meta.url)); assert.match(css, /#pp-toggles:not\(\.collapsed\) > \.pp-panel-body > #param-slider-panel\.active\s*\{[\s\S]*?flex:\s*0 0 auto;[\s\S]*?max-height:\s*none;[\s\S]*?overflow-y:\s*visible;/); assert.match(ui, /readDisplayScrollTop: \(\) =>\s*this\._displayPortalScrollRestoreOwner === 'standard'[\s\S]*?this\._standardDisplayScrollTop[\s\S]*?displayPanelScroller\(this\._ppToggles\)\?\.scrollTop \|\| 0/); assert.match(rightRail, /scroller\.scrollTop = Math\.min\(displayScrollTop, maxScrollTop\);/); // The panel's parameters change, and the rail lays out again. assert.match( ui, /this\._publishParameters\(null\);\s*this\._scheduleRightPanelLayout\(\);/, ); assert.match( ui, /this\._publishParameters\(\{[\s\S]*?\}\);\s*this\._scheduleRightPanelLayout\(\);/, ); assert.match( css, /\.param-slider\s*\{[\s\S]*?flex:\s*1;[\s\S]*?min-width:\s*0;/, 'parameter sliders must shrink before their value column can overflow', ); }); test('expanded Display uses its container shell instead of a nested header card', () => { const css = readStylesheet(new URL('../style.css', import.meta.url)); assert.match( css, /#pp-toggles:not\(\.collapsed\) > \.pp-header-row\s*\{[\s\S]*?border:\s*0;[\s\S]*?background:\s*transparent;[\s\S]*?box-shadow:\s*none;/, ); assert.match( css, /#pp-toggles\.collapsed \.pp-header-row\s*\{[\s\S]*?width:\s*var\(\s*--right-collapsed-width, 132px\);/, 'collapsed Display must retain its standalone launcher sizing', ); }); test('expanded left panels integrate their headers with the container shell', () => { const css = readStylesheet(new URL('../style.css', import.meta.url)); assert.match( css, /#left-panel-stack > \[data-panel-id\]:not\(\.collapsed\) \.panel-header\s*\{[\s\S]*?position:\s*sticky;[\s\S]*?background:\s*transparent;/, ); assert.match( css, /#left-panel-stack > \[data-panel-id\]:not\(\.collapsed\) \.panel-divider\s*\{[\s\S]*?linear-gradient\(\s*90deg,\s*rgb\(0 212 255 \/ 28%\),\s*rgba\(0, 212, 255, 0\.18\) 58%,\s*transparent\s*\)[\s\S]*?box-shadow:\s*0 0 7px rgba\(0, 212, 255, 0\.22\);/, ); }); test('Map Source uses five compact tiles in the bottom Visual Presets tray', async () => { const html = await renderApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8')); const css = readStylesheet(new URL('../style.css', import.meta.url)); assert.doesNotMatch(html, /id="stack-panel"/); assert.match(html, /id="control-panel"[\s\S]*?class="map-source-section"[\s\S]*?id="map-stack-chips"/); assert.match( css, /\.map-stack-chip-row\s*\{[\s\S]*?grid-template-columns:\s*repeat\(5, minmax\(0, 1fr\)\);/, 'the desktop source selector keeps all five tiles on one row', ); }); test('expanded right panels highlight the title divider without changing collapsed launchers', async () => { const html = await renderApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8')); const css = readStylesheet(new URL('../style.css', import.meta.url)); assert.match( html, /class="compact pp-header-row"[\s\S]*?class="pp-header-label">DISPLAY<\/span>[\s\S]*?class="panel-divider"/, ); assert.match( css, /#right-context-rail \[data-panel-id\]:not\(\.collapsed\) \.panel-divider\s*\{[\s\S]*?linear-gradient\(\s*90deg,\s*rgb\(0 212 255 \/ 28%\),\s*rgba\(0, 212, 255, 0\.18\) 58%,\s*transparent\s*\)[\s\S]*?box-shadow:\s*0 0 7px rgba\(0, 212, 255, 0\.22\);/, ); assert.match( css, /#param-slider-panel:not\(\.collapsed\) \.param-panel-divider\s*\{[\s\S]*?linear-gradient\(\s*90deg,\s*rgb\(0 212 255 \/ 28%\),\s*rgba\(0, 212, 255, 0\.18\) 58%,\s*transparent\s*\);/, ); }); test('Utilities sits between Data Layers and Scenes and follows the left panels through every mode', () => { const css = readStylesheet(new URL('../style.css', import.meta.url)); const order = (id) => Number(new RegExp(`#left-panel-stack > #${id} \\{\\s*order: (\\d+);`).exec(css)?.[1]); assert.ok(order('data-panel') < order('utilities-panel')); assert.ok(order('utilities-panel') < order('scene-panel')); assert.match(css, /body\.ui-clean-view #utilities-panel,/); assert.match(css, /body\.recording-mode #utilities-panel,/); // Directions stays reachable in Cockpit; only Draw and Import step aside. assert.match(css, /body\.cockpit-mode #utilities-panel \.utilities-tools \{\s*display: none !important;/); assert.doesNotMatch(css, /body\.cockpit-mode #left-panel-stack > #utilities-panel[,\s{]/); assert.match(css, /body\.mobile-shell\[data-mobile-sheet='layers'\]\s+#left-panel-stack\s+> #utilities-panel,/); }); test('every left panel has its rail item, Utilities with its own icon', async () => { const { PANEL_RAIL_ITEMS } = await import('./ui/panelRailCatalog.js'); const view = readFileSync(new URL('./ui/views/LayerPanelsView.vue', import.meta.url), 'utf8'); const ids = [...view.matchAll(/ id); assert.ok(ids.includes('utilities-panel')); for (const id of ids) assert.ok(PANEL_RAIL_ITEMS[id], `${id} has a rail item`); assert.equal(PANEL_RAIL_ITEMS['utilities-panel'].icon, 'handyman'); });