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.
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
+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). */
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);
+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),
['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) => {
+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');
});
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();
+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
* 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();