diff --git a/docs/APPLICATION.md b/docs/APPLICATION.md index 2c166603d..17579a19c 100644 --- a/docs/APPLICATION.md +++ b/docs/APPLICATION.md @@ -317,11 +317,12 @@ Rail and Transit read APIs (`getSelectedTrain`, `getSelectedVehicle`, are experimental and may change without notice. A candidate's `selectedAt` is a token that changes each time the user selects -the subject, even the same one again, so entry counts it as selected then. The -Rail and Transit snapshots give one, `selectedAtMs`: when the user last -selected the train or vehicle by a click or `focusTrain`/`focusVehicle`. It is -null after a deselection and for one selected only by a hold, since a hold is -not the user's selection. +the subject, even the same one again, so entry counts it as selected then; a +new `id` under the same token is the same selection (a train's selection +moving to another feed's copy of it). The Rail and Transit snapshots give one, +`selectedAtMs`: when the user last selected the train or vehicle by a click or +`focusTrain`/`focusVehicle`. It is null after a deselection and for one +selected only by a hold, since a hold is not the user's selection. A frame with `heightM: null` stands on the shared floor, or where it last stood while that floor is cold. If no height has been known since entry, the diff --git a/src/layers/rail/cockpitSubject.js b/src/layers/rail/cockpitSubject.js index d5da933ec..762ff9aa6 100644 --- a/src/layers/rail/cockpitSubject.js +++ b/src/layers/rail/cockpitSubject.js @@ -29,14 +29,39 @@ const NO_STOPS = Object.freeze([]); /** Each stops list's frozen copy for a snapshot's `stops` (stopsOf). */ const stopLists = new WeakMap(); +/** Whether `copy` still holds each of `list`'s stops, field by field. */ +function sameStops(copy, list) { + if (copy.length !== list.length) return false; + for (let i = 0; i < list.length; i++) { + const stop = list[i]; + const kept = copy[i]; + if ( + !stop || + typeof stop !== 'object' || + !kept || + typeof kept !== 'object' + ) { + if (!Object.is(stop, kept)) return false; + continue; + } + const names = Object.keys(stop); + if (names.length !== Object.keys(kept).length) return false; + for (const name of names) + if (!Object.is(stop[name], kept[name])) return false; + } + return true; +} + /** * A trip's stops as a snapshot hands them out: a frozen array of frozen - * copies, made once per list the stops provider returns. + * copies, kept per list the stops provider returns while that list still + * holds the same stops, so a provider that updates its list in place is + * copied again. */ function stopsOf(list) { if (!Array.isArray(list)) return NO_STOPS; let stops = stopLists.get(list); - if (!stops) { + if (!stops || !sameStops(stops, list)) { stops = Object.freeze( list.map((stop) => stop && typeof stop === 'object' ? Object.freeze({ ...stop }) : stop, @@ -258,7 +283,9 @@ export function createRailCockpitSubject({ * Hold the train `key` for a first-person view, or release the hold with * null. A train it holds is selected, if it was not already. * @param {string|null} key - * @returns {boolean} False for a train the layer does not show. + * @returns {boolean} False for a key the layer does not list. A listed + * train is held even when its category is off or another feed's copy + * is drawn: the held copy is then the one drawn. Releasing returns true. */ setCockpitSubject(key) { const previous = state._trains.get(state._cockpitSubjectKey); diff --git a/src/layers/rail/cockpitSubject.test.mjs b/src/layers/rail/cockpitSubject.test.mjs index a23ec3bc3..172cf3b56 100644 --- a/src/layers/rail/cockpitSubject.test.mjs +++ b/src/layers/rail/cockpitSubject.test.mjs @@ -158,6 +158,13 @@ test('a snapshot read again while nothing changed is the one read before', async later.stops.map((stop) => stop.name), ['Delta', 'Echo'], ); + // A provider that updates its list in place: the stops are copied again. + list[0].state = 'passed'; + app.run(STOP_REFRESH_MS); + const updated = app.layer.getSelectedTrain(); + assert.notEqual(updated.stops, later.stops); + assert.equal(updated.stops[0].state, 'passed'); + assert.equal(app.layer.getSelectedTrain().stops, updated.stops); }); test('a snapshot’s line is a frozen copy: writing to it leaves the layer’s line', async (t) => { diff --git a/src/ui/cockpitSubjectView.test.mjs b/src/ui/cockpitSubjectView.test.mjs index 59ec07dc9..e988fd80e 100644 --- a/src/ui/cockpitSubjectView.test.mjs +++ b/src/ui/cockpitSubjectView.test.mjs @@ -1049,6 +1049,41 @@ test('the built-in kinds keep their order; a subject takes entry only when selec assert.equal(cockpit.resolveEntryKind(), 'aircraft'); }); +test('a new id under the same selectedAt is not a new selection', async (t) => { + const clock = manualClock(t, 10_000); + const ride = subjectProvider(); + ride.subject.candidate = { id: 'a1', name: 'Example 12', selectedAt: 1 }; + const { cockpit, trackAircraft } = fixture(t, { providers: [ride] }); + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'example-kind'); + // An aircraft tracked after it takes entry. + clock.advance(500); + trackAircraft(TRACKED_AIRCRAFT); + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'aircraft'); + // The selection moves to another copy of the same subject: same token. + clock.advance(500); + ride.subject.candidate = { id: 'b1', name: 'Example 12', selectedAt: 1 }; + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'aircraft'); + // A new token is a new selection. + clock.advance(500); + ride.subject.candidate = { id: 'b1', name: 'Example 12', selectedAt: 2 }; + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'example-kind'); + // Without a token, a new id is a new selection. + clock.advance(500); + trackAircraft(null); + cockpit.syncEntry(); + trackAircraft(TRACKED_AIRCRAFT); + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'aircraft'); + clock.advance(500); + ride.subject.candidate = { id: 'c1', name: 'Example 3' }; + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'example-kind'); +}); + test('a subject first seen together with a built-in one comes after it', async (t) => { manualClock(t, 10_000); const ride = subjectProvider(); diff --git a/src/ui/cockpitTrackingController.js b/src/ui/cockpitTrackingController.js index 7367c10f6..8327d5a07 100644 --- a/src/ui/cockpitTrackingController.js +++ b/src/ui/cockpitTrackingController.js @@ -110,7 +110,10 @@ const BUILT_IN_ENTRY_ORDER = Object.freeze({ * takes it back. Providers' candidates are ordered most recently selected * first, then by priority. A candidate's time is when this controller first * saw it (it looks on its entry cadence and on tracking changes), or saw it - * selected again (a provider candidate whose `selectedAt` changes). One + * selected again: another subject of a built-in kind, a provider candidate + * whose `selectedAt` changes, or one without a `selectedAt` whose id changes. + * A new id under the same `selectedAt` is the same selection, as when a + * train's selection moves to another feed's copy of it. One * first seen together with a built-in candidate comes after it. With no * provider, entry is the built-in order alone. Each carries `allowed()`, its * kind's entry gate. @@ -123,8 +126,16 @@ export function entryCandidates(nowMs = performance.now()) { const list = []; const note = (kind, id, entry, selectedAt = null) => { const last = seen.get(kind); - if (!last || last.id !== id || last.selectedAt !== selectedAt) + // A provider's token says when its subject was selected: a new id under + // the same token (a train handed to another feed's copy of it) is not a + // new selection. Without a token, a new id is. + if ( + !last || + last.selectedAt !== selectedAt || + (selectedAt === null && last.id !== id) + ) seen.set(kind, { id, selectedAt, atMs: nowMs }); + else if (last.id !== id) seen.set(kind, { ...last, id }); list.push({ kind, id, seenAtMs: seen.get(kind).atMs, ...entry }); }; const aircraft = this.readAircraftInfo();