refactor(h3d): extract color/tuning/splitscreen utils to src/utils.js (h3d-carve-3)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
This commit is contained in:
byrongamatos
2026-09-05 07:52:41 +02:00
co-authored by Claude Sonnet 4.6
parent f75e91088f
commit c7f7c88c62
5 changed files with 377 additions and 118 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "highway_3d",
"name": "3D Highway",
"version": "3.38.0",
"version": "3.39.0",
"type": "visualization",
"scriptType": "module",
"bundled": true,
+6 -117
View File
@@ -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 7string 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<string, unknown>} 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
+144
View File
@@ -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));
}
@@ -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 });
+209
View File
@@ -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');
});