import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs'; import { _syncContextModeButtons } from './ui/contextPresentation.js'; // Contacts-scoped detection (owner playtest 2026-08-18: "when you click on // Contacts, detections should just turn on, and they should stay on in Cockpit // or in third-person tracking inside Contacts"). // // These drive the REAL detection engine (src/data/detection.js) through the // REAL transition function ui.js calls — no stand-in for the engine. The one // thing a Node test cannot boot is the context-mode transaction itself (Cesium // plus the data-manager intent lanes); that wiring is pinned by source // assertions here and driven for real in scripts/qa-cockpit-utility.mjs. import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import { applyContactsDetection, contactsDetectionEnterPlan, contactsDetectionExitPlan, shareCacheNeedsHeal, shareableDetectionState, } from './contactsDetectionPolicy.js'; import { cycleMode as cycleDetectionMode, getDetectionTuning, getMode as getDetectionMode, setDetectionTuning, setMode as setDetectionModeByLabel, } from './data/detection.js'; import { canonicalizeDensity } from './data/detectionPolicy.js'; // Follow the UI wiring and its extracted preset definitions. const uiSource = readShellSource() + '\n' + fs.readFileSync(new URL('./ui/visualPresets.js', import.meta.url), 'utf8'); /** * The tactical preset ui.js hands Contacts. Read out of the source so this test * cannot drift from the numbers the military styles actually apply. */ const MILITARY_PRESET = (() => { const match = uiSource.match( /const MILITARY_DETECTION_PRESET = Object\.freeze\(\{\s*mode: '(\w+)',\s*densityPct: (\d+),?\s*\}\)/, ); assert.ok(match, 'ui.js must expose one shared military detection preset'); return { mode: match[1].toUpperCase(), densityPct: Number(match[2]) }; })(); /** Engine-level equivalent of ui.js's `_applyDetectionPreset`. */ function applyDetectionState({ mode, densityPct }) { if (Number.isFinite(densityPct)) setDetectionTuning({ densityPct: canonicalizeDensity(densityPct) }); if (mode) setDetectionModeByLabel(String(mode).toUpperCase()); } const readState = () => ({ mode: getDetectionMode(), densityPct: getDetectionTuning().densityPct }); /** Bind the transition to the real engine, as ui.js does. */ function transition(active, { restore = null, styleOwnsDetection = false } = {}) { return applyContactsDetection({ active, restore, styleOwnsDetection, getState: readState, applyPreset: () => applyDetectionState(MILITARY_PRESET), restoreState: applyDetectionState, }); } /** Put the real engine in a known starting mode. */ function startAt(mode, densityPct = null) { if (Number.isFinite(densityPct)) setDetectionTuning({ densityPct: canonicalizeDensity(densityPct) }); setDetectionModeByLabel(mode); assert.equal(getDetectionMode(), mode, 'engine precondition'); } test('activating Contacts turns the real detection engine on from OFF', () => { startAt('OFF', 50); const { restore } = transition(true); assert.notEqual(getDetectionMode(), 'OFF'); assert.deepEqual(restore, { mode: 'OFF', densityPct: 50 }); }); test('the snapshot carries every field activation mutates, not just the mode', () => { // Activation writes DENSITY as well as mode; a mode-only snapshot returned // OFF @ 25% as OFF @ 75%, so the operator's next manual enable came back // Dense instead of the Sparse profile they had been using. startAt('OFF', 25); const { restore } = transition(true); assert.deepEqual(restore, { mode: 'OFF', densityPct: 25 }); assert.equal(getDetectionTuning().densityPct, MILITARY_PRESET.densityPct, 'activation moved density'); transition(false, { restore }); assert.equal(getDetectionMode(), 'OFF'); assert.equal(getDetectionTuning().densityPct, 25, 'deactivation puts the density back'); // The pin the owner would feel: the next manual enable returns their profile. cycleDetectionMode(); assert.equal(getDetectionMode(), 'SPARSE', 'manual enable returns 25%, not the tactical 75%'); assert.equal(getDetectionTuning().densityPct, 25); }); test('a density-only difference is still a restore worth making', () => { // Same mode either side, different density: the exit plan must not call it a // no-op just because the labels match. assert.deepEqual( contactsDetectionExitPlan({ mode: 'DENSE', densityPct: 100 }, { mode: 'DENSE', densityPct: 75 }), { mode: 'DENSE', densityPct: 100 }, ); assert.equal( contactsDetectionExitPlan({ mode: 'DENSE', densityPct: 75 }, { mode: 'DENSE', densityPct: 75 }), null, ); }); test('activating Contacts lands on the tactical preset, not the last profile used', () => { // Owner playtest: the Contacts default is the military look, and it "should // just happen" — so a SPARSE session does NOT drag SPARSE into Contacts. setDetectionTuning({ densityPct: 25 }); startAt('SPARSE'); startAt('OFF'); transition(true); assert.equal(getDetectionMode(), MILITARY_PRESET.mode); assert.equal(getDetectionTuning().densityPct, MILITARY_PRESET.densityPct); }); test('the Contacts preset is the very object the military styles apply', () => { // Reading the numbers out of ui.js is the point: if a style preset changes, // Contacts follows it rather than keeping a stale copy. assert.equal(MILITARY_PRESET.mode, 'DENSE'); assert.equal(MILITARY_PRESET.densityPct, 75); const styles = uiSource.match(/detection: MILITARY_DETECTION_PRESET,/g) || []; assert.equal(styles.length, 3, 'retro, surveillance and thermal all share the one preset object'); assert.doesNotMatch( uiSource, /detection: \{ mode: 'dense'/, 'no style may keep its own copy of the tactical detection numbers', ); assert.match( uiSource, /applyPreset: \(\) => this\._applyDetectionPreset\(MILITARY_DETECTION_PRESET\)/, 'the Contacts force-on must apply that same object', ); }); test('the shared preset applier ignores the style override flag — the caller owns it', () => { const applier = shellMethod('_applyDetectionPreset').toString(); assert.ok(applier.length > 0); assert.doesNotMatch(applier, /_detectionUserOverridden/); // The STYLE path still gates on it; Contacts deliberately does not. assert.match( uiSource, /if \(preset\.detection && !this\._detectionUserOverridden\) \{\s*\n\s*this\._applyDetectionPreset\(preset\.detection\);/, ); }); test('activating Contacts leaves an already-on profile untouched', () => { startAt('DENSE'); const { restore, changed } = transition(true); assert.equal(getDetectionMode(), 'DENSE'); assert.equal(changed, false, 'no engine write, so no redundant UI sync'); assert.deepEqual(restore, { mode: 'DENSE', densityPct: getDetectionTuning().densityPct }); }); test('detection survives cockpit enter and exit inside a Contacts session', () => { // The owner's actual bug: "when I leave the Cockpit, detections go off". // Cockpit is a move WITHIN Contacts and must not touch detection at all, so // the only transitions here are the Contacts ones — repeated syncs while the // session stays active. startAt('OFF'); let { restore } = transition(true); const insideContacts = getDetectionMode(); assert.equal(insideContacts, MILITARY_PRESET.mode); // Cockpit enter, a vision cycle, third-person tracking, cockpit exit: every // one of these re-runs the context-mode sync with Contacts still active. for (let sync = 0; sync < 4; sync += 1) { ({ restore } = transition(true, { restore })); assert.equal(getDetectionMode(), insideContacts, 'a move within Contacts never changes detection'); } assert.deepEqual(restore, { mode: 'OFF', densityPct: restore.densityPct }, 'the entry snapshot is not rewritten'); }); test('a manual detection-off during a Contacts session holds for the session', () => { startAt('OFF'); let { restore } = transition(true); assert.notEqual(getDetectionMode(), 'OFF'); setDetectionModeByLabel('OFF'); // the operator clicks DETECT // Entering the cockpit after that must NOT re-force. ({ restore } = transition(true, { restore })); ({ restore } = transition(true, { restore })); assert.equal(getDetectionMode(), 'OFF'); assert.deepEqual(restore, { mode: 'OFF', densityPct: restore.densityPct }, 'the entry snapshot is not rewritten'); }); test('deactivating Contacts restores the pre-Contacts state in every direction', () => { startAt('OFF'); let { restore } = transition(true); ({ restore } = transition(false, { restore })); assert.equal(getDetectionMode(), 'OFF', 'a Contacts-forced on reverts on deactivation'); assert.equal(restore, null); startAt('DENSE'); ({ restore } = transition(true)); ({ restore } = transition(false, { restore })); assert.equal(getDetectionMode(), 'DENSE', 'on stays on when it was on before'); startAt('DENSE'); ({ restore } = transition(true)); setDetectionModeByLabel('OFF'); transition(false, { restore }); assert.equal(getDetectionMode(), 'DENSE', 'the Contacts-scoped off does not leak out'); }); test('re-activating Contacts re-enables after a session-scoped off', () => { startAt('OFF'); let { restore } = transition(true); setDetectionModeByLabel('OFF'); ({ restore } = transition(false, { restore })); assert.equal(getDetectionMode(), 'OFF'); transition(true, { restore }); assert.equal(getDetectionMode(), MILITARY_PRESET.mode); }); test('a style chosen DURING the session keeps its auto-enable instead of the snapshot', () => { // Cross-check that the military-style auto-Panoptic path still composes: the // preset is already on, so the style is not fighting Contacts, and on the way // out the style rule — younger than the snapshot — wins. startAt('OFF'); const { restore } = transition(true); setDetectionModeByLabel('DENSE'); // _applyStylePresetDefaults('surveillance') const result = transition(false, { restore, styleOwnsDetection: true }); assert.equal(getDetectionMode(), 'DENSE'); assert.equal(result.changed, false, 'the style rule wins, so nothing is replayed'); assert.equal(result.restore, null); }); test('an operator who overrode detection still gets the entry snapshot back', () => { // styleOwnsDetection is false whenever _detectionUserOverridden is true, so an // explicit choice is never overwritten by a style preset. startAt('OFF'); const { restore } = transition(true); setDetectionModeByLabel('DENSE'); transition(false, { restore, styleOwnsDetection: false }); assert.equal(getDetectionMode(), 'OFF'); }); test('deactivation with no captured Contacts state changes nothing', () => { assert.equal(contactsDetectionExitPlan(null, { mode: 'OFF', densityPct: 50 }), null); const same = { mode: 'DENSE', densityPct: 75 }; assert.equal(contactsDetectionExitPlan(same, same), null); assert.equal(contactsDetectionExitPlan({ mode: 'OFF', densityPct: 25 }, same, true), null); startAt('DENSE'); const result = transition(false, { restore: null }); assert.equal(getDetectionMode(), 'DENSE'); assert.equal(result.changed, false); }); test('re-entrancy is decided by the saved snapshot, not the engine state', () => { assert.equal( contactsDetectionEnterPlan({ mode: 'OFF', densityPct: 25 }, { mode: 'DENSE', densityPct: 75 }), null, ); assert.deepEqual( contactsDetectionEnterPlan({ mode: 'OFF', densityPct: 25 }, null), { restore: { mode: 'OFF', densityPct: 25 }, turnOn: true }, ); assert.deepEqual( contactsDetectionEnterPlan({ mode: 'sparse', densityPct: 25 }, null), { restore: { mode: 'SPARSE', densityPct: 25 }, turnOn: false }, ); // A missing density is carried as null rather than invented. assert.deepEqual( contactsDetectionEnterPlan({ mode: 'OFF' }, null), { restore: { mode: 'OFF', densityPct: null }, turnOn: true }, ); }); test('detection is wired to the Contacts transaction, and cockpit no longer touches it', () => { // The trigger lives on the context-mode funnel, gated on the transaction // having SETTLED so a failed activation cannot strand detection on. const helper = uiSource.slice( uiSource.indexOf('_syncContactsDetection() {'), uiSource.indexOf('/** Apply a temporary cockpit-only'), ); assert.ok(helper.length > 0, 'the Contacts detection helper is present in the UI shell source'); assert.match(helper, /if \(this\._contextModeChanging\) return;/, 'fires at settle, not at click'); assert.match(helper, /active: this\._contextMode === 'flights'/); assert.match(helper, /styleOwnsDetection:\s*!this\._detectionUserOverridden/); assert.doesNotMatch( helper.split('styleOwnsDetection')[0], /_detectionUserOverridden/, 'ACTIVATION turns detection on REGARDLESS of the style-preset override flag', ); assert.match( _syncContextModeButtons.toString(), /this\.cockpitView\?\.syncEntry\(\);[\s\S]{0,220}?this\.actions\.syncDetection\(\);/, 'called from _syncContextModeButtons, the funnel every _contextMode mutation routes through', ); assert.match(uiSource, /syncDetection: \(\) => this\._syncContactsDetection\(\)/); // The cockpit vision hook — the old trigger — must be out of the detection // business entirely, or leaving the cockpit turns detections off again. const visionHook = uiSource.slice( uiSource.indexOf('_setCockpitVision(mode, active, { revealParameters = false } = {}) {'), uiSource.indexOf('_syncIrBoost() {'), ); assert.ok(visionHook.length > 0, 'the cockpit vision hook is present in the UI shell source'); assert.doesNotMatch(visionHook, /etection/, 'cockpit transitions must not touch detection'); }); // --------------------------------------------------------------------------- // A link copied while Contacts is active must carry the AUTHOR's preference, // not the session-scoped override Contacts forces on top of it. Contacts is // not itself restored by a share link, so a recipient who inherited the forced // Dense @ 75% had no mode to explain it and no way to undo it. // --------------------------------------------------------------------------- test('share serialization publishes the operator preference, not the Contacts override', () => { // Contacts inactive: the live values ARE the preference. assert.deepEqual( shareableDetectionState({ owned: null, liveMode: 'BALANCED', liveDensityPct: 40 }), { mode: 'BALANCED', densityPct: 40 }, ); // Contacts active: the live values are forced Dense @ 75%, but the author's // own OFF @ 50% is what deactivation restores — and what the link must carry. assert.deepEqual( shareableDetectionState({ owned: { mode: 'OFF', densityPct: 50 }, liveMode: 'DENSE', liveDensityPct: 75, }), { mode: 'OFF', densityPct: 50 }, ); // A partial snapshot falls back to live values field by field, never to // undefined (which would serialize as a malformed dm/dd pair). assert.deepEqual( shareableDetectionState({ owned: { mode: 'SPARSE', densityPct: null }, liveMode: 'DENSE', liveDensityPct: 75, }), { mode: 'SPARSE', densityPct: 75 }, ); assert.deepEqual( shareableDetectionState({ owned: {}, liveMode: 'DENSE', liveDensityPct: 75 }), { mode: 'DENSE', densityPct: 75 }, ); }); test('the share cache is healed whenever Contacts ownership changes, engine or not', () => { // Ordinary case: the engine moved, so the cache is republished. assert.equal( shareCacheNeedsHeal({ changed: true, hadOwnership: false, hasOwnership: true }), true, ); // The regression: exiting Contacts while a MILITARY STYLE owns detection // produces changed:false (the style preset already matches what is drawn), // but ownership was just released — so serialization flips from the saved // snapshot to live state and the cached link is now wrong. assert.equal( shareCacheNeedsHeal({ changed: false, hadOwnership: true, hasOwnership: false }), true, 'releasing ownership must heal the cache even with no engine change', ); // Symmetric: acquiring ownership with no engine change flips it the other way. assert.equal( shareCacheNeedsHeal({ changed: false, hadOwnership: false, hasOwnership: true }), true, ); // Genuine no-op: nothing moved and ownership is unchanged. assert.equal( shareCacheNeedsHeal({ changed: false, hadOwnership: false, hasOwnership: false }), false, ); assert.equal( shareCacheNeedsHeal({ changed: false, hadOwnership: true, hasOwnership: true }), false, ); });