mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
refactor: isolate Context controls and layer restoration
This commit is contained in:
1 parent
7f70b97536
commit
fc369cc65a
26 files changed
+1471
-1271
No files matched your search
@@ -13,6 +13,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- Separate Context controls, mode transitions and layer restoration; release tab listeners and suppress late panel/search feedback after disposal.
|
||||
|
||||
- Separate camera-panel controls, frame loading, calibration editing and status display; cancel stale image and calibration work on camera changes or disposal.
|
||||
|
||||
- Restore UI observer, resize-listener and CCTV subscription cleanup after Location extraction.
|
||||
|
||||
@@ -302,3 +302,11 @@ subscriptions before ending the active tuning interaction.
|
||||
presentation. It receives DOM elements, the existing camera port and explicit
|
||||
application actions; it imports no provider, layer or camera engine. Selection,
|
||||
placement, navigation and storage policy remain outside the component family.
|
||||
|
||||
### Context coordination
|
||||
|
||||
`ui/context` owns mode controls, transactions, session restoration and manager
|
||||
subscriptions. Composition supplies the manager, installations search and
|
||||
explicit visual/panel actions. `ui/context/policy` exposes the existing pure
|
||||
mode and restoration rules. No source transport or renderer is imported by
|
||||
these components; initial state and action results retain their existing shape.
|
||||
@@ -1,5 +1,15 @@
|
||||
# God's Eye View Current State
|
||||
|
||||
## Context coordination
|
||||
|
||||
Context controls own Contacts/Space Missions state, entry and exit transactions,
|
||||
layer snapshots and restoration. The application supplies the existing manager,
|
||||
installations search and camera/panel actions. Tab listeners and pending
|
||||
presentation work stop during disposal; layer restoration retains its existing
|
||||
compensation and latest-intent rules. Clear Selected Layers shares this owner,
|
||||
so an older restore cannot replay over a newer Clear action.
|
||||
|
||||
|
||||
## Camera panel ownership
|
||||
|
||||
CCTV controls receive the existing camera port and explicit application actions.
|
||||
|
||||
+3
-1
@@ -131,6 +131,8 @@
|
||||
"./ui/layers/feedback": "./src/loadingFeedback.js",
|
||||
"./ui/location": "./src/ui/location.js",
|
||||
"./ui/radio": "./src/ui/radio.js",
|
||||
"./ui/cctv": "./src/ui/cctv.js"
|
||||
"./ui/cctv": "./src/ui/cctv.js",
|
||||
"./ui/context": "./src/ui/context.js",
|
||||
"./ui/context/policy": "./src/contextModePolicy.js"
|
||||
}
|
||||
}
|
||||
@@ -369,5 +369,26 @@
|
||||
"src/ui/cctvPresentation.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"context-controls": {
|
||||
"exports": [
|
||||
"./ui/context",
|
||||
"./ui/context/policy"
|
||||
],
|
||||
"modules": [
|
||||
"src/ui/context.js",
|
||||
"src/ui/contextActions.js",
|
||||
"src/ui/contextBindings.js",
|
||||
"src/ui/contextControls.js",
|
||||
"src/ui/contextLayerChanges.js",
|
||||
"src/ui/contextPresentation.js",
|
||||
"src/ui/contextSession.js",
|
||||
"src/ui/contextSubscriptions.js",
|
||||
"src/ui/contextTransactions.js",
|
||||
"src/contextModePolicy.js",
|
||||
"src/rightRailPolicy.js",
|
||||
"src/ui/panelRailGeometry.js"
|
||||
],
|
||||
"external": []
|
||||
}
|
||||
}
|
||||
@@ -184,21 +184,21 @@ try {
|
||||
const toasts = [];
|
||||
styleManager._showToast = (message) => { toasts.push(String(message)); };
|
||||
try {
|
||||
if (styleManager._contextMode) await styleManager._selectContextMode(null);
|
||||
const contactsResult = await styleManager._selectContextMode('flights');
|
||||
const missionResult = await styleManager._selectContextMode('space-missions');
|
||||
if (styleManager._contextControls._contextMode) await styleManager._contextControls._selectContextMode(null);
|
||||
const contactsResult = await styleManager._contextControls._selectContextMode('flights');
|
||||
const missionResult = await styleManager._contextControls._selectContextMode('space-missions');
|
||||
const switched = {
|
||||
contactsResult,
|
||||
missionResult,
|
||||
mode: styleManager._contextMode,
|
||||
mode: styleManager._contextControls._contextMode,
|
||||
missionsEnabled: dataManager.isEnabled('rocket-launches'),
|
||||
toasts: [...toasts],
|
||||
};
|
||||
const exitResult = await styleManager._selectContextMode(null);
|
||||
const exitResult = await styleManager._contextControls._selectContextMode(null);
|
||||
return {
|
||||
...switched,
|
||||
exitResult,
|
||||
modeAfterExit: styleManager._contextMode,
|
||||
modeAfterExit: styleManager._contextControls._contextMode,
|
||||
missionsOffAfterExit: !dataManager.isEffectivelyEnabled('rocket-launches'),
|
||||
};
|
||||
} finally {
|
||||
@@ -281,16 +281,16 @@ try {
|
||||
const deadline = performance.now() + 5_000;
|
||||
while (performance.now() < deadline) {
|
||||
if (
|
||||
styleManager._contextModeEntering === null
|
||||
&& styleManager._contextSessionSnapshot === null
|
||||
styleManager._contextControls._contextModeEntering === null
|
||||
&& styleManager._contextControls._contextSessionSnapshot === null
|
||||
&& dataManager.isEnabled(siblingId)
|
||||
&& !dataManager.isEffectivelyEnabled('rocket-launches')
|
||||
) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
timedOut = !(
|
||||
styleManager._contextModeEntering === null
|
||||
&& styleManager._contextSessionSnapshot === null
|
||||
styleManager._contextControls._contextModeEntering === null
|
||||
&& styleManager._contextControls._contextSessionSnapshot === null
|
||||
&& dataManager.isEnabled(siblingId)
|
||||
&& !dataManager.isEffectivelyEnabled('rocket-launches')
|
||||
);
|
||||
@@ -298,8 +298,8 @@ try {
|
||||
exercised: true,
|
||||
transitionResult,
|
||||
timedOut,
|
||||
entering: styleManager._contextModeEntering,
|
||||
snapshotRetained: Boolean(styleManager._contextSessionSnapshot),
|
||||
entering: styleManager._contextControls._contextModeEntering,
|
||||
snapshotRetained: Boolean(styleManager._contextControls._contextSessionSnapshot),
|
||||
siblingRestored: dataManager.isEnabled(siblingId),
|
||||
missionEffective: dataManager.isEffectivelyEnabled('rocket-launches'),
|
||||
toasts,
|
||||
@@ -382,7 +382,7 @@ try {
|
||||
rocketEntry.module.disable = async () => true;
|
||||
styleManager._showToast = (message) => { toasts.push(String(message)); };
|
||||
try {
|
||||
const entry = styleManager._selectContextMode('space-missions');
|
||||
const entry = styleManager._contextControls._selectContextMode('space-missions');
|
||||
await updateStarted;
|
||||
const replacement = dataManager.setEnabled('rocket-launches', true, { origin: 'programmatic' });
|
||||
releaseUpdate();
|
||||
@@ -390,15 +390,15 @@ try {
|
||||
const committed = {
|
||||
entryResult,
|
||||
replacementResult,
|
||||
mode: styleManager._contextMode,
|
||||
entering: styleManager._contextModeEntering,
|
||||
snapshotRetained: Boolean(styleManager._contextSessionSnapshot),
|
||||
mode: styleManager._contextControls._contextMode,
|
||||
entering: styleManager._contextControls._contextModeEntering,
|
||||
snapshotRetained: Boolean(styleManager._contextControls._contextSessionSnapshot),
|
||||
siblingIsolated: !dataManager.isEffectivelyEnabled(siblingId),
|
||||
missionEnabled: dataManager.isEnabled('rocket-launches'),
|
||||
missionEffective: dataManager.isEffectivelyEnabled('rocket-launches'),
|
||||
toasts: [...toasts],
|
||||
};
|
||||
const exitResult = await styleManager._selectContextMode(null);
|
||||
const exitResult = await styleManager._contextControls._selectContextMode(null);
|
||||
return {
|
||||
exercised: true,
|
||||
...committed,
|
||||
@@ -482,7 +482,7 @@ try {
|
||||
styleManager._showToast = (message) => { toasts.push(String(message)); };
|
||||
try {
|
||||
const entry = styleManager._runUserFacingContextAction(
|
||||
(notificationToken) => styleManager._selectContextMode(
|
||||
(notificationToken) => styleManager._contextControls._selectContextMode(
|
||||
'space-missions',
|
||||
{ notificationToken },
|
||||
),
|
||||
@@ -498,9 +498,9 @@ try {
|
||||
exercised: true,
|
||||
entryResult,
|
||||
offResult,
|
||||
mode: styleManager._contextMode,
|
||||
entering: styleManager._contextModeEntering,
|
||||
snapshotRetained: Boolean(styleManager._contextSessionSnapshot),
|
||||
mode: styleManager._contextControls._contextMode,
|
||||
entering: styleManager._contextControls._contextModeEntering,
|
||||
snapshotRetained: Boolean(styleManager._contextControls._contextSessionSnapshot),
|
||||
siblingRestored: dataManager.isEnabled(siblingId),
|
||||
missionEffective: dataManager.isEffectivelyEnabled('rocket-launches'),
|
||||
toasts,
|
||||
@@ -567,35 +567,35 @@ try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 80));
|
||||
const voiceState = {
|
||||
voiceOn,
|
||||
mode: styleManager._contextMode,
|
||||
entering: styleManager._contextModeEntering,
|
||||
snapshotRetained: Boolean(styleManager._contextSessionSnapshot),
|
||||
mode: styleManager._contextControls._contextMode,
|
||||
entering: styleManager._contextControls._contextModeEntering,
|
||||
snapshotRetained: Boolean(styleManager._contextControls._contextSessionSnapshot),
|
||||
siblingIsolated: !dataManager.isEffectivelyEnabled(siblingId),
|
||||
};
|
||||
const snapshotBeforeOff = [...(styleManager._contextSessionSnapshot?.enabledLayerIds || [])];
|
||||
const snapshotBeforeOff = [...(styleManager._contextControls._contextSessionSnapshot?.enabledLayerIds || [])];
|
||||
const voiceOff = await dataManager.setEnabled('rocket-launches', false, { origin: 'voice' });
|
||||
const reactionCountAfterOff = styleManager._contextLayerReactionPromises.size;
|
||||
const reactionCountAfterOff = styleManager._contextControls._contextLayerReactionPromises.size;
|
||||
await styleManager._waitForContextLayerSettlement();
|
||||
const restoredSiblingEntry = dataManager.layers.get(siblingId);
|
||||
const voiceExit = {
|
||||
voiceOff,
|
||||
snapshotBeforeOff,
|
||||
reactionCountAfterOff,
|
||||
mode: styleManager._contextMode,
|
||||
entering: styleManager._contextModeEntering,
|
||||
snapshotRetained: Boolean(styleManager._contextSessionSnapshot),
|
||||
mode: styleManager._contextControls._contextMode,
|
||||
entering: styleManager._contextControls._contextModeEntering,
|
||||
snapshotRetained: Boolean(styleManager._contextControls._contextSessionSnapshot),
|
||||
siblingRestored: dataManager.isEffectivelyEnabled(siblingId),
|
||||
siblingEnabled: restoredSiblingEntry?.enabled,
|
||||
siblingLifecycle: restoredSiblingEntry?.lifecycleState,
|
||||
restoreActive: Boolean(styleManager._contextRestoreState),
|
||||
restoreActive: Boolean(styleManager._contextControls._contextRestoreState),
|
||||
};
|
||||
|
||||
await dataManager.setEnabled('rocket-launches', true, { origin: 'programmatic' });
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
const internalState = {
|
||||
mode: styleManager._contextMode,
|
||||
entering: styleManager._contextModeEntering,
|
||||
snapshotRetained: Boolean(styleManager._contextSessionSnapshot),
|
||||
mode: styleManager._contextControls._contextMode,
|
||||
entering: styleManager._contextControls._contextModeEntering,
|
||||
snapshotRetained: Boolean(styleManager._contextControls._contextSessionSnapshot),
|
||||
missionEnabled: dataManager.isEffectivelyEnabled('rocket-launches'),
|
||||
};
|
||||
await dataManager.setEnabled('rocket-launches', false, { origin: 'programmatic' });
|
||||
@@ -707,7 +707,7 @@ try {
|
||||
const blockerEntry = dataManager.layers.get(blockerId);
|
||||
blockerEntry.initialized = true;
|
||||
await dataManager.setEnabled(blockerId, true, { origin: 'programmatic' });
|
||||
const transition = styleManager._selectContextMode('flights');
|
||||
const transition = styleManager._contextControls._selectContextMode('flights');
|
||||
const disableStart = await settleWithin(disableStarted, gateTimeoutMs);
|
||||
if (!disableStart.settled) {
|
||||
releaseDisable();
|
||||
@@ -725,7 +725,7 @@ try {
|
||||
styleManager.cockpitView.syncEntry();
|
||||
const entry = document.getElementById('cockpit-entry');
|
||||
const pending = {
|
||||
changing: styleManager._contextModeChanging,
|
||||
changing: styleManager._contextControls._contextModeChanging,
|
||||
entryHidden: Boolean(entry?.hidden),
|
||||
enterResult: styleManager.cockpitView.enter(),
|
||||
trackerPreserved: viewer.trackedEntity === trackedEntityBefore,
|
||||
@@ -763,7 +763,7 @@ try {
|
||||
// probe must not poison the checks that run after it.
|
||||
releaseDisable();
|
||||
if (!deferCleanup && dataManager.layers.has(blockerId)) {
|
||||
if (styleManager._contextSessionSnapshot) await styleManager._selectContextMode(null);
|
||||
if (styleManager._contextControls._contextSessionSnapshot) await styleManager._contextControls._selectContextMode(null);
|
||||
await window.__gevQaUnregisterLayer(dataManager, blockerId);
|
||||
styleManager.cockpitView.syncEntry();
|
||||
}
|
||||
@@ -872,7 +872,7 @@ try {
|
||||
let requestSeen = false;
|
||||
let transition = null;
|
||||
try {
|
||||
await styleManager._selectContextMode(null);
|
||||
await styleManager._contextControls._selectContextMode(null);
|
||||
await dataManager.setEnabled('military-installations', false, { origin: 'programmatic' });
|
||||
|
||||
// Hold the first Overpass request open, honouring the module's own
|
||||
@@ -913,7 +913,7 @@ try {
|
||||
});
|
||||
};
|
||||
|
||||
transition = styleManager._selectContextMode('flights');
|
||||
transition = styleManager._contextControls._selectContextMode('flights');
|
||||
// The activation promise stays PENDING while the first fetch is out, and
|
||||
// the Contacts panel comes up behind it — that is what a deferred
|
||||
// dependency is for. Verified live on :4272: the panel renders and reads
|
||||
@@ -984,8 +984,8 @@ try {
|
||||
released = true;
|
||||
window.fetch = realFetch;
|
||||
if (transition) await settleWithin(transition, 20_000);
|
||||
if (styleManager._contextMode !== 'flights') {
|
||||
await settleWithin(styleManager._selectContextMode('flights'), 15_000);
|
||||
if (styleManager._contextControls._contextMode !== 'flights') {
|
||||
await settleWithin(styleManager._contextControls._selectContextMode('flights'), 15_000);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1060,7 +1060,7 @@ try {
|
||||
return {
|
||||
before: before ? { layerId: before.layerId, id: before.id } : null,
|
||||
after: after ? { layerId: after.layerId, id: after.id } : null,
|
||||
mode: styleManager._contextMode,
|
||||
mode: styleManager._contextControls._contextMode,
|
||||
released,
|
||||
refocused,
|
||||
trackedId: trackedInfo?.icao24 || trackedInfo?.id || null,
|
||||
@@ -1206,9 +1206,9 @@ try {
|
||||
// Start from outside both, so activation is a real edge.
|
||||
cockpit.exit({ restoreTracking: true });
|
||||
await settle();
|
||||
await styleManager._selectContextMode(null);
|
||||
await styleManager._contextControls._selectContextMode(null);
|
||||
await settle(400);
|
||||
const cleanupSnapshotCleared = styleManager._contextSessionSnapshot === null;
|
||||
const cleanupSnapshotCleared = styleManager._contextControls._contextSessionSnapshot === null;
|
||||
const cleanupBlockerId = '__qa_slow_contacts_sibling__';
|
||||
const cleanupUnregistered = await window.__gevQaUnregisterLayer(
|
||||
window.__godsEyeView.dataManager,
|
||||
@@ -1224,7 +1224,7 @@ try {
|
||||
const overriddenBeforeContacts = styleManager._detectionUserOverridden;
|
||||
|
||||
// 1. Activating Contacts forces the tactical preset on.
|
||||
const contactsOn = await styleManager._selectContextMode('flights');
|
||||
const contactsOn = await styleManager._contextControls._selectContextMode('flights');
|
||||
await settle(400);
|
||||
const afterContactsOn = mode();
|
||||
const afterContactsOnDensity = density();
|
||||
@@ -1254,7 +1254,7 @@ try {
|
||||
await settle();
|
||||
|
||||
// 4. Deactivating Contacts restores the pre-Contacts state.
|
||||
await styleManager._selectContextMode(null);
|
||||
await styleManager._contextControls._selectContextMode(null);
|
||||
await settle(400);
|
||||
const afterContactsOff = mode();
|
||||
const afterContactsOffDensity = density();
|
||||
@@ -1268,7 +1268,7 @@ try {
|
||||
await settle();
|
||||
|
||||
// Leave the session exactly as the following checks expect it.
|
||||
const restoredContacts = await styleManager._selectContextMode('flights');
|
||||
const restoredContacts = await styleManager._contextControls._selectContextMode('flights');
|
||||
await settle(400);
|
||||
const restoredCockpit = await enterCockpit();
|
||||
styleManager._detectionUserOverridden = originalOverridden;
|
||||
@@ -1348,8 +1348,8 @@ try {
|
||||
step,
|
||||
cockpitActive: cockpit.active,
|
||||
bodyCockpit: document.body.classList.contains('cockpit-mode'),
|
||||
contextMode: styleManager._contextMode,
|
||||
contextChanging: styleManager._contextModeChanging,
|
||||
contextMode: styleManager._contextControls._contextMode,
|
||||
contextChanging: styleManager._contextControls._contextModeChanging,
|
||||
subject: context?.subject ? `${context.subject.layerId}:${context.subject.id}` : null,
|
||||
tracked: tracker?.gevTrackedId || null,
|
||||
density: styleManager.getDetectionState().densityPct,
|
||||
|
||||
@@ -87,9 +87,9 @@ try {
|
||||
result.push([
|
||||
'native clear activation presents busy state',
|
||||
ui._clearSelectedLayersBtn.getAttribute('aria-busy') === 'true' &&
|
||||
!!ui._clearSelectedLayersPromise,
|
||||
!!ui._contextControls._clearSelectedLayersPromise,
|
||||
]);
|
||||
await ui._clearSelectedLayersPromise;
|
||||
await ui._contextControls._clearSelectedLayersPromise;
|
||||
result.push([
|
||||
'clear settles through the existing layer transaction',
|
||||
!manager.isEnabled(id) &&
|
||||
|
||||
+38
-38
@@ -783,10 +783,10 @@ async function main() {
|
||||
const originalRestoreEnabledLayerIds = dataManager.restoreEnabledLayerIds;
|
||||
const originalIsEnabled = dataManager.isEnabled;
|
||||
const originalIsEffectivelyEnabled = dataManager.isEffectivelyEnabled;
|
||||
const originalClear = styleManager._clearLayersOutsideContextMode;
|
||||
const originalCapture = styleManager._captureContextSessionSnapshot;
|
||||
const originalContextMode = styleManager._contextMode;
|
||||
const originalSnapshot = styleManager._contextSessionSnapshot;
|
||||
const originalClear = styleManager._contextControls._clearLayersOutsideContextMode;
|
||||
const originalCapture = styleManager._contextControls._captureContextSessionSnapshot;
|
||||
const originalContextMode = styleManager._contextControls._contextMode;
|
||||
const originalSnapshot = styleManager._contextControls._contextSessionSnapshot;
|
||||
const originalShowToast = styleManager._showToast;
|
||||
const installations = dataManager.layers.get('military-installations')?.module;
|
||||
const originalSearchNearby = installations?.searchNearby;
|
||||
@@ -797,12 +797,12 @@ async function main() {
|
||||
const semanticFalseMessage = document.getElementById('toast').textContent;
|
||||
|
||||
const enabled = new Set();
|
||||
styleManager._captureContextSessionSnapshot = () => {};
|
||||
styleManager._contextControls._captureContextSessionSnapshot = () => {};
|
||||
const runContextFailure = async (mode, phase, outcome) => {
|
||||
enabled.clear();
|
||||
enabled.add('local-datacenters');
|
||||
styleManager._contextMode = null;
|
||||
styleManager._contextSessionSnapshot = {
|
||||
styleManager._contextControls._contextMode = null;
|
||||
styleManager._contextControls._contextSessionSnapshot = {
|
||||
enabledLayerIds: new Set(enabled),
|
||||
userAdded: new Set(),
|
||||
userRemoved: new Set(),
|
||||
@@ -822,7 +822,7 @@ async function main() {
|
||||
return true;
|
||||
};
|
||||
const result = await styleManager._runUserFacingContextAction(
|
||||
() => styleManager._selectContextMode(mode),
|
||||
() => styleManager._contextControls._selectContextMode(mode),
|
||||
`${mode} QA transition failed`,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
@@ -831,8 +831,8 @@ async function main() {
|
||||
phase,
|
||||
outcome,
|
||||
result,
|
||||
visibleMode: styleManager._contextMode,
|
||||
snapshotCleared: styleManager._contextSessionSnapshot === null,
|
||||
visibleMode: styleManager._contextControls._contextMode,
|
||||
snapshotCleared: styleManager._contextControls._contextSessionSnapshot === null,
|
||||
priorLayerRestored: enabled.has('local-datacenters'),
|
||||
entryEnabled: enabled.has(entryLayerId),
|
||||
flightsButtonDisabled: document.getElementById('global-context-flights-btn').disabled,
|
||||
@@ -870,8 +870,8 @@ async function main() {
|
||||
for (const outcome of ['false', 'reject']) {
|
||||
enabled.clear();
|
||||
enabled.add('local-datacenters');
|
||||
styleManager._contextMode = null;
|
||||
styleManager._contextSessionSnapshot = {
|
||||
styleManager._contextControls._contextMode = null;
|
||||
styleManager._contextControls._contextSessionSnapshot = {
|
||||
enabledLayerIds: new Set(enabled),
|
||||
userAdded: new Set(),
|
||||
userRemoved: new Set(),
|
||||
@@ -886,7 +886,7 @@ async function main() {
|
||||
dataManager.setEnabled = async (candidateId, shouldEnable, options = {}) => {
|
||||
if (!failureUsed && candidateId === 'local-datacenters' && !shouldEnable) {
|
||||
failureUsed = true;
|
||||
styleManager._handleContextLayerChange({
|
||||
styleManager._contextControls._handleContextLayerChange({
|
||||
type: 'visibility-failed',
|
||||
layerId: candidateId,
|
||||
enabled: shouldEnable,
|
||||
@@ -905,7 +905,7 @@ async function main() {
|
||||
enabled: true,
|
||||
origin: 'user',
|
||||
});
|
||||
styleManager._handleContextLayerChange({
|
||||
styleManager._contextControls._handleContextLayerChange({
|
||||
type: 'visibility-blocked',
|
||||
layerId,
|
||||
enabled: true,
|
||||
@@ -925,10 +925,10 @@ async function main() {
|
||||
reason,
|
||||
retryReason,
|
||||
toastMessages,
|
||||
mode: styleManager._contextMode,
|
||||
snapshotCleared: styleManager._contextSessionSnapshot === null,
|
||||
mode: styleManager._contextControls._contextMode,
|
||||
snapshotCleared: styleManager._contextControls._contextSessionSnapshot === null,
|
||||
priorLayerRestored,
|
||||
modeChanging: styleManager._contextModeChanging,
|
||||
modeChanging: styleManager._contextControls._contextModeChanging,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -937,8 +937,8 @@ async function main() {
|
||||
const directActivationFailures = [];
|
||||
for (const layerId of ['military-awareness', 'rocket-launches']) {
|
||||
enabled.clear();
|
||||
styleManager._contextMode = null;
|
||||
styleManager._contextSessionSnapshot = {
|
||||
styleManager._contextControls._contextMode = null;
|
||||
styleManager._contextControls._contextSessionSnapshot = {
|
||||
enabledLayerIds: new Set(['local-datacenters']),
|
||||
userAdded: new Set(),
|
||||
userRemoved: new Set(),
|
||||
@@ -948,7 +948,7 @@ async function main() {
|
||||
shouldEnable ? enabled.add(candidateId) : enabled.delete(candidateId);
|
||||
return true;
|
||||
};
|
||||
styleManager._handleContextLayerChange({
|
||||
styleManager._contextControls._handleContextLayerChange({
|
||||
type: 'visibility-failed',
|
||||
layerId,
|
||||
enabled: true,
|
||||
@@ -957,8 +957,8 @@ async function main() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
directActivationFailures.push({
|
||||
layerId,
|
||||
mode: styleManager._contextMode,
|
||||
snapshotCleared: styleManager._contextSessionSnapshot === null,
|
||||
mode: styleManager._contextControls._contextMode,
|
||||
snapshotCleared: styleManager._contextControls._contextSessionSnapshot === null,
|
||||
priorLayerRestored: enabled.has('local-datacenters'),
|
||||
failedLayerDisabled: !enabled.has(layerId),
|
||||
});
|
||||
@@ -967,8 +967,8 @@ async function main() {
|
||||
const directActivationRollbackFailures = [];
|
||||
for (const layerId of ['military-awareness', 'rocket-launches']) {
|
||||
enabled.clear();
|
||||
styleManager._contextMode = null;
|
||||
styleManager._contextSessionSnapshot = {
|
||||
styleManager._contextControls._contextMode = null;
|
||||
styleManager._contextControls._contextSessionSnapshot = {
|
||||
enabledLayerIds: new Set(['local-datacenters']),
|
||||
userAdded: new Set(),
|
||||
userRemoved: new Set(),
|
||||
@@ -985,7 +985,7 @@ async function main() {
|
||||
shouldEnable ? enabled.add(candidateId) : enabled.delete(candidateId);
|
||||
return true;
|
||||
};
|
||||
styleManager._handleContextLayerChange({
|
||||
styleManager._contextControls._handleContextLayerChange({
|
||||
type: 'visibility-failed',
|
||||
layerId,
|
||||
enabled: true,
|
||||
@@ -996,7 +996,7 @@ async function main() {
|
||||
directActivationRollbackFailures.push({
|
||||
layerId,
|
||||
toastMessages,
|
||||
retryRetained: styleManager._contextSessionSnapshot?.enabledLayerIds?.has('local-datacenters') === true,
|
||||
retryRetained: styleManager._contextControls._contextSessionSnapshot?.enabledLayerIds?.has('local-datacenters') === true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1026,8 +1026,8 @@ async function main() {
|
||||
window.__gevQaRegisterLayer(dataManager, qaLayer);
|
||||
try {
|
||||
const enabledBeforeExit = dataManager.getEnabledLayerIds();
|
||||
styleManager._contextMode = 'flights';
|
||||
styleManager._contextSessionSnapshot = {
|
||||
styleManager._contextControls._contextMode = 'flights';
|
||||
styleManager._contextControls._contextSessionSnapshot = {
|
||||
enabledLayerIds: new Set([...enabledBeforeExit, layerId]),
|
||||
userAdded: new Set(),
|
||||
userRemoved: new Set(),
|
||||
@@ -1039,13 +1039,13 @@ async function main() {
|
||||
};
|
||||
const unhandledBefore = unhandled.length;
|
||||
const result = await styleManager._runUserFacingContextAction(
|
||||
(notificationToken) => styleManager._deactivateContextForLayerChange({ notificationToken }),
|
||||
(notificationToken) => styleManager._contextControls._deactivateContextForLayerChange({ notificationToken }),
|
||||
`QA Context exit ${outcome} surfaced once`,
|
||||
);
|
||||
const retainedForRetry = styleManager._contextSessionSnapshot?.enabledLayerIds?.has(layerId) === true;
|
||||
const retainedForRetry = styleManager._contextControls._contextSessionSnapshot?.enabledLayerIds?.has(layerId) === true;
|
||||
enableOutcome = 'success';
|
||||
const retryResult = await styleManager._runUserFacingContextAction(
|
||||
(notificationToken) => styleManager._deactivateContextForLayerChange({ notificationToken }),
|
||||
(notificationToken) => styleManager._contextControls._deactivateContextForLayerChange({ notificationToken }),
|
||||
`QA Context exit ${outcome} retry failed`,
|
||||
);
|
||||
contextExitFailures.push({
|
||||
@@ -1055,9 +1055,9 @@ async function main() {
|
||||
toastMessages,
|
||||
unhandledDelta: unhandled.length - unhandledBefore,
|
||||
retainedForRetry,
|
||||
mode: styleManager._contextMode,
|
||||
modeChanging: styleManager._contextModeChanging,
|
||||
snapshotClearedAfterRetry: styleManager._contextSessionSnapshot === null,
|
||||
mode: styleManager._contextControls._contextMode,
|
||||
modeChanging: styleManager._contextControls._contextModeChanging,
|
||||
snapshotClearedAfterRetry: styleManager._contextControls._contextSessionSnapshot === null,
|
||||
enabledAfterRetry: dataManager.isEnabled(layerId),
|
||||
toastRole: document.getElementById('toast').getAttribute('role'),
|
||||
toastLive: document.getElementById('toast').getAttribute('aria-live'),
|
||||
@@ -1163,10 +1163,10 @@ async function main() {
|
||||
dataManager.restoreEnabledLayerIds = originalRestoreEnabledLayerIds;
|
||||
dataManager.isEnabled = originalIsEnabled;
|
||||
dataManager.isEffectivelyEnabled = originalIsEffectivelyEnabled;
|
||||
styleManager._clearLayersOutsideContextMode = originalClear;
|
||||
styleManager._captureContextSessionSnapshot = originalCapture;
|
||||
styleManager._contextMode = originalContextMode;
|
||||
styleManager._contextSessionSnapshot = originalSnapshot;
|
||||
styleManager._contextControls._clearLayersOutsideContextMode = originalClear;
|
||||
styleManager._contextControls._captureContextSessionSnapshot = originalCapture;
|
||||
styleManager._contextControls._contextMode = originalContextMode;
|
||||
styleManager._contextControls._contextSessionSnapshot = originalSnapshot;
|
||||
styleManager._showToast = originalShowToast;
|
||||
if (installations) installations.searchNearby = originalSearchNearby;
|
||||
window.removeEventListener('unhandledrejection', onUnhandled);
|
||||
|
||||
@@ -58,6 +58,13 @@ try {
|
||||
counts.cctv++;
|
||||
return cctvUnsubscribe();
|
||||
};
|
||||
const contextFields = ['_contextManagerUnsubscribe', '_dataManagerVisibilityRequestUnsubscribe', '_dataManagerVisibilityGuardUnsubscribe', '_dataManagerBeforeDestroyUnsubscribe'];
|
||||
const contextConnected = contextFields.every((field) => typeof ui._contextControls[field] === 'function');
|
||||
for (const field of contextFields) {
|
||||
const unsubscribe = ui._contextControls[field];
|
||||
counts[field] = 0;
|
||||
ui._contextControls[field] = () => { counts[field]++; return unsubscribe?.(); };
|
||||
}
|
||||
const resizeHandler = ui._windowResizeHandler;
|
||||
const removeEventListener = window.removeEventListener;
|
||||
counts.resize = 0;
|
||||
@@ -79,6 +86,8 @@ try {
|
||||
resizeHandler &&
|
||||
observed.includes('_commandDockTrayObserver'),
|
||||
),
|
||||
contextReleased: contextConnected && ui._contextControls.destroyed
|
||||
&& contextFields.every((field) => counts[field] === 1 && ui._contextControls[field] === null),
|
||||
observersReleased: observed.every(
|
||||
(name) => counts[name] === 1 && ui[name] === null,
|
||||
),
|
||||
@@ -99,6 +108,7 @@ try {
|
||||
'real UI has the expected live resources before disposal',
|
||||
result.connected,
|
||||
);
|
||||
check('UI disposal releases all Context subscriptions and stops its controls', result.contextReleased);
|
||||
check(
|
||||
'UI disposal disconnects each active panel observer once',
|
||||
result.observersReleased,
|
||||
|
||||
@@ -300,7 +300,7 @@ test('newer navigation, reset, Cockpit, and teardown share one generation', () =
|
||||
'this._disposed = true;',
|
||||
'this._stampNavigation();',
|
||||
'this._removeWorldRequestFocusListener?.();',
|
||||
'await this._restoreContextSession();',
|
||||
'await this._contextControls.restoreForDisposal();',
|
||||
], 'dispose invalidation');
|
||||
});
|
||||
|
||||
@@ -311,7 +311,7 @@ test('teardown synchronously closes immediate camera entry points', () => {
|
||||
'this._removeCctvRequestFocusListener?.();',
|
||||
'this._removeWorldRequestFocusListener?.();',
|
||||
'this._navigationOwnerChangedRemover?.();',
|
||||
'await this._restoreContextSession();',
|
||||
'await this._contextControls.restoreForDisposal();',
|
||||
], 'synchronous teardown barrier');
|
||||
|
||||
const navigation = body(
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { _handleContextLayerChange } from './ui/contextLayerChanges.js';
|
||||
import { clearSelectedLayers } from './ui/contextActions.js';
|
||||
import { _selectContextMode } from './ui/contextTransactions.js';
|
||||
import { _restoreContextSession } from './ui/contextSession.js';
|
||||
import { readFileSync as readRadioSource } from 'node:fs';
|
||||
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
|
||||
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
|
||||
@@ -183,9 +187,7 @@ test('the cockpit reads its aircraft from the layer that owns Cesium tracking',
|
||||
});
|
||||
|
||||
test('programmatic Context layer changes cannot bypass explicit expansion policy', () => {
|
||||
const handler = ui.match(/_handleContextLayerChange\(change\) \{([\s\S]*?)\n \}\n\n _syncContextModeButtons/);
|
||||
assert.ok(handler, 'Context layer state handler is missing');
|
||||
assert.doesNotMatch(handler[1], /setPanelCollapsed\('global-context-panel', false\)/);
|
||||
assert.doesNotMatch(_handleContextLayerChange.toString(), /setPanelCollapsed\('global-context-panel', false\)/);
|
||||
});
|
||||
|
||||
test('share startup isolates panel defaults from recipient-local collapse preferences', () => {
|
||||
@@ -305,13 +307,13 @@ test('Clear Selected Layers uses one adopted batch and discards Context restorat
|
||||
manager,
|
||||
/for \(const \{ layerId, intentEpoch \} of targets\)[\s\S]*?visibilityIntentEpoch !== intentEpoch[\s\S]*?await this\.setEnabled\(layerId, false/,
|
||||
);
|
||||
assert.match(ui, /if \(this\._clearSelectedLayersPromise\) return this\._clearSelectedLayersPromise/);
|
||||
assert.match(ui, /async _selectContextMode\([\s\S]*?if \(this\._clearSelectedLayersPromise\) return false/);
|
||||
assert.match(ui, /this\._contextModeGeneration[\s\S]*?this\._contextSessionSnapshot = null;[\s\S]*?this\._contextRestoreState = null;/);
|
||||
assert.match(ui, /if \(this\._contextRestoreState\) this\._contextRestoreState\.cancelled = true/);
|
||||
assert.match(ui, /if \(restoreState\.cancelled\) return;[\s\S]*?settleContextIntentReplay/);
|
||||
assert.match(clearSelectedLayers.toString(), /if \(this\._clearSelectedLayersPromise\) return this\._clearSelectedLayersPromise/);
|
||||
assert.match(_selectContextMode.toString(), /async function _selectContextMode\([\s\S]*?if \(this\._clearSelectedLayersPromise\) return false/);
|
||||
assert.match(clearSelectedLayers.toString(), /this\._contextModeGeneration[\s\S]*?this\._contextSessionSnapshot = null;[\s\S]*?this\._contextRestoreState = null;/);
|
||||
assert.match(clearSelectedLayers.toString(), /if \(this\._contextRestoreState\) this\._contextRestoreState\.cancelled = true/);
|
||||
assert.match(_restoreContextSession.toString(), /if \(restoreState\.cancelled\) return;[\s\S]*?settleContextIntentReplay/);
|
||||
assert.match(radioPresentation, /!this\.actions\.preservePanelStateDuringClear\(\)[\s\S]*?this\.actions\.setPanelCollapsed\('radio-panel', true\)/);
|
||||
assert.match(ui, /this\._userFacingContextNotificationTokens\.add\(notificationToken\)/);
|
||||
assert.match(clearSelectedLayers.toString(), /this\._userFacingContextNotificationTokens\.add\(notificationToken\)/);
|
||||
});
|
||||
|
||||
test('Cockpit Display portal retains both scroll owners across round trips', () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { _syncContextModeButtons } from './ui/contextPresentation.js';
|
||||
// Contacts-scoped detection (owner playtest 2026-08-18: "when you click on
|
||||
// Contacts, detections should just turn on, and they should stay on in Cockpit
|
||||
// or in third-person tracking inside Contacts").
|
||||
@@ -292,10 +293,11 @@ test('detection is wired to the Contacts transaction, and cockpit no longer touc
|
||||
'ACTIVATION turns detection on REGARDLESS of the style-preset override flag',
|
||||
);
|
||||
assert.match(
|
||||
uiSource,
|
||||
/this\.cockpitView\?\.syncEntry\(\);[\s\S]{0,220}?this\._syncContactsDetection\(\);/,
|
||||
_syncContextModeButtons.toString(),
|
||||
/this\.cockpitView\?\.syncEntry\(\);[\s\S]{0,220}?this\.actions\.syncDetection\(\);/,
|
||||
'called from _syncContextModeButtons, the funnel every _contextMode mutation routes through',
|
||||
);
|
||||
assert.match(uiSource, /syncDetection: \(\) => this\._syncContactsDetection\(\)/);
|
||||
// The cockpit vision hook — the old trigger — must be out of the detection
|
||||
// business entirely, or leaving the cockpit turns detections off again.
|
||||
const visionHook = uiSource.slice(
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { _handleContextLayerChange } from './ui/contextLayerChanges.js';
|
||||
import { connectContextManager } from './ui/contextSubscriptions.js';
|
||||
import { _restoreContextSession, _restoreContextSessionAfterLayerSettles } from './ui/contextSession.js';
|
||||
import { _selectContextMode, _clearLayersOutsideContextMode } from './ui/contextTransactions.js';
|
||||
import { _initGlobalContextPanel } from './ui/contextBindings.js';
|
||||
import { setContextMode as contextModeAction } from './ui/contextActions.js';
|
||||
import { ContextControls } from './ui/contextControls.js';
|
||||
import { readFileSync as readRadioSource } from 'node:fs';
|
||||
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
|
||||
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
|
||||
const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8');
|
||||
// Source-contract pins for StyleManager's Context-session handler ordering.
|
||||
// ui.js cannot be imported headlessly (it touches the DOM at module scope), so
|
||||
// these read the shipped source — the idiom established by cockpitMarkup /
|
||||
// cameraHandoff tests. Each pin guards a bug that shipped or nearly shipped:
|
||||
// Source-contract pins read the actual Context owners and root disposal wiring. Each pin guards a bug that shipped or nearly shipped:
|
||||
// - session bookkeeping ran AFTER the exit early-return, so the compensating
|
||||
// userAdded.delete never ran on the left-panel chip exit and restoration
|
||||
// resurrected the mission layer the user just disabled;
|
||||
@@ -21,10 +25,7 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
const src = readFileSync(fileURLToPath(new URL('./ui.js', import.meta.url)), 'utf8');
|
||||
|
||||
const handlerStart = src.indexOf('_handleContextLayerChange(change) {');
|
||||
assert.ok(handlerStart > 0, 'handler found');
|
||||
const handlerEnd = src.indexOf('_syncContextModeButtons() {', handlerStart);
|
||||
const handler = src.slice(handlerStart, handlerEnd);
|
||||
const handler = _handleContextLayerChange.toString();
|
||||
|
||||
test('context handler: session bookkeeping runs before the exit early-return', () => {
|
||||
const record = handler.indexOf('recordContextSessionUserChange(');
|
||||
@@ -56,9 +57,7 @@ test('cancelled Space Missions entry resolves ownership before settled bookkeepi
|
||||
});
|
||||
|
||||
test('direct Context entry captures on the synchronous request boundary', () => {
|
||||
const attachStart = src.indexOf('attachDataManager(dataManager) {');
|
||||
const attachEnd = src.indexOf('_syncContextModeButtons()', attachStart);
|
||||
const attach = src.slice(attachStart, attachEnd);
|
||||
const attach = connectContextManager.toString();
|
||||
assert.match(
|
||||
attach,
|
||||
/subscribeVisibilityRequests\(\(change\) => \{[\s\S]*?shouldCaptureContextSession\(change\)/,
|
||||
@@ -70,9 +69,7 @@ test('direct Context entry captures on the synchronous request boundary', () =>
|
||||
});
|
||||
|
||||
test('Clear All defers a newer explicit mission guard until its batch settles', () => {
|
||||
const attachStart = src.indexOf('attachDataManager(dataManager) {');
|
||||
const attachEnd = src.indexOf('if (typeof this._dataManager?.subscribeBeforeDestroy', attachStart);
|
||||
const attach = src.slice(attachStart, attachEnd);
|
||||
const attach = connectContextManager.toString();
|
||||
assert.match(attach, /shouldDeferContextEntryDuringClear\(\{/);
|
||||
assert.match(attach, /_contextModeDeferredEntryIntent = \{/);
|
||||
assert.match(attach, /await this\._clearSelectedLayersManagerPromise/);
|
||||
@@ -80,10 +77,7 @@ test('Clear All defers a newer explicit mission guard until its batch settles',
|
||||
});
|
||||
|
||||
test('context restore replays explicit companion intent after the stale restore queue drains', () => {
|
||||
const restore = src.slice(
|
||||
src.indexOf('async _restoreContextSession('),
|
||||
src.indexOf('async _selectContextMode('),
|
||||
);
|
||||
const restore = _restoreContextSession.toString();
|
||||
const restoreAwait = restore.indexOf('await this._dataManager.restoreEnabledLayerIds(');
|
||||
const replay = restore.indexOf('await settleContextIntentReplay(');
|
||||
assert.ok(restoreAwait > 0, 'restore queue is awaited');
|
||||
@@ -92,10 +86,7 @@ test('context restore replays explicit companion intent after the stale restore
|
||||
});
|
||||
|
||||
test('context restore settles the Contacts coordinator before dependency fanout', () => {
|
||||
const restore = src.slice(
|
||||
src.indexOf('async _restoreContextSession('),
|
||||
src.indexOf('async _selectContextMode('),
|
||||
);
|
||||
const restore = _restoreContextSession.toString();
|
||||
const settle = restore.indexOf("const contactsCoordinatorId = 'military-awareness'");
|
||||
const coordinatorOff = restore.indexOf('const coordinatorSettled = await this._dataManager.setEnabled(', settle);
|
||||
const fanout = restore.indexOf('await this._dataManager.restoreEnabledLayerIds(', settle);
|
||||
@@ -106,10 +97,7 @@ test('context restore settles the Contacts coordinator before dependency fanout'
|
||||
});
|
||||
|
||||
test('context restore preserves its exact pending target after a failed transition', () => {
|
||||
const restore = src.slice(
|
||||
src.indexOf('async _restoreContextSession('),
|
||||
src.indexOf('async _selectContextMode('),
|
||||
);
|
||||
const restore = _restoreContextSession.toString();
|
||||
assert.match(restore, /const replayError = await settleContextIntentReplay\(\{/);
|
||||
assert.match(restore, /if \(restoreError && !this\._contextSessionSnapshot\)/);
|
||||
assert.match(restore, /enabledLayerIds: new Set\(restoreState\.enabledLayerIds\)/);
|
||||
@@ -130,7 +118,7 @@ test('context handler: either failed direct Context-shell start rolls the sessio
|
||||
assert.match(failedBranch, /const failureMessage =/);
|
||||
assert.match(
|
||||
failedBranch,
|
||||
/await this\._restoreContextSessionAfterLayerSettles\([\s\S]*?this\._showToast\(failureMessage\);\s*return true;/,
|
||||
/await this\._restoreContextSessionAfterLayerSettles\([\s\S]*?this\.showToast\(failureMessage\);\s*return true;/,
|
||||
);
|
||||
assert.match(failedBranch, /\},\s*failureMessage,\s*\)\);/);
|
||||
assert.match(failedBranch, /_trackContextLayerReaction\(/);
|
||||
@@ -139,10 +127,7 @@ test('context handler: either failed direct Context-shell start rolls the sessio
|
||||
/else if \(!this\._userFacingContextNotificationTokens\.has\(change\.notificationToken\)\)/,
|
||||
);
|
||||
|
||||
const deferredRestore = src.slice(
|
||||
src.indexOf('async _restoreContextSessionAfterLayerSettles('),
|
||||
src.indexOf('async _selectContextMode('),
|
||||
);
|
||||
const deferredRestore = _restoreContextSessionAfterLayerSettles.toString();
|
||||
assert.match(deferredRestore, /await this\._dataManager\?\.waitForLayerSettled\?\.\(layerId\)/);
|
||||
assert.match(deferredRestore, /return this\._restoreContextSession\(\{ notificationToken \}\)/);
|
||||
assert.doesNotMatch(failedBranch, /excludeLayerIds/);
|
||||
@@ -157,14 +142,11 @@ test('wrapped visibility blocks leave the accessible toast to the wrapper token
|
||||
blockedBranch,
|
||||
/!this\._userFacingContextNotificationTokens\.has\(change\.notificationToken\)/,
|
||||
);
|
||||
assert.match(blockedBranch, /this\._showToast\(/);
|
||||
assert.match(blockedBranch, /this\.showToast\(/);
|
||||
});
|
||||
|
||||
test('every user-facing Context exit route settles through the failure surface', () => {
|
||||
const initPanel = src.slice(
|
||||
src.indexOf('_initGlobalContextPanel() {'),
|
||||
src.indexOf('_captureContextSessionSnapshot(', src.indexOf('_initGlobalContextPanel() {')),
|
||||
);
|
||||
const initPanel = _initGlobalContextPanel.toString();
|
||||
assert.equal((initPanel.match(/void this\._runUserFacingContextAction/g) || []).length, 3);
|
||||
assert.doesNotMatch(initPanel, /falseIsFailure:\s*false/);
|
||||
assert.doesNotMatch(initPanel, /void this\._selectContextMode/);
|
||||
@@ -198,10 +180,7 @@ test('only the expanded Radio Enable gesture requests the contained post-enable
|
||||
});
|
||||
|
||||
test('right-rail context entry is transactional: activation result gates the mode', () => {
|
||||
const select = src.slice(
|
||||
src.indexOf('async _selectContextMode('),
|
||||
src.indexOf('async _deactivateContextForLayerChange('),
|
||||
);
|
||||
const select = _selectContextMode.toString();
|
||||
const activation = select.indexOf('activationIntent = this._dataManager._setEnabledWithIntent(');
|
||||
assert.ok(activation > 0, 'activation result captured');
|
||||
const failureBlock = select.slice(activation);
|
||||
@@ -220,10 +199,7 @@ test('right-rail context entry is transactional: activation result gates the mod
|
||||
});
|
||||
|
||||
test('Context entry awaits isolation and direct shell routes isolate in the visibility guard', () => {
|
||||
const select = src.slice(
|
||||
src.indexOf('async _selectContextMode('),
|
||||
src.indexOf('async _deactivateContextForLayerChange('),
|
||||
);
|
||||
const select = _selectContextMode.toString();
|
||||
assert.match(select, /await this\._clearLayersOutsideContextMode\(mode, \{ notificationToken, signal \}\)/);
|
||||
assert.match(select, /await this\._restoreContextSession\(\{ notificationToken, signal \}\)/);
|
||||
assert.doesNotMatch(select, /void this\._clearLayersOutsideContextMode\(mode\)/);
|
||||
@@ -255,12 +231,10 @@ test('Context entry awaits isolation and direct shell routes isolate in the visi
|
||||
// invalidates in-flight Context transactions so teardown cannot be raced into
|
||||
// publishing a mode.
|
||||
const dispose = src.slice(src.indexOf(' async dispose() {'), src.indexOf('this._stampNavigation();', src.indexOf(' async dispose() {')));
|
||||
assert.match(dispose, /this\._contextModeGeneration \+= 1;/);
|
||||
assert.match(dispose, /this\._contextControls\.stop\(\)/);
|
||||
assert.match(ContextControls.prototype.stop.toString(), /this\._contextModeGeneration\+\+/);
|
||||
|
||||
const guard = src.slice(
|
||||
src.indexOf('this._dataManagerVisibilityGuardUnsubscribe = this._dataManager.addVisibilityGuard'),
|
||||
src.indexOf("if (typeof this._dataManager?.subscribeBeforeDestroy === 'function')"),
|
||||
);
|
||||
const guard = connectContextManager.toString();
|
||||
assert.match(guard, /\['military-awareness', 'rocket-launches'\]\.includes\(change\.layerId\)/);
|
||||
assert.match(guard, /const notificationToken = change\.notificationToken \|\| Symbol\('direct-context-shell-entry'\)/);
|
||||
assert.match(guard, /this\._userFacingContextNotificationTokens\.add\(notificationToken\)/);
|
||||
@@ -277,10 +251,7 @@ test('a lost cross-mode switch says Context is off, and the state agrees', () =>
|
||||
// a bare "did not complete" while the operator's Context was silently gone.
|
||||
// Text and state are derived from the same verdict so they cannot disagree,
|
||||
// and the failed layer ids survive (Manjunath's honesty requirement).
|
||||
const setter = src.slice(
|
||||
src.indexOf(' async setContextMode(mode, {'),
|
||||
src.indexOf(' getCockpitState() {'),
|
||||
);
|
||||
const setter = contextModeAction.toString();
|
||||
assert.match(setter, /const priorMode = this\._contextMode;/, 'the report knows what was lost');
|
||||
assert.match(
|
||||
setter,
|
||||
@@ -301,14 +272,10 @@ test('a lost cross-mode switch says Context is off, and the state agrees', () =>
|
||||
});
|
||||
|
||||
test('Context cancellation reaches isolation and restore lifecycle mutations', () => {
|
||||
const clearStart = src.indexOf('async _clearLayersOutsideContextMode(');
|
||||
const clear = src.slice(clearStart, src.indexOf('_handleContextLayerChange(', clearStart));
|
||||
const clear = _clearLayersOutsideContextMode.toString();
|
||||
assert.match(clear, /setEnabled\(layerId, false, \{[\s\S]*?signal/);
|
||||
|
||||
const restore = src.slice(
|
||||
src.indexOf('async _restoreContextSession('),
|
||||
src.indexOf('async _restoreContextSessionAfterLayerSettles('),
|
||||
);
|
||||
const restore = _restoreContextSession.toString();
|
||||
assert.match(restore, /restoreEnabledLayerIds\([\s\S]*?signal/);
|
||||
assert.match(restore, /setEnabled: \(layerId, enabled, options = \{\}\)[\s\S]*?signal/);
|
||||
assert.match(restore, /error\.failedLayerIds = \[contactsCoordinatorId\]/);
|
||||
@@ -319,10 +286,7 @@ test('Context cancellation reaches isolation and restore lifecycle mutations', (
|
||||
});
|
||||
|
||||
test('Context facade preserves success when cancellation arrives after commit', () => {
|
||||
const setContextMode = src.slice(
|
||||
src.indexOf('async setContextMode('),
|
||||
src.indexOf('getCockpitState()', src.indexOf('async setContextMode(')),
|
||||
);
|
||||
const setContextMode = contextModeAction.toString();
|
||||
assert.match(
|
||||
setContextMode,
|
||||
/transitioned === null \|\| \(!requestIsCurrent\(\) && transitioned !== true\)/,
|
||||
@@ -334,10 +298,7 @@ test('Context facade preserves success when cancellation arrives after commit',
|
||||
});
|
||||
|
||||
test('Context production rollback paths merge primary and restore failed-layer identities', () => {
|
||||
const select = src.slice(
|
||||
src.indexOf('async _selectContextMode('),
|
||||
src.indexOf('async _deactivateContextForLayerChange('),
|
||||
);
|
||||
const select = _selectContextMode.toString();
|
||||
assert.equal(
|
||||
(select.match(/mergeContextTransitionErrors\(transitionError, restoreError\)/g) || []).length,
|
||||
2,
|
||||
@@ -347,10 +308,7 @@ test('Context production rollback paths merge primary and restore failed-layer i
|
||||
});
|
||||
|
||||
test('stale Context cancellation preserves rollback failed-layer identities', () => {
|
||||
const setContextMode = src.slice(
|
||||
src.indexOf('async setContextMode('),
|
||||
src.indexOf('getCockpitState()', src.indexOf('async setContextMode(')),
|
||||
);
|
||||
const setContextMode = contextModeAction.toString();
|
||||
assert.match(
|
||||
setContextMode,
|
||||
/if \(!requestIsCurrent\(\)\) \{[\s\S]*?\.\.\.cancellationResult\(\),[\s\S]*?failedLayerIds: \[\.\.\.error\.failedLayerIds\]/,
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { _selectContextMode } from './ui/contextTransactions.js';
|
||||
import { _syncContextModeButtons } from './ui/contextPresentation.js';
|
||||
import { _initGlobalContextPanel } from './ui/contextBindings.js';
|
||||
import { clearSelectedLayers } from './ui/contextActions.js';
|
||||
import { readFileSync as readRadioSource } from 'node:fs';
|
||||
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
|
||||
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
|
||||
@@ -18,16 +22,14 @@ test('Contacts and Space Missions both participate in the ordinary Tab sequence'
|
||||
assert.match(button[0], /tabindex="0"/);
|
||||
}
|
||||
|
||||
const sync = ui.match(/_syncContextModeButtons\(\) \{([\s\S]*?)\n \}\n\n \/\*\* Wire/);
|
||||
assert.ok(sync, 'Context mode sync is missing');
|
||||
assert.match(sync[1], /\[this\._globalContextFlightsBtn, this\._globalContextMissionsBtn\]/);
|
||||
assert.match(sync[1], /button\.tabIndex = 0/);
|
||||
assert.doesNotMatch(sync[1], /tabIndex\s*=\s*[^;]*\?\s*-1/);
|
||||
const syncSource = _syncContextModeButtons.toString();
|
||||
assert.match(syncSource, /\[this\._globalContextFlightsBtn, this\._globalContextMissionsBtn\]/);
|
||||
assert.match(syncSource, /button\.tabIndex = 0/);
|
||||
assert.doesNotMatch(syncSource, /tabIndex\s*=\s*[^;]*\?\s*-1/);
|
||||
});
|
||||
|
||||
test('Context transition state preserves focus and Tab availability until settle', () => {
|
||||
const sync = ui.match(/_syncContextModeButtons\(\) \{([\s\S]*?)\n \}\n\n \/\*\* Wire/);
|
||||
assert.ok(sync, 'Context mode sync is missing');
|
||||
const syncSource = _syncContextModeButtons.toString();
|
||||
|
||||
const attributes = () => new Map();
|
||||
const makeButton = () => {
|
||||
@@ -56,6 +58,8 @@ test('Context transition state preserves focus and Tab availability until settle
|
||||
const priorDocument = globalThis.document;
|
||||
globalThis.document = { activeElement: missions, getElementById: () => panel };
|
||||
const owner = {
|
||||
_globalContextPanel: panel,
|
||||
actions: { syncDetection() {}, scheduleLayout() {} },
|
||||
_contextMode: null,
|
||||
_contextModeChanging: true,
|
||||
_globalContextFlightsBtn: contacts,
|
||||
@@ -68,7 +72,7 @@ test('Context transition state preserves focus and Tab availability until settle
|
||||
_scheduleRightPanelLayout() {},
|
||||
};
|
||||
try {
|
||||
Function(sync[1]).call(owner);
|
||||
_syncContextModeButtons.call(owner);
|
||||
assert.equal(globalThis.document.activeElement, missions, 'busy sync retains focused Space Missions');
|
||||
for (const button of [contacts, missions]) {
|
||||
assert.equal(button.disabled, false);
|
||||
@@ -79,7 +83,7 @@ test('Context transition state preserves focus and Tab availability until settle
|
||||
|
||||
owner._contextModeChanging = false;
|
||||
owner._contextMode = 'space-missions';
|
||||
Function(sync[1]).call(owner);
|
||||
_syncContextModeButtons.call(owner);
|
||||
assert.equal(globalThis.document.activeElement, missions, 'settled sync retains focused Space Missions');
|
||||
for (const button of [contacts, missions]) {
|
||||
assert.equal(button.disabled, false);
|
||||
@@ -93,32 +97,22 @@ test('Context transition state preserves focus and Tab availability until settle
|
||||
});
|
||||
|
||||
test('Context activation and Clear All never native-disable tabs and guard repeated clicks', () => {
|
||||
const init = ui.slice(
|
||||
ui.indexOf('_initGlobalContextPanel() {'),
|
||||
ui.indexOf('async _runUserFacingContextAction(', ui.indexOf('_initGlobalContextPanel() {')),
|
||||
);
|
||||
const select = ui.slice(
|
||||
ui.indexOf('async _selectContextMode('),
|
||||
ui.indexOf('async _deactivateContextForLayerChange(', ui.indexOf('async _selectContextMode(')),
|
||||
);
|
||||
const clear = ui.slice(
|
||||
ui.indexOf('clearSelectedLayers() {'),
|
||||
ui.indexOf('resetToGlobeView() {', ui.indexOf('clearSelectedLayers() {')),
|
||||
);
|
||||
assert.equal((init.match(/if \(this\._contextModeChanging \|\| this\._clearSelectedLayersPromise\) return;/g) || []).length, 2);
|
||||
const init = _initGlobalContextPanel.toString();
|
||||
const select = _selectContextMode.toString();
|
||||
const clear = clearSelectedLayers.toString();
|
||||
assert.equal((init.match(/if \(this\.destroyed \|\| this\._contextModeChanging \|\| this\._clearSelectedLayersPromise\) return;/g) || []).length, 2);
|
||||
assert.doesNotMatch(select, /_globalContext(?:Flights|Missions)Btn\.disabled\s*=\s*true/);
|
||||
assert.doesNotMatch(clear, /_globalContext(?:Flights|Missions)Btn\.disabled\s*=\s*true/);
|
||||
});
|
||||
|
||||
test('Context tablist retains Left, Right, Home, and End keyboard navigation', () => {
|
||||
const init = ui.match(/_initGlobalContextPanel\(\) \{([\s\S]*?)\n this\._globalContextFlightsBtn\?\.addEventListener/);
|
||||
assert.ok(init, 'Context panel initialization is missing');
|
||||
assert.match(init[1], /event\.key === 'ArrowRight'/);
|
||||
assert.match(init[1], /event\.key === 'ArrowLeft'/);
|
||||
assert.match(init[1], /event\.key === 'Home'/);
|
||||
assert.match(init[1], /event\.key === 'End'/);
|
||||
assert.match(init[1], /contextTabs\[nextIndex\]\.focus\(\{ preventScroll: true \}\)/);
|
||||
assert.match(init[1], /contextTabs\[nextIndex\]\.click\(\)/);
|
||||
const initSource = _initGlobalContextPanel.toString();
|
||||
assert.match(initSource, /event\.key === 'ArrowRight'/);
|
||||
assert.match(initSource, /event\.key === 'ArrowLeft'/);
|
||||
assert.match(initSource, /event\.key === 'Home'/);
|
||||
assert.match(initSource, /event\.key === 'End'/);
|
||||
assert.match(initSource, /contextTabs\[nextIndex\]\.focus\(\{ preventScroll: true \}\)/);
|
||||
assert.match(initSource, /contextTabs\[nextIndex\]\.click\(\)/);
|
||||
});
|
||||
|
||||
test('Context tabs draw a visible keyboard-focus outline including active tabs', () => {
|
||||
@@ -136,16 +130,10 @@ test('Context tabs draw a visible keyboard-focus outline including active tabs',
|
||||
});
|
||||
|
||||
test('Context async action buttons remain focused while busy', () => {
|
||||
const init = ui.slice(
|
||||
ui.indexOf('_initGlobalContextPanel() {'),
|
||||
ui.indexOf('async _runUserFacingContextAction(', ui.indexOf('_initGlobalContextPanel() {')),
|
||||
);
|
||||
const init = _initGlobalContextPanel.toString();
|
||||
const radio = radioBindings.slice(radioBindings.indexOf('const toggleRadio = async (trigger) => {'), radioBindings.indexOf('this.listen(this._contextRadioToggleBtn,'));
|
||||
const radioSync = radioPresentation.slice(0, radioPresentation.indexOf('if (this._radioFilter)'));
|
||||
const clear = ui.slice(
|
||||
ui.indexOf('clearSelectedLayers() {'),
|
||||
ui.indexOf('resetToGlobeView() {', ui.indexOf('clearSelectedLayers() {')),
|
||||
);
|
||||
const clear = clearSelectedLayers.toString();
|
||||
|
||||
assert.match(init, /button\.getAttribute\('aria-busy'\) === 'true'/);
|
||||
assert.doesNotMatch(init, /button\.disabled\s*=\s*true/);
|
||||
@@ -155,7 +143,7 @@ test('Context async action buttons remain focused while busy', () => {
|
||||
assert.match(radioSync, new RegExp(`${name}\\.disabled = false`));
|
||||
}
|
||||
assert.doesNotMatch(clear, /_clearSelectedLayersBtn\.disabled\s*=\s*true/);
|
||||
assert.match(clear, /_clearLayersControl\?\.setBusy\(true\)/);
|
||||
assert.match(clear, /this\.setClearBusy\(true\)/);
|
||||
const control = readFileSync(new URL('./ui/clearLayersControl.js', import.meta.url), 'utf8');
|
||||
assert.match(control, /button\.setAttribute\('aria-busy', String\(busy\)\)/);
|
||||
assert.doesNotMatch(control, /button\.disabled\s*=/);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { _claimContextVisualAuthority, setContextMode } from './ui/contextActions.js';
|
||||
import { _initGlobalContextPanel } from './ui/contextBindings.js';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
@@ -464,19 +466,17 @@ test('explicit Context transitions claim the visual restore lane before transiti
|
||||
assert.ok(claimIndex < mutationIndex, `${label} must claim before mutation`);
|
||||
};
|
||||
|
||||
const helper = sourceBlock(
|
||||
' _claimContextVisualAuthority() {',
|
||||
' async _selectContextMode(mode, {',
|
||||
);
|
||||
const helper = _claimContextVisualAuthority.toString();
|
||||
assert.ok(
|
||||
helper.includes("claimRestoreLane?.('visual')"),
|
||||
helper.includes('this.actions.claimVisualAuthority()'),
|
||||
'the Context authority helper must claim the visual lane',
|
||||
);
|
||||
|
||||
const contextPanel = sourceBlock(' _initGlobalContextPanel() {', ' async _runUserFacingContextAction(');
|
||||
assert.match(uiSource, /claimVisualAuthority: \(\) => this\.shareLinkManager\?\.claimRestoreLane\?\.\('visual'\)/);
|
||||
const contextPanel = _initGlobalContextPanel.toString();
|
||||
for (const [start, end, label] of [
|
||||
["this._globalContextFlightsBtn?.addEventListener('click'", "this._globalContextMissionsBtn?.addEventListener('click'", 'Contacts tab'],
|
||||
["this._globalContextMissionsBtn?.addEventListener('click'", 'CONTEXT_PANEL_END', 'Space Missions tab'],
|
||||
["this.listen(this._globalContextFlightsBtn, 'click'", "this.listen(this._globalContextMissionsBtn, 'click'", 'Contacts tab'],
|
||||
["this.listen(this._globalContextMissionsBtn, 'click'", 'CONTEXT_PANEL_END', 'Space Missions tab'],
|
||||
]) {
|
||||
const startIndex = contextPanel.indexOf(start);
|
||||
const endIndex = end === 'CONTEXT_PANEL_END'
|
||||
@@ -487,7 +487,7 @@ test('explicit Context transitions claim the visual restore lane before transiti
|
||||
}
|
||||
|
||||
// The voice/tool facade validates the mode first, then transitions.
|
||||
const facade = sourceBlock(' async setContextMode(mode, {', ' getCockpitState() {');
|
||||
const facade = setContextMode.toString();
|
||||
assertContextClaimsBefore(facade, 'this._selectContextMode(', 'setContextMode facade');
|
||||
// Authority is taken per validated branch, never ahead of validation. The
|
||||
// OFF branch is validated by its own guard; the named-mode branch must claim
|
||||
@@ -517,8 +517,8 @@ test('explicit Context transitions claim the visual restore lane before transiti
|
||||
// detection: `_detectionUserOverridden` is what suppresses the military-style
|
||||
// auto-enable for the rest of the session. Contacts entry is not that.
|
||||
test('Context lane claims never set the session detection-override flag', () => {
|
||||
const contextPanel = sourceBlock(' _initGlobalContextPanel() {', ' async _runUserFacingContextAction(');
|
||||
const facade = sourceBlock(' async setContextMode(mode, {', ' getCockpitState() {');
|
||||
const contextPanel = _initGlobalContextPanel.toString();
|
||||
const facade = setContextMode.toString();
|
||||
for (const [block, label] of [
|
||||
[contextPanel, 'Context panel'],
|
||||
[facade, 'setContextMode facade'],
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { ContextControls } from './contextControls.js';
|
||||
@@ -0,0 +1,219 @@
|
||||
import { settleUserFacingContextAction, contextModeWord } from '../contextModePolicy.js';
|
||||
|
||||
export async function _runUserFacingContextAction(
|
||||
operation,
|
||||
message = 'Context could not restore every layer; try again',
|
||||
{ falseIsFailure = true } = {},
|
||||
) {
|
||||
if (this.destroyed) return false;
|
||||
const notificationToken = Symbol('user-facing-context-action');
|
||||
this._userFacingContextNotificationTokens.add(notificationToken);
|
||||
try {
|
||||
return await settleUserFacingContextAction({
|
||||
operation: () => operation(notificationToken),
|
||||
falseIsFailure,
|
||||
onFailure: (error) => {
|
||||
if (this.destroyed) return;
|
||||
console.warn('[Context] user-facing transition failed', error);
|
||||
this.showToast(message);
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
this._userFacingContextNotificationTokens.delete(notificationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim the visual restore lane for an explicit Context transition.
|
||||
*
|
||||
* Contacts OWNS detection while active (forced Dense @ 75%), so entering or
|
||||
* leaving it is a visual-lane gesture exactly like the HUD or detection
|
||||
* controls. Without this claim, the shared-view restore that lands 1.5 s into
|
||||
* startup re-applied the link's `dm`/`dd` over the forced preset and Contacts
|
||||
* lost its own overlay mid-session.
|
||||
*
|
||||
* Deliberately does NOT set `_detectionUserOverridden`: that flag means the
|
||||
* OPERATOR hand-edited detection and suppresses the military-style
|
||||
* auto-enable for the rest of the session. Context entry is not that, and
|
||||
* conflating them would silently disable a separate landed behavior.
|
||||
*
|
||||
* Call only for VALIDATED explicit transitions — never for programmatic or
|
||||
* restore-driven ones, which must stay eligible for the shared visual state.
|
||||
*/
|
||||
export function _claimContextVisualAuthority() {
|
||||
this.actions.claimVisualAuthority();
|
||||
}
|
||||
|
||||
export function getContextModeState() {
|
||||
return {
|
||||
mode: this._contextMode || null,
|
||||
active: Boolean(this._contextMode),
|
||||
changing: Boolean(this._contextModeChanging),
|
||||
entering: this._contextModeEntering || null,
|
||||
canContact: !this._contextMode || this._contextMode === 'flights',
|
||||
canMission: !this._contextMode || this._contextMode === 'space-missions',
|
||||
snapshotCaptured: Boolean(this._contextSessionSnapshot),
|
||||
};
|
||||
}
|
||||
|
||||
export async function setContextMode(mode, {
|
||||
notificationToken = null,
|
||||
signal = null,
|
||||
isCurrent = null,
|
||||
claimVisualAuthority = true,
|
||||
} = {}) {
|
||||
const requestIsCurrent = () => !this.destroyed && !signal?.aborted
|
||||
&& (typeof isCurrent !== 'function' || isCurrent());
|
||||
const cancellationResult = () => ({
|
||||
ok: false,
|
||||
action: 'set_context_mode',
|
||||
cancelled: true,
|
||||
error: 'Context request was superseded by a newer voice turn',
|
||||
...this.getContextModeState(),
|
||||
...(this._contextTransitionFailedLayerIds?.length
|
||||
? { failedLayerIds: [...this._contextTransitionFailedLayerIds] }
|
||||
: {}),
|
||||
});
|
||||
if (!requestIsCurrent()) return cancellationResult();
|
||||
try {
|
||||
if (!mode || mode === 'off') {
|
||||
// Validated explicit transition — Context owns detection, so take the
|
||||
// visual lane before a delayed shared restore can reclaim it. Internal
|
||||
// Cockpit choreography opts out: it is not an operator Context request.
|
||||
if (claimVisualAuthority) this._claimContextVisualAuthority();
|
||||
const result = await this._selectContextMode(null, { notificationToken, signal });
|
||||
if (result === null || (!requestIsCurrent() && result !== true)) return cancellationResult();
|
||||
const state = this.getContextModeState();
|
||||
return {
|
||||
ok: result === true,
|
||||
action: 'set_context_mode',
|
||||
mode: state.mode,
|
||||
...state,
|
||||
...(result === true ? {} : { error: 'Context mode transition did not complete' }),
|
||||
...(this._contextTransitionFailedLayerIds?.length
|
||||
? { failedLayerIds: [...this._contextTransitionFailedLayerIds] }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
const canonical = mode === 'contacts' ? 'flights' : mode;
|
||||
if (!['flights', 'space-missions'].includes(canonical)) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'set_context_mode',
|
||||
error: `Unknown context mode: ${mode}`,
|
||||
mode: this._contextMode,
|
||||
...this.getContextModeState(),
|
||||
};
|
||||
}
|
||||
const priorMode = this._contextMode;
|
||||
// Claimed only after the mode enum validates above, so a rejected request
|
||||
// takes no authority and leaves the shared visual state eligible. Internal
|
||||
// Cockpit choreography opts out: it is not an operator Context request.
|
||||
if (claimVisualAuthority) this._claimContextVisualAuthority();
|
||||
const transitioned = await this._selectContextMode(canonical, { notificationToken, signal });
|
||||
if (transitioned === null || (!requestIsCurrent() && transitioned !== true)) return cancellationResult();
|
||||
const state = this.getContextModeState();
|
||||
// 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:
|
||||
// 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
|
||||
// below carry the same verdict, so text and state cannot disagree.
|
||||
const crossModeSwitchLost = transitioned !== true
|
||||
&& Boolean(priorMode) && priorMode !== canonical && !state.mode;
|
||||
return {
|
||||
ok: transitioned === true,
|
||||
action: 'set_context_mode',
|
||||
mode: state.mode,
|
||||
...state,
|
||||
...(transitioned === true ? {} : {
|
||||
// Named in the operator's vocabulary, not the internal id: this
|
||||
// string is read by the voice model, which takes 'contacts'.
|
||||
error: crossModeSwitchLost
|
||||
? `Switch to ${contextModeWord(canonical)} did not complete — Context is now off`
|
||||
: 'Context mode transition did not complete',
|
||||
...(crossModeSwitchLost ? { contextOff: true, priorMode } : {}),
|
||||
}),
|
||||
...(this._contextTransitionFailedLayerIds?.length
|
||||
? { failedLayerIds: [...this._contextTransitionFailedLayerIds] }
|
||||
: {}),
|
||||
};
|
||||
} catch (error) {
|
||||
if (!requestIsCurrent()) {
|
||||
return {
|
||||
...cancellationResult(),
|
||||
...(Array.isArray(error?.failedLayerIds)
|
||||
? { failedLayerIds: [...error.failedLayerIds] }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
action: 'set_context_mode',
|
||||
error: error?.message || 'Context mode transition failed',
|
||||
...(Array.isArray(error?.failedLayerIds)
|
||||
? { failedLayerIds: [...error.failedLayerIds] }
|
||||
: {}),
|
||||
...this.getContextModeState(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function clearSelectedLayers() {
|
||||
if (this.destroyed) return Promise.resolve({ targetIds: [], items: [], clearedIds: [], notClearedIds: [], cancelled: true });
|
||||
if (this._clearSelectedLayersPromise) return this._clearSelectedLayersPromise;
|
||||
if (!this._dataManager?.clearSelectedLayers) {
|
||||
return Promise.resolve({ targetIds: [], items: [], clearedIds: [], notClearedIds: [] });
|
||||
}
|
||||
const generation = ++this._contextModeGeneration;
|
||||
const notificationToken = Symbol('clear-selected-layers');
|
||||
if (this._contextRestoreState) this._contextRestoreState.cancelled = true;
|
||||
this._contextModeChanging = true;
|
||||
this._contextMode = null;
|
||||
this._contextModeEntering = null;
|
||||
this._contextModeEntryIntent = null;
|
||||
this._contextModeReplacementIntent = null;
|
||||
this._contextSessionSnapshot = null;
|
||||
this._contextRestoreState = null;
|
||||
this._preservePanelStateDuringLayerClear = true;
|
||||
this._syncContextModeButtons();
|
||||
this._userFacingContextNotificationTokens.add(notificationToken);
|
||||
this.setClearBusy(true);
|
||||
|
||||
const managerOperation = this._dataManager.clearSelectedLayers({
|
||||
origin: 'user',
|
||||
notificationToken,
|
||||
});
|
||||
this._clearSelectedLayersManagerPromise = managerOperation;
|
||||
const operation = managerOperation.then((result) => {
|
||||
if (result.targetIds.length === 0) {
|
||||
this.showToast('No selected data layers');
|
||||
} else if (result.notClearedIds.length > 0) {
|
||||
this.showToast(`${result.notClearedIds.length} data layer${result.notClearedIds.length === 1 ? '' : 's'} could not be cleared`);
|
||||
} else {
|
||||
this.showToast(`Cleared ${result.clearedIds.length} data layer${result.clearedIds.length === 1 ? '' : 's'}`);
|
||||
}
|
||||
return result;
|
||||
}).catch((error) => {
|
||||
console.warn('[Data] clear selected layers failed', error);
|
||||
this.showToast('Selected data layers could not be cleared');
|
||||
return {
|
||||
targetIds: [],
|
||||
items: [],
|
||||
clearedIds: [],
|
||||
notClearedIds: [],
|
||||
error,
|
||||
};
|
||||
}).finally(() => {
|
||||
this._userFacingContextNotificationTokens.delete(notificationToken);
|
||||
if (generation === this._contextModeGeneration) {
|
||||
this._contextModeChanging = false;
|
||||
this._syncContextModeButtons();
|
||||
}
|
||||
this.setClearBusy(false);
|
||||
this._preservePanelStateDuringLayerClear = false;
|
||||
this._clearSelectedLayersManagerPromise = null;
|
||||
this._clearSelectedLayersPromise = null;
|
||||
});
|
||||
this._clearSelectedLayersPromise = operation;
|
||||
return operation;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { shouldExpandGlobalContextPanel } from '../rightRailPolicy.js';
|
||||
|
||||
export function _initGlobalContextPanel() {
|
||||
const contextTabs = [this._globalContextFlightsBtn, this._globalContextMissionsBtn].filter(Boolean);
|
||||
contextTabs.forEach((tab, index) => this.listen(tab, 'keydown', (event) => {
|
||||
let nextIndex = null;
|
||||
if (event.key === 'ArrowRight') 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 === 'End') nextIndex = contextTabs.length - 1;
|
||||
if (nextIndex === null) return;
|
||||
event.preventDefault();
|
||||
contextTabs[nextIndex].focus({ preventScroll: true });
|
||||
contextTabs[nextIndex].click();
|
||||
}));
|
||||
this.listen(this._globalContextFlightsBtn, 'click', () => {
|
||||
if (this.destroyed || this._contextModeChanging || this._clearSelectedLayersPromise) return;
|
||||
const nextMode = this._contextMode === 'flights' ? null : 'flights';
|
||||
this._claimContextVisualAuthority();
|
||||
void this._runUserFacingContextAction(
|
||||
(notificationToken) => this._selectContextMode(
|
||||
nextMode,
|
||||
{ notificationToken },
|
||||
),
|
||||
'Contacts could not complete the requested transition; try again',
|
||||
).then((succeeded) => {
|
||||
if (!this.destroyed && nextMode && shouldExpandGlobalContextPanel({
|
||||
action: 'contacts',
|
||||
explicitUserAction: true,
|
||||
succeeded: succeeded === true,
|
||||
})) this.actions.setPanelCollapsed('global-context-panel', false, { explicit: true });
|
||||
});
|
||||
});
|
||||
this.listen(this._globalContextMissionsBtn, 'click', () => {
|
||||
if (this.destroyed || this._contextModeChanging || this._clearSelectedLayersPromise) return;
|
||||
const nextMode = this._contextMode === 'space-missions' ? null : 'space-missions';
|
||||
this._claimContextVisualAuthority();
|
||||
void this._runUserFacingContextAction(
|
||||
(notificationToken) => this._selectContextMode(
|
||||
nextMode,
|
||||
{ notificationToken },
|
||||
),
|
||||
'Space Missions could not complete the requested transition; try again',
|
||||
).then((succeeded) => {
|
||||
if (!this.destroyed && nextMode && shouldExpandGlobalContextPanel({
|
||||
action: 'space-missions',
|
||||
explicitUserAction: true,
|
||||
succeeded: succeeded === true,
|
||||
})) this.actions.setPanelCollapsed('global-context-panel', false, { explicit: true });
|
||||
});
|
||||
});
|
||||
this.listen(this._installationsSearchBtn, 'click', () => {
|
||||
if (this.destroyed || !this._dataManager?.layers?.has('military-installations')) return;
|
||||
const button = this._installationsSearchBtn;
|
||||
if (button.getAttribute('aria-busy') === 'true') return;
|
||||
button.setAttribute('aria-disabled', 'true');
|
||||
button.setAttribute('aria-busy', 'true');
|
||||
void this._runUserFacingContextAction(async (notificationToken) => {
|
||||
const enabled = await this._dataManager.setEnabled('military-installations', true, {
|
||||
origin: 'user',
|
||||
notificationToken,
|
||||
});
|
||||
if (this.destroyed || enabled === false || !this._dataManager.isEnabled('military-installations')) return false;
|
||||
const searched = await this.installations.searchNearby?.();
|
||||
if (this.destroyed || searched === false) return false;
|
||||
const stats = this.installations.getStats?.();
|
||||
this.showToast(stats?.statusMessage || (stats?.status === 'zoom-in'
|
||||
? 'Zoom in to search mapped installations'
|
||||
: 'Nearby installations refreshed'));
|
||||
return true;
|
||||
}, 'Nearby installations could not be refreshed; try again').finally(() => {
|
||||
if (this.destroyed) return;
|
||||
button.setAttribute('aria-disabled', 'false');
|
||||
button.setAttribute('aria-busy', 'false');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { _initGlobalContextPanel } from './contextBindings.js';
|
||||
import { _syncContextModeButtons } from './contextPresentation.js';
|
||||
import { _trackContextLayerReaction, _waitForContextLayerSettlement, _captureContextSessionSnapshot, _restoreContextSession, _restoreContextSessionAfterLayerSettles } from './contextSession.js';
|
||||
import { _selectContextMode, _deactivateContextForLayerChange, _clearLayersOutsideContextMode } from './contextTransactions.js';
|
||||
import { _handleContextLayerChange } from './contextLayerChanges.js';
|
||||
import { _runUserFacingContextAction, _claimContextVisualAuthority, getContextModeState, setContextMode, clearSelectedLayers } from './contextActions.js';
|
||||
import { connectContextManager } from './contextSubscriptions.js';
|
||||
|
||||
/** Own Context transactions and controls through explicit layer and presentation ports. */
|
||||
export class ContextControls {
|
||||
constructor({ elements, installations, actions }) {
|
||||
Object.assign(this, elements);
|
||||
this.installations = installations;
|
||||
this.actions = actions;
|
||||
this.listeners = new AbortController();
|
||||
this.destroyed = false;
|
||||
this._dataManager = null;
|
||||
this._contextMode = null;
|
||||
this._contextModeChanging = false;
|
||||
this._contextModeGeneration = 0;
|
||||
this._contextModeEntering = null;
|
||||
this._contextModeEntryIntent = null;
|
||||
this._contextModeReplacementIntent = null;
|
||||
this._contextModeDeferredEntryIntent = null;
|
||||
this._contextSessionSnapshot = null;
|
||||
this._contextRestoreState = null;
|
||||
this._contextLayerReactionPromises = new Set();
|
||||
this._preservePanelStateDuringLayerClear = false;
|
||||
this._userFacingContextNotificationTokens = new Set();
|
||||
this._clearSelectedLayersPromise = null;
|
||||
this._clearSelectedLayersManagerPromise = null;
|
||||
this._initGlobalContextPanel();
|
||||
}
|
||||
showToast(...args) { if (!this.destroyed) this.actions.showToast(...args); }
|
||||
setClearBusy(...args) { if (!this.destroyed) this.actions.setClearBusy(...args); }
|
||||
get cockpitView() { return this.actions.getCockpit(); }
|
||||
listen(target, type, callback, options = {}) {
|
||||
target?.addEventListener(type, callback, { ...options, signal: this.listeners.signal });
|
||||
}
|
||||
connect(manager) { return connectContextManager.call(this, manager); }
|
||||
disconnect() {
|
||||
for (const field of ['_contextManagerUnsubscribe', '_dataManagerBeforeDestroyUnsubscribe', '_dataManagerVisibilityGuardUnsubscribe', '_dataManagerVisibilityRequestUnsubscribe']) {
|
||||
this[field]?.();
|
||||
this[field] = null;
|
||||
}
|
||||
}
|
||||
stop() {
|
||||
if (this.destroyed) return;
|
||||
this.destroyed = true;
|
||||
this._contextModeGeneration++;
|
||||
this.listeners.abort();
|
||||
}
|
||||
async restoreForDisposal() {
|
||||
this._contextModeChanging = true;
|
||||
this._contextMode = null;
|
||||
await this._restoreContextSession();
|
||||
}
|
||||
_initGlobalContextPanel(...args) { return _initGlobalContextPanel.call(this, ...args); }
|
||||
_syncContextModeButtons(...args) { return _syncContextModeButtons.call(this, ...args); }
|
||||
_trackContextLayerReaction(...args) { return _trackContextLayerReaction.call(this, ...args); }
|
||||
_waitForContextLayerSettlement(...args) { return _waitForContextLayerSettlement.call(this, ...args); }
|
||||
_captureContextSessionSnapshot(...args) { return _captureContextSessionSnapshot.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); }
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { ContextControls } from './contextControls.js';
|
||||
|
||||
class Button extends EventTarget {
|
||||
constructor() { super(); 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));
|
||||
function fixture(t, elements = {}) {
|
||||
const calls = [];
|
||||
const controls = new ContextControls({
|
||||
elements,
|
||||
installations: {},
|
||||
actions: {
|
||||
getCockpit: () => null,
|
||||
claimVisualAuthority: () => calls.push('claim'),
|
||||
showToast: () => calls.push('notice'),
|
||||
setClearBusy: () => {},
|
||||
syncDetection: () => {},
|
||||
scheduleLayout: () => {},
|
||||
refreshRadio: () => {},
|
||||
setPanelCollapsed: () => calls.push('expand'),
|
||||
},
|
||||
});
|
||||
t.after(() => { controls.stop(); controls.disconnect(); });
|
||||
return { controls, calls };
|
||||
}
|
||||
|
||||
test('Context starts with an explicit idle snapshot', (t) => {
|
||||
const { controls } = fixture(t);
|
||||
assert.deepEqual(controls.getContextModeState(), {
|
||||
mode: null, active: false, changing: false, entering: null,
|
||||
canContact: true, canMission: true, snapshotCaptured: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('a pending Context tab cannot reopen a disposed panel', async (t) => {
|
||||
const tab = new Button();
|
||||
const { controls, calls } = fixture(t, { _globalContextFlightsBtn: tab });
|
||||
let release;
|
||||
controls._selectContextMode = () => new Promise((resolve) => { release = resolve; });
|
||||
tab.dispatchEvent(new Event('click'));
|
||||
assert.deepEqual(calls, ['claim']);
|
||||
controls.stop();
|
||||
release(true);
|
||||
await turn();
|
||||
tab.dispatchEvent(new Event('click'));
|
||||
assert.deepEqual(calls, ['claim']);
|
||||
});
|
||||
|
||||
test('stopping during installations enable prevents the delayed search', async (t) => {
|
||||
const button = new Button();
|
||||
const { controls, calls } = fixture(t, { _installationsSearchBtn: button });
|
||||
let release;
|
||||
controls._dataManager = {
|
||||
layers: new Map([['military-installations', {}]]),
|
||||
setEnabled: () => new Promise((resolve) => { release = resolve; }),
|
||||
isEnabled: () => true,
|
||||
};
|
||||
controls.installations.searchNearby = () => assert.fail('search started after stop');
|
||||
button.dispatchEvent(new Event('click'));
|
||||
controls.stop();
|
||||
release(true);
|
||||
await turn();
|
||||
assert.equal(calls.length, 0);
|
||||
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) => {
|
||||
const button = new Button();
|
||||
const { controls, calls } = fixture(t, { _installationsSearchBtn: button });
|
||||
let release;
|
||||
controls._dataManager = {
|
||||
layers: new Map([['military-installations', {}]]),
|
||||
setEnabled: async () => true, isEnabled: () => true,
|
||||
};
|
||||
controls.installations.searchNearby = () => new Promise((resolve) => { release = resolve; });
|
||||
button.dispatchEvent(new Event('click'));
|
||||
await turn();
|
||||
controls.stop();
|
||||
release(true);
|
||||
await turn();
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('reconnecting and disposal release each manager subscription exactly once', (t) => {
|
||||
const { controls } = fixture(t);
|
||||
const counts = new Map();
|
||||
const manager = (name) => Object.fromEntries([
|
||||
'subscribe', 'subscribeVisibilityRequests', 'addVisibilityGuard', 'subscribeBeforeDestroy',
|
||||
].map((method) => [method, () => () => {
|
||||
const key = `${name}:${method}`;
|
||||
counts.set(key, (counts.get(key) || 0) + 1);
|
||||
}]));
|
||||
controls.connect(manager('a'));
|
||||
controls.connect(manager('b'));
|
||||
assert.equal(counts.size, 4);
|
||||
controls.stop();
|
||||
controls.disconnect();
|
||||
controls.disconnect();
|
||||
controls.connect(manager('c'));
|
||||
assert.equal(counts.size, 8);
|
||||
assert.ok([...counts.values()].every((count) => count === 1));
|
||||
});
|
||||
|
||||
test('a rejected tracked reaction settles without an unhandled rejection', async (t) => {
|
||||
const { controls } = fixture(t);
|
||||
const failure = new Error('source unavailable');
|
||||
const pending = controls._trackContextLayerReaction(Promise.reject(failure));
|
||||
await assert.rejects(pending, failure);
|
||||
await controls._waitForContextLayerSettlement();
|
||||
assert.equal(controls._contextLayerReactionPromises.size, 0);
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import { cockpitEntryAllowed, isExplicitUserIntentOrigin, recordContextRestoreExplicitChange, recordContextSessionUserChange, shouldExitContextForLayerChange, spaceMissionEntryCancellationDisposition } from '../contextModePolicy.js';
|
||||
|
||||
export function _handleContextLayerChange(change) {
|
||||
if (this.destroyed) return;
|
||||
if (change?.layerId === 'radio' && [
|
||||
'visibility-transition',
|
||||
'visibility',
|
||||
'visibility-cancelled',
|
||||
'visibility-failed',
|
||||
].includes(change.type)) {
|
||||
this.actions.refreshRadio();
|
||||
}
|
||||
if (change?.type === 'visibility-transition') return;
|
||||
// The effective mode must be read BEFORE the entering flag is cleared:
|
||||
// the entry layer's own enable event is the one that clears it, and the
|
||||
// session bookkeeping below needs to know a mode was being entered.
|
||||
const effectiveContextMode = this._contextModeEntering || this._contextMode;
|
||||
if (change?.type === 'visibility-cancelled') {
|
||||
const cancellationDisposition = spaceMissionEntryCancellationDisposition({
|
||||
change,
|
||||
});
|
||||
if (
|
||||
this._contextModeDeferredEntryIntent?.layerId === change.layerId
|
||||
&& this._contextModeDeferredEntryIntent.intentEpoch === change.intentEpoch
|
||||
) {
|
||||
if (cancellationDisposition !== 'replacement') {
|
||||
this._contextModeDeferredEntryIntent = null;
|
||||
}
|
||||
}
|
||||
if (cancellationDisposition === 'replacement') {
|
||||
this._contextModeEntering = 'space-missions';
|
||||
const entryIntent = this._contextModeEntryIntent;
|
||||
if (
|
||||
entryIntent?.generation === this._contextModeGeneration
|
||||
&& entryIntent.layerId === change.layerId
|
||||
&& entryIntent.intentEpoch === change.intentEpoch
|
||||
) {
|
||||
this._contextModeReplacementIntent = {
|
||||
generation: entryIntent.generation,
|
||||
layerId: change.layerId,
|
||||
intentEpoch: change.successorIntentEpoch,
|
||||
};
|
||||
}
|
||||
} else if (cancellationDisposition === 'restore') {
|
||||
this._contextModeEntering = null;
|
||||
this._contextModeEntryIntent = null;
|
||||
this._contextModeReplacementIntent = null;
|
||||
if (this._contextSessionSnapshot && !this._contextModeChanging) {
|
||||
this._contextMode = null;
|
||||
void this._trackContextLayerReaction(this._runUserFacingContextAction(
|
||||
async (notificationToken) => {
|
||||
await this._restoreContextSessionAfterLayerSettles(
|
||||
change.layerId,
|
||||
{ notificationToken },
|
||||
);
|
||||
return true;
|
||||
},
|
||||
'Space Missions cancellation could not restore the previous layer state',
|
||||
));
|
||||
}
|
||||
}
|
||||
this._syncContextModeButtons();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
change?.layerId === 'rocket-launches'
|
||||
&& ['visibility', 'visibility-blocked', 'visibility-failed'].includes(change.type)
|
||||
) {
|
||||
this._contextModeEntering = null;
|
||||
}
|
||||
if (change?.type === 'visibility-blocked') {
|
||||
if (!this._userFacingContextNotificationTokens.has(change.notificationToken)) {
|
||||
this.showToast(change.reason || 'That layer is unavailable in the current Context mode');
|
||||
}
|
||||
this._syncContextModeButtons();
|
||||
return;
|
||||
}
|
||||
if (change?.type === 'visibility-failed') {
|
||||
const failureMessage = `${change.layerId} could not ${change.enabled ? 'start' : 'stop'} cleanly`;
|
||||
// A failed direct Context-shell START has already had its siblings
|
||||
// cleared by the visibility guard. Wait outside the synchronous manager
|
||||
// notification for this queue to settle, then reconcile the complete
|
||||
// snapshot, including an uncertain failed shell.
|
||||
const needsDeferredShellRestore = (
|
||||
['military-awareness', 'rocket-launches'].includes(change.layerId)
|
||||
&& change.enabled
|
||||
&& this._contextSessionSnapshot
|
||||
&& !this._contextModeChanging
|
||||
);
|
||||
if (needsDeferredShellRestore) {
|
||||
this._contextMode = null;
|
||||
void this._trackContextLayerReaction(this._runUserFacingContextAction(
|
||||
async (notificationToken) => {
|
||||
await this._restoreContextSessionAfterLayerSettles(
|
||||
change.layerId,
|
||||
{ notificationToken },
|
||||
);
|
||||
// The wrapper owns failure announcements. On a successful rollback
|
||||
// announce the original activation failure here so the same direct
|
||||
// action still produces exactly one accessible notification.
|
||||
this.showToast(failureMessage);
|
||||
return true;
|
||||
},
|
||||
failureMessage,
|
||||
));
|
||||
} else if (!this._userFacingContextNotificationTokens.has(change.notificationToken)) {
|
||||
this.showToast(failureMessage);
|
||||
}
|
||||
this._syncContextModeButtons();
|
||||
return;
|
||||
}
|
||||
if (change?.type === 'visibility-will-change') {
|
||||
// Explicit entry capture happens on the synchronous visibility-requested
|
||||
// boundary. Keeping this later branch side-effect free prevents an
|
||||
// awaited Clear/guard from replacing that authoritative pre-entry view.
|
||||
return;
|
||||
}
|
||||
// Session bookkeeping must run BEFORE any exit path below: the exit
|
||||
// handlers restore `snapshot ∪ userAdded`, so a stale entry here becomes
|
||||
// a layer resurrected against the user's explicit disable.
|
||||
recordContextSessionUserChange({
|
||||
snapshot: this._contextSessionSnapshot,
|
||||
change,
|
||||
effectiveContextMode,
|
||||
});
|
||||
recordContextRestoreExplicitChange({
|
||||
restoreState: this._contextRestoreState,
|
||||
change,
|
||||
});
|
||||
if (shouldExitContextForLayerChange({
|
||||
contextMode: this._contextMode,
|
||||
globalContextEnabled: !!this._dataManager?.isEnabled('military-awareness'),
|
||||
change,
|
||||
})) {
|
||||
void this._trackContextLayerReaction(this._runUserFacingContextAction((notificationToken) => (
|
||||
this._deactivateContextForLayerChange({ notificationToken })
|
||||
)));
|
||||
return;
|
||||
}
|
||||
if (!this._contextModeChanging) {
|
||||
if (change.layerId === 'military-awareness') {
|
||||
// The coordinator remains manager-addressable for restoration and
|
||||
// programmatic routes, but Contacts is selected only from the
|
||||
// dedicated right-side Global Context chooser.
|
||||
this._contextMode = change.enabled
|
||||
? null
|
||||
: (this._contextMode === 'flights' ? null : this._contextMode);
|
||||
if (change.enabled) {
|
||||
this._syncContextModeButtons();
|
||||
} else if (this._contextSessionSnapshot) {
|
||||
void this._trackContextLayerReaction(this._runUserFacingContextAction((notificationToken) => (
|
||||
this._deactivateContextForLayerChange({ notificationToken })
|
||||
)));
|
||||
}
|
||||
} else if (change.layerId === 'rocket-launches') {
|
||||
const ownsContextEntry = isExplicitUserIntentOrigin(change.origin, change.layerId)
|
||||
|| this._contextMode === 'space-missions'
|
||||
|| effectiveContextMode === 'space-missions';
|
||||
if (!ownsContextEntry) return;
|
||||
this._contextMode = change.enabled ? 'space-missions' : (this._contextMode === 'space-missions' ? null : this._contextMode);
|
||||
if (change.enabled) {
|
||||
this._syncContextModeButtons();
|
||||
} else if (this._contextSessionSnapshot) {
|
||||
void this._trackContextLayerReaction(this._runUserFacingContextAction((notificationToken) => (
|
||||
this._deactivateContextForLayerChange({ notificationToken })
|
||||
)));
|
||||
}
|
||||
} else if (
|
||||
this._contextMode === 'flights'
|
||||
&& ['flights', 'military', 'ais-live-vessels', 'military-installations'].includes(change.layerId)
|
||||
&& !change.enabled
|
||||
) {
|
||||
void this._trackContextLayerReaction(this._runUserFacingContextAction((notificationToken) => (
|
||||
this._deactivateContextForLayerChange({ notificationToken })
|
||||
)));
|
||||
}
|
||||
}
|
||||
if (this.cockpitView?.active && !cockpitEntryAllowed({
|
||||
contextMode: this._contextMode,
|
||||
contextModeChanging: this._contextModeChanging,
|
||||
flightsEnabled: !!this._dataManager?.isEnabled('flights'),
|
||||
militaryEnabled: !!this._dataManager?.isEnabled('military'),
|
||||
})) {
|
||||
this.cockpitView.exit({ restoreTracking: false });
|
||||
}
|
||||
this._syncContextModeButtons();
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
|
||||
|
||||
export function _syncContextModeButtons() {
|
||||
if (this.destroyed) return;
|
||||
const flightsActive = this._contextMode === 'flights';
|
||||
const missionsActive = this._contextMode === 'space-missions';
|
||||
const panel = this._globalContextPanel;
|
||||
panel?.classList.toggle('context-enabled', flightsActive || missionsActive);
|
||||
panel?.setAttribute('data-context-mode', this._contextMode || 'none');
|
||||
this._globalContextFlightsBtn?.classList.toggle('active', flightsActive);
|
||||
this._globalContextFlightsBtn?.setAttribute('aria-selected', String(flightsActive));
|
||||
this._globalContextMissionsBtn?.classList.toggle('active', missionsActive);
|
||||
this._globalContextMissionsBtn?.setAttribute('aria-selected', String(missionsActive));
|
||||
const transitionBusy = Boolean(this._contextModeChanging);
|
||||
// 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
|
||||
// Missions from the keyboard. Semantic busy state keeps them perceivable
|
||||
// while synchronous click guards prevent a second transition.
|
||||
for (const button of [this._globalContextFlightsBtn, this._globalContextMissionsBtn]) {
|
||||
if (!button) continue;
|
||||
button.disabled = false;
|
||||
button.tabIndex = 0;
|
||||
button.setAttribute('aria-disabled', String(transitionBusy));
|
||||
button.setAttribute('aria-busy', String(transitionBusy));
|
||||
}
|
||||
if (this._contextModeStandby) this._contextModeStandby.hidden = flightsActive || missionsActive;
|
||||
if (this._contextFlightsView) this._contextFlightsView.hidden = !flightsActive;
|
||||
if (this._contextMissionsView) this._contextMissionsView.hidden = !missionsActive;
|
||||
this.cockpitView?.syncEntry();
|
||||
// Every _contextMode mutation funnels through here; the sync no-ops until
|
||||
// the transaction settles, so this is the activation/deactivation edge.
|
||||
this.actions.syncDetection();
|
||||
this.actions.scheduleLayout();
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { contextRestoreLayerIds, contextSnapshotLayerIds, mergeContextTransitionErrors, settleContextIntentReplay } from '../contextModePolicy.js';
|
||||
|
||||
export function _trackContextLayerReaction(promise) {
|
||||
const tracked = Promise.resolve(promise);
|
||||
this._contextLayerReactionPromises.add(tracked);
|
||||
void tracked.then(
|
||||
() => this._contextLayerReactionPromises.delete(tracked),
|
||||
() => this._contextLayerReactionPromises.delete(tracked),
|
||||
);
|
||||
return tracked;
|
||||
}
|
||||
|
||||
export async function _waitForContextLayerSettlement() {
|
||||
while (this._contextLayerReactionPromises.size > 0) {
|
||||
await Promise.allSettled([...this._contextLayerReactionPromises]);
|
||||
}
|
||||
}
|
||||
|
||||
export function _captureContextSessionSnapshot({ excludeLayerIds = [] } = {}) {
|
||||
if (!this._dataManager || this._contextSessionSnapshot) return;
|
||||
const params = {};
|
||||
for (const layerId of ['military-awareness', 'satellites']) {
|
||||
const value = this._dataManager.getLayerParams(layerId);
|
||||
if (value) params[layerId] = value;
|
||||
}
|
||||
this._contextSessionSnapshot = {
|
||||
enabledLayerIds: contextSnapshotLayerIds(
|
||||
this._dataManager.getEnabledLayerIds(),
|
||||
this._contextRestoreState?.enabledLayerIds,
|
||||
excludeLayerIds,
|
||||
),
|
||||
userAdded: new Set(),
|
||||
userRemoved: new Set(),
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
export async function _restoreContextSession({
|
||||
excludeLayerIds = [],
|
||||
notificationToken = null,
|
||||
signal = null,
|
||||
} = {}) {
|
||||
const snapshot = this._contextSessionSnapshot;
|
||||
if (!snapshot || !this._dataManager) return;
|
||||
// Clear the stored session before emitting restore notifications so none
|
||||
// of those transitions can be mistaken for a fresh Context entry.
|
||||
this._contextSessionSnapshot = null;
|
||||
const restoreState = {
|
||||
enabledLayerIds: contextRestoreLayerIds(snapshot),
|
||||
explicitLayerStates: new Map(),
|
||||
};
|
||||
this._contextRestoreState = restoreState;
|
||||
for (const [layerId, params] of Object.entries(snapshot.params)) {
|
||||
this._dataManager.setLayerParams(layerId, params);
|
||||
}
|
||||
let restoreError = null;
|
||||
const restoreSnapshot = async (restoreSignal = null) => {
|
||||
// Contacts owns the dependency intents it starts. Settle that
|
||||
// coordinator before restoring the remaining snapshot, otherwise its
|
||||
// dependency releases can supersede the restore's same-target requests
|
||||
// and make a valid Contacts-to-Missions handoff look like a failure.
|
||||
const contactsCoordinatorId = 'military-awareness';
|
||||
const settleContactsCoordinator = !restoreState.enabledLayerIds.has(contactsCoordinatorId)
|
||||
&& this._dataManager.isEffectivelyEnabled(contactsCoordinatorId);
|
||||
if (settleContactsCoordinator) {
|
||||
const coordinatorSettled = await this._dataManager.setEnabled(
|
||||
contactsCoordinatorId,
|
||||
false,
|
||||
{
|
||||
origin: 'context-restore',
|
||||
...(notificationToken ? { notificationToken } : {}),
|
||||
...(restoreSignal ? { signal: restoreSignal } : {}),
|
||||
},
|
||||
);
|
||||
if (coordinatorSettled === false) {
|
||||
const error = new Error('Failed to settle Contacts before restoring Context');
|
||||
error.failedLayerIds = [contactsCoordinatorId];
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
await this._dataManager.restoreEnabledLayerIds(restoreState.enabledLayerIds, {
|
||||
origin: 'context-restore',
|
||||
excludeLayerIds: settleContactsCoordinator
|
||||
? [...excludeLayerIds, contactsCoordinatorId]
|
||||
: excludeLayerIds,
|
||||
notificationToken,
|
||||
...(restoreSignal ? { signal: restoreSignal } : {}),
|
||||
});
|
||||
};
|
||||
try {
|
||||
await restoreSnapshot(signal);
|
||||
} catch (error) {
|
||||
restoreError = error;
|
||||
// A caller abort can arrive after only part of the exact restore has
|
||||
// settled. Finish that same target without the stale caller signal while
|
||||
// this restoreState still records newer explicit intents; replay below
|
||||
// then gives those newer intents final authority.
|
||||
if (signal?.aborted && !restoreState.cancelled) {
|
||||
try {
|
||||
await restoreSnapshot(null);
|
||||
restoreError = null;
|
||||
} catch (compensationError) {
|
||||
restoreError = mergeContextTransitionErrors(restoreError, compensationError);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (this._contextRestoreState === restoreState) this._contextRestoreState = null;
|
||||
}
|
||||
// Clear Selected Layers owns a newer global OFF intent. A restore that was
|
||||
// already awaiting lifecycle work must not replay its captured companion
|
||||
// intent or recreate the discarded session after Clear invalidates it.
|
||||
if (restoreState.cancelled) return;
|
||||
// A direct Radio command may finish after restore has already copied its
|
||||
// target and queued the opposite state. Replay that newer intent only
|
||||
// after the stale queue drains; the replay origin cannot recurse here.
|
||||
const replaySignal = signal?.aborted ? null : signal;
|
||||
const replayError = await settleContextIntentReplay({
|
||||
restoreState,
|
||||
setEnabled: (layerId, enabled, options = {}) => this._dataManager.setEnabled(
|
||||
layerId,
|
||||
enabled,
|
||||
{ ...options, ...(replaySignal ? { signal: replaySignal } : {}) },
|
||||
),
|
||||
notificationToken,
|
||||
});
|
||||
restoreError = mergeContextTransitionErrors(restoreError, replayError);
|
||||
if (restoreError && !this._contextSessionSnapshot) {
|
||||
// Keep the exact still-pending target so a later exit/teardown can retry
|
||||
// instead of silently losing the user's pre-Context layer state.
|
||||
this._contextSessionSnapshot = {
|
||||
enabledLayerIds: new Set(restoreState.enabledLayerIds),
|
||||
userAdded: new Set(),
|
||||
userRemoved: new Set(),
|
||||
params: snapshot.params,
|
||||
};
|
||||
}
|
||||
if (restoreError) throw restoreError;
|
||||
}
|
||||
|
||||
/*
|
||||
* Cross-mode cancellation and failure deliberately settle on Context OFF.
|
||||
*
|
||||
* A reinstatement transaction lived here for three review rounds and was
|
||||
* removed on purpose. Restoring the prior mode is genuinely racy: the prior
|
||||
* mode has to be read before the teardown, but a second request arriving
|
||||
* while the first reinstatement is mid-activation reads `_contextMode` as
|
||||
* null and inherits nothing, so two overlapping cancellations still land on
|
||||
* OFF — and the only fix is a cross-transaction "logical prior mode" chain,
|
||||
* which is new shared mutable state read while an earlier transaction is
|
||||
* still awaiting. That trades a rare wrong resting state for a permanent
|
||||
* interleaving hazard.
|
||||
*
|
||||
* The defect that started this was the LIE, not the OFF: the transition
|
||||
* claimed to have cancelled cleanly while silently leaving Context off. So
|
||||
* the resting state stays OFF and is REPORTED as such, with the failed layer
|
||||
* ids preserved. A restore feature can be rebuilt post-launch on the
|
||||
* generation discipline the surrounding transaction already follows.
|
||||
*/
|
||||
export async function _restoreContextSessionAfterLayerSettles(layerId, { notificationToken = null } = {}) {
|
||||
await this._dataManager?.waitForLayerSettled?.(layerId);
|
||||
return this._restoreContextSession({ notificationToken });
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { contextLayerEnableBlockReason, isExplicitUserIntentOrigin, runWithContextModeChanging, settleContextModeChange, shouldCaptureContextSession, shouldDeferContextEntryDuringClear } from '../contextModePolicy.js';
|
||||
|
||||
export function connectContextManager(manager) {
|
||||
this.disconnect();
|
||||
if (this.destroyed) return;
|
||||
this._dataManager = manager || null;
|
||||
this._contextManagerUnsubscribe = this._dataManager?.subscribe?.((change) => {
|
||||
if (String(change?.type || '').startsWith('visibility')) this._handleContextLayerChange(change);
|
||||
});
|
||||
if (typeof this._dataManager?.subscribeVisibilityRequests === 'function') {
|
||||
this._dataManagerVisibilityRequestUnsubscribe = this._dataManager.subscribeVisibilityRequests((change) => {
|
||||
if (this.destroyed) return;
|
||||
if (shouldCaptureContextSession(change)) {
|
||||
// This event is synchronous with intent publication, before an
|
||||
// awaited guard or Clear All can alter the rest of the layer set.
|
||||
// Manager effective visibility already includes both the new entry
|
||||
// intent and Clear's reserved OFF baseline.
|
||||
this._captureContextSessionSnapshot({ excludeLayerIds: [change.layerId] });
|
||||
if (shouldDeferContextEntryDuringClear({
|
||||
change,
|
||||
clearInFlight: Boolean(this._clearSelectedLayersPromise),
|
||||
})) {
|
||||
this._contextModeDeferredEntryIntent = {
|
||||
layerId: change.layerId,
|
||||
intentEpoch: change.intentEpoch,
|
||||
origin: change.origin,
|
||||
};
|
||||
this._contextModeEntering = 'space-missions';
|
||||
this._syncContextModeButtons();
|
||||
}
|
||||
} else if (
|
||||
change?.layerId === 'rocket-launches'
|
||||
&& change.enabled === false
|
||||
&& isExplicitUserIntentOrigin(change.origin, change.layerId)
|
||||
) {
|
||||
this._contextModeDeferredEntryIntent = null;
|
||||
if (this._clearSelectedLayersPromise) {
|
||||
this._contextSessionSnapshot = null;
|
||||
this._contextModeEntering = null;
|
||||
this._syncContextModeButtons();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if (typeof this._dataManager?.addVisibilityGuard === 'function') {
|
||||
this._dataManagerVisibilityGuardUnsubscribe = this._dataManager.addVisibilityGuard(async (change) => {
|
||||
if (this.destroyed) return null;
|
||||
const layerName = this._dataManager?.layers?.get(change.layerId)?.module?.name || change.layerId;
|
||||
const reason = contextLayerEnableBlockReason({
|
||||
contextMode: this._contextModeEntering || this._contextMode,
|
||||
change,
|
||||
layerName,
|
||||
});
|
||||
if (reason) return reason;
|
||||
if (
|
||||
change.enabled
|
||||
&& ['military-awareness', 'rocket-launches'].includes(change.layerId)
|
||||
&& shouldCaptureContextSession(change)
|
||||
&& (
|
||||
!this._contextModeChanging
|
||||
|| (
|
||||
change.layerId === 'rocket-launches'
|
||||
&& this._contextModeDeferredEntryIntent?.intentEpoch === change.intentEpoch
|
||||
)
|
||||
)
|
||||
) {
|
||||
const entryMode = change.layerId === 'rocket-launches' ? 'space-missions' : null;
|
||||
const deferredClearEntry = this._contextModeDeferredEntryIntent?.intentEpoch === change.intentEpoch;
|
||||
// A deferred entry owns the state after Clear settles. Restoring
|
||||
// Clear's transient busy flag here would leave Context stuck.
|
||||
const priorChanging = deferredClearEntry ? false : this._contextModeChanging;
|
||||
const notificationToken = change.notificationToken || Symbol('direct-context-shell-entry');
|
||||
const ownsNotificationToken = !change.notificationToken;
|
||||
if (ownsNotificationToken) {
|
||||
this._userFacingContextNotificationTokens.add(notificationToken);
|
||||
}
|
||||
this._contextModeEntering = entryMode;
|
||||
this._contextModeChanging = true;
|
||||
try {
|
||||
if (deferredClearEntry) {
|
||||
await this._clearSelectedLayersManagerPromise;
|
||||
if (this.destroyed) return false;
|
||||
if (this._contextModeDeferredEntryIntent?.intentEpoch !== change.intentEpoch) return false;
|
||||
this._contextModeDeferredEntryIntent = null;
|
||||
}
|
||||
await this._clearLayersOutsideContextMode(entryMode, { notificationToken });
|
||||
if (this.destroyed) return false;
|
||||
} catch (error) {
|
||||
this._contextModeEntering = null;
|
||||
console.warn(`[Context] ${change.layerId} isolation failed`, error);
|
||||
try {
|
||||
await this._restoreContextSession({
|
||||
excludeLayerIds: [change.layerId],
|
||||
notificationToken,
|
||||
});
|
||||
} catch (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`;
|
||||
} finally {
|
||||
if (ownsNotificationToken) {
|
||||
this._userFacingContextNotificationTokens.delete(notificationToken);
|
||||
}
|
||||
if (!this.destroyed) settleContextModeChange(this, priorChanging);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
if (typeof this._dataManager?.subscribeBeforeDestroy === 'function') {
|
||||
this._dataManagerBeforeDestroyUnsubscribe = this._dataManager.subscribeBeforeDestroy(async ({ layerId } = {}) => {
|
||||
if (this.destroyed || !this._contextSessionSnapshot) return;
|
||||
await runWithContextModeChanging(this, async () => {
|
||||
this._contextMode = null;
|
||||
this.cockpitView?.exit({ restoreTracking: false });
|
||||
this._syncContextModeButtons();
|
||||
await this._restoreContextSession({ excludeLayerIds: layerId ? [layerId] : [] });
|
||||
});
|
||||
});
|
||||
}
|
||||
this._syncContextModeButtons();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { contextAllowedLayerIds, mergeContextTransitionErrors } from '../contextModePolicy.js';
|
||||
|
||||
export async function _selectContextMode(mode, { notificationToken = null, signal = null } = {}) {
|
||||
if (this.destroyed || !this._dataManager) return false;
|
||||
if (this._clearSelectedLayersPromise) return false;
|
||||
this._contextTransitionFailedLayerIds = [];
|
||||
const generation = ++this._contextModeGeneration;
|
||||
const isCurrent = () => !this.destroyed && generation === this._contextModeGeneration;
|
||||
this._contextModeEntryIntent = null;
|
||||
this._contextModeReplacementIntent = null;
|
||||
this._contextModeChanging = true;
|
||||
this._syncContextModeButtons();
|
||||
try {
|
||||
if (mode !== 'flights' && this.cockpitView?.active) {
|
||||
this.cockpitView.exit({ restoreTracking: false });
|
||||
}
|
||||
if (!mode) {
|
||||
this._contextMode = null;
|
||||
this._syncContextModeButtons();
|
||||
await this._restoreContextSession({ notificationToken, signal });
|
||||
return isCurrent();
|
||||
}
|
||||
// A cross-mode switch dismantles the prior mode BEFORE the new one is
|
||||
// committed. If the caller aborts in that window the switch never lands,
|
||||
// and the resting state is Context OFF — reported as such by
|
||||
// setContextMode rather than dressed up as a clean cancellation. See the
|
||||
// note above _restoreContextSessionAfterLayerSettles.
|
||||
const crossModeSwitch = Boolean(this._contextMode && this._contextMode !== mode);
|
||||
if (crossModeSwitch) {
|
||||
this._contextMode = null;
|
||||
await this._restoreContextSession({ notificationToken, signal });
|
||||
if (!isCurrent()) return false;
|
||||
if (signal?.aborted) return false;
|
||||
}
|
||||
this._captureContextSessionSnapshot();
|
||||
this._contextMode = mode;
|
||||
this._syncContextModeButtons();
|
||||
// Replay isolation must settle before Space Missions starts. Contacts
|
||||
// keeps its non-dependency teardown in the background so slow source
|
||||
// shutdown does not delay cockpit entry.
|
||||
try {
|
||||
await this._clearLayersOutsideContextMode(mode, { notificationToken, signal });
|
||||
} catch (error) {
|
||||
if (!isCurrent()) return false;
|
||||
let transitionError = error;
|
||||
console.warn(`[Context] ${mode} isolation failed`, error);
|
||||
this._contextMode = null;
|
||||
this._syncContextModeButtons();
|
||||
try {
|
||||
await this._restoreContextSession({ notificationToken });
|
||||
} catch (restoreError) {
|
||||
transitionError = mergeContextTransitionErrors(transitionError, restoreError);
|
||||
this._contextTransitionFailedLayerIds = [...(transitionError.failedLayerIds || [])];
|
||||
throw transitionError;
|
||||
}
|
||||
this._contextTransitionFailedLayerIds = [...(transitionError?.failedLayerIds || [])];
|
||||
return false;
|
||||
}
|
||||
if (!isCurrent()) return false;
|
||||
// Entry is one transaction: isolation succeeded above, so a failed mode
|
||||
// activation must roll the cleared layers back instead of stranding the
|
||||
// user in a half-entered mode with an orphaned snapshot.
|
||||
const entryLayerId = mode === 'flights' ? 'military-awareness' : 'rocket-launches';
|
||||
if (mode === 'flights') {
|
||||
this._dataManager.setLayerParams('military-awareness', { passive: false });
|
||||
}
|
||||
let activated = false;
|
||||
let activationError = null;
|
||||
let activationIntent = null;
|
||||
let terminalIntentOutcome = null;
|
||||
try {
|
||||
activationIntent = this._dataManager._setEnabledWithIntent(
|
||||
entryLayerId,
|
||||
true,
|
||||
{ notificationToken, ...(signal ? { signal } : {}) },
|
||||
);
|
||||
this._contextModeEntryIntent = {
|
||||
generation,
|
||||
layerId: entryLayerId,
|
||||
intentEpoch: activationIntent.intentEpoch,
|
||||
};
|
||||
activated = await activationIntent.promise;
|
||||
terminalIntentOutcome = await this._dataManager._waitForVisibilityIntent?.(
|
||||
entryLayerId,
|
||||
activationIntent.intentEpoch,
|
||||
);
|
||||
} catch (error) {
|
||||
activationError = error;
|
||||
}
|
||||
if (!isCurrent()) return false;
|
||||
let replacementIntent = mode === 'space-missions'
|
||||
&& this._contextModeReplacementIntent?.generation === generation
|
||||
&& this._contextModeReplacementIntent.layerId === entryLayerId
|
||||
? this._contextModeReplacementIntent
|
||||
: null;
|
||||
while (replacementIntent) {
|
||||
const outcome = await this._dataManager._waitForVisibilityIntent?.(
|
||||
entryLayerId,
|
||||
replacementIntent.intentEpoch,
|
||||
);
|
||||
terminalIntentOutcome = outcome;
|
||||
if (!isCurrent()) return false;
|
||||
const replacementOwnsMode = outcome?.intentEpoch === replacementIntent.intentEpoch
|
||||
&& outcome.enabled === true
|
||||
&& outcome.succeeded === true;
|
||||
if (replacementOwnsMode) {
|
||||
this._contextModeEntering = null;
|
||||
this._contextModeEntryIntent = null;
|
||||
this._contextModeReplacementIntent = null;
|
||||
this._syncContextModeButtons();
|
||||
return true;
|
||||
}
|
||||
const successorEpoch = outcome?.cancellationReason === 'superseded'
|
||||
&& outcome.successorEnabled === true
|
||||
&& Number.isInteger(outcome.successorIntentEpoch)
|
||||
&& outcome.successorIntentEpoch > replacementIntent.intentEpoch
|
||||
? outcome.successorIntentEpoch
|
||||
: null;
|
||||
replacementIntent = successorEpoch === null ? null : {
|
||||
generation,
|
||||
layerId: entryLayerId,
|
||||
intentEpoch: successorEpoch,
|
||||
};
|
||||
}
|
||||
if (activationError || activated === false || !this._dataManager.isEnabled(entryLayerId)) {
|
||||
const cancelledAndSettled = terminalIntentOutcome?.succeeded === false
|
||||
&& ['caller-abort', 'resource-abort', 'superseded'].includes(
|
||||
terminalIntentOutcome.cancellationReason,
|
||||
);
|
||||
let transitionError = null;
|
||||
if (!cancelledAndSettled) {
|
||||
transitionError = activationError instanceof Error
|
||||
? activationError
|
||||
: new Error(`Context activation failed for: ${entryLayerId}`);
|
||||
transitionError.failedLayerIds = [...new Set([
|
||||
...(transitionError.failedLayerIds || []),
|
||||
entryLayerId,
|
||||
])];
|
||||
this._contextTransitionFailedLayerIds = [...transitionError.failedLayerIds];
|
||||
console.warn(`[Context] ${mode} activation failed; restoring previous layers`, activationError || 'not enabled');
|
||||
}
|
||||
this._contextMode = null;
|
||||
this._contextModeEntryIntent = null;
|
||||
this._contextModeReplacementIntent = null;
|
||||
this._syncContextModeButtons();
|
||||
try {
|
||||
await this._restoreContextSession({
|
||||
excludeLayerIds: [entryLayerId],
|
||||
notificationToken,
|
||||
});
|
||||
} catch (restoreError) {
|
||||
transitionError = mergeContextTransitionErrors(transitionError, restoreError);
|
||||
this._contextTransitionFailedLayerIds = [...(transitionError?.failedLayerIds || [])];
|
||||
throw transitionError;
|
||||
}
|
||||
// `null` means the requested entry was cancelled and its exact rollback
|
||||
// completed. The action wrapper treats that as a silent non-commit,
|
||||
// while callers still require literal `true` before expanding Context.
|
||||
return cancelledAndSettled ? null : false;
|
||||
}
|
||||
this._contextModeEntryIntent = null;
|
||||
return true;
|
||||
} finally {
|
||||
if (isCurrent()) {
|
||||
this._contextModeChanging = false;
|
||||
this._syncContextModeButtons();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function _deactivateContextForLayerChange({ notificationToken = null } = {}) {
|
||||
this._contextModeGeneration += 1;
|
||||
this._contextModeChanging = true;
|
||||
this._contextMode = null;
|
||||
this._contextModeEntryIntent = null;
|
||||
this._contextModeReplacementIntent = null;
|
||||
if (this.cockpitView?.active) this.cockpitView.exit({ restoreTracking: false });
|
||||
this._syncContextModeButtons();
|
||||
try {
|
||||
await this._restoreContextSession({ notificationToken });
|
||||
} finally {
|
||||
this._contextModeChanging = false;
|
||||
this._syncContextModeButtons();
|
||||
}
|
||||
}
|
||||
|
||||
export async function _clearLayersOutsideContextMode(
|
||||
mode = null,
|
||||
{ notificationToken = null, signal = null } = {},
|
||||
) {
|
||||
const allowed = contextAllowedLayerIds(mode);
|
||||
const pending = [];
|
||||
for (const [layerId] of this._dataManager.layers || []) {
|
||||
// Effective visibility: a disallowed layer still mid-ENABLING must be
|
||||
// isolated too, or it settles ON inside the exclusive mode.
|
||||
if (!allowed.has(layerId) && this._dataManager.isEffectivelyEnabled(layerId)) {
|
||||
pending.push({
|
||||
layerId,
|
||||
transition: this._dataManager.setEnabled(layerId, false, {
|
||||
notificationToken,
|
||||
...(signal ? { signal } : {}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
const results = await Promise.all(pending.map(({ transition }) => transition));
|
||||
const failed = pending
|
||||
.filter(({ layerId }, index) => results[index] === false || this._dataManager.isEnabled(layerId))
|
||||
.map(({ layerId }) => layerId);
|
||||
if (failed.length > 0) {
|
||||
const error = new Error(`Context isolation failed for: ${failed.join(', ')}`);
|
||||
error.failedLayerIds = failed;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user