mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 23:32:20 +00:00
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:
1 parent
1e4060f6ba
commit
4c33552105
13 files changed
+239
-28
No files matched your search
@@ -306,3 +306,18 @@ for a provider placing a train along its line. The frame, HUD view and
|
|||||||
candidate fields are documented in `src/cockpitSubjectProviders.js`; the Rail
|
candidate fields are documented in `src/cockpitSubjectProviders.js`; the Rail
|
||||||
and Transit read APIs in `src/layers/rail/cockpitSubject.js` and
|
and Transit read APIs in `src/layers/rail/cockpitSubject.js` and
|
||||||
`src/layers/transit/cockpitSubject.js`.
|
`src/layers/transit/cockpitSubject.js`.
|
||||||
|
|
||||||
|
A candidate's `selectedAt` is a token that changes each time the user selects
|
||||||
|
the subject, even the same one again, so entry takes it first. 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
|
||||||
|
view waits, showing ACQUIRING SURFACE, for at most five seconds
|
||||||
|
(`COCKPIT_GROUND_WAIT_TIMEOUT_MS`), then ends and releases the subject. A
|
||||||
|
`hold` that throws or returns a promise refuses entry and is followed by
|
||||||
|
`release(id, { restore: false })`, again once a returned promise settles, so
|
||||||
|
`release` must be harmless when called more than once.
|
||||||
@@ -2011,7 +2011,9 @@ older subject's.
|
|||||||
|
|
||||||
A frame places the eye `eye.upM` above `heightM` (the shared floor where the
|
A frame places the eye `eye.upM` above `heightM` (the shared floor where the
|
||||||
frame gives none, or where it last stood while that floor is cold; with
|
frame gives none, or where it last stood while that floor is cold; with
|
||||||
neither the camera waits where it is), slews toward `headingDeg` at the provider's rate, and may
|
neither, the camera stays where it is and the HUD reads ACQUIRING SURFACE for
|
||||||
|
at most `COCKPIT_GROUND_WAIT_TIMEOUT_MS`, after which the view ends and the
|
||||||
|
subject is released: no height stands in for a floor never read), slews toward `headingDeg` at the provider's rate, and may
|
||||||
turn off the floor clamps (`groundClamp: false`, for a subject inside a
|
turn off the floor clamps (`groundClamp: false`, for a subject inside a
|
||||||
structure) or keep the anchor exactly on the subject (`anchor: 'exact'`, for a
|
structure) or keep the anchor exactly on the subject (`anchor: 'exact'`, for a
|
||||||
layer that already moves it smoothly); `cameraIntervalMs: 0` updates the
|
layer that already moves it smoothly); `cameraIntervalMs: 0` updates the
|
||||||
@@ -2034,7 +2036,8 @@ The Rail layer offers a read API for such a view: `getSelectedTrain()` and
|
|||||||
`getCockpitSubject()` (a snapshot of the train: where it is along its line,
|
`getCockpitSubject()` (a snapshot of the train: where it is along its line,
|
||||||
which way it runs (`pathBinding.js` learns it), its rail height, speed as
|
which way it runs (`pathBinding.js` learns it), its rail height, speed as
|
||||||
reported or derived, position source, feed credit and stops, and
|
reported or derived, position source, feed credit and stops, and
|
||||||
`selectedAtMs`, when the user last selected it, even again),
|
`selectedAtMs`, when the user last selected it by a click or `focusTrain`,
|
||||||
|
even again; null after a deselection and for a train only a hold selected),
|
||||||
`setCockpitSubject(key)` (its marker and card hidden, the selection kept, its
|
`setCockpitSubject(key)` (its marker and card hidden, the selection kept, its
|
||||||
feed held, clicks and Escape left to the view) and `focusTrain(key)`; its pure
|
feed held, clicks and Escape left to the view) and `focusTrain(key)`; its pure
|
||||||
path module is exported as `@gods-eye-view/core/layers/rail/paths`. Transit
|
path module is exported as `@gods-eye-view/core/layers/rail/paths`. Transit
|
||||||
|
|||||||
@@ -170,8 +170,10 @@ function refusal(provider, kinds) {
|
|||||||
* - `candidate()`, `isEntryAllowed(candidate)` (optional), `hold(id)`,
|
* - `candidate()`, `isEntryAllowed(candidate)` (optional), `hold(id)`,
|
||||||
* `read(nowMs, ctx)`, `hud(frame, ctx)` and `release(id, {restore})`, all
|
* `read(nowMs, ctx)`, `hud(frame, ctx)` and `release(id, {restore})`, all
|
||||||
* synchronous (createCockpitSubjectCaller). `isEntryAllowed` allows entry
|
* synchronous (createCockpitSubjectCaller). `isEntryAllowed` allows entry
|
||||||
* only by returning `true`. A `hold` that throws is followed by
|
* only by returning `true`. A `hold` that throws or returns a promise
|
||||||
* `release(id, {restore: false})`.
|
* 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.
|
* A provider that is not so is left out with one warning naming it.
|
||||||
* @param {unknown} list
|
* @param {unknown} list
|
||||||
@@ -501,7 +503,9 @@ export function cockpitSubjectHudView(raw) {
|
|||||||
* Provider methods are synchronous: the Cockpit calls them on its render
|
* 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
|
* 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
|
* 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]
|
* @param {{warn?: (message: string, error: unknown) => void}} [options]
|
||||||
*/
|
*/
|
||||||
export function createCockpitSubjectCaller({
|
export function createCockpitSubjectCaller({
|
||||||
@@ -517,7 +521,13 @@ export function createCockpitSubjectCaller({
|
|||||||
error,
|
error,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
return function call(provider, method, args = [], fallback = null) {
|
return function call(
|
||||||
|
provider,
|
||||||
|
method,
|
||||||
|
args = [],
|
||||||
|
fallback = null,
|
||||||
|
onSettled = null,
|
||||||
|
) {
|
||||||
let result;
|
let result;
|
||||||
try {
|
try {
|
||||||
result = provider[method](...args);
|
result = provider[method](...args);
|
||||||
@@ -530,7 +540,11 @@ export function createCockpitSubjectCaller({
|
|||||||
(typeof result === 'object' || typeof result === 'function') &&
|
(typeof result === 'object' || typeof result === 'function') &&
|
||||||
typeof result.then === '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(
|
warnOnce(
|
||||||
provider,
|
provider,
|
||||||
method,
|
method,
|
||||||
|
|||||||
@@ -17,8 +17,9 @@ import { displayMotion } from '../transit/movement.js';
|
|||||||
* view that waits for it holds it again if the feed lists it again.
|
* 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.
|
* `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
|
* 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
|
* train (a click, or `focusTrain`), even again, for a view's entry to tell a
|
||||||
* re-selection.
|
* 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.
|
* @param {object} context The layer's state, parts, services and timetable.
|
||||||
* @returns {{methods: object}}
|
* @returns {{methods: object}}
|
||||||
@@ -181,7 +182,11 @@ export function createRailCockpitSubject({
|
|||||||
}
|
}
|
||||||
const entry = state._trains.get(key);
|
const entry = state._trains.get(key);
|
||||||
if (!entry) return false;
|
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._cockpitSubjectKey = key;
|
||||||
state._cockpitLostFeedId = null;
|
state._cockpitLostFeedId = null;
|
||||||
if (previous && previous !== entry) parts.rendering.paint(previous);
|
if (previous && previous !== entry) parts.rendering.paint(previous);
|
||||||
|
|||||||
@@ -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);
|
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) => {
|
test('turning the layer off ends the hold', async (t) => {
|
||||||
const app = await onTheLine(t);
|
const app = await onTheLine(t);
|
||||||
app.layer.setCockpitSubject(T1);
|
app.layer.setCockpitSubject(T1);
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ export function createSelection({ state, services, parts }) {
|
|||||||
const hadSelection = state._selectedKey !== null;
|
const hadSelection = state._selectedKey !== null;
|
||||||
parts.follow.release({ untrack });
|
parts.follow.release({ untrack });
|
||||||
state._selectedKey = null;
|
state._selectedKey = null;
|
||||||
|
state._selectedAtMs = null;
|
||||||
state._selectedCardText = null;
|
state._selectedCardText = null;
|
||||||
if (hadSelection) {
|
if (hadSelection) {
|
||||||
parts.rendering.paintAll();
|
parts.rendering.paintAll();
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ import { displayMotion } from './movement.js';
|
|||||||
* change the selection. The hold ends with `setCockpitSubject(null)`, and on
|
* change the selection. The hold ends with `setCockpitSubject(null)`, and on
|
||||||
* its own when the vehicle is removed or the layer is turned off.
|
* 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
|
* `focusVehicle(key)` selects a vehicle, as a click does. A snapshot's
|
||||||
* `selectedAtMs` is when the user last selected the selected vehicle, even
|
* `selectedAtMs` is when the user last selected the selected vehicle (a
|
||||||
* again (null for any other).
|
* 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.
|
* @param {object} context The layer's state, parts and services.
|
||||||
* @returns {{methods: object}}
|
* @returns {{methods: object}}
|
||||||
@@ -165,7 +167,7 @@ export function createTransitCockpitSubject({ state, parts, services }) {
|
|||||||
*/
|
*/
|
||||||
focusVehicle(key) {
|
focusVehicle(key) {
|
||||||
if (!state._vehicles.get(key)?.marker) return false;
|
if (!state._vehicles.get(key)?.marker) return false;
|
||||||
parts.selection.selectVehicle(key);
|
parts.selection.selectVehicle(key, { byUser: true });
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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));
|
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) => {
|
test('a held vehicle hides, keeps moving out of view, and lets go when it goes', async (t) => {
|
||||||
const app = harness(t);
|
const app = harness(t);
|
||||||
const key = await busRunningNorth(app);
|
const key = await busRunningNorth(app);
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ export function createSelection({ state, services, parts }) {
|
|||||||
: null;
|
: null;
|
||||||
parts.trails.clear();
|
parts.trails.clear();
|
||||||
state._selectedKey = null;
|
state._selectedKey = null;
|
||||||
|
state._selectedAtMs = null;
|
||||||
state._selectedCardText = null;
|
state._selectedCardText = null;
|
||||||
state._detectRevision += 1;
|
state._detectRevision += 1;
|
||||||
// Cleared first: the styling path reads the selection from state.
|
// 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);
|
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();
|
clearSelection();
|
||||||
const entry = state._vehicles.get(key);
|
const entry = state._vehicles.get(key);
|
||||||
if (!entry?.marker) return;
|
if (!entry?.marker) return;
|
||||||
state._selectedKey = key;
|
state._selectedKey = key;
|
||||||
state._selectedAtMs = Date.now();
|
if (byUser) state._selectedAtMs = Date.now();
|
||||||
state._detectRevision += 1;
|
state._detectRevision += 1;
|
||||||
parts.rendering.paintSelected(entry, true);
|
parts.rendering.paintSelected(entry, true);
|
||||||
parts.trails.select(entry);
|
parts.trails.select(entry);
|
||||||
@@ -190,11 +197,11 @@ export function createSelection({ state, services, parts }) {
|
|||||||
typeof primitiveId === 'string' &&
|
typeof primitiveId === 'string' &&
|
||||||
state._vehicles.has(primitiveId)
|
state._vehicles.has(primitiveId)
|
||||||
) {
|
) {
|
||||||
selectVehicle(primitiveId);
|
selectVehicle(primitiveId, { byUser: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (typeof picked.id === 'string' && state._vehicles.has(picked.id)) {
|
if (typeof picked.id === 'string' && state._vehicles.has(picked.id)) {
|
||||||
selectVehicle(picked.id);
|
selectVehicle(picked.id, { byUser: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-3
@@ -349,12 +349,33 @@ function updateSubject(nowMs) {
|
|||||||
}
|
}
|
||||||
if (!Number.isFinite(heightM)) {
|
if (!Number.isFinite(heightM)) {
|
||||||
// Nothing to stand on yet, from the first frame: the camera stays where
|
// 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
|
// it is while the floor is acquired, its HUD saying so, rather than
|
||||||
// surface, rather than standing on the ellipsoid (kilometres underground
|
// standing on the ellipsoid (kilometres underground on a plateau). The
|
||||||
// on a plateau).
|
// 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;
|
this.surfaceAcquiring = true;
|
||||||
|
if (
|
||||||
|
cockpitUiUpdateDue(nowMs, this.lastHudUpdateMs, COCKPIT_HUD_UPDATE_MS)
|
||||||
|
) {
|
||||||
|
this.lastHudUpdateMs = nowMs;
|
||||||
|
this.updateHud(this.subjectInfo(frame), nowMs);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.surfaceWaitStartedMs = 0;
|
||||||
this.surfaceAcquiring = false;
|
this.surfaceAcquiring = false;
|
||||||
this.subjectHeightM = heightM;
|
this.subjectHeightM = heightM;
|
||||||
const target = Cesium.Cartesian3.fromDegrees(
|
const target = Cesium.Cartesian3.fromDegrees(
|
||||||
|
|||||||
@@ -183,9 +183,12 @@ function updateSubjectHud(info, nowMs, forceContext) {
|
|||||||
view.trackSource = null;
|
view.trackSource = null;
|
||||||
view.kicker = hud.kicker || provider.labels.kicker;
|
view.kicker = hud.kicker || provider.labels.kicker;
|
||||||
view.callsign = hud.title || info.name || '';
|
view.callsign = hud.title || info.name || '';
|
||||||
view.meta.text = hud.meta.text;
|
// While the floor under a subject with no height is acquired
|
||||||
view.meta.tone = hud.meta.tone;
|
// (cockpitCamera.js), the meta line says so in place of the provider's.
|
||||||
view.meta.emergency = hud.meta.tone === 'alert';
|
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;
|
const speed = hud.speed?.value ?? null;
|
||||||
view.speed = hud.speed
|
view.speed = hud.speed
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import CockpitView from './views/CockpitView.vue';
|
|||||||
import ContextView from './views/ContextView.vue';
|
import ContextView from './views/ContextView.vue';
|
||||||
import { cockpitSubjectProviders } from '../cockpitSubjectProviders.js';
|
import { cockpitSubjectProviders } from '../cockpitSubjectProviders.js';
|
||||||
import { getLocale, setLocale } from '../i18n/index.js';
|
import { getLocale, setLocale } from '../i18n/index.js';
|
||||||
|
import { COCKPIT_GROUND_WAIT_TIMEOUT_MS } from './cockpitPresentation.js';
|
||||||
import { vesselCockpitInfo } from '../layers/vessels/cockpitSubject.js';
|
import { vesselCockpitInfo } from '../layers/vessels/cockpitSubject.js';
|
||||||
|
|
||||||
const LAT = 47;
|
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 clock = manualClock(t, 10_000);
|
||||||
const ride = subjectProvider();
|
const ride = subjectProvider();
|
||||||
ride.subject.frame = { ...ride.subject.frame, heightM: null };
|
ride.subject.frame = { ...ride.subject.frame, heightM: null };
|
||||||
const { cockpit, shots, setFloor } = fixture(t, { providers: [ride] });
|
const { $, cockpit, shots, setFloor } = fixture(t, { providers: [ride] });
|
||||||
// Nothing to stand on from the first frame: the camera waits where it is.
|
// 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);
|
setFloor(undefined);
|
||||||
cockpit.syncEntry();
|
cockpit.syncEntry();
|
||||||
assert.equal(cockpit.enter(), true);
|
assert.equal(cockpit.enter(), true);
|
||||||
const before = shots.length;
|
const before = shots.length;
|
||||||
|
const huds = () => ride.calls.filter(([name]) => name === 'hud').length;
|
||||||
|
const hudsBefore = huds();
|
||||||
for (let i = 0; i < 5; i += 1) {
|
for (let i = 0; i < 5; i += 1) {
|
||||||
clock.advance(16);
|
clock.advance(200);
|
||||||
cockpit.update();
|
cockpit.update();
|
||||||
}
|
}
|
||||||
|
await settle();
|
||||||
assert.equal(shots.length, before, 'no camera placed');
|
assert.equal(shots.length, before, 'no camera placed');
|
||||||
assert.equal(cockpit.active, true, 'the view waits');
|
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.
|
// The floor arrives: the eye stands on it.
|
||||||
setFloor(FLOOR_M);
|
setFloor(FLOOR_M);
|
||||||
clock.advance(16);
|
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);
|
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) => {
|
test('a provider’s labels, entry face and look follow a change of language', async (t) => {
|
||||||
t.after(() => setLocale('en'));
|
t.after(() => setLocale('en'));
|
||||||
setLocale('en');
|
setLocale('en');
|
||||||
|
|||||||
@@ -399,10 +399,29 @@ function enterSubject(provider) {
|
|||||||
if (!candidate || !this.isSubjectEntryAllowed(provider, candidate))
|
if (!candidate || !this.isSubjectEntryAllowed(provider, candidate))
|
||||||
return false;
|
return false;
|
||||||
const failed = {};
|
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) {
|
if (held === failed) {
|
||||||
// A hold that threw (or broke the contract) may have half happened.
|
// A hold that threw (or broke the contract) may have half happened.
|
||||||
this.callSubject(provider, 'release', [candidate.id, { restore: false }]);
|
release();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (held !== true) return false;
|
if (held !== true) return false;
|
||||||
|
|||||||
Reference in new issue
Block a user