mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Merge branch 'main' into fix/e-route-distance-ten-km
This commit is contained in:
commit
941a7a392e
779 files changed
+69864
-21453
No files matched your search
@@ -35,6 +35,10 @@ import { ensureGeoidReady, geoidHeight } from './geoid.js';
|
||||
import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js';
|
||||
import { applyVesselOverlayPolicy } from './vesselLabels.js';
|
||||
import { layerFeedState } from './manager.js';
|
||||
import {
|
||||
presentSelectedVessel,
|
||||
subscribeSelectedVessel,
|
||||
} from '../hudReadouts.js';
|
||||
|
||||
test('open feed with vessels is healthy (null)', () => {
|
||||
assert.equal(deriveAisFeedError({ status: 'open', lastMessageAt: 1, error: null }, 42), null);
|
||||
@@ -856,15 +860,6 @@ function makeTrailSpy() {
|
||||
};
|
||||
}
|
||||
|
||||
function makeClassList(...initial) {
|
||||
const classes = new Set(initial);
|
||||
return {
|
||||
add(value) { classes.add(value); },
|
||||
remove(value) { classes.delete(value); },
|
||||
contains(value) { return classes.has(value); },
|
||||
};
|
||||
}
|
||||
|
||||
function makeInteractionHandler() {
|
||||
return {
|
||||
click: null,
|
||||
@@ -934,14 +929,20 @@ function installWireHarness(picked, stateOverrides = {}) {
|
||||
};
|
||||
const record = makeRecord();
|
||||
const trail = makeTrailSpy();
|
||||
presentSelectedVessel({ text: `AIS: ${record.name}` });
|
||||
let vesselReadout;
|
||||
const stopReadout = subscribeSelectedVessel(
|
||||
(readout) => (vesselReadout = readout),
|
||||
);
|
||||
const hud = {
|
||||
textContent: `AIS: ${record.name}`,
|
||||
classList: makeClassList('active'),
|
||||
get readout() {
|
||||
return vesselReadout;
|
||||
},
|
||||
};
|
||||
|
||||
globalThis.window = windowTarget;
|
||||
globalThis.document = {
|
||||
getElementById: (id) => (id === 'hud-ais-vessel' ? hud : null),
|
||||
getElementById: () => null,
|
||||
};
|
||||
_setVesselStateForTest({
|
||||
records: [record],
|
||||
@@ -966,6 +967,7 @@ function installWireHarness(picked, stateOverrides = {}) {
|
||||
reinstalledHandlers,
|
||||
interactionHandlerFactory,
|
||||
cleanup() {
|
||||
stopReadout();
|
||||
aisLiveVesselsLayer.destroy();
|
||||
if (hadWindow) globalThis.window = priorWindow;
|
||||
else delete globalThis.window;
|
||||
@@ -1091,8 +1093,7 @@ test('vessel interaction wire: id-less 3D Tiles pick deselects and resets the HU
|
||||
harness.handler.click({ position: { x: 10, y: 20 } });
|
||||
|
||||
assert.equal(aisLiveVesselsLayer.getSelectedInfo(), null);
|
||||
assert.equal(harness.hud.textContent, 'AIS: --');
|
||||
assert.equal(harness.hud.classList.contains('active'), false);
|
||||
assert.equal(harness.hud.readout, null);
|
||||
// Clicking away is a deliberate deselect, not the vessel aging out of the
|
||||
// feed. Consumers that keep a readout on screen (the Cockpit Contact
|
||||
// panel) tear down on 'deliberate' and hold last-known on 'evicted'.
|
||||
@@ -1119,8 +1120,7 @@ test('vessel interaction wire: empty pick deselects and emits gev:entity-selecti
|
||||
harness.handler.click({ position: { x: 10, y: 20 } });
|
||||
|
||||
assert.equal(aisLiveVesselsLayer.getSelectedInfo(), null);
|
||||
assert.equal(harness.hud.textContent, 'AIS: --');
|
||||
assert.equal(harness.hud.classList.contains('active'), false);
|
||||
assert.equal(harness.hud.readout, null);
|
||||
// Empty pick is a deliberate deselect — see the note above.
|
||||
assert.deepEqual(cleared, [{ layerId: 'ais-live-vessels', reason: 'deliberate' }]);
|
||||
} finally {
|
||||
@@ -1133,8 +1133,7 @@ test('vessel interaction wire: Escape deselects the selected vessel', () => {
|
||||
try {
|
||||
harness.keyTarget.dispatch({ key: 'Escape' });
|
||||
assert.equal(aisLiveVesselsLayer.getSelectedInfo(), null);
|
||||
assert.equal(harness.hud.textContent, 'AIS: --');
|
||||
assert.equal(harness.hud.classList.contains('active'), false);
|
||||
assert.equal(harness.hud.readout, null);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
@@ -1322,6 +1321,27 @@ test('buildVesselCard: heading falls back to course; missing parts are omitted',
|
||||
assert.deepEqual(bare.details, []);
|
||||
});
|
||||
|
||||
test('vessel cards read a course that rounds to north as 0°, not 360°', () => {
|
||||
const card = (course) => buildVesselCard(makeRecord({ heading: null, course })).details[0];
|
||||
assert.equal(card(359.4), 'CONTAINER SHIP · 14.5KT · 359°');
|
||||
assert.equal(card(359.7), 'CONTAINER SHIP · 14.5KT · 0°');
|
||||
assert.equal(
|
||||
buildSelectedVesselCard(makeRecord({ heading: null, course: 359.7 })).details[0],
|
||||
'CONTAINER SHIP · 14.5KT · 0°',
|
||||
);
|
||||
});
|
||||
|
||||
test('the vessel HUD reads a course that rounds to north as HDG 0DEG', () => {
|
||||
const harness = installWireHarness(undefined, { selectedRecord: null });
|
||||
try {
|
||||
Object.assign(harness.record, { heading: null, course: 359.7 });
|
||||
assert.equal(aisLiveVesselsLayer.selectById(harness.record.mmsi), true);
|
||||
assert.match(harness.hud.readout.text, /HDG: 0DEG/);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('buildVesselCard: unnamed vessels title as MMSI; long names truncate', () => {
|
||||
const unnamed = buildVesselCard(makeRecord({ name: 'VESSEL' }));
|
||||
assert.equal(unnamed.title, 'MMSI 353136000');
|
||||
@@ -1719,3 +1739,50 @@ test('vessel selection passes the opaque source reference to optional history',
|
||||
aisLiveVesselsLayer.setSource(createVesselSource());
|
||||
}
|
||||
});
|
||||
|
||||
test('vessel focus wire skips hidden sprites at rest while a target is tracked', () => {
|
||||
const makeColor = (alpha) => ({ alpha, withAlpha: (next) => makeColor(next) });
|
||||
const sprite = (show) => ({
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
show,
|
||||
color: makeColor(1),
|
||||
});
|
||||
const visible = sprite(true);
|
||||
const hidden = Array.from({ length: 50 }, () => sprite(false));
|
||||
const records = [visible, ...hidden].map((billboard) => ({
|
||||
position: billboard.position,
|
||||
billboard,
|
||||
}));
|
||||
const target = {
|
||||
screenRect: { left: 40, top: 40, right: 60, bottom: 60 },
|
||||
paddingPx: 0,
|
||||
cameraDistance: 1000,
|
||||
};
|
||||
let projected = 0;
|
||||
let measured = 0;
|
||||
const apply = (nowMs) =>
|
||||
applyVesselFocusDeemphasis({
|
||||
records,
|
||||
target,
|
||||
nowMs,
|
||||
screenPositionFor: () => {
|
||||
projected += 1;
|
||||
return { x: 50, y: 50 };
|
||||
},
|
||||
cameraDistanceFor: () => {
|
||||
measured += 1;
|
||||
return 1200;
|
||||
},
|
||||
params: { paddingPx: 0, dimFloor: 0.25, attackMs: 300 },
|
||||
});
|
||||
|
||||
apply(0);
|
||||
const result = apply(300);
|
||||
|
||||
// Only the on-screen sprite pays for projection and distance.
|
||||
assert.equal(projected, 2);
|
||||
assert.equal(measured, 2);
|
||||
assert.equal(visible.color.alpha, 0.25);
|
||||
assert.equal(result.activeCount, 1);
|
||||
for (const billboard of hidden) assert.equal(billboard.color.alpha, 1);
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { t, msg } from '../i18n/index.js';
|
||||
import { t, msg, onLocaleChange } from '../i18n/index.js';
|
||||
|
||||
const YOUTUBE_HOSTS = new Set([
|
||||
'youtube.com',
|
||||
@@ -569,7 +569,8 @@ function anchorOnScreen(viewer, anchor, root) {
|
||||
return { x, y, width: rootBounds.width, height: rootBounds.height };
|
||||
}
|
||||
|
||||
function placeCard(card, leader, anchor) {
|
||||
/** Place a media card beside its map anchor, in the lane between the panels. */
|
||||
export function placeCard(card, leader, anchor) {
|
||||
const margin = 18;
|
||||
const gap = 42;
|
||||
const width = card.offsetWidth || 392;
|
||||
@@ -580,22 +581,22 @@ function placeCard(card, leader, anchor) {
|
||||
let laneRight = anchor.width - margin;
|
||||
let laneBottom = anchor.height - margin;
|
||||
if (rootBounds && documentRef?.querySelector) {
|
||||
const leftPanel = documentRef
|
||||
.querySelector('#scene-panel')
|
||||
?.getBoundingClientRect?.();
|
||||
const rightRail = documentRef
|
||||
.querySelector('#right-context-rail')
|
||||
?.getBoundingClientRect?.();
|
||||
const commandDock = documentRef
|
||||
.querySelector('#command-dock')
|
||||
?.getBoundingClientRect?.();
|
||||
if (leftPanel?.width > 0)
|
||||
laneLeft = Math.max(laneLeft, leftPanel.right - rootBounds.left + margin);
|
||||
if (rightRail?.width > 0)
|
||||
laneRight = Math.min(
|
||||
laneRight,
|
||||
rightRail.left - rootBounds.left - margin,
|
||||
);
|
||||
// The scene panel and the context rail swap sides in right-to-left
|
||||
// languages: each narrows the lane from the side it sits on.
|
||||
for (const selector of ['#scene-panel', '#right-context-rail']) {
|
||||
const panel = documentRef
|
||||
.querySelector(selector)
|
||||
?.getBoundingClientRect?.();
|
||||
if (!(panel?.width > 0)) continue;
|
||||
const start = panel.left - rootBounds.left;
|
||||
const end = panel.right - rootBounds.left;
|
||||
if (start + end < rootBounds.width)
|
||||
laneLeft = Math.max(laneLeft, end + margin);
|
||||
else laneRight = Math.min(laneRight, start - margin);
|
||||
}
|
||||
if (commandDock?.height > 0)
|
||||
laneBottom = Math.min(
|
||||
laneBottom,
|
||||
@@ -674,6 +675,30 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
let record = null;
|
||||
let warmRecord = null;
|
||||
let generation = 0;
|
||||
// Text the cards show, kept as the calls that word it so a locale change
|
||||
// words it again. A node is forgotten once it has left the page.
|
||||
const worded = new Map();
|
||||
function forgetDetached() {
|
||||
for (const node of worded.keys())
|
||||
if (!node.isConnected) worded.delete(node);
|
||||
}
|
||||
function word(node, property, text) {
|
||||
let properties = worded.get(node);
|
||||
if (!properties) worded.set(node, (properties = new Map()));
|
||||
properties.set(property, text);
|
||||
writeWord(node, property, text());
|
||||
}
|
||||
function writeWord(node, property, value) {
|
||||
if (property === 'textContent' || property === 'title')
|
||||
node[property] = value;
|
||||
else node.setAttribute(property, value);
|
||||
}
|
||||
const offLocale = onLocaleChange(() => {
|
||||
forgetDetached();
|
||||
for (const [node, properties] of worded)
|
||||
for (const [property, text] of properties)
|
||||
writeWord(node, property, text());
|
||||
});
|
||||
const leavingRecords = new Set();
|
||||
|
||||
const prefersReducedMotion = () =>
|
||||
@@ -818,6 +843,7 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
current.youtubeSession?.destroy();
|
||||
current.card?.remove?.();
|
||||
leavingRecords.delete(current);
|
||||
forgetDetached();
|
||||
}
|
||||
|
||||
const position = () => {
|
||||
@@ -871,6 +897,7 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
destroyFacebookSession(warmRecord.facebookSession);
|
||||
else warmRecord.iframe?.remove?.();
|
||||
warmRecord = null;
|
||||
forgetDetached();
|
||||
}
|
||||
|
||||
function takeWarmFacebook(key, host) {
|
||||
@@ -917,9 +944,11 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
startAtSec,
|
||||
endAtSec,
|
||||
});
|
||||
iframe.title = t('Preloading {provider} media', {
|
||||
provider: providerLabel(source.provider),
|
||||
});
|
||||
word(iframe, 'title', () =>
|
||||
t('Preloading {provider} media', {
|
||||
provider: providerLabel(source.provider),
|
||||
}),
|
||||
);
|
||||
iframe.tabIndex = -1;
|
||||
iframe.setAttribute('aria-hidden', 'true');
|
||||
iframe.loading = 'eager';
|
||||
@@ -930,7 +959,7 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
}
|
||||
|
||||
async function mountX(current, token) {
|
||||
current.status.textContent = t('LOADING X POST…');
|
||||
word(current.status, 'textContent', () => t('LOADING X POST…'));
|
||||
try {
|
||||
const api = await xLoader(documentRef, globalRef);
|
||||
if (token !== generation || record !== current) return;
|
||||
@@ -950,14 +979,18 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
throw new Error(
|
||||
msg('The X post is unavailable or cannot be embedded.'),
|
||||
);
|
||||
current.status.textContent = t('X POST · PROVIDER CONTROLS');
|
||||
word(current.status, 'textContent', () =>
|
||||
t('X POST · PROVIDER CONTROLS'),
|
||||
);
|
||||
position();
|
||||
} catch (error) {
|
||||
if (token !== generation || record !== current) return;
|
||||
current.card.classList.add('is-unavailable');
|
||||
current.status.textContent = t('{reason} OPEN ORIGINAL.', {
|
||||
reason: t(String(error?.message || msg('X embed unavailable'))),
|
||||
});
|
||||
word(current.status, 'textContent', () =>
|
||||
t('{reason} OPEN ORIGINAL.', {
|
||||
reason: t(String(error?.message || msg('X embed unavailable'))),
|
||||
}),
|
||||
);
|
||||
position();
|
||||
}
|
||||
}
|
||||
@@ -970,22 +1003,28 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
session.shouldPlay = autoplay === true;
|
||||
session.onReady = () => {
|
||||
if (token !== generation || record !== current) return;
|
||||
current.status.textContent = t('FACEBOOK · PROVIDER CONTROLS');
|
||||
word(current.status, 'textContent', () =>
|
||||
t('FACEBOOK · PROVIDER CONTROLS'),
|
||||
);
|
||||
current.card.classList.remove('is-unavailable');
|
||||
position();
|
||||
};
|
||||
session.onError = (error) => {
|
||||
if (token !== generation || record !== current) return;
|
||||
current.card.classList.add('is-unavailable');
|
||||
current.status.textContent = t('{reason} OPEN ORIGINAL.', {
|
||||
reason: t(String(error?.message || msg('Facebook embed unavailable'))),
|
||||
});
|
||||
word(current.status, 'textContent', () =>
|
||||
t('{reason} OPEN ORIGINAL.', {
|
||||
reason: t(
|
||||
String(error?.message || msg('Facebook embed unavailable')),
|
||||
),
|
||||
}),
|
||||
);
|
||||
position();
|
||||
};
|
||||
session.onPlaybackBlocked = () => {
|
||||
if (token !== generation || record !== current) return;
|
||||
current.status.textContent = t(
|
||||
'AUTOPLAY BLOCKED · USE PROVIDER CONTROLS',
|
||||
word(current.status, 'textContent', () =>
|
||||
t('AUTOPLAY BLOCKED · USE PROVIDER CONTROLS'),
|
||||
);
|
||||
};
|
||||
if (session.ready) session.onReady();
|
||||
@@ -1036,8 +1075,7 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
const portraitClass = isPortraitMedia(media) ? ' is-portrait' : '';
|
||||
card.className = `bhote-embedded-callout is-${source.provider}${portraitClass}`;
|
||||
card.dataset.provider = source.provider;
|
||||
card.setAttribute(
|
||||
'aria-label',
|
||||
word(card, 'aria-label', () =>
|
||||
t('Embedded {provider} media for {source}', {
|
||||
provider: providerLabel(source.provider),
|
||||
source: observation?.title
|
||||
@@ -1054,22 +1092,26 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
leader.innerHTML =
|
||||
'<path pathLength="1" fill="none" stroke="rgba(147,213,228,.72)" stroke-width="1.5"/><circle r="4" fill="#6be8ff" stroke="#ffffff" stroke-width="1.5"/>';
|
||||
const player = documentRef.createElement('div');
|
||||
player.className = 'bhote-embedded-callout-player';
|
||||
player.className = 'bhote-embedded-callout-player ui-scroll';
|
||||
const footer = documentRef.createElement('footer');
|
||||
const title = documentRef.createElement('strong');
|
||||
title.textContent = bhoteEventText(
|
||||
observation?.shortTitle || observation?.title || t('Witness source'),
|
||||
).toUpperCase();
|
||||
word(title, 'textContent', () =>
|
||||
bhoteEventText(
|
||||
observation?.shortTitle || observation?.title || t('Witness source'),
|
||||
).toUpperCase(),
|
||||
);
|
||||
const status = documentRef.createElement('small');
|
||||
status.setAttribute('role', 'status');
|
||||
status.textContent = t('{provider} · EMBEDDED SOURCE', {
|
||||
provider: providerLabel(source.provider),
|
||||
});
|
||||
word(status, 'textContent', () =>
|
||||
t('{provider} · EMBEDDED SOURCE', {
|
||||
provider: providerLabel(source.provider),
|
||||
}),
|
||||
);
|
||||
const link = documentRef.createElement('a');
|
||||
link.href = source.url;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer';
|
||||
link.textContent = t('OPEN ORIGINAL ↗');
|
||||
word(link, 'textContent', () => t('OPEN ORIGINAL ↗'));
|
||||
footer.append(title, status, link);
|
||||
const body = documentRef.createElement('div');
|
||||
body.className = 'bhote-embedded-callout-body';
|
||||
@@ -1109,12 +1151,14 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
url.searchParams.set('origin', globalRef.location.origin);
|
||||
iframe.src = url.href;
|
||||
}
|
||||
iframe.title = t('{provider} media: {source}', {
|
||||
provider: providerLabel(source.provider),
|
||||
source: observation?.title
|
||||
? bhoteEventText(observation.title)
|
||||
: t('witness source'),
|
||||
});
|
||||
word(iframe, 'title', () =>
|
||||
t('{provider} media: {source}', {
|
||||
provider: providerLabel(source.provider),
|
||||
source: observation?.title
|
||||
? bhoteEventText(observation.title)
|
||||
: t('witness source'),
|
||||
}),
|
||||
);
|
||||
iframe.allow =
|
||||
'autoplay; encrypted-media; picture-in-picture; fullscreen';
|
||||
iframe.allowFullscreen = true;
|
||||
@@ -1131,9 +1175,11 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
record.youtubeSession.getState().phase,
|
||||
)
|
||||
) {
|
||||
status.textContent = t('{provider} · PROVIDER CONTROLS', {
|
||||
provider: providerLabel(source.provider),
|
||||
});
|
||||
word(status, 'textContent', () =>
|
||||
t('{provider} · PROVIDER CONTROLS', {
|
||||
provider: providerLabel(source.provider),
|
||||
}),
|
||||
);
|
||||
}
|
||||
position();
|
||||
},
|
||||
@@ -1151,11 +1197,13 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
isCurrent: () => token === generation && record === current,
|
||||
onStatus: (phase) => {
|
||||
if (phase !== 'completed')
|
||||
status.textContent = t('YOUTUBE {state} · OPEN ORIGINAL', {
|
||||
state: YOUTUBE_PHASE_LABELS[phase]
|
||||
? t(YOUTUBE_PHASE_LABELS[phase])
|
||||
: phase.toUpperCase(),
|
||||
});
|
||||
word(status, 'textContent', () =>
|
||||
t('YOUTUBE {state} · OPEN ORIGINAL', {
|
||||
state: YOUTUBE_PHASE_LABELS[phase]
|
||||
? t(YOUTUBE_PHASE_LABELS[phase])
|
||||
: phase.toUpperCase(),
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1165,6 +1213,8 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
offLocale();
|
||||
worded.clear();
|
||||
hide({ immediate: true });
|
||||
for (const current of [...leavingRecords]) removeRecord(current);
|
||||
clearWarm();
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
createBhoteKoshiEmbeddedMedia,
|
||||
createTrimmedYouTubePlayback,
|
||||
embeddedMediaFrameUrl,
|
||||
placeCard,
|
||||
publicEmbeddedMediaUrl,
|
||||
resolveEmbeddedMediaSource,
|
||||
} from './bhoteKoshiEmbeddedMedia.js';
|
||||
@@ -348,7 +349,7 @@ test('camera updates retain a preload until the visible player loads', () => {
|
||||
assert.equal(frame.parent, root, 'keep the preload while the visible player starts');
|
||||
const card = root.children.find(node => node.tag === 'section');
|
||||
const body = card.children.find(node => node.className === 'bhote-embedded-callout-body');
|
||||
const player = body.children.find(node => node.className === 'bhote-embedded-callout-player');
|
||||
const player = body.children.find(node => node.className.split(' ').includes('bhote-embedded-callout-player'));
|
||||
player.children.find(node => node.tag === 'iframe').dispatchEvent(new Event('load'));
|
||||
assert.equal(frame.parent, null, 'visible player readiness releases the preload');
|
||||
media.destroy();
|
||||
@@ -394,3 +395,25 @@ test('pausing cancels a mounted Facebook preload and rejects its late ready call
|
||||
assert.deepEqual(playerCalls, []);
|
||||
media.destroy();
|
||||
});
|
||||
|
||||
test('a media card stays between the panels when right-to-left swaps their sides', () => {
|
||||
const rect = (left, right, top = 0, bottom = 900) => ({ left, right, top, bottom, width: right - left, height: bottom - top });
|
||||
// Arabic: the scene panel sits on the right, the context rail on the left.
|
||||
const panels = {
|
||||
'#scene-panel': rect(1040, 1420),
|
||||
'#right-context-rail': rect(20, 340),
|
||||
'#command-dock': rect(500, 940, 820, 880),
|
||||
};
|
||||
const card = {
|
||||
offsetWidth: 392,
|
||||
offsetHeight: 360,
|
||||
parentElement: { getBoundingClientRect: () => rect(0, 1440) },
|
||||
ownerDocument: { querySelector: (selector) => ({ getBoundingClientRect: () => panels[selector] }) },
|
||||
classList: { contains: () => false },
|
||||
style: {},
|
||||
};
|
||||
placeCard(card, { querySelector: () => null }, { x: 700, y: 400, width: 1440, height: 900 });
|
||||
const left = parseFloat(card.style.left);
|
||||
assert.ok(left >= 340 + 18, `card at ${left}px lands on the context rail`);
|
||||
assert.ok(left + 392 <= 1040 - 18, `card at ${left}px lands on the scene panel`);
|
||||
});
|
||||
+158
-201
@@ -25,7 +25,9 @@ import {
|
||||
} from './bhoteKoshiEmbeddedMedia.js';
|
||||
import { acquireImageryComparison } from '../maps/imageryComparison.js';
|
||||
import { createImagerySplit } from '../ui/imagerySplit.js';
|
||||
import { formatDate, t, tn, msg } from '../i18n/index.js';
|
||||
import { formatDate, t, tn, msg, onLocaleChange } from '../i18n/index.js';
|
||||
import { useBhoteKoshiPanelStore } from '../ui/stores/bhoteKoshiPanel.js';
|
||||
import { applicationPinia } from '../ui/vueRuntime.js';
|
||||
|
||||
export const BHOTE_KOSHI_LAYER_ID = 'bhote-koshi-2026';
|
||||
export const BHOTE_KOSHI_OVERLAY_SOURCE_ID = 'bhote-koshi-witnesses';
|
||||
@@ -449,18 +451,6 @@ function imageryEndpointText(entry) {
|
||||
return [name, date].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
/** Name, then the date kept on one line so the year never wraps alone. */
|
||||
function renderImageryEndpoint(element, entry) {
|
||||
if (!element) return;
|
||||
const { name, date } = imageryEndpointLabel(entry);
|
||||
element.textContent = date ? `${name} · ` : name;
|
||||
if (!date) return;
|
||||
const dateElement = document.createElement('span');
|
||||
dateElement.className = 'bhote-event-date';
|
||||
dateElement.textContent = date;
|
||||
element.appendChild(dateElement);
|
||||
}
|
||||
|
||||
/** Compact, source-bounded disclosure for multi-source geolocation beats. */
|
||||
export function evidenceCorroborationLabel(observation) {
|
||||
const count = Math.max(
|
||||
@@ -929,185 +919,70 @@ function setElementProperty(element, property, value) {
|
||||
}
|
||||
|
||||
function setElementAttribute(element, attribute, value) {
|
||||
if (!element || element.getAttribute?.(attribute) === value) return false;
|
||||
element.setAttribute(attribute, value);
|
||||
if (!element || element.attributes[attribute] === value) return false;
|
||||
element.attributes[attribute] = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
function collectPanelReferences(panel) {
|
||||
/** One panel control's state, as the panel view renders it. */
|
||||
function panelRecord(initial = {}) {
|
||||
return {
|
||||
textContent: '',
|
||||
hidden: false,
|
||||
disabled: false,
|
||||
value: '',
|
||||
...initial,
|
||||
attributes: { ...initial.attributes },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel's controls as the layer writes them, starting from the panel's
|
||||
* markup. The panel view renders what the layer publishes from them.
|
||||
*/
|
||||
function createPanelRecords(event) {
|
||||
return Object.freeze({
|
||||
imagerySection: panel.querySelector('[data-role="imagery-comparison"]'),
|
||||
observedStatus: panel.querySelector('.bhote-event-status.observed'),
|
||||
beforeDate: panel.querySelector('[data-role="before-date"]'),
|
||||
afterDate: panel.querySelector('[data-role="after-date"]'),
|
||||
time: panel.querySelector('[data-role="time"]'),
|
||||
caveat: panel.querySelector('[data-role="caveat"]'),
|
||||
imageryCloudNote: panel.querySelector('[data-role="imagery-cloud-note"]'),
|
||||
reportList: panel.querySelector('.bhote-event-report-list'),
|
||||
playButton: panel.querySelector('[data-action="play"]'),
|
||||
playSceneButton: panel.querySelector('[data-action="play-scene"]'),
|
||||
cinematicButton: panel.querySelector('[data-action="cinematic"]'),
|
||||
storyReplayButton: panel.querySelector('[data-action="story-replay"]'),
|
||||
corridorButton: panel.querySelector('[data-action="corridor"]'),
|
||||
previousButton: panel.querySelector('[data-action="previous-beat"]'),
|
||||
nextButton: panel.querySelector('[data-action="next-beat"]'),
|
||||
openSourceButton: panel.querySelector('[data-action="open-source"]'),
|
||||
progressInput: panel.querySelector('[data-role="progress"]'),
|
||||
splitInput: panel.querySelector('[data-role="split"]'),
|
||||
splitReadout: panel.querySelector('[data-role="split-readout"]'),
|
||||
timelineLabel: panel.querySelector('[data-role="timeline-label"]'),
|
||||
beatIndex: panel.querySelector('[data-role="beat-index"]'),
|
||||
beatTitle: panel.querySelector('[data-role="beat-title"]'),
|
||||
beatMeta: panel.querySelector('[data-role="beat-meta"]'),
|
||||
root: { sceneBeat: false },
|
||||
imagerySection: panelRecord(),
|
||||
observedStatus: panelRecord(),
|
||||
time: panelRecord({
|
||||
textContent: `~0:00 / ${elapsedLabel(1, event.reconstruction.elapsedSeconds)}`,
|
||||
}),
|
||||
caveat: panelRecord({
|
||||
textContent: bhoteEventText(event.reconstruction.caveat),
|
||||
}),
|
||||
imageryCloudNote: panelRecord(),
|
||||
playButton: panelRecord({ textContent: t('▶ PLAY') }),
|
||||
playSceneButton: panelRecord({
|
||||
textContent: t('▶ PLAY SCENE'),
|
||||
hidden: true,
|
||||
}),
|
||||
cinematicButton: panelRecord({
|
||||
textContent: t('◉ CINEMATIC'),
|
||||
attributes: { 'aria-pressed': 'false' },
|
||||
}),
|
||||
storyReplayButton: panelRecord({ textContent: t('↺ FULL STORY') }),
|
||||
corridorButton: panelRecord(),
|
||||
previousButton: panelRecord({
|
||||
attributes: { 'aria-label': t('Previous story beat') },
|
||||
}),
|
||||
nextButton: panelRecord({
|
||||
attributes: { 'aria-label': t('Next story beat') },
|
||||
}),
|
||||
openSourceButton: panelRecord(),
|
||||
progressInput: panelRecord({
|
||||
value: '0',
|
||||
attributes: { 'aria-label': t('Schematic downstream progression') },
|
||||
}),
|
||||
splitInput: panelRecord({ value: '50' }),
|
||||
splitReadout: panelRecord({ textContent: '50 / 50' }),
|
||||
timelineLabel: panelRecord({ textContent: t('RECONSTRUCTION CLOCK') }),
|
||||
beatIndex: panelRecord({ textContent: '01 / 06' }),
|
||||
beatTitle: panelRecord({ textContent: t('CAUSE') }),
|
||||
beatMeta: panelRecord({ textContent: t('CAPTURE TIME UNVERIFIED') }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Escape translated text for the panel markup template. */
|
||||
function escapeMarkup(text) {
|
||||
return String(text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function createPanel(event, handlers) {
|
||||
const panel = document.createElement('aside');
|
||||
panel.id = 'bhote-koshi-event-panel';
|
||||
panel.setAttribute(
|
||||
'aria-label',
|
||||
t('Bhote Koshi flood reconstruction controls'),
|
||||
);
|
||||
panel.innerHTML = `
|
||||
<div class="bhote-event-header">
|
||||
<div>
|
||||
<div class="bhote-event-kicker">${escapeMarkup(t('EVENT RECONSTRUCTION · 26 AUG 2026'))}</div>
|
||||
<div class="bhote-event-title">${escapeMarkup(t('BHOTE KOSHI OUTBURST FLOOD'))}</div>
|
||||
</div>
|
||||
<button class="bhote-event-icon-btn" type="button" data-action="close" title="${escapeMarkup(t('Close event layer'))}" aria-label="${escapeMarkup(t('Close event layer'))}">×</button>
|
||||
</div>
|
||||
<div class="bhote-event-status-row">
|
||||
<span class="bhote-event-status observed">${escapeMarkup(t('OBSERVED IMAGERY'))}</span>
|
||||
<span class="bhote-event-status reconstructed">${escapeMarkup(t('SCHEMATIC CORRIDOR'))}</span>
|
||||
</div>
|
||||
<section class="bhote-event-section" data-role="imagery-comparison">
|
||||
<div class="bhote-event-section-head">
|
||||
<span>${escapeMarkup(t('2021 HISTORICAL REFERENCE / 2026 POST-EVENT'))}</span>
|
||||
<span data-role="split-readout">50 / 50</span>
|
||||
</div>
|
||||
<input class="bhote-event-range" data-role="split" type="range" min="0" max="100" value="50" aria-label="${escapeMarkup(t('Historical reference and post-event image split'))}" />
|
||||
<div class="bhote-event-dates">
|
||||
<span data-role="before-date"></span>
|
||||
<span data-role="after-date"></span>
|
||||
</div>
|
||||
</section>
|
||||
<section class="bhote-event-section">
|
||||
<div class="bhote-event-section-head">
|
||||
<span data-role="timeline-label">${escapeMarkup(t('RECONSTRUCTION CLOCK'))}</span>
|
||||
<span data-role="time"></span>
|
||||
</div>
|
||||
<input class="bhote-event-range flood" data-role="progress" type="range" min="0" max="1000" value="0" aria-label="${escapeMarkup(t('Schematic downstream progression'))}" />
|
||||
<div class="bhote-event-story-nav" aria-label="${escapeMarkup(t('Story beat navigation'))}">
|
||||
<button type="button" data-action="previous-beat" title="${escapeMarkup(t('Previous story beat'))}" aria-label="${escapeMarkup(t('Previous story beat'))}">‹</button>
|
||||
<div class="bhote-event-story-current">
|
||||
<span data-role="beat-index">01 / 06</span>
|
||||
<strong data-role="beat-title" aria-live="polite">${escapeMarkup(t('CAUSE'))}</strong>
|
||||
<small data-role="beat-meta">${escapeMarkup(t('CAPTURE TIME UNVERIFIED'))}</small>
|
||||
</div>
|
||||
<button type="button" data-action="next-beat" title="${escapeMarkup(t('Next story beat'))}" aria-label="${escapeMarkup(t('Next story beat'))}">›</button>
|
||||
</div>
|
||||
<div class="bhote-event-actions">
|
||||
<button type="button" data-action="play">${escapeMarkup(t('▶ PLAY'))}</button>
|
||||
<button type="button" data-action="play-scene" hidden title="${escapeMarkup(t('Play the next shot and continue through this scene'))}">${escapeMarkup(t('▶ PLAY SCENE'))}</button>
|
||||
<button type="button" data-action="cinematic" aria-pressed="false">${escapeMarkup(t('◉ CINEMATIC'))}</button>
|
||||
<button type="button" data-action="story-replay">${escapeMarkup(t('↺ FULL STORY'))}</button>
|
||||
<button type="button" data-action="open-source">${escapeMarkup(t('↗ OPEN ORIGINAL'))}</button>
|
||||
<button type="button" data-action="corridor">${escapeMarkup(t('⌖ LOWER GORGE'))}</button>
|
||||
</div>
|
||||
</section>
|
||||
<details class="bhote-event-evidence">
|
||||
<summary>${escapeMarkup(t('FIELD REPORTS · {count}', { count: event.fieldReports.length }))}</summary>
|
||||
<div class="bhote-event-report-list"></div>
|
||||
</details>
|
||||
<div class="bhote-event-credit">
|
||||
<span>${escapeMarkup(t('GEOLOCATIONS · {credit}', { credit: 'GEO GEORGE SHADRACH' }))}</span>
|
||||
<button type="button" data-action="geolocation-map">${escapeMarkup(t('↗ OPEN PUBLIC MAP'))}</button>
|
||||
</div>
|
||||
<p class="bhote-event-caveat"><span data-role="caveat"></span><span data-role="imagery-cloud-note">${escapeMarkup(t(' Clouds are preserved from the source imagery.'))}</span></p>
|
||||
`;
|
||||
|
||||
const refs = collectPanelReferences(panel);
|
||||
renderImageryEndpoint(refs.beforeDate, event.imagery.before);
|
||||
renderImageryEndpoint(refs.afterDate, event.imagery.after);
|
||||
refs.time.textContent = `~0:00 / ${elapsedLabel(1, event.reconstruction.elapsedSeconds)}`;
|
||||
refs.caveat.textContent = bhoteEventText(event.reconstruction.caveat);
|
||||
|
||||
for (const report of event.fieldReports) {
|
||||
const button = document.createElement('button');
|
||||
const label = document.createElement('span');
|
||||
const status = document.createElement('small');
|
||||
button.type = 'button';
|
||||
button.className = 'bhote-event-report';
|
||||
label.textContent = bhoteEventText(report.label);
|
||||
status.textContent = bhoteEventText(report.status);
|
||||
button.append(label, status);
|
||||
button.addEventListener('click', () => openExternal(report.url));
|
||||
refs.reportList.appendChild(button);
|
||||
}
|
||||
|
||||
panel
|
||||
.querySelector('[data-action="close"]')
|
||||
.addEventListener('click', handlers.close);
|
||||
panel
|
||||
.querySelector('[data-action="play"]')
|
||||
.addEventListener('click', handlers.play);
|
||||
panel
|
||||
.querySelector('[data-action="play-scene"]')
|
||||
.addEventListener('click', handlers.playScene);
|
||||
panel
|
||||
.querySelector('[data-action="cinematic"]')
|
||||
.addEventListener('click', handlers.toggleCinematic);
|
||||
panel
|
||||
.querySelector('[data-action="previous-beat"]')
|
||||
.addEventListener('click', handlers.previousBeat);
|
||||
panel
|
||||
.querySelector('[data-action="next-beat"]')
|
||||
.addEventListener('click', handlers.nextBeat);
|
||||
panel
|
||||
.querySelector('[data-action="story-replay"]')
|
||||
.addEventListener('click', handlers.replayCinematic);
|
||||
panel
|
||||
.querySelector('[data-action="open-source"]')
|
||||
.addEventListener('click', handlers.openActiveSource);
|
||||
panel
|
||||
.querySelector('[data-action="geolocation-map"]')
|
||||
.addEventListener('click', handlers.openGeolocationMap);
|
||||
panel
|
||||
.querySelector('[data-action="corridor"]')
|
||||
.addEventListener('click', handlers.focusCorridor);
|
||||
panel
|
||||
.querySelector('[data-role="split"]')
|
||||
.addEventListener('input', (inputEvent) => {
|
||||
handlers.setSplit(Number(inputEvent.target.value) / 100);
|
||||
});
|
||||
panel
|
||||
.querySelector('[data-role="progress"]')
|
||||
.addEventListener('input', (inputEvent) => {
|
||||
handlers.previewProgress(Number(inputEvent.target.value) / 1000);
|
||||
});
|
||||
panel
|
||||
.querySelector('[data-role="progress"]')
|
||||
.addEventListener('change', (inputEvent) => {
|
||||
handlers.commitProgress(Number(inputEvent.target.value) / 1000, {
|
||||
final: true,
|
||||
});
|
||||
});
|
||||
const rightRail = document.getElementById?.('right-context-rail');
|
||||
rightRail?.classList.add('bhote-event-active');
|
||||
(rightRail || document.body).appendChild(panel);
|
||||
return { panel, refs };
|
||||
}
|
||||
|
||||
export function createBhoteKoshiEventLayer({
|
||||
overlayHost = DEFAULT_OVERLAY_HOST,
|
||||
renderHost = DEFAULT_RENDER_HOST,
|
||||
@@ -1119,14 +994,22 @@ export function createBhoteKoshiEventLayer({
|
||||
evidenceFrameUpdater = updateEvidenceFrame,
|
||||
videoFactory = DEFAULT_VIDEO_FACTORY,
|
||||
embeddedMediaFactory = createBhoteKoshiEmbeddedMedia,
|
||||
panelStore = useBhoteKoshiPanelStore(applicationPinia()),
|
||||
} = {}) {
|
||||
let _viewer = null;
|
||||
let _dataManager = null;
|
||||
let _mapStackController = null;
|
||||
let _event = null;
|
||||
let _enabled = false;
|
||||
let _panel = null;
|
||||
// The panel's controls; null while the layer is off. Their state is
|
||||
// published to the panel store, which the context rail renders.
|
||||
let _panelRefs = null;
|
||||
let _panelOwner = null;
|
||||
let _panelPublished = '';
|
||||
// The dates and field reports, which stay as they were when the panel
|
||||
// opened, until a locale change words them again.
|
||||
let _panelFixed = null;
|
||||
let _offPanelLocale = null;
|
||||
let _splitControl = null;
|
||||
let _beforeLayer = null;
|
||||
let _afterLayer = null;
|
||||
@@ -2723,9 +2606,9 @@ export function createBhoteKoshiEventLayer({
|
||||
id: 'bhote-koshi-split-line',
|
||||
handleClass: 'bhote-koshi-split-handle',
|
||||
cssProperty: '--bhote-koshi-split',
|
||||
beforeTitle: imageryEndpointText(event.imagery.before),
|
||||
afterTitle: imageryEndpointText(event.imagery.after),
|
||||
ariaLabel: t('Historical reference and post-event image divider'),
|
||||
beforeTitle: () => imageryEndpointText(event.imagery.before),
|
||||
afterTitle: () => imageryEndpointText(event.imagery.after),
|
||||
ariaLabel: () => t('Historical reference and post-event image divider'),
|
||||
formatValueText: (beforePercent, afterPercent) =>
|
||||
t(
|
||||
'A historical reference {before} percent, B post-event {after} percent',
|
||||
@@ -2756,15 +2639,63 @@ export function createBhoteKoshiEventLayer({
|
||||
_lastCinematicProgress = _progress;
|
||||
}
|
||||
|
||||
/** Present the panel's controls as they stand; an unchanged panel is not sent again. */
|
||||
function publishPanel() {
|
||||
if (!_panelRefs) return;
|
||||
const { root, ...controls } = _panelRefs;
|
||||
const refs = Object.fromEntries(
|
||||
Object.entries(controls).map(([key, record]) => [
|
||||
key,
|
||||
{ ...record, attributes: { ...record.attributes } },
|
||||
]),
|
||||
);
|
||||
const signature = JSON.stringify([root.sceneBeat, refs]);
|
||||
if (signature === _panelPublished) return;
|
||||
_panelPublished = signature;
|
||||
_panelFixed ??= {
|
||||
beforeDate: imageryEndpointLabel(_event.imagery.before),
|
||||
afterDate: imageryEndpointLabel(_event.imagery.after),
|
||||
reports: _event.fieldReports.map((report) => ({
|
||||
label: bhoteEventText(report.label),
|
||||
status: bhoteEventText(report.status),
|
||||
})),
|
||||
};
|
||||
panelStore.publish({
|
||||
owner: _panelOwner,
|
||||
sceneBeat: root.sceneBeat,
|
||||
refs,
|
||||
..._panelFixed,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Word the panel again in the current locale: fresh records, written by
|
||||
* the same syncs that keep them current.
|
||||
*/
|
||||
function relocalizePanel() {
|
||||
if (!_panelRefs || !_event) return;
|
||||
_panelRefs = createPanelRecords(_event);
|
||||
_panelFixed = null;
|
||||
writeCinematicButton();
|
||||
syncPanel();
|
||||
syncPresentationMode();
|
||||
}
|
||||
|
||||
function syncCinematicButton() {
|
||||
if (writeCinematicButton()) publishPanel();
|
||||
}
|
||||
|
||||
/** Write the cinematic button's label and state; false without a panel. */
|
||||
function writeCinematicButton() {
|
||||
const button = _panelRefs?.cinematicButton;
|
||||
if (!button) return;
|
||||
if (!button) return false;
|
||||
setElementProperty(
|
||||
button,
|
||||
'textContent',
|
||||
_cinematicActive ? t('■ RELEASE CAMERA') : t('◉ CINEMATIC'),
|
||||
);
|
||||
setElementAttribute(button, 'aria-pressed', String(_cinematicActive));
|
||||
return true;
|
||||
}
|
||||
|
||||
function claimCinematicCamera() {
|
||||
@@ -3029,6 +2960,7 @@ export function createBhoteKoshiEventLayer({
|
||||
'textContent',
|
||||
`~${elapsedLabel(_sceneScrubProgress, sceneDurationSec)} / ${elapsedLabel(1, sceneDurationSec)}`,
|
||||
);
|
||||
publishPanel();
|
||||
if (_sceneSeekFrame != null) return;
|
||||
const schedule =
|
||||
globalThis.requestAnimationFrame ||
|
||||
@@ -3302,7 +3234,7 @@ export function createBhoteKoshiEventLayer({
|
||||
'disabled',
|
||||
!observation?.media?.sourceUrl,
|
||||
);
|
||||
syncCinematicButton();
|
||||
writeCinematicButton();
|
||||
setElementProperty(
|
||||
_panelRefs.splitInput,
|
||||
'value',
|
||||
@@ -3314,6 +3246,7 @@ export function createBhoteKoshiEventLayer({
|
||||
'textContent',
|
||||
`${beforePercent} / ${100 - beforePercent}`,
|
||||
);
|
||||
publishPanel();
|
||||
}
|
||||
|
||||
function syncPresentationMode() {
|
||||
@@ -3330,7 +3263,7 @@ export function createBhoteKoshiEventLayer({
|
||||
const evidenceAvailable =
|
||||
!sceneDirected ||
|
||||
(_sceneSurface === BHOTE_KOSHI_SCENE_EVIDENCE_BEAT && knownSceneBeat);
|
||||
_panel?.classList?.toggle('bhote-event-scene-beat', sceneDirected);
|
||||
if (_panelRefs) _panelRefs.root.sceneBeat = sceneDirected;
|
||||
if (_panelRefs?.playSceneButton)
|
||||
_panelRefs.playSceneButton.hidden = !sceneDirected;
|
||||
if (_panelRefs?.imagerySection)
|
||||
@@ -3364,6 +3297,7 @@ export function createBhoteKoshiEventLayer({
|
||||
}
|
||||
if (_panelRefs?.openSourceButton)
|
||||
_panelRefs.openSourceButton.hidden = !evidenceAvailable;
|
||||
publishPanel();
|
||||
overlayHost.setVisible(BHOTE_KOSHI_OVERLAY_SOURCE_ID, evidenceAvailable);
|
||||
}
|
||||
|
||||
@@ -3561,7 +3495,12 @@ export function createBhoteKoshiEventLayer({
|
||||
throwIfEnableCancelled(signal, _enabled);
|
||||
createFloodDataSource();
|
||||
createSplitControl(event);
|
||||
const panelView = createPanel(event, {
|
||||
_panelRefs = createPanelRecords(event);
|
||||
_panelOwner = {};
|
||||
_panelPublished = '';
|
||||
_panelFixed = null;
|
||||
const owner = _panelOwner;
|
||||
const controls = {
|
||||
close: () =>
|
||||
_dataManager?.setEnabled(BHOTE_KOSHI_LAYER_ID, false, {
|
||||
origin: 'user',
|
||||
@@ -3578,9 +3517,23 @@ export function createBhoteKoshiEventLayer({
|
||||
commitProgress,
|
||||
setSplit,
|
||||
focusCorridor,
|
||||
});
|
||||
_panel = panelView.panel;
|
||||
_panelRefs = panelView.refs;
|
||||
openReport: (index) => {
|
||||
const url = event.fieldReports[index]?.url;
|
||||
if (url) openExternal(url);
|
||||
},
|
||||
};
|
||||
// Only the panel this enable published may act.
|
||||
panelStore.connect(
|
||||
Object.fromEntries(
|
||||
Object.entries(controls).map(([name, control]) => [
|
||||
name,
|
||||
(...args) => (_panelOwner === owner ? control(...args) : undefined),
|
||||
]),
|
||||
),
|
||||
);
|
||||
publishPanel();
|
||||
_offPanelLocale?.();
|
||||
_offPanelLocale = onLocaleChange(relocalizePanel);
|
||||
syncPresentationMode();
|
||||
overlayHost.setVisible(
|
||||
BHOTE_KOSHI_OVERLAY_SOURCE_ID,
|
||||
@@ -3673,12 +3626,16 @@ export function createBhoteKoshiEventLayer({
|
||||
_surgePoint = null;
|
||||
_lastSurgePosition = null;
|
||||
_lastSurgeVisible = null;
|
||||
_panel?.remove();
|
||||
document
|
||||
.getElementById?.('right-context-rail')
|
||||
?.classList.remove('bhote-event-active');
|
||||
_panel = null;
|
||||
if (_panelOwner && panelStore.panel?.owner === _panelOwner) {
|
||||
panelStore.connect(null);
|
||||
panelStore.publish(null);
|
||||
}
|
||||
_offPanelLocale?.();
|
||||
_offPanelLocale = null;
|
||||
_panelOwner = null;
|
||||
_panelRefs = null;
|
||||
_panelPublished = '';
|
||||
_panelFixed = null;
|
||||
_splitControl?.destroy();
|
||||
_splitControl = null;
|
||||
_progress = 0;
|
||||
|
||||
@@ -3,6 +3,9 @@ import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
import { claimPointer, releasePointer } from './inputOwnership.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { useBhoteKoshiPanelStore } from '../ui/stores/bhoteKoshiPanel.js';
|
||||
import { useImagerySplitStore } from '../ui/stores/imagerySplit.js';
|
||||
import { applicationPinia } from '../ui/vueRuntime.js';
|
||||
import { SOURCE_PATH_BEAT_IDS } from './bhoteKoshiShotPaths.js';
|
||||
import {
|
||||
BHOTE_KOSHI_LAYER_ID,
|
||||
@@ -36,6 +39,95 @@ import { SceneDirector } from '../scenes/director.js';
|
||||
const eventUrl = new URL('../../public/events/bhote-koshi-2026/event.json', import.meta.url);
|
||||
const eventModuleUrl = new URL('./bhoteKoshiEvent.js', import.meta.url);
|
||||
|
||||
/** The event's divider as the imagery split store holds it, or undefined. */
|
||||
const eventSplit = () =>
|
||||
useImagerySplitStore(applicationPinia()).splits.findLast(
|
||||
(split) => split.view.id === 'bhote-koshi-split-line',
|
||||
);
|
||||
|
||||
// The panel controls the layer presents, by the selector the panel view gives each.
|
||||
const PANEL_REFS = {
|
||||
'[data-role="imagery-comparison"]': 'imagerySection',
|
||||
'[data-role="time"]': 'time',
|
||||
'[data-role="caveat"]': 'caveat',
|
||||
'[data-role="imagery-cloud-note"]': 'imageryCloudNote',
|
||||
'[data-role="progress"]': 'progressInput',
|
||||
'[data-role="split"]': 'splitInput',
|
||||
'[data-role="timeline-label"]': 'timelineLabel',
|
||||
'[data-role="beat-index"]': 'beatIndex',
|
||||
'[data-role="beat-title"]': 'beatTitle',
|
||||
'[data-role="beat-meta"]': 'beatMeta',
|
||||
'[data-action="play"]': 'playButton',
|
||||
'[data-action="play-scene"]': 'playSceneButton',
|
||||
'[data-action="cinematic"]': 'cinematicButton',
|
||||
'[data-action="story-replay"]': 'storyReplayButton',
|
||||
'[data-action="corridor"]': 'corridorButton',
|
||||
'[data-action="previous-beat"]': 'previousButton',
|
||||
'[data-action="next-beat"]': 'nextButton',
|
||||
'[data-action="open-source"]': 'openSourceButton',
|
||||
};
|
||||
// What each control calls, as the panel view wires it.
|
||||
const PANEL_EVENTS = {
|
||||
playButton: { click: 'play' },
|
||||
playSceneButton: { click: 'playScene' },
|
||||
cinematicButton: { click: 'toggleCinematic' },
|
||||
storyReplayButton: { click: 'replayCinematic' },
|
||||
corridorButton: { click: 'focusCorridor' },
|
||||
previousButton: { click: 'previousBeat' },
|
||||
nextButton: { click: 'nextBeat' },
|
||||
openSourceButton: { click: 'openActiveSource' },
|
||||
progressInput: {
|
||||
input: (controls, value) => controls.previewProgress(Number(value) / 1000),
|
||||
change: (controls, value) => controls.commitProgress(Number(value) / 1000, { final: true }),
|
||||
},
|
||||
splitInput: { input: (controls, value) => controls.setSplit(Number(value) / 100) },
|
||||
};
|
||||
|
||||
/**
|
||||
* The event panel as the layer presents it to the panel store, or undefined
|
||||
* while it is off. Controls read the latest presentation, and dispatching an
|
||||
* event calls the connected controls as the panel view does.
|
||||
*/
|
||||
function eventPanel() {
|
||||
const store = useBhoteKoshiPanelStore(applicationPinia());
|
||||
if (!store.panel) return undefined;
|
||||
const control = (key) => {
|
||||
let typed = null;
|
||||
const record = () => store.panel.refs[key];
|
||||
return {
|
||||
get textContent() { return record().textContent; },
|
||||
get hidden() { return record().hidden; },
|
||||
get disabled() { return record().disabled; },
|
||||
get value() { return typed ?? record().value; },
|
||||
set value(value) { typed = value; },
|
||||
getAttribute: (name) => record().attributes[name] ?? null,
|
||||
dispatch(type) {
|
||||
const value = typed ?? record().value;
|
||||
typed = null;
|
||||
const handler = PANEL_EVENTS[key]?.[type];
|
||||
return typeof handler === 'function'
|
||||
? handler(store.controls, value)
|
||||
: store.controls[handler]?.();
|
||||
},
|
||||
};
|
||||
};
|
||||
const named = (name) => ({ dispatch: () => store.controls[name]() });
|
||||
return {
|
||||
querySelector(selector) {
|
||||
if (selector === '.bhote-event-report-list') {
|
||||
return {
|
||||
children: store.panel.reports.map((_, index) => ({
|
||||
dispatch: () => store.controls.openReport(index),
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (selector === '[data-action="geolocation-map"]') return named('openGeolocationMap');
|
||||
if (selector === '[data-action="close"]') return named('close');
|
||||
return control(PANEL_REFS[selector]);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class FakeElement {
|
||||
constructor(tagName = 'div') {
|
||||
this.tagName = tagName;
|
||||
@@ -840,6 +932,7 @@ test('Bhote Koshi disable preserves an operator-selected map and tears down ever
|
||||
const originalDocument = globalThis.document;
|
||||
const originalWindow = globalThis.window;
|
||||
const { body, removedProperties } = installEventDom();
|
||||
assert.equal(eventSplit(), undefined, 'no divider is left from an earlier test');
|
||||
const clearedTimeouts = [];
|
||||
globalThis.window = {
|
||||
setTimeout: () => 71,
|
||||
@@ -879,7 +972,8 @@ test('Bhote Koshi disable preserves an operator-selected map and tears down ever
|
||||
assert.equal(await layer.enable(viewer, { origin: 'scene' }), true);
|
||||
assert.equal(viewer._test.imagery.length, 2);
|
||||
assert.equal(viewer._test.dataSources.length, 1);
|
||||
assert.equal(body.children.length, 2);
|
||||
assert.ok(eventPanel());
|
||||
assert.ok(eventSplit());
|
||||
|
||||
activeMap = 'operator-choice';
|
||||
generation += 1;
|
||||
@@ -888,7 +982,8 @@ test('Bhote Koshi disable preserves an operator-selected map and tears down ever
|
||||
assert.deepEqual(mapCalls, ['esri-imagery']);
|
||||
assert.equal(viewer._test.removedImagery.length, 2);
|
||||
assert.equal(viewer._test.removedDataSources.length, 1);
|
||||
assert.ok(body.children.every((element) => element.removed));
|
||||
assert.equal(eventPanel(), undefined);
|
||||
assert.equal(eventSplit(), undefined);
|
||||
assert.equal(viewer.scene.splitPosition, 0.37);
|
||||
assert.deepEqual(clearedTimeouts, [71]);
|
||||
assert.ok(overlayCalls.some(([kind]) => kind === 'clear'));
|
||||
@@ -934,7 +1029,7 @@ test('Bhote Koshi disable stops active playback and releases its global render h
|
||||
|
||||
try {
|
||||
await layer.enable(viewer, { origin: 'scene' });
|
||||
const panel = body.children.find((element) => element.tagName === 'aside');
|
||||
const panel = eventPanel();
|
||||
assert.equal(panel.querySelector('[data-action="play-scene"]').hidden, true);
|
||||
assert.equal(await panel.querySelector('[data-action="play-scene"]').dispatch('click'), false);
|
||||
panel.querySelector('[data-action="play"]').dispatch('click');
|
||||
@@ -1075,6 +1170,7 @@ test('Bhote Koshi scene beats preserve photoreal and never take camera ownership
|
||||
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
||||
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame;
|
||||
const { body } = installEventDom();
|
||||
assert.equal(eventSplit(), undefined, 'no divider is left from an earlier test');
|
||||
const timers = [];
|
||||
const openedUrls = [];
|
||||
const cancelledFrames = [];
|
||||
@@ -1181,11 +1277,10 @@ test('Bhote Koshi scene beats preserve photoreal and never take camera ownership
|
||||
assert.deepEqual(timers, []);
|
||||
assert.equal(viewer._test.imagery.length, 2, 'the event surface keeps its cached imagery pair');
|
||||
assert.ok(viewer._test.imagery.every((imageryLayer) => imageryLayer.show === false));
|
||||
const panel = body.children.find((element) => element.tagName === 'aside');
|
||||
const panel = eventPanel();
|
||||
assert.ok(panel);
|
||||
const splitLine = body.children.find((element) => element.id === 'bhote-koshi-split-line');
|
||||
assert.ok(splitLine);
|
||||
assert.equal(splitLine.hidden, true);
|
||||
assert.ok(eventSplit());
|
||||
assert.equal(eventSplit().view.hidden, true);
|
||||
assert.equal(panel.querySelector('[data-role="imagery-comparison"]').hidden, true);
|
||||
assert.match(panel.querySelector('[data-role="caveat"]').textContent, /SCHEMATIC CORRIDOR/i);
|
||||
assert.doesNotMatch(panel.querySelector('[data-role="caveat"]').textContent, /historical/i);
|
||||
@@ -1220,8 +1315,6 @@ test('Bhote Koshi scene beats preserve photoreal and never take camera ownership
|
||||
assert.equal(panel.querySelector('[data-action="cinematic"]').disabled, true);
|
||||
assert.equal(panel.querySelector('[data-action="corridor"]').hidden, false);
|
||||
assert.equal(panel.querySelector('[data-action="corridor"]').disabled, false);
|
||||
assert.doesNotMatch(panel.innerHTML, /data-action="witness"|◎ WITNESS/);
|
||||
assert.match(panel.innerHTML, /data-action="open-source"/);
|
||||
const fullStory = panel.querySelector('[data-action="story-replay"]');
|
||||
assert.equal(fullStory.textContent, '↺ FULL STORY');
|
||||
assert.equal(fullStory.hidden, true, 'PLAY SCENE owns authored scene playback');
|
||||
@@ -1240,30 +1333,27 @@ test('Bhote Koshi scene beats preserve photoreal and never take camera ownership
|
||||
beatId: 'timure-cluster',
|
||||
sceneControls: { imageryComparison: true },
|
||||
}, { origin: 'scene' });
|
||||
assert.equal(splitLine.hidden, false);
|
||||
assert.equal(eventSplit().view.hidden, false);
|
||||
assert.equal(panel.querySelector('[data-role="imagery-comparison"]').hidden, false);
|
||||
assert.ok(viewer._test.imagery.every((imageryLayer) => imageryLayer.show === true));
|
||||
const splitHandle = splitLine.children.find(
|
||||
(element) => element.className === 'bhote-koshi-split-handle',
|
||||
);
|
||||
assert.ok(splitHandle);
|
||||
assert.equal(splitHandle.textContent, '↔');
|
||||
assert.equal(splitHandle.getAttribute('aria-valuenow'), '62');
|
||||
splitHandle.dispatch('pointerdown', {
|
||||
assert.equal(eventSplit().view.handleClass, 'bhote-koshi-split-handle');
|
||||
assert.equal(eventSplit().view.valueNow, 62);
|
||||
const splitHandle = eventSplit().controls;
|
||||
splitHandle.pointerdown({
|
||||
button: 0,
|
||||
clientX: 250,
|
||||
pointerId: 4,
|
||||
preventDefault() {},
|
||||
});
|
||||
splitHandle.dispatch('pointermove', {
|
||||
splitHandle.pointermove({
|
||||
clientX: 750,
|
||||
pointerId: 4,
|
||||
preventDefault() {},
|
||||
});
|
||||
splitHandle.dispatch('pointerup', { pointerId: 4 });
|
||||
splitHandle.pointerup({ pointerId: 4 });
|
||||
assert.equal(viewer.scene.splitPosition, 0.75);
|
||||
assert.equal(splitHandle.getAttribute('aria-valuenow'), '75');
|
||||
splitHandle.dispatch('keydown', {
|
||||
assert.equal(eventSplit().view.valueNow, 75);
|
||||
splitHandle.keydown({
|
||||
key: 'ArrowLeft',
|
||||
shiftKey: true,
|
||||
preventDefault() {},
|
||||
@@ -1276,7 +1366,7 @@ test('Bhote Koshi scene beats preserve photoreal and never take camera ownership
|
||||
split: 0.62,
|
||||
sceneControls: {},
|
||||
}, { origin: 'scene' });
|
||||
assert.equal(splitLine.hidden, true);
|
||||
assert.equal(eventSplit().view.hidden, true);
|
||||
assert.ok(viewer._test.imagery.every((imageryLayer) => imageryLayer.show === false));
|
||||
assert.equal(viewer.scene.splitPosition, 0.62);
|
||||
assert.equal(overlayVisibility.at(-1).visible, false);
|
||||
@@ -1347,7 +1437,7 @@ test('Bhote Koshi scene beats preserve photoreal and never take camera ownership
|
||||
assert.equal(layer.getCinematicState().active, false);
|
||||
assert.equal(viewer._test.cameraLooks.length, 0);
|
||||
assert.deepEqual(mapCalls, []);
|
||||
assert.equal(body.children.filter((element) => element.tagName === 'aside').length, 1);
|
||||
assert.ok(eventPanel());
|
||||
assert.equal(viewer.scene.splitPosition, 0.62);
|
||||
for (const beatId of [
|
||||
'debris-dammed-lake',
|
||||
@@ -1594,7 +1684,7 @@ test('Bhote Koshi publishes the evidence spine once and scrubs through cards and
|
||||
assert.equal(breadcrumbs.filter((entry) => entry.position()).length, 0);
|
||||
assert.ok(cards.every((entry) => entry.requireImage && entry.image.stamp === 1));
|
||||
|
||||
const panel = body.children.find((element) => element.tagName === 'aside');
|
||||
const panel = eventPanel();
|
||||
const progress = panel.querySelector('[data-role="progress"]');
|
||||
progress.value = '285';
|
||||
progress.dispatch('input');
|
||||
@@ -1685,7 +1775,7 @@ test('Bhote Koshi keeps one local fallback video active and unloads it on beat c
|
||||
videos[0].dispatch('loadeddata');
|
||||
assert.equal(layer.getCinematicState().mediaReady, true);
|
||||
|
||||
const panel = body.children.find((element) => element.tagName === 'aside');
|
||||
const panel = eventPanel();
|
||||
const progress = panel.querySelector('[data-role="progress"]');
|
||||
progress.value = '285';
|
||||
progress.dispatch('input');
|
||||
@@ -1808,7 +1898,7 @@ test('Upper Valley Collapse autoplays its muted local fallback without starting
|
||||
assert.ok(video.pauseCalls >= 1);
|
||||
assert.ok(video.cancelledFrameHandles.length >= 1);
|
||||
assert.equal(layer.getCinematicState().mediaEvidenceId, null);
|
||||
assert.equal(body.children.find((element) => element.tagName === 'aside')?.removed, false);
|
||||
assert.ok(eventPanel());
|
||||
await layer.disable();
|
||||
} finally {
|
||||
globalThis.document = originalDocument;
|
||||
@@ -2121,10 +2211,8 @@ test('Bhote Koshi beat controls keep cards, flood, camera, and panel on the one
|
||||
|
||||
try {
|
||||
await layer.enable(viewer, { origin: 'local-restore' });
|
||||
const panel = body.children.find((element) => element.tagName === 'aside');
|
||||
assert.match(panel.innerHTML, /2021 HISTORICAL REFERENCE \/ 2026 POST-EVENT/);
|
||||
assert.match(panel.innerHTML, /RECONSTRUCTION CLOCK/);
|
||||
assert.match(panel.innerHTML, /OPEN PUBLIC MAP/);
|
||||
const panel = eventPanel();
|
||||
assert.equal(panel.querySelector('[data-role="timeline-label"]').textContent, 'RECONSTRUCTION CLOCK');
|
||||
const entries = publications[0].entries;
|
||||
const cards = entries.filter((entry) => entry.variant === 'thumbnail');
|
||||
const breadcrumbs = entries.filter((entry) => entry.variant === 'label');
|
||||
@@ -2203,7 +2291,7 @@ test('Bhote Koshi beat controls keep cards, flood, camera, and panel on the one
|
||||
}
|
||||
});
|
||||
|
||||
test('Bhote Koshi repeated event frames perform no panel lookup, DOM write, or camera churn', async () => {
|
||||
test('Bhote Koshi repeated event frames republish no panel and churn no camera', async () => {
|
||||
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
|
||||
const originalDocument = globalThis.document;
|
||||
const originalWindow = globalThis.window;
|
||||
@@ -2238,26 +2326,19 @@ test('Bhote Koshi repeated event frames perform no panel lookup, DOM write, or c
|
||||
|
||||
try {
|
||||
await layer.enable(viewer, { origin: 'local-restore' });
|
||||
const panel = body.children.find((element) => element.tagName === 'aside');
|
||||
const panel = eventPanel();
|
||||
const cinematic = panel.querySelector('[data-action="cinematic"]');
|
||||
const progress = panel.querySelector('[data-role="progress"]');
|
||||
cinematic.dispatch('click');
|
||||
progress.value = '500';
|
||||
progress.dispatch('input');
|
||||
|
||||
const queryCount = panel._queryCount;
|
||||
const writeCount = [...panel._selectors.values()].reduce((sum, element) => (
|
||||
sum + element._writeCount
|
||||
), 0);
|
||||
const panelStore = useBhoteKoshiPanelStore(applicationPinia());
|
||||
const published = panelStore.panel;
|
||||
const cameraLookCount = viewer._test.cameraLooks.length;
|
||||
for (let index = 0; index < 20; index += 1) progress.dispatch('input');
|
||||
|
||||
assert.equal(panel._queryCount, queryCount, 'hot path uses cached panel references');
|
||||
assert.equal(
|
||||
[...panel._selectors.values()].reduce((sum, element) => sum + element._writeCount, 0),
|
||||
writeCount,
|
||||
'unchanged frames do not rewrite panel state',
|
||||
);
|
||||
assert.ok(panelStore.panel === published, 'unchanged frames do not republish the panel');
|
||||
assert.equal(
|
||||
viewer._test.cameraLooks.length,
|
||||
cameraLookCount,
|
||||
@@ -2328,7 +2409,7 @@ test('Bhote Koshi cinematic camera stays synchronized through pause and determin
|
||||
|
||||
try {
|
||||
await layer.enable(viewer, { origin: 'local-restore' });
|
||||
const panel = body.children.find((element) => element.tagName === 'aside');
|
||||
const panel = eventPanel();
|
||||
panel.querySelector('[data-action="cinematic"]').dispatch('click');
|
||||
assert.equal(layer.getCinematicState().active, true);
|
||||
assert.equal(layer.getPlaybackState().playing, true);
|
||||
@@ -2413,7 +2494,7 @@ test('Bhote Koshi replay restores cinematic ownership unless the operator releas
|
||||
|
||||
try {
|
||||
await layer.enable(viewer, { origin: 'local-restore' });
|
||||
const panel = body.children.find((element) => element.tagName === 'aside');
|
||||
const panel = eventPanel();
|
||||
panel.querySelector('[data-action="cinematic"]').dispatch('click');
|
||||
animationCallbacks[0](91001);
|
||||
assert.equal(layer.getPlaybackState().progress, 1);
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from './dataCredits.js';
|
||||
import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
|
||||
import { INCIDENT_OVERVIEW_PLACES } from './bhoteKoshiIncidentPlaces.js';
|
||||
import { t, msg, getLocale } from '../i18n/index.js';
|
||||
import { t, msg, getLocale, isolate } from '../i18n/index.js';
|
||||
|
||||
const BHOTE_KOSHI_LOCATOR_LAYER_ID = 'bhote-koshi-locator';
|
||||
export const BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID =
|
||||
@@ -67,7 +67,10 @@ const PLACE_TITLE_TEXT = new Map(
|
||||
|
||||
function placeTitle(title) {
|
||||
const text = PLACE_TITLE_TEXT.get(title);
|
||||
return text ? t(text).toLocaleUpperCase(getLocale()) : title;
|
||||
// Isolated: the card draws left to right after its "11 · " counter, so in
|
||||
// Arabic a translated title with a Latin place name («نقطة الإطلالة في
|
||||
// DANDAGAUN») must keep its own right-to-left order.
|
||||
return text ? isolate(t(text).toLocaleUpperCase(getLocale())) : title;
|
||||
}
|
||||
|
||||
const INCIDENT = Object.freeze({
|
||||
|
||||
@@ -728,6 +728,8 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
|
||||
viewer,
|
||||
_sceneSeekGeneration: 0,
|
||||
_loadGeneration: 0,
|
||||
_bumpSceneSeekGeneration:
|
||||
SceneDirector.prototype._bumpSceneSeekGeneration,
|
||||
_cancelActiveSceneTravel:
|
||||
SceneDirector.prototype._cancelActiveSceneTravel,
|
||||
_setSceneMediaPlayback: SceneDirector.prototype._setSceneMediaPlayback,
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
// src/data/bhoteKoshiPanelView.test.mjs — the context rail renders the Bhote
|
||||
// Koshi reconstruction panel the event layer presents and sends its clicks
|
||||
// and slider moves back.
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createApp, nextTick } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import { useBhoteKoshiPanelStore } from '../ui/stores/bhoteKoshiPanel.js';
|
||||
import ContextView from '../ui/views/ContextView.vue';
|
||||
|
||||
const record = (initial = {}) => ({
|
||||
textContent: '',
|
||||
hidden: false,
|
||||
disabled: false,
|
||||
value: '',
|
||||
attributes: {},
|
||||
...initial,
|
||||
});
|
||||
|
||||
function presentation(patch = {}) {
|
||||
return {
|
||||
owner: {},
|
||||
sceneBeat: false,
|
||||
beforeDate: { name: 'Historical reference', date: '2021-05-01' },
|
||||
afterDate: { name: 'Post-event', date: '' },
|
||||
reports: [
|
||||
{ label: 'Report A', status: 'Verified' },
|
||||
{ label: 'Report B', status: 'Unverified' },
|
||||
],
|
||||
refs: {
|
||||
imagerySection: record(),
|
||||
observedStatus: record(),
|
||||
time: record({ textContent: '~0:00 / 2:00' }),
|
||||
caveat: record({ textContent: 'Schematic only.' }),
|
||||
imageryCloudNote: record(),
|
||||
playButton: record({ textContent: '▶ PLAY' }),
|
||||
playSceneButton: record({ textContent: '▶ PLAY SCENE', hidden: true }),
|
||||
cinematicButton: record({
|
||||
textContent: '◉ CINEMATIC',
|
||||
attributes: { 'aria-pressed': 'false' },
|
||||
}),
|
||||
storyReplayButton: record({ textContent: '↺ FULL STORY' }),
|
||||
corridorButton: record(),
|
||||
previousButton: record({
|
||||
disabled: true,
|
||||
attributes: { 'aria-label': 'Previous story beat' },
|
||||
}),
|
||||
nextButton: record({ attributes: { 'aria-label': 'Next story beat' } }),
|
||||
openSourceButton: record(),
|
||||
progressInput: record({
|
||||
value: '0',
|
||||
attributes: { 'aria-label': 'Schematic downstream progression' },
|
||||
}),
|
||||
splitInput: record({ value: '50' }),
|
||||
splitReadout: record({ textContent: '50 / 50' }),
|
||||
timelineLabel: record({ textContent: 'RECONSTRUCTION CLOCK' }),
|
||||
beatIndex: record({ textContent: '01 / 06' }),
|
||||
beatTitle: record({ textContent: 'CAUSE' }),
|
||||
beatMeta: record({ textContent: 'CAPTURE TIME UNVERIFIED' }),
|
||||
},
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
function fixture(t) {
|
||||
document.body.replaceChildren();
|
||||
const pinia = createPinia();
|
||||
const app = createApp(ContextView).use(pinia);
|
||||
app.mount(document.body.appendChild(document.createElement('div')));
|
||||
t.after(() => app.unmount());
|
||||
const store = useBhoteKoshiPanelStore(pinia);
|
||||
const calls = [];
|
||||
store.connect(
|
||||
new Proxy(
|
||||
{},
|
||||
{
|
||||
get:
|
||||
(_, name) =>
|
||||
(...args) =>
|
||||
calls.push([name, ...args]),
|
||||
},
|
||||
),
|
||||
);
|
||||
const rail = document.getElementById('right-context-rail');
|
||||
const $ = (selector) => rail.querySelector(selector);
|
||||
return { store, calls, rail, $ };
|
||||
}
|
||||
|
||||
test('the panel renders last in the context rail and marks the rail while on', async (t) => {
|
||||
const { store, rail, $ } = fixture(t);
|
||||
assert.equal($('#bhote-koshi-event-panel'), null);
|
||||
assert.equal(rail.classList.contains('bhote-event-active'), false);
|
||||
store.publish(presentation());
|
||||
await nextTick();
|
||||
const panel = $('#bhote-koshi-event-panel');
|
||||
assert.equal(panel.tagName, 'ASIDE');
|
||||
assert.ok(rail.lastElementChild === panel);
|
||||
assert.ok(rail.classList.contains('ui-scroll'));
|
||||
assert.ok(rail.classList.contains('bhote-event-active'));
|
||||
assert.equal(
|
||||
panel.getAttribute('aria-label'),
|
||||
'Bhote Koshi flood reconstruction controls',
|
||||
);
|
||||
assert.match(
|
||||
panel.textContent,
|
||||
/2021 HISTORICAL REFERENCE \/ 2026 POST-EVENT/,
|
||||
);
|
||||
assert.match(panel.textContent, /RECONSTRUCTION CLOCK/);
|
||||
assert.match(panel.textContent, /OPEN PUBLIC MAP/);
|
||||
assert.equal(
|
||||
$('[data-role="before-date"]').textContent,
|
||||
'Historical reference · 2021-05-01',
|
||||
);
|
||||
assert.equal(
|
||||
$('[data-role="before-date"] .bhote-event-date').textContent,
|
||||
'2021-05-01',
|
||||
);
|
||||
assert.equal($('[data-role="after-date"]').textContent, 'Post-event');
|
||||
assert.equal($('[data-role="after-date"] .bhote-event-date'), null);
|
||||
assert.equal($('[data-action="play-scene"]').hidden, true);
|
||||
assert.ok($('[data-action="open-source"]'));
|
||||
assert.equal($('[data-action="witness"]'), null);
|
||||
assert.doesNotMatch(panel.innerHTML, /witness/i);
|
||||
assert.equal($('[data-action="previous-beat"]').disabled, true);
|
||||
assert.equal($('[data-action="previous-beat"]').title, 'Previous story beat');
|
||||
assert.equal(
|
||||
$('[data-action="cinematic"]').getAttribute('aria-pressed'),
|
||||
'false',
|
||||
);
|
||||
assert.equal($('[data-action="play"]').hasAttribute('aria-label'), false);
|
||||
assert.equal($('[data-role="progress"]').value, '0');
|
||||
assert.equal($('[data-role="split"]').value, '50');
|
||||
assert.equal($('summary').textContent, 'FIELD REPORTS · 2');
|
||||
assert.deepEqual(
|
||||
[...$('.bhote-event-report-list').children].map(
|
||||
(button) => button.textContent,
|
||||
),
|
||||
['Report AVerified', 'Report BUnverified'],
|
||||
);
|
||||
store.publish(null);
|
||||
await nextTick();
|
||||
assert.equal($('#bhote-koshi-event-panel'), null);
|
||||
assert.equal(rail.classList.contains('bhote-event-active'), false);
|
||||
assert.ok(rail.classList.contains('ui-scroll'));
|
||||
});
|
||||
|
||||
test('a later presentation updates the controls in place', async (t) => {
|
||||
const { store, $ } = fixture(t);
|
||||
store.publish(presentation());
|
||||
await nextTick();
|
||||
const play = $('[data-action="play"]');
|
||||
const next = presentation({ sceneBeat: true });
|
||||
next.refs.playButton = record({
|
||||
textContent: '▶ PLAY SHOT',
|
||||
disabled: true,
|
||||
attributes: { 'aria-label': 'Play Global context' },
|
||||
});
|
||||
next.refs.progressInput = record({ value: '250', hidden: true });
|
||||
next.refs.imagerySection = record({ hidden: true });
|
||||
store.publish(next);
|
||||
await nextTick();
|
||||
assert.ok($('[data-action="play"]') === play);
|
||||
assert.equal(play.textContent, '▶ PLAY SHOT');
|
||||
assert.equal(play.disabled, true);
|
||||
assert.equal(play.getAttribute('aria-label'), 'Play Global context');
|
||||
assert.ok(
|
||||
$('#bhote-koshi-event-panel').classList.contains('bhote-event-scene-beat'),
|
||||
);
|
||||
assert.equal($('[data-role="progress"]').value, '250');
|
||||
assert.equal($('[data-role="progress"]').hidden, true);
|
||||
assert.equal($('[data-role="imagery-comparison"]').hidden, true);
|
||||
});
|
||||
|
||||
test('buttons, sliders and reports call the connected controls', async (t) => {
|
||||
const { store, calls, $ } = fixture(t);
|
||||
store.publish(presentation());
|
||||
await nextTick();
|
||||
for (const action of [
|
||||
'close',
|
||||
'play',
|
||||
'cinematic',
|
||||
'story-replay',
|
||||
'open-source',
|
||||
'corridor',
|
||||
'next-beat',
|
||||
'geolocation-map',
|
||||
])
|
||||
$(`[data-action="${action}"]`).click();
|
||||
$('.bhote-event-report-list').children[1].click();
|
||||
const progress = $('[data-role="progress"]');
|
||||
progress.value = '500';
|
||||
progress.dispatchEvent(new Event('input'));
|
||||
progress.dispatchEvent(new Event('change'));
|
||||
const split = $('[data-role="split"]');
|
||||
split.value = '25';
|
||||
split.dispatchEvent(new Event('input'));
|
||||
assert.deepEqual(calls, [
|
||||
['close'],
|
||||
['play'],
|
||||
['toggleCinematic'],
|
||||
['replayCinematic'],
|
||||
['openActiveSource'],
|
||||
['focusCorridor'],
|
||||
['nextBeat'],
|
||||
['openGeolocationMap'],
|
||||
['openReport', 1],
|
||||
['previewProgress', 0.5],
|
||||
['commitProgress', 0.5, { final: true }],
|
||||
['setSplit', 0.25],
|
||||
]);
|
||||
});
|
||||
@@ -47,6 +47,8 @@ export const prioritizeActiveCctvGeometryRecord =
|
||||
export const processGeometryBatch = layer.processGeometryBatch;
|
||||
export const hideCctvRecordVisuals = layer.hideCctvRecordVisuals;
|
||||
export const refreshCoverageStyles = layer.refreshCoverageStyles;
|
||||
export const refreshHorizonCulling = layer.refreshHorizonCulling;
|
||||
export const _viewRecordIdsForTest = layer._viewRecordIdsForTest;
|
||||
export const clearProbeClampOnDeactivation =
|
||||
layer.clearProbeClampOnDeactivation;
|
||||
export const cctvRecordNeedsActivation = layer.cctvRecordNeedsActivation;
|
||||
|
||||
@@ -25,9 +25,6 @@ function componentFunctionSource(fn) {
|
||||
.replace(/\(\s+/g, '(');
|
||||
}
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as Cesium from 'cesium';
|
||||
import cctvLayer, {
|
||||
CCTV_PROJECTION_OVERLAY_SOURCE_OPTIONS,
|
||||
@@ -83,16 +80,6 @@ import {
|
||||
LIVE_CAMERA_COLOR,
|
||||
} from '../layers/cctv/policy.js';
|
||||
|
||||
const CCTV_PRESENTATION_SOURCE = fs.readFileSync(
|
||||
path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
'ui',
|
||||
'cctvPresentation.js',
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const ASPECT = 16 / 9;
|
||||
const toRad = (deg) => (deg * Math.PI) / 180;
|
||||
const GESTURE_TYPES = {
|
||||
@@ -1302,19 +1289,6 @@ test('CCTV null-active coverage, auto-hop, cycling, and panel targets stay hones
|
||||
assert.equal(cctvCycleIndex(-1, -1, records.length), records.length - 1);
|
||||
assert.equal(cctvCycleIndex(2, 1, records.length), 0);
|
||||
assert.equal(cctvCycleIndex(0, -1, records.length), records.length - 1);
|
||||
|
||||
const renderer = CCTV_PRESENTATION_SOURCE.match(
|
||||
/_renderCctvState\(state\) \{[\s\S]*?\n\}\n/,
|
||||
);
|
||||
assert.ok(renderer, '_renderCctvState is missing');
|
||||
assert.match(
|
||||
renderer[0],
|
||||
/else if \(!activeId\)[\s\S]*?selectedIndex = -1/,
|
||||
);
|
||||
assert.match(
|
||||
renderer[0],
|
||||
/_cctvFocusBtn\.disabled\s*=\s*!enabled\s*\|\|\s*cameras\.length === 0\s*\|\|\s*!activeId/,
|
||||
);
|
||||
} finally {
|
||||
cctvLayer.setParams({ autoHop: false });
|
||||
_setCctvOverlayHostForTest();
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
a22WebcamToSource,
|
||||
loadA22SourcesFromOpenDataHub,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
A22_DEFAULT_GROUND_ELEVATION_M,
|
||||
A22_IMAGE_ORIGIN,
|
||||
DEFAULT_A22_CCTV_URL,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
import { fetchWithinHost } from '../../server/providers/cctv/media.js';
|
||||
import { staticFrameRefreshMs } from './cctvLod.js';
|
||||
|
||||
/** One Open Data Hub `WebcamInfo` item, shaped like the live a22 payload. */
|
||||
const item = (
|
||||
patch = {},
|
||||
{ km = '68', lat = 46.53591, lon = 11.49772 } = {},
|
||||
) => ({
|
||||
Id: `A22_${km}`,
|
||||
Active: true,
|
||||
Source: 'a22',
|
||||
Shortname: 'ADS Sciliar',
|
||||
Webcamname: { it: 'ADS Sciliar' },
|
||||
Webcamurl: `https://www.autobrennero.it/WebCamImg/km${km}.jpg`,
|
||||
Mapping: { a22: { km } },
|
||||
GpsInfo: [{ Gpstype: 'position', Latitude: lat, Longitude: lon }],
|
||||
LicenseInfo: {
|
||||
ClosedData: false,
|
||||
LicenseHolder: 'https://www.autobrennero.it/',
|
||||
},
|
||||
...patch,
|
||||
});
|
||||
|
||||
test('an A22 webcam maps to a source on the pinned operator frame', () => {
|
||||
const source = a22WebcamToSource(item());
|
||||
assert.equal(source.id, 'it-a22-km68');
|
||||
assert.equal(source.name, 'A22 ADS Sciliar (km 68)');
|
||||
assert.equal(source.city, 'A22 Brennero');
|
||||
assert.equal(source.cityId, 'a22');
|
||||
assert.equal(source.provider, 'Autostrada del Brennero');
|
||||
assert.equal(source.lat, 46.53591);
|
||||
assert.equal(source.lon, 11.49772);
|
||||
assert.equal(source.headingConfidence, 'low');
|
||||
assert.equal(source.feedType, 'image');
|
||||
assert.equal(source.sourceKind, 'a22-opendatahub');
|
||||
assert.equal(source.code, 'ADS SCILIAR');
|
||||
assert.equal(source.url, `${A22_IMAGE_ORIGIN}km68.jpg`);
|
||||
assert.equal(source.snapshotUrl, source.url);
|
||||
});
|
||||
|
||||
test('ground elevation follows the motorway from the Brenner Pass to the Po', () => {
|
||||
const brenner = a22WebcamToSource(
|
||||
item({ Shortname: 'Brennero' }, { km: '1', lat: 46.99682, lon: 11.50218 }),
|
||||
);
|
||||
const ponte = a22WebcamToSource(
|
||||
item(
|
||||
{ Shortname: 'Ponte Po' },
|
||||
{ km: '272', lat: 45.03145, lon: 10.85369 },
|
||||
),
|
||||
);
|
||||
assert.ok(brenner.groundElevationM > 1300);
|
||||
assert.ok(ponte.groundElevationM < 50);
|
||||
// A kilometre the table does not know falls back to the corridor prior.
|
||||
const unknown = a22WebcamToSource(
|
||||
item({}, { km: '150', lat: 45.95, lon: 11.05 }),
|
||||
);
|
||||
assert.equal(unknown.groundElevationM, A22_DEFAULT_GROUND_ELEVATION_M);
|
||||
});
|
||||
|
||||
test('inactive cameras, off-host frames and bad geometry are dropped', () => {
|
||||
assert.equal(a22WebcamToSource(item({ Active: false })), null);
|
||||
assert.equal(a22WebcamToSource(item({ Source: 'other' })), null);
|
||||
assert.equal(
|
||||
a22WebcamToSource(
|
||||
item({ Webcamurl: 'https://evil.test/WebCamImg/km68.jpg' }),
|
||||
),
|
||||
null,
|
||||
);
|
||||
// The frame must be exactly the camera's own kilometre image.
|
||||
assert.equal(
|
||||
a22WebcamToSource(
|
||||
item({ Webcamurl: 'https://www.autobrennero.it/WebCamImg/km1.jpg' }),
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
a22WebcamToSource(item({ Mapping: { a22: { km: '../x' } } })),
|
||||
null,
|
||||
);
|
||||
// Milan: plausible, but off the Brenner–Modena corridor.
|
||||
assert.equal(
|
||||
a22WebcamToSource(item({}, { km: '68', lat: 45.46, lon: 9.19 })),
|
||||
null,
|
||||
);
|
||||
assert.equal(a22WebcamToSource(item({ GpsInfo: [] })), null);
|
||||
assert.equal(a22WebcamToSource(null), null);
|
||||
});
|
||||
|
||||
test('the loader dedupes, orders north to south, and degrades to empty', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const requested = [];
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
return Response.json({
|
||||
TotalResults: 3,
|
||||
Items: [
|
||||
item(
|
||||
{ Shortname: 'Ponte Po' },
|
||||
{ km: '272', lat: 45.03145, lon: 10.85369 },
|
||||
),
|
||||
item(),
|
||||
item(),
|
||||
item(
|
||||
{ Shortname: 'Brennero' },
|
||||
{ km: '1', lat: 46.99682, lon: 11.50218 },
|
||||
),
|
||||
],
|
||||
});
|
||||
});
|
||||
const cameras = await loadA22SourcesFromOpenDataHub();
|
||||
assert.deepEqual(requested, [DEFAULT_A22_CCTV_URL]);
|
||||
assert.deepEqual(
|
||||
cameras.map((c) => c.id),
|
||||
['it-a22-km1', 'it-a22-km68', 'it-a22-km272'],
|
||||
);
|
||||
|
||||
fetchMock.mock.mockImplementation(
|
||||
async () => new Response('nope', { status: 503 }),
|
||||
);
|
||||
assert.deepEqual(await loadA22SourcesFromOpenDataHub(), []);
|
||||
fetchMock.mock.mockImplementation(
|
||||
async () =>
|
||||
new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: 'https://evil.test/' },
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(await loadA22SourcesFromOpenDataHub(), []);
|
||||
fetchMock.mock.mockImplementation(async () => {
|
||||
throw new Error('offline');
|
||||
});
|
||||
assert.deepEqual(await loadA22SourcesFromOpenDataHub(), []);
|
||||
});
|
||||
|
||||
const runCatalog = async (t) => {
|
||||
const requested = [];
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const href = String(url);
|
||||
requested.push(href);
|
||||
if (href === DEFAULT_A22_CCTV_URL) {
|
||||
return Response.json({ Items: [item()] });
|
||||
}
|
||||
return Response.json([]);
|
||||
});
|
||||
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
|
||||
return { requested, sources };
|
||||
};
|
||||
|
||||
const withEnv = async (patch, fn) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
delete process.env.CCTV_SOURCES_FILE;
|
||||
delete process.env.CCTV_SOURCES_JSON;
|
||||
delete process.env.CCTV_A22_ENABLED;
|
||||
Object.assign(process.env, patch);
|
||||
await fn();
|
||||
} finally {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in saved)) delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, saved);
|
||||
}
|
||||
};
|
||||
|
||||
test('the A22 lane is wired into the catalog', async (t) => {
|
||||
await withEnv({}, async () => {
|
||||
const { requested, sources } = await runCatalog(t);
|
||||
assert.ok(requested.includes(DEFAULT_A22_CCTV_URL));
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'a22').map((s) => s.id),
|
||||
['it-a22-km68'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('CCTV_A22_ENABLED=0 keeps the lane from being loaded', async (t) => {
|
||||
await withEnv({ CCTV_A22_ENABLED: '0' }, async () => {
|
||||
const { requested, sources } = await runCatalog(t);
|
||||
assert.equal(requested.includes(DEFAULT_A22_CCTV_URL), false);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'a22'),
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('a redirect on the A22 frame host stays inside WebCamImg/', async () => {
|
||||
const frame = `${A22_IMAGE_ORIGIN}km68.jpg`;
|
||||
const redirectTo = (location) => async (url) =>
|
||||
url === frame
|
||||
? new Response(null, { status: 302, headers: { location } })
|
||||
: new Response('frame', { headers: { 'content-type': 'image/jpeg' } });
|
||||
for (const location of ['/it/area-riservata/', '/WebCamImg/../x.jpg'])
|
||||
assert.equal(
|
||||
await fetchWithinHost(frame, {}, redirectTo(location)),
|
||||
null,
|
||||
location,
|
||||
);
|
||||
const followed = await fetchWithinHost(
|
||||
frame,
|
||||
{},
|
||||
redirectTo('/WebCamImg/km77.jpg'),
|
||||
);
|
||||
assert.equal(await followed.text(), 'frame');
|
||||
});
|
||||
|
||||
test('A22 ambient cards refresh on the highway cadence, not the default', () => {
|
||||
const source = a22WebcamToSource(item());
|
||||
assert.equal(staticFrameRefreshMs(source), 3 * 60 * 1000);
|
||||
});
|
||||
@@ -42,6 +42,9 @@ const PROVIDER_STATIC_REFRESH_MS = Object.freeze({
|
||||
// every 20s showed no change inside 64s but a new frame by ~90-150s later —
|
||||
// matches the other highway packs' 3-minute cadence.
|
||||
'servei català de trànsit': 3 * 60 * 1000,
|
||||
// Autobrennero (A22) refreshes its stills about once a minute (Last-Modified
|
||||
// tracks the minute); 3 min matches the other highway packs.
|
||||
'autostrada del brennero': 3 * 60 * 1000,
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
// src/data/cctvViewThinning.test.mjs — the CCTV view pass: only cameras on or
|
||||
// near the screen show, a zoomed-out view keeps one camera per world-anchored
|
||||
// cell, and the layer's UI state never rebuilds every camera for a listener
|
||||
// that does not read the list.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import cctvLayer, {
|
||||
_setCctvCoverageStateForTest,
|
||||
_viewRecordIdsForTest,
|
||||
refreshHorizonCulling,
|
||||
} from './cctv.js';
|
||||
|
||||
const WIDTH = 800;
|
||||
const HEIGHT = 600;
|
||||
|
||||
/**
|
||||
* Records at the given [lon, lat] degrees near the origin, each drawn at the
|
||||
* screen point `screenAt` gives it (default: the middle of the screen).
|
||||
*/
|
||||
function recordsAt(
|
||||
lonLats,
|
||||
screenAt = () => ({ x: WIDTH / 2, y: HEIGHT / 2 }),
|
||||
) {
|
||||
const screenByPosition = new Map();
|
||||
const records = lonLats.map(([lon, lat], index) => {
|
||||
const position = Cesium.Cartesian3.fromDegrees(lon, lat, 10);
|
||||
screenByPosition.set(position, screenAt(index));
|
||||
return {
|
||||
camera: {
|
||||
id: `cam-${index}`,
|
||||
name: `Camera ${index}`,
|
||||
city: 'Test',
|
||||
lat,
|
||||
lon,
|
||||
headingDeg: 0,
|
||||
pitchDeg: -18,
|
||||
fovDeg: 44,
|
||||
rangeM: 145,
|
||||
mountHeightM: 8,
|
||||
},
|
||||
position,
|
||||
billboard: { position, show: true },
|
||||
coverageEntities: [],
|
||||
projection: null,
|
||||
};
|
||||
});
|
||||
return { records, screenByPosition };
|
||||
}
|
||||
|
||||
function viewerAt(heightM, screenByPosition, shiftX = 0) {
|
||||
return {
|
||||
isDestroyed: () => false,
|
||||
camera: {
|
||||
positionWC: Cesium.Cartesian3.fromDegrees(0, 0, heightM),
|
||||
positionCartographic: { latitude: 0, longitude: 0, height: heightM },
|
||||
frustum: { fovy: Math.PI / 3 },
|
||||
},
|
||||
scene: {
|
||||
canvas: { clientWidth: WIDTH, clientHeight: HEIGHT },
|
||||
cartesianToCanvasCoordinates: (position) => {
|
||||
const screen = screenByPosition.get(position);
|
||||
return screen && { x: screen.x + shiftX, y: screen.y };
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function shownIds(records) {
|
||||
return records
|
||||
.filter((record) => record.billboard.show)
|
||||
.map((record) => record.camera.id)
|
||||
.sort();
|
||||
}
|
||||
|
||||
// Three cameras within ~110 m, and one ~22 km away.
|
||||
const CLUSTER = [
|
||||
[0, 0],
|
||||
[0.001, 0],
|
||||
[0.0005, 0.0005],
|
||||
[0.2, 0],
|
||||
];
|
||||
|
||||
test('zoomed out, cameras sharing a ground cell show one icon', () => {
|
||||
const { records, screenByPosition } = recordsAt(CLUSTER);
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(20_000, screenByPosition),
|
||||
records,
|
||||
});
|
||||
assert.equal(
|
||||
refreshHorizonCulling(),
|
||||
true,
|
||||
'the first pass changes the view',
|
||||
);
|
||||
const shown = shownIds(records);
|
||||
assert.equal(shown.length, 2, 'one for the cluster, one for the far camera');
|
||||
assert.ok(shown.includes('cam-3'), 'a camera alone in its cell shows');
|
||||
assert.deepEqual(_viewRecordIdsForTest().sort(), shown);
|
||||
});
|
||||
|
||||
test('a pan keeps the same winners, and an unchanged view keeps its list', () => {
|
||||
const { records, screenByPosition } = recordsAt(CLUSTER, (index) => ({
|
||||
x: 100 + index * 7,
|
||||
y: 300,
|
||||
}));
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(20_000, screenByPosition),
|
||||
records,
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
const before = shownIds(records);
|
||||
const list = _viewRecordIdsForTest();
|
||||
for (const shiftX of [3, 11, 20, 37]) {
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(20_000, screenByPosition, shiftX),
|
||||
records,
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
assert.deepEqual(shownIds(records), before, `pan by ${shiftX} px`);
|
||||
}
|
||||
assert.equal(refreshHorizonCulling(), false, 'the same view changes nothing');
|
||||
assert.deepEqual(_viewRecordIdsForTest(), list);
|
||||
});
|
||||
|
||||
test('a small zoom within one cell size keeps the same winners', () => {
|
||||
const { records, screenByPosition } = recordsAt(CLUSTER);
|
||||
const shownAt = (heightM) => {
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(heightM, screenByPosition),
|
||||
records,
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
return shownIds(records);
|
||||
};
|
||||
// Cells come in power-of-two metre steps: about 1.5 km and 1.6 km cells at
|
||||
// these heights fall in the same step, so the same cameras win.
|
||||
assert.deepEqual(shownAt(20_500), shownAt(20_000));
|
||||
});
|
||||
|
||||
test('a healthy camera wins its cell over an unknown one', () => {
|
||||
const { records, screenByPosition } = recordsAt(CLUSTER.slice(0, 2));
|
||||
// Whichever camera the id order would pick, the healthy one must win.
|
||||
for (const id of ['cam-0', 'cam-1']) {
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(20_000, screenByPosition),
|
||||
records,
|
||||
healthById: new Map([[id, { status: 'ok' }]]),
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
assert.deepEqual(shownIds(records), [id]);
|
||||
}
|
||||
});
|
||||
|
||||
test('the active camera always shows, even in a crowded cell', () => {
|
||||
const { records, screenByPosition } = recordsAt(CLUSTER.slice(0, 3));
|
||||
for (const active of records) {
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(20_000, screenByPosition),
|
||||
records,
|
||||
activeCameraId: active.camera.id,
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
assert.ok(active.billboard.show, `${active.camera.id} shows while active`);
|
||||
}
|
||||
});
|
||||
|
||||
test('close up, every camera near the screen shows and far-off ones hide', () => {
|
||||
const { records, screenByPosition } = recordsAt(
|
||||
[
|
||||
[0, 0],
|
||||
[0.0001, 0],
|
||||
[0.0002, 0],
|
||||
],
|
||||
(index) =>
|
||||
index === 2 ? { x: WIDTH * 3, y: HEIGHT / 2 } : { x: 100, y: 100 },
|
||||
);
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(300, screenByPosition),
|
||||
records,
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
assert.deepEqual(shownIds(records), ['cam-0', 'cam-1']);
|
||||
});
|
||||
|
||||
test('a disabled layer skips the view pass', () => {
|
||||
const { records, screenByPosition } = recordsAt(CLUSTER);
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(20_000, screenByPosition),
|
||||
records,
|
||||
enabled: false,
|
||||
});
|
||||
assert.equal(refreshHorizonCulling(), false);
|
||||
assert.deepEqual(_viewRecordIdsForTest(), []);
|
||||
});
|
||||
|
||||
test('UI state builds the full camera list only when it is read', () => {
|
||||
const { records, screenByPosition } = recordsAt(CLUSTER.slice(0, 2));
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(300, screenByPosition),
|
||||
records,
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
let built = 0;
|
||||
for (const record of records) {
|
||||
const camera = record.camera;
|
||||
record.camera = new Proxy(camera, {
|
||||
get(target, key) {
|
||||
if (key === 'calibration') built += 1;
|
||||
return target[key];
|
||||
},
|
||||
});
|
||||
}
|
||||
const first = cctvLayer.getUIState();
|
||||
assert.equal(built, 0, 'listeners that skip `cameras` cost nothing');
|
||||
assert.deepEqual(
|
||||
first.cameraIndex.map((camera) => camera.id),
|
||||
['cam-0', 'cam-1'],
|
||||
);
|
||||
assert.equal(
|
||||
cctvLayer.getUIState().cameraIndex,
|
||||
first.cameraIndex,
|
||||
'the index keeps its identity while the view does',
|
||||
);
|
||||
assert.equal(first.cameras.length, 2);
|
||||
assert.ok(built > 0, 'reading `cameras` builds it');
|
||||
assert.equal(first.cameras, first.cameras, 'and builds it once');
|
||||
});
|
||||
|
||||
test('the camera picker lists the view, with the active camera first', () => {
|
||||
const { records, screenByPosition } = recordsAt(
|
||||
[
|
||||
[0, 0],
|
||||
[0.0001, 0],
|
||||
[0.0002, 0],
|
||||
],
|
||||
(index) => (index === 1 ? { x: WIDTH * 3, y: 0 } : { x: 100, y: 100 }),
|
||||
);
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(300, screenByPosition),
|
||||
records,
|
||||
activeCameraId: 'cam-1',
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
const state = cctvLayer.getUIState();
|
||||
assert.deepEqual(
|
||||
state.cameraIndex.map((camera) => camera.id),
|
||||
['cam-1', 'cam-0', 'cam-2'],
|
||||
'the off-screen active camera leads, then what is in view',
|
||||
);
|
||||
assert.equal(state.cameras.length, 3, 'the full list is still every camera');
|
||||
});
|
||||
@@ -343,6 +343,13 @@ export const DATA_CREDITS = [
|
||||
key: 'panoramax',
|
||||
html: 'Street Level: imagery © <a href="https://panoramax.fr" target="_blank" rel="noopener">Panoramax</a> contributors, licence per picture',
|
||||
},
|
||||
{
|
||||
key: 'a22-cctv',
|
||||
html:
|
||||
'Motorway webcams (A22 Brennero–Modena): images © ' +
|
||||
'<a href="https://www.autobrennero.it/" target="_blank" rel="noopener">Autostrada del Brennero S.p.A.</a>, ' +
|
||||
'camera list via the <a href="https://opendatahub.com/" target="_blank" rel="noopener">Open Data Hub</a> (NOI Techpark)',
|
||||
},
|
||||
{
|
||||
key: 'gbfs',
|
||||
html:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expandApplicationHtml } from '../../build/application-html.js';
|
||||
import { renderApplicationHtml } from '../testSupport/applicationHtml.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
} from './detectionPolicy.js';
|
||||
import { KEYHOLE_OUTSIDE_OPACITY_DEFAULT } from '../celestialRing.js';
|
||||
|
||||
const indexHtml = expandApplicationHtml(fs.readFileSync(new URL('../../index.html', import.meta.url), 'utf8'));
|
||||
const indexHtml = await renderApplicationHtml(fs.readFileSync(new URL('../../index.html', import.meta.url), 'utf8'));
|
||||
|
||||
test('side aircraft brackets stay readable without changing zero-opacity intent', () => {
|
||||
assert.equal(detectionBracketAlpha('AIR', 0), 0);
|
||||
|
||||
@@ -877,6 +877,13 @@ test('route numbers follow the page language and never break from their unit', (
|
||||
);
|
||||
const list = directionsStepList({ route });
|
||||
assert.equal(list.items[1].lead, '240\u00a0м');
|
||||
|
||||
// Japanese unit words follow the number: 1時間5分, but 1.5 km.
|
||||
setLocale('ja');
|
||||
assert.equal(routeDistanceText(1500), '1.5\u00a0km');
|
||||
assert.equal(routeDurationText(39), '39秒');
|
||||
assert.equal(routeDurationText(3900), '1時間5分');
|
||||
assert.equal(routeDurationText(7200), '2時間');
|
||||
});
|
||||
|
||||
test('ramp and lane wording stays localized after choosing the maneuver form', (t) => {
|
||||
@@ -894,6 +901,12 @@ test('ramp and lane wording stays localized after choosing the maneuver form', (
|
||||
'Toma la salida bruscamente a la derecha',
|
||||
'Toma el carril izquierdo',
|
||||
],
|
||||
[
|
||||
'ja',
|
||||
'ランプを斜め左に進んでI-95に入る',
|
||||
'出口を急角度で右に出る',
|
||||
'左車線を通行',
|
||||
],
|
||||
[
|
||||
'zh-CN',
|
||||
'向左前方驶入匝道,进入 I-95',
|
||||
@@ -921,3 +934,19 @@ test('ramp and lane wording stays localized after choosing the maneuver form', (
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the reroute tooltip keeps German mode nouns capitalized', (t) => {
|
||||
t.after(() => setLocale('en'));
|
||||
const title = (id) =>
|
||||
directionsRowControls(idle).chips.find((chip) => chip.id === id).title;
|
||||
assert.equal(title('mode-foot'), 'Walk — reroute for walk');
|
||||
setLocale('de');
|
||||
assert.equal(
|
||||
title('mode-foot'),
|
||||
'Zu Fuß – Route im Modus „Zu Fuß“ neu berechnen',
|
||||
);
|
||||
assert.equal(
|
||||
title('mode-bike'),
|
||||
'Fahrrad – Route im Modus „Fahrrad“ neu berechnen',
|
||||
);
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
FIRMS_OVERLAY_SOURCE_ID,
|
||||
} from './firmsLabels.js';
|
||||
import { FIRE_ANCHOR_LIFT_M } from './fireAnchors.js';
|
||||
import { getLocale, setLocale } from '../i18n/index.js';
|
||||
import { reportMeshFloorCell, setMeshFloorPreferred } from './groundFloor.js';
|
||||
|
||||
const NOW = Date.UTC(2026, 6, 17, 4, 0);
|
||||
@@ -100,6 +101,30 @@ test('buildCellCard: plural noun, max FRP and newest age, accent passthrough', (
|
||||
assert.equal(card.accent, accentForSeverity('orange'));
|
||||
});
|
||||
|
||||
test('buildCellCard: the count picks the plural form of the language', () => {
|
||||
const title = (count) =>
|
||||
buildCellCard(
|
||||
{
|
||||
cell: { count, maxFrp: 10, newestAcqMs: 0 },
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
},
|
||||
NOW,
|
||||
).title;
|
||||
const previous = getLocale();
|
||||
try {
|
||||
assert.equal(title(1), '1 FIRE');
|
||||
setLocale('ar');
|
||||
assert.deepEqual(
|
||||
[2, 3, 14].map((count) => title(count).replace(/[\u2068\u2069]/g, '')),
|
||||
['حريقان (2)', '3 حرائق', '14 حريقًا'],
|
||||
);
|
||||
setLocale('ru');
|
||||
assert.equal(title(14), '14 ПОЖАРОВ');
|
||||
} finally {
|
||||
setLocale(previous);
|
||||
}
|
||||
});
|
||||
|
||||
// Owner field finding 2026-07-21: anchors must sit on the DEM once the shared
|
||||
// ground floor is warm — and the cached per-fire position must re-anchor when
|
||||
// the floor lands AFTER the first (cold, height-0) render. Distinct coords
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import dns from 'node:dns/promises';
|
||||
import dnsLookup from 'node:dns';
|
||||
import https from 'node:https';
|
||||
import net from 'node:net';
|
||||
import { Readable } from 'node:stream';
|
||||
import { filterTrailing24h, parseFirmsCsv } from './firmsCsv.js';
|
||||
import {
|
||||
describeFetchError,
|
||||
fetchFirms,
|
||||
fetchIpv4,
|
||||
isAddressSelectionFailure,
|
||||
} from '../../server/providers/firms.js';
|
||||
|
||||
const config = fs.readFileSync(new URL('../../server/providers/firms.js', import.meta.url), 'utf8');
|
||||
const start = config.indexOf(' async function refreshUpstream(key) {');
|
||||
@@ -17,8 +29,8 @@ const recent = { acqDate: '2026-09-11', acqTime: '1100' };
|
||||
// Inject only its upstream, clock and filter dependencies; keep its aggregation
|
||||
// and source-status code intact, including failures while consuming records.
|
||||
function createRefresh(fetchSource, filter = filterTrailing24h) {
|
||||
return new Function('SOURCES', 'fetchSource', 'filterTrailing24h', 'Date', 'console',
|
||||
`return (${refreshSource});`)(SOURCES, fetchSource, filter, { now: () => NOW }, { warn() {} });
|
||||
return new Function('SOURCES', 'fetchSource', 'filterTrailing24h', 'describeFetchError', 'Date', 'console',
|
||||
`return (${refreshSource});`)(SOURCES, fetchSource, filter, describeFetchError, { now: () => NOW }, { warn() {} });
|
||||
}
|
||||
|
||||
test('FIRMS retains large sources in order and filters expired rows', async () => {
|
||||
@@ -78,3 +90,220 @@ test('FIRMS distinguishes all-source failure from successful empty sources', asy
|
||||
assert.deepEqual(result.fires, []);
|
||||
assert.deepEqual(result.sources, SOURCES.map(source => ({ source, count: 0, ok: true })));
|
||||
});
|
||||
|
||||
// #68: Node's network-family auto-selection gives each address a short attempt
|
||||
// timeout, abandons a slow IPv4 handshake for an unreachable IPv6 address, and
|
||||
// reports only `fetch failed`. This is the shape Node 24 throws for it.
|
||||
function addressSelectionFailure() {
|
||||
const v4 = Object.assign(new Error('connect ETIMEDOUT 198.118.194.34:443'), { code: 'ETIMEDOUT' });
|
||||
const v6 = Object.assign(new Error('connect ENETUNREACH 2001:4d0:241a:40c0::34:443'), { code: 'ENETUNREACH' });
|
||||
const cause = Object.assign(new AggregateError([v4, v6]), { code: 'ETIMEDOUT' });
|
||||
return new TypeError('fetch failed', { cause });
|
||||
}
|
||||
// Node 26's fetch wraps the same AggregateError in a ConnectTimeoutError.
|
||||
function wrappedAddressSelectionFailure() {
|
||||
const inner = addressSelectionFailure().cause;
|
||||
const cause = Object.assign(new Error('Connect Timeout Error (attempted addresses: 198.118.194.34:443, 2001:4d0:241a:40c0::34:443, timeout: 10000ms)'),
|
||||
{ name: 'ConnectTimeoutError', code: 'UND_ERR_CONNECT_TIMEOUT', cause: inner });
|
||||
return new TypeError('fetch failed', { cause });
|
||||
}
|
||||
const URL_WITH_KEY = 'https://firms.modaps.eosdis.nasa.gov/api/area/csv/SECRET/VIIRS_SNPP_NRT/world/2';
|
||||
|
||||
test('FIRMS failures log the socket cause, never the keyed URL', () => {
|
||||
const described = describeFetchError(addressSelectionFailure());
|
||||
assert.equal(described,
|
||||
'fetch failed (connect ETIMEDOUT 198.118.194.34:443; connect ENETUNREACH 2001:4d0:241a:40c0::34:443)');
|
||||
assert.ok(!described.includes('SECRET'));
|
||||
assert.equal(describeFetchError(wrappedAddressSelectionFailure()), described, 'Node 26 reports the same attempts');
|
||||
const single = new TypeError('fetch failed', { cause: Object.assign(new Error('getaddrinfo ENOTFOUND x'), { code: 'ENOTFOUND' }) });
|
||||
assert.equal(describeFetchError(single), 'fetch failed (getaddrinfo ENOTFOUND x)');
|
||||
assert.equal(describeFetchError(new Error('HTTP 503')), 'HTTP 503');
|
||||
});
|
||||
|
||||
test('FIRMS retries once over IPv4 when every address fails to connect', async () => {
|
||||
for (const shape of [addressSelectionFailure, wrappedAddressSelectionFailure]) {
|
||||
const retried = [];
|
||||
await fetchFirms(URL_WITH_KEY, {}, {
|
||||
fetchImpl: async () => { throw shape(); },
|
||||
fetchIpv4Impl: async (url) => { retried.push(url); return new Response('csv'); },
|
||||
warn() {},
|
||||
});
|
||||
assert.deepEqual(retried, [URL_WITH_KEY], `${shape.name} is retried`);
|
||||
}
|
||||
const signal = new AbortController().signal;
|
||||
const ok = new Response('csv');
|
||||
const retried = [];
|
||||
const warnings = [];
|
||||
const response = await fetchFirms(URL_WITH_KEY, { signal }, {
|
||||
fetchImpl: async () => { throw addressSelectionFailure(); },
|
||||
fetchIpv4Impl: async (url, options) => { retried.push([url, options]); return ok; },
|
||||
warn: (message) => warnings.push(message),
|
||||
});
|
||||
assert.equal(response, ok);
|
||||
assert.deepEqual(retried, [[URL_WITH_KEY, { signal }]], 'one retry, same URL and deadline');
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0], /ETIMEDOUT 198\.118\.194\.34:443; .*ENETUNREACH .* retrying over IPv4/);
|
||||
assert.ok(!warnings[0].includes('SECRET'));
|
||||
|
||||
const failure = new Error('connect ETIMEDOUT 198.118.194.34:443');
|
||||
await assert.rejects(fetchFirms(URL_WITH_KEY, {}, {
|
||||
fetchImpl: async () => { throw addressSelectionFailure(); },
|
||||
fetchIpv4Impl: async () => { throw failure; },
|
||||
warn() {},
|
||||
}), (err) => err === failure, 'a failed retry reports its own error');
|
||||
});
|
||||
|
||||
test('FIRMS does not retry anything but an address-selection failure', async () => {
|
||||
const deps = (fetchImpl) => ({
|
||||
fetchImpl,
|
||||
fetchIpv4Impl: async () => assert.fail('must not retry'),
|
||||
warn: () => assert.fail('must not warn'),
|
||||
});
|
||||
const serverError = new Response('down', { status: 503 });
|
||||
assert.equal(await fetchFirms(URL_WITH_KEY, {}, deps(async () => serverError)), serverError);
|
||||
for (const thrown of [
|
||||
new TypeError('fetch failed', { cause: Object.assign(new Error('getaddrinfo ENOTFOUND x'), { code: 'ENOTFOUND' }) }),
|
||||
new TypeError('fetch failed', { cause: new AggregateError([]) }),
|
||||
new TypeError('fetch failed'),
|
||||
new DOMException('The operation was aborted due to timeout', 'TimeoutError'),
|
||||
]) {
|
||||
await assert.rejects(fetchFirms(URL_WITH_KEY, {}, deps(async () => { throw thrown; })), (err) => err === thrown);
|
||||
}
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const aborted = addressSelectionFailure();
|
||||
await assert.rejects(fetchFirms(URL_WITH_KEY, { signal: controller.signal },
|
||||
deps(async () => { throw aborted; })), (err) => err === aborted, 'an expired deadline is not retried');
|
||||
});
|
||||
|
||||
test('the IPv4 retry connects to an IPv4 address only', async (t) => {
|
||||
// `localhost` resolves to both ::1 and 127.0.0.1. Listen on both with the
|
||||
// same port and record which one the retry reaches; the TLS handshake then
|
||||
// fails against a plain socket, which is fine — only the connection matters.
|
||||
const reached = [];
|
||||
const listen = (host, port) => new Promise((resolve, reject) => {
|
||||
const server = net.createServer((socket) => { reached.push(host); socket.destroy(); });
|
||||
server.once('error', reject).listen(port, host, () => resolve(server));
|
||||
});
|
||||
const v4 = await listen('127.0.0.1', 0);
|
||||
t.after(() => v4.close());
|
||||
const { port } = v4.address();
|
||||
let v6;
|
||||
try {
|
||||
v6 = await listen('::1', port);
|
||||
} catch {
|
||||
t.skip('no IPv6 loopback on this host, so IPv4-only cannot be told apart');
|
||||
return;
|
||||
}
|
||||
t.after(() => v6.close());
|
||||
const order = await dns.lookup('localhost', { all: true });
|
||||
if (order[0]?.family !== 6) {
|
||||
t.skip('localhost resolves IPv4 first here, so IPv4-only cannot be told apart');
|
||||
return;
|
||||
}
|
||||
await assert.rejects(fetchIpv4(`https://localhost:${port}/`, { signal: AbortSignal.timeout(5_000) }));
|
||||
assert.deepEqual(reached, ['127.0.0.1']);
|
||||
});
|
||||
|
||||
// Answer the IPv4 retry with `status`, invoking the response callback
|
||||
// asynchronously as `https.request` does, outside the caller's promise.
|
||||
// The returned promise rejects on an uncaught exception, so a regression fails
|
||||
// the race at once instead of crashing the test runner.
|
||||
function mockIpv4Status(t, status, statusMessage = '') {
|
||||
let crash;
|
||||
const crashed = new Promise((_resolve, reject) => (crash = reject));
|
||||
const onUncaught = (error) =>
|
||||
crash(new Error(`uncaught exception: ${error.message}`));
|
||||
process.on('uncaughtException', onUncaught);
|
||||
t.after(() => process.off('uncaughtException', onUncaught));
|
||||
t.mock.method(https, 'request', (_url, _options, callback) => {
|
||||
const request = new EventEmitter();
|
||||
request.end = () =>
|
||||
setImmediate(() =>
|
||||
callback(
|
||||
Object.assign(Readable.from([]), {
|
||||
statusCode: status,
|
||||
statusMessage,
|
||||
headers: { 'content-type': 'text/csv' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
return request;
|
||||
});
|
||||
return crashed;
|
||||
}
|
||||
|
||||
for (const status of [204, 205, 304]) {
|
||||
test(`the IPv4 retry answering null-body status ${status} resolves instead of crashing`, async (t) => {
|
||||
const crashed = mockIpv4Status(t, status);
|
||||
const response = await Promise.race([fetchIpv4(URL_WITH_KEY), crashed]);
|
||||
assert.equal(response.status, status);
|
||||
assert.equal(response.body, null);
|
||||
});
|
||||
}
|
||||
|
||||
for (const status of [600, 999]) {
|
||||
test(`the IPv4 retry answering out-of-range status ${status} rejects instead of crashing`, async (t) => {
|
||||
const crashed = mockIpv4Status(t, status);
|
||||
await assert.rejects(
|
||||
Promise.race([fetchIpv4(URL_WITH_KEY), crashed]),
|
||||
new RegExp(`unsupported status ${status}`),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('the IPv4 retry answering an invalid reason phrase rejects instead of crashing', async (t) => {
|
||||
const crashed = mockIpv4Status(t, 200, 'OK\x7f');
|
||||
await assert.rejects(Promise.race([fetchIpv4(URL_WITH_KEY), crashed]), TypeError);
|
||||
});
|
||||
|
||||
// The fixtures above copy Node's error shape; check it against a real fetch so
|
||||
// a Node or undici change that stops matching fails here instead of silently
|
||||
// disabling the retry. A closed port refuses both loopback addresses; resolve
|
||||
// `localhost` to both here, since some hosts map it to 127.0.0.1 only.
|
||||
test('a real all-addresses connect failure is recognised as address selection', async (t) => {
|
||||
const probe = net.createServer();
|
||||
try {
|
||||
await new Promise((resolve, reject) => probe.once('error', reject).listen(0, '::1', resolve));
|
||||
} catch {
|
||||
t.skip('no IPv6 loopback on this host');
|
||||
return;
|
||||
}
|
||||
const { port } = probe.address();
|
||||
await new Promise((resolve) => probe.close(resolve));
|
||||
const v4 = net.createServer();
|
||||
await new Promise((resolve, reject) => v4.once('error', reject).listen(port, '127.0.0.1', resolve));
|
||||
await new Promise((resolve) => v4.close(resolve));
|
||||
const loopback = [{ address: '::1', family: 6 }, { address: '127.0.0.1', family: 4 }];
|
||||
const lookup = t.mock.method(dnsLookup, 'lookup', (_host, _options, callback) => callback(null, loopback));
|
||||
const err = await fetch(`http://localhost:${port}/`).then(() => null, (error) => error);
|
||||
assert.equal(lookup.mock.callCount(), 1, 'fetch resolves localhost through dns.lookup');
|
||||
assert.ok(err, 'a closed port refuses the connection');
|
||||
assert.equal(isAddressSelectionFailure(err), true, describeFetchError(err));
|
||||
assert.match(describeFetchError(err), /ECONNREFUSED .*; .*ECONNREFUSED/);
|
||||
});
|
||||
|
||||
// #68 only stays fixed while both upstream calls go through fetchFirms; the
|
||||
// refresh harness injects fetchSource, so guard the call sites directly.
|
||||
test('every FIRMS upstream request goes through the IPv4 fallback', async () => {
|
||||
assert.doesNotMatch(config, /await fetch\(/, 'no FIRMS request bypasses fetchFirms');
|
||||
const fetchStart = config.indexOf(' async function fetchSource(key, source) {');
|
||||
assert.notEqual(fetchStart, -1, 'FIRMS fetchSource exists');
|
||||
const fetchSourceText = config.slice(fetchStart, config.indexOf('\n }', fetchStart) + 4);
|
||||
const calls = [];
|
||||
const response = new Response('latitude,longitude\n');
|
||||
const fetchSource = new Function('fetchFirms', 'readResponseTextCapped', 'parseFirmsCsv',
|
||||
'FIRMS_MAX_RESPONSE_BYTES', 'AbortSignal', 'fetch', `return (${fetchSourceText});`)(
|
||||
async (url, options) => { calls.push(['fetchFirms', url, options.signal]); return response; },
|
||||
async (res, cap, signal) => { calls.push(['read', res, signal]); return 'csv'; },
|
||||
() => [],
|
||||
1024,
|
||||
AbortSignal,
|
||||
() => assert.fail('fetchSource must call fetchFirms, not fetch'),
|
||||
);
|
||||
assert.deepEqual(await fetchSource('SECRET', 'MODIS_NRT'), []);
|
||||
assert.equal(calls.length, 2);
|
||||
assert.equal(calls[0][0], 'fetchFirms');
|
||||
assert.ok(calls[0][2] instanceof AbortSignal);
|
||||
assert.deepEqual(calls[1], ['read', response, calls[0][2]], 'the body read shares the request deadline');
|
||||
});
|
||||
@@ -555,6 +555,21 @@ export function advanceFocusEvidenceNowMs(deltaMs) {
|
||||
return _focusEvidenceNowMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a sprite holds no dimming state, or has settled at full emphasis
|
||||
* with nothing pending. Advancing such a sprite without a screen position (a
|
||||
* hidden sprite) changes neither its state nor its alpha, so a layer pass may
|
||||
* skip it.
|
||||
* @param {object} sprite
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function spriteFocusAtRest(sprite) {
|
||||
const state = _spriteStates.get(sprite);
|
||||
return (
|
||||
!state || (state.factor === 1 && state.desired === 1 && !state.wasFarther)
|
||||
);
|
||||
}
|
||||
|
||||
/** Forget state when a primitive is removed from its owning collection. */
|
||||
export function forgetSpriteFocus(sprite) {
|
||||
if (sprite) _spriteStates.delete(sprite);
|
||||
|
||||
@@ -65,7 +65,8 @@ const KML_REMOVED_ELEMENTS = new Set([
|
||||
/**
|
||||
* An error whose message is meant for the person who chose the file. The
|
||||
* message stays English; `template` and `values`, when given, let the UI show
|
||||
* it in the active language.
|
||||
* it in the active language. Numbers stay numbers in `values`: the UI formats
|
||||
* them in that language's numerals.
|
||||
*/
|
||||
export class GeoImportError extends Error {
|
||||
constructor(message, template = message, values = {}) {
|
||||
@@ -259,7 +260,7 @@ function overLimitError({ over, features }) {
|
||||
return new GeoImportError(
|
||||
`That file creates more than ${MAX_IMPORT_ENTITIES.toLocaleString()} map objects (the limit).`,
|
||||
msg('That file creates more than {limit} map objects (the limit).'),
|
||||
{ limit: MAX_IMPORT_ENTITIES.toLocaleString() },
|
||||
{ limit: MAX_IMPORT_ENTITIES },
|
||||
);
|
||||
if (over === 'links')
|
||||
return new GeoImportError(
|
||||
@@ -267,13 +268,13 @@ function overLimitError({ over, features }) {
|
||||
msg(
|
||||
'That file has more than {limit} style definitions and image links after cleanup (the limit).',
|
||||
),
|
||||
{ limit: MAX_KML_LINKS.toLocaleString() },
|
||||
{ limit: MAX_KML_LINKS },
|
||||
);
|
||||
if (over === 'features')
|
||||
return new GeoImportError(
|
||||
`That file has more than ${MAX_IMPORT_ENTITIES.toLocaleString()} features (the limit).`,
|
||||
msg('That file has more than {limit} features (the limit).'),
|
||||
{ limit: MAX_IMPORT_ENTITIES.toLocaleString() },
|
||||
{ limit: MAX_IMPORT_ENTITIES },
|
||||
);
|
||||
const limit = MAX_IMPORT_POSITIONS.toLocaleString();
|
||||
if (features)
|
||||
@@ -282,12 +283,12 @@ function overLimitError({ over, features }) {
|
||||
msg(
|
||||
'That file has more than {limit} points (the limit) across {features} features.',
|
||||
),
|
||||
{ limit, features: features.toLocaleString() },
|
||||
{ limit: MAX_IMPORT_POSITIONS, features },
|
||||
);
|
||||
return new GeoImportError(
|
||||
`That file has more than ${limit} points (the limit).`,
|
||||
msg('That file has more than {limit} points (the limit).'),
|
||||
{ limit },
|
||||
{ limit: MAX_IMPORT_POSITIONS },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1466,8 +1467,8 @@ export async function loadGeoFile(
|
||||
`That file has ${entityCount.toLocaleString()} map objects; the limit is ${MAX_IMPORT_ENTITIES.toLocaleString()}.`,
|
||||
msg('That file has {count} map objects; the limit is {limit}.'),
|
||||
{
|
||||
count: entityCount.toLocaleString(),
|
||||
limit: MAX_IMPORT_ENTITIES.toLocaleString(),
|
||||
count: entityCount,
|
||||
limit: MAX_IMPORT_ENTITIES,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/** Normalize a heading into the [0, 360) range; 0 when it is not finite. */
|
||||
export function normalizeHeading(value) {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
// Only a negative remainder takes the extra turn, so a positive bearing
|
||||
// keeps its precision (0.49999999999999 must not become 360.5).
|
||||
const remainder = value % 360;
|
||||
const wrapped = remainder < 0 ? remainder + 360 : remainder;
|
||||
return wrapped === 360 ? 0 : wrapped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole degrees for display, 0–359. 359.5° and above rounds to north (0),
|
||||
* and negative or multi-turn values wrap. Null when the value is not finite,
|
||||
* so each caller keeps its own placeholder.
|
||||
*/
|
||||
export function roundHeadingDegrees(value) {
|
||||
if (!Number.isFinite(value)) return null;
|
||||
return Math.round(normalizeHeading(value)) % 360;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { normalizeHeading, roundHeadingDegrees } from './headingDegrees.js';
|
||||
|
||||
test('normalizeHeading wraps into [0, 360) and reads a non-finite value as 0', () => {
|
||||
assert.equal(normalizeHeading(370), 10);
|
||||
assert.equal(normalizeHeading(-10), 350);
|
||||
assert.equal(normalizeHeading(720), 0);
|
||||
assert.equal(normalizeHeading(Number.NaN), 0);
|
||||
assert.equal(normalizeHeading(Infinity), 0);
|
||||
});
|
||||
|
||||
test('roundHeadingDegrees reads 359.5° and above as north, never 360', () => {
|
||||
assert.equal(roundHeadingDegrees(359.4), 359);
|
||||
assert.equal(roundHeadingDegrees(359.5), 0);
|
||||
assert.equal(roundHeadingDegrees(359.6), 0);
|
||||
assert.equal(roundHeadingDegrees(360), 0);
|
||||
assert.equal(roundHeadingDegrees(0), 0);
|
||||
assert.equal(roundHeadingDegrees(194.2), 194);
|
||||
});
|
||||
|
||||
test('roundHeadingDegrees wraps negative and multi-turn bearings', () => {
|
||||
assert.equal(Object.is(roundHeadingDegrees(-0.4), 0), true);
|
||||
assert.equal(roundHeadingDegrees(-0.6), 359);
|
||||
assert.equal(roundHeadingDegrees(-1), 359);
|
||||
assert.equal(roundHeadingDegrees(719.6), 0);
|
||||
assert.equal(roundHeadingDegrees(-360.6), 359);
|
||||
});
|
||||
|
||||
test('roundHeadingDegrees returns null for a value that is not finite', () => {
|
||||
for (const value of [
|
||||
Number.NaN,
|
||||
Infinity,
|
||||
-Infinity,
|
||||
null,
|
||||
undefined,
|
||||
'12',
|
||||
]) {
|
||||
assert.equal(roundHeadingDegrees(value), null);
|
||||
}
|
||||
});
|
||||
|
||||
test('normalizeHeading keeps a positive bearing just under a half degree', () => {
|
||||
assert.equal(roundHeadingDegrees(0.49999999999999), 0);
|
||||
assert.equal(roundHeadingDegrees(359.49999999999994), 359);
|
||||
assert.equal(normalizeHeading(-1e-15) < 360, true);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { encodeParams, decodeParams } from '../layers/streetLevel/params.js';
|
||||
import { readShellSource } from '../testSupport/readShellSource.mjs';
|
||||
import { expandApplicationHtml } from '../../build/application-html.js';
|
||||
import { renderApplicationHtml } from '../testSupport/applicationHtml.mjs';
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
@@ -734,18 +734,27 @@ test('a fresh boot starts 3D aircraft ON in proximity — codec, both layers, an
|
||||
/this\.(?:flightState\.)?_models3dMode = 'proximity';/,
|
||||
'ui.js: and believes the mode is proximity',
|
||||
);
|
||||
const html = expandApplicationHtml(
|
||||
const html = await renderApplicationHtml(
|
||||
await readFile(new URL('../../index.html', import.meta.url), 'utf8'),
|
||||
);
|
||||
assert.match(
|
||||
html,
|
||||
/class="pp-toggle-btn active" id="models3d-toggle" aria-pressed="true"/,
|
||||
/<button(?=[^>]*\sid="models3d-toggle")(?=[^>]*\sclass="(?=[^"]*\bpp-toggle-btn\b)(?=[^"]*\bactive\b))(?=[^>]*\saria-pressed="true")/,
|
||||
'index.html: the 3D button paints lit on first paint, before ui.js runs — and says so',
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/this\._models3dBtn\?\.setAttribute\(\s*'aria-pressed',\s*String\(this\.(?:flightState\.)?_models3dEnabled\),?\s*\)/,
|
||||
'ui.js: and keeps aria-pressed synchronized, so the lit state is not colour-only',
|
||||
/this\._models3dPressedShown = this\._models3dEnabled;/,
|
||||
'ui.js: and keeps the pressed state synchronized, so the lit state is not colour-only',
|
||||
);
|
||||
const panel = await readFile(
|
||||
new URL('../ui/views/panels/DisplayPanel.vue', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(
|
||||
panel,
|
||||
/:pressed="models3dOn" pressed-class="active" id="models3d-toggle"/,
|
||||
'DisplayPanel.vue: the view renders aria-pressed with the lit state',
|
||||
);
|
||||
assert.match(
|
||||
html,
|
||||
|
||||
@@ -28,9 +28,6 @@ export class DataLayerManager extends LayerLifecycle {
|
||||
_buildMetaText(layer) {
|
||||
return this._presentation.panel._buildMetaText(layer);
|
||||
}
|
||||
_syncToggleButton(button, layer) {
|
||||
return this._presentation.panel._syncToggleButton(button, layer);
|
||||
}
|
||||
get _layerPanel() {
|
||||
return this._presentation._panel;
|
||||
}
|
||||
|
||||
+8
-824
@@ -12,6 +12,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DataLayerManager, layerFeedState } from './manager.js';
|
||||
import { presentLayerToggle } from '../ui/layerPanel.js';
|
||||
import {
|
||||
contextSnapshotLayerIds,
|
||||
shouldCaptureContextSession,
|
||||
@@ -90,68 +91,6 @@ test('adopts settled visibility without re-running lifecycle work', async () =>
|
||||
assert.equal(manager.adoptLayerVisibility('flights', false, { origin: 'user' }), false);
|
||||
});
|
||||
|
||||
test('renders ordinary layer rows without recreating a panel-hidden coordinator', async () => {
|
||||
const originalDocument = globalThis.document;
|
||||
const makeElement = () => {
|
||||
const element = {
|
||||
children: [],
|
||||
className: '',
|
||||
dataset: {},
|
||||
textContent: '',
|
||||
disabled: false,
|
||||
attributes: {},
|
||||
classList: {
|
||||
toggle() {},
|
||||
},
|
||||
appendChild(child) { this.children.push(child); return child; },
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
setAttribute(name, value) { this.attributes[name] = String(value); },
|
||||
querySelector(selector) {
|
||||
if (selector.startsWith('[data-layer-id="')) {
|
||||
const id = selector.slice(16, -2);
|
||||
return this.children.find((child) => child.dataset.layerId === id) || null;
|
||||
}
|
||||
const className = selector.startsWith('.') ? selector.slice(1) : '';
|
||||
const visit = (node) => {
|
||||
if (String(node.className).split(/\s+/).includes(className)) return node;
|
||||
for (const child of node.children || []) {
|
||||
const found = visit(child);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return visit(this);
|
||||
},
|
||||
set innerHTML(value) { if (value === '') this.children = []; },
|
||||
get innerHTML() { return ''; },
|
||||
};
|
||||
return element;
|
||||
};
|
||||
globalThis.document = { createElement: makeElement };
|
||||
const mgr = new DataLayerManager({});
|
||||
const ordinary = makeSlowLayer('flights', { updateInterval: -1 });
|
||||
const coordinator = makeSlowLayer('military-awareness', { updateInterval: -1 });
|
||||
coordinator.module.showInTogglePanel = false;
|
||||
mgr.register(ordinary.module);
|
||||
mgr.register(coordinator.module);
|
||||
const container = makeElement();
|
||||
|
||||
try {
|
||||
mgr.buildTogglePanel(container);
|
||||
assert.ok(container.querySelector('[data-layer-id="flights"]'));
|
||||
assert.equal(container.querySelector('[data-layer-id="military-awareness"]'), null);
|
||||
assert.equal(await mgr.setEnabled('military-awareness', true), true);
|
||||
mgr._refreshTogglePanel();
|
||||
assert.equal(container.querySelector('[data-layer-id="military-awareness"]'), null);
|
||||
assert.equal(mgr.getAll().find(({ id }) => id === 'military-awareness').enabled, true);
|
||||
} finally {
|
||||
await mgr.destroyAll();
|
||||
if (originalDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = originalDocument;
|
||||
}
|
||||
});
|
||||
|
||||
test('clearSelectedLayers includes hidden coordinators and preserves newer dependency restoration', async () => {
|
||||
const mgr = new DataLayerManager({});
|
||||
const order = [];
|
||||
@@ -1861,17 +1800,6 @@ test('layer metadata names degraded state instead of presenting an ordinary age'
|
||||
|
||||
test('uncertain lifecycle state overrides ordinary feed status without disabling reconciliation', () => {
|
||||
const mgr = new DataLayerManager({});
|
||||
const classes = new Map();
|
||||
const attributes = new Map();
|
||||
const button = {
|
||||
classList: {
|
||||
toggle(name, active) { classes.set(name, Boolean(active)); },
|
||||
},
|
||||
dataset: {},
|
||||
disabled: false,
|
||||
textContent: '',
|
||||
setAttribute(name, value) { attributes.set(name, String(value)); },
|
||||
};
|
||||
const layer = {
|
||||
name: 'Radio',
|
||||
source: 'Radio Browser',
|
||||
@@ -1881,14 +1809,14 @@ test('uncertain lifecycle state overrides ordinary feed status without disabling
|
||||
stats: { count: 750, lastUpdate: Date.now() },
|
||||
};
|
||||
|
||||
mgr._syncToggleButton(button, layer);
|
||||
const toggle = presentLayerToggle(layer);
|
||||
|
||||
assert.equal(button.textContent, 'UNCERTAIN');
|
||||
assert.equal(button.dataset.feedState, 'uncertain');
|
||||
assert.equal(button.disabled, false, 'the lifecycle toggle remains available to reconcile authority');
|
||||
assert.equal(attributes.get('aria-label'), 'Radio: UNCERTAIN');
|
||||
assert.equal(classes.get('lifecycle-uncertain'), true);
|
||||
assert.equal(classes.get('feed-nominal'), false);
|
||||
assert.equal(toggle.text, 'UNCERTAIN');
|
||||
assert.equal(toggle.feedState, 'uncertain');
|
||||
assert.equal(toggle.ariaDisabled, false, 'the lifecycle toggle remains available to reconcile authority');
|
||||
assert.equal(toggle.ariaLabel, 'Radio: UNCERTAIN');
|
||||
assert.equal(toggle.classes['lifecycle-uncertain'], true);
|
||||
assert.equal(toggle.classes['feed-nominal'], false);
|
||||
assert.equal(
|
||||
mgr._buildMetaText(layer),
|
||||
'UNCERTAIN · Radio Browser · lifecycle state requires reconciliation',
|
||||
@@ -2729,549 +2657,6 @@ test('destroy settles an explicit refresh waiting behind invalidated periodic wo
|
||||
assert.equal(await destroy, true);
|
||||
});
|
||||
|
||||
// ── Per-layer row controls (chips + color legend) ───────────────────────────
|
||||
// The satellites layer is the first consumer: a DENSE catalog chip and a class
|
||||
// legend rendered under its row. The manager owns the DOM and the param write;
|
||||
// the layer only declares what it wants, so the chip can never disagree with
|
||||
// the layer's real state.
|
||||
|
||||
/** DOM double rich enough for the row-controls render path. */
|
||||
function makeControlElement() {
|
||||
const element = {
|
||||
children: [],
|
||||
className: '',
|
||||
dataset: {},
|
||||
style: {},
|
||||
attributes: {},
|
||||
listeners: {},
|
||||
textContent: '',
|
||||
hidden: false,
|
||||
disabled: false,
|
||||
title: '',
|
||||
type: '',
|
||||
classList: { toggle() {} },
|
||||
appendChild(child) { child.parent = this; this.children.push(child); return child; },
|
||||
append(...nodes) { for (const n of nodes) n.parent = this; this.children.push(...nodes); },
|
||||
insertBefore(child, anchor) {
|
||||
child.parent = this;
|
||||
const index = anchor ? this.children.indexOf(anchor) : this.children.length;
|
||||
this.children.splice(index, 0, child);
|
||||
return child;
|
||||
},
|
||||
replaceChildren(...nodes) { this.children = [...nodes]; },
|
||||
remove() {
|
||||
const siblings = this.parent?.children;
|
||||
if (siblings) this.parent.children = siblings.filter((n) => n !== this);
|
||||
// Browsers blur a node the moment it leaves the document. Modelling that
|
||||
// is the whole point: it is exactly what in-place chip reconciliation
|
||||
// exists to avoid, so a regression to rebuild-everything must fail here.
|
||||
if (globalThis.document?.activeElement === this) globalThis.document.activeElement = null;
|
||||
},
|
||||
focus() { if (globalThis.document) globalThis.document.activeElement = this; },
|
||||
addEventListener(name, handler) { this.listeners[name] = handler; },
|
||||
removeEventListener(name, handler) { if (this.listeners[name] === handler) delete this.listeners[name]; },
|
||||
setAttribute(name, value) { this.attributes[name] = String(value); },
|
||||
getAttribute(name) { return this.attributes[name] ?? null; },
|
||||
closest(selector) {
|
||||
const className = selector.slice(1);
|
||||
return String(this.className).split(/\s+/).includes(className) ? this : null;
|
||||
},
|
||||
querySelector(selector) {
|
||||
if (selector.startsWith('[data-layer-id="')) {
|
||||
const id = selector.slice(16, -2);
|
||||
return this.children.find((child) => child.dataset.layerId === id) || null;
|
||||
}
|
||||
const className = selector.startsWith('.') ? selector.slice(1) : '';
|
||||
const visit = (node) => {
|
||||
if (String(node.className).split(/\s+/).includes(className)) return node;
|
||||
for (const child of node.children || []) {
|
||||
const found = visit(child);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return visit(this);
|
||||
},
|
||||
set innerHTML(value) { if (value === '') this.children = []; },
|
||||
get innerHTML() { return ''; },
|
||||
};
|
||||
return element;
|
||||
}
|
||||
|
||||
test('keyboard focus survives Data Layer enabling and disabling transitions', async () => {
|
||||
const originalDocument = globalThis.document;
|
||||
globalThis.document = { createElement: makeControlElement, activeElement: null };
|
||||
const mgr = new DataLayerManager({});
|
||||
let releaseEnable;
|
||||
let releaseDisable;
|
||||
const calls = { enable: 0, disable: 0 };
|
||||
mgr.register({
|
||||
id: 'focus-layer',
|
||||
name: 'Focus layer',
|
||||
icon: '',
|
||||
source: 'Local focus fixture',
|
||||
updateInterval: -1,
|
||||
init() {},
|
||||
enable() {
|
||||
calls.enable += 1;
|
||||
return new Promise((resolve) => { releaseEnable = resolve; });
|
||||
},
|
||||
disable() {
|
||||
calls.disable += 1;
|
||||
return new Promise((resolve) => { releaseDisable = resolve; });
|
||||
},
|
||||
update() {},
|
||||
destroy() {},
|
||||
getStats() { return { count: 1, lastUpdate: Date.now() }; },
|
||||
});
|
||||
const container = makeControlElement();
|
||||
|
||||
const waitFor = async (predicate, label) => {
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
if (predicate()) return;
|
||||
await Promise.resolve();
|
||||
}
|
||||
assert.fail(`timed out waiting for ${label}`);
|
||||
};
|
||||
|
||||
try {
|
||||
mgr.buildTogglePanel(container);
|
||||
const row = container.querySelector('[data-layer-id="focus-layer"]');
|
||||
const button = row.querySelector('.data-toggle-btn');
|
||||
button.focus();
|
||||
|
||||
const enablingClick = button.listeners.click();
|
||||
await waitFor(() => typeof releaseEnable === 'function', 'enable transition');
|
||||
assert.equal(globalThis.document.activeElement, button);
|
||||
assert.equal(button.disabled, false, 'ENABLING never uses native disabled');
|
||||
assert.equal(button.getAttribute('aria-disabled'), 'true');
|
||||
assert.equal(button.getAttribute('aria-busy'), 'true');
|
||||
assert.equal(button.textContent, 'ENABLING');
|
||||
await button.listeners.click();
|
||||
assert.equal(calls.enable, 1, 'repeat activation is inert while enabling');
|
||||
assert.equal(mgr.layers.get('focus-layer').visibilityIntentEpoch, 1);
|
||||
|
||||
releaseEnable();
|
||||
await enablingClick;
|
||||
assert.equal(globalThis.document.activeElement, button);
|
||||
assert.equal(button.disabled, false);
|
||||
assert.equal(button.getAttribute('aria-disabled'), 'false');
|
||||
assert.equal(button.getAttribute('aria-busy'), 'false');
|
||||
assert.equal(mgr.isEnabled('focus-layer'), true);
|
||||
|
||||
const disablingClick = button.listeners.click();
|
||||
await waitFor(() => typeof releaseDisable === 'function', 'disable transition');
|
||||
assert.equal(globalThis.document.activeElement, button);
|
||||
assert.equal(button.disabled, false, 'DISABLING never uses native disabled');
|
||||
assert.equal(button.getAttribute('aria-disabled'), 'true');
|
||||
assert.equal(button.getAttribute('aria-busy'), 'true');
|
||||
assert.equal(button.textContent, 'DISABLING');
|
||||
await button.listeners.click();
|
||||
assert.equal(calls.disable, 1, 'repeat activation is inert while disabling');
|
||||
assert.equal(mgr.layers.get('focus-layer').visibilityIntentEpoch, 2);
|
||||
|
||||
releaseDisable();
|
||||
await disablingClick;
|
||||
assert.equal(globalThis.document.activeElement, button);
|
||||
assert.equal(button.disabled, false);
|
||||
assert.equal(button.getAttribute('aria-disabled'), 'false');
|
||||
assert.equal(button.getAttribute('aria-busy'), 'false');
|
||||
assert.equal(mgr.isEnabled('focus-layer'), false);
|
||||
} finally {
|
||||
releaseEnable?.();
|
||||
releaseDisable?.();
|
||||
await mgr.destroyAll();
|
||||
if (originalDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = originalDocument;
|
||||
}
|
||||
});
|
||||
|
||||
test('a failed Data Layer transition clears busy state without losing keyboard focus', async () => {
|
||||
const originalDocument = globalThis.document;
|
||||
const originalWarn = console.warn;
|
||||
globalThis.document = { createElement: makeControlElement, activeElement: null };
|
||||
console.warn = () => {};
|
||||
const mgr = new DataLayerManager({});
|
||||
let rejectEnable;
|
||||
let enableCalls = 0;
|
||||
mgr.register({
|
||||
id: 'focus-failure-layer',
|
||||
name: 'Focus failure layer',
|
||||
icon: '',
|
||||
source: 'Local focus fixture',
|
||||
updateInterval: -1,
|
||||
init() {},
|
||||
enable() {
|
||||
enableCalls += 1;
|
||||
if (enableCalls > 1) return true;
|
||||
return new Promise((resolve, reject) => { rejectEnable = reject; });
|
||||
},
|
||||
disable() { return true; },
|
||||
update() {},
|
||||
destroy() {},
|
||||
getStats() { return { count: 0, lastUpdate: null }; },
|
||||
});
|
||||
const container = makeControlElement();
|
||||
|
||||
try {
|
||||
mgr.buildTogglePanel(container);
|
||||
const button = container
|
||||
.querySelector('[data-layer-id="focus-failure-layer"]')
|
||||
.querySelector('.data-toggle-btn');
|
||||
button.focus();
|
||||
|
||||
const failedClick = button.listeners.click();
|
||||
for (let attempt = 0; attempt < 20 && typeof rejectEnable !== 'function'; attempt += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
assert.equal(typeof rejectEnable, 'function');
|
||||
assert.equal(globalThis.document.activeElement, button);
|
||||
assert.equal(button.getAttribute('aria-disabled'), 'true');
|
||||
assert.equal(button.getAttribute('aria-busy'), 'true');
|
||||
|
||||
rejectEnable(new Error('QA enable rejected'));
|
||||
await failedClick;
|
||||
assert.equal(globalThis.document.activeElement, button, 'failure settlement preserves focus');
|
||||
assert.equal(button.disabled, false);
|
||||
assert.equal(button.getAttribute('aria-disabled'), 'false');
|
||||
assert.equal(button.getAttribute('aria-busy'), 'false');
|
||||
assert.equal(button.textContent, 'OFF');
|
||||
assert.deepEqual(mgr.getLayerLifecycleState('focus-failure-layer'), {
|
||||
enabled: false,
|
||||
lifecycleState: 'disabled',
|
||||
uncertain: false,
|
||||
});
|
||||
|
||||
await button.listeners.click();
|
||||
assert.equal(enableCalls, 2, 'the settled control accepts a later retry');
|
||||
assert.equal(globalThis.document.activeElement, button);
|
||||
assert.equal(mgr.isEnabled('focus-failure-layer'), true);
|
||||
} finally {
|
||||
rejectEnable?.(new Error('test cleanup'));
|
||||
console.warn = originalWarn;
|
||||
await mgr.destroyAll();
|
||||
if (originalDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = originalDocument;
|
||||
}
|
||||
});
|
||||
|
||||
/** Collect every node in a rendered subtree carrying `className`. */
|
||||
function collectByClass(node, className) {
|
||||
const found = [];
|
||||
const visit = (current) => {
|
||||
if (String(current.className).split(/\s+/).includes(className)) found.push(current);
|
||||
for (const child of current.children || []) visit(child);
|
||||
};
|
||||
visit(node);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** A layer that declares a two-state chip plus a legend, like satellites. */
|
||||
function makeRowControlLayer() {
|
||||
let mode = 'core';
|
||||
return {
|
||||
get mode() { return mode; },
|
||||
module: {
|
||||
id: 'satellites',
|
||||
name: 'Satellites',
|
||||
icon: '',
|
||||
source: 'CelesTrak',
|
||||
updateInterval: -1,
|
||||
async init() {},
|
||||
enable() {},
|
||||
disable() {},
|
||||
async update() {},
|
||||
getStats() { return { count: 3, lastUpdate: Date.now() }; },
|
||||
setParams(params) { if (params.catalog) mode = params.catalog; },
|
||||
getParams() { return { catalog: mode }; },
|
||||
getRowControls() {
|
||||
const dense = mode === 'dense';
|
||||
return {
|
||||
chips: [{
|
||||
id: 'catalog',
|
||||
label: 'DENSE',
|
||||
active: dense,
|
||||
title: 'toggle the dense catalog',
|
||||
params: { catalog: dense ? 'core' : 'dense' },
|
||||
}],
|
||||
legend: [
|
||||
{ klass: 'nav', label: 'NAV', color: '#4fd8ff', blurb: 'GNSS', count: 2 },
|
||||
{ klass: 'geo', label: 'GEO', color: '#c89bff', blurb: 'belt', count: 5 },
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('a layer that declares row controls renders its chips and color legend', async () => {
|
||||
const originalDocument = globalThis.document;
|
||||
globalThis.document = { createElement: makeControlElement };
|
||||
const mgr = new DataLayerManager({});
|
||||
const layer = makeRowControlLayer();
|
||||
mgr.register(layer.module);
|
||||
const container = makeControlElement();
|
||||
|
||||
try {
|
||||
mgr.buildTogglePanel(container);
|
||||
const row = container.querySelector('[data-layer-id="satellites"]');
|
||||
const controls = row.querySelector('.data-toggle-controls');
|
||||
assert.ok(controls, 'the row gained a controls block');
|
||||
// Disabled layers stay quiet — no chip, no legend.
|
||||
assert.equal(controls.hidden, true);
|
||||
|
||||
assert.equal(await mgr.setEnabled('satellites', true), true);
|
||||
mgr._refreshTogglePanel();
|
||||
assert.equal(controls.hidden, false);
|
||||
|
||||
const chips = collectByClass(controls, 'data-toggle-chip');
|
||||
assert.equal(chips.length, 1);
|
||||
assert.equal(chips[0].textContent, 'DENSE');
|
||||
assert.equal(chips[0].dataset.chipId, 'catalog');
|
||||
assert.equal(chips[0].attributes['aria-pressed'], 'false');
|
||||
assert.equal(chips[0].title, 'toggle the dense catalog');
|
||||
|
||||
const swatches = collectByClass(controls, 'data-toggle-legend-swatch');
|
||||
assert.deepEqual(swatches.map((s) => s.style.background), ['#4fd8ff', '#c89bff'],
|
||||
'each legend swatch is painted the exact class color');
|
||||
const items = collectByClass(controls, 'data-toggle-legend-item');
|
||||
assert.deepEqual(items.map((i) => i.title), ['GNSS', 'belt']);
|
||||
assert.equal(items.length, 2);
|
||||
} finally {
|
||||
await mgr.destroyAll();
|
||||
if (originalDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = originalDocument;
|
||||
}
|
||||
});
|
||||
|
||||
test('clicking a row chip applies the params it declared and re-renders', async () => {
|
||||
const originalDocument = globalThis.document;
|
||||
globalThis.document = { createElement: makeControlElement };
|
||||
const mgr = new DataLayerManager({});
|
||||
const layer = makeRowControlLayer();
|
||||
mgr.register(layer.module);
|
||||
const container = makeControlElement();
|
||||
|
||||
try {
|
||||
mgr.buildTogglePanel(container);
|
||||
assert.equal(await mgr.setEnabled('satellites', true), true);
|
||||
const row = container.querySelector('[data-layer-id="satellites"]');
|
||||
const controls = row.querySelector('.data-toggle-controls');
|
||||
const chip = collectByClass(controls, 'data-toggle-chip')[0];
|
||||
|
||||
controls.listeners.click({ target: chip });
|
||||
assert.equal(layer.mode, 'dense', 'the chip wrote the params it declared');
|
||||
// setLayerParams refreshes the panel, so the chip already reflects the flip.
|
||||
const afterOn = collectByClass(controls, 'data-toggle-chip')[0];
|
||||
assert.equal(afterOn.attributes['aria-pressed'], 'true');
|
||||
assert.equal(afterOn.className.includes('active'), true);
|
||||
|
||||
controls.listeners.click({ target: afterOn });
|
||||
assert.equal(layer.mode, 'core', 'the chip toggles back rather than latching');
|
||||
assert.equal(collectByClass(controls, 'data-toggle-chip')[0].attributes['aria-pressed'], 'false');
|
||||
} finally {
|
||||
await mgr.destroyAll();
|
||||
if (originalDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = originalDocument;
|
||||
}
|
||||
});
|
||||
|
||||
test('a click outside a chip is inert, and a throwing layer cannot blank the panel', async () => {
|
||||
const originalDocument = globalThis.document;
|
||||
globalThis.document = { createElement: makeControlElement };
|
||||
const warn = console.warn;
|
||||
console.warn = () => {};
|
||||
const mgr = new DataLayerManager({});
|
||||
const layer = makeRowControlLayer();
|
||||
mgr.register(layer.module);
|
||||
const container = makeControlElement();
|
||||
|
||||
try {
|
||||
mgr.buildTogglePanel(container);
|
||||
assert.equal(await mgr.setEnabled('satellites', true), true);
|
||||
const row = container.querySelector('[data-layer-id="satellites"]');
|
||||
const controls = row.querySelector('.data-toggle-controls');
|
||||
|
||||
const legendItem = collectByClass(controls, 'data-toggle-legend-item')[0];
|
||||
controls.listeners.click({ target: legendItem });
|
||||
assert.equal(layer.mode, 'core', 'the legend is not a control');
|
||||
controls.listeners.click({ target: { closest: () => null } });
|
||||
assert.equal(layer.mode, 'core');
|
||||
|
||||
layer.module.getRowControls = () => { throw new Error('boom'); };
|
||||
mgr._refreshTogglePanel();
|
||||
assert.equal(controls.hidden, true, 'a throwing layer collapses to an empty block');
|
||||
assert.ok(container.querySelector('[data-layer-id="satellites"]'), 'the row itself survives');
|
||||
} finally {
|
||||
console.warn = warn;
|
||||
await mgr.destroyAll();
|
||||
if (originalDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = originalDocument;
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard focus on a chip survives the refresh its own click triggers', async () => {
|
||||
// _syncRowControls runs on EVERY panel refresh, including the one the chip's
|
||||
// own click triggers. Rebuilding the button would blur it every time, so a
|
||||
// keyboard user loses their place on activation.
|
||||
const originalDocument = globalThis.document;
|
||||
globalThis.document = { createElement: makeControlElement, activeElement: null };
|
||||
const mgr = new DataLayerManager({});
|
||||
const layer = makeRowControlLayer();
|
||||
mgr.register(layer.module);
|
||||
const container = makeControlElement();
|
||||
|
||||
try {
|
||||
mgr.buildTogglePanel(container);
|
||||
assert.equal(await mgr.setEnabled('satellites', true), true);
|
||||
const controls = container
|
||||
.querySelector('[data-layer-id="satellites"]')
|
||||
.querySelector('.data-toggle-controls');
|
||||
const chip = collectByClass(controls, 'data-toggle-chip')[0];
|
||||
|
||||
chip.focus();
|
||||
assert.equal(globalThis.document.activeElement, chip, 'the chip starts focused');
|
||||
|
||||
controls.listeners.click({ target: chip });
|
||||
assert.equal(globalThis.document.activeElement, chip,
|
||||
'activating the chip does not blur it');
|
||||
assert.equal(collectByClass(controls, 'data-toggle-chip')[0], chip,
|
||||
'the SAME button node is reused across the click-driven refresh');
|
||||
|
||||
mgr._refreshTogglePanel();
|
||||
mgr._refreshTogglePanel();
|
||||
assert.equal(globalThis.document.activeElement, chip,
|
||||
'repeated refreshes never steal focus');
|
||||
// Legend entries hold no focus, so they may be replaced — never duplicated.
|
||||
assert.equal(collectByClass(controls, 'data-toggle-legend-item').length, 2);
|
||||
|
||||
// ...and a chip that genuinely goes away still releases focus.
|
||||
layer.module.getRowControls = () => ({ chips: [], legend: [] });
|
||||
mgr._refreshTogglePanel();
|
||||
assert.equal(globalThis.document.activeElement, null);
|
||||
} finally {
|
||||
await mgr.destroyAll();
|
||||
if (originalDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = originalDocument;
|
||||
}
|
||||
});
|
||||
|
||||
test('an async layer pushes its own row refresh, and a busy chip refuses clicks', async () => {
|
||||
const originalDocument = globalThis.document;
|
||||
globalThis.document = { createElement: makeControlElement };
|
||||
const mgr = new DataLayerManager({});
|
||||
|
||||
let settled = false;
|
||||
let writes = 0;
|
||||
const module = {
|
||||
id: 'satellites',
|
||||
name: 'Satellites',
|
||||
icon: '',
|
||||
source: 'CelesTrak',
|
||||
updateInterval: -1,
|
||||
async init() {},
|
||||
enable() {},
|
||||
disable() {},
|
||||
async update() {},
|
||||
getStats() { return { count: 1, lastUpdate: Date.now() }; },
|
||||
setParams() { writes += 1; },
|
||||
getRowControls() {
|
||||
return {
|
||||
chips: [{
|
||||
id: 'catalog',
|
||||
label: settled ? 'DENSE' : 'DENSE ···',
|
||||
active: settled,
|
||||
busy: !settled,
|
||||
disabled: !settled,
|
||||
state: settled ? 'active' : 'loading',
|
||||
title: 'x',
|
||||
params: { catalog: 'core' },
|
||||
}],
|
||||
legend: [],
|
||||
};
|
||||
},
|
||||
setRowControlsListener(fn) { module._listener = fn; },
|
||||
};
|
||||
mgr.register(module);
|
||||
const container = makeControlElement();
|
||||
|
||||
try {
|
||||
mgr.buildTogglePanel(container);
|
||||
assert.equal(await mgr.setEnabled('satellites', true), true);
|
||||
assert.equal(typeof module._listener, 'function', 'the manager installed its listener');
|
||||
|
||||
const controls = container
|
||||
.querySelector('[data-layer-id="satellites"]')
|
||||
.querySelector('.data-toggle-controls');
|
||||
const chip = collectByClass(controls, 'data-toggle-chip')[0];
|
||||
assert.equal(chip.textContent, 'DENSE ···');
|
||||
assert.equal(chip.disabled, true);
|
||||
assert.equal(chip.attributes['aria-busy'], 'true');
|
||||
assert.equal(chip.attributes['aria-pressed'], 'false', 'busy is never reported as active');
|
||||
assert.equal(chip.className.includes('chip-loading'), true);
|
||||
|
||||
controls.listeners.click({ target: chip });
|
||||
assert.equal(writes, 0, 'a disabled chip is inert');
|
||||
|
||||
// The layer settles and pushes its own refresh — no panel poll involved.
|
||||
settled = true;
|
||||
module._listener();
|
||||
mgr._layerPanel._flushRowControlsRefresh();
|
||||
assert.equal(chip.textContent, 'DENSE');
|
||||
assert.equal(chip.disabled, false);
|
||||
assert.equal(chip.attributes['aria-pressed'], 'true');
|
||||
assert.equal(chip.attributes['aria-busy'], 'false');
|
||||
|
||||
controls.listeners.click({ target: chip });
|
||||
assert.equal(writes, 1, 'the settled chip writes again');
|
||||
} finally {
|
||||
await mgr.destroyAll();
|
||||
if (originalDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = originalDocument;
|
||||
}
|
||||
});
|
||||
|
||||
test('a layer that surrenders its row controls hides the block entirely', async () => {
|
||||
// Space Missions borrows the satellite layer with showPoints:false; the row
|
||||
// must not advertise a legend for an empty sky or offer a chip whose write
|
||||
// the dependency owner will silently revert.
|
||||
const originalDocument = globalThis.document;
|
||||
globalThis.document = { createElement: makeControlElement };
|
||||
const mgr = new DataLayerManager({});
|
||||
let surrendered = false;
|
||||
const layer = makeRowControlLayer();
|
||||
const inner = layer.module.getRowControls;
|
||||
layer.module.getRowControls = () => (surrendered ? { chips: [], legend: [] } : inner());
|
||||
mgr.register(layer.module);
|
||||
const container = makeControlElement();
|
||||
|
||||
try {
|
||||
mgr.buildTogglePanel(container);
|
||||
assert.equal(await mgr.setEnabled('satellites', true), true);
|
||||
const controls = container
|
||||
.querySelector('[data-layer-id="satellites"]')
|
||||
.querySelector('.data-toggle-controls');
|
||||
assert.equal(controls.hidden, false);
|
||||
assert.equal(collectByClass(controls, 'data-toggle-chip').length, 1);
|
||||
|
||||
surrendered = true;
|
||||
mgr._refreshTogglePanel();
|
||||
assert.equal(controls.hidden, true);
|
||||
assert.equal(collectByClass(controls, 'data-toggle-chip').length, 0,
|
||||
'the chip is removed, not merely hidden behind a style');
|
||||
assert.equal(collectByClass(controls, 'data-toggle-legend-item').length, 0);
|
||||
|
||||
surrendered = false;
|
||||
mgr._refreshTogglePanel();
|
||||
assert.equal(controls.hidden, false, 'the row returns when the owner releases it');
|
||||
assert.equal(collectByClass(controls, 'data-toggle-chip').length, 1);
|
||||
} finally {
|
||||
await mgr.destroyAll();
|
||||
if (originalDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = originalDocument;
|
||||
}
|
||||
});
|
||||
|
||||
test('layer metadata shows a guidance prompt without reporting it as a fault', () => {
|
||||
const mgr = new DataLayerManager({});
|
||||
const text = mgr._buildMetaText({
|
||||
@@ -3283,207 +2668,6 @@ test('layer metadata shows a guidance prompt without reporting it as a fault', (
|
||||
assert.doesNotMatch(text, /UNAVAILABLE|DEGRADED/);
|
||||
});
|
||||
|
||||
|
||||
test('panel remount releases old listeners and destruction revokes retained controls', async () => {
|
||||
const originalDocument = globalThis.document;
|
||||
globalThis.document = { createElement: makeControlElement, activeElement: null };
|
||||
const manager = new DataLayerManager({});
|
||||
const layer = makeSlowLayer('lifecycle-panel', { updateInterval: -1 });
|
||||
let rowListener;
|
||||
layer.module.getRowControls = () => ({ chips: [] });
|
||||
layer.module.setRowControlsListener = (listener) => { rowListener = listener; };
|
||||
manager.register(layer.module);
|
||||
try {
|
||||
const first = makeControlElement();
|
||||
manager.buildTogglePanel(first);
|
||||
const oldButton = first.querySelector('.data-toggle-btn');
|
||||
const retainedClick = oldButton.listeners.click;
|
||||
const second = makeControlElement();
|
||||
manager.buildTogglePanel(second);
|
||||
assert.equal(oldButton.listeners.click, undefined);
|
||||
await retainedClick();
|
||||
assert.equal(layer.calls.enable, 0, 'a revoked generation cannot issue actions');
|
||||
const currentButton = second.querySelector('.data-toggle-btn');
|
||||
assert.equal(typeof currentButton.listeners.click, 'function');
|
||||
assert.equal(typeof rowListener, 'function');
|
||||
await manager.destroyAll();
|
||||
assert.equal(currentButton.listeners.click, undefined);
|
||||
assert.equal(rowListener, null);
|
||||
} finally {
|
||||
await manager.destroyAll();
|
||||
if (originalDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = originalDocument;
|
||||
}
|
||||
});
|
||||
|
||||
test('row action chips use live disabled state and descriptive counts without writing parameters', async () => {
|
||||
const originalDocument = globalThis.document;
|
||||
globalThis.document = { createElement: makeControlElement };
|
||||
const mgr = new DataLayerManager({});
|
||||
const layer = makeRowControlLayer();
|
||||
let calls = 0, blocked = false;
|
||||
layer.module.getStats = () => ({ count: 3, countLabel: '3 nearby' });
|
||||
layer.module.getRowControls = () => ({ chips: [{ id: 'find', label: 'SHOW NEAREST', disabled: blocked, onClick: () => calls++ }] });
|
||||
layer.module.setParams = () => { throw new Error('navigation must not write persistent params'); };
|
||||
mgr.register(layer.module);
|
||||
const container = makeControlElement();
|
||||
try {
|
||||
mgr.buildTogglePanel(container);
|
||||
await mgr.setEnabled('satellites', true);
|
||||
const row = container.querySelector('[data-layer-id="satellites"]');
|
||||
assert.equal(row.querySelector('.data-count').textContent, '3 nearby');
|
||||
const controls = row.querySelector('.data-toggle-controls');
|
||||
const chip = collectByClass(controls, 'data-toggle-chip')[0];
|
||||
controls.listeners.click({ target: chip });
|
||||
assert.equal(calls, 1);
|
||||
blocked = true;
|
||||
controls.listeners.click({ target: chip });
|
||||
assert.equal(calls, 1, 'live disabled descriptor wins before row repaint');
|
||||
blocked = false;
|
||||
await mgr.setEnabled('satellites', false);
|
||||
controls.listeners.click({ target: chip });
|
||||
assert.equal(calls, 1, 'disabled layer cannot navigate');
|
||||
} finally {
|
||||
await mgr.destroyAll();
|
||||
if (originalDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = originalDocument;
|
||||
}
|
||||
});
|
||||
|
||||
for (const scheduler of ['animation frame', 'timeout']) {
|
||||
test(`row notifications coalesce with ${scheduler} and stop after destroy`, async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const frames = new Map();
|
||||
let nextFrame = 0;
|
||||
const originalRaf = globalThis.requestAnimationFrame;
|
||||
const originalCancel = globalThis.cancelAnimationFrame;
|
||||
globalThis.requestAnimationFrame = scheduler === 'animation frame'
|
||||
? (callback) => { frames.set(++nextFrame, callback); return nextFrame; }
|
||||
: undefined;
|
||||
globalThis.cancelAnimationFrame = (id) => frames.delete(id);
|
||||
const originalDocument = globalThis.document;
|
||||
globalThis.document = { createElement: makeControlElement };
|
||||
const manager = new DataLayerManager({});
|
||||
const layer = makeSlowLayer('wind', { updateInterval: -1 });
|
||||
let notify;
|
||||
layer.module.getRowControls = () => ({ chips: [] });
|
||||
layer.module.setRowControlsListener = (listener) => { if (listener) notify = listener; };
|
||||
manager.register(layer.module);
|
||||
try {
|
||||
manager.buildTogglePanel(makeControlElement());
|
||||
const panel = manager._layerPanel;
|
||||
let refreshes = 0;
|
||||
const refresh = panel._refreshTogglePanel.bind(panel);
|
||||
panel._refreshTogglePanel = () => { refreshes++; refresh(); };
|
||||
for (let i = 0; i < 10; i++) notify();
|
||||
assert.equal(refreshes, 0, 'notifications defer refresh');
|
||||
if (scheduler === 'animation frame') assert.equal(frames.size, 1);
|
||||
panel._flushRowControlsRefresh();
|
||||
assert.equal(refreshes, 1);
|
||||
panel._flushRowControlsRefresh();
|
||||
t.mock.timers.tick(0);
|
||||
assert.equal(frames.size, 0, 'flush cancels the pending frame');
|
||||
assert.equal(refreshes, 1, 'flush does not leave duplicate work');
|
||||
notify();
|
||||
notify();
|
||||
if (scheduler === 'animation frame') {
|
||||
const [id, callback] = frames.entries().next().value;
|
||||
frames.delete(id);
|
||||
callback();
|
||||
} else t.mock.timers.tick(0);
|
||||
assert.equal(refreshes, 2, 'scheduled callback refreshes once');
|
||||
notify();
|
||||
panel.destroy();
|
||||
assert.equal(frames.size, 0, 'destroy cancels pending work');
|
||||
notify();
|
||||
panel._flushRowControlsRefresh();
|
||||
t.mock.timers.tick(0);
|
||||
assert.equal(refreshes, 2, 'retained notifications after destroy are inert');
|
||||
} finally {
|
||||
await manager.destroyAll();
|
||||
if (originalDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = originalDocument;
|
||||
if (originalRaf === undefined) delete globalThis.requestAnimationFrame;
|
||||
else globalThis.requestAnimationFrame = originalRaf;
|
||||
if (originalCancel === undefined) delete globalThis.cancelAnimationFrame;
|
||||
else globalThis.cancelAnimationFrame = originalCancel;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('row info and legends reconcile in place while chips retain focus and order', async () => {
|
||||
const originalDocument = globalThis.document;
|
||||
globalThis.document = { createElement: makeControlElement, activeElement: null };
|
||||
const manager = new DataLayerManager({});
|
||||
const layer = makeSlowLayer('wind', { updateInterval: -1 });
|
||||
let controls = {
|
||||
chips: [{ id: 'one', label: 'One' }],
|
||||
legend: [{ label: 'Low', color: '#0000ff', count: 1, blurb: 'Low values' }],
|
||||
info: 'Forecast',
|
||||
infoTitle: 'Source time',
|
||||
};
|
||||
layer.module.getRowControls = () => controls;
|
||||
manager.register(layer.module);
|
||||
try {
|
||||
const container = makeControlElement();
|
||||
manager.buildTogglePanel(container);
|
||||
await manager.setEnabled('wind', true);
|
||||
const row = container.querySelector('.data-toggle-controls');
|
||||
const chip = row.children[0];
|
||||
let legend = row.children[1];
|
||||
const info = row.children[2];
|
||||
chip.focus();
|
||||
let writes = 0;
|
||||
for (const key of ['textContent', 'title']) {
|
||||
let value = info[key];
|
||||
Object.defineProperty(info, key, {
|
||||
get: () => value,
|
||||
set: (next) => { writes++; value = next; },
|
||||
});
|
||||
}
|
||||
controls = structuredClone(controls);
|
||||
manager._refreshTogglePanel();
|
||||
assert.equal(row.children[1], legend);
|
||||
assert.equal(row.children[2], info);
|
||||
assert.equal(writes, 0);
|
||||
for (const change of [{ label: 'High' }, { color: '#ff0000' }, { count: 2 }, { blurb: 'High values' }]) {
|
||||
Object.assign(controls.legend[0], change);
|
||||
manager._refreshTogglePanel();
|
||||
assert.notEqual(row.children[1], legend);
|
||||
legend = row.children[1];
|
||||
assert.equal(row.children[2], info);
|
||||
assert.equal(globalThis.document.activeElement, chip);
|
||||
}
|
||||
controls.info = '<img onerror=alert(1)>';
|
||||
controls.infoTitle = '';
|
||||
controls.chips.push({ id: 'two', label: 'Two' });
|
||||
manager._refreshTogglePanel();
|
||||
assert.deepEqual(row.children.map(node => node.className), [
|
||||
'data-toggle-chip chip-idle', 'data-toggle-chip chip-idle',
|
||||
'data-toggle-legend-item', 'data-toggle-controls-info',
|
||||
]);
|
||||
assert.equal(row.children[2], legend);
|
||||
assert.equal(row.children[3], info);
|
||||
assert.equal(info.textContent, controls.info);
|
||||
assert.equal(info.title, '');
|
||||
assert.equal(info.children.length, 0, 'info remains plain text');
|
||||
controls = { chips: [], legend: [] };
|
||||
manager._refreshTogglePanel();
|
||||
assert.equal(row.hidden, true);
|
||||
assert.equal(info.hidden, true);
|
||||
assert.deepEqual(row.children, [info]);
|
||||
controls = { info: 'Restored', legend: [{ label: 'New', color: '#ffffff' }] };
|
||||
manager._refreshTogglePanel();
|
||||
assert.equal(row.children.at(-1), info);
|
||||
assert.equal(info.hidden, false);
|
||||
assert.equal(info.textContent, 'Restored');
|
||||
} finally {
|
||||
await manager.destroyAll();
|
||||
if (originalDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = originalDocument;
|
||||
}
|
||||
});
|
||||
|
||||
test('refreshLayerStats reaches presentation through the bare lifecycle', async () => {
|
||||
const { LayerLifecycle } = await import('./lifecycle.js');
|
||||
const lifecycle = new LayerLifecycle({});
|
||||
|
||||
@@ -0,0 +1,933 @@
|
||||
// src/data/managerPanel.test.mjs — the manager's layer rows, rendered into a
|
||||
// real DOM: toggles keep keyboard focus through transitions, row chips write
|
||||
// the params they declare, and legends, info lines and lists follow the layer.
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DataLayerManager } from './manager.js';
|
||||
import { assertSameNode } from '../testSupport/domAssert.mjs';
|
||||
|
||||
/** Build a mock layer whose init/update resolve on the next microtask. */
|
||||
function makeSlowLayer(id, { updateInterval = 1000 } = {}) {
|
||||
const calls = { enable: 0, disable: 0, update: 0, init: 0 };
|
||||
return {
|
||||
calls,
|
||||
module: {
|
||||
id,
|
||||
name: id,
|
||||
icon: '',
|
||||
source: 'test',
|
||||
updateInterval,
|
||||
async init() {
|
||||
calls.init++;
|
||||
await Promise.resolve();
|
||||
},
|
||||
enable() {
|
||||
calls.enable++;
|
||||
},
|
||||
disable() {
|
||||
calls.disable++;
|
||||
},
|
||||
async update() {
|
||||
calls.update++;
|
||||
await Promise.resolve();
|
||||
},
|
||||
getStats() {
|
||||
return { count: 0, lastUpdate: null };
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A layer that declares a two-state chip plus a legend, like satellites. */
|
||||
function makeRowControlLayer() {
|
||||
let mode = 'core';
|
||||
return {
|
||||
get mode() {
|
||||
return mode;
|
||||
},
|
||||
module: {
|
||||
id: 'satellites',
|
||||
name: 'Satellites',
|
||||
icon: '',
|
||||
source: 'CelesTrak',
|
||||
updateInterval: -1,
|
||||
async init() {},
|
||||
enable() {},
|
||||
disable() {},
|
||||
async update() {},
|
||||
getStats() {
|
||||
return { count: 3, lastUpdate: Date.now() };
|
||||
},
|
||||
setParams(params) {
|
||||
if (params.catalog) mode = params.catalog;
|
||||
},
|
||||
getParams() {
|
||||
return { catalog: mode };
|
||||
},
|
||||
getRowControls() {
|
||||
const dense = mode === 'dense';
|
||||
return {
|
||||
chips: [
|
||||
{
|
||||
id: 'catalog',
|
||||
label: 'DENSE',
|
||||
active: dense,
|
||||
title: 'toggle the dense catalog',
|
||||
params: { catalog: dense ? 'core' : 'dense' },
|
||||
},
|
||||
],
|
||||
legend: [
|
||||
{
|
||||
klass: 'nav',
|
||||
label: 'NAV',
|
||||
color: '#4fd8ff',
|
||||
blurb: 'GNSS',
|
||||
count: 2,
|
||||
},
|
||||
{
|
||||
klass: 'geo',
|
||||
label: 'GEO',
|
||||
color: '#c89bff',
|
||||
blurb: 'belt',
|
||||
count: 5,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function panelHost(t) {
|
||||
document.body.replaceChildren();
|
||||
const container = document.body.appendChild(document.createElement('div'));
|
||||
container.id = 'data-toggles';
|
||||
t.after(() => document.body.replaceChildren());
|
||||
return container;
|
||||
}
|
||||
|
||||
const row = (container, id) =>
|
||||
container.querySelector(`[data-layer-id="${id}"]`);
|
||||
const chipsIn = (node) => [...node.querySelectorAll('.data-toggle-chip')];
|
||||
const macrotask = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
async function waitFor(predicate, label) {
|
||||
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||||
if (predicate()) return;
|
||||
await macrotask();
|
||||
}
|
||||
assert.fail(`timed out waiting for ${label}`);
|
||||
}
|
||||
|
||||
test('renders ordinary layer rows without recreating a panel-hidden coordinator', async (t) => {
|
||||
const container = panelHost(t);
|
||||
const mgr = new DataLayerManager({});
|
||||
const ordinary = makeSlowLayer('flights', { updateInterval: -1 });
|
||||
const coordinator = makeSlowLayer('military-awareness', {
|
||||
updateInterval: -1,
|
||||
});
|
||||
coordinator.module.showInTogglePanel = false;
|
||||
mgr.register(ordinary.module);
|
||||
mgr.register(coordinator.module);
|
||||
try {
|
||||
mgr.buildTogglePanel(container);
|
||||
assert.ok(row(container, 'flights'));
|
||||
assert.ok(!row(container, 'military-awareness'));
|
||||
assert.equal(await mgr.setEnabled('military-awareness', true), true);
|
||||
mgr._refreshTogglePanel();
|
||||
assert.ok(!row(container, 'military-awareness'));
|
||||
assert.equal(
|
||||
mgr.getAll().find(({ id }) => id === 'military-awareness').enabled,
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
await mgr.destroyAll();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard focus survives Data Layer enabling and disabling transitions', async (t) => {
|
||||
const container = panelHost(t);
|
||||
const mgr = new DataLayerManager({});
|
||||
let releaseEnable;
|
||||
let releaseDisable;
|
||||
const calls = { enable: 0, disable: 0 };
|
||||
mgr.register({
|
||||
id: 'focus-layer',
|
||||
name: 'Focus layer',
|
||||
icon: '',
|
||||
source: 'Local focus fixture',
|
||||
updateInterval: -1,
|
||||
init() {},
|
||||
enable() {
|
||||
calls.enable += 1;
|
||||
return new Promise((resolve) => {
|
||||
releaseEnable = resolve;
|
||||
});
|
||||
},
|
||||
disable() {
|
||||
calls.disable += 1;
|
||||
return new Promise((resolve) => {
|
||||
releaseDisable = resolve;
|
||||
});
|
||||
},
|
||||
update() {},
|
||||
destroy() {},
|
||||
getStats() {
|
||||
return { count: 1, lastUpdate: Date.now() };
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
mgr.buildTogglePanel(container);
|
||||
const button = row(container, 'focus-layer').querySelector(
|
||||
'.data-toggle-btn',
|
||||
);
|
||||
button.focus();
|
||||
|
||||
button.click();
|
||||
await waitFor(
|
||||
() => typeof releaseEnable === 'function',
|
||||
'enable transition',
|
||||
);
|
||||
assertSameNode(document.activeElement, button);
|
||||
assert.equal(button.disabled, false, 'ENABLING never uses native disabled');
|
||||
assert.equal(button.getAttribute('aria-disabled'), 'true');
|
||||
assert.equal(button.getAttribute('aria-busy'), 'true');
|
||||
assert.equal(button.textContent, 'ENABLING');
|
||||
button.click();
|
||||
await macrotask();
|
||||
assert.equal(calls.enable, 1, 'repeat activation is inert while enabling');
|
||||
assert.equal(mgr.layers.get('focus-layer').visibilityIntentEpoch, 1);
|
||||
|
||||
releaseEnable();
|
||||
await waitFor(
|
||||
() =>
|
||||
mgr.isEnabled('focus-layer') &&
|
||||
button.getAttribute('aria-busy') === 'false',
|
||||
'enabled',
|
||||
);
|
||||
assertSameNode(document.activeElement, button);
|
||||
assertSameNode(
|
||||
row(container, 'focus-layer').querySelector('.data-toggle-btn'),
|
||||
button,
|
||||
'the SAME button node is reused across the transition',
|
||||
);
|
||||
assert.equal(button.disabled, false);
|
||||
assert.equal(button.getAttribute('aria-disabled'), 'false');
|
||||
|
||||
button.click();
|
||||
await waitFor(
|
||||
() => typeof releaseDisable === 'function',
|
||||
'disable transition',
|
||||
);
|
||||
assertSameNode(document.activeElement, button);
|
||||
assert.equal(
|
||||
button.disabled,
|
||||
false,
|
||||
'DISABLING never uses native disabled',
|
||||
);
|
||||
assert.equal(button.getAttribute('aria-disabled'), 'true');
|
||||
assert.equal(button.getAttribute('aria-busy'), 'true');
|
||||
assert.equal(button.textContent, 'DISABLING');
|
||||
button.click();
|
||||
await macrotask();
|
||||
assert.equal(
|
||||
calls.disable,
|
||||
1,
|
||||
'repeat activation is inert while disabling',
|
||||
);
|
||||
assert.equal(mgr.layers.get('focus-layer').visibilityIntentEpoch, 2);
|
||||
|
||||
releaseDisable();
|
||||
await waitFor(
|
||||
() =>
|
||||
!mgr.isEnabled('focus-layer') &&
|
||||
button.getAttribute('aria-busy') === 'false',
|
||||
'disabled',
|
||||
);
|
||||
assertSameNode(document.activeElement, button);
|
||||
assert.equal(button.disabled, false);
|
||||
assert.equal(button.getAttribute('aria-disabled'), 'false');
|
||||
} finally {
|
||||
releaseEnable?.();
|
||||
releaseDisable?.();
|
||||
await mgr.destroyAll();
|
||||
}
|
||||
});
|
||||
|
||||
test('a failed Data Layer transition clears busy state without losing keyboard focus', async (t) => {
|
||||
const container = panelHost(t);
|
||||
const originalWarn = console.warn;
|
||||
console.warn = () => {};
|
||||
const mgr = new DataLayerManager({});
|
||||
let rejectEnable;
|
||||
let enableCalls = 0;
|
||||
mgr.register({
|
||||
id: 'focus-failure-layer',
|
||||
name: 'Focus failure layer',
|
||||
icon: '',
|
||||
source: 'Local focus fixture',
|
||||
updateInterval: -1,
|
||||
init() {},
|
||||
enable() {
|
||||
enableCalls += 1;
|
||||
if (enableCalls > 1) return true;
|
||||
return new Promise((resolve, reject) => {
|
||||
rejectEnable = reject;
|
||||
});
|
||||
},
|
||||
disable() {
|
||||
return true;
|
||||
},
|
||||
update() {},
|
||||
destroy() {},
|
||||
getStats() {
|
||||
return { count: 0, lastUpdate: null };
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
mgr.buildTogglePanel(container);
|
||||
const button = row(container, 'focus-failure-layer').querySelector(
|
||||
'.data-toggle-btn',
|
||||
);
|
||||
button.focus();
|
||||
|
||||
button.click();
|
||||
await waitFor(
|
||||
() => typeof rejectEnable === 'function',
|
||||
'enable transition',
|
||||
);
|
||||
assertSameNode(document.activeElement, button);
|
||||
assert.equal(button.getAttribute('aria-disabled'), 'true');
|
||||
assert.equal(button.getAttribute('aria-busy'), 'true');
|
||||
|
||||
rejectEnable(new Error('QA enable rejected'));
|
||||
await waitFor(
|
||||
() => button.getAttribute('aria-busy') === 'false',
|
||||
'failure settlement',
|
||||
);
|
||||
assertSameNode(
|
||||
document.activeElement,
|
||||
button,
|
||||
'failure settlement preserves focus',
|
||||
);
|
||||
assert.equal(button.disabled, false);
|
||||
assert.equal(button.getAttribute('aria-disabled'), 'false');
|
||||
assert.equal(button.textContent, 'OFF');
|
||||
assert.deepEqual(mgr.getLayerLifecycleState('focus-failure-layer'), {
|
||||
enabled: false,
|
||||
lifecycleState: 'disabled',
|
||||
uncertain: false,
|
||||
});
|
||||
|
||||
button.click();
|
||||
await waitFor(() => mgr.isEnabled('focus-failure-layer'), 'retry');
|
||||
assert.equal(enableCalls, 2, 'the settled control accepts a later retry');
|
||||
assertSameNode(document.activeElement, button);
|
||||
} finally {
|
||||
rejectEnable?.(new Error('test cleanup'));
|
||||
console.warn = originalWarn;
|
||||
await mgr.destroyAll();
|
||||
}
|
||||
});
|
||||
|
||||
test('a layer that declares row controls renders its chips and color legend', async (t) => {
|
||||
const container = panelHost(t);
|
||||
const mgr = new DataLayerManager({});
|
||||
const layer = makeRowControlLayer();
|
||||
mgr.register(layer.module);
|
||||
try {
|
||||
mgr.buildTogglePanel(container);
|
||||
const controls = row(container, 'satellites').querySelector(
|
||||
'.data-toggle-controls',
|
||||
);
|
||||
assert.ok(controls, 'the row gained a controls block');
|
||||
// Disabled layers stay quiet — no chip, no legend.
|
||||
assert.equal(controls.hidden, true);
|
||||
|
||||
assert.equal(await mgr.setEnabled('satellites', true), true);
|
||||
mgr._refreshTogglePanel();
|
||||
assert.equal(controls.hidden, false);
|
||||
|
||||
const chips = chipsIn(controls);
|
||||
assert.equal(chips.length, 1);
|
||||
assert.equal(chips[0].textContent, 'DENSE');
|
||||
assert.equal(chips[0].dataset.chipId, 'catalog');
|
||||
assert.equal(chips[0].getAttribute('aria-pressed'), 'false');
|
||||
assert.equal(chips[0].title, 'toggle the dense catalog');
|
||||
|
||||
const swatches = [
|
||||
...controls.querySelectorAll('.data-toggle-legend-swatch'),
|
||||
];
|
||||
assert.deepEqual(
|
||||
swatches.map((s) => s.style.background),
|
||||
['#4fd8ff', '#c89bff'],
|
||||
'each legend swatch is painted the exact class color',
|
||||
);
|
||||
const items = [...controls.querySelectorAll('.data-toggle-legend-item')];
|
||||
assert.deepEqual(
|
||||
items.map((i) => i.title),
|
||||
['GNSS', 'belt'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
items.map((i) => i.textContent),
|
||||
['NAV 2', 'GEO 5'],
|
||||
);
|
||||
} finally {
|
||||
await mgr.destroyAll();
|
||||
}
|
||||
});
|
||||
|
||||
test('clicking a row chip applies the params it declared and re-renders', async (t) => {
|
||||
const container = panelHost(t);
|
||||
const mgr = new DataLayerManager({});
|
||||
const layer = makeRowControlLayer();
|
||||
mgr.register(layer.module);
|
||||
try {
|
||||
mgr.buildTogglePanel(container);
|
||||
assert.equal(await mgr.setEnabled('satellites', true), true);
|
||||
const controls = row(container, 'satellites').querySelector(
|
||||
'.data-toggle-controls',
|
||||
);
|
||||
const chip = chipsIn(controls)[0];
|
||||
|
||||
chip.click();
|
||||
assert.equal(layer.mode, 'dense', 'the chip wrote the params it declared');
|
||||
// setLayerParams refreshes the panel, so the chip already reflects the flip.
|
||||
assert.equal(chip.getAttribute('aria-pressed'), 'true');
|
||||
assert.equal(chip.classList.contains('active'), true);
|
||||
|
||||
chip.click();
|
||||
assert.equal(
|
||||
layer.mode,
|
||||
'core',
|
||||
'the chip toggles back rather than latching',
|
||||
);
|
||||
assert.equal(chip.getAttribute('aria-pressed'), 'false');
|
||||
} finally {
|
||||
await mgr.destroyAll();
|
||||
}
|
||||
});
|
||||
|
||||
test('a click outside a chip is inert, and a throwing layer cannot blank the panel', async (t) => {
|
||||
const container = panelHost(t);
|
||||
const warn = console.warn;
|
||||
console.warn = () => {};
|
||||
const mgr = new DataLayerManager({});
|
||||
const layer = makeRowControlLayer();
|
||||
mgr.register(layer.module);
|
||||
try {
|
||||
mgr.buildTogglePanel(container);
|
||||
assert.equal(await mgr.setEnabled('satellites', true), true);
|
||||
const controls = row(container, 'satellites').querySelector(
|
||||
'.data-toggle-controls',
|
||||
);
|
||||
|
||||
controls.querySelector('.data-toggle-legend-item').click();
|
||||
assert.equal(layer.mode, 'core', 'the legend is not a control');
|
||||
controls.click();
|
||||
assert.equal(layer.mode, 'core');
|
||||
|
||||
layer.module.getRowControls = () => {
|
||||
throw new Error('boom');
|
||||
};
|
||||
mgr._refreshTogglePanel();
|
||||
assert.equal(
|
||||
controls.hidden,
|
||||
true,
|
||||
'a throwing layer collapses to an empty block',
|
||||
);
|
||||
assert.ok(row(container, 'satellites'), 'the row itself survives');
|
||||
} finally {
|
||||
console.warn = warn;
|
||||
await mgr.destroyAll();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard focus on a chip survives the refresh its own click triggers', async (t) => {
|
||||
// The panel repaints on EVERY refresh, including the one the chip's own
|
||||
// click triggers. Rebuilding the button would blur it every time, so a
|
||||
// keyboard user loses their place on activation.
|
||||
const container = panelHost(t);
|
||||
const mgr = new DataLayerManager({});
|
||||
const layer = makeRowControlLayer();
|
||||
mgr.register(layer.module);
|
||||
try {
|
||||
mgr.buildTogglePanel(container);
|
||||
assert.equal(await mgr.setEnabled('satellites', true), true);
|
||||
const controls = row(container, 'satellites').querySelector(
|
||||
'.data-toggle-controls',
|
||||
);
|
||||
const chip = chipsIn(controls)[0];
|
||||
|
||||
chip.focus();
|
||||
assertSameNode(document.activeElement, chip, 'the chip starts focused');
|
||||
|
||||
chip.click();
|
||||
assertSameNode(
|
||||
document.activeElement,
|
||||
chip,
|
||||
'activating the chip does not blur it',
|
||||
);
|
||||
assertSameNode(
|
||||
chipsIn(controls)[0],
|
||||
chip,
|
||||
'the SAME button node is reused across the click-driven refresh',
|
||||
);
|
||||
|
||||
mgr._refreshTogglePanel();
|
||||
mgr._refreshTogglePanel();
|
||||
assertSameNode(
|
||||
document.activeElement,
|
||||
chip,
|
||||
'repeated refreshes never steal focus',
|
||||
);
|
||||
assert.equal(
|
||||
controls.querySelectorAll('.data-toggle-legend-item').length,
|
||||
2,
|
||||
);
|
||||
|
||||
// ...and a chip that genuinely goes away is removed from the row.
|
||||
layer.module.getRowControls = () => ({ chips: [], legend: [] });
|
||||
mgr._refreshTogglePanel();
|
||||
assert.equal(chip.isConnected, false);
|
||||
} finally {
|
||||
await mgr.destroyAll();
|
||||
}
|
||||
});
|
||||
|
||||
test('an async layer pushes its own row refresh, and a busy chip refuses clicks', async (t) => {
|
||||
const container = panelHost(t);
|
||||
const mgr = new DataLayerManager({});
|
||||
let settled = false;
|
||||
let writes = 0;
|
||||
const module = {
|
||||
id: 'satellites',
|
||||
name: 'Satellites',
|
||||
icon: '',
|
||||
source: 'CelesTrak',
|
||||
updateInterval: -1,
|
||||
async init() {},
|
||||
enable() {},
|
||||
disable() {},
|
||||
async update() {},
|
||||
getStats() {
|
||||
return { count: 1, lastUpdate: Date.now() };
|
||||
},
|
||||
setParams() {
|
||||
writes += 1;
|
||||
},
|
||||
getRowControls() {
|
||||
return {
|
||||
chips: [
|
||||
{
|
||||
id: 'catalog',
|
||||
label: settled ? 'DENSE' : 'DENSE ···',
|
||||
active: settled,
|
||||
busy: !settled,
|
||||
disabled: !settled,
|
||||
state: settled ? 'active' : 'loading',
|
||||
title: 'x',
|
||||
params: { catalog: 'core' },
|
||||
},
|
||||
],
|
||||
legend: [],
|
||||
};
|
||||
},
|
||||
setRowControlsListener(fn) {
|
||||
module._listener = fn;
|
||||
},
|
||||
};
|
||||
mgr.register(module);
|
||||
try {
|
||||
mgr.buildTogglePanel(container);
|
||||
assert.equal(await mgr.setEnabled('satellites', true), true);
|
||||
assert.equal(
|
||||
typeof module._listener,
|
||||
'function',
|
||||
'the manager installed its listener',
|
||||
);
|
||||
|
||||
const controls = row(container, 'satellites').querySelector(
|
||||
'.data-toggle-controls',
|
||||
);
|
||||
const chip = chipsIn(controls)[0];
|
||||
assert.equal(chip.textContent, 'DENSE ···');
|
||||
assert.equal(chip.disabled, true);
|
||||
assert.equal(chip.getAttribute('aria-busy'), 'true');
|
||||
assert.equal(
|
||||
chip.getAttribute('aria-pressed'),
|
||||
'false',
|
||||
'busy is never reported as active',
|
||||
);
|
||||
assert.equal(chip.classList.contains('chip-loading'), true);
|
||||
|
||||
chip.click();
|
||||
assert.equal(writes, 0, 'a disabled chip is inert');
|
||||
|
||||
// The layer settles and pushes its own refresh — no panel poll involved.
|
||||
settled = true;
|
||||
module._listener();
|
||||
mgr._layerPanel._flushRowControlsRefresh();
|
||||
assert.equal(chip.textContent, 'DENSE');
|
||||
assert.equal(chip.disabled, false);
|
||||
assert.equal(chip.getAttribute('aria-pressed'), 'true');
|
||||
assert.equal(chip.getAttribute('aria-busy'), 'false');
|
||||
|
||||
chip.click();
|
||||
assert.equal(writes, 1, 'the settled chip writes again');
|
||||
} finally {
|
||||
await mgr.destroyAll();
|
||||
}
|
||||
});
|
||||
|
||||
test('a layer that surrenders its row controls hides the block entirely', async (t) => {
|
||||
// Space Missions borrows the satellite layer with showPoints:false; the row
|
||||
// must not advertise a legend for an empty sky or offer a chip whose write
|
||||
// the dependency owner will silently revert.
|
||||
const container = panelHost(t);
|
||||
const mgr = new DataLayerManager({});
|
||||
let surrendered = false;
|
||||
const layer = makeRowControlLayer();
|
||||
const inner = layer.module.getRowControls;
|
||||
layer.module.getRowControls = () =>
|
||||
surrendered ? { chips: [], legend: [] } : inner();
|
||||
mgr.register(layer.module);
|
||||
try {
|
||||
mgr.buildTogglePanel(container);
|
||||
assert.equal(await mgr.setEnabled('satellites', true), true);
|
||||
const controls = row(container, 'satellites').querySelector(
|
||||
'.data-toggle-controls',
|
||||
);
|
||||
assert.equal(controls.hidden, false);
|
||||
assert.equal(chipsIn(controls).length, 1);
|
||||
|
||||
surrendered = true;
|
||||
mgr._refreshTogglePanel();
|
||||
assert.equal(controls.hidden, true);
|
||||
assert.equal(
|
||||
chipsIn(controls).length,
|
||||
0,
|
||||
'the chip is removed, not merely hidden behind a style',
|
||||
);
|
||||
assert.equal(
|
||||
controls.querySelectorAll('.data-toggle-legend-item').length,
|
||||
0,
|
||||
);
|
||||
|
||||
surrendered = false;
|
||||
mgr._refreshTogglePanel();
|
||||
assert.equal(
|
||||
controls.hidden,
|
||||
false,
|
||||
'the row returns when the owner releases it',
|
||||
);
|
||||
assert.equal(chipsIn(controls).length, 1);
|
||||
} finally {
|
||||
await mgr.destroyAll();
|
||||
}
|
||||
});
|
||||
|
||||
test('panel remount releases old listeners and destruction revokes retained controls', async (t) => {
|
||||
panelHost(t);
|
||||
const manager = new DataLayerManager({});
|
||||
const layer = makeSlowLayer('lifecycle-panel', { updateInterval: -1 });
|
||||
let rowListener;
|
||||
layer.module.getRowControls = () => ({ chips: [] });
|
||||
layer.module.setRowControlsListener = (listener) => {
|
||||
rowListener = listener;
|
||||
};
|
||||
manager.register(layer.module);
|
||||
try {
|
||||
const first = document.body.appendChild(document.createElement('div'));
|
||||
manager.buildTogglePanel(first);
|
||||
const oldButton = first.querySelector('.data-toggle-btn');
|
||||
const second = document.body.appendChild(document.createElement('div'));
|
||||
manager.buildTogglePanel(second);
|
||||
assert.equal(first.children.length, 0, 'the old container is emptied');
|
||||
oldButton.click();
|
||||
await macrotask();
|
||||
assert.equal(
|
||||
layer.calls.enable,
|
||||
0,
|
||||
'a revoked generation cannot issue actions',
|
||||
);
|
||||
const currentButton = second.querySelector('.data-toggle-btn');
|
||||
assert.ok(currentButton);
|
||||
assert.equal(typeof rowListener, 'function');
|
||||
await manager.destroyAll();
|
||||
assert.equal(second.children.length, 0, 'destruction removes the rows');
|
||||
currentButton.click();
|
||||
await macrotask();
|
||||
assert.equal(
|
||||
layer.calls.enable,
|
||||
0,
|
||||
'a retained control cannot act after destruction',
|
||||
);
|
||||
assert.equal(rowListener, null);
|
||||
} finally {
|
||||
await manager.destroyAll();
|
||||
}
|
||||
});
|
||||
|
||||
test('a retained chip from a remounted or destroyed panel writes nothing', async (t) => {
|
||||
panelHost(t);
|
||||
const mgr = new DataLayerManager({});
|
||||
const layer = makeRowControlLayer();
|
||||
mgr.register(layer.module);
|
||||
try {
|
||||
const first = document.body.appendChild(document.createElement('div'));
|
||||
mgr.buildTogglePanel(first);
|
||||
assert.equal(await mgr.setEnabled('satellites', true), true);
|
||||
const oldChip = chipsIn(first)[0];
|
||||
const second = document.body.appendChild(document.createElement('div'));
|
||||
mgr.buildTogglePanel(second);
|
||||
oldChip.click();
|
||||
assert.equal(
|
||||
layer.mode,
|
||||
'core',
|
||||
'a released generation cannot write params',
|
||||
);
|
||||
const chip = chipsIn(second)[0];
|
||||
mgr._layerPanel.destroy();
|
||||
chip.click();
|
||||
assert.equal(layer.mode, 'core', 'a destroyed panel cannot write params');
|
||||
} finally {
|
||||
await mgr.destroyAll();
|
||||
}
|
||||
});
|
||||
|
||||
test('a toggle that settles after a remount repaints the new row idle', async (t) => {
|
||||
panelHost(t);
|
||||
const mgr = new DataLayerManager({});
|
||||
let releaseEnable;
|
||||
mgr.register({
|
||||
id: 'remount-layer',
|
||||
name: 'Remount layer',
|
||||
icon: '',
|
||||
source: 'Local fixture',
|
||||
updateInterval: -1,
|
||||
init() {},
|
||||
enable() {
|
||||
return new Promise((resolve) => {
|
||||
releaseEnable = resolve;
|
||||
});
|
||||
},
|
||||
disable() {},
|
||||
update() {},
|
||||
destroy() {},
|
||||
getStats() {
|
||||
return { count: 1, lastUpdate: Date.now() };
|
||||
},
|
||||
});
|
||||
try {
|
||||
const first = document.body.appendChild(document.createElement('div'));
|
||||
mgr.buildTogglePanel(first);
|
||||
first.querySelector('.data-toggle-btn').click();
|
||||
await waitFor(
|
||||
() => typeof releaseEnable === 'function',
|
||||
'enable transition',
|
||||
);
|
||||
const second = document.body.appendChild(document.createElement('div'));
|
||||
mgr.buildTogglePanel(second);
|
||||
releaseEnable();
|
||||
await waitFor(() => mgr.isEnabled('remount-layer'), 'enabled');
|
||||
await macrotask();
|
||||
const button = second.querySelector('.data-toggle-btn');
|
||||
assert.equal(button.getAttribute('aria-busy'), 'false');
|
||||
assert.equal(button.getAttribute('aria-disabled'), 'false');
|
||||
assert.equal(button.textContent, 'ON');
|
||||
} finally {
|
||||
releaseEnable?.();
|
||||
await mgr.destroyAll();
|
||||
}
|
||||
});
|
||||
|
||||
test('row action chips use live disabled state and descriptive counts without writing parameters', async (t) => {
|
||||
const container = panelHost(t);
|
||||
const mgr = new DataLayerManager({});
|
||||
const layer = makeRowControlLayer();
|
||||
let calls = 0,
|
||||
blocked = false;
|
||||
layer.module.getStats = () => ({ count: 3, countLabel: '3 nearby' });
|
||||
layer.module.getRowControls = () => ({
|
||||
chips: [
|
||||
{
|
||||
id: 'find',
|
||||
label: 'SHOW NEAREST',
|
||||
disabled: blocked,
|
||||
onClick: () => calls++,
|
||||
},
|
||||
],
|
||||
});
|
||||
layer.module.setParams = () => {
|
||||
throw new Error('navigation must not write persistent params');
|
||||
};
|
||||
mgr.register(layer.module);
|
||||
try {
|
||||
mgr.buildTogglePanel(container);
|
||||
await mgr.setEnabled('satellites', true);
|
||||
const layerRow = row(container, 'satellites');
|
||||
assert.equal(layerRow.querySelector('.data-count').textContent, '3 nearby');
|
||||
const chip = chipsIn(layerRow)[0];
|
||||
chip.click();
|
||||
assert.equal(calls, 1);
|
||||
blocked = true;
|
||||
chip.click();
|
||||
assert.equal(calls, 1, 'live disabled descriptor wins before row repaint');
|
||||
blocked = false;
|
||||
await mgr.setEnabled('satellites', false);
|
||||
assert.equal(chip.isConnected, false, 'the off row drops its chips');
|
||||
mgr._layerPanel._selectChip('satellites', 'find');
|
||||
assert.equal(calls, 1, 'disabled layer cannot navigate');
|
||||
} finally {
|
||||
await mgr.destroyAll();
|
||||
}
|
||||
});
|
||||
|
||||
for (const scheduler of ['animation frame', 'timeout']) {
|
||||
test(`row notifications coalesce with ${scheduler} and stop after destroy`, async (t) => {
|
||||
const container = panelHost(t);
|
||||
const frames = new Map();
|
||||
let nextFrame = 0;
|
||||
const originalRaf = globalThis.requestAnimationFrame;
|
||||
const originalCancel = globalThis.cancelAnimationFrame;
|
||||
globalThis.requestAnimationFrame =
|
||||
scheduler === 'animation frame'
|
||||
? (callback) => {
|
||||
frames.set(++nextFrame, callback);
|
||||
return nextFrame;
|
||||
}
|
||||
: undefined;
|
||||
globalThis.cancelAnimationFrame = (id) => frames.delete(id);
|
||||
const manager = new DataLayerManager({});
|
||||
const layer = makeSlowLayer('wind', { updateInterval: -1 });
|
||||
let notify;
|
||||
layer.module.getRowControls = () => ({ chips: [] });
|
||||
layer.module.setRowControlsListener = (listener) => {
|
||||
if (listener) notify = listener;
|
||||
};
|
||||
manager.register(layer.module);
|
||||
try {
|
||||
manager.buildTogglePanel(container);
|
||||
const panel = manager._layerPanel;
|
||||
let refreshes = 0;
|
||||
const refresh = panel._refreshTogglePanel.bind(panel);
|
||||
panel._refreshTogglePanel = () => {
|
||||
refreshes++;
|
||||
refresh();
|
||||
};
|
||||
for (let i = 0; i < 10; i++) notify();
|
||||
assert.equal(refreshes, 0, 'notifications defer refresh');
|
||||
if (scheduler === 'animation frame') assert.equal(frames.size, 1);
|
||||
panel._flushRowControlsRefresh();
|
||||
assert.equal(refreshes, 1);
|
||||
panel._flushRowControlsRefresh();
|
||||
await macrotask();
|
||||
assert.equal(frames.size, 0, 'flush cancels the pending frame');
|
||||
assert.equal(refreshes, 1, 'flush does not leave duplicate work');
|
||||
notify();
|
||||
notify();
|
||||
if (scheduler === 'animation frame') {
|
||||
const [id, callback] = frames.entries().next().value;
|
||||
frames.delete(id);
|
||||
callback();
|
||||
} else await macrotask();
|
||||
assert.equal(refreshes, 2, 'scheduled callback refreshes once');
|
||||
notify();
|
||||
panel.destroy();
|
||||
assert.equal(frames.size, 0, 'destroy cancels pending work');
|
||||
notify();
|
||||
panel._flushRowControlsRefresh();
|
||||
await macrotask();
|
||||
assert.equal(
|
||||
refreshes,
|
||||
2,
|
||||
'retained notifications after destroy are inert',
|
||||
);
|
||||
} finally {
|
||||
await manager.destroyAll();
|
||||
globalThis.requestAnimationFrame = originalRaf;
|
||||
globalThis.cancelAnimationFrame = originalCancel;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('row info and legends reconcile in place while chips retain focus and order', async (t) => {
|
||||
const container = panelHost(t);
|
||||
const manager = new DataLayerManager({});
|
||||
const layer = makeSlowLayer('wind', { updateInterval: -1 });
|
||||
let controls = {
|
||||
chips: [{ id: 'one', label: 'One' }],
|
||||
legend: [{ label: 'Low', color: '#0000ff', count: 1, blurb: 'Low values' }],
|
||||
info: 'Forecast',
|
||||
infoTitle: 'Source time',
|
||||
};
|
||||
layer.module.getRowControls = () => controls;
|
||||
manager.register(layer.module);
|
||||
try {
|
||||
manager.buildTogglePanel(container);
|
||||
await manager.setEnabled('wind', true);
|
||||
const block = container.querySelector('.data-toggle-controls');
|
||||
const chip = block.children[0];
|
||||
const info = block.children[2];
|
||||
assert.equal(info.className, 'data-toggle-controls-info');
|
||||
assert.equal(info.textContent, 'Forecast');
|
||||
assert.equal(info.title, 'Source time');
|
||||
chip.focus();
|
||||
for (const change of [
|
||||
{ label: 'High' },
|
||||
{ color: '#ff0000' },
|
||||
{ count: 2 },
|
||||
{ blurb: 'High values' },
|
||||
]) {
|
||||
controls = structuredClone(controls);
|
||||
Object.assign(controls.legend[0], change);
|
||||
manager._refreshTogglePanel();
|
||||
assertSameNode(block.children[2], info);
|
||||
assertSameNode(document.activeElement, chip);
|
||||
}
|
||||
const legend = block.children[1];
|
||||
assert.equal(legend.title, 'High values');
|
||||
assert.equal(legend.textContent, 'High 2');
|
||||
assert.equal(legend.firstElementChild.style.background, '#ff0000');
|
||||
controls.info = '<img onerror=alert(1)>';
|
||||
controls.infoTitle = '';
|
||||
controls.chips.push({ id: 'two', label: 'Two' });
|
||||
manager._refreshTogglePanel();
|
||||
assert.deepEqual(
|
||||
[...block.children].map((node) =>
|
||||
[...node.classList].filter((name) => !name.startsWith('ui-')).join(' '),
|
||||
),
|
||||
[
|
||||
'data-toggle-chip chip-idle',
|
||||
'data-toggle-chip chip-idle',
|
||||
'data-toggle-legend-item',
|
||||
'data-toggle-controls-info',
|
||||
],
|
||||
);
|
||||
assertSameNode(block.children[0], chip);
|
||||
assertSameNode(block.children[3], info);
|
||||
assert.equal(info.textContent, controls.info);
|
||||
assert.equal(info.title, '');
|
||||
assert.equal(info.children.length, 0, 'info remains plain text');
|
||||
controls = { chips: [], legend: [] };
|
||||
manager._refreshTogglePanel();
|
||||
assert.equal(block.hidden, true);
|
||||
assert.equal(info.hidden, true);
|
||||
assert.equal(block.children.length, 1);
|
||||
assertSameNode(block.children[0], info);
|
||||
controls = {
|
||||
info: 'Restored',
|
||||
legend: [{ label: 'New', color: '#ffffff' }],
|
||||
};
|
||||
manager._refreshTogglePanel();
|
||||
assertSameNode(block.lastElementChild, info);
|
||||
assert.equal(info.hidden, false);
|
||||
assert.equal(info.textContent, 'Restored');
|
||||
} finally {
|
||||
await manager.destroyAll();
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Pure proximity helpers for Global Context. No capability modeling. */
|
||||
|
||||
import { t } from '../i18n/index.js';
|
||||
import { msg, t } from '../i18n/index.js';
|
||||
import { UNNAMED_PLACE_NAMES } from './installationNames.js';
|
||||
|
||||
const UNNAMED_NAMES = new Set(UNNAMED_PLACE_NAMES);
|
||||
@@ -84,7 +84,8 @@ export function formatAwarenessLabel(value) {
|
||||
/**
|
||||
* Summarize sorted nearby results without promoting missing/stale feeds to a
|
||||
* negative finding. `UNKNOWN` is an input-data state, never an out-of-range
|
||||
* claim in this MVP.
|
||||
* claim in this MVP. `reason` is English data; the awareness panel shows it
|
||||
* in the UI language.
|
||||
*/
|
||||
export function summarizeAwarenessCohort(
|
||||
items,
|
||||
@@ -95,14 +96,14 @@ export function summarizeAwarenessCohort(
|
||||
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
|
||||
count: null,
|
||||
nearest: [],
|
||||
reason: t('feed unavailable'),
|
||||
reason: msg('feed unavailable'),
|
||||
};
|
||||
if (stale)
|
||||
return {
|
||||
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
|
||||
count: null,
|
||||
nearest: [],
|
||||
reason: t('feed stale'),
|
||||
reason: msg('feed stale'),
|
||||
};
|
||||
const normalized = (Array.isArray(items) ? items : [])
|
||||
.filter(
|
||||
@@ -119,8 +120,8 @@ export function summarizeAwarenessCohort(
|
||||
count: normalized.length,
|
||||
nearest: normalized.slice(0, limit),
|
||||
reason: normalized.length
|
||||
? t('observed or mapped nearby context')
|
||||
: t('no observed or mapped objects in current feeds'),
|
||||
? msg('observed or mapped nearby context')
|
||||
: msg('no observed or mapped objects in current feeds'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale, translateKnown } from '../i18n/index.js';
|
||||
import {
|
||||
AWARENESS_RELATIONSHIP,
|
||||
findByDoublingRadius,
|
||||
@@ -47,6 +48,25 @@ test('unavailable and stale feeds remain unknown', () => {
|
||||
assert.equal(summarizeAwarenessCohort([], { stale: true }).reason, 'feed stale');
|
||||
});
|
||||
|
||||
test('cohort reasons stay English in data and translate where the panel shows them', (t) => {
|
||||
t.after(() => setLocale('en'));
|
||||
setLocale('ru');
|
||||
const reasons = [
|
||||
summarizeAwarenessCohort([], { available: false }).reason,
|
||||
summarizeAwarenessCohort([], { stale: true }).reason,
|
||||
summarizeAwarenessCohort([{ id: 'near', distanceM: 10 }]).reason,
|
||||
summarizeAwarenessCohort([]).reason,
|
||||
];
|
||||
assert.deepEqual(reasons, [
|
||||
'feed unavailable',
|
||||
'feed stale',
|
||||
'observed or mapped nearby context',
|
||||
'no observed or mapped objects in current feeds',
|
||||
]);
|
||||
// The awareness panel shows a reason through translateKnown().
|
||||
for (const reason of reasons) assert.notEqual(translateKnown(reason), reason);
|
||||
});
|
||||
|
||||
test('navigation prioritizes nearby targets from the selected cohort', () => {
|
||||
const cohorts = [
|
||||
{ id: 'flights', summary: { nearest: [{ icao24: 'a1', distanceM: 1000 }, { icao24: 'a2', distanceM: 2000 }] } },
|
||||
|
||||
@@ -39,10 +39,6 @@ export const missionRosterEntries = layer.missionRosterEntries;
|
||||
export const missionHoverPreviewRange = layer.missionHoverPreviewRange;
|
||||
export const createMissionRosterPreviewOwnership =
|
||||
layer.createMissionRosterPreviewOwnership;
|
||||
export const bindMissionRosterItemKeyboardPreview =
|
||||
layer.bindMissionRosterItemKeyboardPreview;
|
||||
export const captureMissionRosterFocus = layer.captureMissionRosterFocus;
|
||||
export const restoreMissionRosterFocus = layer.restoreMissionRosterFocus;
|
||||
export const resolveMissionRosterPreviewLaunch =
|
||||
layer.resolveMissionRosterPreviewLaunch;
|
||||
export const formatMissionEventTime = layer.formatMissionEventTime;
|
||||
|
||||
@@ -7,8 +7,6 @@ import rocketLaunchesLayer, {
|
||||
_setRocketMissionOverlayHostForTest,
|
||||
_setSelectedRocketMissionForTest,
|
||||
approximateOrbitPath,
|
||||
bindMissionRosterItemKeyboardPreview,
|
||||
captureMissionRosterFocus,
|
||||
buildMissionPaths,
|
||||
cameraHeadingForPath,
|
||||
compactLaunchSiteName,
|
||||
@@ -46,7 +44,6 @@ import rocketLaunchesLayer, {
|
||||
replayStartAfterPause,
|
||||
replayVehicleScreenRotation,
|
||||
resolveMissionRosterPreviewLaunch,
|
||||
restoreMissionRosterFocus,
|
||||
releaseAircraftTracking,
|
||||
ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT,
|
||||
ROCKET_MISSION_AMBIENT_OVERLAY_COLLISION_CAPACITY,
|
||||
@@ -514,42 +511,6 @@ test('keyboard focus previews roster missions and clears without selecting them'
|
||||
]);
|
||||
});
|
||||
|
||||
test('rendered mission row focus and blur drive the preview ownership controller', () => {
|
||||
const listeners = new Map();
|
||||
const calls = [];
|
||||
const button = {
|
||||
addEventListener(type, listener) {
|
||||
listeners.set(type, listener);
|
||||
},
|
||||
};
|
||||
const nextButton = {
|
||||
dataset: { missionRosterIndex: '4' },
|
||||
closest: () => nextButton,
|
||||
};
|
||||
const roster = { contains: (candidate) => candidate === nextButton };
|
||||
const ownership = {
|
||||
pointerEnter: (index) => calls.push(['pointer-enter', index]),
|
||||
pointerLeave: () => calls.push(['pointer-leave']),
|
||||
focus: (index) => calls.push(['focus', index]),
|
||||
blur: (index) => calls.push(['blur', index]),
|
||||
};
|
||||
|
||||
bindMissionRosterItemKeyboardPreview(button, 2, ownership, roster);
|
||||
listeners.get('mouseenter')();
|
||||
listeners.get('mouseleave')();
|
||||
listeners.get('focus')();
|
||||
listeners.get('blur')({ relatedTarget: nextButton });
|
||||
listeners.get('blur')({ relatedTarget: null });
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
['pointer-enter', 2],
|
||||
['pointer-leave'],
|
||||
['focus', 2],
|
||||
['blur', 4],
|
||||
['blur', null],
|
||||
]);
|
||||
});
|
||||
|
||||
test('leaving a hovered row for an internal roster gap restores keyboard preview', () => {
|
||||
const calls = [];
|
||||
const ownership = createMissionRosterPreviewOwnership({
|
||||
@@ -568,50 +529,6 @@ test('leaving a hovered row for an internal roster gap restores keyboard preview
|
||||
]);
|
||||
});
|
||||
|
||||
test('mission roster refresh restores keyboard focus by stable mission identity after reorder', () => {
|
||||
const focused = {
|
||||
dataset: { missionRosterId: 'mission-b' },
|
||||
closest: () => focused,
|
||||
};
|
||||
let restored = false;
|
||||
const replacement = {
|
||||
dataset: { missionRosterId: 'mission-b' },
|
||||
focus: ({ preventScroll }) => { restored = preventScroll; },
|
||||
};
|
||||
const list = {
|
||||
contains: (candidate) => candidate === focused,
|
||||
querySelectorAll: () => [
|
||||
{ dataset: { missionRosterId: 'mission-c' } },
|
||||
replacement,
|
||||
{ dataset: { missionRosterId: 'mission-a' } },
|
||||
],
|
||||
};
|
||||
|
||||
const snapshot = captureMissionRosterFocus(list, focused);
|
||||
assert.deepEqual(snapshot, { launchId: 'mission-b' });
|
||||
assert.equal(restoreMissionRosterFocus(list, snapshot, null), 'restored');
|
||||
assert.equal(restored, true);
|
||||
});
|
||||
|
||||
test('mission roster refresh continues after the list when the focused mission departed', () => {
|
||||
const focused = {
|
||||
dataset: { missionRosterId: 'departed' },
|
||||
closest: () => focused,
|
||||
};
|
||||
let continuationFocused = false;
|
||||
const list = {
|
||||
contains: (candidate) => candidate === focused,
|
||||
querySelectorAll: () => [{ dataset: { missionRosterId: 'remaining' } }],
|
||||
};
|
||||
const continuation = {
|
||||
focus: ({ preventScroll }) => { continuationFocused = preventScroll; },
|
||||
};
|
||||
|
||||
const snapshot = captureMissionRosterFocus(list, focused);
|
||||
assert.equal(restoreMissionRosterFocus(list, snapshot, continuation), 'continued');
|
||||
assert.equal(continuationFocused, true);
|
||||
});
|
||||
|
||||
test('pending mission preview resolves the current refreshed record by identity', () => {
|
||||
const stale = { id: 'mission-a', name: 'Stale' };
|
||||
const current = { id: 'mission-a', name: 'Current' };
|
||||
|
||||
@@ -107,7 +107,10 @@ export function terrainRetryAfterMs(value, nowMs = Date.now()) {
|
||||
/**
|
||||
* Fetch one upstream chunk with jittered exponential backoff. Network errors,
|
||||
* 429, and 5xx retry; other HTTP failures and malformed successes fail fast.
|
||||
* Retry sleeps and retry attempts share a 10s budget after attempt one.
|
||||
* Retry sleeps and retry attempts share a 10s budget after attempt one. A
|
||||
* Retry-After longer than the budget left ends the retries at once: sleeping
|
||||
* out the budget cannot reach the time the upstream asked for, and it would
|
||||
* delay the caller's own back-off (the proxy's 429 pause) by up to 10 s.
|
||||
*
|
||||
* Dependencies are injectable for deterministic offline tests.
|
||||
* @param {Array<[number, number]>} points
|
||||
@@ -145,6 +148,7 @@ export async function fetchTerrainChunkWithRetry(
|
||||
const res = await fetchImpl(url, signal ? { signal } : {});
|
||||
if (!res.ok) {
|
||||
const error = new Error(`HTTP ${res.status}`);
|
||||
error.status = res.status;
|
||||
error.retryable = res.status === 429 || res.status >= 500;
|
||||
error.retryAfter = res.headers?.get?.('retry-after') ?? null;
|
||||
throw error;
|
||||
@@ -167,6 +171,7 @@ export async function fetchTerrainChunkWithRetry(
|
||||
if (remaining <= 0) break;
|
||||
|
||||
const retryAfterMs = terrainRetryAfterMs(error?.retryAfter, now());
|
||||
if (retryAfterMs != null && retryAfterMs > remaining) break;
|
||||
const backoffMs = Math.round(
|
||||
350 * 2 ** attempt * (0.75 + random() * 0.5),
|
||||
);
|
||||
|
||||
@@ -91,6 +91,36 @@ test('429 retry honors Retry-After before the next attempt', async () => {
|
||||
assert.equal(results[0].id, 'recovered');
|
||||
});
|
||||
|
||||
test('a Retry-After longer than the retry budget ends the retries without sleeping', async () => {
|
||||
let clock = 3_000_000;
|
||||
let attempts = 0;
|
||||
const sleeps = [];
|
||||
await assert.rejects(
|
||||
fetchTerrainChunkWithRetry([[1, 1]], {
|
||||
now: () => clock,
|
||||
random: () => 0.5,
|
||||
makeSignal: () => undefined,
|
||||
sleep: async (ms) => {
|
||||
sleeps.push(ms);
|
||||
clock += ms;
|
||||
},
|
||||
fetchImpl: async () => {
|
||||
attempts += 1;
|
||||
return {
|
||||
ok: false,
|
||||
status: 429,
|
||||
headers: {
|
||||
get: (name) => (name.toLowerCase() === 'retry-after' ? '30' : null),
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
(error) => error.status === 429 && error.retryAfter === '30',
|
||||
);
|
||||
assert.equal(attempts, 1);
|
||||
assert.deepEqual(sleeps, [], 'no sleep that cannot reach the Retry-After');
|
||||
});
|
||||
|
||||
test('permanently failing upstream exhausts retries and returns 502 without fake results', async () => {
|
||||
let clock = 2_000_000;
|
||||
let attempts = 0;
|
||||
|
||||
@@ -9,9 +9,18 @@ const sourceSlot = createSourceSlot(
|
||||
{
|
||||
getFlowSessionStats: () => ({ tilesFetched: 0 }),
|
||||
resetFlowTileCache: () => {},
|
||||
// The layer reads stale cached tiles through this; a source without it
|
||||
// answers with fresh segments only.
|
||||
fetchFlowDetail: async (...args) => ({
|
||||
segments: await sourceSlot.source.fetchFlowForBounds(...args),
|
||||
stale: false,
|
||||
oldestTileAt: null,
|
||||
}),
|
||||
},
|
||||
);
|
||||
export const configureTrafficSource = sourceSlot.configure;
|
||||
/** The source the traffic layer reads, as configured. */
|
||||
export const trafficSource = sourceSlot.source;
|
||||
const layer = createApplicationTraffic({
|
||||
source: sourceSlot.source,
|
||||
});
|
||||
|
||||
+105
-19
@@ -22,7 +22,10 @@ import { DataLayerManager, layerFeedState } from './manager.js';
|
||||
const LIVE_CLAIM = /\bLIVE\b|\bGPS\b|\breal[- ]?time\b/;
|
||||
|
||||
test('a superseded flow fetch is not an outage', () => {
|
||||
assert.equal(deriveTrafficFlowError({ name: 'AbortError', message: 'aborted' }), null);
|
||||
assert.equal(
|
||||
deriveTrafficFlowError({ name: 'AbortError', message: 'aborted' }),
|
||||
null,
|
||||
);
|
||||
assert.equal(deriveTrafficFlowError(null), null);
|
||||
assert.equal(deriveTrafficFlowError(undefined), null);
|
||||
});
|
||||
@@ -30,10 +33,22 @@ test('a superseded flow fetch is not an outage', () => {
|
||||
test('flow failures map onto short, specific reasons', () => {
|
||||
const reason = (message) => deriveTrafficFlowError(new Error(message));
|
||||
assert.equal(reason('flow tile 12/1/1: HTTP 503'), 'TomTom key unavailable');
|
||||
assert.equal(reason('flow tile 12/1/1: HTTP 429'), 'TomTom daily budget reached');
|
||||
assert.equal(reason('flow tile 12/1/1: HTTP 502'), 'TomTom upstream unreachable');
|
||||
assert.equal(reason('flow tile 12/1/1: HTTP 504'), 'TomTom upstream unreachable');
|
||||
assert.equal(reason('flow tile 12/1/1: HTTP 418'), 'TomTom flow error (HTTP 418)');
|
||||
assert.equal(
|
||||
reason('flow tile 12/1/1: HTTP 429'),
|
||||
'TomTom daily budget reached',
|
||||
);
|
||||
assert.equal(
|
||||
reason('flow tile 12/1/1: HTTP 502'),
|
||||
'TomTom upstream unreachable',
|
||||
);
|
||||
assert.equal(
|
||||
reason('flow tile 12/1/1: HTTP 504'),
|
||||
'TomTom upstream unreachable',
|
||||
);
|
||||
assert.equal(
|
||||
reason('flow tile 12/1/1: HTTP 418'),
|
||||
'TomTom flow error (HTTP 418)',
|
||||
);
|
||||
assert.equal(reason('flow fetch failed'), 'TomTom flow unavailable');
|
||||
});
|
||||
|
||||
@@ -56,19 +71,32 @@ test('no keyless label ever implies a live feed', () => {
|
||||
trafficFeedPresentation({ liveMode: false, fetching: false }),
|
||||
trafficFeedPresentation({ liveMode: false, fetching: true }),
|
||||
trafficFeedPresentation({ statusUnavailable: true }),
|
||||
trafficFeedPresentation({ liveMode: true, flowError: 'TomTom flow unavailable' }),
|
||||
trafficFeedPresentation({ liveMode: true, fetching: true, flowError: 'TomTom flow unavailable' }),
|
||||
trafficFeedPresentation({
|
||||
liveMode: true,
|
||||
flowError: 'TomTom flow unavailable',
|
||||
}),
|
||||
trafficFeedPresentation({
|
||||
liveMode: true,
|
||||
fetching: true,
|
||||
flowError: 'TomTom flow unavailable',
|
||||
}),
|
||||
].map((feed) => feed.loadingLabel);
|
||||
for (const label of labels) {
|
||||
assert.ok(!LIVE_CLAIM.test(label), `label implies live data: ${label}`);
|
||||
assert.ok(label.startsWith('SIMULATED'), `fallback label must lead with the mode: ${label}`);
|
||||
assert.ok(
|
||||
label.startsWith('SIMULATED'),
|
||||
`fallback label must lead with the mode: ${label}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('simulating because the status probe failed reads differently from keyless by design', () => {
|
||||
const probeDown = trafficFeedPresentation({ statusUnavailable: true });
|
||||
assert.equal(probeDown.mode, 'sim');
|
||||
assert.equal(probeDown.loadingLabel, 'SIMULATED — traffic service unreachable');
|
||||
assert.equal(
|
||||
probeDown.loadingLabel,
|
||||
'SIMULATED — traffic service unreachable',
|
||||
);
|
||||
});
|
||||
|
||||
test('a healthy keyed layer reports live flow with its real coverage', () => {
|
||||
@@ -76,7 +104,8 @@ test('a healthy keyed layer reports live flow with its real coverage', () => {
|
||||
assert.deepEqual(idle, {
|
||||
mode: 'live',
|
||||
error: null,
|
||||
loadingLabel: 'LIVE · Roads: OpenStreetMap · Flow: TomTom · 87% cov · Unmatched: simulated',
|
||||
loadingLabel:
|
||||
'LIVE · Roads: OpenStreetMap · Flow: TomTom · 87% cov · Unmatched: simulated',
|
||||
});
|
||||
assert.equal(
|
||||
trafficFeedPresentation({ liveMode: true, fetching: true }).loadingLabel,
|
||||
@@ -100,7 +129,11 @@ test('a mid-session flow outage degrades instead of reporting stale live coverag
|
||||
fetching: true,
|
||||
flowError: 'TomTom daily budget reached',
|
||||
});
|
||||
assert.deepEqual(busy, down, 'the degraded state reads the same whether or not a load is in flight');
|
||||
assert.deepEqual(
|
||||
busy,
|
||||
down,
|
||||
'the degraded state reads the same whether or not a load is in flight',
|
||||
);
|
||||
});
|
||||
|
||||
test('the rendered steady-state meta line carries the SIMULATED copy', () => {
|
||||
@@ -116,10 +149,12 @@ test('the rendered steady-state meta line carries the SIMULATED copy', () => {
|
||||
assert.equal(
|
||||
mgr._buildMetaText({
|
||||
source: 'OpenStreetMap',
|
||||
stats: stats(trafficFeedPresentation({
|
||||
liveMode: true,
|
||||
flowError: 'TomTom daily budget reached',
|
||||
})),
|
||||
stats: stats(
|
||||
trafficFeedPresentation({
|
||||
liveMode: true,
|
||||
flowError: 'TomTom daily budget reached',
|
||||
}),
|
||||
),
|
||||
}),
|
||||
'DEGRADED · OpenStreetMap · SIMULATED — TomTom daily budget reached',
|
||||
);
|
||||
@@ -128,17 +163,26 @@ test('the rendered steady-state meta line carries the SIMULATED copy', () => {
|
||||
test('the manager reads keyless as FALLBACK and an outage as DEGRADED', () => {
|
||||
const settled = { count: 4200, lastUpdate: Date.now() };
|
||||
assert.equal(
|
||||
layerFeedState({ ...settled, ...trafficFeedPresentation({ liveMode: false }) }),
|
||||
layerFeedState({
|
||||
...settled,
|
||||
...trafficFeedPresentation({ liveMode: false }),
|
||||
}),
|
||||
'fallback',
|
||||
);
|
||||
assert.equal(
|
||||
layerFeedState({ ...settled, ...trafficFeedPresentation({ liveMode: true }) }),
|
||||
layerFeedState({
|
||||
...settled,
|
||||
...trafficFeedPresentation({ liveMode: true }),
|
||||
}),
|
||||
'nominal',
|
||||
);
|
||||
assert.equal(
|
||||
layerFeedState({
|
||||
...settled,
|
||||
...trafficFeedPresentation({ liveMode: true, flowError: 'TomTom flow unavailable' }),
|
||||
...trafficFeedPresentation({
|
||||
liveMode: true,
|
||||
flowError: 'TomTom flow unavailable',
|
||||
}),
|
||||
}),
|
||||
'degraded',
|
||||
);
|
||||
@@ -148,7 +192,10 @@ test('the shipped layer boots keyless-honest before any status check', () => {
|
||||
const stats = trafficLayer.getStats();
|
||||
assert.equal(stats.mode, 'sim');
|
||||
assert.equal(stats.error, null);
|
||||
assert.ok(!LIVE_CLAIM.test(stats.loadingLabel), `boot label implies live data: ${stats.loadingLabel}`);
|
||||
assert.ok(
|
||||
!LIVE_CLAIM.test(stats.loadingLabel),
|
||||
`boot label implies live data: ${stats.loadingLabel}`,
|
||||
);
|
||||
assert.equal(layerFeedState(stats), 'fallback');
|
||||
});
|
||||
|
||||
@@ -167,3 +214,42 @@ test('zero matched dots never claims live coverage', () => {
|
||||
assert.match(feed.loadingLabel, /TomTom \(no matches\)/);
|
||||
assert.ok(!LIVE_CLAIM.test(feed.loadingLabel));
|
||||
});
|
||||
|
||||
test('the traffic layer source passes fetchFlowDetail through, so stale tiles reach the layer', async () => {
|
||||
const { trafficSource, configureTrafficSource } =
|
||||
await import('./traffic.js');
|
||||
assert.equal(typeof trafficSource.fetchFlowDetail, 'function');
|
||||
const base = {
|
||||
requestRoads: async () => ({}),
|
||||
getStatus: async () => ({}),
|
||||
fetchFlowForBounds: async () => ['segment'],
|
||||
};
|
||||
let release = configureTrafficSource({
|
||||
...base,
|
||||
fetchFlowDetail: async () => ({
|
||||
segments: ['segment'],
|
||||
stale: true,
|
||||
oldestTileAt: 1000,
|
||||
}),
|
||||
});
|
||||
try {
|
||||
assert.deepEqual(await trafficSource.fetchFlowDetail({}), {
|
||||
segments: ['segment'],
|
||||
stale: true,
|
||||
oldestTileAt: 1000,
|
||||
});
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
// A source without fetchFlowDetail answers with fresh segments.
|
||||
release = configureTrafficSource(base);
|
||||
try {
|
||||
assert.deepEqual(await trafficSource.fetchFlowDetail({}), {
|
||||
segments: ['segment'],
|
||||
stale: false,
|
||||
oldestTileAt: null,
|
||||
});
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
});
|
||||
@@ -20,7 +20,7 @@
|
||||
* jam keeps a size bump so queues read as fat beads.
|
||||
* - `crt` (retro): saturated primaries that survive 10-level posterization
|
||||
* plus an all-bucket size boost to out-shout the pixel grid.
|
||||
* - `normal` (normal/anime/snow/unknown): every lookup returns null/0 —
|
||||
* - `normal` (normal/anime/unknown): every lookup returns null/0 —
|
||||
* the shipped palette and sizing apply untouched.
|
||||
*
|
||||
* Sim/uncovered dots (bucket null/'sim') are NEVER restyled under any
|
||||
|
||||
@@ -13,7 +13,7 @@ const luma = ([r, g, b]) => (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
|
||||
const BUCKETS = ['free', 'slow', 'jam'];
|
||||
const MONO_STYLES = ['surveillance', 'thermal', 'noir'];
|
||||
const NORMAL_STYLES = ['normal', 'anime', 'snow', 'does-not-exist', '', undefined, null];
|
||||
const NORMAL_STYLES = ['normal', 'anime', 'does-not-exist', '', undefined, null];
|
||||
|
||||
test('profile mapping: NVG/FLIR/noir are mono, retro is crt, everything else normal', () => {
|
||||
for (const s of MONO_STYLES) assert.equal(trafficStyleProfile(s), 'mono', s);
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
* black sprite with a light ring is what a hot object looks like there.
|
||||
* - `crt` (retro): saturated per-mode colours that survive posterisation,
|
||||
* a size boost to out-shout the pixel grid, and a thinner halo.
|
||||
* - `normal` (normal/anime/snow/unknown): every lookup returns the shipped
|
||||
* - `normal` (normal/anime/unknown): every lookup returns the shipped
|
||||
* value — mode colour, shipped size, hairline edge only.
|
||||
*
|
||||
* Brackets are a different matter. The detection canvas composites ABOVE the
|
||||
|
||||
@@ -17,7 +17,6 @@ test('profile mapping: NVG/FLIR/noir and cockpit nvg are mono, retro is crt, eve
|
||||
for (const plain of [
|
||||
'normal',
|
||||
'anime',
|
||||
'snow',
|
||||
'minecraft',
|
||||
'',
|
||||
null,
|
||||
|
||||
Reference in new issue
Block a user