fix(cockpit): an application subject's wait for the floor is bounded, a late asynchronous hold is released, and a hold never counts as the user's selection

- A subject with no height at all waits at most
  COCKPIT_GROUND_WAIT_TIMEOUT_MS for the floor, its HUD updating and
  reading ACQUIRING SURFACE; then the view ends and the provider gets its
  subject back (there is no height of its own to fall back to).
- A hold that returns a promise is refused as before and released again
  once it settles, unless that subject is being ridden by then; release
  must tolerate a second call.
- Rail and Transit stamp selectedAtMs only for a user's selection (a click,
  focusTrain or focusVehicle); a deselection clears it, and a train or
  vehicle selected only by a hold reads null.
This commit is contained in:
Denis Groshev committed 2026-11-12 12:31:04 +01:00
1 parent 1e4060f6ba
commit 4c33552105
13 files changed
+239 -28

No files matched your search

+19 -5
View File
@@ -170,8 +170,10 @@ function refusal(provider, kinds) {
* - `candidate()`, `isEntryAllowed(candidate)` (optional), `hold(id)`,
* `read(nowMs, ctx)`, `hud(frame, ctx)` and `release(id, {restore})`, all
* synchronous (createCockpitSubjectCaller). `isEntryAllowed` allows entry
* only by returning `true`. A `hold` that throws is followed by
* `release(id, {restore: false})`.
* only by returning `true`. A `hold` that throws or returns a promise
* refuses entry and is followed by `release(id, {restore: false})`, and by
* another once that promise settles; `release` must therefore be harmless
* when called again for a subject it already gave back.
*
* A provider that is not so is left out with one warning naming it.
* @param {unknown} list
@@ -501,7 +503,9 @@ export function cockpitSubjectHudView(raw) {
* Provider methods are synchronous: the Cockpit calls them on its render
* cadence and uses the answer at once. One that returns a promise (an async
* function, say) breaks that contract: the promise is refused as a throw
* would be (its rejection, if any, is swallowed), with one warning.
* would be (its rejection, if any, is swallowed), with one warning, and
* `onSettled`, when given, runs once the promise has settled, so a caller
* can undo work that lands late.
* @param {{warn?: (message: string, error: unknown) => void}} [options]
*/
export function createCockpitSubjectCaller({
@@ -517,7 +521,13 @@ export function createCockpitSubjectCaller({
error,
);
};
return function call(provider, method, args = [], fallback = null) {
return function call(
provider,
method,
args = [],
fallback = null,
onSettled = null,
) {
let result;
try {
result = provider[method](...args);
@@ -530,7 +540,11 @@ export function createCockpitSubjectCaller({
(typeof result === 'object' || typeof result === 'function') &&
typeof result.then === 'function'
) {
Promise.resolve(result).catch(() => {});
// Its work may still land: `onSettled` runs once it has, either way.
const settled = () => {
if (typeof onSettled === 'function') onSettled();
};
Promise.resolve(result).then(settled, settled);
warnOnce(
provider,
method,
+8 -3
View File
@@ -17,8 +17,9 @@ import { displayMotion } from '../transit/movement.js';
* view that waits for it holds it again if the feed lists it again.
* `focusTrain(key)` selects a train and hands it the camera, as a click does.
* A snapshot's `selectedAtMs` is when the user last selected the selected
* train, even again (null for any other), for a view's entry to tell a
* re-selection.
* train (a click, or `focusTrain`), even again, for a view's entry to tell a
* re-selection. It is null for any other train, after a deselection, and for
* a train selected only by a hold: a hold is not a user's selection.
*
* @param {object} context The layer's state, parts, services and timetable.
* @returns {{methods: object}}
@@ -181,7 +182,11 @@ export function createRailCockpitSubject({
}
const entry = state._trains.get(key);
if (!entry) return false;
if (state._selectedKey !== key) parts.selection.selectTrain(key);
if (state._selectedKey !== key) {
parts.selection.selectTrain(key);
// A hold is not a selection the user made (selectedAtMs).
state._selectedAtMs = null;
}
state._cockpitSubjectKey = key;
state._cockpitLostFeedId = null;
if (previous && previous !== entry) parts.rendering.paint(previous);
+30
View File
@@ -196,6 +196,36 @@ test('a held train its feed drops leaves the feed polled until the view lets go'
assert.equal(app.state()._trains.size, 0);
});
test('selectedAtMs is the user’s selection time, never a hold’s or another train’s', async (t) => {
const app = await onTheLine(t);
assert.equal(app.layer.focusTrain(T1), true);
const first = app.layer.getSelectedTrain().selectedAtMs;
assert.ok(Number.isFinite(first), 'a click or focusTrain stamps it');
// Selected again: a later stamp.
app.run(1000);
app.layer.focusTrain(T1);
assert.ok(app.layer.getSelectedTrain().selectedAtMs > first);
// A hold of another train is not the user's selection.
app.layer.setCockpitSubject(T2);
assert.equal(app.layer.getCockpitSubject().key, T2);
assert.equal(app.layer.getCockpitSubject().selectedAtMs, null);
app.layer.setCockpitSubject(null);
// Nor after a deselection (Escape), then a hold.
app.layer.focusTrain(T1);
globalThis.document.dispatchEvent(
Object.assign(new Event('keydown'), { key: 'Escape' }),
);
assert.equal(app.state()._selectedKey, null);
assert.equal(app.state()._selectedAtMs, null, 'deselection clears it');
app.layer.setCockpitSubject(T2);
assert.equal(app.layer.getCockpitSubject().selectedAtMs, null);
// Releasing twice is harmless: the selection stays.
assert.equal(app.layer.setCockpitSubject(null), true);
assert.equal(app.layer.setCockpitSubject(null), true);
assert.equal(app.state()._selectedKey, T2);
assert.equal(app.layer.getCockpitSubject(), null);
});
test('turning the layer off ends the hold', async (t) => {
const app = await onTheLine(t);
app.layer.setCockpitSubject(T1);
+1
View File
@@ -127,6 +127,7 @@ export function createSelection({ state, services, parts }) {
const hadSelection = state._selectedKey !== null;
parts.follow.release({ untrack });
state._selectedKey = null;
state._selectedAtMs = null;
state._selectedCardText = null;
if (hadSelection) {
parts.rendering.paintAll();
+5 -3
View File
@@ -17,8 +17,10 @@ import { displayMotion } from './movement.js';
* change the selection. The hold ends with `setCockpitSubject(null)`, and on
* its own when the vehicle is removed or the layer is turned off.
* `focusVehicle(key)` selects a vehicle, as a click does. A snapshot's
* `selectedAtMs` is when the user last selected the selected vehicle, even
* again (null for any other).
* `selectedAtMs` is when the user last selected the selected vehicle (a
* click, or `focusVehicle`), even again. It is null for any other vehicle,
* after a deselection, and for a vehicle selected only by a hold: a hold is
* not a user's selection.
*
* @param {object} context The layer's state, parts and services.
* @returns {{methods: object}}
@@ -165,7 +167,7 @@ export function createTransitCockpitSubject({ state, parts, services }) {
*/
focusVehicle(key) {
if (!state._vehicles.get(key)?.marker) return false;
parts.selection.selectVehicle(key);
parts.selection.selectVehicle(key, { byUser: true });
return true;
},
};
+26
View File
@@ -4196,6 +4196,32 @@ test('the selected vehicle reads as a snapshot of its place, motion and source',
assert.ok(Number.isFinite(view.position.latitude));
});
test('selectedAtMs is the user’s selection time; a hold and a deselection leave none', async (t) => {
const app = harness(t);
const key = await busRunningNorth(app);
// A vehicle only a hold selected reports none.
assert.equal(app.layer.setCockpitSubject(key), true);
assert.equal(app.layer.getCockpitSubject().selectedAtMs, null);
app.layer.setCockpitSubject(null);
assert.equal(app.layer.setCockpitSubject(null), true, 'twice is harmless');
assert.equal(app.state()._selectedKey, key);
// The user's selection stamps it, again on a re-selection.
assert.equal(app.layer.focusVehicle(key), true);
const first = app.layer.getSelectedVehicle().selectedAtMs;
assert.ok(Number.isFinite(first));
app.run(1000);
app.layer.focusVehicle(key);
assert.ok(app.layer.getSelectedVehicle().selectedAtMs > first);
// Deselected, then held: none.
globalThis.document.dispatchEvent(
Object.assign(new Event('keydown'), { key: 'Escape' }),
);
assert.equal(app.state()._selectedKey, null);
assert.equal(app.state()._selectedAtMs, null);
app.layer.setCockpitSubject(key);
assert.equal(app.layer.getCockpitSubject().selectedAtMs, null);
});
test('a held vehicle hides, keeps moving out of view, and lets go when it goes', async (t) => {
const app = harness(t);
const key = await busRunningNorth(app);
+11 -4
View File
@@ -97,6 +97,7 @@ export function createSelection({ state, services, parts }) {
: null;
parts.trails.clear();
state._selectedKey = null;
state._selectedAtMs = null;
state._selectedCardText = null;
state._detectRevision += 1;
// Cleared first: the styling path reads the selection from state.
@@ -104,12 +105,18 @@ export function createSelection({ state, services, parts }) {
state._overlayHost.clearSource(TRANSIT_SELECTED_OVERLAY_SOURCE_ID);
}
function selectVehicle(key) {
/**
* Select a vehicle. `byUser` marks a selection the user made (a click, or
* focusVehicle), even of the selected vehicle again: its time is the
* snapshot's `selectedAtMs` (cockpitSubject.js). A first-person view's hold
* selects without it.
*/
function selectVehicle(key, { byUser = false } = {}) {
clearSelection();
const entry = state._vehicles.get(key);
if (!entry?.marker) return;
state._selectedKey = key;
state._selectedAtMs = Date.now();
if (byUser) state._selectedAtMs = Date.now();
state._detectRevision += 1;
parts.rendering.paintSelected(entry, true);
parts.trails.select(entry);
@@ -190,11 +197,11 @@ export function createSelection({ state, services, parts }) {
typeof primitiveId === 'string' &&
state._vehicles.has(primitiveId)
) {
selectVehicle(primitiveId);
selectVehicle(primitiveId, { byUser: true });
return;
}
if (typeof picked.id === 'string' && state._vehicles.has(picked.id)) {
selectVehicle(picked.id);
selectVehicle(picked.id, { byUser: true });
return;
}
}
+24 -3
View File
@@ -349,12 +349,33 @@ function updateSubject(nowMs) {
}
if (!Number.isFinite(heightM)) {
// Nothing to stand on yet, from the first frame: the camera stays where
// it is while the floor is acquired, as the built-in views wait for the
// surface, rather than standing on the ellipsoid (kilometres underground
// on a plateau).
// it is while the floor is acquired, its HUD saying so, rather than
// standing on the ellipsoid (kilometres underground on a plateau). The
// wait is bounded as the built-in views' is. Unlike a parked aircraft,
// whose fallback is its own reported altitude, such a subject has no
// height at all to fall back to: any number would be a guess, so the
// view ends and the provider gets its subject back.
if (!this.surfaceWaitStartedMs) this.surfaceWaitStartedMs = nowMs;
if (
cockpitSurfaceWaitExpired(
nowMs,
this.surfaceWaitStartedMs,
COCKPIT_GROUND_WAIT_TIMEOUT_MS,
)
) {
this.exit({ restoreTracking: false });
return;
}
this.surfaceAcquiring = true;
if (
cockpitUiUpdateDue(nowMs, this.lastHudUpdateMs, COCKPIT_HUD_UPDATE_MS)
) {
this.lastHudUpdateMs = nowMs;
this.updateHud(this.subjectInfo(frame), nowMs);
}
return;
}
this.surfaceWaitStartedMs = 0;
this.surfaceAcquiring = false;
this.subjectHeightM = heightM;
const target = Cesium.Cartesian3.fromDegrees(
+6 -3
View File
@@ -183,9 +183,12 @@ function updateSubjectHud(info, nowMs, forceContext) {
view.trackSource = null;
view.kicker = hud.kicker || provider.labels.kicker;
view.callsign = hud.title || info.name || '';
view.meta.text = hud.meta.text;
view.meta.tone = hud.meta.tone;
view.meta.emergency = hud.meta.tone === 'alert';
// While the floor under a subject with no height is acquired
// (cockpitCamera.js), the meta line says so in place of the provider's.
const acquiring = this.surfaceAcquiring === true;
view.meta.text = acquiring ? t('ACQUIRING SURFACE') : hud.meta.text;
view.meta.tone = acquiring ? 'warning' : hud.meta.tone;
view.meta.emergency = !acquiring && hud.meta.tone === 'alert';
const speed = hud.speed?.value ?? null;
view.speed = hud.speed
? {
+68 -3
View File
@@ -15,6 +15,7 @@ import CockpitView from './views/CockpitView.vue';
import ContextView from './views/ContextView.vue';
import { cockpitSubjectProviders } from '../cockpitSubjectProviders.js';
import { getLocale, setLocale } from '../i18n/index.js';
import { COCKPIT_GROUND_WAIT_TIMEOUT_MS } from './cockpitPresentation.js';
import { vesselCockpitInfo } from '../layers/vessels/cockpitSubject.js';
const LAT = 47;
@@ -528,18 +529,28 @@ test('a frame with no height and a cold floor never stands the eye on the ellips
const clock = manualClock(t, 10_000);
const ride = subjectProvider();
ride.subject.frame = { ...ride.subject.frame, heightM: null };
const { cockpit, shots, setFloor } = fixture(t, { providers: [ride] });
// Nothing to stand on from the first frame: the camera waits where it is.
const { $, cockpit, shots, setFloor } = fixture(t, { providers: [ride] });
// Nothing to stand on from the first frame: the camera waits where it is,
// its HUD saying so and still read from the provider.
setFloor(undefined);
cockpit.syncEntry();
assert.equal(cockpit.enter(), true);
const before = shots.length;
const huds = () => ride.calls.filter(([name]) => name === 'hud').length;
const hudsBefore = huds();
for (let i = 0; i < 5; i += 1) {
clock.advance(16);
clock.advance(200);
cockpit.update();
}
await settle();
assert.equal(shots.length, before, 'no camera placed');
assert.equal(cockpit.active, true, 'the view waits');
assert.ok(huds() > hudsBefore, 'the HUD is not frozen');
assert.equal($('#cockpit-aircraft-meta').textContent, 'ACQUIRING SURFACE');
assert.equal(
$('#cockpit-aircraft-meta').getAttribute('data-tone'),
'warning',
);
// The floor arrives: the eye stands on it.
setFloor(FLOOR_M);
clock.advance(16);
@@ -561,6 +572,60 @@ test('a frame with no height and a cold floor never stands the eye on the ellips
assert.ok(Math.abs(shotHeight(shots) - (FLOOR_M + 3.5)) < 0.01);
});
test('a floor that never comes ends the view after the bounded wait, releasing the subject', async (t) => {
const clock = manualClock(t, 10_000);
const ride = subjectProvider();
ride.subject.frame = { ...ride.subject.frame, heightM: null };
const { $, cockpit, shots, setFloor } = fixture(t, { providers: [ride] });
setFloor(undefined);
cockpit.syncEntry();
assert.equal(cockpit.enter(), true);
const before = shots.length;
// Acquiring, the HUD updating, for just under the wait...
for (let elapsed = 0; elapsed < COCKPIT_GROUND_WAIT_TIMEOUT_MS - 250;) {
clock.advance(250);
elapsed += 250;
cockpit.update();
}
await settle();
assert.equal(cockpit.active, true);
assert.equal($('#cockpit-aircraft-meta').textContent, 'ACQUIRING SURFACE');
// ...then no guessed height: the view ends and the subject goes back.
clock.advance(500);
cockpit.update();
assert.equal(cockpit.active, false);
assert.equal(shots.length, before, 'never placed on a guess');
assert.deepEqual(lastRelease(ride.calls), ['release', 'a1', false]);
});
test('an asynchronous hold that lands after entry was refused is released again', async (t) => {
let heldId = null;
const releases = [];
const ride = subjectProvider({
hold: async (id) => {
await Promise.resolve();
heldId = id;
return true;
},
release: (id, options) => {
releases.push([id, options.restore]);
heldId = null;
},
});
const { cockpit, warned } = fixture(t, { providers: [ride] });
cockpit.syncEntry();
assert.equal(cockpit.enter(), false, 'refused at once');
assert.deepEqual(releases, [['a1', false]]);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(heldId, null, 'the late hold is let go');
assert.deepEqual(releases, [
['a1', false],
['a1', false],
]);
assert.equal(cockpit.active, false);
assert.match(warned[0], /hold\(\) returned a promise/);
});
test('a provider’s labels, entry face and look follow a change of language', async (t) => {
t.after(() => setLocale('en'));
setLocale('en');
+21 -2
View File
@@ -399,10 +399,29 @@ function enterSubject(provider) {
if (!candidate || !this.isSubjectEntryAllowed(provider, candidate))
return false;
const failed = {};
const held = this.callSubject(provider, 'hold', [candidate.id], failed);
const release = () =>
this.callSubject(provider, 'release', [candidate.id, { restore: false }]);
// An asynchronous hold may land after it was refused: it is released
// again once it settles, unless this subject is being ridden by then.
const releaseLate = () => {
if (
this.active &&
this.subjectProvider === provider &&
this.subjectId === candidate.id
)
return;
release();
};
const held = this.callSubject(
provider,
'hold',
[candidate.id],
failed,
releaseLate,
);
if (held === failed) {
// A hold that threw (or broke the contract) may have half happened.
this.callSubject(provider, 'release', [candidate.id, { restore: false }]);
release();
return false;
}
if (held !== true) return false;