fix(cockpit): a provider member that throws when read (a getter) leaves that provider out with one warning and keeps the rest; a worded member that starts throwing keeps its last good words; a candidate, frame or HUD view whose getter throws reads as null, so the view ends or keeps its last HUD

This commit is contained in:
Denis Groshev committed 2026-11-13 09:22:55 +01:00
1 parent 22f43aab01
commit fbc1a9a0ec
6 files changed
+236 -64

No files matched your search

+7 -3
View File
@@ -237,15 +237,19 @@ Core's Cockpit rides a tracked aircraft, the tracked satellite and the
selected AIS vessel. An application adds kinds of its own with selected AIS vessel. An application adds kinds of its own with
`services.cockpitSubjects` on `createApplicationControls`: a list of `services.cockpitSubjects` on `createApplicationControls`: a list of
providers, checked once when the controls are built providers, checked once when the controls are built
(`src/cockpitSubjectProviders.js`). A malformed provider is left out with a (`src/cockpitSubjectProviders.js`). A malformed provider, or one with a member
warning. A provider's subject takes entry when it was selected after every that throws when read (a getter), is left out with a warning and the rest are
kept. A provider's subject takes entry when it was selected after every
built-in subject present (a tracked aircraft or satellite, a selected vessel), built-in subject present (a tracked aircraft or satellite, a selected vessel),
whose own fixed order is unchanged, and needs the provider's `layerId` on; the whose own fixed order is unchanged, and needs the provider's `layerId` on; the
provider then holds its subject, hands the camera a frame and the generic HUD provider then holds its subject, hands the camera a frame and the generic HUD
a view model on every update, and releases it on exit. Provider methods are a view model on every update, and releases it on exit. Provider methods are
synchronous and are called on the provider, so a class instance's methods keep synchronous and are called on the provider, so a class instance's methods keep
their `this`; one that throws or returns a promise is treated as having their `this`; one that throws or returns a promise is treated as having
failed. failed. What a method returns may carry getters: one that throws when read
makes a candidate none, a frame malformed (the view ends) and a HUD view keep
the last; a worded member (labels, entry face, look) that throws keeps its
last good words.
```js ```js
import { getLocale, onLocaleChange } from '@gods-eye-view/core/i18n'; import { getLocale, onLocaleChange } from '@gods-eye-view/core/i18n';
+2 -1
View File
@@ -2213,7 +2213,8 @@ covers entry, camera, zoom, look, HUD and exit.
An application can add its own first-person subjects beside the aircraft, An application can add its own first-person subjects beside the aircraft,
vessel and satellite: `services.cockpitSubjects` to `createApplicationControls`, vessel and satellite: `services.cockpitSubjects` to `createApplicationControls`,
a list of subject providers (`src/cockpitSubjectProviders.js`, checked once by a list of subject providers (`src/cockpitSubjectProviders.js`, checked once by
the coordinator; a malformed one is left out with a warning). A provider names the coordinator; a malformed one, or one with a member that throws when read,
is left out with a warning and the rest are kept). A provider names
its layer, an entry face and labels in the page's language, and supplies four its layer, an entry face and labels in the page's language, and supplies four
operations: `candidate()` (what entry would ride now), `hold(id)`, operations: `candidate()` (what entry would ride now), `hold(id)`,
`read(nowMs, ctx)` (a camera frame each update) and `hud(frame, ctx)` (a HUD `read(nowMs, ctx)` (a camera frame each update) and `hud(frame, ctx)` (a HUD
+112 -56
View File
@@ -104,18 +104,46 @@ function labelsOf(raw) {
: null; : null;
} }
/** /** A provider's look, normalized, or null without an aheadLabel. */
* A provider's worded member as it reads now: the value, or what the function lookOf(raw) {
* function it is returns when called on `provider` (so it can follow the if (!isObject(raw)) return null;
* page's language); undefined when that throws. const aheadLabel = cockpitSubjectText(raw.aheadLabel, 64);
*/ if (!aheadLabel) return null;
function wordedNow(value, provider) { return Object.freeze({
if (typeof value !== 'function') return value; restPitchDeg: finite(raw.restPitchDeg)
try { ? clamp(raw.restPitchDeg, -90, 90)
return value.call(provider); : 0,
} catch { aheadLabel,
return undefined; });
} }
/**
* A provider's worded member `key` as it reads now, normalized: the value,
* or what the function it is returns when called on `provider` (so it can
* follow the page's language); null when it is malformed or a read throws.
* Reading the member, and reading what it gives, runs provider code too (a
* getter), so both are guarded as the call is.
*/
function wordedNow(provider, key, normalize) {
try {
const value = provider[key];
return (
normalize(typeof value === 'function' ? value.call(provider) : value) ??
null
);
} catch {
return null;
}
}
/** A provider's name in a warning: its kind, cut, or its place in the list. */
function providerName(provider, order) {
try {
if (typeof provider?.kind === 'string') return provider.kind.slice(0, 32);
} catch {
// A kind getter that throws gives no name.
}
return `#${order}`;
} }
/** Why `provider` cannot be kept, or null. */ /** Why `provider` cannot be kept, or null. */
@@ -137,15 +165,10 @@ function refusal(provider, kinds) {
typeof provider.isEntryAllowed !== 'function' typeof provider.isEntryAllowed !== 'function'
) )
return 'isEntryAllowed is not a function'; return 'isEntryAllowed is not a function';
if (!faceOf(wordedNow(provider.entryFace, provider))) if (!wordedNow(provider, 'entryFace', faceOf)) return 'malformed entry face';
return 'malformed entry face'; if (!wordedNow(provider, 'labels', labelsOf))
if (!labelsOf(wordedNow(provider.labels, provider)))
return 'labels need view, exit and exitText'; return 'labels need view, exit and exitText';
const look = wordedNow(provider.look, provider); if (provider.look != null && !wordedNow(provider, 'look', lookOf))
if (
provider.look != null &&
(!isObject(look) || !cockpitSubjectText(look.aheadLabel, 64))
)
return 'look needs an aheadLabel'; return 'look needs an aheadLabel';
return null; return null;
} }
@@ -184,7 +207,8 @@ function refusal(provider, kinds) {
* once that promise settles; `release` must therefore be harmless for a * once that promise settles; `release` must therefore be harmless for a
* subject it does not hold or already gave back. * subject it does not hold or already gave back.
* *
* A provider that is not so is left out with one warning naming it. * A provider that is not so, or whose members throw when read (a getter), is
* left out with one warning naming it; the providers after it are kept.
* @param {unknown} list * @param {unknown} list
* @param {{warn?: (message: string) => void}} [options] * @param {{warn?: (message: string) => void}} [options]
* @returns {ReadonlyArray<object>} * @returns {ReadonlyArray<object>}
@@ -196,49 +220,54 @@ export function cockpitSubjectProviders(
const kept = []; const kept = [];
const kinds = new Set(); const kinds = new Set();
(Array.isArray(list) ? list : []).forEach((provider, order) => { (Array.isArray(list) ? list : []).forEach((provider, order) => {
const reason = refusal(provider, kinds); let reason;
let copy = null;
try {
reason = refusal(provider, kinds);
if (!reason) copy = keptProvider(provider, order);
} catch {
reason = 'a member threw when read';
}
if (reason) { if (reason) {
const name = warn(
typeof provider?.kind === 'string' `[Cockpit] subject provider ${providerName(provider, order)} left out: ${reason}`,
? provider.kind.slice(0, 32) );
: `#${order}`;
warn(`[Cockpit] subject provider ${name} left out: ${reason}`);
return; return;
} }
kinds.add(provider.kind); kinds.add(copy.kind);
// Worded members are read again on each use (see above); the last good kept.push(copy);
// read stands in for a bad one.
const worded = (value) => wordedNow(value, provider);
let face = faceOf(worded(provider.entryFace));
let labels = labelsOf(worded(provider.labels));
const firstLook = worded(provider.look);
const lookOf = (raw) =>
Object.freeze({
restPitchDeg: finite(firstLook.restPitchDeg)
? clamp(firstLook.restPitchDeg, -90, 90)
: 0,
aheadLabel: cockpitSubjectText(raw.aheadLabel, 64),
}); });
let look = isObject(firstLook) ? lookOf(firstLook) : null; kept.sort((a, b) => a.priority - b.priority || a.order - b.order);
kept.push( return Object.freeze(kept);
Object.freeze({ }
/** The frozen copy the Cockpit keeps of a provider `refusal` passed. */
function keptProvider(provider, order) {
// Worded members are read again on each use (see above); the last good
// read stands in for one that is malformed or throws.
let face = wordedNow(provider, 'entryFace', faceOf);
let labels = wordedNow(provider, 'labels', labelsOf);
// On or off, and its rest pitch, as first read.
let look = wordedNow(provider, 'look', lookOf);
const restPitchDeg = look?.restPitchDeg ?? 0;
return Object.freeze({
kind: provider.kind, kind: provider.kind,
layerId: provider.layerId, layerId: provider.layerId,
priority: finite(provider.priority) ? provider.priority : 0, priority: finite(provider.priority) ? provider.priority : 0,
order, order,
get entryFace() { get entryFace() {
face = faceOf(worded(provider.entryFace)) ?? face; face = wordedNow(provider, 'entryFace', faceOf) ?? face;
return face; return face;
}, },
get labels() { get labels() {
labels = labelsOf(worded(provider.labels)) ?? labels; labels = wordedNow(provider, 'labels', labelsOf) ?? labels;
return labels; return labels;
}, },
get look() { get look() {
if (!look) return null; if (!look) return null;
const raw = worded(provider.look); const now = wordedNow(provider, 'look', lookOf);
if (isObject(raw) && cockpitSubjectText(raw.aheadLabel, 64)) if (now)
look = lookOf(raw); look = Object.freeze({ restPitchDeg, aheadLabel: now.aheadLabel });
return look; return look;
}, },
cameraIntervalMs: cameraIntervalMs:
@@ -252,8 +281,8 @@ export function cockpitSubjectProviders(
(finite(provider.headingSlewDps) && provider.headingSlewDps > 0) (finite(provider.headingSlewDps) && provider.headingSlewDps > 0)
? provider.headingSlewDps ? provider.headingSlewDps
: null, : null,
// Bound, so a method that uses `this` (a class instance's, say) // Bound, so a method that uses `this` (a class instance's, say) still
// still reads its own provider when called on this copy. // reads its own provider when called on this copy.
candidate: provider.candidate.bind(provider), candidate: provider.candidate.bind(provider),
isEntryAllowed: provider.isEntryAllowed isEntryAllowed: provider.isEntryAllowed
? provider.isEntryAllowed.bind(provider) ? provider.isEntryAllowed.bind(provider)
@@ -262,11 +291,7 @@ export function cockpitSubjectProviders(
read: provider.read.bind(provider), read: provider.read.bind(provider),
hud: provider.hud.bind(provider), hud: provider.hud.bind(provider),
release: provider.release.bind(provider), release: provider.release.bind(provider),
}),
);
}); });
kept.sort((a, b) => a.priority - b.priority || a.order - b.order);
return Object.freeze(kept);
} }
/** /**
@@ -275,9 +300,19 @@ export function cockpitSubjectProviders(
* `selectedAt` (a number or a short string, else null) is a token the * `selectedAt` (a number or a short string, else null) is a token the
* provider changes each time its subject is selected, even again: a * provider changes each time its subject is selected, even again: a
* subject selected after the built-in ones takes entry (entryCandidates). * subject selected after the built-in ones takes entry (entryCandidates).
* Null too when reading `raw` throws (a getter runs provider code).
* @param {unknown} raw What `candidate()` returned. * @param {unknown} raw What `candidate()` returned.
*/ */
export function cockpitSubjectCandidate(raw) { export function cockpitSubjectCandidate(raw) {
try {
return candidateOf(raw);
} catch {
// A getter on what candidate() returned threw: no candidate.
return null;
}
}
function candidateOf(raw) {
if (!isObject(raw) || typeof raw.id !== 'string') return null; if (!isObject(raw) || typeof raw.id !== 'string') return null;
const id = raw.id.trim(); const id = raw.id.trim();
if (!id || id.length > 128) return null; if (!id || id.length > 128) return null;
@@ -297,7 +332,8 @@ export function cockpitSubjectCandidate(raw) {
/** /**
* One camera frame from a provider, normalized, or null when it cannot place * One camera frame from a provider, normalized, or null when it cannot place
* the camera (the view then ends): not an object, an id other than `heldId`, * the camera (the view then ends): not an object, an id other than `heldId`,
* a position that is not finite, or an eye with no valid `upM` (0..1000). * a position that is not finite, an eye with no valid `upM` (0..1000), or a
* read of it that throws (a getter runs provider code).
* A `heightM` that is not finite or lies outside * A `heightM` that is not finite or lies outside
* COCKPIT_SUBJECT_MIN_HEIGHT_M..COCKPIT_SUBJECT_MAX_HEIGHT_M reads as null: * COCKPIT_SUBJECT_MIN_HEIGHT_M..COCKPIT_SUBJECT_MAX_HEIGHT_M reads as null:
* the eye then stands on the shared floor, or where it last stood while that * the eye then stands on the shared floor, or where it last stood while that
@@ -309,6 +345,15 @@ export function cockpitSubjectCandidate(raw) {
* pitchDeg, minClearanceM}}`. * pitchDeg, minClearanceM}}`.
*/ */
export function cockpitSubjectFrame(raw, heldId) { export function cockpitSubjectFrame(raw, heldId) {
try {
return frameOf(raw, heldId);
} catch {
// A getter on what read() returned threw: as a malformed frame.
return null;
}
}
function frameOf(raw, heldId) {
if (!isObject(raw) || raw.id !== heldId) return null; if (!isObject(raw) || raw.id !== heldId) return null;
const latitude = raw.latitude; const latitude = raw.latitude;
const longitude = raw.longitude; const longitude = raw.longitude;
@@ -412,10 +457,12 @@ function progressOf(raw) {
* A provider's HUD view model, normalized; never throws. Texts are coerced * A provider's HUD view model, normalized; never throws. Texts are coerced
* and cut to their caps, lists to their lengths, tones to * and cut to their caps, lists to their lengths, tones to
* COCKPIT_SUBJECT_TONES, numbers clamped; a malformed member reads as absent. * COCKPIT_SUBJECT_TONES, numbers clamped; a malformed member reads as absent.
* Null when reading `raw` throws (a getter runs provider code), so the caller
* keeps the view it has, as when `hud()` itself throws.
* All text is shown as given: the provider words it in the page's language. * All text is shown as given: the provider words it in the page's language.
* @param {unknown} raw What `hud()` returned. * @param {unknown} raw What `hud()` returned.
* @returns {object} `{kicker, title, meta: {text, tone}, speed: {value, unit, * @returns {object|null} `{kicker, title, meta: {text, tone}, speed: {value,
* tag, tone}|null, readouts: [{id, label, value, detail, tone}], * unit, tag, tone}|null, readouts: [{id, label, value, detail, tone}],
* sources: [{id, label, text, tone}], * sources: [{id, label, text, tone}],
* route: {title, from, to, status}|null, * route: {title, from, to, status}|null,
* progress: {ticks: [{at, tone, label: {text, detail, priority, * progress: {ticks: [{at, tone, label: {text, detail, priority,
@@ -424,6 +471,15 @@ function progressOf(raw) {
* lookReadout, signals: [{id, tone, title, detail}]}`. * lookReadout, signals: [{id, tone, title, detail}]}`.
*/ */
export function cockpitSubjectHudView(raw) { export function cockpitSubjectHudView(raw) {
try {
return hudViewOf(raw);
} catch {
// A getter on what hud() returned threw: the caller keeps its last view.
return null;
}
}
function hudViewOf(raw) {
const view = isObject(raw) ? raw : {}; const view = isObject(raw) ? raw : {};
const meta = isObject(view.meta) ? view.meta : {}; const meta = isObject(view.meta) ? view.meta : {};
const speed = const speed =
+89
View File
@@ -596,3 +596,92 @@ test('labels, entry face and look given as functions are read again on each use'
assert.deepEqual(bad.kept, []); assert.deepEqual(bad.kept, []);
assert.equal(bad.warnings.length, 2); assert.equal(bad.warnings.length, 2);
}); });
/** `target` with a getter for `key` that throws (provider code runs on a read). */
const throwingGetter = (target, key) =>
Object.defineProperty(target, key, {
enumerable: true,
get() {
throw new Error(`${key} unavailable`);
},
});
test('a provider whose getter throws is left out with one warning; the next is kept', () => {
const { kept, warnings } = keep([
throwingGetter(provider({ kind: 'thrown-face' }), 'entryFace'),
throwingGetter(provider({ kind: 'thrown-labels' }), 'labels'),
throwingGetter(provider({ kind: 'thrown-look' }), 'look'),
throwingGetter(provider(), 'kind'),
throwingGetter(provider({ kind: 'thrown-method' }), 'read'),
// Read only once the provider is accepted, so its kind must stay free.
throwingGetter(provider({ kind: 'example-kind' }), 'priority'),
provider({ kind: 'example-kind' }),
]);
assert.deepEqual(
kept.map(({ kind }) => kind),
['example-kind'],
);
assert.deepEqual(warnings, [
'[Cockpit] subject provider thrown-face left out: malformed entry face',
'[Cockpit] subject provider thrown-labels left out: labels need view, exit and exitText',
'[Cockpit] subject provider thrown-look left out: a member threw when read',
'[Cockpit] subject provider #3 left out: a member threw when read',
'[Cockpit] subject provider thrown-method left out: a member threw when read',
'[Cockpit] subject provider example-kind left out: a member threw when read',
]);
});
test('a worded getter that starts throwing keeps the last good words', () => {
let failing = false;
const words = (value) => () => {
if (failing) throw new Error('translations unavailable');
return value;
};
const definition = provider();
for (const [key, value] of Object.entries({
labels: { view: 'Ride view', exit: 'Exit ride view', exitText: 'EXIT' },
entryFace: { icon: 'commute', label: 'RIDE' },
look: { restPitchDeg: 10, aheadLabel: 'Look ahead' },
}))
Object.defineProperty(definition, key, { get: words(value) });
const { kept, warnings } = keep([definition]);
assert.deepEqual(warnings, []);
const [ride] = kept;
failing = true;
assert.equal(ride.labels.view, 'Ride view');
assert.equal(ride.entryFace.label, 'RIDE');
assert.deepEqual(ride.look, { restPitchDeg: 10, aheadLabel: 'Look ahead' });
// So does a worded object whose own member throws when read.
failing = false;
Object.defineProperty(definition, 'labels', {
get: () => throwingGetter({ view: 'v', exit: 'e', exitText: 'x' }, 'view'),
});
assert.equal(ride.labels.view, 'Ride view');
});
test('a candidate, frame or HUD view whose getter throws reads as null', () => {
assert.equal(
cockpitSubjectCandidate(throwingGetter({ name: 'Example' }, 'id')),
null,
);
const frame = { id: 'a1', latitude: 1, longitude: 2, eye: { upM: 3 } };
assert.ok(cockpitSubjectFrame(frame, 'a1'), 'well formed');
assert.equal(
cockpitSubjectFrame(throwingGetter({ ...frame }, 'latitude'), 'a1'),
null,
);
assert.equal(
cockpitSubjectFrame({ ...frame, eye: throwingGetter({}, 'upM') }, 'a1'),
null,
);
assert.equal(
cockpitSubjectHudView(throwingGetter({ title: 'T' }, 'meta')),
null,
);
assert.equal(
cockpitSubjectHudView({ title: 'T', readouts: [throwingGetter({}, 'id')] }),
null,
);
// Nothing to read is still the empty view, never null.
assert.equal(cockpitSubjectHudView(null).title, '');
});
+5 -4
View File
@@ -154,9 +154,9 @@ const SUBJECT_SIGNAL_TONES = Object.freeze({
/** /**
* Present a provider subject's HUD: its view model (cockpitSubjectHudView) * Present a provider subject's HUD: its view model (cockpitSubjectHudView)
* in the generic readouts, the compass at the camera's heading, the route * in the generic readouts, the compass at the camera's heading, the route
* card, and its signals in Live Signals. A HUD view that is null, or a * card, and its signals in Live Signals. A HUD view that is null, a `hud()`
* `hud()` that throws, keeps the last one. Contact navigation stays with * that throws, or a view whose reading throws keeps the last one. Contact
* aircraft, so the Contact card is withdrawn. * navigation stays with aircraft, so the Contact card is withdrawn.
*/ */
function updateSubjectHud(info, nowMs, forceContext) { function updateSubjectHud(info, nowMs, forceContext) {
const provider = this.subjectProvider; const provider = this.subjectProvider;
@@ -176,7 +176,8 @@ function updateSubjectHud(info, nowMs, forceContext) {
null, null,
) )
: null; : null;
if (raw != null) this.subjectHud = cockpitSubjectHudView(raw); if (raw != null)
this.subjectHud = cockpitSubjectHudView(raw) ?? this.subjectHud;
const hud = this.subjectHud ?? cockpitSubjectHudView(null); const hud = this.subjectHud ?? cockpitSubjectHudView(null);
const view = this.instruments; const view = this.instruments;
view.kind = 'subject'; view.kind = 'subject';
+21
View File
@@ -604,6 +604,16 @@ test('the view ends when the frame stops coming, and gives the subject back', as
for (const ending of [ for (const ending of [
null, null,
{ latitude: LAT, longitude: LON, id: 'other', eye: { upM: 2 } }, { latitude: LAT, longitude: LON, id: 'other', eye: { upM: 2 } },
// A frame whose getter throws runs provider code: as a malformed frame.
Object.defineProperty(
{ id: 'a1', longitude: LON, eye: { upM: 2 } },
'latitude',
{
get() {
throw new Error('latitude unavailable');
},
},
),
]) { ]) {
const ride = subjectProvider(); const ride = subjectProvider();
const { cockpit } = fixture(t, { providers: [ride] }); const { cockpit } = fixture(t, { providers: [ride] });
@@ -937,6 +947,17 @@ test('a read that throws ends the view once; a hud that throws keeps the last',
await settle(); await settle();
assert.equal(cockpit.active, true); assert.equal(cockpit.active, true);
assert.equal($('#cockpit-callsign').textContent, HUD.title, 'last view kept'); assert.equal($('#cockpit-callsign').textContent, HUD.title, 'last view kept');
// So does a view whose getter throws when read.
ride.subject.hud = Object.defineProperty({ ...HUD }, 'title', {
get() {
throw new Error('title unavailable');
},
});
clock.advance(200);
cockpit.update();
await settle();
assert.equal(cockpit.active, true);
assert.equal($('#cockpit-callsign').textContent, HUD.title, 'last view kept');
ride.subject.frame = new Error('read failed'); ride.subject.frame = new Error('read failed');
clock.advance(16); clock.advance(16);
cockpit.update(); cockpit.update();