Files
feedBack/tests/js/highway_3d_materials.test.js
T
byrongamatosandClaude Sonnet 4.6 e6b2f86068 refactor(h3d-carve-6): move N-section material builders to src/materials.js
Extract TXT_STYLES + 13 material builder functions (txtMat, pinchHarmonicMat,
naturalHarmonicMat, palmMuteXSpriteMat, fretHandMuteXSpriteMat, muteXMat,
triMat, bendChevronMat, darkenHex, slideArrowMat, _meshMatForGhostFretDigit,
_spriteMat2MeshMat) and pool() from screen.js N-section into:
  plugins/highway_3d/src/materials.js  (createMaterialBuilders factory)

screen.js drops ~600 lines (15302→14705).

Surprises vs plan §4:
  • DI is 4 params { getT, getTxtCache, techMatCache, techMeshMatClones } not 1
  • _syncOpenStringPitchLabels cluster excluded (20+ factory-scope deps)
  • _techMatCache stays in screen.js factory scope for teardown .values()/.clear()

Beyond-subst (4):
  1. Factory wrapper createMaterialBuilders({...})
  2. T → const T = getT() inside each function body (live accessor)
  3. txtCache[k] → const cache = getTxtCache(); cache[k]
  4. _techMatCache/_techMeshMatClones → DI param names techMatCache/techMeshMatClones

Tests: 16 new class-killer tests in highway_3d_materials.test.js;
pool warm tests retargeted to src/materials.js; panel_controls stub added.
Full suite: 1246/1248 pass (2 pre-existing: network + nut-labels).

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

339 lines
17 KiB
JavaScript

// Contract tests for h3d-carve-6: src/materials.js (createMaterialBuilders).
//
// Class-killer tests — each names the mutation that makes it RED.
// Source-scan + vm-sandbox pattern (no canvas, no WebGL lifecycle).
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 MATERIALS_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'materials.js');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
function src() { return fs.readFileSync(MATERIALS_JS, 'utf8'); }
function screenSrc() { return fs.readFileSync(SCREEN_JS, 'utf8'); }
// Strip block and line comments from JS source for identifier-presence checks.
function stripComments(s) {
return s
.replace(/\/\*[\s\S]*?\*\//g, '') // block comments
.replace(/\/\/[^\n]*/g, ''); // line comments
}
// ── vm sandbox helpers ──────────────────────────────────────────────────────
// Evaluate materials.js in a sandbox and return the createMaterialBuilders export.
// Three.js is stubbed; canvas ops are no-ops.
function loadFactory() {
const raw = src();
// materials.js uses `export function` — strip the `export` keyword for vm.
const code = raw.replace(/^export\s+/m, '');
const sandbox = {
document: {
createElement: () => ({
getContext: () => ({
font: '', textAlign: '', textBaseline: '',
clearRect() {}, beginPath() {}, moveTo() {}, lineTo() {},
closePath() {}, fill() {}, stroke() {}, fillText() {},
strokeText() {}, save() {}, restore() {}, translate() {},
ellipse() {}, arc() {}, createRadialGradient: () => ({
addColorStop() {},
}),
measureText: () => ({
width: 10,
actualBoundingBoxLeft: 0, actualBoundingBoxRight: 10,
actualBoundingBoxAscent: 8, actualBoundingBoxDescent: 2,
}),
getImageData: (x, y, w, h) => ({ data: new Uint8Array(w * h * 4) }),
fillStyle: '', strokeStyle: '', lineWidth: 0,
lineJoin: '', lineCap: '', shadowColor: '',
shadowBlur: 0, shadowOffsetX: 0, shadowOffsetY: 0,
miterLimit: 0, globalCompositeOperation: '',
}),
width: 0, height: 0,
}),
},
Math,
Number,
Map,
Set,
String,
Object,
Array,
};
vm.createContext(sandbox);
vm.runInContext(code, sandbox, { filename: MATERIALS_JS });
return sandbox.createMaterialBuilders;
}
// Build a minimal DI bundle for testing factory internals.
function makeDI(overrides = {}) {
let txtCache = {};
const techMatCache = new Map();
const techMeshMatClones = new Set();
const T = {
SpriteMaterial: class { constructor(o) { Object.assign(this, o); this.map = o.map; this.userData = {}; } clone() { const c = new T.SpriteMaterial(this); return c; } dispose() {} },
MeshBasicMaterial: class { constructor(o) { Object.assign(this, o); this.userData = {}; } clone() { return new T.MeshBasicMaterial(this); } dispose() {} },
CanvasTexture: class { constructor(c) { this._c = c; } dispose() {} },
Color: class { constructor(v) { this.r = 1; this.g = 1; this.b = 1; } getHexString() { return 'ffffff'; } },
DoubleSide: 2,
};
return {
getT: () => T,
getTxtCache: () => txtCache,
techMatCache,
techMeshMatClones,
_resetCache: () => { txtCache = {}; },
...overrides,
};
}
// ── 1. Module exports createMaterialBuilders ────────────────────────────────
test('src/materials.js exports createMaterialBuilders', () => {
// Mutation: rename to createMaterials → RED.
assert.match(src(), /export\s+function\s+createMaterialBuilders\s*\(/, 'must export createMaterialBuilders');
});
// ── 2. Return set covers all expected symbols ───────────────────────────────
test('createMaterialBuilders returns all required symbols', () => {
// Mutation: remove `pool` from return {...} → RED.
const createMaterialBuilders = loadFactory();
const di = makeDI();
const result = createMaterialBuilders(di);
const EXPECTED = [
'txtMat', 'pinchHarmonicMat', 'naturalHarmonicMat',
'palmMuteXSpriteMat', 'fretHandMuteXSpriteMat', 'muteXMat',
'triMat', 'bendChevronMat', 'darkenHex', 'slideArrowMat',
'_meshMatForGhostFretDigit', '_spriteMat2MeshMat', 'pool',
];
for (const sym of EXPECTED) {
assert.ok(sym in result, `createMaterialBuilders must return ${sym}`);
}
});
// ── 3. Stranded-caller: every returned symbol in screen.js destructure ───────
test('every symbol returned by createMaterialBuilders is in the screen.js destructure', () => {
// Mutation: add _newHelper to return{} but not the screen.js destructure → leaked=['_newHelper'] → RED.
const matSrc = stripComments(src());
const scr = screenSrc();
// Extract the module-level return block — anchored by the first symbol
// (txtMat) so pool's inner return { get, reset, warm } can't shadow it.
// Mutation: remove txtMat from the return → anchor fails → RED.
const retMatch = matSrc.match(/return\s*\{\s*\n\s*(txtMat\s*,[\s\S]+?)\n\s*\};/);
assert.ok(retMatch, 'createMaterialBuilders must end with return { txtMat, ... }');
const returned = new Set(
retMatch[1].split(',').map(s => s.trim()).filter(Boolean)
);
// Extract destructured names from the screen.js tombstone.
const dsMatch = scr.match(/const\s*\{([^}]+)\}\s*=\s*createMaterialBuilders\s*\(/);
assert.ok(dsMatch, 'screen.js must have createMaterialBuilders destructure');
const destructured = new Set(
dsMatch[1].split(',').map(s => s.trim().split(/\s+/).pop()).filter(Boolean)
);
const leaked = [...returned].filter(sym => !destructured.has(sym));
assert.deepStrictEqual(leaked, [],
'createMaterialBuilders returns symbols not in screen.js destructure: ' + leaked.join(', '));
});
// ── 4. Stale-private guard: _pm/_fhXSpriteMat not bare in screen.js ──────────
test('_pmXSpriteMat and _fhXSpriteMat do not appear bare in screen.js IIFE body', () => {
// Mutation: reference _pmXSpriteMat directly in screen.js body → RED.
// These two let vars were factory-scope in the old N-section; now they live
// in the materials.js module closure and must not appear in screen.js.
const scr = screenSrc();
let scrStripped = scr.replace(/^import\s+.*\n/gm, '');
scrStripped = stripComments(scrStripped);
scrStripped = scrStripped.replace(/const\s*\{[^}]+\}\s*=\s*createMaterialBuilders\s*\([^)]*\)\s*;/, '');
for (const sym of ['_pmXSpriteMat', '_fhXSpriteMat']) {
assert.doesNotMatch(scrStripped, new RegExp('\\b' + sym + '\\b'),
`screen.js must not reference private module var ${sym}`);
}
});
// ── 5. DI: T is accessed via getT() at call time, not factory construction ───
test('material builder functions call getT() at call time', () => {
// Mutation: top-level `const T = getT()` at factory construction → RED.
// Each function must call getT() inside its own body.
const s = src();
// Must NOT have `const T = getT()` at the top level of the factory
// (outside any function body). Check that it's scoped inside function bodies.
assert.doesNotMatch(
s,
/createMaterialBuilders\s*\([^)]*\)\s*\{[^}]*const T = getT\(\)/,
'T must not be captured at factory construction — only inside function bodies'
);
// Each T-using function must contain getT() in its body.
for (const fn of ['txtMat', 'pinchHarmonicMat', 'naturalHarmonicMat', 'muteXMat',
'triMat', 'bendChevronMat', 'slideArrowMat',
'_meshMatForGhostFretDigit', '_spriteMat2MeshMat']) {
const fnIdx = s.indexOf(`function ${fn}(`);
assert.ok(fnIdx !== -1, `${fn} must exist in materials.js`);
// Find the body of this function (brace-balanced).
const openBrace = s.indexOf('{', fnIdx);
let depth = 1, i = openBrace + 1;
while (i < s.length && depth > 0) {
if (s[i] === '{') depth++;
else if (s[i] === '}') depth--;
i++;
}
const body = s.slice(openBrace, i);
assert.match(body, /const T = getT\(\)/, `${fn} must call getT() inside its body`);
}
});
// ── 6. DI: txtCache is accessed via getTxtCache() inside each function ────────
test('txtCache-using functions access cache via getTxtCache()', () => {
// Mutation: use bare `txtCache[k]` instead → RED (and also a runtime bug).
const s = stripComments(src());
// The module code must never reference a bare `txtCache` identifier.
assert.doesNotMatch(s, /\btxtCache\b/, 'materials.js code must not reference bare txtCache — use getTxtCache()');
// Each cache-using function must call getTxtCache().
for (const fn of ['txtMat', 'pinchHarmonicMat', 'naturalHarmonicMat', 'muteXMat']) {
assert.ok(s.includes(`function ${fn}(`), `${fn} must exist`);
const fnIdx = s.indexOf(`function ${fn}(`);
const openBrace = s.indexOf('{', fnIdx);
let depth = 1, i = openBrace + 1;
while (i < s.length && depth > 0) {
if (s[i] === '{') depth++;
else if (s[i] === '}') depth--;
i++;
}
const body = s.slice(openBrace, i);
assert.match(body, /getTxtCache\(\)/, `${fn} must call getTxtCache() inside its body`);
}
});
// ── 7. DI: techMatCache param used (not bare _techMatCache) ──────────────────
test('triMat/bendChevronMat/slideArrowMat use techMatCache DI param', () => {
// Mutation: use `_techMatCache.get(key)` → RED (and runtime ReferenceError).
const s = stripComments(src());
assert.doesNotMatch(s, /\b_techMatCache\b/, 'materials.js code must not reference _techMatCache — use DI param techMatCache');
});
// ── 8. DI: techMeshMatClones param used (not bare _techMeshMatClones) ─────────
test('_spriteMat2MeshMat uses techMeshMatClones DI param', () => {
// Mutation: use `_techMeshMatClones.add(clone)` → RED.
const s = stripComments(src());
assert.doesNotMatch(s, /\b_techMeshMatClones\b/, 'materials.js code must not reference _techMeshMatClones — use DI param');
});
// ── 9. TXT_STYLES literal pin ────────────────────────────────────────────────
test('TXT_STYLES presets match known-good values', () => {
// Mutation: change technique.srcH from 128 to 256 → RED.
const s = src();
// fretRow / noteFret / ghostFret — srcH 256, strokeW 18
for (const key of ['fretRow', 'noteFret', 'ghostFret']) {
assert.match(s, new RegExp(key + '[\\s\\S]{0,400}srcH:\\s*256'), `${key}.srcH must be 256`);
assert.match(s, new RegExp(key + '[\\s\\S]{0,400}strokeW:\\s*18'), `${key}.strokeW must be 18`);
}
// All three large presets share this stroke color.
assert.ok(s.includes("stroke: '#0a1018'"), "fretRow/noteFret/ghostFret stroke must be '#0a1018'");
// chord / section / technique / open — srcH 128, strokeW 6
for (const key of ['chord', 'section', 'technique', 'open']) {
assert.match(s, new RegExp(key + '[\\s\\S]{0,400}srcH:\\s*128'), `${key}.srcH must be 128`);
assert.match(s, new RegExp(key + '[\\s\\S]{0,400}strokeW:\\s*6'), `${key}.strokeW must be 6`);
}
});
// ── 10. darkenHex is pure (no T dependency) ──────────────────────────────────
test('darkenHex has no getT() call', () => {
// Mutation: add getT() call → RED (no T needed for a pure bit-twiddler).
const s = src();
const fnIdx = s.indexOf('function darkenHex(');
assert.ok(fnIdx !== -1, 'darkenHex must exist');
const openBrace = s.indexOf('{', fnIdx);
let depth = 1, i = openBrace + 1;
while (i < s.length && depth > 0) {
if (s[i] === '{') depth++;
else if (s[i] === '}') depth--;
i++;
}
const body = s.slice(openBrace, i);
assert.doesNotMatch(body, /getT\(\)/, 'darkenHex must not call getT() — it is a pure bit-twiddler');
});
// ── 11. pool has no getT() call ──────────────────────────────────────────────
test('pool has no getT() call', () => {
// Mutation: add getT() call → RED (pool creates no Three.js objects).
const s = src();
const fnIdx = s.indexOf('function pool(parent, mk)');
assert.ok(fnIdx !== -1, 'pool must exist in materials.js');
const openBrace = s.indexOf('{', fnIdx);
let depth = 1, i = openBrace + 1;
while (i < s.length && depth > 0) {
if (s[i] === '{') depth++;
else if (s[i] === '}') depth--;
i++;
}
const body = s.slice(openBrace, i);
assert.doesNotMatch(body, /getT\(\)/, 'pool must not call getT() — it is a pure container factory');
});
// ── 12. screen.js tombstone is present ───────────────────────────────────────
test('screen.js has the h3d-carve-6 tombstone comment', () => {
// Mutation: delete the tombstone block → RED.
assert.match(screenSrc(), /h3d-carve-6: material builders/, 'tombstone comment must be present');
assert.match(screenSrc(), /const _techMatCache = new Map\(\)/, '_techMatCache must be hoisted to screen.js factory scope');
});
// ── 13. _techMatCache NOT declared in materials.js code ───────────────────────
test('_techMatCache is not declared in materials.js code', () => {
// Mutation: move const _techMatCache = new Map() into materials.js → RED
// (teardown accesses it directly via the factory-scope const).
assert.doesNotMatch(stripComments(src()), /const _techMatCache\s*=/,
'_techMatCache must not be declared in materials.js — it stays in screen.js factory scope for teardown');
});
// ── 14. txtMat caches and returns a SpriteMaterial ───────────────────────────
test('txtMat creates and caches a SpriteMaterial on cache miss', () => {
// Mutation: remove `cache[k] = mat` → txtMat allocates a new material every call → RED.
const createMaterialBuilders = loadFactory();
const di = makeDI();
const { txtMat } = createMaterialBuilders(di);
const m1 = txtMat('5', '#ff0000', false, 'noteFret');
assert.ok(m1, 'txtMat must return a material');
const m2 = txtMat('5', '#ff0000', false, 'noteFret');
assert.strictEqual(m1, m2, 'txtMat must return the same instance on cache hit');
const m3 = txtMat('5', '#00ff00', false, 'noteFret');
assert.notStrictEqual(m1, m3, 'different color → different material');
});
// ── 15. triMat cache uses techMatCache (DI param), not a local Map ───────────
test('triMat stores results in techMatCache and returns cached entry', () => {
// Mutation: return a fresh material every call → RED.
const createMaterialBuilders = loadFactory();
const di = makeDI();
const { triMat } = createMaterialBuilders(di);
assert.strictEqual(di.techMatCache.size, 0, 'techMatCache starts empty');
const m1 = triMat(true, 0xff0000);
assert.strictEqual(di.techMatCache.size, 1, 'triMat must populate techMatCache');
const m2 = triMat(true, 0xff0000);
assert.strictEqual(m1, m2, 'triMat cache hit must return same object');
});
// ── 16. pool warm() pre-allocates and warm() is idempotent ───────────────────
test('pool.warm pre-allocates up to cap and is idempotent past cap', () => {
// Mutation: remove while-loop in warm() → warm() allocates nothing → RED.
const createMaterialBuilders = loadFactory();
const di = makeDI();
const { pool } = createMaterialBuilders(di);
const parent = { add() {} };
let mkCount = 0;
const p = pool(parent, () => { mkCount++; return { visible: true, center: null }; });
p.warm(5);
assert.strictEqual(mkCount, 5, 'warm(5) must pre-allocate 5 objects');
p.warm(3); // below current length — must be idempotent
assert.strictEqual(mkCount, 5, 'warm(3) after warm(5) must not allocate more');
p.warm(8);
assert.strictEqual(mkCount, 8, 'warm(8) after warm(5) must allocate 3 more');
});