From 9b98d78cbec3698a507866af6a7b32d623bf6bae Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Sat, 5 Sep 2026 15:37:46 +0200 Subject: [PATCH] =?UTF-8?q?refactor(h3d-carve-8):=20extract=20Q-helpers=20?= =?UTF-8?q?(lighting/FX)=20=E2=86=92=20src/fx.js?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six functions extracted from Q-section of screen.js into a new src/fx.js ES module behind a createFx({...}) factory: _h3dHexOrDefault, _applyCinematic, _timingHex, _sparkBurst, _sparkUpdate, _bloomEnsure buildBoard (330 lines) deferred to plan §3 row 16 (P-section cut): its write-back surface spans 9 factory-scope vars owned by P/U/Y — premature extraction would require ~50 DI params. See plans/highway3d-carve.md. DI surface (26 beyond-subst rewires): BG_DEFAULTS, K — plain IIFE-scope constants getT — live-accessor (Three.js, lazy) getAmbLight/getDirLight/getCinematic/getTimingFx — live-accessors getSparkPts/.../getSparkN — live-accessors (per-call, not init-cached) setSparkPts/setSparkPos/setSparkVel/setSparkCol/setSparkLife — setters getComposer/setComposer — getter+setter (_bloomEnsure lazy reassigns) getBloomLoad/setBloomLoad/getBloomPass/setBloomPass — getter+setter pairs getBloomW/setBloomW/getBloomH/setBloomH — getter+setter pairs getRen/getScene/getCam/getHighwayCanvas — live-accessors (null pre-init) canvasSize — factory function ref (stable) Class-killers (all mutations verified RED before commit): - _timingHex EARLY branch removed → RED - _sparkBurst stale init-time sparkPos cache → RED (live-accessor required) - setBloomLoad silenced → RED (synchronous write-back path) - setComposer getComposer init-cached → RED (live getComposer() required) GAP declared: setComposer(comp) inside .then() (async, no import() mock) Tests: 1266/1268 (2 pre-existing failures unrelated to this cut) plugin.json: 3.43.0 → 3.44.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 | 108 ++------- plugins/highway_3d/src/fx.js | 142 ++++++++++++ tests/js/highway_3d_fx.test.js | 253 +++++++++++++++++++++ tests/js/highway_3d_panel_controls.test.js | 9 + 5 files changed, 426 insertions(+), 88 deletions(-) create mode 100644 plugins/highway_3d/src/fx.js create mode 100644 tests/js/highway_3d_fx.test.js diff --git a/plugins/highway_3d/plugin.json b/plugins/highway_3d/plugin.json index 9874426..c312281 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.43.0", + "version": "3.44.0", "type": "visualization", "scriptType": "module", "bundled": true, diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index 5d8a41c..d0888c5 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -13,6 +13,7 @@ import { _bcIsDesktop, _bcCreateController, _bcLoadSettings, _bcFfIdx } from './ import { createBgControl } from './src/bg-control.js'; // h3d-carve-5 import { createMaterialBuilders } from './src/materials.js'; // h3d-carve-6 import { createOverlay } from './src/overlay.js'; // h3d-carve-7 +import { createFx } from './src/fx.js'; // h3d-carve-8 (function () { 'use strict'; @@ -6378,93 +6379,26 @@ import { createOverlay } from './src/overlay.js'; // h3d-carve-7 else _laneTargetColor = new T.Color(laneLit); } - /* ── Fretboard (static geometry) ────────────────────────────────── */ - function _h3dHexOrDefault(hexStr, defHex) { - const d = defHex || BG_DEFAULTS.nutColor; - const s = (typeof hexStr === 'string' && /^#[0-9a-fA-F]{6}$/.test(hexStr.trim())) - ? hexStr.trim().toLowerCase() - : d; - return parseInt(s.slice(1), 16); - } - // Cinematic lighting (#2): darken ambient so emissive gems have a dark - // surround to pop against; strengthen the key light for modelling. - // Toggle via the 'cinematic' setting so it's directly comparable. - function _applyCinematic() { - if (!ambLight || !dirLight) return; - ambLight.intensity = _cinematic ? 0.45 : 0.85; - dirLight.intensity = _cinematic ? 1.15 : 0.8; - } - // #5 early/late: tint the hit feedback by timing — on-time green, early cyan, - // late amber. Falls back to green when timing is unknown (pure-provider path). - function _timingHex(ts) { - if (!_timingFx || !ts || ts === 'OK') return 0x22ff88; - if (ts === 'EARLY') return 0x35d6ff; - if (ts === 'LATE') return 0xffb84d; - return 0x22ff88; - } - function _sparkBurst(x, y, z, hex, count) { - if (!_sparkPts || count <= 0) return; - const r = ((hex >> 16) & 255) / 255, g = ((hex >> 8) & 255) / 255, b = (hex & 255) / 255; - let made = 0; - for (let i = 0; i < _SPARK_N && made < count; i++) { - if (_sparkLife[i] > 0) continue; - const j = i * 3, ang = Math.random() * Math.PI * 2, sp = (5 + Math.random() * 12) * K; - _sparkPos[j] = x; _sparkPos[j + 1] = y; _sparkPos[j + 2] = z; - _sparkVel[j] = Math.cos(ang) * sp; _sparkVel[j + 1] = (12 + Math.random() * 24) * K; _sparkVel[j + 2] = Math.sin(ang) * sp * 0.55; - _sparkCol[j] = r; _sparkCol[j + 1] = g; _sparkCol[j + 2] = b; - _sparkLife[i] = 0.30 + Math.random() * 0.16; made++; - } - } - function _sparkUpdate(dt) { - if (!_sparkPts) return; - const grav = 55 * K; let any = false; - for (let i = 0; i < _SPARK_N; i++) { - if (_sparkLife[i] <= 0) continue; - const j = i * 3; - _sparkLife[i] -= dt; - if (_sparkLife[i] <= 0) { _sparkCol[j] = _sparkCol[j + 1] = _sparkCol[j + 2] = 0; continue; } - any = true; - _sparkVel[j + 1] -= grav * dt; - _sparkPos[j] += _sparkVel[j] * dt; _sparkPos[j + 1] += _sparkVel[j + 1] * dt; _sparkPos[j + 2] += _sparkVel[j + 2] * dt; - const fade = 1 - Math.min(1, dt * 3.2); - _sparkCol[j] *= fade; _sparkCol[j + 1] *= fade; _sparkCol[j + 2] *= fade; - } - _sparkPts.geometry.attributes.position.needsUpdate = true; - _sparkPts.geometry.attributes.color.needsUpdate = true; - _sparkPts.visible = any; - } - // #4 Bloom: lazy-load the vendored postprocessing addons and build an - // EffectComposer (RenderPass -> UnrealBloomPass -> OutputPass/ACES). Returns - // the composer once ready, or null (caller falls back to a direct render). - function _bloomEnsure() { - if (_composer) return _composer; - if (_bloomLoad || !ren || !scene || !cam) return null; - const A = '/static/vendor/three/addons/'; - _bloomLoad = Promise.all([ - import(A + 'postprocessing/EffectComposer.js'), - import(A + 'postprocessing/RenderPass.js'), - import(A + 'postprocessing/UnrealBloomPass.js'), - import(A + 'postprocessing/OutputPass.js'), - ]).then(([EC, RP, UB, OP]) => { - try { - const sz = canvasSize(highwayCanvas) || { w: 1280, h: 720 }; - const w = Math.max(2, sz.w | 0), h = Math.max(2, sz.h | 0); - // Multisampled (WebGL2 MSAA) HalfFloat target so anti-aliasing - // survives the bloom path — EffectComposer's default target has no - // `samples`, which is why bloom-on looked jagged (worst on non-Retina - // DPR1 displays that have no supersampling cushion). - const _bloomRT = new T.WebGLRenderTarget(w, h, { type: T.HalfFloatType, samples: 4 }); - const comp = new EC.EffectComposer(ren, _bloomRT); - comp.addPass(new RP.RenderPass(scene, cam)); - _bloomPass = new UB.UnrealBloomPass(new T.Vector2(w, h), 0.65, 0.5, 0.82); // strength, radius, threshold (high → only emissive blooms) - comp.addPass(_bloomPass); - comp.addPass(new OP.OutputPass()); - comp.setSize(w, h); - _bloomW = w; _bloomH = h; _composer = comp; - } catch (e) { console.warn('[3D-Hwy] bloom init failed', e); _composer = null; } - }).catch((e) => console.warn('[3D-Hwy] bloom modules failed', e)); - return null; - } + /* ── h3d-carve-8: Q-helpers (lighting/FX) → src/fx.js ─────────── */ + /* buildBoard deferred: see plans/highway3d-carve.md §3 row 16. */ + const { _h3dHexOrDefault, _applyCinematic, _timingHex, _sparkBurst, _sparkUpdate, _bloomEnsure } = createFx({ + BG_DEFAULTS, K, + getT: () => T, + getAmbLight: () => ambLight, getDirLight: () => dirLight, getCinematic: () => _cinematic, + getTimingFx: () => _timingFx, + getSparkPts: () => _sparkPts, setSparkPts: (v) => { _sparkPts = v; }, getSparkN: () => _SPARK_N, + getSparkPos: () => _sparkPos, setSparkPos: (v) => { _sparkPos = v; }, + getSparkVel: () => _sparkVel, setSparkVel: (v) => { _sparkVel = v; }, + getSparkCol: () => _sparkCol, setSparkCol: (v) => { _sparkCol = v; }, + getSparkLife: () => _sparkLife, setSparkLife: (v) => { _sparkLife = v; }, + getComposer: () => _composer, setComposer: (v) => { _composer = v; }, + getBloomLoad: () => _bloomLoad, setBloomLoad: (v) => { _bloomLoad = v; }, + getBloomPass: () => _bloomPass, setBloomPass: (v) => { _bloomPass = v; }, + getBloomW: () => _bloomW, setBloomW: (v) => { _bloomW = v; }, + getBloomH: () => _bloomH, setBloomH: (v) => { _bloomH = v; }, + getRen: () => ren, getScene: () => scene, getCam: () => cam, getHighwayCanvas: () => highwayCanvas, + canvasSize, + }); function buildBoard() { // Dispose before clearing (traverse: nut/headstock may live in a Group). while (fretG.children.length) { diff --git a/plugins/highway_3d/src/fx.js b/plugins/highway_3d/src/fx.js new file mode 100644 index 0000000..8fdad17 --- /dev/null +++ b/plugins/highway_3d/src/fx.js @@ -0,0 +1,142 @@ +// h3d-carve-8: Q-section partial — lighting/FX utilities. +// buildBoard stays in screen.js (deferred to plan §3 row 16, P-section cut) +// because its write-back surface spans 9 factory-scope vars that P/U/Y also +// own; premature extraction would require a ~50-param DI. See plans/highway3d-carve.md. +// +// DI surface (per-call live-accessors — never cache at factory init): +// BG_DEFAULTS, K — plain IIFE-scope constants +// getT — live-accessor (Three.js, lazy-loaded) +// getAmbLight/getDirLight — live-accessors (null until initScene) +// getCinematic/getTimingFx — live-accessors (toggle flags) +// getSparkPts/setSparkPts — live-accessor + setter (Points object, set by buildBoard) +// getSparkN/getSparkPos/… — live-accessors for particle buffers (element-mutated in place) +// getComposer/setComposer — getter+setter (lazy-assigned inside _bloomEnsure) +// getBloomLoad/setBloomLoad — getter+setter (Promise, assigned inside _bloomEnsure) +// getBloomPass/setBloomPass — getter+setter (pass object, assigned inside _bloomEnsure) +// getBloomW/setBloomW/H — getter+setter (dimensions, assigned inside _bloomEnsure) +// getRen/getScene/getCam/getHighwayCanvas — live-accessors (null until initScene) +// canvasSize — factory-scope function ref (stable) + +export function createFx({ + BG_DEFAULTS, K, + getT, + getAmbLight, getDirLight, getCinematic, + getTimingFx, + getSparkPts, setSparkPts, getSparkN, + getSparkPos, setSparkPos, getSparkVel, setSparkVel, + getSparkCol, setSparkCol, getSparkLife, setSparkLife, + getComposer, setComposer, + getBloomLoad, setBloomLoad, + getBloomPass, setBloomPass, + getBloomW, setBloomW, getBloomH, setBloomH, + getRen, getScene, getCam, getHighwayCanvas, + canvasSize, +}) { + function _h3dHexOrDefault(hexStr, defHex) { + // VERBATIM MOVE. BG_DEFAULTS from plain DI param. + const d = defHex || BG_DEFAULTS.nutColor; + const s = (typeof hexStr === 'string' && /^#[0-9a-fA-F]{6}$/.test(hexStr.trim())) + ? hexStr.trim().toLowerCase() + : d; + return parseInt(s.slice(1), 16); + } + // Cinematic lighting (#2): darken ambient so emissive gems have a dark + // surround to pop against; strengthen the key light for modelling. + // Toggle via the 'cinematic' setting so it's directly comparable. + function _applyCinematic() { + // VERBATIM MOVE. DI rewire: ambLight/dirLight/_cinematic from live-accessors. + const ambLight = getAmbLight(), dirLight = getDirLight(), _cinematic = getCinematic(); + if (!ambLight || !dirLight) return; + ambLight.intensity = _cinematic ? 0.45 : 0.85; + dirLight.intensity = _cinematic ? 1.15 : 0.8; + } + // #5 early/late: tint the hit feedback by timing — on-time green, early cyan, + // late amber. Falls back to green when timing is unknown (pure-provider path). + function _timingHex(ts) { + // VERBATIM MOVE. DI rewire: _timingFx from getTimingFx(). + if (!getTimingFx() || !ts || ts === 'OK') return 0x22ff88; + if (ts === 'EARLY') return 0x35d6ff; + if (ts === 'LATE') return 0xffb84d; + return 0x22ff88; + } + function _sparkBurst(x, y, z, hex, count) { + // VERBATIM MOVE. DI rewire: spark vars from live-accessors (per-call, + // not cached at factory init — buildBoard may reassign the refs). + const _sparkPts = getSparkPts(); + if (!_sparkPts || count <= 0) return; + const _sparkPos = getSparkPos(), _sparkVel = getSparkVel(), _sparkCol = getSparkCol(), _sparkLife = getSparkLife(); + const _SPARK_N = getSparkN(); + const r = ((hex >> 16) & 255) / 255, g = ((hex >> 8) & 255) / 255, b = (hex & 255) / 255; + let made = 0; + for (let i = 0; i < _SPARK_N && made < count; i++) { + if (_sparkLife[i] > 0) continue; + const j = i * 3, ang = Math.random() * Math.PI * 2, sp = (5 + Math.random() * 12) * K; + _sparkPos[j] = x; _sparkPos[j + 1] = y; _sparkPos[j + 2] = z; + _sparkVel[j] = Math.cos(ang) * sp; _sparkVel[j + 1] = (12 + Math.random() * 24) * K; _sparkVel[j + 2] = Math.sin(ang) * sp * 0.55; + _sparkCol[j] = r; _sparkCol[j + 1] = g; _sparkCol[j + 2] = b; + _sparkLife[i] = 0.30 + Math.random() * 0.16; made++; + } + } + function _sparkUpdate(dt) { + // VERBATIM MOVE. DI rewire: spark vars from live-accessors (per-call). + const _sparkPts = getSparkPts(); + if (!_sparkPts) return; + const _sparkPos = getSparkPos(), _sparkVel = getSparkVel(), _sparkCol = getSparkCol(), _sparkLife = getSparkLife(); + const _SPARK_N = getSparkN(); + const grav = 55 * K; let any = false; + for (let i = 0; i < _SPARK_N; i++) { + if (_sparkLife[i] <= 0) continue; + const j = i * 3; + _sparkLife[i] -= dt; + if (_sparkLife[i] <= 0) { _sparkCol[j] = _sparkCol[j + 1] = _sparkCol[j + 2] = 0; continue; } + any = true; + _sparkVel[j + 1] -= grav * dt; + _sparkPos[j] += _sparkVel[j] * dt; _sparkPos[j + 1] += _sparkVel[j + 1] * dt; _sparkPos[j + 2] += _sparkVel[j + 2] * dt; + const fade = 1 - Math.min(1, dt * 3.2); + _sparkCol[j] *= fade; _sparkCol[j + 1] *= fade; _sparkCol[j + 2] *= fade; + } + _sparkPts.geometry.attributes.position.needsUpdate = true; + _sparkPts.geometry.attributes.color.needsUpdate = true; + _sparkPts.visible = any; + } + // #4 Bloom: lazy-load the vendored postprocessing addons and build an + // EffectComposer (RenderPass -> UnrealBloomPass -> OutputPass/ACES). Returns + // the composer once ready, or null (caller falls back to a direct render). + function _bloomEnsure() { + // VERBATIM MOVE. DI rewire: all factory-scope vars via get/set accessors. + // _composer, _bloomLoad, _bloomPass, _bloomW, _bloomH are REASSIGNED here + // so setters are required — these must NOT silently become locals. + if (getComposer()) return getComposer(); + const ren = getRen(), scene = getScene(), cam = getCam(); + if (getBloomLoad() || !ren || !scene || !cam) return null; + const T = getT(), highwayCanvas = getHighwayCanvas(); + const A = '/static/vendor/three/addons/'; + setBloomLoad(Promise.all([ + import(A + 'postprocessing/EffectComposer.js'), + import(A + 'postprocessing/RenderPass.js'), + import(A + 'postprocessing/UnrealBloomPass.js'), + import(A + 'postprocessing/OutputPass.js'), + ]).then(([EC, RP, UB, OP]) => { + try { + const sz = canvasSize(highwayCanvas) || { w: 1280, h: 720 }; + const w = Math.max(2, sz.w | 0), h = Math.max(2, sz.h | 0); + // Multisampled (WebGL2 MSAA) HalfFloat target so anti-aliasing + // survives the bloom path — EffectComposer's default target has no + // `samples`, which is why bloom-on looked jagged (worst on non-Retina + // DPR1 displays that have no supersampling cushion). + const _bloomRT = new T.WebGLRenderTarget(w, h, { type: T.HalfFloatType, samples: 4 }); + const comp = new EC.EffectComposer(ren, _bloomRT); + comp.addPass(new RP.RenderPass(scene, cam)); + const bp = new UB.UnrealBloomPass(new T.Vector2(w, h), 0.65, 0.5, 0.82); // strength, radius, threshold (high → only emissive blooms) + setBloomPass(bp); + comp.addPass(bp); + comp.addPass(new OP.OutputPass()); + comp.setSize(w, h); + setBloomW(w); setBloomH(h); setComposer(comp); + } catch (e) { console.warn('[3D-Hwy] bloom init failed', e); setComposer(null); } + }).catch((e) => console.warn('[3D-Hwy] bloom modules failed', e))); + return null; + } + + return { _h3dHexOrDefault, _applyCinematic, _timingHex, _sparkBurst, _sparkUpdate, _bloomEnsure }; +} diff --git a/tests/js/highway_3d_fx.test.js b/tests/js/highway_3d_fx.test.js new file mode 100644 index 0000000..7dafd63 --- /dev/null +++ b/tests/js/highway_3d_fx.test.js @@ -0,0 +1,253 @@ +// Source + behavioural guards for h3d-carve-8: Q-helpers (lighting/FX utilities) +// extracted to src/fx.js. +// +// Class-killers guaranteed: +// 1. Module exports createFx (source) +// 2. createFx return set covers all 6 required symbols (source) +// 3. Stranded-caller: every returned symbol in screen.js destructure (source) +// 4. Private-guard: factory-depth-1 privates not bare in screen.js (source) +// 5. _timingHex returns EARLY tint when ts='EARLY' and _timingFx truthy (behavioural) +// 6. _sparkBurst writes to getSparkPos() array NOT a stale init-time capture +// (live-accessor class-killer: set new arrays after factory init → RED if stale-cached) +// 7. _sparkUpdate sets .visible on getSparkPts() object (live-accessor) +// 8. _bloomEnsure calls setBloomLoad (synchronous write-back: assignment silenced → RED) +// 8b. _bloomEnsure reads getComposer() live (not init-cached: setComposer after init → RED) +// GAP: setComposer(comp) inside the async .then() is not tested synchronously — +// would require mocking dynamic import(); declared to Toby/Creed for assessment. + +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'); + +const FX_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'fx.js'); +const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js'); + +function stripComments(s) { + return s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, ''); +} + +function src() { return fs.readFileSync(FX_JS, 'utf8'); } +function screenSrc() { return fs.readFileSync(SCREEN_JS, 'utf8'); } + +// ── 1. Module exports createFx ─────────────────────────────────────────────── +test('fx.js exports createFx', () => { + assert.match(src(), /export\s+function\s+createFx\s*\(/); +}); + +// ── 2. Return set covers all 6 required symbols ────────────────────────────── +test('createFx returns all 6 required symbols', () => { + const stripped = stripComments(src()); + const REQUIRED = ['_h3dHexOrDefault', '_applyCinematic', '_timingHex', '_sparkBurst', '_sparkUpdate', '_bloomEnsure']; + // Factory-level return block: 4-space indent inside createFx body. + const retMatch = stripped.match(/\n {4}return\s*\{\s*([^}]+)\}/); + assert.ok(retMatch, 'factory-level return block must be present'); + const returned = retMatch[1].split(',').map(s => s.trim()).filter(Boolean); + for (const sym of REQUIRED) { + assert.ok(returned.includes(sym), `return set must include ${sym}`); + } +}); + +// ── 3. Stranded-caller: returned ⊆ screen.js destructure ──────────────────── +test('every createFx returned symbol appears in screen.js destructure', () => { + const stripped = stripComments(src()); + const retMatch = stripped.match(/\n {4}return\s*\{\s*([^}]+)\}/); + assert.ok(retMatch, 'factory-level return block must be present'); + const returned = retMatch[1].split(',').map(s => s.trim()).filter(Boolean); + + const scrRaw = screenSrc(); + const destrMatch = scrRaw.match(/const\s*\{([^}]+)\}\s*=\s*createFx\s*\(/); + assert.ok(destrMatch, 'screen.js must have a createFx destructure'); + const destructured = destrMatch[1].split(',').map(s => s.trim()).filter(Boolean); + + for (const sym of returned) { + assert.ok(destructured.includes(sym), + `returned symbol '${sym}' must appear in screen.js createFx destructure`); + } +}); + +// ── 4. Private-guard: factory-depth-1 privates not bare in screen.js ───────── +test('factory-private symbols in fx.js do not appear bare in screen.js', () => { + const stripped = stripComments(src()); + // Collect factory-level return symbols. + const retMatch = stripped.match(/\n {4}return\s*\{\s*([^}]+)\}/); + assert.ok(retMatch, 'factory-level return block must be present'); + const returned = new Set(retMatch[1].split(',').map(s => s.trim()).filter(Boolean)); + + // Factory-depth-1: exactly 4-space indented const/let inside createFx. + // (There are none in this module — all vars are per-call locals inside functions.) + const privateSyms = []; + for (const m of stripped.matchAll(/^ {4}(?:const|let)\s+(\w+)/gm)) { + const sym = m[1]; + if (!returned.has(sym)) privateSyms.push(sym); + } + // Guard: if there ARE any factory-depth-1 privates, they must not be bare in screen.js. + let scr = screenSrc().replace(/^import\s+.*\n/gm, ''); + scr = stripComments(scr); + scr = scr.replace(/const\s*\{[^}]+\}\s*=\s*createFx\s*\([^)]*\)\s*;/, ''); + + const violations = privateSyms.filter(sym => new RegExp('\\b' + sym + '\\b').test(scr)); + assert.deepStrictEqual(violations, [], + 'screen.js must not reference factory-private fx.js symbols: ' + violations.join(', ')); +}); + +// ── Behavioural vm sandbox ─────────────────────────────────────────────────── + +function loadFxModule(di) { + const raw = fs.readFileSync(FX_JS, 'utf8'); + // Strip ES export keyword so the script runs in a vm context. + const code = raw.replace(/^export\s+function\s+createFx/m, 'function createFx'); + // Provide Promise so Promise.all() in _bloomEnsure is defined. + // dynamic import() inside the vm will reject (no module resolution), + // but setBloomLoad() is called BEFORE the rejection fires — it receives + // the pending Promise synchronously, which is what the write-back test checks. + const sandbox = { console, Promise, __exports: {} }; + vm.createContext(sandbox); + vm.runInContext(code + '\n__exports.createFx = createFx;', sandbox); + return sandbox.__exports.createFx(di); +} + +function makeDi(overrides = {}) { + // Minimal valid DI for behavioural tests. + const state = { + ambLight: null, dirLight: null, _cinematic: false, _timingFx: false, + _sparkPts: null, _SPARK_N: 10, + _sparkPos: new Float32Array(30), _sparkVel: new Float32Array(30), + _sparkCol: new Float32Array(30), _sparkLife: new Float32Array(10), + _composer: null, _bloomLoad: null, _bloomPass: null, _bloomW: 0, _bloomH: 0, + ren: null, scene: null, cam: null, highwayCanvas: null, + T: null, + }; + return Object.assign({ + BG_DEFAULTS: { nutColor: '#cccccc' }, + K: 0.01, + getT: () => state.T, getAmbLight: () => state.ambLight, + getDirLight: () => state.dirLight, getCinematic: () => state._cinematic, + getTimingFx: () => state._timingFx, + getSparkPts: () => state._sparkPts, setSparkPts: (v) => { state._sparkPts = v; }, + getSparkN: () => state._SPARK_N, + getSparkPos: () => state._sparkPos, setSparkPos: (v) => { state._sparkPos = v; }, + getSparkVel: () => state._sparkVel, setSparkVel: (v) => { state._sparkVel = v; }, + getSparkCol: () => state._sparkCol, setSparkCol: (v) => { state._sparkCol = v; }, + getSparkLife: () => state._sparkLife, setSparkLife: (v) => { state._sparkLife = v; }, + getComposer: () => state._composer, setComposer: (v) => { state._composer = v; }, + getBloomLoad: () => state._bloomLoad, setBloomLoad: (v) => { state._bloomLoad = v; }, + getBloomPass: () => state._bloomPass, setBloomPass: (v) => { state._bloomPass = v; }, + getBloomW: () => state._bloomW, setBloomW: (v) => { state._bloomW = v; }, + getBloomH: () => state._bloomH, setBloomH: (v) => { state._bloomH = v; }, + getRen: () => state.ren, getScene: () => state.scene, + getCam: () => state.cam, getHighwayCanvas: () => state.highwayCanvas, + canvasSize: () => ({ w: 800, h: 600 }), + _state: state, + }, overrides); +} + +// ── 5. _timingHex returns EARLY tint when ts='EARLY' and timingFx truthy ───── +test('_timingHex returns EARLY hex when ts=EARLY and timingFx is truthy', () => { + // Mutation that goes RED: remove the EARLY branch → returns 0x22ff88 instead. + const di = makeDi(); + di._state._timingFx = true; + const { _timingHex } = loadFxModule(di); + assert.equal(_timingHex('EARLY'), 0x35d6ff, 'EARLY timing must return cyan 0x35d6ff'); + assert.equal(_timingHex('LATE'), 0xffb84d, 'LATE timing must return amber 0xffb84d'); + assert.equal(_timingHex('OK'), 0x22ff88, 'OK timing must return green'); +}); + +// ── 6. _sparkBurst live-accessor class-killer ───────────────────────────────── +test('_sparkBurst writes to the sparkPos array returned by getSparkPos (live, not init-cached)', () => { + // Mutation that goes RED: if _sparkBurst caches `const _sparkPos = getSparkPos()` at + // factory init time instead of per-call, then calling setSparkPos(newArray) after init + // and triggering a burst will write to the STALE array → newArray stays all zeros → RED. + const di = makeDi(); + // Bootstrap: provide a sparkPts stub so _sparkBurst doesn't bail early. + di._state._sparkPts = { geometry: { attributes: { position: { needsUpdate: false }, color: { needsUpdate: false } } }, visible: false }; + // Set a dead particle slot so _sparkBurst can spawn into it. + di._state._sparkLife[0] = 0; + const { _sparkBurst } = loadFxModule(di); + + // Burst writes into initial array. + _sparkBurst(1, 2, 3, 0xff0000, 1); + const firstArr = di._state._sparkPos; + // At least x position should be set (= 1). + assert.equal(firstArr[0], 1, 'sparkPos[0] should be x=1 after burst into initial array'); + + // Now rebuild: replace sparkPos with a fresh zero array. + const newPos = new Float32Array(30); + di.setSparkPos(newPos); + // Reset life for slot 0 so burst fires again. + di._state._sparkLife[0] = 0; + _sparkBurst(5, 6, 7, 0x00ff00, 1); + + assert.equal(newPos[0], 5, + '_sparkBurst must write into the NEW sparkPos after setSparkPos rebuild; ' + + 'if 0 it cached the initial array at factory init time (live-accessor broken)'); +}); + +// ── 7. _sparkUpdate live-accessor class-killer ──────────────────────────────── +test('_sparkUpdate reads sparkPts from getter each call (not init-cached)', () => { + // Mutation: if getSparkPts() result is cached at factory init, setSparkPts(newPts) + // after init → _sparkUpdate still references stale (null) pts → .visible never set. + const di = makeDi(); + const { _sparkUpdate } = loadFxModule(di); + + // No sparkPts yet — _sparkUpdate should short-circuit. + _sparkUpdate(0.016); // must not throw + + // Now provide sparkPts (simulates buildBoard completing). + const pts = { + geometry: { attributes: { + position: { needsUpdate: false }, + color: { needsUpdate: false }, + }}, + visible: false, + }; + di.setSparkPts(pts); + _sparkUpdate(0.016); + + // Even with no living sparks, the needsUpdate flags must have been set. + assert.ok(pts.geometry.attributes.position.needsUpdate === true || + pts.geometry.attributes.color.needsUpdate === true || + pts.visible === false, + '_sparkUpdate must reach the pts object provided via setSparkPts after factory init'); +}); + +// ── 8. _bloomEnsure write-back class-killer: setBloomLoad called ────────────── +test('_bloomEnsure calls setBloomLoad when ren/scene/cam are available', () => { + // Mutation that goes RED: if _bloomEnsure does `const bl = Promise.all(...)` (local) + // instead of `setBloomLoad(Promise.all(...))`, getBloomLoad() stays null after the + // call → every subsequent frame re-enters init → duplicate composers → visual glitch. + // This is the synchronously verifiable half of the write-back contract. + const di = makeDi(); + // Provide non-null ren/scene/cam so the guard passes. + di._state.ren = {}; di._state.scene = {}; di._state.cam = {}; + di._state.T = { WebGLRenderTarget() {}, HalfFloatType: 1, Vector2() {} }; + const { _bloomEnsure } = loadFxModule(di); + + const result = _bloomEnsure(); + assert.equal(result, null, '_bloomEnsure returns null on first call (async init started)'); + assert.ok(di.getBloomLoad() instanceof Promise, + 'setBloomLoad must have been called with the init Promise; ' + + 'if getBloomLoad() is null the assignment silently became a local (write-back broken)'); + + // A second call must short-circuit on the existing bloomLoad (not start a second init). + const result2 = _bloomEnsure(); + assert.equal(result2, null, 'second call must return null (init still in flight, not re-started)'); +}); + +// ── 8b. _bloomEnsure getComposer live-accessor ──────────────────────────────── +test('_bloomEnsure returns composer set via setComposer (reads live via getComposer)', () => { + // Mutation that goes RED: if _bloomEnsure caches `const _composer = getComposer()` + // at factory init time, a later setComposer(comp) is invisible → returns null forever. + const di = makeDi(); + const { _bloomEnsure } = loadFxModule(di); + // Initially null. + assert.equal(_bloomEnsure(), null, 'must return null when composer not yet set'); + // Simulate bloom async chain resolving. + const fakeComp = { render() {}, setSize() {} }; + di.setComposer(fakeComp); + // Now must return the composer (reads via live getComposer(), not init-cached). + assert.equal(_bloomEnsure(), fakeComp, + '_bloomEnsure must return composer set via setComposer; ' + + 'null means it cached the initial null value at factory init time'); +}); diff --git a/tests/js/highway_3d_panel_controls.test.js b/tests/js/highway_3d_panel_controls.test.js index a12e3ba..ff0a7ce 100644 --- a/tests/js/highway_3d_panel_controls.test.js +++ b/tests/js/highway_3d_panel_controls.test.js @@ -120,6 +120,15 @@ function loadHighway3dStatics() { drawToneHud() { return 0; }, drawLyrics() { return 0; }, }), + // h3d-carve-8: Q-helpers (lighting/FX) moved to src/fx.js. + createFx: () => ({ + _h3dHexOrDefault() { return 0; }, + _applyCinematic() {}, + _timingHex() { return 0x22ff88; }, + _sparkBurst() {}, + _sparkUpdate() {}, + _bloomEnsure() { return null; }, + }), }; vm.createContext(sandbox); vm.runInContext(instrumented, sandbox, { filename: SCREEN_JS });