mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
feat(ui): render the Contacts panel from the awareness layer's published view
The awareness layer presents its panel as a plain view and publishes it through subscribePanel() instead of writing markup into #military-awareness-panel. AwarenessPanel renders it from the gev-context store, calls the layer's navigation, and keeps focus on the same control by identity across live repaints, continuing at the note when that control leaves or is disabled.
This commit is contained in:
1 parent
749bfd5cd4
commit
1e4df63efe
16 files changed
+582
-380
No files matched your search
@@ -25,12 +25,11 @@ export const collectAircraftProximityWindow =
|
||||
layer.collectAircraftProximityWindow;
|
||||
export const _getAwarenessNavigationStateForTest =
|
||||
layer._getAwarenessNavigationStateForTest;
|
||||
export const _getAwarenessPanelRendersForTest =
|
||||
layer._getAwarenessPanelRendersForTest;
|
||||
export const canNavigateAwarenessNext = layer.canNavigateAwarenessNext;
|
||||
export const historySubjectSnapshot = layer.historySubjectSnapshot;
|
||||
export const findCompatibleHistoryIndex = layer.findCompatibleHistoryIndex;
|
||||
export const awarenessPanelControlKey = layer.awarenessPanelControlKey;
|
||||
export const captureAwarenessPanelFocus = layer.captureAwarenessPanelFocus;
|
||||
export const restoreAwarenessPanelFocus = layer.restoreAwarenessPanelFocus;
|
||||
export const awarenessResultsAreLive = layer.awarenessResultsAreLive;
|
||||
export const awarenessNeedsContinuousRender =
|
||||
layer.awarenessNeedsContinuousRender;
|
||||
|
||||
@@ -1,26 +1,25 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import militaryAwarenessLayer, {
|
||||
_getAwarenessNavigationStateForTest,
|
||||
_getAwarenessPanelRendersForTest,
|
||||
awarenessClearMatchesSubject,
|
||||
awarenessNeedsContinuousRender,
|
||||
awarenessRefreshDecision,
|
||||
awarenessRefreshIntervalMs,
|
||||
awarenessRefreshRequired,
|
||||
awarenessResultsAreLive,
|
||||
awarenessPanelControlKey,
|
||||
buildAwarenessContextSnapshot,
|
||||
canNavigateAwarenessNext,
|
||||
contactsWindowFromSnapshot,
|
||||
contextTargetFlyToAllowed,
|
||||
captureAwarenessPanelFocus,
|
||||
summarizeInstallationViewport,
|
||||
findCompatibleHistoryIndex,
|
||||
AWARENESS_QUERY_LIMIT,
|
||||
historySubjectSnapshot,
|
||||
restoreAwarenessPanelFocus,
|
||||
} from './militaryAwareness.js';
|
||||
import flightsLayer, {
|
||||
_setTrackedFlightRefreshStateForTest,
|
||||
@@ -52,6 +51,10 @@ const militaryAwarenessSource = readLayerSource(
|
||||
new URL('./militaryAwareness.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const awarenessPanelSource = readFileSync(
|
||||
new URL('../ui/views/panels/AwarenessPanel.vue', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const AWARENESS_DEPENDENCIES = [
|
||||
'flights',
|
||||
@@ -98,27 +101,12 @@ function installAwarenessRuntime({
|
||||
const listeners = new Map();
|
||||
let nextTimer = 1;
|
||||
let preRenderListener = null;
|
||||
const createdElements = [];
|
||||
|
||||
class FakeElement {
|
||||
constructor() {
|
||||
this.classList = { contains: () => false };
|
||||
this.style = { setProperty() {} };
|
||||
this._hidden = false;
|
||||
// renderResults() writes `hidden = false` on EVERY call, ahead of its
|
||||
// markup diff, so counting these writes observes "was the panel
|
||||
// repaint path entered?" independently of whether the markup changed.
|
||||
this.hiddenWrites = 0;
|
||||
this.innerHTML = '';
|
||||
}
|
||||
|
||||
get hidden() {
|
||||
return this._hidden;
|
||||
}
|
||||
|
||||
set hidden(value) {
|
||||
this._hidden = value;
|
||||
this.hiddenWrites += 1;
|
||||
this.hidden = false;
|
||||
}
|
||||
|
||||
setAttribute() {}
|
||||
@@ -213,9 +201,7 @@ function installAwarenessRuntime({
|
||||
return null;
|
||||
},
|
||||
createElement() {
|
||||
const element = new FakeElement();
|
||||
createdElements.push(element);
|
||||
return element;
|
||||
return new FakeElement();
|
||||
},
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
@@ -229,6 +215,10 @@ function installAwarenessRuntime({
|
||||
});
|
||||
militaryAwarenessLayer.setParams({ passive: true });
|
||||
militaryAwarenessLayer.init(viewer);
|
||||
const panelViews = [];
|
||||
const unsubscribePanel = militaryAwarenessLayer.subscribePanel((view) =>
|
||||
panelViews.push(view),
|
||||
);
|
||||
militaryAwarenessLayer.enable();
|
||||
|
||||
return {
|
||||
@@ -248,15 +238,16 @@ function installAwarenessRuntime({
|
||||
);
|
||||
return taken;
|
||||
},
|
||||
/** The standalone Context panel element renderResults() paints into. */
|
||||
/** The Contacts panel as the layer publishes it to the view. */
|
||||
panel() {
|
||||
return (
|
||||
createdElements.find(
|
||||
(element) => element.id === 'military-awareness-panel',
|
||||
) || null
|
||||
);
|
||||
return {
|
||||
view: panelViews.at(-1) ?? null,
|
||||
publishes: panelViews.length,
|
||||
renders: _getAwarenessPanelRendersForTest(),
|
||||
};
|
||||
},
|
||||
restore() {
|
||||
unsubscribePanel();
|
||||
militaryAwarenessLayer.destroy();
|
||||
globalThis.window = originalWindow;
|
||||
globalThis.document = originalDocument;
|
||||
@@ -264,144 +255,24 @@ function installAwarenessRuntime({
|
||||
};
|
||||
}
|
||||
|
||||
function awarenessControl(dataset, { disabled = false } = {}) {
|
||||
return {
|
||||
dataset,
|
||||
disabled,
|
||||
focusCalls: 0,
|
||||
closest(selector) {
|
||||
if (selector === '[data-awareness-focus-continuation]') {
|
||||
return this.isContinuation ? this : null;
|
||||
}
|
||||
return selector.includes('button') && !this.isContinuation ? this : null;
|
||||
},
|
||||
matches(selector) {
|
||||
return (
|
||||
selector === '[data-awareness-focus-continuation]' &&
|
||||
this.isContinuation
|
||||
);
|
||||
},
|
||||
focus(options) {
|
||||
this.focusCalls += 1;
|
||||
this.focusOptions = options;
|
||||
},
|
||||
};
|
||||
/** The count a published Contacts panel shows for one cohort row. */
|
||||
function panelCount(runtime, label) {
|
||||
return runtime.panel().view?.rows?.find((row) => row.label === label)?.count;
|
||||
}
|
||||
|
||||
function awarenessPanel(controls) {
|
||||
const continuation = awarenessControl({});
|
||||
continuation.isContinuation = true;
|
||||
return {
|
||||
controls,
|
||||
continuation,
|
||||
contains(element) {
|
||||
return controls.includes(element) || element === continuation;
|
||||
},
|
||||
querySelectorAll() {
|
||||
return this.controls;
|
||||
},
|
||||
querySelector(selector) {
|
||||
return selector === '[data-awareness-focus-continuation]'
|
||||
? continuation
|
||||
: null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('Contacts live repaint restores only stable identity and never retargets another contact', () => {
|
||||
const previous = awarenessControl({ awarenessAction: 'previous' });
|
||||
const focus = awarenessControl({ awarenessAction: 'focus' });
|
||||
const next = awarenessControl({ awarenessAction: 'next' });
|
||||
const alpha = awarenessControl({
|
||||
awarenessLayer: 'flights',
|
||||
awarenessId: 'abc123',
|
||||
});
|
||||
const bravo = awarenessControl({
|
||||
awarenessLayer: 'military',
|
||||
awarenessId: 'def456',
|
||||
});
|
||||
const before = awarenessPanel([previous, focus, next, alpha, bravo]);
|
||||
const alphaSnapshot = captureAwarenessPanelFocus(before, alpha);
|
||||
|
||||
assert.deepEqual(alphaSnapshot, { key: 'target:flights:abc123' });
|
||||
assert.equal(awarenessPanelControlKey(next), 'action:next');
|
||||
|
||||
const retainedAlpha = awarenessControl({
|
||||
awarenessLayer: 'flights',
|
||||
awarenessId: 'abc123',
|
||||
});
|
||||
const retained = awarenessPanel([
|
||||
previous,
|
||||
focus,
|
||||
next,
|
||||
bravo,
|
||||
retainedAlpha,
|
||||
]);
|
||||
assert.equal(
|
||||
restoreAwarenessPanelFocus(retained, alphaSnapshot),
|
||||
retainedAlpha,
|
||||
);
|
||||
assert.equal(
|
||||
retainedAlpha.focusCalls,
|
||||
1,
|
||||
'identity wins even when live distance reorders rows',
|
||||
);
|
||||
assert.deepEqual(retainedAlpha.focusOptions, { preventScroll: true });
|
||||
|
||||
const forward = awarenessControl({
|
||||
awarenessLayer: 'ais-live-vessels',
|
||||
awarenessId: '789',
|
||||
});
|
||||
const departed = awarenessPanel([previous, focus, next, forward]);
|
||||
assert.equal(
|
||||
restoreAwarenessPanelFocus(departed, alphaSnapshot),
|
||||
departed.continuation,
|
||||
);
|
||||
assert.equal(
|
||||
forward.focusCalls,
|
||||
0,
|
||||
'automatic paging must not transfer focus to another contact',
|
||||
);
|
||||
assert.equal(departed.continuation.focusCalls, 1);
|
||||
|
||||
const bravoSnapshot = captureAwarenessPanelFocus(before, bravo);
|
||||
const noRemainingRow = awarenessPanel([previous, focus, next, alpha]);
|
||||
assert.equal(
|
||||
restoreAwarenessPanelFocus(noRemainingRow, bravoSnapshot),
|
||||
noRemainingRow.continuation,
|
||||
);
|
||||
assert.equal(
|
||||
noRemainingRow.continuation.focusCalls,
|
||||
1,
|
||||
'the end sentinel continues Tab beyond the list',
|
||||
);
|
||||
|
||||
const continuationSnapshot = captureAwarenessPanelFocus(
|
||||
departed,
|
||||
departed.continuation,
|
||||
);
|
||||
assert.deepEqual(continuationSnapshot, { key: 'continuation' });
|
||||
const repaintedAgain = awarenessPanel([previous, focus, next, forward]);
|
||||
assert.equal(
|
||||
restoreAwarenessPanelFocus(repaintedAgain, continuationSnapshot),
|
||||
repaintedAgain.continuation,
|
||||
);
|
||||
assert.equal(
|
||||
repaintedAgain.continuation.focusCalls,
|
||||
1,
|
||||
'later live repaints keep focus on the continuation point until the user tabs onward',
|
||||
);
|
||||
});
|
||||
|
||||
test('Contacts continuation target is the visible explanatory note', () => {
|
||||
const source = readLayerSource(
|
||||
new URL('./militaryAwareness.js', import.meta.url),
|
||||
assert.match(
|
||||
awarenessPanelSource,
|
||||
/<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>\{\{ view\.note \}\}<\/p>/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
awarenessPanelSource,
|
||||
/<span[^>]*data-awareness-focus-continuation/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>\$\{t\('Open-source mapped\/observed context\.[^']*'\)\}<\/p>/,
|
||||
militaryAwarenessSource,
|
||||
/'Open-source mapped\/observed context\./,
|
||||
);
|
||||
assert.doesNotMatch(source, /<span[^>]*data-awareness-focus-continuation/);
|
||||
});
|
||||
|
||||
test('awareness disable settles every owned dependency release before resolving', async () => {
|
||||
@@ -599,9 +470,9 @@ test('pending mapped installations render as unknown instead of a false zero', a
|
||||
'loading is not an observed empty cohort',
|
||||
);
|
||||
assert.match(installations?.reason || '', /unavailable/i);
|
||||
assert.match(
|
||||
runtime.panel()?.innerHTML || '',
|
||||
/Mapped installations<\/strong><b aria-live="polite">\?<\/b>/,
|
||||
assert.equal(
|
||||
panelCount(runtime, 'Mapped installations'),
|
||||
'?',
|
||||
'the operator sees ? rather than an all-clear 0 while installations load',
|
||||
);
|
||||
} finally {
|
||||
@@ -741,15 +612,10 @@ test('a vessel feed still connecting after the lifecycle settles reads as unknow
|
||||
'a source that has never answered has told us nothing — 0 would be a fabricated all-clear',
|
||||
);
|
||||
assert.match(vessels?.reason || '', /unavailable/i);
|
||||
assert.match(
|
||||
runtime.panel()?.innerHTML || '',
|
||||
/AIS vessels<\/strong><b aria-live="polite">\?<\/b>/,
|
||||
'the panel prints ? for the whole settled-but-connecting window',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
runtime.panel()?.innerHTML || '',
|
||||
/AIS vessels<\/strong><b aria-live="polite">0<\/b>/,
|
||||
'the panel must never print an all-clear 0 before the feed has answered once',
|
||||
assert.equal(
|
||||
panelCount(runtime, 'AIS vessels'),
|
||||
'?',
|
||||
'the panel prints ? for the whole settled-but-connecting window, never an all-clear 0',
|
||||
);
|
||||
} finally {
|
||||
restoreCollections();
|
||||
@@ -789,9 +655,9 @@ test('a settled vessel feed reporting a real empty viewport recovers to 0', asyn
|
||||
0,
|
||||
'an answered empty viewport is a real observation',
|
||||
);
|
||||
assert.match(
|
||||
runtime.panel()?.innerHTML || '',
|
||||
/AIS vessels<\/strong><b aria-live="polite">0<\/b>/,
|
||||
assert.equal(
|
||||
panelCount(runtime, 'AIS vessels'),
|
||||
'0',
|
||||
'the operator sees the real count once the feed answers',
|
||||
);
|
||||
} finally {
|
||||
@@ -1254,17 +1120,23 @@ test('a cached Context subject re-reads its label when enrichment lands after se
|
||||
id,
|
||||
);
|
||||
const panel = runtime.panel();
|
||||
assert.ok(panel, 'the Context panel must exist');
|
||||
assert.match(panel.innerHTML, /ae1fa4/, 'panel starts on the hex');
|
||||
assert.equal(panel.view?.kind, 'results', 'the Context panel must exist');
|
||||
assert.match(panel.view.subject, /ae1fa4/, 'panel starts on the hex');
|
||||
|
||||
// A refresh that changes NOTHING must not repaint (no per-refresh storm).
|
||||
const settledWrites = panel.hiddenWrites;
|
||||
const settledRenders = runtime.panel().renders;
|
||||
const settledPublishes = runtime.panel().publishes;
|
||||
await militaryAwarenessLayer.update();
|
||||
assert.equal(
|
||||
panel.hiddenWrites,
|
||||
settledWrites,
|
||||
runtime.panel().renders,
|
||||
settledRenders,
|
||||
'an unchanged label must not trigger a panel repaint',
|
||||
);
|
||||
assert.equal(
|
||||
runtime.panel().publishes,
|
||||
settledPublishes,
|
||||
'an unchanged refresh publishes no new panel, results or standby',
|
||||
);
|
||||
|
||||
// Enrichment answers. The contact has NOT moved and no source revision
|
||||
// changed, so the label is the only thing making this render-worthy.
|
||||
@@ -1283,17 +1155,18 @@ test('a cached Context subject re-reads its label when enrichment lands after se
|
||||
'subject identity must stay the ICAO hex',
|
||||
);
|
||||
// The RENDERED panel is the surface the operator actually reads.
|
||||
const repainted = runtime.panel();
|
||||
assert.ok(
|
||||
panel.hiddenWrites > settledWrites,
|
||||
repainted.publishes > settledPublishes,
|
||||
'a changed label must repaint the standalone Context panel',
|
||||
);
|
||||
assert.match(
|
||||
panel.innerHTML,
|
||||
repainted.view.subject,
|
||||
/N123AB/,
|
||||
'the rendered Context panel must show the registration without a position change',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
panel.innerHTML,
|
||||
JSON.stringify(repainted.view),
|
||||
/ae1fa4/,
|
||||
'the hex must be gone from the panel',
|
||||
);
|
||||
@@ -2240,9 +2113,10 @@ test('expanded Context results omit the redundant status heading', () => {
|
||||
militaryAwarenessSource,
|
||||
/GLOBAL CONTEXT <span>CONTEXT ONLY<\/span>/,
|
||||
);
|
||||
assert.match(militaryAwarenessSource, /military-awareness-subject/);
|
||||
assert.doesNotMatch(awarenessPanelSource, /military-awareness-heading/);
|
||||
assert.match(awarenessPanelSource, /military-awareness-subject/);
|
||||
assert.match(militaryAwarenessSource, /FLIGHT \/ VESSEL WINDOW/);
|
||||
assert.match(militaryAwarenessSource, /military-awareness-controls/);
|
||||
assert.match(awarenessPanelSource, /military-awareness-controls/);
|
||||
});
|
||||
|
||||
test('Context entry tracks a nearer civilian aircraft over a farther military aircraft', async () => {
|
||||
@@ -4229,7 +4103,7 @@ test('a moving camera refreshes Contacts more than once inside one parked interv
|
||||
}
|
||||
});
|
||||
|
||||
test('a selected installation card keeps every member name and escapes source text', () => {
|
||||
test('a selected installation card keeps every member name as text', () => {
|
||||
const runtime = installAwarenessRuntime();
|
||||
const restores = [];
|
||||
try {
|
||||
@@ -4252,16 +4126,15 @@ test('a selected installation card keeps every member name and escapes source te
|
||||
},
|
||||
});
|
||||
militaryAwarenessLayer.update();
|
||||
const html = runtime.panel().innerHTML;
|
||||
assert.match(html, /Named areas \(4\)/);
|
||||
for (const name of [
|
||||
// The view renders names as text; ui/awarenessPanel.test.mjs covers it.
|
||||
const names = runtime.panel().view?.subjectNames;
|
||||
assert.equal(names?.summary, 'Named areas (4)');
|
||||
assert.deepEqual(names?.names, [
|
||||
'Area one',
|
||||
'Area two',
|
||||
'Area three',
|
||||
'<Area four>',
|
||||
])
|
||||
assert.ok(html.includes(name));
|
||||
assert.ok(!html.includes('<Area four>'));
|
||||
'<Area four>',
|
||||
]);
|
||||
} finally {
|
||||
restores.reverse().forEach((restore) => restore());
|
||||
runtime.restore();
|
||||
|
||||
Reference in new issue
Block a user