style: format Context component modules

This commit is contained in:
Sameh Khamis committed 2026-09-13 01:41:01 -07:00
1 parent fc369cc65a
commit 3e078ef49d
11 files changed
+643 -269

No files matched your search

+11 -1
View File
@@ -181,5 +181,15 @@
"src/ui/cctvControls.test.mjs", "src/ui/cctvControls.test.mjs",
"src/ui/cctvFrames.js", "src/ui/cctvFrames.js",
"src/ui/cctvPresentation.js", "src/ui/cctvPresentation.js",
"scripts/qa-camera-controls.mjs" "scripts/qa-camera-controls.mjs",
"src/ui/context.js",
"src/ui/contextActions.js",
"src/ui/contextBindings.js",
"src/ui/contextControls.js",
"src/ui/contextControls.test.mjs",
"src/ui/contextLayerChanges.js",
"src/ui/contextPresentation.js",
"src/ui/contextSession.js",
"src/ui/contextSubscriptions.js",
"src/ui/contextTransactions.js"
] ]
+24 -6
View File
@@ -58,12 +58,22 @@ try {
counts.cctv++; counts.cctv++;
return cctvUnsubscribe(); return cctvUnsubscribe();
}; };
const contextFields = ['_contextManagerUnsubscribe', '_dataManagerVisibilityRequestUnsubscribe', '_dataManagerVisibilityGuardUnsubscribe', '_dataManagerBeforeDestroyUnsubscribe']; const contextFields = [
const contextConnected = contextFields.every((field) => typeof ui._contextControls[field] === 'function'); '_contextManagerUnsubscribe',
'_dataManagerVisibilityRequestUnsubscribe',
'_dataManagerVisibilityGuardUnsubscribe',
'_dataManagerBeforeDestroyUnsubscribe',
];
const contextConnected = contextFields.every(
(field) => typeof ui._contextControls[field] === 'function',
);
for (const field of contextFields) { for (const field of contextFields) {
const unsubscribe = ui._contextControls[field]; const unsubscribe = ui._contextControls[field];
counts[field] = 0; counts[field] = 0;
ui._contextControls[field] = () => { counts[field]++; return unsubscribe?.(); }; ui._contextControls[field] = () => {
counts[field]++;
return unsubscribe?.();
};
} }
const resizeHandler = ui._windowResizeHandler; const resizeHandler = ui._windowResizeHandler;
const removeEventListener = window.removeEventListener; const removeEventListener = window.removeEventListener;
@@ -86,8 +96,13 @@ try {
resizeHandler && resizeHandler &&
observed.includes('_commandDockTrayObserver'), observed.includes('_commandDockTrayObserver'),
), ),
contextReleased: contextConnected && ui._contextControls.destroyed contextReleased:
&& contextFields.every((field) => counts[field] === 1 && ui._contextControls[field] === null), contextConnected &&
ui._contextControls.destroyed &&
contextFields.every(
(field) =>
counts[field] === 1 && ui._contextControls[field] === null,
),
observersReleased: observed.every( observersReleased: observed.every(
(name) => counts[name] === 1 && ui[name] === null, (name) => counts[name] === 1 && ui[name] === null,
), ),
@@ -108,7 +123,10 @@ try {
'real UI has the expected live resources before disposal', 'real UI has the expected live resources before disposal',
result.connected, result.connected,
); );
check('UI disposal releases all Context subscriptions and stops its controls', result.contextReleased); check(
'UI disposal releases all Context subscriptions and stops its controls',
result.contextReleased,
);
check( check(
'UI disposal disconnects each active panel observer once', 'UI disposal disconnects each active panel observer once',
result.observersReleased, result.observersReleased,
+68 -26
View File
@@ -1,10 +1,13 @@
import { settleUserFacingContextAction, contextModeWord } from '../contextModePolicy.js'; import {
settleUserFacingContextAction,
contextModeWord,
} from '../contextModePolicy.js';
export async function _runUserFacingContextAction( export async function _runUserFacingContextAction(
operation, operation,
message = 'Context could not restore every layer; try again', message = 'Context could not restore every layer; try again',
{ falseIsFailure = true } = {}, { falseIsFailure = true } = {},
) { ) {
if (this.destroyed) return false; if (this.destroyed) return false;
const notificationToken = Symbol('user-facing-context-action'); const notificationToken = Symbol('user-facing-context-action');
this._userFacingContextNotificationTokens.add(notificationToken); this._userFacingContextNotificationTokens.add(notificationToken);
@@ -21,7 +24,7 @@ export async function _runUserFacingContextAction(
} finally { } finally {
this._userFacingContextNotificationTokens.delete(notificationToken); this._userFacingContextNotificationTokens.delete(notificationToken);
} }
} }
/** /**
* Claim the visual restore lane for an explicit Context transition. * Claim the visual restore lane for an explicit Context transition.
@@ -42,7 +45,7 @@ export async function _runUserFacingContextAction(
*/ */
export function _claimContextVisualAuthority() { export function _claimContextVisualAuthority() {
this.actions.claimVisualAuthority(); this.actions.claimVisualAuthority();
} }
export function getContextModeState() { export function getContextModeState() {
return { return {
@@ -54,16 +57,21 @@ export function getContextModeState() {
canMission: !this._contextMode || this._contextMode === 'space-missions', canMission: !this._contextMode || this._contextMode === 'space-missions',
snapshotCaptured: Boolean(this._contextSessionSnapshot), snapshotCaptured: Boolean(this._contextSessionSnapshot),
}; };
} }
export async function setContextMode(mode, { export async function setContextMode(
mode,
{
notificationToken = null, notificationToken = null,
signal = null, signal = null,
isCurrent = null, isCurrent = null,
claimVisualAuthority = true, claimVisualAuthority = true,
} = {}) { } = {},
const requestIsCurrent = () => !this.destroyed && !signal?.aborted ) {
&& (typeof isCurrent !== 'function' || isCurrent()); const requestIsCurrent = () =>
!this.destroyed &&
!signal?.aborted &&
(typeof isCurrent !== 'function' || isCurrent());
const cancellationResult = () => ({ const cancellationResult = () => ({
ok: false, ok: false,
action: 'set_context_mode', action: 'set_context_mode',
@@ -81,15 +89,21 @@ export async function setContextMode(mode, {
// visual lane before a delayed shared restore can reclaim it. Internal // visual lane before a delayed shared restore can reclaim it. Internal
// Cockpit choreography opts out: it is not an operator Context request. // Cockpit choreography opts out: it is not an operator Context request.
if (claimVisualAuthority) this._claimContextVisualAuthority(); if (claimVisualAuthority) this._claimContextVisualAuthority();
const result = await this._selectContextMode(null, { notificationToken, signal }); const result = await this._selectContextMode(null, {
if (result === null || (!requestIsCurrent() && result !== true)) return cancellationResult(); notificationToken,
signal,
});
if (result === null || (!requestIsCurrent() && result !== true))
return cancellationResult();
const state = this.getContextModeState(); const state = this.getContextModeState();
return { return {
ok: result === true, ok: result === true,
action: 'set_context_mode', action: 'set_context_mode',
mode: state.mode, mode: state.mode,
...state, ...state,
...(result === true ? {} : { error: 'Context mode transition did not complete' }), ...(result === true
? {}
: { error: 'Context mode transition did not complete' }),
...(this._contextTransitionFailedLayerIds?.length ...(this._contextTransitionFailedLayerIds?.length
? { failedLayerIds: [...this._contextTransitionFailedLayerIds] } ? { failedLayerIds: [...this._contextTransitionFailedLayerIds] }
: {}), : {}),
@@ -110,22 +124,31 @@ export async function setContextMode(mode, {
// takes no authority and leaves the shared visual state eligible. Internal // takes no authority and leaves the shared visual state eligible. Internal
// Cockpit choreography opts out: it is not an operator Context request. // Cockpit choreography opts out: it is not an operator Context request.
if (claimVisualAuthority) this._claimContextVisualAuthority(); if (claimVisualAuthority) this._claimContextVisualAuthority();
const transitioned = await this._selectContextMode(canonical, { notificationToken, signal }); const transitioned = await this._selectContextMode(canonical, {
if (transitioned === null || (!requestIsCurrent() && transitioned !== true)) return cancellationResult(); notificationToken,
signal,
});
if (transitioned === null || (!requestIsCurrent() && transitioned !== true))
return cancellationResult();
const state = this.getContextModeState(); const state = this.getContextModeState();
// A cross-mode switch tears the prior mode down before it commits, so a // A cross-mode switch tears the prior mode down before it commits, so a
// cancelled or failed switch rests on Context OFF. Say that plainly: // cancelled or failed switch rests on Context OFF. Say that plainly:
// reporting a bare "did not complete" while the operator's Context is // reporting a bare "did not complete" while the operator's Context is
// gone is the dishonesty this whole path was fixed for. The state fields // gone is the dishonesty this whole path was fixed for. The state fields
// below carry the same verdict, so text and state cannot disagree. // below carry the same verdict, so text and state cannot disagree.
const crossModeSwitchLost = transitioned !== true const crossModeSwitchLost =
&& Boolean(priorMode) && priorMode !== canonical && !state.mode; transitioned !== true &&
Boolean(priorMode) &&
priorMode !== canonical &&
!state.mode;
return { return {
ok: transitioned === true, ok: transitioned === true,
action: 'set_context_mode', action: 'set_context_mode',
mode: state.mode, mode: state.mode,
...state, ...state,
...(transitioned === true ? {} : { ...(transitioned === true
? {}
: {
// Named in the operator's vocabulary, not the internal id: this // Named in the operator's vocabulary, not the internal id: this
// string is read by the voice model, which takes 'contacts'. // string is read by the voice model, which takes 'contacts'.
error: crossModeSwitchLost error: crossModeSwitchLost
@@ -156,13 +179,25 @@ export async function setContextMode(mode, {
...this.getContextModeState(), ...this.getContextModeState(),
}; };
} }
} }
export function clearSelectedLayers() { export function clearSelectedLayers() {
if (this.destroyed) return Promise.resolve({ targetIds: [], items: [], clearedIds: [], notClearedIds: [], cancelled: true }); if (this.destroyed)
return Promise.resolve({
targetIds: [],
items: [],
clearedIds: [],
notClearedIds: [],
cancelled: true,
});
if (this._clearSelectedLayersPromise) return this._clearSelectedLayersPromise; if (this._clearSelectedLayersPromise) return this._clearSelectedLayersPromise;
if (!this._dataManager?.clearSelectedLayers) { if (!this._dataManager?.clearSelectedLayers) {
return Promise.resolve({ targetIds: [], items: [], clearedIds: [], notClearedIds: [] }); return Promise.resolve({
targetIds: [],
items: [],
clearedIds: [],
notClearedIds: [],
});
} }
const generation = ++this._contextModeGeneration; const generation = ++this._contextModeGeneration;
const notificationToken = Symbol('clear-selected-layers'); const notificationToken = Symbol('clear-selected-layers');
@@ -184,16 +219,22 @@ export function clearSelectedLayers() {
notificationToken, notificationToken,
}); });
this._clearSelectedLayersManagerPromise = managerOperation; this._clearSelectedLayersManagerPromise = managerOperation;
const operation = managerOperation.then((result) => { const operation = managerOperation
.then((result) => {
if (result.targetIds.length === 0) { if (result.targetIds.length === 0) {
this.showToast('No selected data layers'); this.showToast('No selected data layers');
} else if (result.notClearedIds.length > 0) { } else if (result.notClearedIds.length > 0) {
this.showToast(`${result.notClearedIds.length} data layer${result.notClearedIds.length === 1 ? '' : 's'} could not be cleared`); this.showToast(
`${result.notClearedIds.length} data layer${result.notClearedIds.length === 1 ? '' : 's'} could not be cleared`,
);
} else { } else {
this.showToast(`Cleared ${result.clearedIds.length} data layer${result.clearedIds.length === 1 ? '' : 's'}`); this.showToast(
`Cleared ${result.clearedIds.length} data layer${result.clearedIds.length === 1 ? '' : 's'}`,
);
} }
return result; return result;
}).catch((error) => { })
.catch((error) => {
console.warn('[Data] clear selected layers failed', error); console.warn('[Data] clear selected layers failed', error);
this.showToast('Selected data layers could not be cleared'); this.showToast('Selected data layers could not be cleared');
return { return {
@@ -203,7 +244,8 @@ export function clearSelectedLayers() {
notClearedIds: [], notClearedIds: [],
error, error,
}; };
}).finally(() => { })
.finally(() => {
this._userFacingContextNotificationTokens.delete(notificationToken); this._userFacingContextNotificationTokens.delete(notificationToken);
if (generation === this._contextModeGeneration) { if (generation === this._contextModeGeneration) {
this._contextModeChanging = false; this._contextModeChanging = false;
@@ -216,4 +258,4 @@ export function clearSelectedLayers() {
}); });
this._clearSelectedLayersPromise = operation; this._clearSelectedLayersPromise = operation;
return operation; return operation;
} }
+71 -27
View File
@@ -1,72 +1,116 @@
import { shouldExpandGlobalContextPanel } from '../rightRailPolicy.js'; import { shouldExpandGlobalContextPanel } from '../rightRailPolicy.js';
export function _initGlobalContextPanel() { export function _initGlobalContextPanel() {
const contextTabs = [this._globalContextFlightsBtn, this._globalContextMissionsBtn].filter(Boolean); const contextTabs = [
contextTabs.forEach((tab, index) => this.listen(tab, 'keydown', (event) => { this._globalContextFlightsBtn,
this._globalContextMissionsBtn,
].filter(Boolean);
contextTabs.forEach((tab, index) =>
this.listen(tab, 'keydown', (event) => {
let nextIndex = null; let nextIndex = null;
if (event.key === 'ArrowRight') nextIndex = (index + 1) % contextTabs.length; if (event.key === 'ArrowRight')
else if (event.key === 'ArrowLeft') nextIndex = (index - 1 + contextTabs.length) % contextTabs.length; nextIndex = (index + 1) % contextTabs.length;
else if (event.key === 'ArrowLeft')
nextIndex = (index - 1 + contextTabs.length) % contextTabs.length;
else if (event.key === 'Home') nextIndex = 0; else if (event.key === 'Home') nextIndex = 0;
else if (event.key === 'End') nextIndex = contextTabs.length - 1; else if (event.key === 'End') nextIndex = contextTabs.length - 1;
if (nextIndex === null) return; if (nextIndex === null) return;
event.preventDefault(); event.preventDefault();
contextTabs[nextIndex].focus({ preventScroll: true }); contextTabs[nextIndex].focus({ preventScroll: true });
contextTabs[nextIndex].click(); contextTabs[nextIndex].click();
})); }),
);
this.listen(this._globalContextFlightsBtn, 'click', () => { this.listen(this._globalContextFlightsBtn, 'click', () => {
if (this.destroyed || this._contextModeChanging || this._clearSelectedLayersPromise) return; if (
this.destroyed ||
this._contextModeChanging ||
this._clearSelectedLayersPromise
)
return;
const nextMode = this._contextMode === 'flights' ? null : 'flights'; const nextMode = this._contextMode === 'flights' ? null : 'flights';
this._claimContextVisualAuthority(); this._claimContextVisualAuthority();
void this._runUserFacingContextAction( void this._runUserFacingContextAction(
(notificationToken) => this._selectContextMode( (notificationToken) =>
nextMode, this._selectContextMode(nextMode, { notificationToken }),
{ notificationToken },
),
'Contacts could not complete the requested transition; try again', 'Contacts could not complete the requested transition; try again',
).then((succeeded) => { ).then((succeeded) => {
if (!this.destroyed && nextMode && shouldExpandGlobalContextPanel({ if (
!this.destroyed &&
nextMode &&
shouldExpandGlobalContextPanel({
action: 'contacts', action: 'contacts',
explicitUserAction: true, explicitUserAction: true,
succeeded: succeeded === true, succeeded: succeeded === true,
})) this.actions.setPanelCollapsed('global-context-panel', false, { explicit: true }); })
)
this.actions.setPanelCollapsed('global-context-panel', false, {
explicit: true,
});
}); });
}); });
this.listen(this._globalContextMissionsBtn, 'click', () => { this.listen(this._globalContextMissionsBtn, 'click', () => {
if (this.destroyed || this._contextModeChanging || this._clearSelectedLayersPromise) return; if (
const nextMode = this._contextMode === 'space-missions' ? null : 'space-missions'; this.destroyed ||
this._contextModeChanging ||
this._clearSelectedLayersPromise
)
return;
const nextMode =
this._contextMode === 'space-missions' ? null : 'space-missions';
this._claimContextVisualAuthority(); this._claimContextVisualAuthority();
void this._runUserFacingContextAction( void this._runUserFacingContextAction(
(notificationToken) => this._selectContextMode( (notificationToken) =>
nextMode, this._selectContextMode(nextMode, { notificationToken }),
{ notificationToken },
),
'Space Missions could not complete the requested transition; try again', 'Space Missions could not complete the requested transition; try again',
).then((succeeded) => { ).then((succeeded) => {
if (!this.destroyed && nextMode && shouldExpandGlobalContextPanel({ if (
!this.destroyed &&
nextMode &&
shouldExpandGlobalContextPanel({
action: 'space-missions', action: 'space-missions',
explicitUserAction: true, explicitUserAction: true,
succeeded: succeeded === true, succeeded: succeeded === true,
})) this.actions.setPanelCollapsed('global-context-panel', false, { explicit: true }); })
)
this.actions.setPanelCollapsed('global-context-panel', false, {
explicit: true,
});
}); });
}); });
this.listen(this._installationsSearchBtn, 'click', () => { this.listen(this._installationsSearchBtn, 'click', () => {
if (this.destroyed || !this._dataManager?.layers?.has('military-installations')) return; if (
this.destroyed ||
!this._dataManager?.layers?.has('military-installations')
)
return;
const button = this._installationsSearchBtn; const button = this._installationsSearchBtn;
if (button.getAttribute('aria-busy') === 'true') return; if (button.getAttribute('aria-busy') === 'true') return;
button.setAttribute('aria-disabled', 'true'); button.setAttribute('aria-disabled', 'true');
button.setAttribute('aria-busy', 'true'); button.setAttribute('aria-busy', 'true');
void this._runUserFacingContextAction(async (notificationToken) => { void this._runUserFacingContextAction(async (notificationToken) => {
const enabled = await this._dataManager.setEnabled('military-installations', true, { const enabled = await this._dataManager.setEnabled(
'military-installations',
true,
{
origin: 'user', origin: 'user',
notificationToken, notificationToken,
}); },
if (this.destroyed || enabled === false || !this._dataManager.isEnabled('military-installations')) return false; );
if (
this.destroyed ||
enabled === false ||
!this._dataManager.isEnabled('military-installations')
)
return false;
const searched = await this.installations.searchNearby?.(); const searched = await this.installations.searchNearby?.();
if (this.destroyed || searched === false) return false; if (this.destroyed || searched === false) return false;
const stats = this.installations.getStats?.(); const stats = this.installations.getStats?.();
this.showToast(stats?.statusMessage || (stats?.status === 'zoom-in' this.showToast(
stats?.statusMessage ||
(stats?.status === 'zoom-in'
? 'Zoom in to search mapped installations' ? 'Zoom in to search mapped installations'
: 'Nearby installations refreshed')); : 'Nearby installations refreshed'),
);
return true; return true;
}, 'Nearby installations could not be refreshed; try again').finally(() => { }, 'Nearby installations could not be refreshed; try again').finally(() => {
if (this.destroyed) return; if (this.destroyed) return;
@@ -74,4 +118,4 @@ export function _initGlobalContextPanel() {
button.setAttribute('aria-busy', 'false'); button.setAttribute('aria-busy', 'false');
}); });
}); });
} }
+89 -25
View File
@@ -1,9 +1,25 @@
import { _initGlobalContextPanel } from './contextBindings.js'; import { _initGlobalContextPanel } from './contextBindings.js';
import { _syncContextModeButtons } from './contextPresentation.js'; import { _syncContextModeButtons } from './contextPresentation.js';
import { _trackContextLayerReaction, _waitForContextLayerSettlement, _captureContextSessionSnapshot, _restoreContextSession, _restoreContextSessionAfterLayerSettles } from './contextSession.js'; import {
import { _selectContextMode, _deactivateContextForLayerChange, _clearLayersOutsideContextMode } from './contextTransactions.js'; _trackContextLayerReaction,
_waitForContextLayerSettlement,
_captureContextSessionSnapshot,
_restoreContextSession,
_restoreContextSessionAfterLayerSettles,
} from './contextSession.js';
import {
_selectContextMode,
_deactivateContextForLayerChange,
_clearLayersOutsideContextMode,
} from './contextTransactions.js';
import { _handleContextLayerChange } from './contextLayerChanges.js'; import { _handleContextLayerChange } from './contextLayerChanges.js';
import { _runUserFacingContextAction, _claimContextVisualAuthority, getContextModeState, setContextMode, clearSelectedLayers } from './contextActions.js'; import {
_runUserFacingContextAction,
_claimContextVisualAuthority,
getContextModeState,
setContextMode,
clearSelectedLayers,
} from './contextActions.js';
import { connectContextManager } from './contextSubscriptions.js'; import { connectContextManager } from './contextSubscriptions.js';
/** Own Context transactions and controls through explicit layer and presentation ports. */ /** Own Context transactions and controls through explicit layer and presentation ports. */
@@ -31,15 +47,31 @@ export class ContextControls {
this._clearSelectedLayersManagerPromise = null; this._clearSelectedLayersManagerPromise = null;
this._initGlobalContextPanel(); this._initGlobalContextPanel();
} }
showToast(...args) { if (!this.destroyed) this.actions.showToast(...args); } showToast(...args) {
setClearBusy(...args) { if (!this.destroyed) this.actions.setClearBusy(...args); } if (!this.destroyed) this.actions.showToast(...args);
get cockpitView() { return this.actions.getCockpit(); } }
setClearBusy(...args) {
if (!this.destroyed) this.actions.setClearBusy(...args);
}
get cockpitView() {
return this.actions.getCockpit();
}
listen(target, type, callback, options = {}) { listen(target, type, callback, options = {}) {
target?.addEventListener(type, callback, { ...options, signal: this.listeners.signal }); target?.addEventListener(type, callback, {
...options,
signal: this.listeners.signal,
});
}
connect(manager) {
return connectContextManager.call(this, manager);
} }
connect(manager) { return connectContextManager.call(this, manager); }
disconnect() { disconnect() {
for (const field of ['_contextManagerUnsubscribe', '_dataManagerBeforeDestroyUnsubscribe', '_dataManagerVisibilityGuardUnsubscribe', '_dataManagerVisibilityRequestUnsubscribe']) { for (const field of [
'_contextManagerUnsubscribe',
'_dataManagerBeforeDestroyUnsubscribe',
'_dataManagerVisibilityGuardUnsubscribe',
'_dataManagerVisibilityRequestUnsubscribe',
]) {
this[field]?.(); this[field]?.();
this[field] = null; this[field] = null;
} }
@@ -55,20 +87,52 @@ export class ContextControls {
this._contextMode = null; this._contextMode = null;
await this._restoreContextSession(); await this._restoreContextSession();
} }
_initGlobalContextPanel(...args) { return _initGlobalContextPanel.call(this, ...args); } _initGlobalContextPanel(...args) {
_syncContextModeButtons(...args) { return _syncContextModeButtons.call(this, ...args); } return _initGlobalContextPanel.call(this, ...args);
_trackContextLayerReaction(...args) { return _trackContextLayerReaction.call(this, ...args); } }
_waitForContextLayerSettlement(...args) { return _waitForContextLayerSettlement.call(this, ...args); } _syncContextModeButtons(...args) {
_captureContextSessionSnapshot(...args) { return _captureContextSessionSnapshot.call(this, ...args); } return _syncContextModeButtons.call(this, ...args);
_restoreContextSession(...args) { return _restoreContextSession.call(this, ...args); } }
_restoreContextSessionAfterLayerSettles(...args) { return _restoreContextSessionAfterLayerSettles.call(this, ...args); } _trackContextLayerReaction(...args) {
_selectContextMode(...args) { return _selectContextMode.call(this, ...args); } return _trackContextLayerReaction.call(this, ...args);
_deactivateContextForLayerChange(...args) { return _deactivateContextForLayerChange.call(this, ...args); } }
_clearLayersOutsideContextMode(...args) { return _clearLayersOutsideContextMode.call(this, ...args); } _waitForContextLayerSettlement(...args) {
_handleContextLayerChange(...args) { return _handleContextLayerChange.call(this, ...args); } return _waitForContextLayerSettlement.call(this, ...args);
_runUserFacingContextAction(...args) { return _runUserFacingContextAction.call(this, ...args); } }
_claimContextVisualAuthority(...args) { return _claimContextVisualAuthority.call(this, ...args); } _captureContextSessionSnapshot(...args) {
getContextModeState(...args) { return getContextModeState.call(this, ...args); } return _captureContextSessionSnapshot.call(this, ...args);
setContextMode(...args) { return setContextMode.call(this, ...args); } }
clearSelectedLayers(...args) { return clearSelectedLayers.call(this, ...args); } _restoreContextSession(...args) {
return _restoreContextSession.call(this, ...args);
}
_restoreContextSessionAfterLayerSettles(...args) {
return _restoreContextSessionAfterLayerSettles.call(this, ...args);
}
_selectContextMode(...args) {
return _selectContextMode.call(this, ...args);
}
_deactivateContextForLayerChange(...args) {
return _deactivateContextForLayerChange.call(this, ...args);
}
_clearLayersOutsideContextMode(...args) {
return _clearLayersOutsideContextMode.call(this, ...args);
}
_handleContextLayerChange(...args) {
return _handleContextLayerChange.call(this, ...args);
}
_runUserFacingContextAction(...args) {
return _runUserFacingContextAction.call(this, ...args);
}
_claimContextVisualAuthority(...args) {
return _claimContextVisualAuthority.call(this, ...args);
}
getContextModeState(...args) {
return getContextModeState.call(this, ...args);
}
setContextMode(...args) {
return setContextMode.call(this, ...args);
}
clearSelectedLayers(...args) {
return clearSelectedLayers.call(this, ...args);
}
} }
+55 -16
View File
@@ -3,9 +3,16 @@ import { test } from 'node:test';
import { ContextControls } from './contextControls.js'; import { ContextControls } from './contextControls.js';
class Button extends EventTarget { class Button extends EventTarget {
constructor() { super(); this.attributes = new Map(); } constructor() {
getAttribute(name) { return this.attributes.get(name); } super();
setAttribute(name, value) { this.attributes.set(name, value); } this.attributes = new Map();
}
getAttribute(name) {
return this.attributes.get(name);
}
setAttribute(name, value) {
this.attributes.set(name, value);
}
} }
const turn = () => new Promise((resolve) => setImmediate(resolve)); const turn = () => new Promise((resolve) => setImmediate(resolve));
function fixture(t, elements = {}) { function fixture(t, elements = {}) {
@@ -24,15 +31,23 @@ function fixture(t, elements = {}) {
setPanelCollapsed: () => calls.push('expand'), setPanelCollapsed: () => calls.push('expand'),
}, },
}); });
t.after(() => { controls.stop(); controls.disconnect(); }); t.after(() => {
controls.stop();
controls.disconnect();
});
return { controls, calls }; return { controls, calls };
} }
test('Context starts with an explicit idle snapshot', (t) => { test('Context starts with an explicit idle snapshot', (t) => {
const { controls } = fixture(t); const { controls } = fixture(t);
assert.deepEqual(controls.getContextModeState(), { assert.deepEqual(controls.getContextModeState(), {
mode: null, active: false, changing: false, entering: null, mode: null,
canContact: true, canMission: true, snapshotCaptured: false, active: false,
changing: false,
entering: null,
canContact: true,
canMission: true,
snapshotCaptured: false,
}); });
}); });
@@ -40,7 +55,10 @@ test('a pending Context tab cannot reopen a disposed panel', async (t) => {
const tab = new Button(); const tab = new Button();
const { controls, calls } = fixture(t, { _globalContextFlightsBtn: tab }); const { controls, calls } = fixture(t, { _globalContextFlightsBtn: tab });
let release; let release;
controls._selectContextMode = () => new Promise((resolve) => { release = resolve; }); controls._selectContextMode = () =>
new Promise((resolve) => {
release = resolve;
});
tab.dispatchEvent(new Event('click')); tab.dispatchEvent(new Event('click'));
assert.deepEqual(calls, ['claim']); assert.deepEqual(calls, ['claim']);
controls.stop(); controls.stop();
@@ -56,16 +74,24 @@ test('stopping during installations enable prevents the delayed search', async (
let release; let release;
controls._dataManager = { controls._dataManager = {
layers: new Map([['military-installations', {}]]), layers: new Map([['military-installations', {}]]),
setEnabled: () => new Promise((resolve) => { release = resolve; }), setEnabled: () =>
new Promise((resolve) => {
release = resolve;
}),
isEnabled: () => true, isEnabled: () => true,
}; };
controls.installations.searchNearby = () => assert.fail('search started after stop'); controls.installations.searchNearby = () =>
assert.fail('search started after stop');
button.dispatchEvent(new Event('click')); button.dispatchEvent(new Event('click'));
controls.stop(); controls.stop();
release(true); release(true);
await turn(); await turn();
assert.equal(calls.length, 0); assert.equal(calls.length, 0);
assert.equal(button.getAttribute('aria-busy'), 'true', 'disposed controls are not mutated by completion'); assert.equal(
button.getAttribute('aria-busy'),
'true',
'disposed controls are not mutated by completion',
);
}); });
test('a late installations search cannot publish a notice after disposal', async (t) => { test('a late installations search cannot publish a notice after disposal', async (t) => {
@@ -74,9 +100,13 @@ test('a late installations search cannot publish a notice after disposal', async
let release; let release;
controls._dataManager = { controls._dataManager = {
layers: new Map([['military-installations', {}]]), layers: new Map([['military-installations', {}]]),
setEnabled: async () => true, isEnabled: () => true, setEnabled: async () => true,
isEnabled: () => true,
}; };
controls.installations.searchNearby = () => new Promise((resolve) => { release = resolve; }); controls.installations.searchNearby = () =>
new Promise((resolve) => {
release = resolve;
});
button.dispatchEvent(new Event('click')); button.dispatchEvent(new Event('click'));
await turn(); await turn();
controls.stop(); controls.stop();
@@ -88,12 +118,21 @@ test('a late installations search cannot publish a notice after disposal', async
test('reconnecting and disposal release each manager subscription exactly once', (t) => { test('reconnecting and disposal release each manager subscription exactly once', (t) => {
const { controls } = fixture(t); const { controls } = fixture(t);
const counts = new Map(); const counts = new Map();
const manager = (name) => Object.fromEntries([ const manager = (name) =>
'subscribe', 'subscribeVisibilityRequests', 'addVisibilityGuard', 'subscribeBeforeDestroy', Object.fromEntries(
].map((method) => [method, () => () => { [
'subscribe',
'subscribeVisibilityRequests',
'addVisibilityGuard',
'subscribeBeforeDestroy',
].map((method) => [
method,
() => () => {
const key = `${name}:${method}`; const key = `${name}:${method}`;
counts.set(key, (counts.get(key) || 0) + 1); counts.set(key, (counts.get(key) || 0) + 1);
}])); },
]),
);
controls.connect(manager('a')); controls.connect(manager('a'));
controls.connect(manager('b')); controls.connect(manager('b'));
assert.equal(counts.size, 4); assert.equal(counts.size, 4);
+103 -63
View File
@@ -1,13 +1,23 @@
import { cockpitEntryAllowed, isExplicitUserIntentOrigin, recordContextRestoreExplicitChange, recordContextSessionUserChange, shouldExitContextForLayerChange, spaceMissionEntryCancellationDisposition } from '../contextModePolicy.js'; import {
cockpitEntryAllowed,
isExplicitUserIntentOrigin,
recordContextRestoreExplicitChange,
recordContextSessionUserChange,
shouldExitContextForLayerChange,
spaceMissionEntryCancellationDisposition,
} from '../contextModePolicy.js';
export function _handleContextLayerChange(change) { export function _handleContextLayerChange(change) {
if (this.destroyed) return; if (this.destroyed) return;
if (change?.layerId === 'radio' && [ if (
change?.layerId === 'radio' &&
[
'visibility-transition', 'visibility-transition',
'visibility', 'visibility',
'visibility-cancelled', 'visibility-cancelled',
'visibility-failed', 'visibility-failed',
].includes(change.type)) { ].includes(change.type)
) {
this.actions.refreshRadio(); this.actions.refreshRadio();
} }
if (change?.type === 'visibility-transition') return; if (change?.type === 'visibility-transition') return;
@@ -20,8 +30,8 @@ export function _handleContextLayerChange(change) {
change, change,
}); });
if ( if (
this._contextModeDeferredEntryIntent?.layerId === change.layerId this._contextModeDeferredEntryIntent?.layerId === change.layerId &&
&& this._contextModeDeferredEntryIntent.intentEpoch === change.intentEpoch this._contextModeDeferredEntryIntent.intentEpoch === change.intentEpoch
) { ) {
if (cancellationDisposition !== 'replacement') { if (cancellationDisposition !== 'replacement') {
this._contextModeDeferredEntryIntent = null; this._contextModeDeferredEntryIntent = null;
@@ -31,9 +41,9 @@ export function _handleContextLayerChange(change) {
this._contextModeEntering = 'space-missions'; this._contextModeEntering = 'space-missions';
const entryIntent = this._contextModeEntryIntent; const entryIntent = this._contextModeEntryIntent;
if ( if (
entryIntent?.generation === this._contextModeGeneration entryIntent?.generation === this._contextModeGeneration &&
&& entryIntent.layerId === change.layerId entryIntent.layerId === change.layerId &&
&& entryIntent.intentEpoch === change.intentEpoch entryIntent.intentEpoch === change.intentEpoch
) { ) {
this._contextModeReplacementIntent = { this._contextModeReplacementIntent = {
generation: entryIntent.generation, generation: entryIntent.generation,
@@ -47,30 +57,35 @@ export function _handleContextLayerChange(change) {
this._contextModeReplacementIntent = null; this._contextModeReplacementIntent = null;
if (this._contextSessionSnapshot && !this._contextModeChanging) { if (this._contextSessionSnapshot && !this._contextModeChanging) {
this._contextMode = null; this._contextMode = null;
void this._trackContextLayerReaction(this._runUserFacingContextAction( void this._trackContextLayerReaction(
async (notificationToken) => { this._runUserFacingContextAction(async (notificationToken) => {
await this._restoreContextSessionAfterLayerSettles( await this._restoreContextSessionAfterLayerSettles(change.layerId, {
change.layerId, notificationToken,
{ notificationToken }, });
);
return true; return true;
}, }, 'Space Missions cancellation could not restore the previous layer state'),
'Space Missions cancellation could not restore the previous layer state', );
));
} }
} }
this._syncContextModeButtons(); this._syncContextModeButtons();
return; return;
} }
if ( if (
change?.layerId === 'rocket-launches' change?.layerId === 'rocket-launches' &&
&& ['visibility', 'visibility-blocked', 'visibility-failed'].includes(change.type) ['visibility', 'visibility-blocked', 'visibility-failed'].includes(
change.type,
)
) { ) {
this._contextModeEntering = null; this._contextModeEntering = null;
} }
if (change?.type === 'visibility-blocked') { if (change?.type === 'visibility-blocked') {
if (!this._userFacingContextNotificationTokens.has(change.notificationToken)) { if (
this.showToast(change.reason || 'That layer is unavailable in the current Context mode'); !this._userFacingContextNotificationTokens.has(change.notificationToken)
) {
this.showToast(
change.reason ||
'That layer is unavailable in the current Context mode',
);
} }
this._syncContextModeButtons(); this._syncContextModeButtons();
return; return;
@@ -81,29 +96,28 @@ export function _handleContextLayerChange(change) {
// cleared by the visibility guard. Wait outside the synchronous manager // cleared by the visibility guard. Wait outside the synchronous manager
// notification for this queue to settle, then reconcile the complete // notification for this queue to settle, then reconcile the complete
// snapshot, including an uncertain failed shell. // snapshot, including an uncertain failed shell.
const needsDeferredShellRestore = ( const needsDeferredShellRestore =
['military-awareness', 'rocket-launches'].includes(change.layerId) ['military-awareness', 'rocket-launches'].includes(change.layerId) &&
&& change.enabled change.enabled &&
&& this._contextSessionSnapshot this._contextSessionSnapshot &&
&& !this._contextModeChanging !this._contextModeChanging;
);
if (needsDeferredShellRestore) { if (needsDeferredShellRestore) {
this._contextMode = null; this._contextMode = null;
void this._trackContextLayerReaction(this._runUserFacingContextAction( void this._trackContextLayerReaction(
async (notificationToken) => { this._runUserFacingContextAction(async (notificationToken) => {
await this._restoreContextSessionAfterLayerSettles( await this._restoreContextSessionAfterLayerSettles(change.layerId, {
change.layerId, notificationToken,
{ notificationToken }, });
);
// The wrapper owns failure announcements. On a successful rollback // The wrapper owns failure announcements. On a successful rollback
// announce the original activation failure here so the same direct // announce the original activation failure here so the same direct
// action still produces exactly one accessible notification. // action still produces exactly one accessible notification.
this.showToast(failureMessage); this.showToast(failureMessage);
return true; return true;
}, }, failureMessage),
failureMessage, );
)); } else if (
} else if (!this._userFacingContextNotificationTokens.has(change.notificationToken)) { !this._userFacingContextNotificationTokens.has(change.notificationToken)
) {
this.showToast(failureMessage); this.showToast(failureMessage);
} }
this._syncContextModeButtons(); this._syncContextModeButtons();
@@ -127,14 +141,19 @@ export function _handleContextLayerChange(change) {
restoreState: this._contextRestoreState, restoreState: this._contextRestoreState,
change, change,
}); });
if (shouldExitContextForLayerChange({ if (
shouldExitContextForLayerChange({
contextMode: this._contextMode, contextMode: this._contextMode,
globalContextEnabled: !!this._dataManager?.isEnabled('military-awareness'), globalContextEnabled:
!!this._dataManager?.isEnabled('military-awareness'),
change, change,
})) { })
void this._trackContextLayerReaction(this._runUserFacingContextAction((notificationToken) => ( ) {
this._deactivateContextForLayerChange({ notificationToken }) void this._trackContextLayerReaction(
))); this._runUserFacingContextAction((notificationToken) =>
this._deactivateContextForLayerChange({ notificationToken }),
),
);
return; return;
} }
if (!this._contextModeChanging) { if (!this._contextModeChanging) {
@@ -144,44 +163,65 @@ export function _handleContextLayerChange(change) {
// dedicated right-side Global Context chooser. // dedicated right-side Global Context chooser.
this._contextMode = change.enabled this._contextMode = change.enabled
? null ? null
: (this._contextMode === 'flights' ? null : this._contextMode); : this._contextMode === 'flights'
? null
: this._contextMode;
if (change.enabled) { if (change.enabled) {
this._syncContextModeButtons(); this._syncContextModeButtons();
} else if (this._contextSessionSnapshot) { } else if (this._contextSessionSnapshot) {
void this._trackContextLayerReaction(this._runUserFacingContextAction((notificationToken) => ( void this._trackContextLayerReaction(
this._deactivateContextForLayerChange({ notificationToken }) this._runUserFacingContextAction((notificationToken) =>
))); this._deactivateContextForLayerChange({ notificationToken }),
),
);
} }
} else if (change.layerId === 'rocket-launches') { } else if (change.layerId === 'rocket-launches') {
const ownsContextEntry = isExplicitUserIntentOrigin(change.origin, change.layerId) const ownsContextEntry =
|| this._contextMode === 'space-missions' isExplicitUserIntentOrigin(change.origin, change.layerId) ||
|| effectiveContextMode === 'space-missions'; this._contextMode === 'space-missions' ||
effectiveContextMode === 'space-missions';
if (!ownsContextEntry) return; if (!ownsContextEntry) return;
this._contextMode = change.enabled ? 'space-missions' : (this._contextMode === 'space-missions' ? null : this._contextMode); this._contextMode = change.enabled
? 'space-missions'
: this._contextMode === 'space-missions'
? null
: this._contextMode;
if (change.enabled) { if (change.enabled) {
this._syncContextModeButtons(); this._syncContextModeButtons();
} else if (this._contextSessionSnapshot) { } else if (this._contextSessionSnapshot) {
void this._trackContextLayerReaction(this._runUserFacingContextAction((notificationToken) => ( void this._trackContextLayerReaction(
this._deactivateContextForLayerChange({ notificationToken }) this._runUserFacingContextAction((notificationToken) =>
))); this._deactivateContextForLayerChange({ notificationToken }),
),
);
} }
} else if ( } else if (
this._contextMode === 'flights' this._contextMode === 'flights' &&
&& ['flights', 'military', 'ais-live-vessels', 'military-installations'].includes(change.layerId) [
&& !change.enabled 'flights',
'military',
'ais-live-vessels',
'military-installations',
].includes(change.layerId) &&
!change.enabled
) { ) {
void this._trackContextLayerReaction(this._runUserFacingContextAction((notificationToken) => ( void this._trackContextLayerReaction(
this._deactivateContextForLayerChange({ notificationToken }) this._runUserFacingContextAction((notificationToken) =>
))); this._deactivateContextForLayerChange({ notificationToken }),
),
);
} }
} }
if (this.cockpitView?.active && !cockpitEntryAllowed({ if (
this.cockpitView?.active &&
!cockpitEntryAllowed({
contextMode: this._contextMode, contextMode: this._contextMode,
contextModeChanging: this._contextModeChanging, contextModeChanging: this._contextModeChanging,
flightsEnabled: !!this._dataManager?.isEnabled('flights'), flightsEnabled: !!this._dataManager?.isEnabled('flights'),
militaryEnabled: !!this._dataManager?.isEnabled('military'), militaryEnabled: !!this._dataManager?.isEnabled('military'),
})) { })
) {
this.cockpitView.exit({ restoreTracking: false }); this.cockpitView.exit({ restoreTracking: false });
} }
this._syncContextModeButtons(); this._syncContextModeButtons();
} }
+19 -9
View File
@@ -1,5 +1,3 @@
export function _syncContextModeButtons() { export function _syncContextModeButtons() {
if (this.destroyed) return; if (this.destroyed) return;
const flightsActive = this._contextMode === 'flights'; const flightsActive = this._contextMode === 'flights';
@@ -8,27 +6,39 @@ export function _syncContextModeButtons() {
panel?.classList.toggle('context-enabled', flightsActive || missionsActive); panel?.classList.toggle('context-enabled', flightsActive || missionsActive);
panel?.setAttribute('data-context-mode', this._contextMode || 'none'); panel?.setAttribute('data-context-mode', this._contextMode || 'none');
this._globalContextFlightsBtn?.classList.toggle('active', flightsActive); this._globalContextFlightsBtn?.classList.toggle('active', flightsActive);
this._globalContextFlightsBtn?.setAttribute('aria-selected', String(flightsActive)); this._globalContextFlightsBtn?.setAttribute(
'aria-selected',
String(flightsActive),
);
this._globalContextMissionsBtn?.classList.toggle('active', missionsActive); this._globalContextMissionsBtn?.classList.toggle('active', missionsActive);
this._globalContextMissionsBtn?.setAttribute('aria-selected', String(missionsActive)); this._globalContextMissionsBtn?.setAttribute(
'aria-selected',
String(missionsActive),
);
const transitionBusy = Boolean(this._contextModeChanging); const transitionBusy = Boolean(this._contextModeChanging);
// Both Context choices stay in the ordinary Tab sequence. Arrow keys still // Both Context choices stay in the ordinary Tab sequence. Arrow keys still
// provide tablist navigation, but must not be the only way to reach Space // provide tablist navigation, but must not be the only way to reach Space
// Missions from the keyboard. Semantic busy state keeps them perceivable // Missions from the keyboard. Semantic busy state keeps them perceivable
// while synchronous click guards prevent a second transition. // while synchronous click guards prevent a second transition.
for (const button of [this._globalContextFlightsBtn, this._globalContextMissionsBtn]) { for (const button of [
this._globalContextFlightsBtn,
this._globalContextMissionsBtn,
]) {
if (!button) continue; if (!button) continue;
button.disabled = false; button.disabled = false;
button.tabIndex = 0; button.tabIndex = 0;
button.setAttribute('aria-disabled', String(transitionBusy)); button.setAttribute('aria-disabled', String(transitionBusy));
button.setAttribute('aria-busy', String(transitionBusy)); button.setAttribute('aria-busy', String(transitionBusy));
} }
if (this._contextModeStandby) this._contextModeStandby.hidden = flightsActive || missionsActive; if (this._contextModeStandby)
if (this._contextFlightsView) this._contextFlightsView.hidden = !flightsActive; this._contextModeStandby.hidden = flightsActive || missionsActive;
if (this._contextMissionsView) this._contextMissionsView.hidden = !missionsActive; if (this._contextFlightsView)
this._contextFlightsView.hidden = !flightsActive;
if (this._contextMissionsView)
this._contextMissionsView.hidden = !missionsActive;
this.cockpitView?.syncEntry(); this.cockpitView?.syncEntry();
// Every _contextMode mutation funnels through here; the sync no-ops until // Every _contextMode mutation funnels through here; the sync no-ops until
// the transaction settles, so this is the activation/deactivation edge. // the transaction settles, so this is the activation/deactivation edge.
this.actions.syncDetection(); this.actions.syncDetection();
this.actions.scheduleLayout(); this.actions.scheduleLayout();
} }
+38 -20
View File
@@ -1,4 +1,9 @@
import { contextRestoreLayerIds, contextSnapshotLayerIds, mergeContextTransitionErrors, settleContextIntentReplay } from '../contextModePolicy.js'; import {
contextRestoreLayerIds,
contextSnapshotLayerIds,
mergeContextTransitionErrors,
settleContextIntentReplay,
} from '../contextModePolicy.js';
export function _trackContextLayerReaction(promise) { export function _trackContextLayerReaction(promise) {
const tracked = Promise.resolve(promise); const tracked = Promise.resolve(promise);
@@ -8,13 +13,13 @@ export function _trackContextLayerReaction(promise) {
() => this._contextLayerReactionPromises.delete(tracked), () => this._contextLayerReactionPromises.delete(tracked),
); );
return tracked; return tracked;
} }
export async function _waitForContextLayerSettlement() { export async function _waitForContextLayerSettlement() {
while (this._contextLayerReactionPromises.size > 0) { while (this._contextLayerReactionPromises.size > 0) {
await Promise.allSettled([...this._contextLayerReactionPromises]); await Promise.allSettled([...this._contextLayerReactionPromises]);
} }
} }
export function _captureContextSessionSnapshot({ excludeLayerIds = [] } = {}) { export function _captureContextSessionSnapshot({ excludeLayerIds = [] } = {}) {
if (!this._dataManager || this._contextSessionSnapshot) return; if (!this._dataManager || this._contextSessionSnapshot) return;
@@ -33,13 +38,13 @@ export function _captureContextSessionSnapshot({ excludeLayerIds = [] } = {}) {
userRemoved: new Set(), userRemoved: new Set(),
params, params,
}; };
} }
export async function _restoreContextSession({ export async function _restoreContextSession({
excludeLayerIds = [], excludeLayerIds = [],
notificationToken = null, notificationToken = null,
signal = null, signal = null,
} = {}) { } = {}) {
const snapshot = this._contextSessionSnapshot; const snapshot = this._contextSessionSnapshot;
if (!snapshot || !this._dataManager) return; if (!snapshot || !this._dataManager) return;
// Clear the stored session before emitting restore notifications so none // Clear the stored session before emitting restore notifications so none
@@ -60,8 +65,9 @@ export async function _restoreContextSession({
// dependency releases can supersede the restore's same-target requests // dependency releases can supersede the restore's same-target requests
// and make a valid Contacts-to-Missions handoff look like a failure. // and make a valid Contacts-to-Missions handoff look like a failure.
const contactsCoordinatorId = 'military-awareness'; const contactsCoordinatorId = 'military-awareness';
const settleContactsCoordinator = !restoreState.enabledLayerIds.has(contactsCoordinatorId) const settleContactsCoordinator =
&& this._dataManager.isEffectivelyEnabled(contactsCoordinatorId); !restoreState.enabledLayerIds.has(contactsCoordinatorId) &&
this._dataManager.isEffectivelyEnabled(contactsCoordinatorId);
if (settleContactsCoordinator) { if (settleContactsCoordinator) {
const coordinatorSettled = await this._dataManager.setEnabled( const coordinatorSettled = await this._dataManager.setEnabled(
contactsCoordinatorId, contactsCoordinatorId,
@@ -73,19 +79,24 @@ export async function _restoreContextSession({
}, },
); );
if (coordinatorSettled === false) { if (coordinatorSettled === false) {
const error = new Error('Failed to settle Contacts before restoring Context'); const error = new Error(
'Failed to settle Contacts before restoring Context',
);
error.failedLayerIds = [contactsCoordinatorId]; error.failedLayerIds = [contactsCoordinatorId];
throw error; throw error;
} }
} }
await this._dataManager.restoreEnabledLayerIds(restoreState.enabledLayerIds, { await this._dataManager.restoreEnabledLayerIds(
restoreState.enabledLayerIds,
{
origin: 'context-restore', origin: 'context-restore',
excludeLayerIds: settleContactsCoordinator excludeLayerIds: settleContactsCoordinator
? [...excludeLayerIds, contactsCoordinatorId] ? [...excludeLayerIds, contactsCoordinatorId]
: excludeLayerIds, : excludeLayerIds,
notificationToken, notificationToken,
...(restoreSignal ? { signal: restoreSignal } : {}), ...(restoreSignal ? { signal: restoreSignal } : {}),
}); },
);
}; };
try { try {
await restoreSnapshot(signal); await restoreSnapshot(signal);
@@ -100,11 +111,15 @@ export async function _restoreContextSession({
await restoreSnapshot(null); await restoreSnapshot(null);
restoreError = null; restoreError = null;
} catch (compensationError) { } catch (compensationError) {
restoreError = mergeContextTransitionErrors(restoreError, compensationError); restoreError = mergeContextTransitionErrors(
restoreError,
compensationError,
);
} }
} }
} finally { } finally {
if (this._contextRestoreState === restoreState) this._contextRestoreState = null; if (this._contextRestoreState === restoreState)
this._contextRestoreState = null;
} }
// Clear Selected Layers owns a newer global OFF intent. A restore that was // Clear Selected Layers owns a newer global OFF intent. A restore that was
// already awaiting lifecycle work must not replay its captured companion // already awaiting lifecycle work must not replay its captured companion
@@ -116,11 +131,11 @@ export async function _restoreContextSession({
const replaySignal = signal?.aborted ? null : signal; const replaySignal = signal?.aborted ? null : signal;
const replayError = await settleContextIntentReplay({ const replayError = await settleContextIntentReplay({
restoreState, restoreState,
setEnabled: (layerId, enabled, options = {}) => this._dataManager.setEnabled( setEnabled: (layerId, enabled, options = {}) =>
layerId, this._dataManager.setEnabled(layerId, enabled, {
enabled, ...options,
{ ...options, ...(replaySignal ? { signal: replaySignal } : {}) }, ...(replaySignal ? { signal: replaySignal } : {}),
), }),
notificationToken, notificationToken,
}); });
restoreError = mergeContextTransitionErrors(restoreError, replayError); restoreError = mergeContextTransitionErrors(restoreError, replayError);
@@ -135,7 +150,7 @@ export async function _restoreContextSession({
}; };
} }
if (restoreError) throw restoreError; if (restoreError) throw restoreError;
} }
/* /*
* Cross-mode cancellation and failure deliberately settle on Context OFF. * Cross-mode cancellation and failure deliberately settle on Context OFF.
@@ -156,7 +171,10 @@ export async function _restoreContextSession({
* ids preserved. A restore feature can be rebuilt post-launch on the * ids preserved. A restore feature can be rebuilt post-launch on the
* generation discipline the surrounding transaction already follows. * generation discipline the surrounding transaction already follows.
*/ */
export async function _restoreContextSessionAfterLayerSettles(layerId, { notificationToken = null } = {}) { export async function _restoreContextSessionAfterLayerSettles(
layerId,
{ notificationToken = null } = {},
) {
await this._dataManager?.waitForLayerSettled?.(layerId); await this._dataManager?.waitForLayerSettled?.(layerId);
return this._restoreContextSession({ notificationToken }); return this._restoreContextSession({ notificationToken });
} }
+64 -32
View File
@@ -1,25 +1,38 @@
import { contextLayerEnableBlockReason, isExplicitUserIntentOrigin, runWithContextModeChanging, settleContextModeChange, shouldCaptureContextSession, shouldDeferContextEntryDuringClear } from '../contextModePolicy.js'; import {
contextLayerEnableBlockReason,
isExplicitUserIntentOrigin,
runWithContextModeChanging,
settleContextModeChange,
shouldCaptureContextSession,
shouldDeferContextEntryDuringClear,
} from '../contextModePolicy.js';
export function connectContextManager(manager) { export function connectContextManager(manager) {
this.disconnect(); this.disconnect();
if (this.destroyed) return; if (this.destroyed) return;
this._dataManager = manager || null; this._dataManager = manager || null;
this._contextManagerUnsubscribe = this._dataManager?.subscribe?.((change) => { this._contextManagerUnsubscribe = this._dataManager?.subscribe?.((change) => {
if (String(change?.type || '').startsWith('visibility')) this._handleContextLayerChange(change); if (String(change?.type || '').startsWith('visibility'))
this._handleContextLayerChange(change);
}); });
if (typeof this._dataManager?.subscribeVisibilityRequests === 'function') { if (typeof this._dataManager?.subscribeVisibilityRequests === 'function') {
this._dataManagerVisibilityRequestUnsubscribe = this._dataManager.subscribeVisibilityRequests((change) => { this._dataManagerVisibilityRequestUnsubscribe =
this._dataManager.subscribeVisibilityRequests((change) => {
if (this.destroyed) return; if (this.destroyed) return;
if (shouldCaptureContextSession(change)) { if (shouldCaptureContextSession(change)) {
// This event is synchronous with intent publication, before an // This event is synchronous with intent publication, before an
// awaited guard or Clear All can alter the rest of the layer set. // awaited guard or Clear All can alter the rest of the layer set.
// Manager effective visibility already includes both the new entry // Manager effective visibility already includes both the new entry
// intent and Clear's reserved OFF baseline. // intent and Clear's reserved OFF baseline.
this._captureContextSessionSnapshot({ excludeLayerIds: [change.layerId] }); this._captureContextSessionSnapshot({
if (shouldDeferContextEntryDuringClear({ excludeLayerIds: [change.layerId],
});
if (
shouldDeferContextEntryDuringClear({
change, change,
clearInFlight: Boolean(this._clearSelectedLayersPromise), clearInFlight: Boolean(this._clearSelectedLayersPromise),
})) { })
) {
this._contextModeDeferredEntryIntent = { this._contextModeDeferredEntryIntent = {
layerId: change.layerId, layerId: change.layerId,
intentEpoch: change.intentEpoch, intentEpoch: change.intentEpoch,
@@ -29,9 +42,9 @@ export function connectContextManager(manager) {
this._syncContextModeButtons(); this._syncContextModeButtons();
} }
} else if ( } else if (
change?.layerId === 'rocket-launches' change?.layerId === 'rocket-launches' &&
&& change.enabled === false change.enabled === false &&
&& isExplicitUserIntentOrigin(change.origin, change.layerId) isExplicitUserIntentOrigin(change.origin, change.layerId)
) { ) {
this._contextModeDeferredEntryIntent = null; this._contextModeDeferredEntryIntent = null;
if (this._clearSelectedLayersPromise) { if (this._clearSelectedLayersPromise) {
@@ -43,9 +56,12 @@ export function connectContextManager(manager) {
}); });
} }
if (typeof this._dataManager?.addVisibilityGuard === 'function') { if (typeof this._dataManager?.addVisibilityGuard === 'function') {
this._dataManagerVisibilityGuardUnsubscribe = this._dataManager.addVisibilityGuard(async (change) => { this._dataManagerVisibilityGuardUnsubscribe =
this._dataManager.addVisibilityGuard(async (change) => {
if (this.destroyed) return null; if (this.destroyed) return null;
const layerName = this._dataManager?.layers?.get(change.layerId)?.module?.name || change.layerId; const layerName =
this._dataManager?.layers?.get(change.layerId)?.module?.name ||
change.layerId;
const reason = contextLayerEnableBlockReason({ const reason = contextLayerEnableBlockReason({
contextMode: this._contextModeEntering || this._contextMode, contextMode: this._contextModeEntering || this._contextMode,
change, change,
@@ -53,23 +69,26 @@ export function connectContextManager(manager) {
}); });
if (reason) return reason; if (reason) return reason;
if ( if (
change.enabled change.enabled &&
&& ['military-awareness', 'rocket-launches'].includes(change.layerId) ['military-awareness', 'rocket-launches'].includes(change.layerId) &&
&& shouldCaptureContextSession(change) shouldCaptureContextSession(change) &&
&& ( (!this._contextModeChanging ||
!this._contextModeChanging (change.layerId === 'rocket-launches' &&
|| ( this._contextModeDeferredEntryIntent?.intentEpoch ===
change.layerId === 'rocket-launches' change.intentEpoch))
&& this._contextModeDeferredEntryIntent?.intentEpoch === change.intentEpoch
)
)
) { ) {
const entryMode = change.layerId === 'rocket-launches' ? 'space-missions' : null; const entryMode =
const deferredClearEntry = this._contextModeDeferredEntryIntent?.intentEpoch === change.intentEpoch; change.layerId === 'rocket-launches' ? 'space-missions' : null;
const deferredClearEntry =
this._contextModeDeferredEntryIntent?.intentEpoch ===
change.intentEpoch;
// A deferred entry owns the state after Clear settles. Restoring // A deferred entry owns the state after Clear settles. Restoring
// Clear's transient busy flag here would leave Context stuck. // Clear's transient busy flag here would leave Context stuck.
const priorChanging = deferredClearEntry ? false : this._contextModeChanging; const priorChanging = deferredClearEntry
const notificationToken = change.notificationToken || Symbol('direct-context-shell-entry'); ? false
: this._contextModeChanging;
const notificationToken =
change.notificationToken || Symbol('direct-context-shell-entry');
const ownsNotificationToken = !change.notificationToken; const ownsNotificationToken = !change.notificationToken;
if (ownsNotificationToken) { if (ownsNotificationToken) {
this._userFacingContextNotificationTokens.add(notificationToken); this._userFacingContextNotificationTokens.add(notificationToken);
@@ -80,10 +99,16 @@ export function connectContextManager(manager) {
if (deferredClearEntry) { if (deferredClearEntry) {
await this._clearSelectedLayersManagerPromise; await this._clearSelectedLayersManagerPromise;
if (this.destroyed) return false; if (this.destroyed) return false;
if (this._contextModeDeferredEntryIntent?.intentEpoch !== change.intentEpoch) return false; if (
this._contextModeDeferredEntryIntent?.intentEpoch !==
change.intentEpoch
)
return false;
this._contextModeDeferredEntryIntent = null; this._contextModeDeferredEntryIntent = null;
} }
await this._clearLayersOutsideContextMode(entryMode, { notificationToken }); await this._clearLayersOutsideContextMode(entryMode, {
notificationToken,
});
if (this.destroyed) return false; if (this.destroyed) return false;
} catch (error) { } catch (error) {
this._contextModeEntering = null; this._contextModeEntering = null;
@@ -94,12 +119,17 @@ export function connectContextManager(manager) {
notificationToken, notificationToken,
}); });
} catch (restoreError) { } catch (restoreError) {
console.warn(`[Context] ${change.layerId} rollback failed`, restoreError); console.warn(
`[Context] ${change.layerId} rollback failed`,
restoreError,
);
} }
return `${entryMode === 'space-missions' ? 'Space Missions' : 'Context'} could not start because another layer did not stop cleanly`; return `${entryMode === 'space-missions' ? 'Space Missions' : 'Context'} could not start because another layer did not stop cleanly`;
} finally { } finally {
if (ownsNotificationToken) { if (ownsNotificationToken) {
this._userFacingContextNotificationTokens.delete(notificationToken); this._userFacingContextNotificationTokens.delete(
notificationToken,
);
} }
if (!this.destroyed) settleContextModeChange(this, priorChanging); if (!this.destroyed) settleContextModeChange(this, priorChanging);
} }
@@ -108,16 +138,18 @@ export function connectContextManager(manager) {
}); });
} }
if (typeof this._dataManager?.subscribeBeforeDestroy === 'function') { if (typeof this._dataManager?.subscribeBeforeDestroy === 'function') {
this._dataManagerBeforeDestroyUnsubscribe = this._dataManager.subscribeBeforeDestroy(async ({ layerId } = {}) => { this._dataManagerBeforeDestroyUnsubscribe =
this._dataManager.subscribeBeforeDestroy(async ({ layerId } = {}) => {
if (this.destroyed || !this._contextSessionSnapshot) return; if (this.destroyed || !this._contextSessionSnapshot) return;
await runWithContextModeChanging(this, async () => { await runWithContextModeChanging(this, async () => {
this._contextMode = null; this._contextMode = null;
this.cockpitView?.exit({ restoreTracking: false }); this.cockpitView?.exit({ restoreTracking: false });
this._syncContextModeButtons(); this._syncContextModeButtons();
await this._restoreContextSession({ excludeLayerIds: layerId ? [layerId] : [] }); await this._restoreContextSession({
excludeLayerIds: layerId ? [layerId] : [],
});
}); });
}); });
} }
this._syncContextModeButtons(); this._syncContextModeButtons();
} }
+101 -44
View File
@@ -1,11 +1,18 @@
import { contextAllowedLayerIds, mergeContextTransitionErrors } from '../contextModePolicy.js'; import {
contextAllowedLayerIds,
mergeContextTransitionErrors,
} from '../contextModePolicy.js';
export async function _selectContextMode(mode, { notificationToken = null, signal = null } = {}) { export async function _selectContextMode(
mode,
{ notificationToken = null, signal = null } = {},
) {
if (this.destroyed || !this._dataManager) return false; if (this.destroyed || !this._dataManager) return false;
if (this._clearSelectedLayersPromise) return false; if (this._clearSelectedLayersPromise) return false;
this._contextTransitionFailedLayerIds = []; this._contextTransitionFailedLayerIds = [];
const generation = ++this._contextModeGeneration; const generation = ++this._contextModeGeneration;
const isCurrent = () => !this.destroyed && generation === this._contextModeGeneration; const isCurrent = () =>
!this.destroyed && generation === this._contextModeGeneration;
this._contextModeEntryIntent = null; this._contextModeEntryIntent = null;
this._contextModeReplacementIntent = null; this._contextModeReplacementIntent = null;
this._contextModeChanging = true; this._contextModeChanging = true;
@@ -25,7 +32,9 @@ export async function _selectContextMode(mode, { notificationToken = null, signa
// and the resting state is Context OFF — reported as such by // and the resting state is Context OFF — reported as such by
// setContextMode rather than dressed up as a clean cancellation. See the // setContextMode rather than dressed up as a clean cancellation. See the
// note above _restoreContextSessionAfterLayerSettles. // note above _restoreContextSessionAfterLayerSettles.
const crossModeSwitch = Boolean(this._contextMode && this._contextMode !== mode); const crossModeSwitch = Boolean(
this._contextMode && this._contextMode !== mode,
);
if (crossModeSwitch) { if (crossModeSwitch) {
this._contextMode = null; this._contextMode = null;
await this._restoreContextSession({ notificationToken, signal }); await this._restoreContextSession({ notificationToken, signal });
@@ -39,7 +48,10 @@ export async function _selectContextMode(mode, { notificationToken = null, signa
// keeps its non-dependency teardown in the background so slow source // keeps its non-dependency teardown in the background so slow source
// shutdown does not delay cockpit entry. // shutdown does not delay cockpit entry.
try { try {
await this._clearLayersOutsideContextMode(mode, { notificationToken, signal }); await this._clearLayersOutsideContextMode(mode, {
notificationToken,
signal,
});
} catch (error) { } catch (error) {
if (!isCurrent()) return false; if (!isCurrent()) return false;
let transitionError = error; let transitionError = error;
@@ -49,20 +61,30 @@ export async function _selectContextMode(mode, { notificationToken = null, signa
try { try {
await this._restoreContextSession({ notificationToken }); await this._restoreContextSession({ notificationToken });
} catch (restoreError) { } catch (restoreError) {
transitionError = mergeContextTransitionErrors(transitionError, restoreError); transitionError = mergeContextTransitionErrors(
this._contextTransitionFailedLayerIds = [...(transitionError.failedLayerIds || [])]; transitionError,
restoreError,
);
this._contextTransitionFailedLayerIds = [
...(transitionError.failedLayerIds || []),
];
throw transitionError; throw transitionError;
} }
this._contextTransitionFailedLayerIds = [...(transitionError?.failedLayerIds || [])]; this._contextTransitionFailedLayerIds = [
...(transitionError?.failedLayerIds || []),
];
return false; return false;
} }
if (!isCurrent()) return false; if (!isCurrent()) return false;
// Entry is one transaction: isolation succeeded above, so a failed mode // Entry is one transaction: isolation succeeded above, so a failed mode
// activation must roll the cleared layers back instead of stranding the // activation must roll the cleared layers back instead of stranding the
// user in a half-entered mode with an orphaned snapshot. // user in a half-entered mode with an orphaned snapshot.
const entryLayerId = mode === 'flights' ? 'military-awareness' : 'rocket-launches'; const entryLayerId =
mode === 'flights' ? 'military-awareness' : 'rocket-launches';
if (mode === 'flights') { if (mode === 'flights') {
this._dataManager.setLayerParams('military-awareness', { passive: false }); this._dataManager.setLayerParams('military-awareness', {
passive: false,
});
} }
let activated = false; let activated = false;
let activationError = null; let activationError = null;
@@ -80,7 +102,8 @@ export async function _selectContextMode(mode, { notificationToken = null, signa
intentEpoch: activationIntent.intentEpoch, intentEpoch: activationIntent.intentEpoch,
}; };
activated = await activationIntent.promise; activated = await activationIntent.promise;
terminalIntentOutcome = await this._dataManager._waitForVisibilityIntent?.( terminalIntentOutcome =
await this._dataManager._waitForVisibilityIntent?.(
entryLayerId, entryLayerId,
activationIntent.intentEpoch, activationIntent.intentEpoch,
); );
@@ -88,9 +111,10 @@ export async function _selectContextMode(mode, { notificationToken = null, signa
activationError = error; activationError = error;
} }
if (!isCurrent()) return false; if (!isCurrent()) return false;
let replacementIntent = mode === 'space-missions' let replacementIntent =
&& this._contextModeReplacementIntent?.generation === generation mode === 'space-missions' &&
&& this._contextModeReplacementIntent.layerId === entryLayerId this._contextModeReplacementIntent?.generation === generation &&
this._contextModeReplacementIntent.layerId === entryLayerId
? this._contextModeReplacementIntent ? this._contextModeReplacementIntent
: null; : null;
while (replacementIntent) { while (replacementIntent) {
@@ -100,9 +124,10 @@ export async function _selectContextMode(mode, { notificationToken = null, signa
); );
terminalIntentOutcome = outcome; terminalIntentOutcome = outcome;
if (!isCurrent()) return false; if (!isCurrent()) return false;
const replacementOwnsMode = outcome?.intentEpoch === replacementIntent.intentEpoch const replacementOwnsMode =
&& outcome.enabled === true outcome?.intentEpoch === replacementIntent.intentEpoch &&
&& outcome.succeeded === true; outcome.enabled === true &&
outcome.succeeded === true;
if (replacementOwnsMode) { if (replacementOwnsMode) {
this._contextModeEntering = null; this._contextModeEntering = null;
this._contextModeEntryIntent = null; this._contextModeEntryIntent = null;
@@ -110,34 +135,48 @@ export async function _selectContextMode(mode, { notificationToken = null, signa
this._syncContextModeButtons(); this._syncContextModeButtons();
return true; return true;
} }
const successorEpoch = outcome?.cancellationReason === 'superseded' const successorEpoch =
&& outcome.successorEnabled === true outcome?.cancellationReason === 'superseded' &&
&& Number.isInteger(outcome.successorIntentEpoch) outcome.successorEnabled === true &&
&& outcome.successorIntentEpoch > replacementIntent.intentEpoch Number.isInteger(outcome.successorIntentEpoch) &&
outcome.successorIntentEpoch > replacementIntent.intentEpoch
? outcome.successorIntentEpoch ? outcome.successorIntentEpoch
: null; : null;
replacementIntent = successorEpoch === null ? null : { replacementIntent =
successorEpoch === null
? null
: {
generation, generation,
layerId: entryLayerId, layerId: entryLayerId,
intentEpoch: successorEpoch, intentEpoch: successorEpoch,
}; };
} }
if (activationError || activated === false || !this._dataManager.isEnabled(entryLayerId)) { if (
const cancelledAndSettled = terminalIntentOutcome?.succeeded === false activationError ||
&& ['caller-abort', 'resource-abort', 'superseded'].includes( activated === false ||
!this._dataManager.isEnabled(entryLayerId)
) {
const cancelledAndSettled =
terminalIntentOutcome?.succeeded === false &&
['caller-abort', 'resource-abort', 'superseded'].includes(
terminalIntentOutcome.cancellationReason, terminalIntentOutcome.cancellationReason,
); );
let transitionError = null; let transitionError = null;
if (!cancelledAndSettled) { if (!cancelledAndSettled) {
transitionError = activationError instanceof Error transitionError =
activationError instanceof Error
? activationError ? activationError
: new Error(`Context activation failed for: ${entryLayerId}`); : new Error(`Context activation failed for: ${entryLayerId}`);
transitionError.failedLayerIds = [...new Set([ transitionError.failedLayerIds = [
...(transitionError.failedLayerIds || []), ...new Set([...(transitionError.failedLayerIds || []), entryLayerId]),
entryLayerId, ];
])]; this._contextTransitionFailedLayerIds = [
this._contextTransitionFailedLayerIds = [...transitionError.failedLayerIds]; ...transitionError.failedLayerIds,
console.warn(`[Context] ${mode} activation failed; restoring previous layers`, activationError || 'not enabled'); ];
console.warn(
`[Context] ${mode} activation failed; restoring previous layers`,
activationError || 'not enabled',
);
} }
this._contextMode = null; this._contextMode = null;
this._contextModeEntryIntent = null; this._contextModeEntryIntent = null;
@@ -149,8 +188,13 @@ export async function _selectContextMode(mode, { notificationToken = null, signa
notificationToken, notificationToken,
}); });
} catch (restoreError) { } catch (restoreError) {
transitionError = mergeContextTransitionErrors(transitionError, restoreError); transitionError = mergeContextTransitionErrors(
this._contextTransitionFailedLayerIds = [...(transitionError?.failedLayerIds || [])]; transitionError,
restoreError,
);
this._contextTransitionFailedLayerIds = [
...(transitionError?.failedLayerIds || []),
];
throw transitionError; throw transitionError;
} }
// `null` means the requested entry was cancelled and its exact rollback // `null` means the requested entry was cancelled and its exact rollback
@@ -166,15 +210,18 @@ export async function _selectContextMode(mode, { notificationToken = null, signa
this._syncContextModeButtons(); this._syncContextModeButtons();
} }
} }
} }
export async function _deactivateContextForLayerChange({ notificationToken = null } = {}) { export async function _deactivateContextForLayerChange({
notificationToken = null,
} = {}) {
this._contextModeGeneration += 1; this._contextModeGeneration += 1;
this._contextModeChanging = true; this._contextModeChanging = true;
this._contextMode = null; this._contextMode = null;
this._contextModeEntryIntent = null; this._contextModeEntryIntent = null;
this._contextModeReplacementIntent = null; this._contextModeReplacementIntent = null;
if (this.cockpitView?.active) this.cockpitView.exit({ restoreTracking: false }); if (this.cockpitView?.active)
this.cockpitView.exit({ restoreTracking: false });
this._syncContextModeButtons(); this._syncContextModeButtons();
try { try {
await this._restoreContextSession({ notificationToken }); await this._restoreContextSession({ notificationToken });
@@ -182,18 +229,21 @@ export async function _deactivateContextForLayerChange({ notificationToken = nul
this._contextModeChanging = false; this._contextModeChanging = false;
this._syncContextModeButtons(); this._syncContextModeButtons();
} }
} }
export async function _clearLayersOutsideContextMode( export async function _clearLayersOutsideContextMode(
mode = null, mode = null,
{ notificationToken = null, signal = null } = {}, { notificationToken = null, signal = null } = {},
) { ) {
const allowed = contextAllowedLayerIds(mode); const allowed = contextAllowedLayerIds(mode);
const pending = []; const pending = [];
for (const [layerId] of this._dataManager.layers || []) { for (const [layerId] of this._dataManager.layers || []) {
// Effective visibility: a disallowed layer still mid-ENABLING must be // Effective visibility: a disallowed layer still mid-ENABLING must be
// isolated too, or it settles ON inside the exclusive mode. // isolated too, or it settles ON inside the exclusive mode.
if (!allowed.has(layerId) && this._dataManager.isEffectivelyEnabled(layerId)) { if (
!allowed.has(layerId) &&
this._dataManager.isEffectivelyEnabled(layerId)
) {
pending.push({ pending.push({
layerId, layerId,
transition: this._dataManager.setEnabled(layerId, false, { transition: this._dataManager.setEnabled(layerId, false, {
@@ -203,13 +253,20 @@ export async function _clearLayersOutsideContextMode(
}); });
} }
} }
const results = await Promise.all(pending.map(({ transition }) => transition)); const results = await Promise.all(
pending.map(({ transition }) => transition),
);
const failed = pending const failed = pending
.filter(({ layerId }, index) => results[index] === false || this._dataManager.isEnabled(layerId)) .filter(
({ layerId }, index) =>
results[index] === false || this._dataManager.isEnabled(layerId),
)
.map(({ layerId }) => layerId); .map(({ layerId }) => layerId);
if (failed.length > 0) { if (failed.length > 0) {
const error = new Error(`Context isolation failed for: ${failed.join(', ')}`); const error = new Error(
`Context isolation failed for: ${failed.join(', ')}`,
);
error.failedLayerIds = failed; error.failedLayerIds = failed;
throw error; throw error;
} }
} }