diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c972290a..96f654587 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [Unreleased] +- Extract Display control bindings with synchronous listener cleanup; preserve existing visual actions and native input behavior. + - Extract application shortcuts and shader-parameter controls into reusable UI components, preserving inputs and cleaning up listeners on rebuild/disposal. diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index 2faaa2605..607315d4b 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -243,3 +243,10 @@ Clearing permits reuse; destruction is final. Neither module imports the app, renderer, persistence or services. The facade retains panel visibility, share restore claims and render scheduling. Both controls are destroyed before the facade's asynchronous teardown can yield. + +## Display controls + +`ui/display` owns Display button, selector and slider subscriptions. It receives +DOM elements and explicit actions, imports no application or effect singleton, +and releases every listener on destruction. Settings and rendering remain with +the caller. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index eee4e1eba..61d996883 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,12 @@ # God's Eye View Current State +## Display control ownership + +Display button, selector and slider listeners have a single destroyable owner. +The application supplies actions and retains effect settings, restore claims and +rendering. Native keyboard editing, model modes and current defaults are preserved. +Destroying the UI removes these listeners before asynchronous teardown. + ## Application shortcuts and shader parameter controls `ui/input` supplies the bubbling application shortcut listener and generated diff --git a/package.json b/package.json index 7d5e0b841..aae850dc0 100644 --- a/package.json +++ b/package.json @@ -122,6 +122,7 @@ "./ui/panels": "./src/ui/panelDisclosure.js", "./ui/surfaces": "./src/ui/surfaceKeyboard.js", "./ui/layout": "./src/ui/panelRails.js", - "./ui/input": "./src/ui/visualInput.js" + "./ui/input": "./src/ui/visualInput.js", + "./ui/display": "./src/ui/displayControls.js" } } diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 7b924ad83..91296df96 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -294,5 +294,10 @@ "src/ui/styleParameters.js" ], "external": [] + }, + "display-controls": { + "exports": ["./ui/display"], + "modules": ["src/ui/displayControls.js"], + "external": [] } } diff --git a/scripts/qa-visual-input.mjs b/scripts/qa-visual-input.mjs index 9aa75afa1..7d9d47cd2 100644 --- a/scripts/qa-visual-input.mjs +++ b/scripts/qa-visual-input.mjs @@ -140,6 +140,34 @@ try { hudBefore, ), ); + const displayBefore = await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + manager._setBloomEnabled(true); + manager._bloomSlider.value = '80'; + manager._bloomSlider.dispatchEvent(new Event('input', { bubbles: true })); + manager._bloomSlider.focus(); + return { value: Number(manager._bloomSlider.value), contrast: manager._bloomStage.uniforms.contrast }; + }); + await page.keyboard.press('ArrowRight'); + check('native Display slider updates effect state', await page.evaluate((before) => { + const manager = window.__godsEyeView.styleManager; + return Number(manager._bloomSlider.value) > before.value && manager._bloomStage.uniforms.contrast < before.contrast; + }, displayBefore)); + check('Display buttons toggle current state once', await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + const before = manager.bloomEnabled; + manager._bloomBtn.click(); + const changed = manager.bloomEnabled !== before; + manager._bloomBtn.click(); + return changed && manager.bloomEnabled === before; + })); + check('Display rebind removes old listeners', await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + manager._initUI(); + const before = manager.bloomEnabled; + manager._bloomBtn.click(); + return manager.bloomEnabled !== before; + })); fs.mkdirSync('qa-shots/visual-input', { recursive: true }); await page.screenshot({ path: 'qa-shots/visual-input/desktop.png' }); await page.setViewport({ width: 620, height: 900 }); @@ -184,6 +212,16 @@ try { ); }), ); + check('destroyed Display controls cannot change settings', await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + manager._displayControls.destroy(); + const before = manager.bloomEnabled; + const contrast = manager._bloomStage.uniforms.contrast; + manager._bloomBtn.click(); + manager._bloomSlider.value = '15'; + manager._bloomSlider.dispatchEvent(new Event('input', { bubbles: true })); + return manager.bloomEnabled === before && manager._bloomStage.uniforms.contrast === contrast; + })); check('runtime has no uncaught browser errors', errors.length === 0); } finally { await browser.close(); diff --git a/src/reasonableDefaults.test.mjs b/src/reasonableDefaults.test.mjs index 1907c58ae..b007128c8 100644 --- a/src/reasonableDefaults.test.mjs +++ b/src/reasonableDefaults.test.mjs @@ -246,7 +246,9 @@ test('detection-on-by-default is a default, not an operator override', () => { // preset consults the flag, and the detection button sets it. assert.match(uiSource, /if \(preset\.detection && !this\._detectionUserOverridden\) \{/, 'a style preset still yields to an operator who changed detection by hand'); - const detectionButton = uiBlock("this._detectionBtn.addEventListener('click'", 'cycleDetectionMode()'); + const displayActions = uiSource.slice(uiSource.indexOf('this._displayControls =')); + const detectionButton = displayActions.slice(displayActions.indexOf('cycleDetection:'), displayActions.indexOf('toggleModels:')); + assert.match(detectionButton, /cycleDetectionMode\(\)/, 'the button still invokes the detection action'); assert.match(detectionButton, /this\._detectionUserOverridden = true;/, 'and the detection control still claims the override when the operator uses it'); diff --git a/src/sharelink.celestial.test.mjs b/src/sharelink.celestial.test.mjs index 63d6a3d2b..c655401cf 100644 --- a/src/sharelink.celestial.test.mjs +++ b/src/sharelink.celestial.test.mjs @@ -419,16 +419,16 @@ test('every explicit visual UI gesture claims restore authority before it mutate const gestureRoutes = [ ['toggleHud: () => {', 'toggleOrbit: () =>', 'this.hud.toggle()', 'HUD hotkey'], ['cycleDetection: () => {', 'toggleCctv: () =>', 'cycleDetectionMode()', 'detection hotkey'], - ['// Bloom toggle', '// Bloom intensity slider', 'this._setBloomEnabled(', 'bloom button'], - ['// Bloom intensity slider', '// Sharpen toggle', 'this._setBloomIntensity(', 'bloom slider'], - ['// Sharpen toggle', '// Scope mask', 'this._setSharpenEnabled(', 'sharpen button'], - ["this._scopeBtn?.addEventListener('click'", "this._scopeFeatherSlider?.addEventListener('input'", 'setScopeMaskEnabled(', 'scope button'], - ["this._scopeFeatherSlider?.addEventListener('input'", 'if (this._sharpenSlider)', 'setScopeMaskFeather(', 'scope feather slider'], - ["this._sharpenSlider.addEventListener('input'", 'if (this._hudLayoutSelect)', 'this._applySharpenIntensity(', 'sharpen slider'], - ["this._hudLayoutSelect.addEventListener('change'", 'if (this._cleanViewBtn)', 'this._setHudVariant(', 'HUD layout select'], - ["this._detectionDensitySlider.addEventListener('input'", 'for (const button of this._detectionAllocationBtns)', 'this._applyDetectionDensityFromUi()', 'detection density slider'], - ["button.addEventListener('click'", 'for (const slider of [this._detectionFadeSlider', 'this._setDetectionAllocation(', 'detection allocation button'], - ["slider?.addEventListener('input'", 'if (this._celestialBtn)', 'this._applyDetectionFadeFromUi()', 'detection fade controls'], + ['toggleBloom:', 'setBloomIntensity:', 'this._setBloomEnabled(', 'toggleBloom control'], + ['setBloomIntensity:', 'toggleSharpen:', 'this._setBloomIntensity(', 'setBloomIntensity control'], + ['toggleSharpen:', 'toggleScope:', 'this._setSharpenEnabled(', 'toggleSharpen control'], + ['toggleScope:', 'setScopeFeather:', 'setScopeMaskEnabled(', 'toggleScope control'], + ['setScopeFeather:', 'setSharpenIntensity:', 'setScopeMaskFeather(', 'setScopeFeather control'], + ['setSharpenIntensity:', 'setHudLayout:', 'this._applySharpenIntensity(', 'setSharpenIntensity control'], + ['setHudLayout:', 'toggleCleanView:', 'this._setHudVariant(', 'setHudLayout control'], + ['setDensity:', 'setAllocation:', 'this._applyDetectionDensityFromUi()', 'setDensity control'], + ['setAllocation:', 'setFade:', 'this._setDetectionAllocation(', 'setAllocation control'], + ['setFade:', 'toggleCelestial:', 'this._applyDetectionFadeFromUi()', 'setFade control'], ]; for (const [start, end, mutation, label] of gestureRoutes) { const startIndex = initUi.indexOf(start); @@ -437,23 +437,10 @@ test('every explicit visual UI gesture claims restore authority before it mutate assertClaimsBefore(initUi.slice(startIndex, endIndex), mutation, label); } - const hudToggle = sourceBlock(' _initHUDToggle() {', ' _initCockpitDisplayPortal() {'); - assertClaimsBefore( - hudToggle.slice( - hudToggle.indexOf("this._hudBtn.addEventListener('click'"), - hudToggle.indexOf('if (this._hudLayoutSelect)'), - ), - 'this.hud.toggle()', - 'HUD button', - ); - assertClaimsBefore( - hudToggle.slice( - hudToggle.indexOf("this._detectionBtn.addEventListener('click'"), - hudToggle.indexOf('this._cockpitDisplayToggleBtn'), - ), - 'cycleDetectionMode()', - 'detection button', - ); + const displayActions = initUi.slice(initUi.indexOf('this._displayControls =')); + assertClaimsBefore(displayActions.slice(displayActions.indexOf('toggleHud:'), displayActions.indexOf('cycleDetection:')), 'this.hud.toggle()', 'HUD button'); + assertClaimsBefore(displayActions.slice(displayActions.indexOf('cycleDetection:'), displayActions.indexOf('toggleModels:')), 'cycleDetectionMode()', 'detection button'); + }); // Contacts OWNS detection while it is active (forced Dense @ 75%). That makes @@ -703,5 +690,6 @@ test('visual input listeners are revoked before asynchronous UI teardown', () => assert.ok(firstAwait > 0); const synchronous = disposal.slice(0, firstAwait); assert.match(synchronous, /this\._applicationShortcuts\?\.destroy\(\)/); + assert.match(synchronous, /this\._displayControls\?\.destroy\(\)/); assert.match(synchronous, /this\._styleParameters\?\.destroy\(\)/); }); diff --git a/src/ui.js b/src/ui.js index 31e49526c..e614eae70 100644 --- a/src/ui.js +++ b/src/ui.js @@ -1,3 +1,4 @@ +import { bindDisplayControls } from './ui/displayControls.js'; import { bindApplicationShortcuts, createStyleParameters } from './ui/visualInput.js'; import { layoutLeftPanelRail, layoutRightPanelRail } from './ui/panelRails.js'; import { bindPanelDisclosure, collapsePanelOnEscape, createHoverDisclosure } from './ui/panelDisclosure.js'; @@ -3395,11 +3396,6 @@ export class StyleManager { * @returns {void} */ _initUI() { - // Style buttons - document.querySelectorAll('.style-btn').forEach(btn => { - btn.addEventListener('click', () => this.setStyle(btn.dataset.style)); - }); - this._applicationShortcuts?.destroy(); this._applicationShortcuts = bindApplicationShortcuts({ documentRef: document, @@ -3432,108 +3428,109 @@ export class StyleManager { }, }); - // Bloom toggle - this._bloomBtn.addEventListener('click', () => { - this.shareLinkManager?.claimRestoreLane?.('visual'); - this._setBloomEnabled(!this.bloomEnabled); + this._displayControls?.destroy(); + this._displayControls = bindDisplayControls({ + elements: { + styleButtons: document.querySelectorAll('.style-btn'), + bloomButton: this._bloomBtn, bloomSlider: this._bloomSlider, + sharpenButton: this._sharpenBtn, sharpenSlider: this._sharpenSlider, + scopeButton: this._scopeBtn, scopeFeatherSlider: this._scopeFeatherSlider, + hudLayout: this._hudLayoutSelect, hudButton: this._hudBtn, + cleanViewButton: this._cleanViewBtn, cleanViewExitButton: this._cleanViewExitBtn, + densitySlider: this._detectionDensitySlider, detectionButton: this._detectionBtn, + allocationButtons: this._detectionAllocationBtns, + fadeSliders: [this._detectionFadeSlider, this._detectionOpacitySlider], + celestialButton: this._celestialBtn, + modelsButton: this._models3dBtn, + modelModeButtons: this._models3dBtn ? this._models3dModeBtns : [], + }, + actions: { + setStyle: (style) => this.setStyle(style), + toggleBloom: () => { + this.shareLinkManager?.claimRestoreLane?.('visual'); + this._setBloomEnabled(!this.bloomEnabled); + }, + setBloomIntensity: (value) => { + this.shareLinkManager?.claimRestoreLane?.('visual'); + this._setBloomIntensity(value); + }, + toggleSharpen: () => { + this.shareLinkManager?.claimRestoreLane?.('visual'); + this._setSharpenEnabled(!this.sharpenEnabled); + }, + toggleScope: () => { + this.shareLinkManager?.claimRestoreLane?.('visual'); + const next = !isScopeMaskEnabled(); + setScopeMaskEnabled(next); + this._scopeBtn.classList.toggle('active', next); + this._scopeBtn.setAttribute('aria-pressed', String(next)); + this._syncShareState(); + }, + setScopeFeather: (value) => { + this.shareLinkManager?.claimRestoreLane?.('visual'); + const pct = Math.max(0, Math.min(100, value || 0)); + if (this._scopeFeatherValue) this._scopeFeatherValue.textContent = `${pct}%`; + setScopeMaskFeather(pct / 100); + this._syncShareState(); + }, + setSharpenIntensity: (pct) => { + this.shareLinkManager?.claimRestoreLane?.('visual'); + if (this._sharpenSliderValue) this._sharpenSliderValue.textContent = `${pct}%`; + this._applySharpenIntensity(pct / 100); + this._syncShareState(); + }, + setHudLayout: (value) => { + this.shareLinkManager?.claimRestoreLane?.('visual'); + this._setHudVariant(value); + }, + toggleCleanView: () => this.toggleCleanView(), + exitCleanView: () => this.toggleCleanView(false), + setDensity: (value) => { + this.shareLinkManager?.claimRestoreLane?.('visual'); + this._detectionUserOverridden = true; + const pct = canonicalizeDensity(value); + this._detectionDensitySlider.value = String(pct); + if (this._detectionDensityValue) this._detectionDensityValue.textContent = `${pct}%`; + this._applyDetectionDensityFromUi(); + this._syncShareState(); + }, + setAllocation: (value) => { + this.shareLinkManager?.claimRestoreLane?.('visual'); + this._detectionUserOverridden = true; + this._setDetectionAllocation(value); + }, + setFade: () => { + this.shareLinkManager?.claimRestoreLane?.('visual'); + this._applyDetectionFadeFromUi(); + this._syncShareState(); + }, + toggleCelestial: () => { + const ringIsVisible = !!this.celestialRing?.visible; + if (!this.celestialRingEnabled || !ringIsVisible) { + this.setCelestialRingEnabled(true, { focus: true }); + } else { + this.setCelestialRingEnabled(false); + } + }, + toggleHud: () => { + this.shareLinkManager?.claimRestoreLane?.('visual'); + this.hud.toggle(); + this._updateHudButtonState(); + this._syncShareState(); + }, + cycleDetection: () => { + this.shareLinkManager?.claimRestoreLane?.('visual'); + this._detectionUserOverridden = true; + cycleDetectionMode(); + this._syncShareState(); + }, + toggleModels: () => { + this._setModels3dEnabled(!this._models3dEnabled); + this._syncModels3dModeRow(); + }, + setModelsMode: (mode) => this._setModels3dMode(mode), + }, }); - - // Bloom intensity slider - this._bloomSlider.addEventListener('input', () => { - this.shareLinkManager?.claimRestoreLane?.('visual'); - this._setBloomIntensity(parseInt(this._bloomSlider.value, 10)); - }); - - // Sharpen toggle - this._sharpenBtn.addEventListener('click', () => { - this.shareLinkManager?.claimRestoreLane?.('visual'); - this._setSharpenEnabled(!this.sharpenEnabled); - }); - - // Scope mask — the explicit circular viewport treatment (owner ask: - // standalone toggle + featherable edge; see src/scopeMask.js). - this._scopeBtn?.addEventListener('click', () => { - this.shareLinkManager?.claimRestoreLane?.('visual'); - const next = !isScopeMaskEnabled(); - setScopeMaskEnabled(next); - this._scopeBtn.classList.toggle('active', next); - this._scopeBtn.setAttribute('aria-pressed', String(next)); - this._syncShareState(); - }); - this._scopeFeatherSlider?.addEventListener('input', () => { - this.shareLinkManager?.claimRestoreLane?.('visual'); - const pct = Math.max(0, Math.min(100, parseInt(this._scopeFeatherSlider.value, 10) || 0)); - if (this._scopeFeatherValue) this._scopeFeatherValue.textContent = `${pct}%`; - setScopeMaskFeather(pct / 100); - this._syncShareState(); - }); - - if (this._sharpenSlider) { - this._sharpenSlider.addEventListener('input', () => { - this.shareLinkManager?.claimRestoreLane?.('visual'); - const pct = parseInt(this._sharpenSlider.value, 10); - if (this._sharpenSliderValue) { - this._sharpenSliderValue.textContent = `${pct}%`; - } - this._applySharpenIntensity(pct / 100); - this._syncShareState(); - }); - } - - if (this._hudLayoutSelect) { - this._hudLayoutSelect.addEventListener('change', () => { - this.shareLinkManager?.claimRestoreLane?.('visual'); - this._setHudVariant(this._hudLayoutSelect.value); - }); - } - - if (this._cleanViewBtn) { - this._cleanViewBtn.addEventListener('click', () => this.toggleCleanView()); - } - if (this._cleanViewExitBtn) { - this._cleanViewExitBtn.addEventListener('click', () => this.toggleCleanView(false)); - } - - if (this._detectionDensitySlider) { - this._detectionDensitySlider.addEventListener('input', () => { - this.shareLinkManager?.claimRestoreLane?.('visual'); - this._detectionUserOverridden = true; - const pct = canonicalizeDensity(this._detectionDensitySlider.value); - this._detectionDensitySlider.value = String(pct); - if (this._detectionDensityValue) { - this._detectionDensityValue.textContent = `${pct}%`; - } - this._applyDetectionDensityFromUi(); - this._syncShareState(); - }); - } - - for (const button of this._detectionAllocationBtns) { - button.addEventListener('click', () => { - this.shareLinkManager?.claimRestoreLane?.('visual'); - this._detectionUserOverridden = true; - this._setDetectionAllocation(button.dataset.allocation); - }); - } - - for (const slider of [this._detectionFadeSlider, this._detectionOpacitySlider]) { - slider?.addEventListener('input', () => { - this.shareLinkManager?.claimRestoreLane?.('visual'); - this._applyDetectionFadeFromUi(); - this._syncShareState(); - }); - } - - if (this._celestialBtn) { - this._celestialBtn.addEventListener('click', () => { - const ringIsVisible = !!this.celestialRing?.visible; - if (!this.celestialRingEnabled || !ringIsVisible) { - this.setCelestialRingEnabled(true, { focus: true }); - } else { - this.setCelestialRingEnabled(false); - } - }); - } } /** @@ -9395,26 +9392,15 @@ export class StyleManager { this._layoutRightPanels(); } + _syncModels3dModeRow() { + if (this._models3dModeRow) this._models3dModeRow.classList.toggle('visible', this._models3dEnabled); + this._layoutRightPanels(); + } + _initModels3dToggle() { if (!this._models3dBtn) return; - // The Proximity/All mode row is revealed only while 3D is on (mirrors the DETECT slider row). - const syncModeRow = () => { - if (this._models3dModeRow) this._models3dModeRow.classList.toggle('visible', this._models3dEnabled); - this._layoutRightPanels(); - }; - this._models3dBtn.addEventListener('click', () => { - this._setModels3dEnabled(!this._models3dEnabled); - syncModeRow(); - }); - for (const btn of this._models3dModeBtns) { - if (!btn) continue; - btn.addEventListener('click', () => { - const mode = btn.dataset.mode === 'all' ? 'all' : 'proximity'; - this._setModels3dMode(mode); - }); - } this._syncModels3dButtonState(); - syncModeRow(); + this._syncModels3dModeRow(); } _setModels3dEnabled(enabled) { @@ -9447,13 +9433,6 @@ export class StyleManager { } _initHUDToggle() { - this._hudBtn.addEventListener('click', () => { - this.shareLinkManager?.claimRestoreLane?.('visual'); - this.hud.toggle(); - this._updateHudButtonState(); - this._syncShareState(); - }); - if (this._hudLayoutSelect) { this._hudLayoutSelect.value = 'tactical'; } @@ -9461,13 +9440,6 @@ export class StyleManager { this.hud.setMode('on'); this._updateHudButtonState(); - // Detection toggle button - this._detectionBtn.addEventListener('click', () => { - this.shareLinkManager?.claimRestoreLane?.('visual'); - this._detectionUserOverridden = true; - cycleDetectionMode(); - this._syncShareState(); - }); this._cockpitDisplayToggleBtn?.addEventListener('click', () => { const open = this._cockpitDisplayToggleBtn.getAttribute('aria-expanded') === 'true'; this._setCockpitDisclosure?.('display', !open); @@ -9690,6 +9662,7 @@ export class StyleManager { if (this._globalLoadingStatus) this._globalLoadingStatus.hidden = true; this._disposed = true; this._applicationShortcuts?.destroy(); + this._displayControls?.destroy(); this._styleParameters?.destroy(); for (const control of this._panelDisclosureControls || []) control.destroy(); this._panelDisclosureControls = []; diff --git a/src/ui/displayControls.js b/src/ui/displayControls.js new file mode 100644 index 000000000..77a4000c2 --- /dev/null +++ b/src/ui/displayControls.js @@ -0,0 +1,41 @@ +/** + * Bind Display controls to application actions. Inputs remain native DOM controls; + * the caller owns settings and effect state. Destroy before replacing the view. + * @param {{ elements: object, actions: object }} options + * @returns {{ destroy(): void }} + */ +export function bindDisplayControls({ elements, actions }) { + const removers = []; + const listen = (element, type, action, read) => { + if (!element) return; + const handler = () => actions[action](...(read ? [read(element)] : [])); + element.addEventListener(type, handler); + removers.push(() => element.removeEventListener(type, handler)); + }; + const integer = (element) => parseInt(element.value, 10); + for (const [name, action] of [ + ['bloomButton', 'toggleBloom'], ['sharpenButton', 'toggleSharpen'], + ['scopeButton', 'toggleScope'], ['cleanViewButton', 'toggleCleanView'], + ['cleanViewExitButton', 'exitCleanView'], ['celestialButton', 'toggleCelestial'], + ['hudButton', 'toggleHud'], ['detectionButton', 'cycleDetection'], + ['modelsButton', 'toggleModels'], + ]) listen(elements[name], 'click', action); + for (const [name, action] of [ + ['bloomSlider', 'setBloomIntensity'], ['sharpenSlider', 'setSharpenIntensity'], + ['scopeFeatherSlider', 'setScopeFeather'], + ]) listen(elements[name], 'input', action, integer); + listen(elements.densitySlider, 'input', 'setDensity', (el) => el.value); + listen(elements.hudLayout, 'change', 'setHudLayout', (el) => el.value); + for (const el of elements.styleButtons || []) + listen(el, 'click', 'setStyle', (el) => el.dataset.style); + for (const el of elements.allocationButtons || []) + listen(el, 'click', 'setAllocation', (el) => el.dataset.allocation); + for (const el of elements.modelModeButtons || []) + listen(el, 'click', 'setModelsMode', (el) => el.dataset.mode === 'all' ? 'all' : 'proximity'); + for (const el of elements.fadeSliders || []) listen(el, 'input', 'setFade'); + return { + destroy() { + for (const remove of removers.splice(0)) remove(); + }, + }; +} diff --git a/src/ui/displayControls.test.mjs b/src/ui/displayControls.test.mjs new file mode 100644 index 000000000..28822f564 --- /dev/null +++ b/src/ui/displayControls.test.mjs @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { bindDisplayControls } from './displayControls.js'; + +function element(value = '') { + const el = new EventTarget(); + el.value = value; + el.dataset = {}; + return el; +} + +test('controls read current values without preventing native input behavior', () => { + const bloomSlider = element('24'); + const hudLayout = element('tactical'); + const calls = []; + const control = bindDisplayControls({ elements: { bloomSlider, hudLayout }, actions: { + setBloomIntensity: (value) => calls.push(value), + setHudLayout: (value) => calls.push(value), + } }); + const event = new Event('input', { cancelable: true }); + bloomSlider.dispatchEvent(event); + bloomSlider.value = '37'; + bloomSlider.dispatchEvent(new Event('input')); + hudLayout.value = 'minimal'; + hudLayout.dispatchEvent(new Event('change')); + assert.deepEqual(calls, [24, 37, 'minimal']); + assert.equal(event.defaultPrevented, false); + control.destroy(); +}); + +test('destroyed and replaced controls cannot issue stale actions', () => { + const bloomButton = element(); + let oldCalls = 0; + let newCalls = 0; + const first = bindDisplayControls({ elements: { bloomButton }, actions: { toggleBloom: () => oldCalls++ } }); + bloomButton.dispatchEvent(new Event('click')); + first.destroy(); + const second = bindDisplayControls({ elements: { bloomButton }, actions: { toggleBloom: () => newCalls++ } }); + first.destroy(); + bloomButton.dispatchEvent(new Event('click')); + assert.equal(oldCalls, 1); + assert.equal(newCalls, 1); + second.destroy(); + bloomButton.dispatchEvent(new Event('click')); + assert.equal(newCalls, 1); +}); + +test('style, allocation and model choices retain their current data attributes', () => { + const style = element(); + const allocation = element(); + const mode = element(); + style.dataset.style = 'thermal'; + allocation.dataset.allocation = 'balanced'; + mode.dataset.mode = 'all'; + const calls = []; + const control = bindDisplayControls({ elements: { styleButtons: [style], allocationButtons: [allocation], modelModeButtons: [mode] }, actions: { + setStyle: (value) => calls.push(value), setAllocation: (value) => calls.push(value), setModelsMode: (value) => calls.push(value), + } }); + for (const el of [style, allocation, mode]) el.dispatchEvent(new Event('click')); + mode.dataset.mode = 'unknown'; + mode.dispatchEvent(new Event('click')); + assert.deepEqual(calls, ['thermal', 'balanced', 'all', 'proximity']); + control.destroy(); +}); + +test('optional controls are absent safely and subscriptions stay instance-owned', () => { + const calls = []; + const a = element(); + const b = element(); + const one = bindDisplayControls({ elements: { fadeSliders: [null, a] }, actions: { setFade: () => calls.push('a') } }); + const two = bindDisplayControls({ elements: { fadeSliders: [b] }, actions: { setFade: () => calls.push('b') } }); + one.destroy(); + a.dispatchEvent(new Event('input')); + b.dispatchEvent(new Event('input')); + assert.deepEqual(calls, ['b']); + two.destroy(); +});