Test scope opacity, the Visual reset and the panel's presets

The share codec carries field 30 and still encodes a link made before it
to the same code; the long form writes sco only below full opacity; the
mask fades without repainting; the panel shows and sets the opacity, and
the reset and the presets call their actions.

Co-authored-by: Manjunath Paparaju <[email protected]>
This commit is contained in:
Sameh KhamisandManjunath Paparaju committed 2026-11-14 15:31:33 -08:00
1 parent 471151910d
commit 6c1744e55b
6 files changed
+294 -1

No files matched your search

+33
View File
@@ -19,6 +19,8 @@ import {
SCOPE_TERMINUS_MAX_PCT, SCOPE_TERMINUS_MAX_PCT,
SCOPE_FEATHER_RATIO_DEFAULT, SCOPE_FEATHER_RATIO_DEFAULT,
getScopeMaskFeather, getScopeMaskFeather,
setScopeMaskOpacity,
getScopeMaskOpacity,
SCOPE_OUTSIDE_ALPHA, SCOPE_OUTSIDE_ALPHA,
SCOPE_TERMINUS_ALPHA_NEAR, SCOPE_TERMINUS_ALPHA_NEAR,
SCOPE_TERMINUS_FAR_M, 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', () => { test('a DPR change with no resize still repaints the backing store', () => {
const dom = stubScopeMaskDom({ width: 1000, height: 800, dpr: 1 }); const dom = stubScopeMaskDom({ width: 1000, height: 800, dpr: 1 });
try { try {
+50
View File
@@ -119,6 +119,7 @@ function randomState(random) {
detectionFadePct: maybe(0.5, int(0, 40)), detectionFadePct: maybe(0.5, int(0, 40)),
detectionOutsideOpacityPct: maybe(0.5, int(0, 100)), detectionOutsideOpacityPct: maybe(0.5, int(0, 100)),
scopeFeatherPct: maybe(0.5, int(0, 200)), scopeFeatherPct: maybe(0.5, int(0, 200)),
scopeOpacityPct: maybe(0.5, int(0, 100)),
scopeTerminusPct: maybe( scopeTerminusPct: maybe(
0.3, 0.3,
int(SCOPE_TERMINUS_MIN_PCT, SCOPE_TERMINUS_MAX_PCT), 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, detectionFadePct: 8,
detectionOutsideOpacityPct: 2, detectionOutsideOpacityPct: 2,
scopeFeatherPct: 99, scopeFeatherPct: 99,
scopeOpacityPct: 40,
scopeTerminusPct: 95, scopeTerminusPct: 95,
}; };
const defaults = createDefaultShareViewState(); const defaults = createDefaultShareViewState();
@@ -355,6 +357,7 @@ test('every registered field round-trips at once', () => {
celestialRing: true, celestialRing: true,
scopeEnabled: false, scopeEnabled: false,
scopeFeatherPct: 35, scopeFeatherPct: 35,
scopeOpacityPct: 60,
scopeTerminusPct: 97, scopeTerminusPct: 97,
mapStack: 'esri-imagery', mapStack: 'esri-imagery',
layerState, layerState,
@@ -1587,3 +1590,50 @@ test('Data Library tokens round-trip and malformed ones are skipped', () => {
assert.equal(normalizeLibraryTokens(null), null); assert.equal(normalizeLibraryTokens(null), null);
assert.deepEqual(normalizeLibraryTokens('x'.repeat(500)), []); 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,
);
});
+23
View File
@@ -121,6 +121,29 @@ test('scope feather above 100% survives share parsing and serialization', () =>
clearTimeout(manager._debounceTimer); 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', () => { test('share-link serialization emits the current celestial state', () => {
const manager = makeManager(); const manager = makeManager();
manager.onToggleChange(false, false, { celestialRingEnabled: false }); manager.onToggleChange(false, false, { celestialRingEnabled: false });
+38 -1
View File
@@ -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 assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import test from 'node:test'; 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 }); 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) => { test('a scene shot saved with the retired Snow style plays on Normal', async (t) => {
const { owner } = styleOwner(t, 'tactical'); const { owner } = styleOwner(t, 'tactical');
owner.setStyle('noir'); owner.setStyle('noir');
+53
View File
@@ -314,6 +314,59 @@ test('Scope and Celestial render what VisualSettings shows', async (t) => {
assert.equal(celestial.title, 'Celestial ring — reveal the full globe'); 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) => { test('the 3D aircraft group renders what AircraftDisplay shows', async (t) => {
const { store, $ } = fixture(t); const { store, $ } = fixture(t);
const params = []; const params = [];
+97
View File
@@ -231,3 +231,100 @@ test('several owners answer their own actions, the newest first', (t) => {
['display', 'toggleBloom'], ['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'],
]);
});