diff --git a/plugins/drum_highway_3d/plugin.json b/plugins/drum_highway_3d/plugin.json index 3309b6e..576e9b9 100644 --- a/plugins/drum_highway_3d/plugin.json +++ b/plugins/drum_highway_3d/plugin.json @@ -1,7 +1,7 @@ { "id": "drum_highway_3d", "name": "3D Drum Highway", - "version": "0.3.1", + "version": "0.3.2", "type": "visualization", "bundled": true, "script": "screen.js", diff --git a/plugins/drum_highway_3d/screen.js b/plugins/drum_highway_3d/screen.js index c5c9821..595ce06 100644 --- a/plugins/drum_highway_3d/screen.js +++ b/plugins/drum_highway_3d/screen.js @@ -1361,6 +1361,41 @@ } catch (_) { /* dispatch unavailable — persisted value applies next init */ } }; + /* ====================================================================== + * Camera Director bridge resolver (pure — exported via createFactory.__test) + * ====================================================================== */ + + /** + * The active splitscreen API, defensive on the global-name rename in flight + * (feedBackSplitscreen is canonical; slopsmithSplitscreen is the legacy alias). + * @returns {object|null} the splitscreen API, or null when not present + */ + function _ssApi() { return window.feedBackSplitscreen || window.slopsmithSplitscreen || null; } + + /** + * Resolve the Camera Director camera for a canvas: this panel's camera under + * splitscreen, else the global, else null (Camera Director absent → stock + * framing). Throw-safe on panelIndexFor so a misbehaving splitscreen build + * can't break framing. + * @param {HTMLCanvasElement} canvas this renderer's highway canvas + * @param {object|null} ss the splitscreen API (see _ssApi) + * @param {object|null} panelsMap window.__h3dCamCtlPanels (per-panel cameras by index) + * @param {object|null} globalCam window.__h3dCamCtl (single global camera) + * @returns {object|null} the resolved free-camera bridge, or null + */ + function _resolveFreeCam(canvas, ss, panelsMap, globalCam) { + if (panelsMap && ss && typeof ss.panelIndexFor === 'function') { + try { + const i = ss.panelIndexFor(canvas); + // Only a non-negative integer indexes the panel map — a non-int / + // negative / string index (or a prototype key) must not resolve an + // unintended/inherited property; fall through to the global then. + if (Number.isInteger(i) && i >= 0 && panelsMap[i]) return panelsMap[i]; + } catch (e) { /* ignore */ } + } + return globalCam || null; + } + /* ====================================================================== * Renderer factory * ====================================================================== */ @@ -1414,7 +1449,7 @@ let _sparkPts = null, _sparkPos = null, _sparkCol = null, _sparkVel = null, _sparkLife = null; let _fxLastWall = 0; // wall clock for FX integration (sparks, pulse decay) let _kickPulse = 0; // kick-hit camera-dip + floor-wash envelope - let _camBaseH = 0, _camBaseD = 0; // positionCamera's unpulsed pose + let _camBaseH = null, _camBaseD = null; // positionCamera's unpulsed pose (null until it first runs; applyCamera's guard depends on this) let _gaussTex = null; // shared soft-falloff texture for flash quads let _laneFlashQuads = []; // pooled additive quad per hand lane (z=0) let _kickFlashQuad = null; // full-width flash quad for the kick bar @@ -2651,6 +2686,48 @@ cam.lookAt(0, 0, -AHEAD * TS * 0.45); } + /** + * Camera Director bridge for THIS panel — delegates to the pure, unit- + * tested _resolveFreeCam / _ssApi (resolver block above the factory). + * Reads the live globals: per-panel map __h3dCamCtlPanels → this panel's + * camera, else the global __h3dCamCtl, else null (stock framing). + * @param {HTMLCanvasElement} canvas this panel's highway canvas + * @returns {object|null} the resolved free-camera bridge, or null + */ + function _freeCamFor(canvas) { + return _resolveFreeCam(canvas, _ssApi(), window.__h3dCamCtlPanels, window.__h3dCamCtl); + } + + // Per-frame camera write: static base pose (positionCamera) + kick-pulse Y + // dip, then layer Camera Director free-cam offsets (dolly/height/orbit on + // the camera-from-target vector; pan/pitch on the look target). Runs every + // frame so a live free-cam drag is smooth; allocation-free; NaN-safe; a + // null/disabled bridge reproduces the stock static+pulse pose exactly. + function applyCamera() { + if (_camBaseH == null) return; // before first positionCamera() + const _dip = (_kickPulse > 0.001) ? (0.8 * K * _kickPulse * fx.hitFx) : 0; + let _cx = 0, _cy = _camBaseH - _dip, _cz = _camBaseD; + let _lx = 0, _ly = 0, _lz = -AHEAD * TS * 0.45; + const _fc = _freeCamFor(highwayCanvas); + if (_fc && _fc.enabled) { + const _dm = Number.isFinite(_fc.distMul) ? _fc.distMul : 1; + const _hm = Number.isFinite(_fc.heightMul) ? _fc.heightMul : 1; + const _yaw = Number.isFinite(_fc.yaw) ? _fc.yaw : 0; + let _vx = _cx - _lx, _vy = _cy - _ly, _vz = _cz - _lz; + _vx *= _dm; _vy *= _dm; _vz *= _dm; // dolly (zoom) + _vy *= _hm; // height + const _cyw = Math.cos(_yaw), _syw = Math.sin(_yaw); + const _rx = _vx * _cyw - _vz * _syw, _rz = _vx * _syw + _vz * _cyw; // orbit around Y + _cx = _lx + _rx; _cy = _ly + _vy; _cz = _lz + _rz; + const _px = Number.isFinite(_fc.panX) ? _fc.panX : 0; + const _py = Number.isFinite(_fc.panY) ? _fc.panY : 0; + const _pt = Number.isFinite(_fc.pitch) ? _fc.pitch : 0; + _lx += _px * K; _ly += (_pt + _py) * K; + } + cam.position.set(_cx, _cy, _cz); + cam.lookAt(_lx, _ly, _lz); + } + function buildLanes(_floorW, floorD) { laneGroup = new T.Group(); laneStripeMats = []; @@ -3431,15 +3508,18 @@ BG_STYLES[_bgState._style].update(_bgState.s, bands, fdt, nowMs / 1000); } catch (_) { /* visual-only */ } } + // Kick pulse decays each frame; it drives the floor flash and, + // via applyCamera(), the camera Y dip. if (_kickPulse > 0.001) { _kickPulse *= Math.exp(-fdt * 7); - cam.position.y = _camBaseH - 0.8 * K * _kickPulse * fx.hitFx; if (_floorFlash) _floorFlash.material.opacity = 0.25 * _kickPulse * fx.hitFx; } else if (_kickPulse !== 0) { _kickPulse = 0; - cam.position.y = _camBaseH; if (_floorFlash) _floorFlash.material.opacity = 0; } + // Write the camera every frame: static base pose + kick dip + + // Camera Director free-cam offsets (per-panel-aware). + applyCamera(); } // Approach highlight: raise each lane stripe toward its next // note (accumulated by the rebuildNotes walk above). @@ -3565,6 +3645,8 @@ // vm-loaded with no DOM/WebGL; everything here must stay side-effect // free to call). window.slopsmithViz_drum_highway_3d.__test = { + _resolveFreeCam, + _ssApi, _variantForHit, _classifyTiming, readFxSettings, diff --git a/plugins/drum_highway_3d/tests/camera_bridge.test.js b/plugins/drum_highway_3d/tests/camera_bridge.test.js new file mode 100644 index 0000000..0b2ec70 --- /dev/null +++ b/plugins/drum_highway_3d/tests/camera_bridge.test.js @@ -0,0 +1,78 @@ +// Camera Director bridge resolver tests: per-panel select, global fallback, +// null-when-absent, throw-safety, and the splitscreen global-name alias. Loads +// screen.js in a bare vm window and exercises the __test exports (no DOM/WebGL). +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +function load() { + const window = { + console, + location: { protocol: 'http:', host: 'localhost' }, + slopsmith: {}, + }; + window.window = window; + window.globalThis = window; + const context = vm.createContext(window); + const src = fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8'); + vm.runInContext(src, context, { filename: 'screen.js' }); + return { window, __test: window.slopsmithViz_drum_highway_3d.__test }; +} + +test('_resolveFreeCam: per-panel camera under splitscreen', () => { + const { __test } = load(); + const c0 = {}, c1 = {}; + const ss = { panelIndexFor: (c) => (c === c0 ? 0 : 1) }; + const map = { 0: { id: 'p0' }, 1: { id: 'p1' } }; + assert.equal(__test._resolveFreeCam(c0, ss, map, { id: 'g' }).id, 'p0'); + assert.equal(__test._resolveFreeCam(c1, ss, map, { id: 'g' }).id, 'p1'); +}); + +test('_resolveFreeCam: falls back to global when there is no panel map', () => { + const { __test } = load(); + const g = { id: 'global' }; + assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 0 }, null, g), g); +}); + +test('_resolveFreeCam: falls back to global when the panel has no map entry', () => { + const { __test } = load(); + const g = { id: 'global' }; + const ss = { panelIndexFor: () => 3 }; // index 3 absent from map + assert.equal(__test._resolveFreeCam({}, ss, { 0: {} }, g), g); +}); + +test('_resolveFreeCam: null when Camera Director is absent (no global)', () => { + const { __test } = load(); + assert.equal(__test._resolveFreeCam({}, null, null, null), null); + assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 0 }, {}, undefined), null); +}); + +test('_resolveFreeCam: throw-safe on panelIndexFor → falls back to global', () => { + const { __test } = load(); + const g = { id: 'global' }; + const ss = { panelIndexFor: () => { throw new Error('boom'); } }; + assert.equal(__test._resolveFreeCam({}, ss, { 0: {} }, g), g); +}); + +test('_resolveFreeCam: NaN/negative/float/string index → falls back to global', () => { + const { __test } = load(); + const g = { id: 'global' }; + assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => NaN }, { 0: {} }, g), g); + assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => -1 }, { 0: {} }, g), g); + assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 0.5 }, { 0: {} }, g), g); + // A string/prototype key must not resolve an inherited property (e.g. toString). + assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 'toString' }, {}, g), g); +}); + +test('_ssApi: null when neither global set; slopsmith alias; feedBack canonical wins', () => { + const { window, __test } = load(); + assert.equal(__test._ssApi(), null); + const legacy = { panelIndexFor: () => 0 }; + window.slopsmithSplitscreen = legacy; + assert.equal(__test._ssApi(), legacy); // legacy alias picked up + const current = { panelIndexFor: () => 1 }; + window.feedBackSplitscreen = current; + assert.equal(__test._ssApi(), current); // canonical name takes precedence +}); diff --git a/plugins/highway_3d/plugin.json b/plugins/highway_3d/plugin.json index 9eed075..cea8168 100644 --- a/plugins/highway_3d/plugin.json +++ b/plugins/highway_3d/plugin.json @@ -1,7 +1,7 @@ { "id": "highway_3d", "name": "3D Highway", - "version": "3.31.3", + "version": "3.31.4", "type": "visualization", "bundled": true, "script": "screen.js", diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index 0f7fc1d..af5290d 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -2595,10 +2595,51 @@ } const FRET_NUMBER_GHOST_SCOPE_IDS = ['chords', 'all']; + /** + * localStorage panel key for per-panel background settings ('main' or + * 'panel'). Defensive on the splitscreen global-name rename in flight, + * and throw-safe on panelIndexFor — same as _freeCamFor — so a misbehaving + * splitscreen build can't take down background-settings resolution. Only a + * non-negative integer index yields a 'panel' key; anything else (null, + * NaN, negative, non-integer) falls back to 'main' so a bad index can never + * mint a bogus "panelNaN"-style key. + * @param {HTMLCanvasElement} canvas this renderer's highway canvas + * @returns {string} 'main' or 'panel' + */ function _bgPanelKey(canvas) { - const ss = window.feedBackSplitscreen; - const idx = (ss && typeof ss.panelIndexFor === 'function') ? ss.panelIndexFor(canvas) : null; - return (idx == null) ? 'main' : 'panel' + idx; + const ss = window.feedBackSplitscreen || window.slopsmithSplitscreen; + let idx = null; + if (ss && typeof ss.panelIndexFor === 'function') { + try { idx = ss.panelIndexFor(canvas); } catch (e) { idx = null; } + } + return (Number.isInteger(idx) && idx >= 0) ? 'panel' + idx : 'main'; + } + + /** + * Camera Director bridge resolver. Prefers THIS panel's per-panel camera under + * splitscreen (window.__h3dCamCtlPanels[panelIndex]) and falls back to the + * single global (window.__h3dCamCtl); returns null when Camera Director is + * absent → 100% stock framing. Defensive on the splitscreen global-name rename + * in flight (feedBackSplitscreen vs slopsmithSplitscreen); throw-safe on + * panelIndexFor. Mirrors the panel resolution in _bgPanelKey. + * @param {HTMLCanvasElement} canvas this renderer's highway canvas + * @returns {object|null} the resolved free-camera bridge, or null + */ + function _freeCamFor(canvas) { + const map = window.__h3dCamCtlPanels; + if (map) { + const ss = window.feedBackSplitscreen || window.slopsmithSplitscreen; + if (ss && typeof ss.panelIndexFor === 'function') { + try { + const i = ss.panelIndexFor(canvas); + // Only a non-negative integer indexes the map (same hardening + // as _bgPanelKey) — a non-int / negative / string index must not + // resolve an unintended/inherited property; fall through then. + if (Number.isInteger(i) && i >= 0 && map[i]) return map[i]; + } catch (e) { /* ignore */ } + } + } + return window.__h3dCamCtl || null; } // In-memory fallback for when localStorage is blocked (private mode, // sandboxed iframes, some test runners). _bgWriteGlobal stages the @@ -14664,7 +14705,10 @@ // suppressed while the Camera Director owns the view (it wins). const _startAspect = (_tune && Number.isFinite(_tune.startAspect) && _tune.startAspect > 0) ? _tune.startAspect : HORPLUS_START_ASPECT; - const _dirActive = !!(window.__h3dCamCtl && window.__h3dCamCtl.enabled); + // Resolve the Camera Director bridge once (per-panel under splitscreen, + // else global). Used both for the wide-pane gate and the transforms below. + const _freeCam = _freeCamFor(highwayCanvas); + const _dirActive = !!(_freeCam && _freeCam.enabled); const _wide = !!(_tune && _paneAspect > _startAspect) && !_dirActive; const _poseHMul = (_wide && Number.isFinite(_tune.heightMul)) ? _tune.heightMul : 1; const _poseDMul = (_wide && Number.isFinite(_tune.distMul)) ? _tune.distMul : 1; @@ -14691,13 +14735,16 @@ if (_poseHMul !== 1) _camY *= _poseHMul; if (_poseDMul !== 1) _camZ *= _poseDMul; // ── Free-camera user tweaks (orbit / height / zoom / pan) ── - // Driven by the Camera Director plugin via window.__h3dCamCtl. + // Driven by the Camera Director plugin via the camera bridge: + // window.__h3dCamCtlPanels[panelIndexFor(canvas)] when split (this + // panel's own camera), falling back to the global window.__h3dCamCtl. // Layered ON TOP of the auto-framing so note tracking still works. // The bridge is read once into _freeCam and reused for both the // position and the look-at transforms; every field is coerced to a // finite number before use so a malformed object can never feed NaN // into cam.position / cam.lookAt. - const _freeCam = window.__h3dCamCtl; + // _freeCam resolved above via _freeCamFor(highwayCanvas): the + // per-panel __h3dCamCtlPanels entry, else global __h3dCamCtl, else null. const _lookAtZ = -FOCUS_D * 0.35 * _poseLookZMul; if (_freeCam && _freeCam.enabled) { const _distMul = Number.isFinite(_freeCam.distMul) ? _freeCam.distMul : 1; diff --git a/plugins/keys_highway_3d/plugin.json b/plugins/keys_highway_3d/plugin.json index 80bb008..1a9fe30 100644 --- a/plugins/keys_highway_3d/plugin.json +++ b/plugins/keys_highway_3d/plugin.json @@ -1,7 +1,7 @@ { "id": "keys_highway_3d", "name": "Keys Highway 3D", - "version": "0.2.0", + "version": "0.2.1", "description": "RS+-style 3D falling-note piano highway fed by the Sloppak Notation Format, with Web MIDI input scoring.", "type": "visualization", "bundled": true, diff --git a/plugins/keys_highway_3d/screen.js b/plugins/keys_highway_3d/screen.js index 1871b32..ff8e3f5 100644 --- a/plugins/keys_highway_3d/screen.js +++ b/plugins/keys_highway_3d/screen.js @@ -1600,6 +1600,41 @@ _aiRegisteredCount = 0; } + /* ====================================================================== + * Camera Director bridge resolver (pure — exported via createFactory.__test) + * ====================================================================== */ + + /** + * The active splitscreen API, defensive on the global-name rename in flight + * (feedBackSplitscreen is canonical; slopsmithSplitscreen is the legacy alias). + * @returns {object|null} the splitscreen API, or null when not present + */ + function _ssApi() { return window.feedBackSplitscreen || window.slopsmithSplitscreen || null; } + + /** + * Resolve the Camera Director camera for a canvas: this panel's camera under + * splitscreen, else the global, else null (Camera Director absent → 100% stock + * framing). Throw-safe on panelIndexFor so a misbehaving splitscreen build + * can't break framing. + * @param {HTMLCanvasElement} canvas this renderer's highway canvas + * @param {object|null} ss the splitscreen API (see _ssApi) + * @param {object|null} panelsMap window.__h3dCamCtlPanels (per-panel cameras by index) + * @param {object|null} globalCam window.__h3dCamCtl (single global camera) + * @returns {object|null} the resolved free-camera bridge, or null + */ + function _resolveFreeCam(canvas, ss, panelsMap, globalCam) { + if (panelsMap && ss && typeof ss.panelIndexFor === 'function') { + try { + const i = ss.panelIndexFor(canvas); + // Only a non-negative integer indexes the panel map — a non-int / + // negative / string index (or a prototype key) must not resolve an + // unintended/inherited property; fall through to the global then. + if (Number.isInteger(i) && i >= 0 && panelsMap[i]) return panelsMap[i]; + } catch (e) { /* ignore */ } + } + return globalCam || null; + } + /* ====================================================================== * Renderer factory * ====================================================================== */ @@ -1849,6 +1884,18 @@ _rigOut.lookZ = _camPreset.lookZ; return _rigOut; } + + /** + * Camera Director bridge for THIS panel — delegates to the pure, unit- + * tested _resolveFreeCam / _ssApi (resolver block above the factory). + * Reads the live globals: per-panel map __h3dCamCtlPanels → this panel's + * camera, else the global __h3dCamCtl, else null (stock framing). + * @param {HTMLCanvasElement} canvas this panel's highway canvas + * @returns {object|null} the resolved free-camera bridge, or null + */ + function _freeCamFor(canvas) { + return _resolveFreeCam(canvas, _ssApi(), window.__h3dCamCtlPanels, window.__h3dCamCtl); + } // Per-key approach glow: a key lights in its pitch-class color ONLY while a // note is heading for it, ramping up the closer that note gets to the hit-line. const KEY_GLOW_AHEAD = 2.0; // seconds before the hit-line a key starts to light @@ -3259,7 +3306,33 @@ } _camX += (_camTargetX - _camX) * CAM_PAN_LERP; _camZoom += (_camTargetZoom - _camZoom) * CAM_ZOOM_LERP; - { const r = _rig(); cam.position.set(_camX, r.y * K * _camZoom, r.z * K * _camZoom); cam.lookAt(_camX, r.lookY * K * _camZoom, r.lookZ * K * _camZoom); } + { + const r = _rig(); + let _cx = _camX, _cy = r.y * K * _camZoom, _cz = r.z * K * _camZoom; + let _lx = _camX, _ly = r.lookY * K * _camZoom, _lz = r.lookZ * K * _camZoom; + // Camera Director free-cam offsets (per-panel-aware), layered on top + // of the auto-framing so pan/zoom-follow still works. Dolly/height/ + // orbit act on the camera-from-target vector; pan/pitch shift the + // look target. NaN-safe; null/disabled bridge → stock. + const _fc = _freeCamFor(highwayCanvas); + if (_fc && _fc.enabled) { + const _dm = Number.isFinite(_fc.distMul) ? _fc.distMul : 1; + const _hm = Number.isFinite(_fc.heightMul) ? _fc.heightMul : 1; + const _yaw = Number.isFinite(_fc.yaw) ? _fc.yaw : 0; + let _vx = _cx - _lx, _vy = _cy - _ly, _vz = _cz - _lz; + _vx *= _dm; _vy *= _dm; _vz *= _dm; // dolly (zoom) + _vy *= _hm; // height + const _cyw = Math.cos(_yaw), _syw = Math.sin(_yaw); + const _rx = _vx * _cyw - _vz * _syw, _rz = _vx * _syw + _vz * _cyw; // orbit around Y + _cx = _lx + _rx; _cy = _ly + _vy; _cz = _lz + _rz; + const _px = Number.isFinite(_fc.panX) ? _fc.panX : 0; + const _py = Number.isFinite(_fc.panY) ? _fc.panY : 0; + const _pt = Number.isFinite(_fc.pitch) ? _fc.pitch : 0; + _lx += _px * K; _ly += (_pt + _py) * K; + } + cam.position.set(_cx, _cy, _cz); + cam.lookAt(_lx, _ly, _lz); + } for (const km of keyMeshes.values()) km.userData.glow = 0; for (const { mesh, note, len, label } of noteMeshes) { @@ -3960,6 +4033,8 @@ }; // Pure data-layer + scoring hooks for headless tests. window.slopsmithViz_keys_highway_3d.__test = { + _resolveFreeCam, + _ssApi, beatDurSec, flattenNotation, keyRange, diff --git a/plugins/keys_highway_3d/tests/camera_bridge.test.js b/plugins/keys_highway_3d/tests/camera_bridge.test.js new file mode 100644 index 0000000..e723f05 --- /dev/null +++ b/plugins/keys_highway_3d/tests/camera_bridge.test.js @@ -0,0 +1,78 @@ +// Camera Director bridge resolver tests: per-panel select, global fallback, +// null-when-absent, throw-safety, and the splitscreen global-name alias. Loads +// screen.js in a bare vm window and exercises the __test exports (no DOM/WebGL). +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +function load() { + const window = { + console, + location: { protocol: 'http:', host: 'localhost' }, + slopsmith: {}, + }; + window.window = window; + window.globalThis = window; + const context = vm.createContext(window); + const src = fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8'); + vm.runInContext(src, context, { filename: 'screen.js' }); + return { window, __test: window.slopsmithViz_keys_highway_3d.__test }; +} + +test('_resolveFreeCam: per-panel camera under splitscreen', () => { + const { __test } = load(); + const c0 = {}, c1 = {}; + const ss = { panelIndexFor: (c) => (c === c0 ? 0 : 1) }; + const map = { 0: { id: 'p0' }, 1: { id: 'p1' } }; + assert.equal(__test._resolveFreeCam(c0, ss, map, { id: 'g' }).id, 'p0'); + assert.equal(__test._resolveFreeCam(c1, ss, map, { id: 'g' }).id, 'p1'); +}); + +test('_resolveFreeCam: falls back to global when there is no panel map', () => { + const { __test } = load(); + const g = { id: 'global' }; + assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 0 }, null, g), g); +}); + +test('_resolveFreeCam: falls back to global when the panel has no map entry', () => { + const { __test } = load(); + const g = { id: 'global' }; + const ss = { panelIndexFor: () => 3 }; // index 3 absent from map + assert.equal(__test._resolveFreeCam({}, ss, { 0: {} }, g), g); +}); + +test('_resolveFreeCam: null when Camera Director is absent (no global)', () => { + const { __test } = load(); + assert.equal(__test._resolveFreeCam({}, null, null, null), null); + assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 0 }, {}, undefined), null); +}); + +test('_resolveFreeCam: throw-safe on panelIndexFor → falls back to global', () => { + const { __test } = load(); + const g = { id: 'global' }; + const ss = { panelIndexFor: () => { throw new Error('boom'); } }; + assert.equal(__test._resolveFreeCam({}, ss, { 0: {} }, g), g); +}); + +test('_resolveFreeCam: NaN/negative/float/string index → falls back to global', () => { + const { __test } = load(); + const g = { id: 'global' }; + assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => NaN }, { 0: {} }, g), g); + assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => -1 }, { 0: {} }, g), g); + assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 0.5 }, { 0: {} }, g), g); + // A string/prototype key must not resolve an inherited property (e.g. toString). + assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 'toString' }, {}, g), g); +}); + +test('_ssApi: null when neither global set; slopsmith alias; feedBack canonical wins', () => { + const { window, __test } = load(); + assert.equal(__test._ssApi(), null); + const legacy = { panelIndexFor: () => 0 }; + window.slopsmithSplitscreen = legacy; + assert.equal(__test._ssApi(), legacy); // legacy alias picked up + const current = { panelIndexFor: () => 1 }; + window.feedBackSplitscreen = current; + assert.equal(__test._ssApi(), current); // canonical name takes precedence +});