Files
feedBack/tests/js/highway_3d_bc_panel.test.js
T
byrongamatosandClaude Sonnet 4.6 f69c544eea fix(h3d-carve-4): export _bcLoadSettings + _bcFfIdx; add caller-coverage test (Creed HIGH)
screen.js H/P-section render path at lines 15396-15402 calls _bcLoadSettings()
and _bcFfIdx() (3×) — both moved to bc-panel.js in cut 4 but omitted from the
export list.  Browser: first render() after bcCtrl creation → ReferenceError;
seek/loop fast-forward index also dead.  Suite was green because no test
executed the butterchurn render path.

Fix:
- export _bcLoadSettings and _bcFfIdx from bc-panel.js
- add both to the tagged import in screen.js

Class-killer (test 12 — generic, not instance-specific):
  Extracts all exports from bc-panel.js, all imports in screen.js's
  bc-panel.js import clause, then asserts no exported symbol appears as a
  bare reference in the screen.js IIFE body without being imported.
  Generic: adding a new export + new caller without updating the import → RED.

Own grep (audit):
  grep (non-comment lines, all private _bc* names from bc-panel.js):
    _bcLoadSettings  1 hit  (line 15396)
    _bcFfIdx         3 hits (lines 15400-15402)
    all others: 0 hits
  Only the two symbols Creed found.

Mutation-verify:
  remove _bcLoadSettings from screen.js import →
    node --test tests/js/highway_3d_bc_panel.test.js
    tests 12   pass 11   fail 1   (test 12 RED)  ✓
  restore →
    node --test tests/js/highway_3d*.test.js plugins/highway_3d/tests/*.test.js
    tests 222   pass 222   fail 0               ✓ GREEN

Plan §8 amended: exports 2→4 with dated note.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 09:03:10 +02:00

240 lines
13 KiB
JavaScript

// Class-killer tests for src/bc-panel.js — h3d-carve-4.
//
// Most tests are source-scan (grep for structural invariants that protect
// against specific mutations). Two tests eval _bcIsDesktop in isolation
// (a pure function that only reads window.*) using new Function so Node
// can run it without a browser. Screen.js wiring is verified by scanning
// the import declaration and checking that moved symbols are gone from the
// IIFE.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const BC_PANEL_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'bc-panel.js');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
let _src;
function src() { if (!_src) _src = fs.readFileSync(BC_PANEL_JS, 'utf8'); return _src; }
let _screenSrc;
function screenSrc() { if (!_screenSrc) _screenSrc = fs.readFileSync(SCREEN_JS, 'utf8'); return _screenSrc; }
// ── Eval helper for _bcIsDesktop (pure; only reads window.*) ──────────────────
let _isDesktopFn;
function isDesktopFn() {
if (_isDesktopFn) return _isDesktopFn;
// Strip 'export' keywords; extract just _bcIsDesktop body for eval.
const stripped = src().replace(/^export\s+/gm, '');
// Wrap in a factory that returns _bcIsDesktop after evaluating the whole
// module (so inner references resolve). window is the only global needed.
const factory = new Function('window', stripped + '\nreturn _bcIsDesktop;');
_isDesktopFn = factory;
return _isDesktopFn;
}
// ── 1. _bcLoading reset on rejection ─────────────────────────────────────────
test('_bcLoading reset to null when lib-load promise rejects', () => {
// Mutation: remove `.catch(() => { _bcLoading = null; })` from _bcLoadLib.
// Without it, a rejected load-promise is cached forever; every subsequent
// mount returns the rejected promise immediately → Butterchurn permanently
// disabled for the session with no retry.
assert.match(src(), /_bcLoading\s*=\s*null/,
'_bcLoadLib must reset _bcLoading to null in a .catch handler so failed loads retry');
// Verify the reset appears in a .catch context (not just an early-return path).
assert.match(src(), /\.catch\s*\([\s\S]{1,60}_bcLoading\s*=\s*null/,
'_bcLoading = null must appear inside a .catch callback');
});
// ── 2. window.h3dBcApplySettings at module scope ──────────────────────────────
test('window.h3dBcApplySettings is assigned at module scope (not inside a function)', () => {
// Mutation: move assignment inside _bcCreateController → it isn't available
// until first mount; settings.html's `?.` call silently no-ops → settings
// changes (opacity, enabled, cycle mode) never apply until the user visits
// the player for the first time.
//
// Structural check: the assignment must appear BEFORE the first `function`
// or `export function` declaration in bc-panel.js (i.e. at module scope).
const s = src();
// Line-anchored regex: `^window.` matches only an unindented assignment.
// A comment mention or an indented assignment (inside a function body) both
// fail to match and return -1 — so this single check is sufficient.
const assignIdx = s.search(/^window\.h3dBcApplySettings\s*=/m);
assert.ok(assignIdx >= 0,
'window.h3dBcApplySettings must be assigned at line-start (module scope) in bc-panel.js — ' +
'an indented assignment (inside a function) would not match /^window\\./m');
});
// ── 3. _bcIsDesktop guards all three required conditions ──────────────────────
test('_bcIsDesktop checks isDesktop, .audio, and typeof getRawAudioFrame', () => {
// Mutation: remove any one guard → non-desktop host (Docker/web app) enters
// the desktop guitar-feed path → audioProvider is wrong; pcmLoop errors
// every 16 ms trying to call an undefined getRawAudioFrame.
const s = src();
assert.match(s, /d\.isDesktop/,
'_bcIsDesktop must guard on d.isDesktop');
assert.match(s, /d\.audio/,
'_bcIsDesktop must guard on d.audio');
assert.match(s, /typeof\s+d\.audio\.getRawAudioFrame\s*===\s*'function'/,
"_bcIsDesktop must guard typeof d.audio.getRawAudioFrame === 'function'");
});
// ── 4. _bcIsDesktop eval: returns false when window has no desktop bridge ─────
test('_bcIsDesktop returns false when window.feedBackDesktop is absent', () => {
// Mutation: remove the `d && ...` guard → accessing .isDesktop on undefined
// throws in the browser; the whole highway init crashes.
const fn = isDesktopFn()({ feedBackDesktop: undefined, slopsmithDesktop: undefined });
assert.strictEqual(fn(), false,
'_bcIsDesktop must return false when neither feedBackDesktop nor slopsmithDesktop is set');
});
// ── 5. _bcIsDesktop eval: returns false when isDesktop is missing from bridge ─
test('_bcIsDesktop returns false when bridge has audio but no isDesktop flag', () => {
// Mutation: rely on truthy bridge presence alone (drop isDesktop check) → any host
// that exposes feedBackDesktop for non-guitar purposes (e.g. file manager) would
// be misidentified as the guitar-input desktop host.
const fn = isDesktopFn()({
feedBackDesktop: { audio: { getRawAudioFrame: () => new Float32Array(512) } },
slopsmithDesktop: undefined,
});
assert.strictEqual(fn(), false,
'_bcIsDesktop must return false when feedBackDesktop lacks the isDesktop flag');
});
// ── 6. destroy() removes controller from _bcControllers ──────────────────────
test('destroy() calls _bcControllers.delete(ctrl)', () => {
// Mutation: remove delete call → dead controller stays in _bcControllers;
// _bcApplyAll iterates the Set and calls applySettings() on the dead controller;
// null canvas/scrim refs throw; multiple repeated mounts eventually saturate Set.
assert.match(src(), /_bcControllers\.delete\s*\(\s*ctrl\s*\)/,
'destroy() must call _bcControllers.delete(ctrl) to remove the dead controller');
});
// ── 7. _bcReleaseCanvasGL called in destroy() ─────────────────────────────────
test('_bcReleaseCanvasGL is called inside destroy()', () => {
// Mutation: remove the release call from destroy() → WebGL context not freed on
// dismount; browsers allow ~16 concurrent contexts; repeated mount/toggles
// exhaust the cap; subsequent mounts get null from getContext('webgl') →
// Butterchurn init silently fails.
//
// The call appears in two places: destroy() method and the .catch error handler.
// Both are required. This test checks that destroy() includes it.
const s = src();
// `destroy() {` (with space+brace) anchors the actual method definition,
// not comment references like "so destroy() closes only ...".
const destroyIdx = s.indexOf('destroy() {');
assert.ok(destroyIdx >= 0, 'destroy() method must exist in the returned controller object');
// Find the release call after the destroy label (within 1000 chars).
const destroyBlock = s.slice(destroyIdx, destroyIdx + 1000);
assert.match(destroyBlock, /_bcReleaseCanvasGL/,
'_bcReleaseCanvasGL must be called inside destroy() to free the WebGL context');
});
// ── 8. _bcReleaseCanvasGL called in async-init failure handler ────────────────
test('_bcReleaseCanvasGL is called in the _bcLoadLib().catch handler', () => {
// Mutation: remove from .catch → half-initialised failure (lib load, WebGL
// context creation) leaves a bound WebGL context on the abandoned canvas;
// the context is never freed; same cap exhaustion as above.
const s = src();
// The error handler's .catch takes a named error param (e) and logs via
// console.error — that's how we distinguish it from the narrow no-op catches.
// Look for `_bcReleaseCanvasGL` inside a `.catch((e) => {` block.
assert.match(s, /\.catch\s*\(\s*\(e\)[\s\S]{1,600}_bcReleaseCanvasGL/,
'_bcReleaseCanvasGL must appear in the error-handler .catch((e) => {}) block');
});
// ── 9. _bcLoadSettings merges saved state with BC_DEFAULTS ───────────────────
test('_bcLoadSettings uses Object.assign with BC_DEFAULTS as base', () => {
// Mutation: return raw parsed JSON without merging → missing keys from
// localStorage (fresh install, partial save) become undefined;
// s.enabled → undefined → bg disabled on first launch.
assert.match(src(), /Object\.assign\s*\(\s*\{\s*\}\s*,\s*BC_DEFAULTS/,
'_bcLoadSettings must merge with BC_DEFAULTS so missing keys get defaults');
});
// ── 10. screen.js imports both exports from src/bc-panel.js ──────────────────
test('screen.js imports _bcCreateController and _bcIsDesktop from src/bc-panel.js', () => {
// Mutation: remove import → H-section call to _bcCreateController throws
// ReferenceError at the first butterchurn mount; the 3D highway becomes
// permanently broken when butterchurn bg is selected.
const s = screenSrc();
assert.match(s,
/import\s+\{[^}]*_bcCreateController[^}]*\}\s+from\s+['"]\.\/src\/bc-panel\.js['"]/,
'screen.js must import _bcCreateController from ./src/bc-panel.js');
assert.match(s,
/import\s+\{[^}]*_bcIsDesktop[^}]*\}\s+from\s+['"]\.\/src\/bc-panel\.js['"]/,
'screen.js must import _bcIsDesktop from ./src/bc-panel.js');
});
// ── 12. screen.js has no bare caller references to private bc-panel.js symbols ─
test('every bc-panel.js export referenced in screen.js IIFE is in the import statement', () => {
// Mutation: remove _bcLoadSettings (or any other export) from the screen.js import
// → symbol is exported by bc-panel.js, referenced in the IIFE, but not bound via
// import → ReferenceError at runtime. This is the class Creed HIGH found on
// cut 4 (screen.js:15396-15402: _bcLoadSettings + _bcFfIdx called but not imported).
//
// Method (generalised):
// exported = all _bc* symbols with `export` keyword in bc-panel.js
// imported = all symbols in screen.js's `from './src/bc-panel.js'` import clause
// leaked = exported symbols that appear as bare refs in screen.js IIFE
// but are NOT in imported
// Adding a new export and a new caller without updating the import → leaked is
// non-empty → test RED.
const bcSrc = src();
const scrSrc = screenSrc();
// All exported _bc* symbols from bc-panel.js.
const exported = new Set(
[...bcSrc.matchAll(/^export\s+(?:const|let|var|function)\s+(_bc\w+)/mg)].map(m => m[1])
);
// Symbols actually imported from bc-panel.js in screen.js.
const importMatch = scrSrc.match(/import\s+\{([^}]+)\}\s+from\s+['"]\.\/src\/bc-panel\.js['"]/);
const imported = new Set(
importMatch ? importMatch[1].split(',').map(s => s.trim()).filter(Boolean) : []
);
// IIFE body: strip import lines and line comments to avoid false positives.
const noImports = scrSrc.replace(/^import\s+.*\n/gm, '');
const noComments = noImports.replace(/\/\/[^\n]*/g, '');
// Exported symbols referenced in the IIFE body but absent from the import list.
const leaked = [...exported].filter(
sym => new RegExp('\\b' + sym + '\\b').test(noComments) && !imported.has(sym)
);
assert.deepStrictEqual(leaked, [],
'screen.js references bc-panel.js exports that are not in its import clause: ' +
leaked.join(', ') +
' — add them to the import { … } from \'./src/bc-panel.js\' line in screen.js');
});
// ── 11. screen.js IIFE no longer defines moved B-section symbols ──────────────
test('screen.js IIFE does not redeclare _bcCreateController or _bcLoadLib', () => {
// Mutation: re-add function _bcCreateController() to the IIFE → double definition;
// the IIFE-scope function shadows the imported one inside the factory; bc-panel.js
// private state is split: the IIFE copy has its own _bcControllers, _bcSettings, etc.
// The panel never shows, controller objects leak, applySettings no-ops.
const s = screenSrc();
// Strip import lines so we only scan the IIFE body.
const iife = s.replace(/^import\s+.*\n/gm, '');
assert.doesNotMatch(iife, /function\s+_bcCreateController\s*\(/,
'IIFE must not redeclare _bcCreateController');
assert.doesNotMatch(iife, /function\s+_bcLoadLib\s*\(/,
'IIFE must not redeclare _bcLoadLib');
});