From c7f7c88c6244e270fb02fadb04e6ff5f118dfe80 Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Sat, 5 Sep 2026 07:52:41 +0200 Subject: [PATCH] refactor(h3d): extract color/tuning/splitscreen utils to src/utils.js (h3d-carve-3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves 12 pure-function / compile-time-constant exports from the screen.js IIFE into a new src/utils.js ES module: Color utils: _h3dHexToInt, _clampByteI, _darkenInt, _lightenInt String-count: resolveStringCount Tuning/pitch: _NOTE_NAMES_SHARP, _BASE_OPEN_MIDI_BASS4/5, _BASE_OPEN_MIDI_GUITAR6/7/8, _baseOpenStringMidis, _midiToPitchLabel, _openStringPitchLabelsForTuning Splitscreen: _ssActive, _ssIsCanvasFocused Free-identifier audit: all clean. resolveStringCount uses MAX_RENDER_STRINGS and NSTR — copied as compile-time constants (both = 6) matching IIFE values. _ssActive/_ssIsCanvasFocused read window.feedBackSplitscreen live per call. Survey discrepancy declared: the 12 functions span two non-contiguous regions in current screen.js (lines 741–882 and 1490–1503) rather than the plan's original 1703–2193 range; function list from the plan is exact. New test file highway_3d_utils.test.js: 19 class-killer tests. Mutation-verified (3-char shorthand removal → RED, original → GREEN). Panel-controls sandbox: 16 stubs added for the new imports. Suite: node --test tests/js/highway_3d*.test.js plugins/highway_3d/tests/*.test.js 188→207/207 pass. Plugin: 3.38.0 → 3.39.0 Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW --- plugins/highway_3d/plugin.json | 2 +- plugins/highway_3d/screen.js | 123 +----------- plugins/highway_3d/src/utils.js | 144 ++++++++++++++ tests/js/highway_3d_panel_controls.test.js | 17 ++ tests/js/highway_3d_utils.test.js | 209 +++++++++++++++++++++ 5 files changed, 377 insertions(+), 118 deletions(-) create mode 100644 plugins/highway_3d/src/utils.js create mode 100644 tests/js/highway_3d_utils.test.js diff --git a/plugins/highway_3d/plugin.json b/plugins/highway_3d/plugin.json index a924d6e..aa0907b 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.38.0", + "version": "3.39.0", "type": "visualization", "scriptType": "module", "bundled": true, diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index 7a6f1d2..e89e17f 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -8,6 +8,7 @@ import { geoFretX, dZ, slideTrailEnd, camBaseDistU, camLowFretPullbackU, computeBPM, _makeGaussTex, RENDER_ORDER_LAYER_STACK, RENDER_ORDER_LAYER_INDEX, RENDER_ORDER_AT_Z_ZERO, RENDER_ORDER_FAR_CLAMP, renderOrderForLayerAtZ, _noteKey, lowerBoundT, hwyFirstRelevantFrettedTime, geoFretMid } from './src/geometry.js'; // h3d-carve-1b import { loadThree, T } from './src/three-loader.js'; // h3d-carve-2 +import { _h3dHexToInt, _clampByteI, _darkenInt, _lightenInt, resolveStringCount, _NOTE_NAMES_SHARP, _BASE_OPEN_MIDI_BASS4, _BASE_OPEN_MIDI_BASS5, _BASE_OPEN_MIDI_GUITAR6, _BASE_OPEN_MIDI_GUITAR7, _BASE_OPEN_MIDI_GUITAR8, _baseOpenStringMidis, _midiToPitchLabel, _openStringPitchLabelsForTuning, _ssActive, _ssIsCanvasFocused } from './src/utils.js'; // h3d-carve-3 (function () { 'use strict'; @@ -738,27 +739,7 @@ import { loadThree, T } from './src/three-loader.js'; // h3d-carve-2 // numeric hex, falling back to the default palette per missing index. // Mutated in place by _resolveCustomPalette so the reference stays stable. let _customPalette = PALETTES.default.slice(); - function _h3dHexToInt(hex) { - if (typeof hex !== 'string') return null; - const t = hex.trim().replace(/^#/, ''); - const full = t.length === 3 ? t[0] + t[0] + t[1] + t[1] + t[2] + t[2] : t; - if (!/^[0-9a-fA-F]{6}$/.test(full)) return null; - return parseInt(full, 16); - } - // Numeric (0xRRGGBB) darken/lighten — used to derive the gem-gradient - // top-highlight / bottom-shade stops from a custom per-string base color - // so the note bodies follow the custom palette (mirrors the 2D highway's - // dim/bright derivation). factor 0..1 keeps that fraction of each channel; - // lighten mixes t toward white. - function _clampByteI(n) { return n < 0 ? 0 : (n > 255 ? 255 : Math.round(n)); } - function _darkenInt(hex, factor) { - const r = (hex >> 16) & 0xff, g = (hex >> 8) & 0xff, b = hex & 0xff; - return (_clampByteI(r * factor) << 16) | (_clampByteI(g * factor) << 8) | _clampByteI(b * factor); - } - function _lightenInt(hex, t) { - const r = (hex >> 16) & 0xff, g = (hex >> 8) & 0xff, b = hex & 0xff; - return (_clampByteI(r + (255 - r) * t) << 16) | (_clampByteI(g + (255 - g) * t) << 8) | _clampByteI(b + (255 - b) * t); - } + // _h3dHexToInt, _clampByteI, _darkenInt, _lightenInt — moved to src/utils.js (h3d-carve-3). // Default per-string gem gradient stops [topHighlight, bottomShade] — // sampled from the original colour PNGs. Used verbatim for the built-in // palettes (and for unchanged slots of a custom palette) so the stock look @@ -800,86 +781,11 @@ import { loadThree, T } from './src/three-loader.js'; // h3d-carve-2 // Extend S_COL above to support more strings. const MAX_RENDER_STRINGS = S_COL.length; - // Resolve the string count for the active arrangement. Prefer - // bundle.stringCount (exposed by feedBack core since #93 — derived - // from notes/chords/tuning, so it works for 5-string bass, 7- and - // 8-string guitar, etc.). Fall back to arrangement-name detection - // for older feedBack cores that don't emit the field. Clamp to the - // palette size so a malformed bundle or a 12-string chart doesn't - // index past the per-string material arrays. - function resolveStringCount(bundle) { - const sc = bundle && bundle.stringCount; - if (Number.isFinite(sc) && sc >= 1) { - return Math.min(Math.trunc(sc), MAX_RENDER_STRINGS); - } - return /bass/i.test(bundle?.songInfo?.arrangement || '') ? 4 : NSTR; - } + // resolveStringCount — moved to src/utils.js (h3d-carve-3). - /** Chart-format tuning entries are semitone offsets from instrument standard. */ - const _NOTE_NAMES_SHARP = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; + // _NOTE_NAMES_SHARP, _BASE_OPEN_MIDI_BASS4/5, _BASE_OPEN_MIDI_GUITAR6/7/8 — moved to src/utils.js (h3d-carve-3). - // Open-string MIDI (thick → thin), matched to RS string index 0 low. - const _BASE_OPEN_MIDI_BASS4 = Object.freeze([28, 33, 38, 43]); - const _BASE_OPEN_MIDI_BASS5 = Object.freeze([23, 28, 33, 38, 43]); - const _BASE_OPEN_MIDI_GUITAR6 = Object.freeze([40, 45, 50, 55, 59, 64]); - const _BASE_OPEN_MIDI_GUITAR7 = Object.freeze([35, 40, 45, 50, 55, 59, 64]); - // F#/B/E standard extension — low string is a fifth below RS 7‑string low B. - const _BASE_OPEN_MIDI_GUITAR8 = Object.freeze([28, 35, 40, 45, 50, 55, 59, 64]); - - function _baseOpenStringMidis(sc, arrangement) { - const isBass = /bass/i.test(arrangement || ''); - if (sc === 4 && isBass) return _BASE_OPEN_MIDI_BASS4.slice(); - if (sc === 4) return _BASE_OPEN_MIDI_GUITAR6.slice(0, 4); - if (sc === 5 && isBass) return _BASE_OPEN_MIDI_BASS5.slice(); - if (sc === 5) return _BASE_OPEN_MIDI_GUITAR6.slice(0, 5); - if (sc === 7) return _BASE_OPEN_MIDI_GUITAR7.slice(); - if (sc === 8) return _BASE_OPEN_MIDI_GUITAR8.slice(); - if (Number.isFinite(sc) && sc > 8) { - const out = Array.from(_BASE_OPEN_MIDI_GUITAR8); - let last = out[out.length - 1]; - while (out.length < sc) { - last += 5; - out.push(last); - } - return out.slice(0, sc); - } - const g6 = _BASE_OPEN_MIDI_GUITAR6.slice(); - if (Number.isFinite(sc) && sc < 6 && sc >= 1) return g6.slice(0, sc); - return g6; - } - - function _midiToPitchLabel(midi) { - const m = Math.round(midi); - const octave = Math.floor(m / 12) - 1; - const n = _NOTE_NAMES_SHARP[(m % 12 + 12) % 12]; - return n + octave; - } - - /** - * @param {number} nEffective string count clamped like nStr / resolveStringCount - * @param {Record} songInfo WS song_info blob (subset) - */ - function _openStringPitchLabelsForTuning(bundle, songInfo, nEffective) { - const n = Number.isFinite(nEffective) ? Math.min(Math.max(1, Math.trunc(nEffective)), MAX_RENDER_STRINGS) : resolveStringCount(bundle); - // bundle first: chart-transform substitutes tuning/capo there, while - // songInfo keeps the chart's originals by contract. A malformed - // (non-array) bundle.tuning falls back to songInfo instead of - // blanking the labels. - let tuning = Array.isArray(bundle.tuning) ? bundle.tuning : (songInfo && songInfo.tuning); - let cap = bundle.capo; - cap = Number.isFinite(cap) ? cap : (songInfo && Number.isFinite(songInfo.capo) ? songInfo.capo : 0); - if (!Array.isArray(tuning)) tuning = []; - - const base = _baseOpenStringMidis(n, songInfo?.arrangement); - const labels = []; - for (let s = 0; s < n; s++) { - const offRaw = tuning[s]; - const off = Number.isFinite(offRaw) ? offRaw : 0; - const midi = (base[s] !== undefined ? base[s] : 40) + off + cap; - labels.push(_midiToPitchLabel(midi)); - } - return labels; - } + // _baseOpenStringMidis, _midiToPitchLabel, _openStringPitchLabelsForTuning — moved to src/utils.js (h3d-carve-3). const STR_THICK = 0.25 * K; @@ -1483,24 +1389,7 @@ import { loadThree, T } from './src/three-loader.js'; // h3d-carve-2 // T, loadThree — moved to src/three-loader.js (h3d-carve-2). // T is a live-binding export; the IIFE reads the updated value after loadThree() resolves. - /* ====================================================================== - * Splitscreen helpers - * ====================================================================== */ - - function _ssActive() { - const ss = window.feedBackSplitscreen; - if (!ss || typeof ss.isActive !== 'function' || !ss.isActive()) return false; - return typeof ss.isCanvasFocused === 'function' - && typeof ss.onFocusChange === 'function' - && typeof ss.offFocusChange === 'function'; - } - - function _ssIsCanvasFocused(highwayCanvas) { - const ss = window.feedBackSplitscreen; - if (!_ssActive()) return true; - return !!(ss && typeof ss.isCanvasFocused === 'function' && - ss.isCanvasFocused(highwayCanvas)); - } + // _ssActive, _ssIsCanvasFocused — moved to src/utils.js (h3d-carve-3). // Shortcut for the wide-pane framing tuner. Opens/closes the floating panel // (the A/B on/off and the per-pane target live inside it now). Registered diff --git a/plugins/highway_3d/src/utils.js b/plugins/highway_3d/src/utils.js new file mode 100644 index 0000000..6cb8233 --- /dev/null +++ b/plugins/highway_3d/src/utils.js @@ -0,0 +1,144 @@ +/** + * Color utilities, tuning helpers, and splitscreen predicates — h3d-carve-3. + * + * All exports are pure functions or compile-time constants; none capture + * factory-scope state. NSTR and MAX_RENDER_STRINGS are compile-time copies + * of the matching IIFE constants (both = 6 for the current 6-entry + * PALETTES.default / S_COL layout). + */ + +// ── Compile-time copies of IIFE constants ───────────────────────────────────── +const NSTR = 6; +const MAX_RENDER_STRINGS = 6; // S_COL.length = PALETTES.default.length + +// ── Color utilities ─────────────────────────────────────────────────────────── + +/** + * Parse a CSS hex color string ('#rrggbb', '#rgb', or bare variants) to a + * packed 0xRRGGBB integer. Returns null on any parse failure. + */ +export function _h3dHexToInt(hex) { + if (typeof hex !== 'string') return null; + const t = hex.trim().replace(/^#/, ''); + const full = t.length === 3 ? t[0] + t[0] + t[1] + t[1] + t[2] + t[2] : t; + if (!/^[0-9a-fA-F]{6}$/.test(full)) return null; + return parseInt(full, 16); +} + +export function _clampByteI(n) { return n < 0 ? 0 : (n > 255 ? 255 : Math.round(n)); } + +export function _darkenInt(hex, factor) { + const r = (hex >> 16) & 0xff, g = (hex >> 8) & 0xff, b = hex & 0xff; + return (_clampByteI(r * factor) << 16) | (_clampByteI(g * factor) << 8) | _clampByteI(b * factor); +} + +export function _lightenInt(hex, t) { + const r = (hex >> 16) & 0xff, g = (hex >> 8) & 0xff, b = hex & 0xff; + return (_clampByteI(r + (255 - r) * t) << 16) | (_clampByteI(g + (255 - g) * t) << 8) | _clampByteI(b + (255 - b) * t); +} + +// ── String-count resolution ─────────────────────────────────────────────────── + +/** + * Resolve the string count for the active arrangement. Prefer + * bundle.stringCount (exposed by feedBack core since #93 — derived from + * notes/chords/tuning, works for 5-string bass, 7- and 8-string guitar). + * Falls back to arrangement-name detection for older feedBack cores. + * Clamped to MAX_RENDER_STRINGS so a malformed bundle doesn't index past + * the per-string material arrays. + */ +export function resolveStringCount(bundle) { + const sc = bundle && bundle.stringCount; + if (Number.isFinite(sc) && sc >= 1) { + return Math.min(Math.trunc(sc), MAX_RENDER_STRINGS); + } + return /bass/i.test(bundle?.songInfo?.arrangement || '') ? 4 : NSTR; +} + +// ── Tuning / pitch-label helpers ────────────────────────────────────────────── + +/** Chart-format tuning entries are semitone offsets from instrument standard. */ +export const _NOTE_NAMES_SHARP = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; + +// Open-string MIDI (thick → thin), matched to RS string index 0 low. +export const _BASE_OPEN_MIDI_BASS4 = Object.freeze([28, 33, 38, 43]); +export const _BASE_OPEN_MIDI_BASS5 = Object.freeze([23, 28, 33, 38, 43]); +export const _BASE_OPEN_MIDI_GUITAR6 = Object.freeze([40, 45, 50, 55, 59, 64]); +export const _BASE_OPEN_MIDI_GUITAR7 = Object.freeze([35, 40, 45, 50, 55, 59, 64]); +// F#/B/E standard extension — low string is a fifth below RS 7-string low B. +export const _BASE_OPEN_MIDI_GUITAR8 = Object.freeze([28, 35, 40, 45, 50, 55, 59, 64]); + +export function _baseOpenStringMidis(sc, arrangement) { + const isBass = /bass/i.test(arrangement || ''); + if (sc === 4 && isBass) return _BASE_OPEN_MIDI_BASS4.slice(); + if (sc === 4) return _BASE_OPEN_MIDI_GUITAR6.slice(0, 4); + if (sc === 5 && isBass) return _BASE_OPEN_MIDI_BASS5.slice(); + if (sc === 5) return _BASE_OPEN_MIDI_GUITAR6.slice(0, 5); + if (sc === 7) return _BASE_OPEN_MIDI_GUITAR7.slice(); + if (sc === 8) return _BASE_OPEN_MIDI_GUITAR8.slice(); + if (Number.isFinite(sc) && sc > 8) { + const out = Array.from(_BASE_OPEN_MIDI_GUITAR8); + let last = out[out.length - 1]; + while (out.length < sc) { + last += 5; + out.push(last); + } + return out.slice(0, sc); + } + const g6 = _BASE_OPEN_MIDI_GUITAR6.slice(); + if (Number.isFinite(sc) && sc < 6 && sc >= 1) return g6.slice(0, sc); + return g6; +} + +export function _midiToPitchLabel(midi) { + const m = Math.round(midi); + const octave = Math.floor(m / 12) - 1; + const n = _NOTE_NAMES_SHARP[(m % 12 + 12) % 12]; + return n + octave; +} + +/** + * @param {object} bundle Highway render bundle (tuning, capo, stringCount) + * @param {object} songInfo WS song_info blob (arrangement, tuning, capo) + * @param {number} nEffective String count clamped like nStr / resolveStringCount + */ +export function _openStringPitchLabelsForTuning(bundle, songInfo, nEffective) { + const n = Number.isFinite(nEffective) ? Math.min(Math.max(1, Math.trunc(nEffective)), MAX_RENDER_STRINGS) : resolveStringCount(bundle); + // bundle first: chart-transform substitutes tuning/capo there, while + // songInfo keeps the chart's originals by contract. A malformed + // (non-array) bundle.tuning falls back to songInfo instead of + // blanking the labels. + let tuning = Array.isArray(bundle.tuning) ? bundle.tuning : (songInfo && songInfo.tuning); + let cap = bundle.capo; + cap = Number.isFinite(cap) ? cap : (songInfo && Number.isFinite(songInfo.capo) ? songInfo.capo : 0); + if (!Array.isArray(tuning)) tuning = []; + + const base = _baseOpenStringMidis(n, songInfo?.arrangement); + const labels = []; + for (let s = 0; s < n; s++) { + const offRaw = tuning[s]; + const off = Number.isFinite(offRaw) ? offRaw : 0; + const midi = (base[s] !== undefined ? base[s] : 40) + off + cap; + labels.push(_midiToPitchLabel(midi)); + } + return labels; +} + +// ── Splitscreen predicates ──────────────────────────────────────────────────── +// window.feedBackSplitscreen is read live each call — never captured at +// module or factory scope. + +export function _ssActive() { + const ss = window.feedBackSplitscreen; + if (!ss || typeof ss.isActive !== 'function' || !ss.isActive()) return false; + return typeof ss.isCanvasFocused === 'function' + && typeof ss.onFocusChange === 'function' + && typeof ss.offFocusChange === 'function'; +} + +export function _ssIsCanvasFocused(highwayCanvas) { + const ss = window.feedBackSplitscreen; + if (!_ssActive()) return true; + return !!(ss && typeof ss.isCanvasFocused === 'function' && + ss.isCanvasFocused(highwayCanvas)); +} diff --git a/tests/js/highway_3d_panel_controls.test.js b/tests/js/highway_3d_panel_controls.test.js index 9cc90eb..094111b 100644 --- a/tests/js/highway_3d_panel_controls.test.js +++ b/tests/js/highway_3d_panel_controls.test.js @@ -75,6 +75,23 @@ function loadHighway3dStatics() { // h3d-carve-2: T and loadThree moved to src/three-loader.js. T: null, loadThree: () => Promise.resolve(), + // h3d-carve-3: color/tuning/splitscreen utils moved to src/utils.js. + _h3dHexToInt: () => null, + _clampByteI: n => n, + _darkenInt: (hex) => hex, + _lightenInt: (hex) => hex, + resolveStringCount: () => 6, + _NOTE_NAMES_SHARP: ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'], + _BASE_OPEN_MIDI_BASS4: Object.freeze([28, 33, 38, 43]), + _BASE_OPEN_MIDI_BASS5: Object.freeze([23, 28, 33, 38, 43]), + _BASE_OPEN_MIDI_GUITAR6: Object.freeze([40, 45, 50, 55, 59, 64]), + _BASE_OPEN_MIDI_GUITAR7: Object.freeze([35, 40, 45, 50, 55, 59, 64]), + _BASE_OPEN_MIDI_GUITAR8: Object.freeze([28, 35, 40, 45, 50, 55, 59, 64]), + _baseOpenStringMidis: () => [40, 45, 50, 55, 59, 64], + _midiToPitchLabel: () => 'A4', + _openStringPitchLabelsForTuning: () => [], + _ssActive: () => false, + _ssIsCanvasFocused: () => true, }; vm.createContext(sandbox); vm.runInContext(instrumented, sandbox, { filename: SCREEN_JS }); diff --git a/tests/js/highway_3d_utils.test.js b/tests/js/highway_3d_utils.test.js new file mode 100644 index 0000000..51bbc0d --- /dev/null +++ b/tests/js/highway_3d_utils.test.js @@ -0,0 +1,209 @@ +// Class-killer tests for src/utils.js — h3d-carve-3. +// +// Pure functions are evaluated by stripping 'export' keywords and wrapping +// the source in a new Function so the whole module runs in a controlled +// scope. _ssActive and _ssIsCanvasFocused use `window` (live global), so +// they are covered by source-scan only. Screen.js wiring is verified by +// scanning the import declaration. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const UTILS_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'utils.js'); +const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js'); + +let _utils; +function utils() { if (!_utils) _utils = fs.readFileSync(UTILS_JS, 'utf8'); return _utils; } + +// Evaluate all pure exports in a CommonJS-compatible scope. +// Strips 'export' keywords; _ssActive/_ssIsCanvasFocused access window +// which is not available here — skip them in the pure eval. +let _fns; +function fns() { + if (_fns) return _fns; + const src = utils().replace(/^export\s+/gm, ''); + // provide a minimal window stub so _ssActive / _ssIsCanvasFocused don't + // throw at declaration time (they only READ window inside their bodies). + const factory = new Function('window', src + ` +return { + _h3dHexToInt, _clampByteI, _darkenInt, _lightenInt, + resolveStringCount, + _NOTE_NAMES_SHARP, + _BASE_OPEN_MIDI_BASS4, _BASE_OPEN_MIDI_BASS5, + _BASE_OPEN_MIDI_GUITAR6, _BASE_OPEN_MIDI_GUITAR7, _BASE_OPEN_MIDI_GUITAR8, + _baseOpenStringMidis, _midiToPitchLabel, _openStringPitchLabelsForTuning, + _ssActive, _ssIsCanvasFocused, +};`); + _fns = factory({ feedBackSplitscreen: null }); + return _fns; +} + +// ── _h3dHexToInt ────────────────────────────────────────────────────────────── + +test('_h3dHexToInt: 6-char hex parses to integer', () => { + // Mutation: remove parseInt → returns NaN; renderer sees non-numeric color. + assert.strictEqual(fns()._h3dHexToInt('#ff0000'), 0xff0000); + assert.strictEqual(fns()._h3dHexToInt('00ff00'), 0x00ff00); +}); + +test('_h3dHexToInt: 3-char shorthand expands to 6', () => { + // Mutation: remove the t[0]+t[0] expansion → 'fff' parses as 0x0fff (wrong). + assert.strictEqual(fns()._h3dHexToInt('#fff'), 0xffffff, + '#fff must expand to #ffffff, not 0x0fff'); + assert.strictEqual(fns()._h3dHexToInt('abc'), 0xaabbcc); +}); + +test('_h3dHexToInt: invalid input returns null', () => { + // Mutation: remove the regex guard → parseInt('gg0000', 16) returns NaN, not null. + assert.strictEqual(fns()._h3dHexToInt('gg0000'), null); + assert.strictEqual(fns()._h3dHexToInt(null), null); + assert.strictEqual(fns()._h3dHexToInt(42), null); +}); + +// ── _clampByteI ─────────────────────────────────────────────────────────────── + +test('_clampByteI: clamps below 0', () => { + // Mutation: remove < 0 guard → returns negative value; bitshift corrupts high channels. + assert.strictEqual(fns()._clampByteI(-1), 0); + assert.strictEqual(fns()._clampByteI(-999), 0); +}); + +test('_clampByteI: clamps above 255 and rounds', () => { + // Mutation: remove > 255 guard or Math.round → oversaturated channels / float bits. + assert.strictEqual(fns()._clampByteI(256), 255); + assert.strictEqual(fns()._clampByteI(127.7), 128, + 'must round 127.7 to 128, not truncate to 127'); +}); + +// ── _darkenInt / _lightenInt ────────────────────────────────────────────────── + +test('_darkenInt: halves each channel of pure white', () => { + // Mutation: remove _clampByteI call → channel value not clamped; bitshift carries. + const result = fns()._darkenInt(0xffffff, 0.5); + const r = (result >> 16) & 0xff, g = (result >> 8) & 0xff, b = result & 0xff; + assert.strictEqual(r, 128, 'red channel must be Math.round(255*0.5)=128'); + assert.strictEqual(g, 128); + assert.strictEqual(b, 128); +}); + +test('_lightenInt: mixing pure black toward white by 1.0 yields white', () => { + // Mutation: swap r+(255-r)*t → r*(1-t) → wrong formula for lightening. + assert.strictEqual(fns()._lightenInt(0x000000, 1.0), 0xffffff, + 'black mixed t=1 toward white must equal 0xffffff'); +}); + +// ── resolveStringCount ──────────────────────────────────────────────────────── + +test('resolveStringCount: uses bundle.stringCount and clamps to 6', () => { + // Mutation: remove Math.min → returns 8 for a chart that declares 8 strings; + // _NOTE_NAMES_SHARP lookup and per-string material arrays index OOB. + assert.strictEqual(fns().resolveStringCount({ stringCount: 8 }), 6, + 'stringCount=8 exceeds MAX_RENDER_STRINGS=6; must clamp to 6'); + assert.strictEqual(fns().resolveStringCount({ stringCount: 4 }), 4); +}); + +test('resolveStringCount: falls back to 4 for bass arrangement', () => { + // Mutation: remove /bass/i test → bass charts get 6 strings; 5th/6th string + // material slots are undefined and T.WebGLRenderer calls throw. + assert.strictEqual( + fns().resolveStringCount({ songInfo: { arrangement: 'Bass' } }), + 4, + 'arrangement containing "Bass" must fall back to 4 strings'); +}); + +test('resolveStringCount: defaults to NSTR=6 when bundle has no string info', () => { + assert.strictEqual(fns().resolveStringCount({}), 6); +}); + +// ── _NOTE_NAMES_SHARP ───────────────────────────────────────────────────────── + +test('_NOTE_NAMES_SHARP: 12 entries, correct spot values', () => { + // Mutation: remove 'F#' → midiToPitchLabel returns 'G' for F# notes; tuner wrong. + const n = fns()._NOTE_NAMES_SHARP; + assert.strictEqual(n.length, 12, 'chromatic octave must have 12 entries'); + assert.strictEqual(n[0], 'C'); + assert.strictEqual(n[6], 'F#', 'index 6 must be F#'); + assert.strictEqual(n[11], 'B'); +}); + +// ── _baseOpenStringMidis ────────────────────────────────────────────────────── + +test('_baseOpenStringMidis: 4-string bass returns standard bass4 tuning', () => { + // Mutation: remove sc===4 && isBass branch → returns guitar4 slice instead. + const result = fns()._baseOpenStringMidis(4, 'Bass'); + assert.deepStrictEqual(result, [28, 33, 38, 43], + '4-string bass must use standard E-A-D-G bass open-string MIDIs'); +}); + +test('_baseOpenStringMidis: 6-string default returns guitar6 tuning', () => { + const result = fns()._baseOpenStringMidis(6, 'Lead'); + assert.deepStrictEqual(result, [40, 45, 50, 55, 59, 64]); +}); + +// ── _midiToPitchLabel ───────────────────────────────────────────────────────── + +test('_midiToPitchLabel: MIDI 60 = C4, MIDI 69 = A4', () => { + // Mutation: remove "- 1" from octave calc → C4 becomes C5. + assert.strictEqual(fns()._midiToPitchLabel(60), 'C4', + 'MIDI 60 is middle C (C4); the "- 1" octave offset is required'); + assert.strictEqual(fns()._midiToPitchLabel(69), 'A4', + 'MIDI 69 is concert A (A4)'); +}); + +// ── _openStringPitchLabelsForTuning ────────────────────────────────────────── + +test('_openStringPitchLabelsForTuning: standard guitar in E returns correct labels', () => { + // Smoke: 6 zero-offset strings with guitar6 MIDI base. + const labels = fns()._openStringPitchLabelsForTuning( + { tuning: [0, 0, 0, 0, 0, 0], capo: 0, stringCount: 6 }, + { arrangement: 'Lead' }, + 6, + ); + assert.deepStrictEqual(labels, ['E2', 'A2', 'D3', 'G3', 'B3', 'E4'], + 'standard guitar open-string labels must be E2-A2-D3-G3-B3-E4'); +}); + +// ── _ssActive / _ssIsCanvasFocused — source-scan ───────────────────────────── + +test('_ssActive reads window.feedBackSplitscreen live', () => { + // Mutation: capture window.feedBackSplitscreen at module scope → old reference + // used after splitscreen enables mid-session; ss.isActive() never true. + assert.match(utils(), /window\.feedBackSplitscreen/, + '_ssActive must read window.feedBackSplitscreen without caching it'); +}); + +test('_ssIsCanvasFocused calls _ssActive', () => { + // Mutation: inline _ssActive logic → test becomes two separate paths to maintain; + // one diverges silently. + assert.match(utils(), /_ssIsCanvasFocused[\s\S]{1,200}_ssActive\(\)/, + '_ssIsCanvasFocused must delegate to _ssActive()'); +}); + +// ── screen.js wiring ────────────────────────────────────────────────────────── + +test('screen.js imports all Cut 3 utils from src/utils.js', () => { + const src = fs.readFileSync(SCREEN_JS, 'utf8'); + assert.match(src, + /import\s+\{[^}]*_ssActive[^}]*\}\s+from\s+['"]\.\/src\/utils\.js['"]/, + 'screen.js must import _ssActive (and other utils) from ./src/utils.js'); + assert.match(src, /resolveStringCount/, + 'resolveStringCount must appear in the utils.js import line'); + assert.match(src, /_h3dHexToInt/, + '_h3dHexToInt must appear in the utils.js import line'); +}); + +test('screen.js IIFE no longer declares the moved symbols', () => { + // Strip import lines first so we only scan the IIFE body. + const src = fs.readFileSync(SCREEN_JS, 'utf8'); + const iife = src.replace(/^import\s+.*\n/gm, ''); + assert.doesNotMatch(iife, /function\s+_h3dHexToInt\s*\(/, + 'IIFE must not redefine _h3dHexToInt'); + assert.doesNotMatch(iife, /function\s+_ssActive\s*\(/, + 'IIFE must not redefine _ssActive'); + assert.doesNotMatch(iife, /const\s+_NOTE_NAMES_SHARP\s*=/, + 'IIFE must not redefine _NOTE_NAMES_SHARP'); + assert.doesNotMatch(iife, /function\s+resolveStringCount\s*\(/, + 'IIFE must not redefine resolveStringCount'); +});