fix(cockpit): a train handed to another feed's copy keeps its selection time, so it never takes entry back on its own; snapshot stops are copied again when a provider updates its list in place; setCockpitSubject's return is documented as it is

This commit is contained in:
Denis Groshev committed 2026-11-12 17:22:55 +01:00
1 parent 09f3380203
commit 0584292405
5 files changed
+91 -10

No files matched your search

+6 -5
View File
@@ -317,11 +317,12 @@ Rail and Transit read APIs (`getSelectedTrain`, `getSelectedVehicle`,
are experimental and may change without notice. are experimental and may change without notice.
A candidate's `selectedAt` is a token that changes each time the user selects 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 the subject, even the same one again, so entry counts it as selected then; a
Rail and Transit snapshots give one, `selectedAtMs`: when the user last new `id` under the same token is the same selection (a train's selection
selected the train or vehicle by a click or `focusTrain`/`focusVehicle`. It is moving to another feed's copy of it). The Rail and Transit snapshots give one,
null after a deselection and for one selected only by a hold, since a hold is `selectedAtMs`: when the user last selected the train or vehicle by a click or
not the user's selection. `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 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 stood while that floor is cold. If no height has been known since entry, the
+30 -3
View File
@@ -29,14 +29,39 @@ const NO_STOPS = Object.freeze([]);
/** Each stops list's frozen copy for a snapshot's `stops` (stopsOf). */ /** Each stops list's frozen copy for a snapshot's `stops` (stopsOf). */
const stopLists = new WeakMap(); 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 * 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) { function stopsOf(list) {
if (!Array.isArray(list)) return NO_STOPS; if (!Array.isArray(list)) return NO_STOPS;
let stops = stopLists.get(list); let stops = stopLists.get(list);
if (!stops) { if (!stops || !sameStops(stops, list)) {
stops = Object.freeze( stops = Object.freeze(
list.map((stop) => list.map((stop) =>
stop && typeof stop === 'object' ? Object.freeze({ ...stop }) : 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 * 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. * null. A train it holds is selected, if it was not already.
* @param {string|null} key * @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) { setCockpitSubject(key) {
const previous = state._trains.get(state._cockpitSubjectKey); const previous = state._trains.get(state._cockpitSubjectKey);
+7
View File
@@ -158,6 +158,13 @@ test('a snapshot read again while nothing changed is the one read before', async
later.stops.map((stop) => stop.name), later.stops.map((stop) => stop.name),
['Delta', 'Echo'], ['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) => { test('a snapshot’s line is a frozen copy: writing to it leaves the layer’s line', async (t) => {
+35
View File
@@ -1049,6 +1049,41 @@ test('the built-in kinds keep their order; a subject takes entry only when selec
assert.equal(cockpit.resolveEntryKind(), 'aircraft'); 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) => { test('a subject first seen together with a built-in one comes after it', async (t) => {
manualClock(t, 10_000); manualClock(t, 10_000);
const ride = subjectProvider(); const ride = subjectProvider();
+13 -2
View File
@@ -110,7 +110,10 @@ const BUILT_IN_ENTRY_ORDER = Object.freeze({
* takes it back. Providers' candidates are ordered most recently selected * takes it back. Providers' candidates are ordered most recently selected
* first, then by priority. A candidate's time is when this controller first * 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 * 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 * 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 * provider, entry is the built-in order alone. Each carries `allowed()`, its
* kind's entry gate. * kind's entry gate.
@@ -123,8 +126,16 @@ export function entryCandidates(nowMs = performance.now()) {
const list = []; const list = [];
const note = (kind, id, entry, selectedAt = null) => { const note = (kind, id, entry, selectedAt = null) => {
const last = seen.get(kind); 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 }); 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 }); list.push({ kind, id, seenAtMs: seen.get(kind).atMs, ...entry });
}; };
const aircraft = this.readAircraftInfo(); const aircraft = this.readAircraftInfo();