diff --git a/src/scopeMask.test.mjs b/src/scopeMask.test.mjs index d37b6b23b..ef612493b 100644 --- a/src/scopeMask.test.mjs +++ b/src/scopeMask.test.mjs @@ -19,6 +19,8 @@ import { SCOPE_TERMINUS_MAX_PCT, SCOPE_FEATHER_RATIO_DEFAULT, getScopeMaskFeather, + setScopeMaskOpacity, + getScopeMaskOpacity, SCOPE_OUTSIDE_ALPHA, SCOPE_TERMINUS_ALPHA_NEAR, SCOPE_TERMINUS_FAR_M, @@ -205,6 +207,37 @@ test('scope appearance follows direct state changes and install/destroy lifecycl } }); +test('scope opacity fades the painted mask without repainting it', () => { + const dom = stubScopeMaskDom(); + try { + assert.equal(getScopeMaskOpacity(), 1, 'full by default'); + setScopeMaskOpacity(0.4); + installScopeMask({ container: dom.container }); + assert.equal(dom.canvas.style.opacity, '0.4', 'a choice made before install applies'); + const paints = { ...dom.ops }; + const terminus = getScopeTerminusAlpha(); + setScopeMaskOpacity(0.25); + assert.equal(dom.canvas.style.opacity, '0.25'); + assert.deepEqual(dom.ops, paints, 'the mask is not repainted'); + assert.equal(getScopeTerminusAlpha(), terminus, 'the terminus is its own setting'); + setScopeMaskOpacity(3); + assert.equal(getScopeMaskOpacity(), 1); + setScopeMaskOpacity(-1); + assert.equal(getScopeMaskOpacity(), 0); + setScopeMaskOpacity('soft'); + assert.equal(getScopeMaskOpacity(), 1, 'not a number is full'); + setScopeMaskOpacity(0.5); + destroyScopeMask(); + installScopeMask({ container: dom.container }); + assert.equal(dom.canvas.style.opacity, '0.5', 'reinstall keeps it'); + _resetScopeMaskForTest(); + assert.equal(getScopeMaskOpacity(), 1, 'test reset restores full'); + } finally { + destroyScopeMask(); + dom.restore(); + } +}); + test('a DPR change with no resize still repaints the backing store', () => { const dom = stubScopeMaskDom({ width: 1000, height: 800, dpr: 1 }); try { diff --git a/src/share/viewCodec.test.mjs b/src/share/viewCodec.test.mjs index f19261a16..7b876269a 100644 --- a/src/share/viewCodec.test.mjs +++ b/src/share/viewCodec.test.mjs @@ -119,6 +119,7 @@ function randomState(random) { detectionFadePct: maybe(0.5, int(0, 40)), detectionOutsideOpacityPct: maybe(0.5, int(0, 100)), scopeFeatherPct: maybe(0.5, int(0, 200)), + scopeOpacityPct: maybe(0.5, int(0, 100)), scopeTerminusPct: maybe( 0.3, int(SCOPE_TERMINUS_MIN_PCT, SCOPE_TERMINUS_MAX_PCT), @@ -210,6 +211,7 @@ test('each setting costs bytes only when it differs from its default', () => { detectionFadePct: 8, detectionOutsideOpacityPct: 2, scopeFeatherPct: 99, + scopeOpacityPct: 40, scopeTerminusPct: 95, }; const defaults = createDefaultShareViewState(); @@ -355,6 +357,7 @@ test('every registered field round-trips at once', () => { celestialRing: true, scopeEnabled: false, scopeFeatherPct: 35, + scopeOpacityPct: 60, scopeTerminusPct: 97, mapStack: 'esri-imagery', layerState, @@ -1587,3 +1590,50 @@ test('Data Library tokens round-trip and malformed ones are skipped', () => { assert.equal(normalizeLibraryTokens(null), null); assert.deepEqual(normalizeLibraryTokens('x'.repeat(500)), []); }); + +test('scope opacity is field 30; a link made before it opens at full opacity', () => { + // Made before field 30 existed: thermal, Cyber, Scope off at 35% feather + // and a pinned terminus. + const before = 'ARD-7j4YuxMguBcoUEgQcAOAAQPIASPQAWGa'; + const old = decodeOk(before).state; + assert.equal(old.scopeOpacityPct, 100); + assert.equal(old.scopeFeatherPct, 35); + assert.equal(encodeShareView(old), before, 'it still encodes to itself'); + const state = { + lat: 51.5007, + lon: -0.1246, + alt: 1500, + heading: 40, + style: 'thermal', + scopeEnabled: false, + scopeFeatherPct: 35, + scopeTerminusPct: 97, + hudVariant: 'cyber', + }; + assert.equal(encodeShareView(state), before); + assert.equal(encodeShareView({ ...state, scopeOpacityPct: 100 }), before); + const faded = assertRoundTrip({ ...state, scopeOpacityPct: 45 }); + assert.notEqual(faded, before); + assert.equal(decodeOk(faded).state.scopeOpacityPct, 45); + assert.equal( + decodeOk(assertRoundTrip({ scopeOpacityPct: 0 })).state.scopeOpacityPct, + 0, + ); + // Out of range is clamped; anything not a number is the default. + assert.equal( + normalizeShareViewState({ scopeOpacityPct: 150 }).scopeOpacityPct, + 100, + ); + assert.equal( + normalizeShareViewState({ scopeOpacityPct: -5 }).scopeOpacityPct, + 0, + ); + assert.equal( + normalizeShareViewState({ scopeOpacityPct: 33.6 }).scopeOpacityPct, + 34, + ); + assert.equal( + normalizeShareViewState({ scopeOpacityPct: 'x' }).scopeOpacityPct, + 100, + ); +}); diff --git a/src/sharelink.celestial.test.mjs b/src/sharelink.celestial.test.mjs index 0e77e8303..a7326d12b 100644 --- a/src/sharelink.celestial.test.mjs +++ b/src/sharelink.celestial.test.mjs @@ -121,6 +121,29 @@ test('scope feather above 100% survives share parsing and serialization', () => clearTimeout(manager._debounceTimer); }); +test('scope opacity rides as sco only below full, and through the compact link', () => { + // Links without `sco`, every link made before it, open at full opacity. + assert.equal(makeManager('#v=2&lat=10&lon=20&scf=35').parseInitialHash().scopeOpacityPct, 100); + assert.equal(makeManager('#v=2&lat=10&lon=20&sco=40').parseInitialHash().scopeOpacityPct, 40); + assert.equal(makeManager('#v=2&lat=10&lon=20&sco=250').parseInitialHash().scopeOpacityPct, 100); + assert.equal(makeManager('#v=2&lat=10&lon=20&sco=-3').parseInitialHash().scopeOpacityPct, 0); + assert.equal(makeManager('#v=2&lat=10&lon=20&sco=x').parseInitialHash().scopeOpacityPct, 100); + + const manager = makeManager(); + manager.onToggleChange(false, true, {}); + const before = manager._buildHashParams().toString(); + assert.equal(manager._buildHashParams().has('sco'), false, 'full opacity writes nothing'); + manager.onToggleChange(false, true, { scopeOpacityPct: 100 }); + assert.equal(manager._buildHashParams().toString(), before); + manager.onToggleChange(false, true, { scopeOpacityPct: 45.4 }); + assert.equal(manager._buildHashParams().get('sco'), '45'); + assert.equal(sharedState(manager.buildLink()).scopeOpacityPct, 45); + manager.onToggleChange(false, true, { scopeOpacityPct: 100 }); + assert.equal(manager._buildHashParams().has('sco'), false); + assert.equal(sharedState(manager.buildLink()).scopeOpacityPct, 100); + clearTimeout(manager._debounceTimer); +}); + test('share-link serialization emits the current celestial state', () => { const manager = makeManager(); manager.onToggleChange(false, false, { celestialRingEnabled: false }); diff --git a/src/ui/cyberTheme.test.mjs b/src/ui/cyberTheme.test.mjs index fdbe64284..4c8b40dff 100644 --- a/src/ui/cyberTheme.test.mjs +++ b/src/ui/cyberTheme.test.mjs @@ -1,4 +1,11 @@ -import { getScopeMaskFeather, isScopeMaskEnabled, setScopeMaskEnabled, setScopeMaskFeather } from '../scopeMask.js'; +import { + getScopeMaskFeather, + getScopeMaskOpacity, + isScopeMaskEnabled, + setScopeMaskEnabled, + setScopeMaskFeather, + setScopeMaskOpacity, +} from '../scopeMask.js'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import test from 'node:test'; @@ -298,6 +305,36 @@ test('scope choice and expanded feather survive every HUD layout and both restor assert.deepEqual(shown, { enabled: true, featherPct: 200, opacityPct: 100 }); }); +test('a shared scope opacity is restored and rides in the next share state', async (t) => { + const { owner } = styleOwner(t, 'tactical'); + const previous = getScopeMaskOpacity(); + t.after(() => setScopeMaskOpacity(previous)); + Object.assign(owner.services, { + setScopeMaskEnabled() {}, + setScopeMaskFeather() {}, + setScopeMaskOpacity, + }); + let shown = null; + owner.store = { publish: (view) => (shown = view.scope) }; + await owner.restoreShareState({ scopeOpacityPct: 40 }); + assert.equal(getScopeMaskOpacity(), 0.4); + assert.equal(shown.opacityPct, 40); + // A state without it leaves the opacity alone. + await owner.restoreShareState({ scopeFeatherPct: 80 }); + assert.equal(getScopeMaskOpacity(), 0.4); + assert.equal(shown.opacityPct, 40); + Object.assign(owner.services, { + getDetectionTuning: () => ({ allocationStrategy: 'ELASTIC' }), + getDetectionMode: () => 'OFF', + isScopeMaskEnabled: () => true, + getScopeMaskFeather: () => 0.8, + getScopeMaskOpacity, + getScopeTerminusOverride: () => null, + }); + owner.readCelestialRing = () => null; + assert.equal(owner._readShareState().options.scopeOpacityPct, 40); +}); + test('a scene shot saved with the retired Snow style plays on Normal', async (t) => { const { owner } = styleOwner(t, 'tactical'); owner.setStyle('noir'); diff --git a/src/ui/displayPanel.test.mjs b/src/ui/displayPanel.test.mjs index 5185f9ab5..5b9489f39 100644 --- a/src/ui/displayPanel.test.mjs +++ b/src/ui/displayPanel.test.mjs @@ -314,6 +314,59 @@ test('Scope and Celestial render what VisualSettings shows', async (t) => { assert.equal(celestial.title, 'Celestial ring — reveal the full globe'); }); +test('scope opacity renders what VisualSettings shows and sets the mask', async (t) => { + const { store, $ } = fixture(t); + const { owner } = settings(store); + const mask = { opacity: 1 }; + owner.services = { + ...owner.services, + setScopeMaskEnabled() {}, + setScopeMaskOpacity: (value) => (mask.opacity = value), + }; + assert.equal($('scope-opacity-slider').value, '100'); + assert.equal($('scope-opacity-value').textContent, '100%'); + + owner._setScope({ opacityPct: 42.4 }); + await nextTick(); + assert.equal(mask.opacity, 0.42); + assert.equal($('scope-opacity-slider').value, '42'); + assert.equal($('scope-opacity-value').textContent, '42%'); + assert.equal(store.view.scope.opacityPct, 42); + + owner._setScope({ opacityPct: 180 }); + await nextTick(); + assert.equal(mask.opacity, 1, 'clamped'); + assert.equal($('scope-opacity-value').textContent, '100%'); + + owner._setScope({ opacityPct: 30 }, { apply: false }); + await nextTick(); + assert.equal(mask.opacity, 1, 'only shown'); + assert.equal($('scope-opacity-value').textContent, '30%'); + + owner._setScope({ enabled: false }); + await nextTick(); + assert.equal($('scope-opacity-slider').disabled, true, 'off with the scope'); + assert.equal(store.view.scope.opacityPct, 30, 'kept while off'); +}); + +test('the Visual panel groups its controls and words its on/off pills', (t) => { + const { $ } = fixture(t); + const panel = $('pp-toggles'); + assert.deepEqual( + [...panel.querySelectorAll('.pp-section-heading, .visual-presets__heading')].map( + (el) => el.textContent.trim(), + ), + ['Look', 'MAP SOURCE 3D', 'Overlay', 'Effects'], + ); + assert.equal(panel.querySelector('.pp-header-label').textContent, 'VISUAL'); + const body = panel.querySelector('.pp-panel-body'); + assert.equal(body.style.getPropertyValue('--pp-pill-on'), '"ON"'); + assert.equal(body.style.getPropertyValue('--pp-pill-off'), '"OFF"'); + const reset = panel.querySelector('.pp-reset-btn'); + assert.equal(reset, body.lastElementChild, 'the reset closes the panel'); + assert.equal(reset.textContent.trim(), 'restart_altReset to default'); +}); + test('the 3D aircraft group renders what AircraftDisplay shows', async (t) => { const { store, $ } = fixture(t); const params = []; diff --git a/src/ui/displayPanelActions.test.mjs b/src/ui/displayPanelActions.test.mjs index 061a13fba..32a8e8d1b 100644 --- a/src/ui/displayPanelActions.test.mjs +++ b/src/ui/displayPanelActions.test.mjs @@ -231,3 +231,100 @@ test('several owners answer their own actions, the newest first', (t) => { ['display', 'toggleBloom'], ]); }); + +test('the Visual panel’s scope opacity, reset and presets call their actions', async (t) => { + const store = mount(t); + const { calls } = recorder(store); + const selected = []; + store.connectMapSource({ select: async (id) => selected.push(id) }); + store.publishMapSource({ + status: { text: 'OSM', warn: false }, + chips: [ + { id: 'photoreal', label: 'Google 3D', available: false }, + { id: 'osm', label: 'OSM', available: true, active: true }, + ], + }); + await nextTick(); + input($('scope-opacity-slider'), 40); + document.querySelector('#pp-toggles .pp-reset-btn').click(); + const presets = document.querySelector('#pp-toggles .visual-presets'); + presets.querySelector('[data-style="noir"]').click(); + const chips = presets.querySelectorAll('.visual-presets__chip'); + chips[0].click(); + chips[1].click(); + assert.deepEqual(calls, [ + ['setScopeOpacity', 40], + ['resetVisual'], + ['setStyle', 'noir'], + ]); + assert.deepEqual(selected, ['osm'], 'an unavailable source is not selected'); + // The presets follow the published style, as the dock's do. + assert.equal( + presets.querySelector('[data-style="normal"]').getAttribute('aria-pressed'), + 'true', + ); + store.publishStyle({ name: 'noir', label: 'NOIR' }); + await nextTick(); + assert.equal( + presets.querySelector('[data-style="noir"]').getAttribute('aria-pressed'), + 'true', + ); + assert.equal( + presets.querySelector('[data-style="normal"]').getAttribute('aria-pressed'), + 'false', + ); + // The dock keeps its own presets and map source beside them. + assert.ok( + document.querySelector('#control-panel .style-btn[data-style="noir"]'), + ); + assert.ok($('map-stack-chips')); +}); + +test('DisplayBindings sets the scope opacity and resets Visual to its defaults', (t) => { + const store = mount(t); + const calls = []; + const bindings = new DisplayBindings({ + viewer: null, + services: { isScopeMaskEnabled: () => true, cycleDetectionMode: () => {} }, + elements: { _locationSearch: document.createElement('input') }, + operations: { + setStyle: (style, options) => calls.push(['style', style, options]), + _applyGlobalPostDefaults: () => calls.push(['postDefaults']), + _setDetectionAllocation: (value) => calls.push(['allocation', value]), + _setScope: (scope) => calls.push(['scope', scope]), + _setModels3dEnabled: (on) => calls.push(['models3d', on]), + _setModels3dMode: (mode) => calls.push(['models3dMode', mode]), + _syncModels3dModeRow: () => calls.push(['models3dRow']), + _syncShareState: () => calls.push(['share']), + }, + readState: () => ({ + shareLinkManager: { + claimRestoreLane: (lane) => calls.push(['claim', lane]), + }, + }), + claimDetection: () => {}, + displayStore: store, + }); + bindings._initUI(); + t.after(() => bindings.destroy()); + calls.length = 0; + input($('scope-opacity-slider'), 35); + assert.deepEqual(calls, [ + ['claim', 'visual'], + ['scope', { opacityPct: 35 }], + ['share'], + ]); + calls.length = 0; + document.querySelector('#pp-toggles .pp-reset-btn').click(); + assert.deepEqual(calls, [ + ['claim', 'visual'], + ['style', 'normal', { userInitiated: true }], + ['postDefaults'], + ['allocation', 'ELASTIC'], + ['scope', { enabled: true, featherPct: 100, opacityPct: 100 }], + ['models3d', true], + ['models3dMode', 'proximity'], + ['models3dRow'], + ['share'], + ]); +});