diff --git a/src/data/militaryAwarenessEngine.js b/src/data/militaryAwarenessEngine.js index 136e9d2fa..d5c2c7fc2 100644 --- a/src/data/militaryAwarenessEngine.js +++ b/src/data/militaryAwarenessEngine.js @@ -14,6 +14,21 @@ export const AWARENESS_RELATIONSHIP = Object.freeze({ UNKNOWN: 'UNKNOWN', }); +/** The identity a nearby item is navigated by. */ +function awarenessItemId(item) { + return String(item?.icao24 || item?.mmsi || item?.id || ''); +} + +/** + * Break exact distance ties by identity, so the same contacts come back in the + * same order whatever order a feed (or a rebuilt scene) listed them in. + */ +export function compareAwarenessIds(a, b) { + const left = String(a ?? ''); + const right = String(b ?? ''); + return left < right ? -1 : left > right ? 1 : 0; +} + /** * Search an expanding area synchronously, doubling the radius after each miss. * The maximum covers the farthest possible Earth-surface chord while keeping @@ -110,7 +125,11 @@ export function summarizeAwarenessCohort( (item.distanceM ?? item.distance) >= 0, ) .map((item) => ({ ...item, distanceM: item.distanceM ?? item.distance })) - .sort((a, b) => a.distanceM - b.distanceM); + .sort( + (a, b) => + a.distanceM - b.distanceM || + compareAwarenessIds(awarenessItemId(a), awarenessItemId(b)), + ); return { relationship: normalized.length ? AWARENESS_RELATIONSHIP.NEARBY @@ -177,7 +196,11 @@ export function getAwarenessNavigationTargets( Number(a.layerId !== subject?.layerId) - Number(b.layerId !== subject?.layerId); if (subjectOrder) return subjectOrder; - return (a.item.distanceM ?? Infinity) - (b.item.distanceM ?? Infinity); + return ( + (a.item.distanceM ?? Infinity) - (b.item.distanceM ?? Infinity) || + compareAwarenessIds(a.layerId, b.layerId) || + compareAwarenessIds(a.id, b.id) + ); }); return targets; } diff --git a/src/layers/awareness/edgeCases.test.mjs b/src/layers/awareness/edgeCases.test.mjs new file mode 100644 index 000000000..a7014b9c0 --- /dev/null +++ b/src/layers/awareness/edgeCases.test.mjs @@ -0,0 +1,166 @@ +// Edge cases for military-awareness from docs/migration/planet-separation/layers.md: +// with no scene the same nearest contacts come back, and NEXT/PREV, a type +// filter and an exhausted list lead to the same ID whatever scene is attached +// and whatever order the feeds list equally distant contacts in. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as geo from '../../geo/index.js'; +import { summarizeAwarenessCohort } from '../../data/militaryAwarenessEngine.js'; +import { createState } from './state.js'; +import { createNavigation } from './navigation.js'; +import { createHistory } from './history.js'; +import { createFocus } from './focus.js'; + +const at = (lon) => geo.cartesian3.fromDegrees(lon, 30, 3_000); + +// Two pairs of exact distance ties: f2/f3 inside one layer, f1/m1 across layers. +const CONTACTS = { + flights: [ + { icao24: 'f1', distanceM: 1_500, klass: 'widebody', position: at(0.01) }, + { icao24: 'f2', distanceM: 4_000, klass: 'helicopter', position: at(0.02) }, + { icao24: 'f3', distanceM: 4_000, klass: 'helicopter', position: at(0.03) }, + ], + military: [ + { icao24: 'm1', distanceM: 1_500, klass: 'fighter', position: at(0.04) }, + { icao24: 'm2', distanceM: 9_000, klass: 'helicopter', position: at(0.05) }, + ], + 'ais-live-vessels': [ + { mmsi: '366000001', distanceM: 800, position: at(0.06) }, + ], +}; +const SUBJECT = { layerId: 'military', id: 'm0', label: 'M0', position: at(0) }; + +/** The real navigation, history and focus parts over one Contacts state. */ +function contacts({ reversed = false, display = null } = {}) { + const order = (items) => (reversed ? [...items].reverse() : [...items]); + const state = createState({ services: {} }); + state.enabled = true; + state.display = display; + const parts = {}; + const select = (layerId, id) => { + const item = CONTACTS[layerId].find( + (entry) => String(entry.icao24 || entry.mmsi) === String(id), + ); + if (!item) return false; + parts.subject.selectSubject({ + layerId, + id: String(id), + label: String(id), + position: item.position, + }); + return true; + }; + const aircraftLayer = (layerId) => ({ + getNearby: (_center, radiusM, limit) => + order(CONTACTS[layerId]) + .filter((item) => item.distanceM <= radiusM) + .slice(0, limit), + trackById: (id) => select(layerId, id), + }); + const services = { + flights: aircraftLayer('flights'), + military: aircraftLayer('military'), + vessels: { + selectById: (id) => select('ais-live-vessels', id), + getAllPositions: () => [], + }, + installations: { focusById: () => false }, + navigation: { announceNavigationAuthority() {} }, + }; + const context = { state, services, parts }; + parts.navigation = createNavigation(context); + parts.history = createHistory(context); + parts.focus = createFocus(context); + parts.model = { + contextTargetFlyToAllowed: () => true, + releaseAircraftTracking() {}, + }; + // The bookkeeping subject.js does on a selection (subject.selectSubject), + // without the panel and refresh it also drives. + parts.subject = { + subjectKey: (subject) => + subject ? `${subject.layerId}:${subject.id}` : '', + subjectCohortFeedUnknown: () => false, + selectSubject(subject) { + const key = parts.subject.subjectKey(subject); + state.navigationVisited.add(key); + const suppress = state.suppressedHistoryKey === key; + state.pendingSelectionKey = state.suppressedHistoryKey = null; + const current = state.navigationHistory[state.navigationIndex]; + if (!suppress && parts.subject.subjectKey(current) !== key) { + state.navigationHistory.splice(state.navigationIndex + 1); + state.navigationHistory.push( + parts.history.historySubjectSnapshot( + subject, + parts.history.historySourceItem(subject), + ), + ); + state.navigationIndex = state.navigationHistory.length - 1; + } + state.subject = subject; + }, + }; + state.results = { + cohorts: order(Object.keys(CONTACTS)).map((id) => ({ + id, + summary: parts.navigation.summarizeAwarenessCohortForNavigation( + order(CONTACTS[id]), + { available: true }, + ), + })), + }; + parts.subject.selectSubject(SUBJECT); + return { state, controls: parts.history }; +} + +function walk(session) { + const key = () => + `${session.state.subject.layerId}:${session.state.subject.id}`; + const steps = []; + const step = (label, direction, options) => { + const moved = session.controls.navigateHistory(direction, options); + steps.push(`${label}${moved ? '' : '!'} ${key()}`); + }; + // Walk past the end of the cohort so the visited cycle is exhausted. + for (let i = 0; i < 8; i += 1) step('next', 1); + step('prev', -1); + step('prev', -1); + for (let i = 0; i < 4; i += 1) + step('next-helicopter', 1, { aircraftClass: 'helicopter' }); + for (let i = 0; i < 3; i += 1) + step('next-military', 1, { targetLayer: 'military' }); + return steps; +} + +test('edge:military-awareness: nearest contacts are the same with no scene and in any feed order', () => { + for (const id of Object.keys(CONTACTS)) { + const forward = summarizeAwarenessCohort(CONTACTS[id], { limit: 3 }); + const backward = summarizeAwarenessCohort([...CONTACTS[id]].reverse(), { + limit: 3, + }); + assert.deepEqual( + backward.nearest.map((item) => item.icao24 || item.mmsi), + forward.nearest.map((item) => item.icao24 || item.mmsi), + id, + ); + } +}); + +test('edge:military-awareness: NEXT, PREV, a type filter and exhaustion reach the same IDs on any scene', () => { + const sceneless = walk(contacts()); + const attached = walk( + contacts({ display: { camera: { positionWC: at(1) } } }), + ); + const reordered = walk(contacts({ reversed: true })); + assert.deepEqual(attached, sceneless); + assert.deepEqual(reordered, sceneless); + // Every contact is reached before the cycle starts over. + const firstLap = sceneless.slice(0, 6).map((entry) => entry.split(' ')[1]); + assert.equal(new Set(firstLap).size, 6, firstLap.join(', ')); + assert.ok( + sceneless + .filter((entry) => entry.startsWith('next-helicopter')) + .every((entry) => /:(f2|f3|m2)$/.test(entry)), + 'the type filter only lands on helicopters', + ); +}); diff --git a/src/layers/awareness/navigation.js b/src/layers/awareness/navigation.js index 8882cc7cf..f8a8eb404 100644 --- a/src/layers/awareness/navigation.js +++ b/src/layers/awareness/navigation.js @@ -4,6 +4,7 @@ import { AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M, findByDoublingRadius, AWARENESS_RADIUS_M, + compareAwarenessIds, } from '../../data/militaryAwarenessEngine.js'; import { AWARENESS_MAX_EXAMPLES, @@ -190,7 +191,9 @@ export function createNavigation({ candidates.sort( (a, b) => (a.item.distanceM ?? a.item.distance ?? Infinity) - - (b.item.distanceM ?? b.item.distance ?? Infinity), + (b.item.distanceM ?? b.item.distance ?? Infinity) || + compareAwarenessIds(a.layerId, b.layerId) || + compareAwarenessIds(a.id, b.id), ); return candidates[0] || null; }