Merge pull request #403 from bilawalsidhu/refactor/context-controls

refactor: separate Context controls and layer restoration
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-09-13 02:18:04 -07:00
commit 43db5c19bb
27 files changed
+1862 -1287

No files matched your search

+2
View File
@@ -13,6 +13,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
## [Unreleased] ## [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. - 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. - Restore UI observer, resize-listener and CCTV subscription cleanup after Location extraction.
+8
View File
@@ -302,3 +302,11 @@ subscriptions before ending the active tuning interaction.
presentation. It receives DOM elements, the existing camera port and explicit presentation. It receives DOM elements, the existing camera port and explicit
application actions; it imports no provider, layer or camera engine. Selection, application actions; it imports no provider, layer or camera engine. Selection,
placement, navigation and storage policy remain outside the component family. 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.
+10
View File
@@ -1,5 +1,15 @@
# God's Eye View Current State # 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 ## Camera panel ownership
CCTV controls receive the existing camera port and explicit application actions. CCTV controls receive the existing camera port and explicit application actions.
+3 -1
View File
@@ -131,6 +131,8 @@
"./ui/layers/feedback": "./src/loadingFeedback.js", "./ui/layers/feedback": "./src/loadingFeedback.js",
"./ui/location": "./src/ui/location.js", "./ui/location": "./src/ui/location.js",
"./ui/radio": "./src/ui/radio.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"
} }
} }
+11 -1
View File
@@ -181,5 +181,15 @@
"src/ui/cctvControls.test.mjs", "src/ui/cctvControls.test.mjs",
"src/ui/cctvFrames.js", "src/ui/cctvFrames.js",
"src/ui/cctvPresentation.js", "src/ui/cctvPresentation.js",
"scripts/qa-camera-controls.mjs" "scripts/qa-camera-controls.mjs",
"src/ui/context.js",
"src/ui/contextActions.js",
"src/ui/contextBindings.js",
"src/ui/contextControls.js",
"src/ui/contextControls.test.mjs",
"src/ui/contextLayerChanges.js",
"src/ui/contextPresentation.js",
"src/ui/contextSession.js",
"src/ui/contextSubscriptions.js",
"src/ui/contextTransactions.js"
] ]
+18
View File
@@ -369,5 +369,23 @@
"src/ui/cctvPresentation.js" "src/ui/cctvPresentation.js"
], ],
"external": [] "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": []
} }
} }
+48 -48
View File
@@ -184,21 +184,21 @@ try {
const toasts = []; const toasts = [];
styleManager._showToast = (message) => { toasts.push(String(message)); }; styleManager._showToast = (message) => { toasts.push(String(message)); };
try { try {
if (styleManager._contextMode) await styleManager._selectContextMode(null); if (styleManager._contextControls._contextMode) await styleManager._contextControls._selectContextMode(null);
const contactsResult = await styleManager._selectContextMode('flights'); const contactsResult = await styleManager._contextControls._selectContextMode('flights');
const missionResult = await styleManager._selectContextMode('space-missions'); const missionResult = await styleManager._contextControls._selectContextMode('space-missions');
const switched = { const switched = {
contactsResult, contactsResult,
missionResult, missionResult,
mode: styleManager._contextMode, mode: styleManager._contextControls._contextMode,
missionsEnabled: dataManager.isEnabled('rocket-launches'), missionsEnabled: dataManager.isEnabled('rocket-launches'),
toasts: [...toasts], toasts: [...toasts],
}; };
const exitResult = await styleManager._selectContextMode(null); const exitResult = await styleManager._contextControls._selectContextMode(null);
return { return {
...switched, ...switched,
exitResult, exitResult,
modeAfterExit: styleManager._contextMode, modeAfterExit: styleManager._contextControls._contextMode,
missionsOffAfterExit: !dataManager.isEffectivelyEnabled('rocket-launches'), missionsOffAfterExit: !dataManager.isEffectivelyEnabled('rocket-launches'),
}; };
} finally { } finally {
@@ -281,16 +281,16 @@ try {
const deadline = performance.now() + 5_000; const deadline = performance.now() + 5_000;
while (performance.now() < deadline) { while (performance.now() < deadline) {
if ( if (
styleManager._contextModeEntering === null styleManager._contextControls._contextModeEntering === null
&& styleManager._contextSessionSnapshot === null && styleManager._contextControls._contextSessionSnapshot === null
&& dataManager.isEnabled(siblingId) && dataManager.isEnabled(siblingId)
&& !dataManager.isEffectivelyEnabled('rocket-launches') && !dataManager.isEffectivelyEnabled('rocket-launches')
) break; ) break;
await new Promise((resolve) => setTimeout(resolve, 20)); await new Promise((resolve) => setTimeout(resolve, 20));
} }
timedOut = !( timedOut = !(
styleManager._contextModeEntering === null styleManager._contextControls._contextModeEntering === null
&& styleManager._contextSessionSnapshot === null && styleManager._contextControls._contextSessionSnapshot === null
&& dataManager.isEnabled(siblingId) && dataManager.isEnabled(siblingId)
&& !dataManager.isEffectivelyEnabled('rocket-launches') && !dataManager.isEffectivelyEnabled('rocket-launches')
); );
@@ -298,8 +298,8 @@ try {
exercised: true, exercised: true,
transitionResult, transitionResult,
timedOut, timedOut,
entering: styleManager._contextModeEntering, entering: styleManager._contextControls._contextModeEntering,
snapshotRetained: Boolean(styleManager._contextSessionSnapshot), snapshotRetained: Boolean(styleManager._contextControls._contextSessionSnapshot),
siblingRestored: dataManager.isEnabled(siblingId), siblingRestored: dataManager.isEnabled(siblingId),
missionEffective: dataManager.isEffectivelyEnabled('rocket-launches'), missionEffective: dataManager.isEffectivelyEnabled('rocket-launches'),
toasts, toasts,
@@ -382,7 +382,7 @@ try {
rocketEntry.module.disable = async () => true; rocketEntry.module.disable = async () => true;
styleManager._showToast = (message) => { toasts.push(String(message)); }; styleManager._showToast = (message) => { toasts.push(String(message)); };
try { try {
const entry = styleManager._selectContextMode('space-missions'); const entry = styleManager._contextControls._selectContextMode('space-missions');
await updateStarted; await updateStarted;
const replacement = dataManager.setEnabled('rocket-launches', true, { origin: 'programmatic' }); const replacement = dataManager.setEnabled('rocket-launches', true, { origin: 'programmatic' });
releaseUpdate(); releaseUpdate();
@@ -390,15 +390,15 @@ try {
const committed = { const committed = {
entryResult, entryResult,
replacementResult, replacementResult,
mode: styleManager._contextMode, mode: styleManager._contextControls._contextMode,
entering: styleManager._contextModeEntering, entering: styleManager._contextControls._contextModeEntering,
snapshotRetained: Boolean(styleManager._contextSessionSnapshot), snapshotRetained: Boolean(styleManager._contextControls._contextSessionSnapshot),
siblingIsolated: !dataManager.isEffectivelyEnabled(siblingId), siblingIsolated: !dataManager.isEffectivelyEnabled(siblingId),
missionEnabled: dataManager.isEnabled('rocket-launches'), missionEnabled: dataManager.isEnabled('rocket-launches'),
missionEffective: dataManager.isEffectivelyEnabled('rocket-launches'), missionEffective: dataManager.isEffectivelyEnabled('rocket-launches'),
toasts: [...toasts], toasts: [...toasts],
}; };
const exitResult = await styleManager._selectContextMode(null); const exitResult = await styleManager._contextControls._selectContextMode(null);
return { return {
exercised: true, exercised: true,
...committed, ...committed,
@@ -482,7 +482,7 @@ try {
styleManager._showToast = (message) => { toasts.push(String(message)); }; styleManager._showToast = (message) => { toasts.push(String(message)); };
try { try {
const entry = styleManager._runUserFacingContextAction( const entry = styleManager._runUserFacingContextAction(
(notificationToken) => styleManager._selectContextMode( (notificationToken) => styleManager._contextControls._selectContextMode(
'space-missions', 'space-missions',
{ notificationToken }, { notificationToken },
), ),
@@ -498,9 +498,9 @@ try {
exercised: true, exercised: true,
entryResult, entryResult,
offResult, offResult,
mode: styleManager._contextMode, mode: styleManager._contextControls._contextMode,
entering: styleManager._contextModeEntering, entering: styleManager._contextControls._contextModeEntering,
snapshotRetained: Boolean(styleManager._contextSessionSnapshot), snapshotRetained: Boolean(styleManager._contextControls._contextSessionSnapshot),
siblingRestored: dataManager.isEnabled(siblingId), siblingRestored: dataManager.isEnabled(siblingId),
missionEffective: dataManager.isEffectivelyEnabled('rocket-launches'), missionEffective: dataManager.isEffectivelyEnabled('rocket-launches'),
toasts, toasts,
@@ -567,35 +567,35 @@ try {
await new Promise((resolve) => setTimeout(resolve, 80)); await new Promise((resolve) => setTimeout(resolve, 80));
const voiceState = { const voiceState = {
voiceOn, voiceOn,
mode: styleManager._contextMode, mode: styleManager._contextControls._contextMode,
entering: styleManager._contextModeEntering, entering: styleManager._contextControls._contextModeEntering,
snapshotRetained: Boolean(styleManager._contextSessionSnapshot), snapshotRetained: Boolean(styleManager._contextControls._contextSessionSnapshot),
siblingIsolated: !dataManager.isEffectivelyEnabled(siblingId), 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 voiceOff = await dataManager.setEnabled('rocket-launches', false, { origin: 'voice' });
const reactionCountAfterOff = styleManager._contextLayerReactionPromises.size; const reactionCountAfterOff = styleManager._contextControls._contextLayerReactionPromises.size;
await styleManager._waitForContextLayerSettlement(); await styleManager._waitForContextLayerSettlement();
const restoredSiblingEntry = dataManager.layers.get(siblingId); const restoredSiblingEntry = dataManager.layers.get(siblingId);
const voiceExit = { const voiceExit = {
voiceOff, voiceOff,
snapshotBeforeOff, snapshotBeforeOff,
reactionCountAfterOff, reactionCountAfterOff,
mode: styleManager._contextMode, mode: styleManager._contextControls._contextMode,
entering: styleManager._contextModeEntering, entering: styleManager._contextControls._contextModeEntering,
snapshotRetained: Boolean(styleManager._contextSessionSnapshot), snapshotRetained: Boolean(styleManager._contextControls._contextSessionSnapshot),
siblingRestored: dataManager.isEffectivelyEnabled(siblingId), siblingRestored: dataManager.isEffectivelyEnabled(siblingId),
siblingEnabled: restoredSiblingEntry?.enabled, siblingEnabled: restoredSiblingEntry?.enabled,
siblingLifecycle: restoredSiblingEntry?.lifecycleState, siblingLifecycle: restoredSiblingEntry?.lifecycleState,
restoreActive: Boolean(styleManager._contextRestoreState), restoreActive: Boolean(styleManager._contextControls._contextRestoreState),
}; };
await dataManager.setEnabled('rocket-launches', true, { origin: 'programmatic' }); await dataManager.setEnabled('rocket-launches', true, { origin: 'programmatic' });
await new Promise((resolve) => setTimeout(resolve, 40)); await new Promise((resolve) => setTimeout(resolve, 40));
const internalState = { const internalState = {
mode: styleManager._contextMode, mode: styleManager._contextControls._contextMode,
entering: styleManager._contextModeEntering, entering: styleManager._contextControls._contextModeEntering,
snapshotRetained: Boolean(styleManager._contextSessionSnapshot), snapshotRetained: Boolean(styleManager._contextControls._contextSessionSnapshot),
missionEnabled: dataManager.isEffectivelyEnabled('rocket-launches'), missionEnabled: dataManager.isEffectivelyEnabled('rocket-launches'),
}; };
await dataManager.setEnabled('rocket-launches', false, { origin: 'programmatic' }); await dataManager.setEnabled('rocket-launches', false, { origin: 'programmatic' });
@@ -707,7 +707,7 @@ try {
const blockerEntry = dataManager.layers.get(blockerId); const blockerEntry = dataManager.layers.get(blockerId);
blockerEntry.initialized = true; blockerEntry.initialized = true;
await dataManager.setEnabled(blockerId, true, { origin: 'programmatic' }); await dataManager.setEnabled(blockerId, true, { origin: 'programmatic' });
const transition = styleManager._selectContextMode('flights'); const transition = styleManager._contextControls._selectContextMode('flights');
const disableStart = await settleWithin(disableStarted, gateTimeoutMs); const disableStart = await settleWithin(disableStarted, gateTimeoutMs);
if (!disableStart.settled) { if (!disableStart.settled) {
releaseDisable(); releaseDisable();
@@ -725,7 +725,7 @@ try {
styleManager.cockpitView.syncEntry(); styleManager.cockpitView.syncEntry();
const entry = document.getElementById('cockpit-entry'); const entry = document.getElementById('cockpit-entry');
const pending = { const pending = {
changing: styleManager._contextModeChanging, changing: styleManager._contextControls._contextModeChanging,
entryHidden: Boolean(entry?.hidden), entryHidden: Boolean(entry?.hidden),
enterResult: styleManager.cockpitView.enter(), enterResult: styleManager.cockpitView.enter(),
trackerPreserved: viewer.trackedEntity === trackedEntityBefore, trackerPreserved: viewer.trackedEntity === trackedEntityBefore,
@@ -763,7 +763,7 @@ try {
// probe must not poison the checks that run after it. // probe must not poison the checks that run after it.
releaseDisable(); releaseDisable();
if (!deferCleanup && dataManager.layers.has(blockerId)) { 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); await window.__gevQaUnregisterLayer(dataManager, blockerId);
styleManager.cockpitView.syncEntry(); styleManager.cockpitView.syncEntry();
} }
@@ -872,7 +872,7 @@ try {
let requestSeen = false; let requestSeen = false;
let transition = null; let transition = null;
try { try {
await styleManager._selectContextMode(null); await styleManager._contextControls._selectContextMode(null);
await dataManager.setEnabled('military-installations', false, { origin: 'programmatic' }); await dataManager.setEnabled('military-installations', false, { origin: 'programmatic' });
// Hold the first Overpass request open, honouring the module's own // 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 activation promise stays PENDING while the first fetch is out, and
// the Contacts panel comes up behind it — that is what a deferred // the Contacts panel comes up behind it — that is what a deferred
// dependency is for. Verified live on :4272: the panel renders and reads // dependency is for. Verified live on :4272: the panel renders and reads
@@ -984,8 +984,8 @@ try {
released = true; released = true;
window.fetch = realFetch; window.fetch = realFetch;
if (transition) await settleWithin(transition, 20_000); if (transition) await settleWithin(transition, 20_000);
if (styleManager._contextMode !== 'flights') { if (styleManager._contextControls._contextMode !== 'flights') {
await settleWithin(styleManager._selectContextMode('flights'), 15_000); await settleWithin(styleManager._contextControls._selectContextMode('flights'), 15_000);
} }
} }
}); });
@@ -1060,7 +1060,7 @@ try {
return { return {
before: before ? { layerId: before.layerId, id: before.id } : null, before: before ? { layerId: before.layerId, id: before.id } : null,
after: after ? { layerId: after.layerId, id: after.id } : null, after: after ? { layerId: after.layerId, id: after.id } : null,
mode: styleManager._contextMode, mode: styleManager._contextControls._contextMode,
released, released,
refocused, refocused,
trackedId: trackedInfo?.icao24 || trackedInfo?.id || null, trackedId: trackedInfo?.icao24 || trackedInfo?.id || null,
@@ -1206,9 +1206,9 @@ try {
// Start from outside both, so activation is a real edge. // Start from outside both, so activation is a real edge.
cockpit.exit({ restoreTracking: true }); cockpit.exit({ restoreTracking: true });
await settle(); await settle();
await styleManager._selectContextMode(null); await styleManager._contextControls._selectContextMode(null);
await settle(400); await settle(400);
const cleanupSnapshotCleared = styleManager._contextSessionSnapshot === null; const cleanupSnapshotCleared = styleManager._contextControls._contextSessionSnapshot === null;
const cleanupBlockerId = '__qa_slow_contacts_sibling__'; const cleanupBlockerId = '__qa_slow_contacts_sibling__';
const cleanupUnregistered = await window.__gevQaUnregisterLayer( const cleanupUnregistered = await window.__gevQaUnregisterLayer(
window.__godsEyeView.dataManager, window.__godsEyeView.dataManager,
@@ -1224,7 +1224,7 @@ try {
const overriddenBeforeContacts = styleManager._detectionUserOverridden; const overriddenBeforeContacts = styleManager._detectionUserOverridden;
// 1. Activating Contacts forces the tactical preset on. // 1. Activating Contacts forces the tactical preset on.
const contactsOn = await styleManager._selectContextMode('flights'); const contactsOn = await styleManager._contextControls._selectContextMode('flights');
await settle(400); await settle(400);
const afterContactsOn = mode(); const afterContactsOn = mode();
const afterContactsOnDensity = density(); const afterContactsOnDensity = density();
@@ -1254,7 +1254,7 @@ try {
await settle(); await settle();
// 4. Deactivating Contacts restores the pre-Contacts state. // 4. Deactivating Contacts restores the pre-Contacts state.
await styleManager._selectContextMode(null); await styleManager._contextControls._selectContextMode(null);
await settle(400); await settle(400);
const afterContactsOff = mode(); const afterContactsOff = mode();
const afterContactsOffDensity = density(); const afterContactsOffDensity = density();
@@ -1268,7 +1268,7 @@ try {
await settle(); await settle();
// Leave the session exactly as the following checks expect it. // 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); await settle(400);
const restoredCockpit = await enterCockpit(); const restoredCockpit = await enterCockpit();
styleManager._detectionUserOverridden = originalOverridden; styleManager._detectionUserOverridden = originalOverridden;
@@ -1348,8 +1348,8 @@ try {
step, step,
cockpitActive: cockpit.active, cockpitActive: cockpit.active,
bodyCockpit: document.body.classList.contains('cockpit-mode'), bodyCockpit: document.body.classList.contains('cockpit-mode'),
contextMode: styleManager._contextMode, contextMode: styleManager._contextControls._contextMode,
contextChanging: styleManager._contextModeChanging, contextChanging: styleManager._contextControls._contextModeChanging,
subject: context?.subject ? `${context.subject.layerId}:${context.subject.id}` : null, subject: context?.subject ? `${context.subject.layerId}:${context.subject.id}` : null,
tracked: tracker?.gevTrackedId || null, tracked: tracker?.gevTrackedId || null,
density: styleManager.getDetectionState().densityPct, density: styleManager.getDetectionState().densityPct,
+2 -2
View File
@@ -87,9 +87,9 @@ try {
result.push([ result.push([
'native clear activation presents busy state', 'native clear activation presents busy state',
ui._clearSelectedLayersBtn.getAttribute('aria-busy') === 'true' && ui._clearSelectedLayersBtn.getAttribute('aria-busy') === 'true' &&
!!ui._clearSelectedLayersPromise, !!ui._contextControls._clearSelectedLayersPromise,
]); ]);
await ui._clearSelectedLayersPromise; await ui._contextControls._clearSelectedLayersPromise;
result.push([ result.push([
'clear settles through the existing layer transaction', 'clear settles through the existing layer transaction',
!manager.isEnabled(id) && !manager.isEnabled(id) &&
+42 -38
View File
@@ -783,10 +783,10 @@ async function main() {
const originalRestoreEnabledLayerIds = dataManager.restoreEnabledLayerIds; const originalRestoreEnabledLayerIds = dataManager.restoreEnabledLayerIds;
const originalIsEnabled = dataManager.isEnabled; const originalIsEnabled = dataManager.isEnabled;
const originalIsEffectivelyEnabled = dataManager.isEffectivelyEnabled; const originalIsEffectivelyEnabled = dataManager.isEffectivelyEnabled;
const originalClear = styleManager._clearLayersOutsideContextMode; const originalClear = styleManager._contextControls._clearLayersOutsideContextMode;
const originalCapture = styleManager._captureContextSessionSnapshot; const originalCapture = styleManager._contextControls._captureContextSessionSnapshot;
const originalContextMode = styleManager._contextMode; const originalContextMode = styleManager._contextControls._contextMode;
const originalSnapshot = styleManager._contextSessionSnapshot; const originalSnapshot = styleManager._contextControls._contextSessionSnapshot;
const originalShowToast = styleManager._showToast; const originalShowToast = styleManager._showToast;
const installations = dataManager.layers.get('military-installations')?.module; const installations = dataManager.layers.get('military-installations')?.module;
const originalSearchNearby = installations?.searchNearby; const originalSearchNearby = installations?.searchNearby;
@@ -797,12 +797,12 @@ async function main() {
const semanticFalseMessage = document.getElementById('toast').textContent; const semanticFalseMessage = document.getElementById('toast').textContent;
const enabled = new Set(); const enabled = new Set();
styleManager._captureContextSessionSnapshot = () => {}; styleManager._contextControls._captureContextSessionSnapshot = () => {};
const runContextFailure = async (mode, phase, outcome) => { const runContextFailure = async (mode, phase, outcome) => {
enabled.clear(); enabled.clear();
enabled.add('local-datacenters'); enabled.add('local-datacenters');
styleManager._contextMode = null; styleManager._contextControls._contextMode = null;
styleManager._contextSessionSnapshot = { styleManager._contextControls._contextSessionSnapshot = {
enabledLayerIds: new Set(enabled), enabledLayerIds: new Set(enabled),
userAdded: new Set(), userAdded: new Set(),
userRemoved: new Set(), userRemoved: new Set(),
@@ -822,7 +822,7 @@ async function main() {
return true; return true;
}; };
const result = await styleManager._runUserFacingContextAction( const result = await styleManager._runUserFacingContextAction(
() => styleManager._selectContextMode(mode), () => styleManager._contextControls._selectContextMode(mode),
`${mode} QA transition failed`, `${mode} QA transition failed`,
); );
await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));
@@ -831,8 +831,8 @@ async function main() {
phase, phase,
outcome, outcome,
result, result,
visibleMode: styleManager._contextMode, visibleMode: styleManager._contextControls._contextMode,
snapshotCleared: styleManager._contextSessionSnapshot === null, snapshotCleared: styleManager._contextControls._contextSessionSnapshot === null,
priorLayerRestored: enabled.has('local-datacenters'), priorLayerRestored: enabled.has('local-datacenters'),
entryEnabled: enabled.has(entryLayerId), entryEnabled: enabled.has(entryLayerId),
flightsButtonDisabled: document.getElementById('global-context-flights-btn').disabled, flightsButtonDisabled: document.getElementById('global-context-flights-btn').disabled,
@@ -870,8 +870,8 @@ async function main() {
for (const outcome of ['false', 'reject']) { for (const outcome of ['false', 'reject']) {
enabled.clear(); enabled.clear();
enabled.add('local-datacenters'); enabled.add('local-datacenters');
styleManager._contextMode = null; styleManager._contextControls._contextMode = null;
styleManager._contextSessionSnapshot = { styleManager._contextControls._contextSessionSnapshot = {
enabledLayerIds: new Set(enabled), enabledLayerIds: new Set(enabled),
userAdded: new Set(), userAdded: new Set(),
userRemoved: new Set(), userRemoved: new Set(),
@@ -886,7 +886,7 @@ async function main() {
dataManager.setEnabled = async (candidateId, shouldEnable, options = {}) => { dataManager.setEnabled = async (candidateId, shouldEnable, options = {}) => {
if (!failureUsed && candidateId === 'local-datacenters' && !shouldEnable) { if (!failureUsed && candidateId === 'local-datacenters' && !shouldEnable) {
failureUsed = true; failureUsed = true;
styleManager._handleContextLayerChange({ styleManager._contextControls._handleContextLayerChange({
type: 'visibility-failed', type: 'visibility-failed',
layerId: candidateId, layerId: candidateId,
enabled: shouldEnable, enabled: shouldEnable,
@@ -905,7 +905,7 @@ async function main() {
enabled: true, enabled: true,
origin: 'user', origin: 'user',
}); });
styleManager._handleContextLayerChange({ styleManager._contextControls._handleContextLayerChange({
type: 'visibility-blocked', type: 'visibility-blocked',
layerId, layerId,
enabled: true, enabled: true,
@@ -925,10 +925,10 @@ async function main() {
reason, reason,
retryReason, retryReason,
toastMessages, toastMessages,
mode: styleManager._contextMode, mode: styleManager._contextControls._contextMode,
snapshotCleared: styleManager._contextSessionSnapshot === null, snapshotCleared: styleManager._contextControls._contextSessionSnapshot === null,
priorLayerRestored, priorLayerRestored,
modeChanging: styleManager._contextModeChanging, modeChanging: styleManager._contextControls._contextModeChanging,
}); });
} }
} }
@@ -937,8 +937,8 @@ async function main() {
const directActivationFailures = []; const directActivationFailures = [];
for (const layerId of ['military-awareness', 'rocket-launches']) { for (const layerId of ['military-awareness', 'rocket-launches']) {
enabled.clear(); enabled.clear();
styleManager._contextMode = null; styleManager._contextControls._contextMode = null;
styleManager._contextSessionSnapshot = { styleManager._contextControls._contextSessionSnapshot = {
enabledLayerIds: new Set(['local-datacenters']), enabledLayerIds: new Set(['local-datacenters']),
userAdded: new Set(), userAdded: new Set(),
userRemoved: new Set(), userRemoved: new Set(),
@@ -948,7 +948,7 @@ async function main() {
shouldEnable ? enabled.add(candidateId) : enabled.delete(candidateId); shouldEnable ? enabled.add(candidateId) : enabled.delete(candidateId);
return true; return true;
}; };
styleManager._handleContextLayerChange({ styleManager._contextControls._handleContextLayerChange({
type: 'visibility-failed', type: 'visibility-failed',
layerId, layerId,
enabled: true, enabled: true,
@@ -957,8 +957,8 @@ async function main() {
await new Promise((resolve) => setTimeout(resolve, 50)); await new Promise((resolve) => setTimeout(resolve, 50));
directActivationFailures.push({ directActivationFailures.push({
layerId, layerId,
mode: styleManager._contextMode, mode: styleManager._contextControls._contextMode,
snapshotCleared: styleManager._contextSessionSnapshot === null, snapshotCleared: styleManager._contextControls._contextSessionSnapshot === null,
priorLayerRestored: enabled.has('local-datacenters'), priorLayerRestored: enabled.has('local-datacenters'),
failedLayerDisabled: !enabled.has(layerId), failedLayerDisabled: !enabled.has(layerId),
}); });
@@ -967,8 +967,8 @@ async function main() {
const directActivationRollbackFailures = []; const directActivationRollbackFailures = [];
for (const layerId of ['military-awareness', 'rocket-launches']) { for (const layerId of ['military-awareness', 'rocket-launches']) {
enabled.clear(); enabled.clear();
styleManager._contextMode = null; styleManager._contextControls._contextMode = null;
styleManager._contextSessionSnapshot = { styleManager._contextControls._contextSessionSnapshot = {
enabledLayerIds: new Set(['local-datacenters']), enabledLayerIds: new Set(['local-datacenters']),
userAdded: new Set(), userAdded: new Set(),
userRemoved: new Set(), userRemoved: new Set(),
@@ -985,7 +985,7 @@ async function main() {
shouldEnable ? enabled.add(candidateId) : enabled.delete(candidateId); shouldEnable ? enabled.add(candidateId) : enabled.delete(candidateId);
return true; return true;
}; };
styleManager._handleContextLayerChange({ styleManager._contextControls._handleContextLayerChange({
type: 'visibility-failed', type: 'visibility-failed',
layerId, layerId,
enabled: true, enabled: true,
@@ -996,7 +996,7 @@ async function main() {
directActivationRollbackFailures.push({ directActivationRollbackFailures.push({
layerId, layerId,
toastMessages, 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); window.__gevQaRegisterLayer(dataManager, qaLayer);
try { try {
const enabledBeforeExit = dataManager.getEnabledLayerIds(); const enabledBeforeExit = dataManager.getEnabledLayerIds();
styleManager._contextMode = 'flights'; styleManager._contextControls._contextMode = 'flights';
styleManager._contextSessionSnapshot = { styleManager._contextControls._contextSessionSnapshot = {
enabledLayerIds: new Set([...enabledBeforeExit, layerId]), enabledLayerIds: new Set([...enabledBeforeExit, layerId]),
userAdded: new Set(), userAdded: new Set(),
userRemoved: new Set(), userRemoved: new Set(),
@@ -1039,13 +1039,13 @@ async function main() {
}; };
const unhandledBefore = unhandled.length; const unhandledBefore = unhandled.length;
const result = await styleManager._runUserFacingContextAction( const result = await styleManager._runUserFacingContextAction(
(notificationToken) => styleManager._deactivateContextForLayerChange({ notificationToken }), (notificationToken) => styleManager._contextControls._deactivateContextForLayerChange({ notificationToken }),
`QA Context exit ${outcome} surfaced once`, `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'; enableOutcome = 'success';
const retryResult = await styleManager._runUserFacingContextAction( const retryResult = await styleManager._runUserFacingContextAction(
(notificationToken) => styleManager._deactivateContextForLayerChange({ notificationToken }), (notificationToken) => styleManager._contextControls._deactivateContextForLayerChange({ notificationToken }),
`QA Context exit ${outcome} retry failed`, `QA Context exit ${outcome} retry failed`,
); );
contextExitFailures.push({ contextExitFailures.push({
@@ -1055,9 +1055,9 @@ async function main() {
toastMessages, toastMessages,
unhandledDelta: unhandled.length - unhandledBefore, unhandledDelta: unhandled.length - unhandledBefore,
retainedForRetry, retainedForRetry,
mode: styleManager._contextMode, mode: styleManager._contextControls._contextMode,
modeChanging: styleManager._contextModeChanging, modeChanging: styleManager._contextControls._contextModeChanging,
snapshotClearedAfterRetry: styleManager._contextSessionSnapshot === null, snapshotClearedAfterRetry: styleManager._contextControls._contextSessionSnapshot === null,
enabledAfterRetry: dataManager.isEnabled(layerId), enabledAfterRetry: dataManager.isEnabled(layerId),
toastRole: document.getElementById('toast').getAttribute('role'), toastRole: document.getElementById('toast').getAttribute('role'),
toastLive: document.getElementById('toast').getAttribute('aria-live'), toastLive: document.getElementById('toast').getAttribute('aria-live'),
@@ -1163,10 +1163,10 @@ async function main() {
dataManager.restoreEnabledLayerIds = originalRestoreEnabledLayerIds; dataManager.restoreEnabledLayerIds = originalRestoreEnabledLayerIds;
dataManager.isEnabled = originalIsEnabled; dataManager.isEnabled = originalIsEnabled;
dataManager.isEffectivelyEnabled = originalIsEffectivelyEnabled; dataManager.isEffectivelyEnabled = originalIsEffectivelyEnabled;
styleManager._clearLayersOutsideContextMode = originalClear; styleManager._contextControls._clearLayersOutsideContextMode = originalClear;
styleManager._captureContextSessionSnapshot = originalCapture; styleManager._contextControls._captureContextSessionSnapshot = originalCapture;
styleManager._contextMode = originalContextMode; styleManager._contextControls._contextMode = originalContextMode;
styleManager._contextSessionSnapshot = originalSnapshot; styleManager._contextControls._contextSessionSnapshot = originalSnapshot;
styleManager._showToast = originalShowToast; styleManager._showToast = originalShowToast;
if (installations) installations.searchNearby = originalSearchNearby; if (installations) installations.searchNearby = originalSearchNearby;
window.removeEventListener('unhandledrejection', onUnhandled); window.removeEventListener('unhandledrejection', onUnhandled);
@@ -2031,6 +2031,10 @@ async function main() {
}; };
const waitForHudSettle = async () => { const waitForHudSettle = async () => {
await new Promise((resolve) => setTimeout(resolve, 560)); await new Promise((resolve) => setTimeout(resolve, 560));
// The synthetic shell below stops the aircraft/context update loop.
// Restore its layout pass after the HUD fade before measuring geometry;
// qa-cockpit-utility exercises scheduling with the real controller.
manager.cockpitView.scheduleContextLayout();
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
}; };
const prior = { const prior = {
+28
View File
@@ -58,6 +58,23 @@ try {
counts.cctv++; counts.cctv++;
return cctvUnsubscribe(); 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 resizeHandler = ui._windowResizeHandler;
const removeEventListener = window.removeEventListener; const removeEventListener = window.removeEventListener;
counts.resize = 0; counts.resize = 0;
@@ -79,6 +96,13 @@ try {
resizeHandler && resizeHandler &&
observed.includes('_commandDockTrayObserver'), observed.includes('_commandDockTrayObserver'),
), ),
contextReleased:
contextConnected &&
ui._contextControls.destroyed &&
contextFields.every(
(field) =>
counts[field] === 1 && ui._contextControls[field] === null,
),
observersReleased: observed.every( observersReleased: observed.every(
(name) => counts[name] === 1 && ui[name] === null, (name) => counts[name] === 1 && ui[name] === null,
), ),
@@ -99,6 +123,10 @@ try {
'real UI has the expected live resources before disposal', 'real UI has the expected live resources before disposal',
result.connected, result.connected,
); );
check(
'UI disposal releases all Context subscriptions and stops its controls',
result.contextReleased,
);
check( check(
'UI disposal disconnects each active panel observer once', 'UI disposal disconnects each active panel observer once',
result.observersReleased, result.observersReleased,
+2 -2
View File
@@ -300,7 +300,7 @@ test('newer navigation, reset, Cockpit, and teardown share one generation', () =
'this._disposed = true;', 'this._disposed = true;',
'this._stampNavigation();', 'this._stampNavigation();',
'this._removeWorldRequestFocusListener?.();', 'this._removeWorldRequestFocusListener?.();',
'await this._restoreContextSession();', 'await this._contextControls.restoreForDisposal();',
], 'dispose invalidation'); ], 'dispose invalidation');
}); });
@@ -311,7 +311,7 @@ test('teardown synchronously closes immediate camera entry points', () => {
'this._removeCctvRequestFocusListener?.();', 'this._removeCctvRequestFocusListener?.();',
'this._removeWorldRequestFocusListener?.();', 'this._removeWorldRequestFocusListener?.();',
'this._navigationOwnerChangedRemover?.();', 'this._navigationOwnerChangedRemover?.();',
'await this._restoreContextSession();', 'await this._contextControls.restoreForDisposal();',
], 'synchronous teardown barrier'); ], 'synchronous teardown barrier');
const navigation = body( const navigation = body(
+11 -9
View File
@@ -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'; import { readFileSync as readRadioSource } from 'node:fs';
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8'); 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 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', () => { test('programmatic Context layer changes cannot bypass explicit expansion policy', () => {
const handler = ui.match(/_handleContextLayerChange\(change\) \{([\s\S]*?)\n \}\n\n _syncContextModeButtons/); assert.doesNotMatch(_handleContextLayerChange.toString(), /setPanelCollapsed\('global-context-panel', false\)/);
assert.ok(handler, 'Context layer state handler is missing');
assert.doesNotMatch(handler[1], /setPanelCollapsed\('global-context-panel', false\)/);
}); });
test('share startup isolates panel defaults from recipient-local collapse preferences', () => { 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, manager,
/for \(const \{ layerId, intentEpoch \} of targets\)[\s\S]*?visibilityIntentEpoch !== intentEpoch[\s\S]*?await this\.setEnabled\(layerId, false/, /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(clearSelectedLayers.toString(), /if \(this\._clearSelectedLayersPromise\) return this\._clearSelectedLayersPromise/);
assert.match(ui, /async _selectContextMode\([\s\S]*?if \(this\._clearSelectedLayersPromise\) return false/); assert.match(_selectContextMode.toString(), /async function _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(clearSelectedLayers.toString(), /this\._contextModeGeneration[\s\S]*?this\._contextSessionSnapshot = null;[\s\S]*?this\._contextRestoreState = null;/);
assert.match(ui, /if \(this\._contextRestoreState\) this\._contextRestoreState\.cancelled = true/); assert.match(clearSelectedLayers.toString(), /if \(this\._contextRestoreState\) this\._contextRestoreState\.cancelled = true/);
assert.match(ui, /if \(restoreState\.cancelled\) return;[\s\S]*?settleContextIntentReplay/); 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(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', () => { test('Cockpit Display portal retains both scroll owners across round trips', () => {
+4 -2
View File
@@ -1,3 +1,4 @@
import { _syncContextModeButtons } from './ui/contextPresentation.js';
// Contacts-scoped detection (owner playtest 2026-08-18: "when you click on // 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 // Contacts, detections should just turn on, and they should stay on in Cockpit
// or in third-person tracking inside Contacts"). // 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', 'ACTIVATION turns detection on REGARDLESS of the style-preset override flag',
); );
assert.match( assert.match(
uiSource, _syncContextModeButtons.toString(),
/this\.cockpitView\?\.syncEntry\(\);[\s\S]{0,220}?this\._syncContactsDetection\(\);/, /this\.cockpitView\?\.syncEntry\(\);[\s\S]{0,220}?this\.actions\.syncDetection\(\);/,
'called from _syncContextModeButtons, the funnel every _contextMode mutation routes through', '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 // The cockpit vision hook — the old trigger — must be out of the detection
// business entirely, or leaving the cockpit turns detections off again. // business entirely, or leaving the cockpit turns detections off again.
const visionHook = uiSource.slice( const visionHook = uiSource.slice(
+44 -86
View File
@@ -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'; import { readFileSync as readRadioSource } from 'node:fs';
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8'); 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 radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
const radioControlsSource = readRadioSource(new URL('./ui/radioControls.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. // Source-contract pins read the actual Context owners and root disposal wiring. Each pin guards a bug that shipped or nearly shipped:
// 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:
// - session bookkeeping ran AFTER the exit early-return, so the compensating // - session bookkeeping ran AFTER the exit early-return, so the compensating
// userAdded.delete never ran on the left-panel chip exit and restoration // userAdded.delete never ran on the left-panel chip exit and restoration
// resurrected the mission layer the user just disabled; // 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 src = readFileSync(fileURLToPath(new URL('./ui.js', import.meta.url)), 'utf8');
const handlerStart = src.indexOf('_handleContextLayerChange(change) {'); const handler = _handleContextLayerChange.toString();
assert.ok(handlerStart > 0, 'handler found');
const handlerEnd = src.indexOf('_syncContextModeButtons() {', handlerStart);
const handler = src.slice(handlerStart, handlerEnd);
test('context handler: session bookkeeping runs before the exit early-return', () => { test('context handler: session bookkeeping runs before the exit early-return', () => {
const record = handler.indexOf('recordContextSessionUserChange('); const record = handler.indexOf('recordContextSessionUserChange(');
@@ -56,23 +57,19 @@ test('cancelled Space Missions entry resolves ownership before settled bookkeepi
}); });
test('direct Context entry captures on the synchronous request boundary', () => { test('direct Context entry captures on the synchronous request boundary', () => {
const attachStart = src.indexOf('attachDataManager(dataManager) {'); const attach = connectContextManager.toString();
const attachEnd = src.indexOf('_syncContextModeButtons()', attachStart);
const attach = src.slice(attachStart, attachEnd);
assert.match( assert.match(
attach, attach,
/subscribeVisibilityRequests\(\(change\) => \{[\s\S]*?shouldCaptureContextSession\(change\)/, /subscribeVisibilityRequests\(\(change\) => \{[\s\S]*?shouldCaptureContextSession\(change\)/,
); );
assert.match( assert.match(
attach, attach,
/_captureContextSessionSnapshot\(\{ excludeLayerIds: \[change\.layerId\] \}\)/, /_captureContextSessionSnapshot\(\{\s*excludeLayerIds:\s*\[change\.layerId\],?\s*\}\)/,
); );
}); });
test('Clear All defers a newer explicit mission guard until its batch settles', () => { test('Clear All defers a newer explicit mission guard until its batch settles', () => {
const attachStart = src.indexOf('attachDataManager(dataManager) {'); const attach = connectContextManager.toString();
const attachEnd = src.indexOf('if (typeof this._dataManager?.subscribeBeforeDestroy', attachStart);
const attach = src.slice(attachStart, attachEnd);
assert.match(attach, /shouldDeferContextEntryDuringClear\(\{/); assert.match(attach, /shouldDeferContextEntryDuringClear\(\{/);
assert.match(attach, /_contextModeDeferredEntryIntent = \{/); assert.match(attach, /_contextModeDeferredEntryIntent = \{/);
assert.match(attach, /await this\._clearSelectedLayersManagerPromise/); 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', () => { test('context restore replays explicit companion intent after the stale restore queue drains', () => {
const restore = src.slice( const restore = _restoreContextSession.toString();
src.indexOf('async _restoreContextSession('),
src.indexOf('async _selectContextMode('),
);
const restoreAwait = restore.indexOf('await this._dataManager.restoreEnabledLayerIds('); const restoreAwait = restore.indexOf('await this._dataManager.restoreEnabledLayerIds(');
const replay = restore.indexOf('await settleContextIntentReplay('); const replay = restore.indexOf('await settleContextIntentReplay(');
assert.ok(restoreAwait > 0, 'restore queue is awaited'); 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', () => { test('context restore settles the Contacts coordinator before dependency fanout', () => {
const restore = src.slice( const restore = _restoreContextSession.toString();
src.indexOf('async _restoreContextSession('),
src.indexOf('async _selectContextMode('),
);
const settle = restore.indexOf("const contactsCoordinatorId = 'military-awareness'"); const settle = restore.indexOf("const contactsCoordinatorId = 'military-awareness'");
const coordinatorOff = restore.indexOf('const coordinatorSettled = await this._dataManager.setEnabled(', settle); const coordinatorOff = restore.indexOf('const coordinatorSettled = await this._dataManager.setEnabled(', settle);
const fanout = restore.indexOf('await this._dataManager.restoreEnabledLayerIds(', 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', () => { test('context restore preserves its exact pending target after a failed transition', () => {
const restore = src.slice( const restore = _restoreContextSession.toString();
src.indexOf('async _restoreContextSession('),
src.indexOf('async _selectContextMode('),
);
assert.match(restore, /const replayError = await settleContextIntentReplay\(\{/); assert.match(restore, /const replayError = await settleContextIntentReplay\(\{/);
assert.match(restore, /if \(restoreError && !this\._contextSessionSnapshot\)/); assert.match(restore, /if \(restoreError && !this\._contextSessionSnapshot\)/);
assert.match(restore, /enabledLayerIds: new Set\(restoreState\.enabledLayerIds\)/); assert.match(restore, /enabledLayerIds: new Set\(restoreState\.enabledLayerIds\)/);
@@ -124,25 +112,22 @@ test('context handler: either failed direct Context-shell start rolls the sessio
assert.match(failedBranch, /\['military-awareness', 'rocket-launches'\]\.includes\(change\.layerId\)/); assert.match(failedBranch, /\['military-awareness', 'rocket-launches'\]\.includes\(change\.layerId\)/);
assert.match( assert.match(
failedBranch, failedBranch,
/_restoreContextSessionAfterLayerSettles\(\s*change\.layerId,\s*\{ notificationToken \},\s*\)/, /_restoreContextSessionAfterLayerSettles\(\s*change\.layerId,\s*\{\s*notificationToken,?\s*\},?\s*\)/,
); );
assert.match(failedBranch, /_runUserFacingContextAction/); assert.match(failedBranch, /_runUserFacingContextAction/);
assert.match(failedBranch, /const failureMessage =/); assert.match(failedBranch, /const failureMessage =/);
assert.match( assert.match(
failedBranch, 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, /\},\s*failureMessage,?\s*\),?\s*\);/);
assert.match(failedBranch, /_trackContextLayerReaction\(/); assert.match(failedBranch, /_trackContextLayerReaction\(/);
assert.match( assert.match(
failedBranch, failedBranch,
/else if \(!this\._userFacingContextNotificationTokens\.has\(change\.notificationToken\)\)/, /else if \(\s*!this\._userFacingContextNotificationTokens\.has\(change\.notificationToken\)\s*\)/,
); );
const deferredRestore = src.slice( const deferredRestore = _restoreContextSessionAfterLayerSettles.toString();
src.indexOf('async _restoreContextSessionAfterLayerSettles('),
src.indexOf('async _selectContextMode('),
);
assert.match(deferredRestore, /await this\._dataManager\?\.waitForLayerSettled\?\.\(layerId\)/); assert.match(deferredRestore, /await this\._dataManager\?\.waitForLayerSettled\?\.\(layerId\)/);
assert.match(deferredRestore, /return this\._restoreContextSession\(\{ notificationToken \}\)/); assert.match(deferredRestore, /return this\._restoreContextSession\(\{ notificationToken \}\)/);
assert.doesNotMatch(failedBranch, /excludeLayerIds/); assert.doesNotMatch(failedBranch, /excludeLayerIds/);
@@ -157,20 +142,17 @@ test('wrapped visibility blocks leave the accessible toast to the wrapper token
blockedBranch, blockedBranch,
/!this\._userFacingContextNotificationTokens\.has\(change\.notificationToken\)/, /!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', () => { test('every user-facing Context exit route settles through the failure surface', () => {
const initPanel = src.slice( const initPanel = _initGlobalContextPanel.toString();
src.indexOf('_initGlobalContextPanel() {'),
src.indexOf('_captureContextSessionSnapshot(', src.indexOf('_initGlobalContextPanel() {')),
);
assert.equal((initPanel.match(/void this\._runUserFacingContextAction/g) || []).length, 3); assert.equal((initPanel.match(/void this\._runUserFacingContextAction/g) || []).length, 3);
assert.doesNotMatch(initPanel, /falseIsFailure:\s*false/); assert.doesNotMatch(initPanel, /falseIsFailure:\s*false/);
assert.doesNotMatch(initPanel, /void this\._selectContextMode/); assert.doesNotMatch(initPanel, /void this\._selectContextMode/);
const deactivationCalls = [...handler.matchAll( const deactivationCalls = [...handler.matchAll(
/void this\._trackContextLayerReaction\(this\._runUserFacingContextAction\(\(notificationToken\) => \(\s*this\._deactivateContextForLayerChange\(\{ notificationToken \}\)\s*\)\)\)/g, /void this\._trackContextLayerReaction\(\s*this\._runUserFacingContextAction\(\(notificationToken\) =>\s*this\._deactivateContextForLayerChange\(\{ notificationToken \}\),?\s*\),?\s*\)/g,
)]; )];
assert.equal(deactivationCalls.length, 4, 'dependency and primary layer exits share the caught restore path'); assert.equal(deactivationCalls.length, 4, 'dependency and primary layer exits share the caught restore path');
assert.doesNotMatch(handler, /void this\._deactivateContextForLayerChange\(\)/); assert.doesNotMatch(handler, /void this\._deactivateContextForLayerChange\(\)/);
@@ -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', () => { test('right-rail context entry is transactional: activation result gates the mode', () => {
const select = src.slice( const select = _selectContextMode.toString();
src.indexOf('async _selectContextMode('),
src.indexOf('async _deactivateContextForLayerChange('),
);
const activation = select.indexOf('activationIntent = this._dataManager._setEnabledWithIntent('); const activation = select.indexOf('activationIntent = this._dataManager._setEnabledWithIntent(');
assert.ok(activation > 0, 'activation result captured'); assert.ok(activation > 0, 'activation result captured');
const failureBlock = select.slice(activation); const failureBlock = select.slice(activation);
@@ -211,8 +190,8 @@ test('right-rail context entry is transactional: activation result gates the mod
assert.match(failureBlock, /await this\._dataManager\._waitForVisibilityIntent\?\.\(/); assert.match(failureBlock, /await this\._dataManager\._waitForVisibilityIntent\?\.\(/);
assert.match(failureBlock, /outcome\?\.intentEpoch === replacementIntent\.intentEpoch[\s\S]*?outcome\.succeeded === true/); assert.match(failureBlock, /outcome\?\.intentEpoch === replacementIntent\.intentEpoch[\s\S]*?outcome\.succeeded === true/);
assert.match(failureBlock, /outcome\?\.cancellationReason === 'superseded'[\s\S]*?outcome\.successorEnabled === true[\s\S]*?outcome\.successorIntentEpoch > replacementIntent\.intentEpoch/); assert.match(failureBlock, /outcome\?\.cancellationReason === 'superseded'[\s\S]*?outcome\.successorEnabled === true[\s\S]*?outcome\.successorIntentEpoch > replacementIntent\.intentEpoch/);
assert.match(failureBlock, /activationError \|\| activated === false \|\| !this\._dataManager\.isEnabled\(entryLayerId\)/); assert.match(failureBlock, /activationError\s*\|\|\s*activated === false\s*\|\|\s*!this\._dataManager\.isEnabled\(entryLayerId\)/);
assert.match(failureBlock, /cancelledAndSettled = terminalIntentOutcome\?\.succeeded === false/); assert.match(failureBlock, /cancelledAndSettled\s*=\s*terminalIntentOutcome\?\.succeeded === false/);
assert.match(failureBlock, /\['caller-abort', 'resource-abort', 'superseded'\]/); assert.match(failureBlock, /\['caller-abort', 'resource-abort', 'superseded'\]/);
assert.match(failureBlock, /this\._contextMode = null/); assert.match(failureBlock, /this\._contextMode = null/);
assert.match(failureBlock, /await this\._restoreContextSession\(\{[\s\S]*?excludeLayerIds: \[entryLayerId\],[\s\S]*?notificationToken/); assert.match(failureBlock, /await this\._restoreContextSession\(\{[\s\S]*?excludeLayerIds: \[entryLayerId\],[\s\S]*?notificationToken/);
@@ -220,11 +199,8 @@ 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', () => { test('Context entry awaits isolation and direct shell routes isolate in the visibility guard', () => {
const select = src.slice( const select = _selectContextMode.toString();
src.indexOf('async _selectContextMode('), assert.match(select, /await this\._clearLayersOutsideContextMode\(mode,\s*\{\s*notificationToken,\s*signal,?\s*\}\)/);
src.indexOf('async _deactivateContextForLayerChange('),
);
assert.match(select, /await this\._clearLayersOutsideContextMode\(mode, \{ notificationToken, signal \}\)/);
assert.match(select, /await this\._restoreContextSession\(\{ notificationToken, signal \}\)/); assert.match(select, /await this\._restoreContextSession\(\{ notificationToken, signal \}\)/);
assert.doesNotMatch(select, /void this\._clearLayersOutsideContextMode\(mode\)/); assert.doesNotMatch(select, /void this\._clearLayersOutsideContextMode\(mode\)/);
@@ -255,21 +231,19 @@ test('Context entry awaits isolation and direct shell routes isolate in the visi
// invalidates in-flight Context transactions so teardown cannot be raced into // invalidates in-flight Context transactions so teardown cannot be raced into
// publishing a mode. // publishing a mode.
const dispose = src.slice(src.indexOf(' async dispose() {'), src.indexOf('this._stampNavigation();', src.indexOf(' async dispose() {'))); 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( const guard = connectContextManager.toString();
src.indexOf('this._dataManagerVisibilityGuardUnsubscribe = this._dataManager.addVisibilityGuard'),
src.indexOf("if (typeof this._dataManager?.subscribeBeforeDestroy === 'function')"),
);
assert.match(guard, /\['military-awareness', 'rocket-launches'\]\.includes\(change\.layerId\)/); 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, /const notificationToken\s*=\s*change\.notificationToken\s*\|\|\s*Symbol\('direct-context-shell-entry'\)/);
assert.match(guard, /this\._userFacingContextNotificationTokens\.add\(notificationToken\)/); assert.match(guard, /this\._userFacingContextNotificationTokens\.add\(notificationToken\)/);
assert.match(guard, /await this\._clearLayersOutsideContextMode\(entryMode, \{ notificationToken \}\)/); assert.match(guard, /await this\._clearLayersOutsideContextMode\(entryMode,\s*\{\s*notificationToken,?\s*\}\)/);
assert.match( assert.match(
guard, guard,
/await this\._restoreContextSession\(\{\s*excludeLayerIds: \[change\.layerId\],\s*notificationToken,\s*\}\)/, /await this\._restoreContextSession\(\{\s*excludeLayerIds: \[change\.layerId\],\s*notificationToken,\s*\}\)/,
); );
assert.match(guard, /this\._userFacingContextNotificationTokens\.delete\(notificationToken\)/); assert.match(guard, /this\._userFacingContextNotificationTokens\.delete\(\s*notificationToken,?\s*\)/);
}); });
test('a lost cross-mode switch says Context is off, and the state agrees', () => { test('a lost cross-mode switch says Context is off, and the state agrees', () => {
@@ -277,14 +251,11 @@ 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. // 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, // Text and state are derived from the same verdict so they cannot disagree,
// and the failed layer ids survive (Manjunath's honesty requirement). // and the failed layer ids survive (Manjunath's honesty requirement).
const setter = src.slice( const setter = contextModeAction.toString();
src.indexOf(' async setContextMode(mode, {'),
src.indexOf(' getCockpitState() {'),
);
assert.match(setter, /const priorMode = this\._contextMode;/, 'the report knows what was lost'); assert.match(setter, /const priorMode = this\._contextMode;/, 'the report knows what was lost');
assert.match( assert.match(
setter, setter,
/const crossModeSwitchLost = transitioned !== true\s*&& Boolean\(priorMode\) && priorMode !== canonical && !state\.mode;/, /const crossModeSwitchLost\s*=\s*transitioned !== true\s*&&\s*Boolean\(priorMode\)\s*&&\s*priorMode !== canonical\s*&&\s*!state\.mode;/,
'the verdict requires a real cross-mode switch that ended with no mode', 'the verdict requires a real cross-mode switch that ended with no mode',
); );
assert.match( assert.match(
@@ -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', () => { test('Context cancellation reaches isolation and restore lifecycle mutations', () => {
const clearStart = src.indexOf('async _clearLayersOutsideContextMode('); const clear = _clearLayersOutsideContextMode.toString();
const clear = src.slice(clearStart, src.indexOf('_handleContextLayerChange(', clearStart));
assert.match(clear, /setEnabled\(layerId, false, \{[\s\S]*?signal/); assert.match(clear, /setEnabled\(layerId, false, \{[\s\S]*?signal/);
const restore = src.slice( const restore = _restoreContextSession.toString();
src.indexOf('async _restoreContextSession('),
src.indexOf('async _restoreContextSessionAfterLayerSettles('),
);
assert.match(restore, /restoreEnabledLayerIds\([\s\S]*?signal/); assert.match(restore, /restoreEnabledLayerIds\([\s\S]*?signal/);
assert.match(restore, /setEnabled: \(layerId, enabled, options = \{\}\)[\s\S]*?signal/); assert.match(restore, /setEnabled: \(layerId, enabled, options = \{\}\)[\s\S]*?signal/);
assert.match(restore, /error\.failedLayerIds = \[contactsCoordinatorId\]/); 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', () => { test('Context facade preserves success when cancellation arrives after commit', () => {
const setContextMode = src.slice( const setContextMode = contextModeAction.toString();
src.indexOf('async setContextMode('),
src.indexOf('getCockpitState()', src.indexOf('async setContextMode(')),
);
assert.match( assert.match(
setContextMode, setContextMode,
/transitioned === null \|\| \(!requestIsCurrent\(\) && transitioned !== true\)/, /transitioned === null \|\| \(!requestIsCurrent\(\) && transitioned !== true\)/,
@@ -334,23 +298,17 @@ test('Context facade preserves success when cancellation arrives after commit',
}); });
test('Context production rollback paths merge primary and restore failed-layer identities', () => { test('Context production rollback paths merge primary and restore failed-layer identities', () => {
const select = src.slice( const select = _selectContextMode.toString();
src.indexOf('async _selectContextMode('),
src.indexOf('async _deactivateContextForLayerChange('),
);
assert.equal( assert.equal(
(select.match(/mergeContextTransitionErrors\(transitionError, restoreError\)/g) || []).length, (select.match(/mergeContextTransitionErrors\(\s*transitionError,\s*restoreError,?\s*\)/g) || []).length,
2, 2,
); );
assert.match(select, /transitionError\.failedLayerIds = \[\.\.\.new Set\(\[/); assert.match(select, /transitionError\.failedLayerIds = \[\s*\.\.\.new Set\(\[/);
assert.match(select, /this\._contextTransitionFailedLayerIds = \[\.\.\.\(transitionError\.failedLayerIds \|\| \[\]\)\]/); assert.match(select, /this\._contextTransitionFailedLayerIds = \[\s*\.\.\.\(transitionError\.failedLayerIds \|\| \[\]\),?\s*\]/);
}); });
test('stale Context cancellation preserves rollback failed-layer identities', () => { test('stale Context cancellation preserves rollback failed-layer identities', () => {
const setContextMode = src.slice( const setContextMode = contextModeAction.toString();
src.indexOf('async setContextMode('),
src.indexOf('getCockpitState()', src.indexOf('async setContextMode(')),
);
assert.match( assert.match(
setContextMode, setContextMode,
/if \(!requestIsCurrent\(\)\) \{[\s\S]*?\.\.\.cancellationResult\(\),[\s\S]*?failedLayerIds: \[\.\.\.error\.failedLayerIds\]/, /if \(!requestIsCurrent\(\)\) \{[\s\S]*?\.\.\.cancellationResult\(\),[\s\S]*?failedLayerIds: \[\.\.\.error\.failedLayerIds\]/,
+27 -39
View File
@@ -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'; import { readFileSync as readRadioSource } from 'node:fs';
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8'); 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 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"/); assert.match(button[0], /tabindex="0"/);
} }
const sync = ui.match(/_syncContextModeButtons\(\) \{([\s\S]*?)\n \}\n\n \/\*\* Wire/); const syncSource = _syncContextModeButtons.toString();
assert.ok(sync, 'Context mode sync is missing'); assert.match(syncSource, /\[\s*this\._globalContextFlightsBtn,\s*this\._globalContextMissionsBtn,?\s*\]/);
assert.match(sync[1], /\[this\._globalContextFlightsBtn, this\._globalContextMissionsBtn\]/); assert.match(syncSource, /button\.tabIndex = 0/);
assert.match(sync[1], /button\.tabIndex = 0/); assert.doesNotMatch(syncSource, /tabIndex\s*=\s*[^;]*\?\s*-1/);
assert.doesNotMatch(sync[1], /tabIndex\s*=\s*[^;]*\?\s*-1/);
}); });
test('Context transition state preserves focus and Tab availability until settle', () => { test('Context transition state preserves focus and Tab availability until settle', () => {
const sync = ui.match(/_syncContextModeButtons\(\) \{([\s\S]*?)\n \}\n\n \/\*\* Wire/); const syncSource = _syncContextModeButtons.toString();
assert.ok(sync, 'Context mode sync is missing');
const attributes = () => new Map(); const attributes = () => new Map();
const makeButton = () => { const makeButton = () => {
@@ -56,6 +58,8 @@ test('Context transition state preserves focus and Tab availability until settle
const priorDocument = globalThis.document; const priorDocument = globalThis.document;
globalThis.document = { activeElement: missions, getElementById: () => panel }; globalThis.document = { activeElement: missions, getElementById: () => panel };
const owner = { const owner = {
_globalContextPanel: panel,
actions: { syncDetection() {}, scheduleLayout() {} },
_contextMode: null, _contextMode: null,
_contextModeChanging: true, _contextModeChanging: true,
_globalContextFlightsBtn: contacts, _globalContextFlightsBtn: contacts,
@@ -68,7 +72,7 @@ test('Context transition state preserves focus and Tab availability until settle
_scheduleRightPanelLayout() {}, _scheduleRightPanelLayout() {},
}; };
try { try {
Function(sync[1]).call(owner); _syncContextModeButtons.call(owner);
assert.equal(globalThis.document.activeElement, missions, 'busy sync retains focused Space Missions'); assert.equal(globalThis.document.activeElement, missions, 'busy sync retains focused Space Missions');
for (const button of [contacts, missions]) { for (const button of [contacts, missions]) {
assert.equal(button.disabled, false); assert.equal(button.disabled, false);
@@ -79,7 +83,7 @@ test('Context transition state preserves focus and Tab availability until settle
owner._contextModeChanging = false; owner._contextModeChanging = false;
owner._contextMode = 'space-missions'; owner._contextMode = 'space-missions';
Function(sync[1]).call(owner); _syncContextModeButtons.call(owner);
assert.equal(globalThis.document.activeElement, missions, 'settled sync retains focused Space Missions'); assert.equal(globalThis.document.activeElement, missions, 'settled sync retains focused Space Missions');
for (const button of [contacts, missions]) { for (const button of [contacts, missions]) {
assert.equal(button.disabled, false); 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', () => { test('Context activation and Clear All never native-disable tabs and guard repeated clicks', () => {
const init = ui.slice( const init = _initGlobalContextPanel.toString();
ui.indexOf('_initGlobalContextPanel() {'), const select = _selectContextMode.toString();
ui.indexOf('async _runUserFacingContextAction(', ui.indexOf('_initGlobalContextPanel() {')), const clear = clearSelectedLayers.toString();
); assert.equal((init.match(/if \(\s*this\.destroyed\s*\|\|\s*this\._contextModeChanging\s*\|\|\s*this\._clearSelectedLayersPromise\s*\)\s*return;/g) || []).length, 2);
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);
assert.doesNotMatch(select, /_globalContext(?:Flights|Missions)Btn\.disabled\s*=\s*true/); assert.doesNotMatch(select, /_globalContext(?:Flights|Missions)Btn\.disabled\s*=\s*true/);
assert.doesNotMatch(clear, /_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', () => { test('Context tablist retains Left, Right, Home, and End keyboard navigation', () => {
const init = ui.match(/_initGlobalContextPanel\(\) \{([\s\S]*?)\n this\._globalContextFlightsBtn\?\.addEventListener/); const initSource = _initGlobalContextPanel.toString();
assert.ok(init, 'Context panel initialization is missing'); assert.match(initSource, /event\.key === 'ArrowRight'/);
assert.match(init[1], /event\.key === 'ArrowRight'/); assert.match(initSource, /event\.key === 'ArrowLeft'/);
assert.match(init[1], /event\.key === 'ArrowLeft'/); assert.match(initSource, /event\.key === 'Home'/);
assert.match(init[1], /event\.key === 'Home'/); assert.match(initSource, /event\.key === 'End'/);
assert.match(init[1], /event\.key === 'End'/); assert.match(initSource, /contextTabs\[nextIndex\]\.focus\(\{ preventScroll: true \}\)/);
assert.match(init[1], /contextTabs\[nextIndex\]\.focus\(\{ preventScroll: true \}\)/); assert.match(initSource, /contextTabs\[nextIndex\]\.click\(\)/);
assert.match(init[1], /contextTabs\[nextIndex\]\.click\(\)/);
}); });
test('Context tabs draw a visible keyboard-focus outline including active tabs', () => { 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', () => { test('Context async action buttons remain focused while busy', () => {
const init = ui.slice( const init = _initGlobalContextPanel.toString();
ui.indexOf('_initGlobalContextPanel() {'),
ui.indexOf('async _runUserFacingContextAction(', ui.indexOf('_initGlobalContextPanel() {')),
);
const radio = radioBindings.slice(radioBindings.indexOf('const toggleRadio = async (trigger) => {'), radioBindings.indexOf('this.listen(this._contextRadioToggleBtn,')); 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 radioSync = radioPresentation.slice(0, radioPresentation.indexOf('if (this._radioFilter)'));
const clear = ui.slice( const clear = clearSelectedLayers.toString();
ui.indexOf('clearSelectedLayers() {'),
ui.indexOf('resetToGlobeView() {', ui.indexOf('clearSelectedLayers() {')),
);
assert.match(init, /button\.getAttribute\('aria-busy'\) === 'true'/); assert.match(init, /button\.getAttribute\('aria-busy'\) === 'true'/);
assert.doesNotMatch(init, /button\.disabled\s*=\s*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.match(radioSync, new RegExp(`${name}\\.disabled = false`));
} }
assert.doesNotMatch(clear, /_clearSelectedLayersBtn\.disabled\s*=\s*true/); 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'); const control = readFileSync(new URL('./ui/clearLayersControl.js', import.meta.url), 'utf8');
assert.match(control, /button\.setAttribute\('aria-busy', String\(busy\)\)/); assert.match(control, /button\.setAttribute\('aria-busy', String\(busy\)\)/);
assert.doesNotMatch(control, /button\.disabled\s*=/); assert.doesNotMatch(control, /button\.disabled\s*=/);
+11 -11
View File
@@ -1,3 +1,5 @@
import { _claimContextVisualAuthority, setContextMode } from './ui/contextActions.js';
import { _initGlobalContextPanel } from './ui/contextBindings.js';
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import fs from 'node:fs'; 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`); assert.ok(claimIndex < mutationIndex, `${label} must claim before mutation`);
}; };
const helper = sourceBlock( const helper = _claimContextVisualAuthority.toString();
' _claimContextVisualAuthority() {',
' async _selectContextMode(mode, {',
);
assert.ok( assert.ok(
helper.includes("claimRestoreLane?.('visual')"), helper.includes('this.actions.claimVisualAuthority()'),
'the Context authority helper must claim the visual lane', '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 [ for (const [start, end, label] of [
["this._globalContextFlightsBtn?.addEventListener('click'", "this._globalContextMissionsBtn?.addEventListener('click'", 'Contacts tab'], ["this.listen(this._globalContextFlightsBtn, 'click'", "this.listen(this._globalContextMissionsBtn, 'click'", 'Contacts tab'],
["this._globalContextMissionsBtn?.addEventListener('click'", 'CONTEXT_PANEL_END', 'Space Missions tab'], ["this.listen(this._globalContextMissionsBtn, 'click'", 'CONTEXT_PANEL_END', 'Space Missions tab'],
]) { ]) {
const startIndex = contextPanel.indexOf(start); const startIndex = contextPanel.indexOf(start);
const endIndex = end === 'CONTEXT_PANEL_END' 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. // 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'); assertContextClaimsBefore(facade, 'this._selectContextMode(', 'setContextMode facade');
// Authority is taken per validated branch, never ahead of validation. The // 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 // 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 // detection: `_detectionUserOverridden` is what suppresses the military-style
// auto-enable for the rest of the session. Contacts entry is not that. // auto-enable for the rest of the session. Contacts entry is not that.
test('Context lane claims never set the session detection-override flag', () => { test('Context lane claims never set the session detection-override flag', () => {
const contextPanel = sourceBlock(' _initGlobalContextPanel() {', ' async _runUserFacingContextAction('); const contextPanel = _initGlobalContextPanel.toString();
const facade = sourceBlock(' async setContextMode(mode, {', ' getCockpitState() {'); const facade = setContextMode.toString();
for (const [block, label] of [ for (const [block, label] of [
[contextPanel, 'Context panel'], [contextPanel, 'Context panel'],
[facade, 'setContextMode facade'], [facade, 'setContextMode facade'],
+38 -1048
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
export { ContextControls } from './contextControls.js';
+261
View File
@@ -0,0 +1,261 @@
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;
}
+121
View File
@@ -0,0 +1,121 @@
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');
});
});
}
+138
View File
@@ -0,0 +1,138 @@
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);
}
}
+154
View File
@@ -0,0 +1,154 @@
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);
});
+227
View File
@@ -0,0 +1,227 @@
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();
}
+44
View File
@@ -0,0 +1,44 @@
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();
}
+180
View File
@@ -0,0 +1,180 @@
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 });
}
+155
View File
@@ -0,0 +1,155 @@
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();
}
+272
View File
@@ -0,0 +1,272 @@
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;
}
}