mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
* feat(share): add compact versioned share-view codec Add a standalone codec for `#s=<base64url>` share links: a version byte followed by protobuf-style tag-length-value fields. Settings equal to their default are omitted and refilled from the same table on decode; unknown fields are skipped by wire type, so new fields never break older apps. Views are a place (camera) or a thing (an aircraft, military aircraft, vessel or satellite to follow, with camera offset and last-known fix); an unknown kind falls back to the camera. The decoder bounds its input and rejects malformed payloads with a reason instead of throwing. A converter maps the long form's parsed state to and from the codec state; the long form is unchanged. * fix(share): tighten codec decoding after review Unknown detection mode and allocation names now fall back to their defaults instead of reaching the lenient long-form migration. Future panel values and style-parameter tokens are skipped rather than applied or rejected, and layer options are accepted only under their owner layer. Wrap longitudes before scaling, normalize negative zero, reuse the layer-state tracking ID grammar, and align the format doc with the decoder. * feat(share): let a host share the link its own way ShareLinkManager.buildLink() returns the current share link without copying it, and copyLink() now copies exactly that. The share button passes the link to an optional services.shareLink(url), which resolves to 'shared', 'copied', 'cancelled' or false; 'cancelled' shows no toast and 'shared' shows the new "Link shared" (es, ru, zh-CN). Without the service the button copies as before. * feat(share): open and make short /share/<code> links The share button now makes `<app>/share/<code>` links with the compact share-view code, and the app opens them at start-up through the same restore pipeline as long links. Once the app next writes the address, the share path is replaced (no new history entry) by the app's own address and the live long-form hash, so a reload follows the live view. A host can pass the code as `initialShareCode` instead. A code that fails to decode restores nothing and shows "This share link can't be opened". The code gains a trailing CRC-8 check byte, so any single mistyped character is rejected instead of opening a different view. Thing views no longer store a camera: it is derived from the entity's last-known fix and the follow offset on a sphere, the entity kind is implied by the id's field, and the followed entity's layer bit is implied; an aircraft followed from the app's own camera is a 42-character code. The `#s=` hash form is gone. While the camera follows an aircraft, military aircraft or satellite, the link is a thing view with the camera's offset and the entity's position. Opening one turns the layer on and follows the entity through the existing shared-follow restore, then swings the camera round to the shared offset; a vessel view selects the vessel once the vessel feed has it. If the entity is not reporting within its window, the view stays at its last-known fix and says so. The panel and style-parameter tables now come from the codec, and a host share hook that returns a plain value or throws still gets a toast. * fix(share): encode style settings against the style's own look Settings a style applies when selected (bloom, sharpen, HUD, detection, celestial ring and the style's shader parameters) now default, per field, to what that style applies in a fresh session, read from the tables the app applies (the first-load baseline, the style presets and the shader defaults). The encoder compares against the view's style and the decoder fills omitted settings from the decoded style, so a view that keeps its style's look costs only its camera and style. The previous defaults did not match a fresh session (sharpen, HUD and detection were off), so every link paid for them, and a style's own shader parameters were always written. A test pins the per-style defaults as frozen v1 meanings. A followed aircraft in Noir is now 44 characters and the real long-link example 39. * docs: changelog for #1418
297 lines
12 KiB
JavaScript
297 lines
12 KiB
JavaScript
import { readShellSource } from './testSupport/readShellSource.mjs';
|
|
import { expandApplicationHtml } from '../build/application-html.js';
|
|
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, /classList\.add\('collapsed', 'layout-auto-collapsed'\)/);
|
|
assert.match(readFileSync(new URL('./ui/panelLayoutController.js', import.meta.url), 'utf8'), /classList\.remove\('collapsed', 'layout-auto-collapsed'\)/);
|
|
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*\)/);
|
|
assert.match(ui, /setAttribute\('aria-expanded', String\(!collapsed\)\)/);
|
|
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, /const hiddenSibling =\s*shouldFocus && panel\.classList\.contains\('collapsed'\);/);
|
|
});
|
|
|
|
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 collapsed =[\s\S]*?panelEl\.classList\.contains\('layout-auto-collapsed'\)[\s\S]*?panelEl\.classList\.contains\('cyber-accordion-collapsed'\)[\s\S]*?\? false\s*: panelEl\.classList\.contains\('collapsed'\);/,
|
|
'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\);/);
|
|
assert.match(
|
|
ui,
|
|
/this\._sliderPanel\.classList\.remove\('active'\);\s*this\._scheduleRightPanelLayout\(\);/,
|
|
);
|
|
assert.match(
|
|
ui,
|
|
/this\._sliderPanel\.classList\.add\('active'\);\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', () => {
|
|
const html = expandApplicationHtml(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', () => {
|
|
const html = expandApplicationHtml(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*\);/,
|
|
);
|
|
});
|