Merge remote-tracking branch 'upstream/main' into pr1585-fix

This commit is contained in:
Said Turksever committed 2026-11-05 17:15:30 +00:00
commit e2ca8d9a27
18 files changed
+355 -6

No files matched your search

+1
View File
@@ -196,6 +196,7 @@ export function createModel({ state: layerState, services, parts, source }) {
id: cohort.id,
label: cohort.label,
source: cohort.source,
sourceStatus: cohort.sourceStatus || null,
coverage: cohort.coverage || null,
relationship: cohort.summary.relationship,
count: cohort.summary.count,
+2 -1
View File
@@ -8,6 +8,7 @@ import {
t,
translateKnown,
} from '../../i18n/index.js';
import { sourceStatusLabel } from '../../ui/layerNames.js';
import { formatQuantity } from '../../ui/unitLabels.js';
import { AWARENESS_PAGE_SIZE, AWARENESS_PAGE_ROTATE_MS } from './policy.js';
import { distanceParts } from '../../data/distanceFormat.js';
@@ -150,7 +151,7 @@ export function createPanel({ state: layerState, parts }) {
relationship: summary.relationship.toLowerCase(),
label: cohort.label,
count: `${presentAwarenessCount(summary)}${pageLabel}`,
detail: `${cohort.source}${coverage} · ${presentAwarenessReason(summary)}`,
detail: `${sourceStatusLabel(cohort.source, cohort.sourceStatus)}${coverage} · ${presentAwarenessReason(summary)}`,
nearest,
};
}
+38
View File
@@ -0,0 +1,38 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createPanel } from './panel.js';
test('a Contacts row names its provider as the Layers panel does, with its feed state', () => {
const cohort = (id, source, sourceStatus) => ({
id,
label: id,
source,
sourceStatus,
summary: { relationship: 'OBSERVED', count: 1, nearest: [], reason: 'observed' },
});
const state = {
enabled: true,
results: {
subject: { label: 'AFR38JF' },
cohorts: [
cohort('flights', 'adsb.lol', 'fallback'),
cohort('military', 'adsb.lol', null),
cohort('ais-live-vessels', 'Datalastic', 'stale'),
],
},
cohortPages: new Map(),
panelListeners: new Set(),
panelSignature: '',
navigationIndex: 0,
};
const parts = {
navigation: { canNavigateNext: () => false },
rendering: { hideDirectionOverlay() {} },
};
createPanel({ state, parts }).renderResults();
assert.deepEqual(
state.panelView.rows.map((row) => row.detail),
['adsb.lol · FALLBACK · observed', 'adsb.lol · observed', 'Datalastic · STALE · observed'],
);
state.offPanelLocale?.();
});
+1
View File
@@ -91,6 +91,7 @@ export function createQueries({ state: layerState, services, parts, source }) {
stats.count ?? null,
stats.status || null,
stats.error || null,
stats.source || null,
];
})
.map((parts) => parts.join(':'))
+26
View File
@@ -81,3 +81,29 @@ test('Contacts excludes square corners and measures from the current moving subj
assert.equal(moved.reason, 'mapped matches within 100 km of the subject');
assert.deepEqual(installationState.contextAnchor, { latitude: 0, longitude: 0 });
});
test('Contacts cohorts carry the provider and whether it answered as a fallback or stale', () => {
const services = { installations: { getNearby: () => [], getStats: () => ({}) },
flights: { getNearby: () => [] }, military: { getNearby: () => [] }, vessels: { getNearby: () => [] } };
const parts = { navigation: { summarizeAwarenessCohortForNavigation: (items, source) =>
summarizeAwarenessCohort(items, source) } };
parts.queries = createQueries({ state: {}, services, parts });
const subjectOwner = createSubject({ state: {}, services, parts });
const states = {
// The OpenSky proxy answers from adsb.lol when OpenSky fails.
flights: { available: true, stats: { source: 'adsb.lol', coverage: '250nm regional fallback', count: 4, lastUpdate: 1 } },
military: { available: true, stats: { source: 'adsb.lol', fallback: false, count: 2, lastUpdate: 1 } },
'ais-live-vessels': { available: true, stats: { source: 'Datalastic', stale: true, count: 3, lastUpdate: 1 } },
'military-installations': { available: true, stats: { coverage: { kind: 'subject', radiusM: 100000 } } },
};
const { cohorts } = subjectOwner.evaluateSubject({
id: 'subject', layerId: 'flights', position: Cesium.Cartesian3.fromDegrees(0, 0, 10000),
}, states);
const byId = Object.fromEntries(cohorts.map((cohort) => [cohort.id, cohort]));
assert.equal(byId.flights.source, 'adsb.lol');
assert.equal(byId.flights.sourceStatus, 'fallback');
assert.equal(byId.military.sourceStatus, null);
assert.equal(byId['ais-live-vessels'].source, 'Datalastic');
assert.equal(byId['ais-live-vessels'].sourceStatus, 'stale');
assert.equal(byId['military-installations'].sourceStatus, null);
});
@@ -0,0 +1,159 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createQueries } from './queries.js';
import { createSubject } from './subject.js';
import { createModel } from './model.js';
import { summarizeAwarenessCohort } from '../../data/militaryAwarenessEngine.js';
import { layerFeedState } from '../../data/feedState.js';
import aisLiveVesselsLayer, {
_setVesselStateForTest,
_beginAisSessionForTest,
_applyAisFeedSnapshotForTest,
_setAisRuntimeForTest,
} from '../../data/aisLiveVessels.js';
test('a poll that returns the same stale AIS snapshot restores STALE on Contacts cohorts', () => {
globalThis.document ??= { body: null };
const services = {
installations: { getNearby: () => [], getStats: () => ({}) },
flights: { getNearby: () => [] },
military: { getNearby: () => [] },
vessels: { getNearby: () => [] },
};
const layerState = {
enabled: true,
passive: true,
sourceRevision: '',
cohortPages: new Map(),
subject: {
id: 'site-1',
layerId: 'military-installations',
label: 'Site',
position: Cesium.Cartesian3.fromDegrees(0, 0, 0),
},
};
let states;
const parts = {
navigation: {
summarizeAwarenessCohortForNavigation: (items, source) =>
summarizeAwarenessCohort(items, source),
isFlightLayer: (id) => id === 'flights' || id === 'military',
},
rendering: { renderVisual() {}, scheduleDirectionOverlayUpdate() {} },
panel: { renderResults() {} },
};
const realQueries = createQueries({ state: layerState, services, parts });
parts.queries = { ...realQueries, collectSourceStates: () => states };
parts.model = createModel({ state: layerState, services, parts });
const subject = createSubject({ state: layerState, services, parts });
const flights = (lastUpdate) => ({
available: true, enabled: true, stale: false,
stats: { source: 'OpenSky Network', count: 4, lastUpdate },
});
const vessels = (loading) => ({
available: true, enabled: true, stale: true,
stats: { source: 'Datalastic', stale: true, count: 3, lastUpdate: 1000, loading },
});
const rest = {
military: { available: true, enabled: true, stale: false, stats: { source: 'adsb.lol', fallback: false, count: 1, lastUpdate: 1 } },
'military-installations': { available: true, enabled: true, stale: false, stats: { coverage: { kind: 'subject', radiusM: 100000 } } },
};
const ais = () => layerState.results.cohorts.find((c) => c.id === 'ais-live-vessels');
// 1. Stale AIS cache, nothing loading.
states = { flights: flights(1), 'ais-live-vessels': vessels(false), ...rest };
subject.refreshSelectedSubject(true);
assert.equal(ais().sourceStatus, 'stale');
// 2. AIS re-poll in flight while the flights poll lands (revision changes).
states = { flights: flights(2), 'ais-live-vessels': vessels(true), ...rest };
subject.refreshSelectedSubject();
assert.equal(ais().sourceStatus, 'stale', 'a poll in flight keeps the snapshot stale');
// 3. AIS poll returns the same stale snapshot; only loading flips back.
states = { flights: flights(2), 'ais-live-vessels': vessels(false), ...rest };
subject.refreshSelectedSubject();
subject.refreshSelectedSubject();
assert.equal(ais().sourceStatus, 'stale', 'Layers reads STALE again; Contacts/Cockpit must too');
});
function fakeRuntime() {
return {
now: () => 1000,
setTimeout: () => ({}),
clearTimeout() {},
};
}
test('first AIS connect (no snapshot yet) carries no STALE on the Contacts cohort', () => {
globalThis.document ??= { body: null };
_setAisRuntimeForTest(fakeRuntime());
_setVesselStateForTest({ viewer: {}, records: [] });
try {
_beginAisSessionForTest();
// What server/providers/vessels/ais-live.js answers while its socket connects.
_applyAisFeedSnapshotForTest({}, {
rows: [], source: 'AISStream', status: 'connecting', refreshing: true,
});
const vesselStats = aisLiveVesselsLayer.getStats();
assert.equal(vesselStats.loading, true);
assert.equal(vesselStats.stale, true);
assert.equal(vesselStats.lastUpdate, null);
assert.equal(layerFeedState(vesselStats), 'loading', 'Layers chip reads LOADING');
const flightsStats = { source: 'OpenSky Network', count: 2, lastUpdate: 5 };
const militaryStats = { source: 'adsb.lol', fallback: false, count: 1, lastUpdate: 5 };
const installStats = { coverage: { kind: 'subject', radiusM: 100000 } };
const statsById = {
flights: flightsStats,
military: militaryStats,
'ais-live-vessels': vesselStats,
'military-installations': installStats,
};
const services = {
installations: { getNearby: () => [], getStats: () => installStats },
flights: { getNearby: () => [] },
military: { getNearby: () => [] },
vessels: aisLiveVesselsLayer,
};
const layerState = {
enabled: true,
passive: true,
sourceRevision: '',
cohortPages: new Map(),
dataManager: {
isEnabled: () => true,
getLayerLifecycleState: () => ({ enabled: true, lifecycleState: 'enabled' }),
layers: new Map(Object.entries(statsById).map(([id, s]) => [id, { module: { getStats: () => s } }])),
},
subject: {
id: 'site-1',
layerId: 'military-installations',
label: 'Site',
position: Cesium.Cartesian3.fromDegrees(0, 0, 0),
},
};
const parts = {
navigation: {
summarizeAwarenessCohortForNavigation: (items, source) => summarizeAwarenessCohort(items, source),
isFlightLayer: (id) => id === 'flights' || id === 'military',
},
rendering: { renderVisual() {}, scheduleDirectionOverlayUpdate() {} },
panel: { renderResults() {} },
};
parts.queries = createQueries({ state: layerState, services, parts });
parts.model = createModel({ state: layerState, services, parts });
const subject = createSubject({ state: layerState, services, parts });
const vesselsSource = parts.queries.collectSourceStates()['ais-live-vessels'];
assert.equal(vesselsSource.available, false, 'never-answered source is unavailable');
subject.refreshSelectedSubject(true);
const ais = layerState.results.cohorts.find((c) => c.id === 'ais-live-vessels');
assert.equal(ais.sourceStatus, null, 'no snapshot yet: no STALE label, Layers says LOADING');
} finally {
_setVesselStateForTest({ enabled: false });
_setAisRuntimeForTest();
}
});
+17
View File
@@ -3,6 +3,7 @@ import {
AWARENESS_RELATIONSHIP,
} from '../../data/militaryAwarenessEngine.js';
import * as Cesium from 'cesium';
import { layerFeedState } from '../../data/feedState.js';
import { t } from '../../i18n/index.js';
import {
AWARENESS_QUERY_LIMIT,
@@ -52,6 +53,18 @@ export function createSubject({ state: layerState, services, parts, source }) {
const vesselsState = sourceStates['ais-live-vessels'];
const installationsState = sourceStates['military-installations'];
const evaluatedAt = Date.now();
// A poll in flight keeps FALLBACK or STALE: the revision key ignores loading.
// Before the first answer there is no snapshot, so loading stays.
const sourceStatus = (sourceState) => {
const feedState = layerFeedState(
sourceState.available
? { ...sourceState.stats, loading: false }
: sourceState.stats,
);
return feedState === 'fallback' || feedState === 'stale'
? feedState
: null;
};
const retainedProvenance = (id, label, sourceState, fallbackSource) => {
const retainedSource = sourceState.stats.source || fallbackSource;
return {
@@ -94,6 +107,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
id: 'flights',
label: t('Flights'),
source: flightsState.stats.source || SOURCE_LABEL.flights,
sourceStatus: sourceStatus(flightsState),
provenance: retainedProvenance(
'flights',
'Flights',
@@ -113,6 +127,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
id: 'military',
label: t('Military flights'),
source: militaryState.stats.source || SOURCE_LABEL.military,
sourceStatus: sourceStatus(militaryState),
provenance: retainedProvenance(
'military',
'Military flights',
@@ -132,6 +147,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
id: 'ais-live-vessels',
label: t('AIS vessels'),
source: vesselsState.stats.source || SOURCE_LABEL['ais-live-vessels'],
sourceStatus: sourceStatus(vesselsState),
provenance: retainedProvenance(
'ais-live-vessels',
'AIS vessels',
@@ -149,6 +165,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
source:
installationsState.stats.source ||
SOURCE_LABEL['military-installations'],
sourceStatus: sourceStatus(installationsState),
provenance: retainedProvenance(
'military-installations',
'Mapped installations',
+12
View File
@@ -409,3 +409,15 @@ test('empty coverage cannot hide stale, incomplete or rejected source data', asy
assert.ok(layer.getStats().error, JSON.stringify(patch));
}
});
test('vessel stats name the provider the last snapshot reported', async () => {
const { layer } = setup({
label: 'Vessels',
async getSnapshot() {
return snapshot([observation('111')], { source: 'Datalastic' });
},
});
assert.equal(layer.getStats().source, 'Vessels');
await layer.update();
assert.equal(layer.getStats().source, 'Datalastic');
});
+2
View File
@@ -548,6 +548,8 @@ export function createQueries({
// backing off, say how long until the next attempt instead of leaving
// the user to guess whether anything is still happening.
retryInSec: aisRetryInSec(),
// Same field as flights and military, so Contacts names the provider.
source: layer.source,
};
},
};
+2
View File
@@ -1,6 +1,7 @@
/** Contact readouts and nearby-signal presentation for the Cockpit controller. */
import * as Cesium from 'cesium';
import { formatNumber, t, tn } from '../i18n/index.js';
import { sourceStatusLabel } from './layerNames.js';
import { unitLabel } from './unitLabels.js';
import {
bearingBetweenCoordinates,
@@ -110,6 +111,7 @@ export function updateContext(info, heading) {
if (shown) {
shown.count = cohort.count === null ? '?' : String(cohort.count);
shown.unknown = cohort.relationship === 'UNKNOWN';
shown.source = sourceStatusLabel(cohort.source, cohort.sourceStatus);
}
if (cohort.count === null) unknownCount += 1;
for (const item of cohort.nearest) nearest.push({ ...item, cohort });
+44
View File
@@ -96,6 +96,13 @@ test('before the controller presents, the card shows the state it starts in', (t
),
['—', '—', '—', '—'],
);
// No provider is named before a snapshot reports one.
assert.deepEqual(
[...document.querySelectorAll('[data-context-cohort] small')].map(
(source) => source.textContent,
),
['—', '—', '—', '—'],
);
assert.equal(text('#cockpit-context-nearest-label'), 'NO AVAILABLE EXAMPLE');
assert.equal(
$('#cockpit-context-nearest-label').hasAttribute('aria-label'),
@@ -159,6 +166,43 @@ test('a snapshot fills the card and keeps classes other code sets', async (t) =>
assert.equal(layouts.length, 1);
});
test('each count names the provider its snapshot reports, with its feed state', async (t) => {
const reported = snapshot();
reported.cohorts[0].source = 'adsb.lol';
reported.cohorts[0].sourceStatus = 'fallback';
reported.cohorts[1].source = 'Plane Finder';
reported.cohorts.push(
{
id: 'ais-live-vessels',
label: 'AIS vessels',
source: 'Datalastic',
sourceStatus: 'stale',
count: 3,
relationship: 'OBSERVED',
nearest: [],
},
{
id: 'military-installations',
label: 'Mapped installations',
source: 'OpenStreetMap',
sourceStatus: null,
count: 0,
relationship: 'MAPPED',
nearest: [],
},
);
const { $, owner } = fixture(t, reported);
updateContext.call(owner, INFO, 0);
await nextTick();
const source = (id) => $(`[data-context-cohort="${id}"] small`);
assert.equal(source('flights').textContent, 'adsb.lol · FALLBACK');
assert.equal(source('military').textContent, 'Plane Finder');
assert.equal(source('ais-live-vessels').textContent, 'Datalastic · STALE');
assert.equal(source('military-installations').textContent, 'OpenStreetMap');
// The tile ellipsizes; the full name stays readable on hover.
assert.equal(source('flights').title, 'adsb.lol · FALLBACK');
});
test('a lost contact holds its readout behind the CONTACT LOST cue', async (t) => {
const { $, text, owner, layouts } = fixture(t, snapshot());
updateContext.call(owner, INFO, 0);
+4 -1
View File
@@ -25,7 +25,10 @@ export function createContextState() {
canPrevious: true,
canNext: true,
cohorts: Object.fromEntries(
COCKPIT_CONTEXT_COHORTS.map((id) => [id, { count: '—', unknown: false }]),
COCKPIT_CONTEXT_COHORTS.map((id) => [
id,
{ count: '—', unknown: false, source: '—' },
]),
),
nearest: { label: t('NO AVAILABLE EXAMPLE'), ariaLabel: null },
distance: { text: '—', ariaLabel: null },
+14
View File
@@ -141,3 +141,17 @@ export function sourceLabel(source) {
? source.split(' · ').map(translateKnown).join(' · ')
: translateKnown(source);
}
/**
* A provider name with the feed state it answered in, so a fallback or stale
* snapshot does not read as the live primary feed.
* @param {string} source English source name.
* @param {'fallback'|'stale'|null} status Feed state from `layerFeedState`.
* @returns {string}
*/
export function sourceStatusLabel(source, status) {
const name = sourceLabel(source);
if (status === 'fallback') return `${name} · ${t('FALLBACK')}`;
if (status === 'stale') return `${name} · ${t('STALE')}`;
return name;
}
+17
View File
@@ -7,6 +7,7 @@ import {
LAYER_NAME_KEYS,
PANEL_LABEL_KEYS,
sourceLabel,
sourceStatusLabel,
} from './layerNames.js';
// Layers whose module name is a proper name and stays as it is in every
@@ -51,3 +52,19 @@ for (const locale of ['es', 'ru']) {
}
});
}
test('a provider name carries the fallback or stale state it answered in', () => {
assert.equal(sourceStatusLabel('Plane Finder', null), 'Plane Finder');
assert.equal(sourceStatusLabel('adsb.lol', 'fallback'), 'adsb.lol · FALLBACK');
assert.equal(sourceStatusLabel('OpenSky Network', 'stale'), 'OpenSky Network · STALE');
setLocale('ru');
try {
assert.equal(
sourceStatusLabel('adsb.lol', 'fallback'),
`adsb.lol · ${t('FALLBACK')}`,
);
assert.notEqual(t('FALLBACK'), 'FALLBACK');
} finally {
setLocale('en');
}
});
+4 -4
View File
@@ -49,10 +49,10 @@ const weatherLabel = computed(() =>
<span class="cockpit-context-qualifier">{{ t('CONTEXT ONLY') }}</span>
</header>
<div class="cockpit-context-cohorts" role="list" :aria-label="t('Nearby cohort counts')">
<div v-class-toggle="{ unknown: c.cohorts.flights.unknown }" role="listitem" data-context-cohort="flights"><span>{{ t('FLT') }}</span><strong>{{ c.cohorts.flights.count }}</strong><small translate="no">OpenSky</small></div>
<div v-class-toggle="{ unknown: c.cohorts.military.unknown }" role="listitem" data-context-cohort="military"><span>{{ t('MIL') }}</span><strong>{{ c.cohorts.military.count }}</strong><small translate="no">adsb.lol</small></div>
<div v-class-toggle="{ unknown: c.cohorts['ais-live-vessels'].unknown }" role="listitem" data-context-cohort="ais-live-vessels"><span translate="no">AIS</span><strong>{{ c.cohorts['ais-live-vessels'].count }}</strong><small translate="no">AISStream</small></div>
<div v-class-toggle="{ unknown: c.cohorts['military-installations'].unknown }" role="listitem" data-context-cohort="military-installations"><span>{{ t('SITE') }}</span><strong>{{ c.cohorts['military-installations'].count }}</strong><small translate="no">OpenStreetMap</small></div>
<div v-class-toggle="{ unknown: c.cohorts.flights.unknown }" role="listitem" data-context-cohort="flights"><span>{{ t('FLT') }}</span><strong>{{ c.cohorts.flights.count }}</strong><small translate="no" :title="c.cohorts.flights.source">{{ c.cohorts.flights.source }}</small></div>
<div v-class-toggle="{ unknown: c.cohorts.military.unknown }" role="listitem" data-context-cohort="military"><span>{{ t('MIL') }}</span><strong>{{ c.cohorts.military.count }}</strong><small translate="no" :title="c.cohorts.military.source">{{ c.cohorts.military.source }}</small></div>
<div v-class-toggle="{ unknown: c.cohorts['ais-live-vessels'].unknown }" role="listitem" data-context-cohort="ais-live-vessels"><span translate="no">AIS</span><strong>{{ c.cohorts['ais-live-vessels'].count }}</strong><small translate="no" :title="c.cohorts['ais-live-vessels'].source">{{ c.cohorts['ais-live-vessels'].source }}</small></div>
<div v-class-toggle="{ unknown: c.cohorts['military-installations'].unknown }" role="listitem" data-context-cohort="military-installations"><span>{{ t('SITE') }}</span><strong>{{ c.cohorts['military-installations'].count }}</strong><small translate="no" :title="c.cohorts['military-installations'].source">{{ c.cohorts['military-installations'].source }}</small></div>
</div>
<div class="cockpit-context-nearest">
<span id="cockpit-context-direction" v-class-toggle="{ unknown: c.direction.unknown }" class="material-symbols-outlined" :style="directionStyle" aria-hidden="true">navigation</span>