mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-10 23:24:30 +00:00
feat(h3d-carve-15): extract U-section (per-frame renderer) into src/renderer.js
- createRenderer factory DI: 241 params (consts, fn-refs, 33 pool getters, material/settings/camera/ND getters, 35 setters) - screen.js: tombstone + createRenderer wiring after createNoteRenderer + createCamera wirings (§1 ruling: createNoteRenderer called from screen.js) - Restore createArp wiring + _resetStringDependentCaches to screen.js (accidentally dropped during carve; both needed in IIFE scope) - smoothNow: correction 3 — setter form setFrameNow(v); return v (no bare return (_frameNow = raw)) - _applyNoteCamTargets callers: 2 sites (7940/9923); correction 2 verified - lookaheadSmoothCamStep callers: 3 sites (9963/9974/9978); correction 2 verified - plugin.json: bump 3.50.0 → 3.51.0 Tests (16 files updated to scan renderer.js): - highway_3d_renderer.test.js: new, 15 tests — export contract, DI count (241), tombstone, caller-list corrections, smoothNow semantics, ordering - highway_3d_arp_deferral.test.js: add renderer.js scan (deferChordGems / noteStreamCoversArpShape moved to renderer.js) - highway_3d_lean_sustain.test.js: add renderer.js scan; update to setLeanSus/getLeanSus() getter form - highway_3d_smooth_clock_pause.test.js: fix literal-newline syntax error; update to setClkAudioT/setClkPerf/setFrameNow setter form; update new-sample regex to match getClkAudioT() - highway_3d_slide_target.test.js: add renderer.js to src scan - highway_3d_sustain_rail.test.js: assert against rendererSrc (pattern moved from U-section) - highway_chart_transform.test.js: add utils.js scan (_openStringPitchLabels- ForTuning moved to src/utils.js by h3d-carve-3) - highway_note_state.test.js: add renderer.js scan (_ndGetNoteState / _ndHasProvider captures in renderer.js update()) - highway_3d_camera_bootstrap.test.js: setter form for camSnapped/curX/ measureStarts; renderer.js added to scan - highway_3d_camera_framing.test.js: renderer.js added; setMeasureStarts/ setCamSnapped setter form in assertions Suite: 1395/1396 (test 46 pre-existing failure unrelated to carve-15) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
b02c760aec
commit
7623ad85e3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "highway_3d",
|
||||
"name": "3D Highway",
|
||||
"version": "3.50.0",
|
||||
"version": "3.51.0",
|
||||
"type": "visualization",
|
||||
"scriptType": "module",
|
||||
"bundled": true,
|
||||
|
||||
+252
-3538
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,8 @@ const path = require('node:path');
|
||||
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||
// h3d-carve-12: chordShapeCoveredByStandaloneNotes moved to src/arp.js
|
||||
const ARP_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'arp.js');
|
||||
// h3d-carve-15: deferChordGems / noteStreamCoversArpShape moved to src/renderer.js
|
||||
const RENDERER_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'renderer.js');
|
||||
|
||||
test('chordShapeCoveredByStandaloneNotes helper exists with the expected signature', () => {
|
||||
const src = fs.readFileSync(ARP_JS, 'utf8');
|
||||
@@ -27,7 +29,7 @@ test('deferChordGems gates both synth and explicit+covered branches on note-stre
|
||||
// Either branch firing without coverage produces the empty-lavender-frame
|
||||
// regression PR #262 fixed. Pin both predicates so a refactor that drops
|
||||
// one gate fails the test.
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + fs.readFileSync(RENDERER_JS, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/const\s+deferChordGems\s*=\s*\(\s*ch\.h3dSynth\s*&&\s*noteStreamCoversArpShape\(\)\s*\)\s*\|\|\s*inferredArpPattern\s*\|\|\s*\(\s*hsHintFrame\.explicit\s*&&\s*hsHintFrame\.covered\s*&&\s*noteStreamCoversArpShape\(\)\s*\)/,
|
||||
@@ -39,14 +41,14 @@ test('noteStreamCoversArpShape is computed lazily (called, not eagerly bound)',
|
||||
// Eager allocation regressed perf on dense charts (Copilot review on PR
|
||||
// #262). The shape must be a callable so short-circuit evaluation skips
|
||||
// the note-stream scan when neither gating branch needs it.
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + fs.readFileSync(RENDERER_JS, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/const\s+noteStreamCoversArpShape\s*=\s*(?:\(\s*\)\s*=>|function(?:\s+\w+)?\s*\(\s*\))/,
|
||||
'noteStreamCoversArpShape must be an arrow/function so the scan is lazy',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
src,
|
||||
fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + fs.readFileSync(RENDERER_JS, 'utf8'),
|
||||
/const\s+noteStreamCoversArpShape\s*=\s*chordShapeCoveredByStandaloneNotes\(/,
|
||||
'noteStreamCoversArpShape must not eagerly invoke the coverage helper',
|
||||
);
|
||||
|
||||
@@ -12,7 +12,9 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
const RENDERER_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'renderer.js');
|
||||
// h3d-carve-15: U-section (bootstrap region C) moved to renderer.js; scan both.
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + fs.readFileSync(RENDERER_JS, 'utf8');
|
||||
// Since h3d-carve-1b, hwyFirstRelevantFrettedTime lives in geometry.js.
|
||||
const GEOMETRY_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'geometry.js');
|
||||
const geoSrc = fs.readFileSync(GEOMETRY_JS, 'utf8');
|
||||
@@ -117,11 +119,11 @@ test('recent onsets inside the behind-window bootstrap at now', () => {
|
||||
test('bootstrap runs once when complete chart arrays arrive', () => {
|
||||
const bootstrap = sourceBetween(
|
||||
'// ── Camera bootstrap (first chart data)',
|
||||
' pbBeg(4);',
|
||||
' pbBeg(4);',
|
||||
);
|
||||
assert.match(
|
||||
bootstrap,
|
||||
/if\s*\(\s*!_camSnapped\s*&&\s*!_camPreScanned\s*&&\s*notes\s*&&\s*chords\s*\)/,
|
||||
/if\s*\(\s*!getCamSnapped\s*\(\s*\)\s*&&\s*!getCamPreScanned\s*\(\s*\)\s*&&\s*notes\s*&&\s*chords\s*\)/,
|
||||
'chart bootstrap must be gated to one pass after both arrays arrive',
|
||||
);
|
||||
assert.match(
|
||||
@@ -131,7 +133,7 @@ test('bootstrap runs once when complete chart arrays arrive', () => {
|
||||
);
|
||||
assert.match(
|
||||
bootstrap,
|
||||
/firstFrettedTime\s*===\s*null[\s\S]*?_camSnapped\s*=\s*true/,
|
||||
/firstFrettedTime\s*===\s*null[\s\S]*?setCamSnapped\s*\(\s*true\s*\)/,
|
||||
'all-open/empty charts without lookahead bounds must permanently disable bootstrap work',
|
||||
);
|
||||
});
|
||||
@@ -139,7 +141,7 @@ test('bootstrap runs once when complete chart arrays arrive', () => {
|
||||
test('steady and lookahead modes initialize immediately from future chart data', () => {
|
||||
const bootstrap = sourceBetween(
|
||||
'// ── Camera bootstrap (first chart data)',
|
||||
' pbBeg(4);',
|
||||
' pbBeg(4);',
|
||||
);
|
||||
assert.match(
|
||||
bootstrap,
|
||||
@@ -163,7 +165,7 @@ test('steady and lookahead modes initialize immediately from future chart data',
|
||||
);
|
||||
assert.match(
|
||||
bootstrap,
|
||||
/curX\s*=\s*tgtX\s*;[\s\S]*?curDist\s*=\s*tgtDist\s*;/,
|
||||
/setCurX\s*\(\s*getTgtX\s*\(\)\s*\)\s*;[\s\S]*?setCurDist\s*\(\s*getTgtDist\s*\(\)\s*\)\s*;/,
|
||||
'the initial base position must be applied before the note draw loop',
|
||||
);
|
||||
});
|
||||
@@ -180,31 +182,38 @@ test('silent-intro hold hands off only when live framing is ready', () => {
|
||||
);
|
||||
assert.match(
|
||||
target,
|
||||
/if\s*\(\s*bootstrapHoldActive\s*\)[\s\S]*?lockActive\s*=\s*prevLockActive/,
|
||||
/if\s*\(\s*bootstrapHoldActive\s*\)[\s\S]*?lockActive\s*=\s*getPrevLockActive\s*\(\s*\)/,
|
||||
'the bootstrap target must remain untouched while the live window is empty',
|
||||
);
|
||||
assert.match(
|
||||
target,
|
||||
/_camBootstrapMode\s*!==\s*cameraMode[\s\S]*?_camBootstrapHolding\s*=\s*false/,
|
||||
// h3d-carve-15: bare assignments → setter calls in renderer.js
|
||||
/getCamBootstrapMode\(\)\s*!==\s*cameraMode[\s\S]*?setCamBootstrapHolding\s*\(\s*false\s*\)/,
|
||||
'a live camera-mode change must safely release the old-mode hold',
|
||||
);
|
||||
});
|
||||
|
||||
test('song changes and teardown reset every bootstrap state field', () => {
|
||||
const resetAssignments = src.match(
|
||||
/_camSnapped\s*=\s*false\s*;\s*\r?\n\s*_camPreScanned\s*=\s*false\s*;\s*\r?\n\s*_camBootstrapHolding\s*=\s*false\s*;\s*\r?\n\s*_camBootstrapMode\s*=\s*null\s*;/g,
|
||||
// h3d-carve-15: song-change path uses setter calls (renderer.js);
|
||||
// teardown/init path uses bare assignments (screen.js). Both must exist.
|
||||
const setterResets = src.match(
|
||||
/setCamSnapped\s*\(\s*false\s*\)\s*;\s*\r?\n\s*setCamPreScanned\s*\(\s*false\s*\)/g,
|
||||
) || [];
|
||||
const bareResets = src.match(
|
||||
/_camSnapped\s*=\s*false\s*;\s*\r?\n\s*_camPreScanned\s*=\s*false/g,
|
||||
) || [];
|
||||
const totalResets = setterResets.length + bareResets.length;
|
||||
assert.equal(
|
||||
resetAssignments.length,
|
||||
totalResets,
|
||||
2,
|
||||
'song-change and teardown paths must both reset bootstrap state',
|
||||
`song-change and teardown paths must both reset bootstrap state (setter=${setterResets.length}, bare=${bareResets.length})`,
|
||||
);
|
||||
});
|
||||
|
||||
test('Camera Director still layers after the bootstrapped auto-framing base', () => {
|
||||
const bootstrap = sourceBetween(
|
||||
'// ── Camera bootstrap (first chart data)',
|
||||
' pbBeg(4);',
|
||||
' pbBeg(4);',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
bootstrap,
|
||||
|
||||
@@ -23,7 +23,9 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
const RENDERER_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'renderer.js');
|
||||
// h3d-carve-15: U-section moved to renderer.js; scan both.
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + fs.readFileSync(RENDERER_JS, 'utf8');
|
||||
// h3d-carve-9: camUpdate body moved here; tests that pin its internals retarget to cameraSrc.
|
||||
const CAMERA_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'camera.js');
|
||||
const cameraSrc = fs.readFileSync(CAMERA_JS, 'utf8');
|
||||
@@ -90,9 +92,10 @@ test('lookahead window is expressed in measures with a seconds fallback', () =>
|
||||
|
||||
test('measure-start cache only keeps beats with measure >= 0', () => {
|
||||
// Intra-measure beats carry measure === -1 and must be skipped.
|
||||
// h3d-carve-15: bare _measureStarts = _ms → setMeasureStarts(_ms) in renderer.js
|
||||
assert.match(
|
||||
src,
|
||||
/Number\.isFinite\(\s*_b\.measure\s*\)\s*&&\s*_b\.measure\s*>=\s*0[\s\S]*?_measureStarts\s*=\s*_ms/,
|
||||
/Number\.isFinite\(\s*_b\.measure\s*\)\s*&&\s*_b\.measure\s*>=\s*0[\s\S]*?setMeasureStarts\s*\(\s*_ms\s*\)/,
|
||||
'only measure-start beats (measure >= 0) feed _measureStarts',
|
||||
);
|
||||
});
|
||||
@@ -130,9 +133,10 @@ test('measure-start cache is invalidated on song change', () => {
|
||||
// The song-change reset (reconnect path) resets _camSnapped; it must also
|
||||
// drop the measure-start cache, otherwise lookaheadEndTime sizes the window
|
||||
// off the previous song's measure grid and over-zooms the first-data snap.
|
||||
// h3d-carve-15: bare assignments → setter calls in renderer.js
|
||||
assert.match(
|
||||
src,
|
||||
/_camSnapped\s*=\s*false\s*;[\s\S]*?_measureStarts\s*=\s*\[\]\s*;\s*_measureStartsRef\s*=\s*null\s*;/,
|
||||
/setCamSnapped\s*\(\s*false\s*\)\s*;[\s\S]*?setMeasureStarts\s*\(\s*\[\]\s*\)\s*;\s*setMeasureStartsRef\s*\(\s*null\s*\)\s*;/,
|
||||
'song-change reset must clear _measureStarts / _measureStartsRef alongside _camSnapped',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -26,13 +26,15 @@ const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'scr
|
||||
// h3d-carve-14: V-section moved to note-renderer.js; tests that pin its
|
||||
// patterns must now search both files.
|
||||
const NOTE_RENDERER_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'note-renderer.js');
|
||||
const RENDERER_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'renderer.js');
|
||||
|
||||
let _src;
|
||||
/** Returns screen.js + note-renderer.js concatenated for pattern matching. */
|
||||
function src() {
|
||||
if (!_src) {
|
||||
_src = fs.readFileSync(SCREEN_JS, 'utf8')
|
||||
+ '\n' + fs.readFileSync(NOTE_RENDERER_JS, 'utf8');
|
||||
+ '\n' + fs.readFileSync(NOTE_RENDERER_JS, 'utf8')
|
||||
+ '\n' + fs.readFileSync(RENDERER_JS, 'utf8');
|
||||
}
|
||||
return _src;
|
||||
}
|
||||
|
||||
@@ -17,10 +17,11 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||
const RENDERER_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'renderer.js');
|
||||
|
||||
let _src;
|
||||
function src() {
|
||||
if (!_src) _src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
if (!_src) _src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + fs.readFileSync(RENDERER_JS, 'utf8');
|
||||
return _src;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,9 @@ const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'scr
|
||||
// h3d-carve-14: sustain trail code moved to note-renderer.js; trail tests
|
||||
// must now search both files.
|
||||
const NOTE_RENDERER_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'note-renderer.js');
|
||||
const RENDERER_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'renderer.js');
|
||||
const _noteRendererSrc = fs.readFileSync(NOTE_RENDERER_JS, 'utf8');
|
||||
const _rendererSrc = fs.readFileSync(RENDERER_JS, 'utf8');
|
||||
|
||||
test('lean sustain rendering is the default (_leanSus starts true)', () => {
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
@@ -34,28 +36,30 @@ test('lean sustain rendering is the default (_leanSus starts true)', () => {
|
||||
});
|
||||
|
||||
test('the full-quality look is an opt-out via localStorage h3d_full_sus', () => {
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
// h3d-carve-15: lean poll + setLeanSus call now in renderer.js update()
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + fs.readFileSync(RENDERER_JS, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/_leanSus\s*=\s*localStorage\.getItem\(\s*['"]h3d_full_sus['"]\s*\)\s*!==\s*['"]1['"]/,
|
||||
/setLeanSus\s*\(\s*localStorage\.getItem\(\s*['"]h3d_full_sus['"]\s*\)\s*!==\s*['"]1['"]\s*\)/,
|
||||
"lean must stay on unless localStorage.h3d_full_sus === '1' opts back into the full look",
|
||||
);
|
||||
});
|
||||
|
||||
test('exactly one element is gated behind the lean flag, and it is the rail bloom', () => {
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
// h3d-carve-15: lean gate moved to renderer.js; getter form getLeanSus()
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + fs.readFileSync(RENDERER_JS, 'utf8');
|
||||
// Only the additive rail bloom may hide behind the lean flag. If a future
|
||||
// edit re-gates the trail or ribbon outline behind !_leanSus, this count
|
||||
// edit re-gates the trail or ribbon outline behind !getLeanSus(), this count
|
||||
// climbs above 1 and the test fails — that's the regression guard.
|
||||
const gates = src.match(/if\s*\(\s*!_leanSus\s*\)/g) || [];
|
||||
const gates = src.match(/if\s*\(\s*!getLeanSus\s*\(\s*\)\s*\)/g) || [];
|
||||
assert.equal(
|
||||
gates.length,
|
||||
1,
|
||||
'expected exactly one `if (!_leanSus)` gate (the rail bloom); the outline must stay ungated',
|
||||
'expected exactly one `if (!getLeanSus())` gate (the rail bloom); the outline must stay ungated',
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/if\s*\(\s*!_leanSus\s*\)\s*\{[\s\S]{0,200}?pSusRailBloom\.get\(\)/,
|
||||
/if\s*\(\s*!getLeanSus\s*\(\s*\)\s*\)\s*\{[\s\S]{0,200}?pSusRailBloom\.get\(\)/,
|
||||
'the single lean gate must be the one that wraps pSusRailBloom.get()',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,497 +1,499 @@
|
||||
// Pins the renderOrder hierarchy in plugins/highway_3d/screen.js.
|
||||
//
|
||||
// Three.js renders transparent objects by renderOrder first, then back-to-front
|
||||
// Z sort within the same renderOrder. Nearly all 3D-highway materials use
|
||||
// depthTest:false (exceptions exist — e.g. the accent halo mats set
|
||||
// depthTest:true), so renderOrder is the primary draw-order control — getting it wrong silently
|
||||
// causes one layer to bleed through another (gems clipping through chord frames,
|
||||
// strings buried under notes, etc.).
|
||||
//
|
||||
// Full hierarchy bottom → top:
|
||||
//
|
||||
// -1 background stage traversal
|
||||
// 1 lane quads
|
||||
// 2 fret dividers
|
||||
// 3 fret inlay dots (above the lane so it no longer hides them)
|
||||
// 4 sus-rail bloom (pSusRailBloom seed) ← highway_3d_sustain_bloom.test.js
|
||||
// 5 sus-rail core (pSusRail seed) ← highway_3d_sustain_rail.test.js
|
||||
// 7 string-line glows (in-lane glow lines)
|
||||
// 14 board-projection frame
|
||||
// [renderOrderForLayerAtZ(z, FRET_COLUMN)] fret-column markers (pFretColMarker) — between chord frame and gem
|
||||
// [layered below chordFrameRenderOrder] chord fill / PM-FH fill / PM-FH lines
|
||||
// [chordFrameRenderOrder] chord frame edges = renderOrderForLayerAtZ(z, CHORD_FRAME)
|
||||
// [layered above chordFrameRenderOrder] chord-frame glow, connector/drop lines
|
||||
// [below chordFrameRenderOrder] sustain-trail strip segments (Z-proportional, always < frame)
|
||||
// [renderOrderForLayerAtZ(noteZ, NOTE_OUTLINE)] note gem outline
|
||||
// [renderOrderForLayerAtZ(noteZ, NOTE_CORE)] note gem core
|
||||
// [techniqueMarkerRenderOrder] technique markers
|
||||
// [after board wire layers] note fret labels, above gem symbols and fret wires
|
||||
// [renderOrderForLayerAtZ(0, BOARD_STRING)] string mesh (drawn over gems but under fret wires)
|
||||
// [renderOrderForLayerAtZ(0, BOARD_FRET_WIRE)] static fret wires (above strings, as on a real guitar)
|
||||
// 1000 technique labels, ghost-fret overlay
|
||||
//
|
||||
// Tests are source-level regex checks — no need to load Three.js or a DOM.
|
||||
//
|
||||
// Any PR that changes a renderOrder value must update the relevant test(s) here
|
||||
// and provide a visual justification in the PR description.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||
// Since h3d-carve-1b, RENDER_ORDER_* constants and renderOrderForLayerAtZ
|
||||
// live in geometry.js; screen.js imports them.
|
||||
const GEOMETRY_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'geometry.js');
|
||||
// h3d-carve-14: V-section moved to note-renderer.js; renderOrder tests must
|
||||
// search both files.
|
||||
const NOTE_RENDERER_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'note-renderer.js');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _src;
|
||||
/** Returns screen.js + note-renderer.js concatenated for pattern matching. */
|
||||
function src() {
|
||||
if (!_src) {
|
||||
_src = fs.readFileSync(SCREEN_JS, 'utf8')
|
||||
+ '\n' + fs.readFileSync(NOTE_RENDERER_JS, 'utf8');
|
||||
}
|
||||
return _src;
|
||||
}
|
||||
|
||||
let _geo;
|
||||
/** Returns the cached geometry source (render-order constants + renderOrderForLayerAtZ). */
|
||||
function geo() {
|
||||
if (!_geo) _geo = fs.readFileSync(GEOMETRY_JS, 'utf8');
|
||||
return _geo;
|
||||
}
|
||||
|
||||
/** Parses the declared render-order layer stack from geometry.js. */
|
||||
function layers() {
|
||||
const match = geo().match(/const\s+RENDER_ORDER_LAYER_STACK\s*=\s*Object\.freeze\(\s*\[([\s\S]*?)\]\s*\)/);
|
||||
assert.ok(match, 'RENDER_ORDER_LAYER_STACK must be declared');
|
||||
return Array.from(match[1].matchAll(/'([^']+)'/g), m => m[1]);
|
||||
}
|
||||
|
||||
/** Returns the position of a named layer in the render-order stack. */
|
||||
function layerIndex(name) {
|
||||
const ordered = layers();
|
||||
const idx = ordered.indexOf(name);
|
||||
assert.ok(idx !== -1, `${name} must be present in RENDER_ORDER_LAYER_STACK`);
|
||||
return idx;
|
||||
}
|
||||
|
||||
/** Reads the render-order base used for objects at z = 0. */
|
||||
function zZeroRenderOrder() {
|
||||
const match = geo().match(/const\s+RENDER_ORDER_AT_Z_ZERO\s*=\s*(-?\d+(?:\.\d+)?)\s*;/);
|
||||
assert.ok(match, 'RENDER_ORDER_AT_Z_ZERO must be declared');
|
||||
return Number(match[1]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Static / fixed renderOrder values
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('lane quads use renderOrder 1', () => {
|
||||
assert.match(
|
||||
src(),
|
||||
/lane\.renderOrder\s*=\s*1\s*;/,
|
||||
'lane quads must use renderOrder = 1 (bottom-most visible layer)',
|
||||
);
|
||||
});
|
||||
|
||||
test('fret dividers use renderOrder 2', () => {
|
||||
assert.match(
|
||||
src(),
|
||||
/div\.renderOrder\s*=\s*2\s*;/,
|
||||
'fret dividers must use renderOrder = 2, above lane (1)',
|
||||
);
|
||||
});
|
||||
|
||||
test('fret inlay dots use renderOrder 3, above lane (1) and dividers (2)', () => {
|
||||
// The translucent lane would otherwise paint over and hide the inlay.
|
||||
// The dots must draw after the lane/dividers but stay below the depth-layer stack.
|
||||
assert.match(
|
||||
src(),
|
||||
/d\.renderOrder\s*=\s*3\s*;/,
|
||||
'fret inlay dots must use renderOrder = 3 so the lane no longer hides them',
|
||||
);
|
||||
});
|
||||
|
||||
test('string-line glows use renderOrder 7, above sus-rails (4/5)', () => {
|
||||
// The in-lane string glow lines sit at 7 — above sus-rail bloom (4) and
|
||||
// core (5) so the glow is visible, but below chord fill (chordFrameRenderOrder-4,
|
||||
// min=44) so chord interiors don't disappear behind glow overdraw.
|
||||
assert.match(
|
||||
src(),
|
||||
/line\.renderOrder\s*=\s*7\s*;/,
|
||||
'string glow lines must use renderOrder = 7',
|
||||
);
|
||||
});
|
||||
|
||||
test('board-projection frame mesh uses renderOrder 14', () => {
|
||||
// The fretboard projection plane sits above string glows (7) but below
|
||||
// chord fill (min 44). Value 14 keeps it sandwiched cleanly.
|
||||
// Anchor to the board-projection pool (projMeshArr = activePalette.map(...))
|
||||
// so the assertion only passes when THAT block seeds renderOrder = 14 —
|
||||
// not any unrelated renderOrder = 14 elsewhere in the source.
|
||||
const boardProjRO = /projMeshArr\s*=\s*activePalette\.map\b[\s\S]{0,1200}?m\.renderOrder\s*=\s*14\s*;/;
|
||||
assert.match(
|
||||
src(),
|
||||
boardProjRO,
|
||||
'board-projection pool (projMeshArr) must seed meshes with renderOrder = 14',
|
||||
);
|
||||
const boardMatch = src().match(boardProjRO);
|
||||
assert.ok(boardMatch, 'board projection mesh must be assigned renderOrder = 14');
|
||||
});
|
||||
|
||||
test('string mesh in buildBoard uses the named board-string layer', () => {
|
||||
// The physical string cylinders/planes rendered on the fretboard sit above
|
||||
// the note-gem layers but below fret wires.
|
||||
assert.match(
|
||||
src(),
|
||||
/mesh\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*0\s*,\s*'BOARD_STRING'\s*\)\s*;/,
|
||||
'buildBoard string mesh must use BOARD_STRING',
|
||||
);
|
||||
assert.ok(layerIndex('BOARD_STRING') > layerIndex('TECHNIQUE_MARKER'));
|
||||
assert.ok(layerIndex('BOARD_STRING') < layerIndex('BOARD_FRET_WIRE'));
|
||||
});
|
||||
|
||||
test('static fret wires use bowed TubeGeometry + MeshStandardMaterial, named board-fret-wire layer, depthTest+depthWrite false, idle tier FRET_WIRE_IDLE_HEX', () => {
|
||||
// Fret wires are a single shared, bowed TubeGeometry (backported from
|
||||
// highway_babylon): a CatmullRom curve whose middle pushes away from the
|
||||
// camera by FRET_BOW_DZ so the row of frets reads as wrapping a cylindrical
|
||||
// neck. T.Line is avoided — WebGL ignores linewidth > 1px so a Line always
|
||||
// renders as a hairline. The lit MeshStandardMaterial lets scene light glint
|
||||
// across the rounded surface (gold in-anchor → brass). depthTest:false is
|
||||
// required: the string BoxGeometry (MeshStandardMaterial, depthWrite:true)
|
||||
// writes depth at Z = +STR_THICK/2, so fret wires near Z=0 would fail the
|
||||
// depth test at string pixels despite the higher layer; depthWrite:false
|
||||
// keeps the transparent fret from polluting depth for later overlays.
|
||||
const s = src();
|
||||
assert.match(
|
||||
s,
|
||||
/new\s+T\.TubeGeometry\(\s*tubeCurve\s*,\s*FRET_TUBE_SEG\s*,\s*FRET_TUBE_RADIUS\s*,\s*FRET_TUBE_RADIAL\s*,\s*false\s*,?\s*\)/,
|
||||
'buildBoard fret wires must use a TubeGeometry built from tubeCurve + FRET_TUBE_* params',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/new\s+T\.CatmullRomCurve3\(\s*tubePath\s*\)/,
|
||||
'buildBoard fret tube must follow a CatmullRomCurve3 through the bowed path',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/FRET_BOW_DZ\s*\*\s*zm/,
|
||||
'fret tube path must bow in Z by FRET_BOW_DZ so the neck reads as curved',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/new\s+T\.Mesh\(\s*fretTubeGeo\s*,\s*mat\s*\)/,
|
||||
'buildBoard fret wires must reuse the shared fretTubeGeo (not T.Line)',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/fw\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*0\s*,\s*'BOARD_FRET_WIRE'\s*\)\s*;/,
|
||||
'buildBoard fret wire mesh must use BOARD_FRET_WIRE',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/new\s+T\.MeshStandardMaterial\(/,
|
||||
'fret wires must use MeshStandardMaterial so scene light shades the metal',
|
||||
);
|
||||
// The wire tiers moved to named constants (feedBack#969): idle is the
|
||||
// dimmed 0x4A4A60 so the neck recedes and the anchor lane reads as the
|
||||
// focus cue. Assert the material uses the constant AND pin the constant's
|
||||
// value, so a retune is a deliberate two-line change here.
|
||||
assert.match(
|
||||
s,
|
||||
/color\s*:\s*FRET_WIRE_IDLE_HEX/,
|
||||
'fret wire material must take its default color from FRET_WIRE_IDLE_HEX',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/FRET_WIRE_IDLE_HEX\s*=\s*0x4A4A60/,
|
||||
'FRET_WIRE_IDLE_HEX must be the dimmed idle gray-violet 0x4A4A60',
|
||||
);
|
||||
// Both depth flags anchored to the fret-wire material literal (via its
|
||||
// FRET_WIRE_IDLE_HEX color, unique to it) — an unscoped match would pass
|
||||
// off any other depthTest:false material in the file. Asserted as two
|
||||
// separate anchored matches so property order inside the literal still
|
||||
// isn't pinned.
|
||||
assert.match(
|
||||
s,
|
||||
/color\s*:\s*FRET_WIRE_IDLE_HEX[\s\S]{0,400}?depthTest\s*:\s*false/,
|
||||
'the fret wire material itself must set depthTest: false',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/color\s*:\s*FRET_WIRE_IDLE_HEX[\s\S]{0,400}?depthWrite\s*:\s*false/,
|
||||
'the fret wire material itself must set depthWrite: false (no z-buffer pollution)',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/fretWireMats\s*\[\s*f\s*\]\s*=\s*mat\s*;/,
|
||||
'buildBoard must store each wire material in fretWireMats[f]',
|
||||
);
|
||||
});
|
||||
|
||||
test('update() sets fret wire FRET_WIRE_ACTIVE_HEX (gold) for in-anchor frets, FRET_WIRE_IDLE_HEX otherwise', () => {
|
||||
// Uses anchorLaneBoundsAt() — the same helper the dynamic lane uses —
|
||||
// so fret wire highlight aligns exactly with the lane edges:
|
||||
// dMin = fret - 1, dMax = fret + width - 1
|
||||
// Example: { fret: 3, width: 4 } → dMin=2, dMax=6 → wires 2..6 gold.
|
||||
const s = src();
|
||||
assert.match(
|
||||
s,
|
||||
/fretWireMats\.length/,
|
||||
'update() must guard the per-frame fret wire loop on fretWireMats.length',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/anchorLaneBoundsAt\(\s*anchors\s*,\s*now\s*\)/,
|
||||
'update() must use anchorLaneBoundsAt(anchors, now) to get fret wire range',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/_m\.color\.setHex\(\s*FRET_WIRE_ACTIVE_HEX\s*\)/,
|
||||
'update() must set FRET_WIRE_ACTIVE_HEX for in-anchor fret wires',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/FRET_WIRE_ACTIVE_HEX\s*=\s*0xD8A636/,
|
||||
'FRET_WIRE_ACTIVE_HEX must stay the anchor-lane gold 0xD8A636',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/_m\.color\.setHex\(\s*FRET_WIRE_IDLE_HEX\s*\)/,
|
||||
'update() must set FRET_WIRE_IDLE_HEX for out-of-anchor fret wires',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/_fwBounds\.dMin/,
|
||||
'update() must use dMin from anchorLaneBoundsAt (= fret - 1)',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/_fwBounds\.dMax/,
|
||||
'update() must use dMax from anchorLaneBoundsAt (= fret + width - 1)',
|
||||
);
|
||||
});
|
||||
|
||||
test('fret-column markers use Z-proportional renderOrder between chord frame and gem', () => {
|
||||
// pFretColMarker labels use the named stack: one step above chord frame
|
||||
// and one step below note gems at the same depth.
|
||||
// This ensures chord frame borders never overdraw the label and the label
|
||||
// never overdraws gems, at every Z position across the lookahead window.
|
||||
assert.match(
|
||||
src(),
|
||||
/sp\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'FRET_COLUMN'\s*\)\s*;/,
|
||||
'pFretColMarker renderOrder must use renderOrderForLayerAtZ(z, FRET_COLUMN)',
|
||||
);
|
||||
assert.ok(layerIndex('FRET_COLUMN') > layerIndex('CHORD_FRAME'));
|
||||
assert.ok(layerIndex('FRET_COLUMN') < layerIndex('NOTE_OUTLINE'));
|
||||
});
|
||||
|
||||
test('technique labels and ghost-fret overlay use renderOrder 1000', () => {
|
||||
// 1000 is well above the entire Z-proportional range and the
|
||||
// string/cadence layer — labels must always be readable.
|
||||
const matches = src().match(/m\.renderOrder\s*=\s*1000\s*;/g) || [];
|
||||
assert.ok(
|
||||
matches.length >= 2,
|
||||
'at least two renderOrder = 1000 assignments must exist (technique labels + ghost fret)',
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Z-proportional formulas — chord frame / note gem / technique marker
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('chordFrameRenderOrder uses renderOrderForLayerAtZ(z, CHORD_FRAME)', () => {
|
||||
// Per-chord frame renderOrder mirrors the note-gem scale with an earlier
|
||||
// layer from RENDER_ORDER_LAYER_STACK.
|
||||
assert.match(
|
||||
src(),
|
||||
/const\s+chordFrameRenderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_FRAME'\s*\)\s*;/,
|
||||
'chordFrameRenderOrder must use renderOrderForLayerAtZ(z, CHORD_FRAME)',
|
||||
);
|
||||
// renderOrderForLayerAtZ implementation lives in geometry.js since h3d-carve-1b.
|
||||
assert.match(geo(), /const\s+RENDER_ORDER_LAYER_INDEX\s*=\s*Object\.freeze\(\s*RENDER_ORDER_LAYER_STACK\.reduce\(/);
|
||||
assert.match(geo(), /const\s+layerIndex\s*=\s*RENDER_ORDER_LAYER_INDEX\[layerName\]\s*;/);
|
||||
assert.match(geo(), /if\s*\(\s*layerIndex\s*===\s*undefined\s*\)\s*throw\s+new\s+Error\(`Unknown 3D highway depth layer: \$\{layerName\}`\)\s*;/);
|
||||
assert.match(geo(), /const\s+depthRenderOrder\s*=\s*Math\.max\(\s*RENDER_ORDER_FAR_CLAMP\s*,\s*Math\.round\(\s*RENDER_ORDER_AT_Z_ZERO\s*\+\s*worldZ\s*\/\s*K\s*\)\s*\)\s*;/);
|
||||
// Layer is a sub-unit fraction so the integer depth bucket strictly
|
||||
// dominates (a farther object can't outrank a nearer one via a higher
|
||||
// layer); the layer only breaks ties within the same depth bucket.
|
||||
assert.match(geo(), /return\s+depthRenderOrder\s*\+\s*layerIndex\s*\/\s*RENDER_ORDER_LAYER_STACK\.length\s*;/);
|
||||
assert.ok(layerIndex('CHORD_FRAME') < layerIndex('NOTE_OUTLINE'));
|
||||
});
|
||||
|
||||
test('note outline uses renderOrderForLayerAtZ(noteZ, NOTE_OUTLINE)', () => {
|
||||
// Per-note gem renderOrder. noteZ is negative (ahead of hit line → negative
|
||||
// Z in world space). At noteZ=0 (on the hit line), the note outline uses
|
||||
// the near render-order base plus its layer index; far notes clamp to the
|
||||
// far render-order base plus that same layer index.
|
||||
// The ordered layer list keeps gems above chord frames everywhere.
|
||||
assert.match(
|
||||
src(),
|
||||
/outline\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*noteZ\s*,\s*'NOTE_OUTLINE'\s*\)\s*;/,
|
||||
'note outline must use renderOrderForLayerAtZ(noteZ, NOTE_OUTLINE)',
|
||||
);
|
||||
assert.strictEqual(layerIndex('CHORD_FILL'), 0);
|
||||
});
|
||||
|
||||
test('techniqueMarkerRenderOrder uses the named technique marker layer above gem core', () => {
|
||||
// Technique markers (PM cross, bend arrow, H/P chevron, etc.) must overlay
|
||||
// the gem itself.
|
||||
assert.match(
|
||||
src(),
|
||||
/const\s+techniqueMarkerRenderOrder\s*=\s*renderOrderForLayerAtZ\(\s*noteZ\s*,\s*'TECHNIQUE_MARKER'\s*\)/,
|
||||
'techniqueMarkerRenderOrder must use TECHNIQUE_MARKER',
|
||||
);
|
||||
assert.ok(layerIndex('TECHNIQUE_MARKER') > layerIndex('NOTE_CORE'));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Intra-chord layering (chord fill < PM/FH fill < PM/FH lines < frame edge)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('chord fill interior uses the named layer below chord frame', () => {
|
||||
// The translucent chord-box fill sits below the frame edge so the edge
|
||||
// always wins when both cover the same pixel.
|
||||
assert.match(
|
||||
src(),
|
||||
/fill\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_FILL'\s*\)\s*;/,
|
||||
'chord fill must use CHORD_FILL',
|
||||
);
|
||||
assert.ok(layerIndex('CHORD_FILL') < layerIndex('CHORD_FRAME'));
|
||||
});
|
||||
|
||||
test('PM/FH X fill (pPMXFill / pFHXFill) uses its ordered layer', () => {
|
||||
// The black background fill of the muted-note X symbol is above chord fill
|
||||
// but below the X lines — same chord, so same chord-frame renderOrder base.
|
||||
const matches = src().match(/xf\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_STRUM_FILL'\s*\)\s*;/g) || [];
|
||||
assert.ok(
|
||||
matches.length >= 2,
|
||||
'both PM and FH X-fill meshes must use CHORD_STRUM_FILL (found ' + matches.length + ')',
|
||||
);
|
||||
assert.ok(layerIndex('CHORD_FILL') < layerIndex('CHORD_STRUM_FILL'));
|
||||
assert.ok(layerIndex('CHORD_STRUM_FILL') < layerIndex('CHORD_STRUM_LINE'));
|
||||
});
|
||||
|
||||
test('PM/FH X lines (pMuteXLines / pFHXLines) use their ordered layer', () => {
|
||||
// The coloured X stroke lines are above the black fill but below
|
||||
// the chord frame border edge, so they don't escape the box.
|
||||
const matches = src().match(/xl\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_STRUM_LINE'\s*\)\s*;/g) || [];
|
||||
assert.ok(
|
||||
matches.length >= 2,
|
||||
'both PM and FH X-line meshes must use CHORD_STRUM_LINE (found ' + matches.length + ')',
|
||||
);
|
||||
assert.ok(layerIndex('CHORD_STRUM_LINE') < layerIndex('CHORD_FRAME'));
|
||||
});
|
||||
|
||||
test('chord frame glow uses the layer after chord frame', () => {
|
||||
// Accent glow draws after the frame while still remaining below connectors
|
||||
// and note symbols in the ordered layer list.
|
||||
assert.match(
|
||||
src(),
|
||||
/b\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_EDGE_GLOW'\s*\)\s*;/,
|
||||
'chord frame edge slabs must use CHORD_EDGE_GLOW',
|
||||
);
|
||||
assert.ok(layerIndex('CHORD_EDGE_GLOW') > layerIndex('CHORD_FRAME'));
|
||||
assert.ok(layerIndex('CHORD_EDGE_GLOW') < layerIndex('CONNECTOR_LINE'));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sustain-trail strip & ribbon — always below chord frame of same depth
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('sus-trail strip renderOrder formula keeps trails strictly below chord frames at same Z', () => {
|
||||
// Sustain trails use the ordered layer immediately below chord frames at
|
||||
// the same depth.
|
||||
assert.match(
|
||||
src(),
|
||||
/const\s+trailRenderOrder\s*=\s*renderOrderForLayerAtZ\(\s*Math\.min\(\s*0\s*,\s*zCenter\s*\)\s*,\s*'SUSTAIN_TRAIL'\s*\)\s*;/,
|
||||
'sus-trail strip renderOrder must use renderOrderForLayerAtZ(min zCenter, SUSTAIN_TRAIL)',
|
||||
);
|
||||
assert.ok(layerIndex('SUSTAIN_TRAIL') < layerIndex('CHORD_FRAME'));
|
||||
});
|
||||
|
||||
test('sus-trail ribbon renderOrder formula mirrors strip formula using time-based depth', () => {
|
||||
// Ribbons use _ribDt (time from now to ribbon midpoint) converted to the
|
||||
// same Z scale as dZ() on the sustain-trail layer.
|
||||
assert.match(
|
||||
src(),
|
||||
/const\s+ribbonRenderOrder\s*=\s*renderOrderForLayerAtZ\(\s*-\s*_ribDt\s*\*\s*TS\s*,\s*'SUSTAIN_TRAIL'\s*\)\s*;/,
|
||||
'sus-trail ribbon renderOrder must use renderOrderForLayerAtZ on the sustain-trail layer',
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Note gem ordering (outline < core, both driven by named depth layers)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('note gem outline uses the named outline layer', () => {
|
||||
assert.match(
|
||||
src(),
|
||||
/outline\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*noteZ\s*,\s*'NOTE_OUTLINE'\s*\)\s*;/,
|
||||
'note gem outline must use NOTE_OUTLINE',
|
||||
);
|
||||
assert.ok(layerIndex('NOTE_OUTLINE') > layerIndex('FRET_COLUMN'));
|
||||
});
|
||||
|
||||
test('note gem core uses the named layer above outline', () => {
|
||||
assert.match(
|
||||
src(),
|
||||
/core\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*noteZ\s*,\s*'NOTE_CORE'\s*\)\s*;/,
|
||||
'note gem core must use NOTE_CORE',
|
||||
);
|
||||
assert.ok(layerIndex('NOTE_CORE') > layerIndex('NOTE_OUTLINE'));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Key relative-ordering invariants (derived constants)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('chord frame layer is below note outline layer', () => {
|
||||
// Chord frames must always render below note gems, even at maximum depth
|
||||
// (far end of the lookahead).
|
||||
//
|
||||
assert.ok(layerIndex('CHORD_FRAME') < layerIndex('NOTE_OUTLINE'));
|
||||
});
|
||||
|
||||
test('fret labels are above note symbols in the named stack', () => {
|
||||
assert.ok(layerIndex('NOTE_FRET_LABEL') > layerIndex('NOTE_CORE'), 'note fret labels must draw above gem core');
|
||||
assert.ok(layerIndex('NOTE_FRET_LABEL') > layerIndex('TECHNIQUE_MARKER'), 'note fret labels must draw above technique markers');
|
||||
assert.ok(layerIndex('ARP_NOTE_FRET_LABEL') > layerIndex('NOTE_FRET_LABEL'), 'arp labels retain a one-layer tie-breaker');
|
||||
assert.ok(layerIndex('CHORD_FRET_LABEL') > layerIndex('NOTE_CORE'), 'chord-loop fret labels must draw above gem core at the same depth');
|
||||
assert.ok(layerIndex('NOTE_FRET_LABEL') > layerIndex('BOARD_FRET_WIRE'), 'note fret labels must clear static fret wires');
|
||||
assert.ok(layerIndex('CHORD_FRET_LABEL') > layerIndex('BOARD_FRET_WIRE'), 'chord fret labels must clear static fret wires');
|
||||
});
|
||||
|
||||
test('string mesh layer is above note symbols and below labels', () => {
|
||||
// Board strings are never occluded by flying gems, but labels still appear above strings.
|
||||
const s = src();
|
||||
assert.match(s, /mesh\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*0\s*,\s*'BOARD_STRING'\s*\)\s*;/, 'string mesh must use BOARD_STRING');
|
||||
// Confirm 1000 also exists (labels above strings)
|
||||
assert.match(s, /m\.renderOrder\s*=\s*1000\s*;/, 'technique label renderOrder 1000 must exist');
|
||||
assert.ok(layerIndex('BOARD_STRING') > layerIndex('TECHNIQUE_MARKER'), 'string mesh layer must be above note symbols');
|
||||
assert.ok(layerIndex('BOARD_STRING') < layerIndex('NOTE_FRET_LABEL'), 'string mesh layer must be below fret labels');
|
||||
});
|
||||
|
||||
test('fret-column marker layer is above chord frame and below gem outline', () => {
|
||||
assert.ok(layerIndex('FRET_COLUMN') > layerIndex('CHORD_FRAME'), 'fret-column marker layer must be above chord frame');
|
||||
assert.ok(layerIndex('FRET_COLUMN') < layerIndex('NOTE_OUTLINE'), 'fret-column marker layer must be below gem outline');
|
||||
assert.match(src(), /renderOrderForLayerAtZ\(\s*z\s*,\s*'FRET_COLUMN'\s*\)/);
|
||||
});
|
||||
|
||||
test('static fret wire layer is above string mesh and note symbols', () => {
|
||||
// Structural invariant: fret wires must always draw after (on top of) strings.
|
||||
assert.ok(layerIndex('BOARD_FRET_WIRE') > layerIndex('BOARD_STRING'), 'fret wire must be above string mesh');
|
||||
assert.ok(layerIndex('BOARD_FRET_WIRE') > layerIndex('TECHNIQUE_MARKER'), 'fret wire must be above note symbols');
|
||||
assert.ok(zZeroRenderOrder() + layerIndex('BOARD_FRET_WIRE') < 1000, 'fret wire must be below technique labels (1000)');
|
||||
assert.match(src(), /fw\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*0\s*,\s*'BOARD_FRET_WIRE'\s*\)\s*;/, 'buildBoard fret wire must use BOARD_FRET_WIRE');
|
||||
assert.match(src(), /mesh\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*0\s*,\s*'BOARD_STRING'\s*\)\s*;/, 'string mesh must use BOARD_STRING');
|
||||
});
|
||||
// Pins the renderOrder hierarchy in plugins/highway_3d/screen.js.
|
||||
//
|
||||
// Three.js renders transparent objects by renderOrder first, then back-to-front
|
||||
// Z sort within the same renderOrder. Nearly all 3D-highway materials use
|
||||
// depthTest:false (exceptions exist — e.g. the accent halo mats set
|
||||
// depthTest:true), so renderOrder is the primary draw-order control — getting it wrong silently
|
||||
// causes one layer to bleed through another (gems clipping through chord frames,
|
||||
// strings buried under notes, etc.).
|
||||
//
|
||||
// Full hierarchy bottom → top:
|
||||
//
|
||||
// -1 background stage traversal
|
||||
// 1 lane quads
|
||||
// 2 fret dividers
|
||||
// 3 fret inlay dots (above the lane so it no longer hides them)
|
||||
// 4 sus-rail bloom (pSusRailBloom seed) ← highway_3d_sustain_bloom.test.js
|
||||
// 5 sus-rail core (pSusRail seed) ← highway_3d_sustain_rail.test.js
|
||||
// 7 string-line glows (in-lane glow lines)
|
||||
// 14 board-projection frame
|
||||
// [renderOrderForLayerAtZ(z, FRET_COLUMN)] fret-column markers (pFretColMarker) — between chord frame and gem
|
||||
// [layered below chordFrameRenderOrder] chord fill / PM-FH fill / PM-FH lines
|
||||
// [chordFrameRenderOrder] chord frame edges = renderOrderForLayerAtZ(z, CHORD_FRAME)
|
||||
// [layered above chordFrameRenderOrder] chord-frame glow, connector/drop lines
|
||||
// [below chordFrameRenderOrder] sustain-trail strip segments (Z-proportional, always < frame)
|
||||
// [renderOrderForLayerAtZ(noteZ, NOTE_OUTLINE)] note gem outline
|
||||
// [renderOrderForLayerAtZ(noteZ, NOTE_CORE)] note gem core
|
||||
// [techniqueMarkerRenderOrder] technique markers
|
||||
// [after board wire layers] note fret labels, above gem symbols and fret wires
|
||||
// [renderOrderForLayerAtZ(0, BOARD_STRING)] string mesh (drawn over gems but under fret wires)
|
||||
// [renderOrderForLayerAtZ(0, BOARD_FRET_WIRE)] static fret wires (above strings, as on a real guitar)
|
||||
// 1000 technique labels, ghost-fret overlay
|
||||
//
|
||||
// Tests are source-level regex checks — no need to load Three.js or a DOM.
|
||||
//
|
||||
// Any PR that changes a renderOrder value must update the relevant test(s) here
|
||||
// and provide a visual justification in the PR description.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||
// Since h3d-carve-1b, RENDER_ORDER_* constants and renderOrderForLayerAtZ
|
||||
// live in geometry.js; screen.js imports them.
|
||||
const GEOMETRY_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'geometry.js');
|
||||
// h3d-carve-14: V-section moved to note-renderer.js; renderOrder tests must
|
||||
// search both files.
|
||||
const NOTE_RENDERER_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'note-renderer.js');
|
||||
const RENDERER_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'renderer.js');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _src;
|
||||
/** Returns screen.js + note-renderer.js concatenated for pattern matching. */
|
||||
function src() {
|
||||
if (!_src) {
|
||||
_src = fs.readFileSync(SCREEN_JS, 'utf8')
|
||||
+ '\n' + fs.readFileSync(NOTE_RENDERER_JS, 'utf8')
|
||||
+ '\n' + fs.readFileSync(RENDERER_JS, 'utf8');
|
||||
}
|
||||
return _src;
|
||||
}
|
||||
|
||||
let _geo;
|
||||
/** Returns the cached geometry source (render-order constants + renderOrderForLayerAtZ). */
|
||||
function geo() {
|
||||
if (!_geo) _geo = fs.readFileSync(GEOMETRY_JS, 'utf8');
|
||||
return _geo;
|
||||
}
|
||||
|
||||
/** Parses the declared render-order layer stack from geometry.js. */
|
||||
function layers() {
|
||||
const match = geo().match(/const\s+RENDER_ORDER_LAYER_STACK\s*=\s*Object\.freeze\(\s*\[([\s\S]*?)\]\s*\)/);
|
||||
assert.ok(match, 'RENDER_ORDER_LAYER_STACK must be declared');
|
||||
return Array.from(match[1].matchAll(/'([^']+)'/g), m => m[1]);
|
||||
}
|
||||
|
||||
/** Returns the position of a named layer in the render-order stack. */
|
||||
function layerIndex(name) {
|
||||
const ordered = layers();
|
||||
const idx = ordered.indexOf(name);
|
||||
assert.ok(idx !== -1, `${name} must be present in RENDER_ORDER_LAYER_STACK`);
|
||||
return idx;
|
||||
}
|
||||
|
||||
/** Reads the render-order base used for objects at z = 0. */
|
||||
function zZeroRenderOrder() {
|
||||
const match = geo().match(/const\s+RENDER_ORDER_AT_Z_ZERO\s*=\s*(-?\d+(?:\.\d+)?)\s*;/);
|
||||
assert.ok(match, 'RENDER_ORDER_AT_Z_ZERO must be declared');
|
||||
return Number(match[1]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Static / fixed renderOrder values
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('lane quads use renderOrder 1', () => {
|
||||
assert.match(
|
||||
src(),
|
||||
/lane\.renderOrder\s*=\s*1\s*;/,
|
||||
'lane quads must use renderOrder = 1 (bottom-most visible layer)',
|
||||
);
|
||||
});
|
||||
|
||||
test('fret dividers use renderOrder 2', () => {
|
||||
assert.match(
|
||||
src(),
|
||||
/div\.renderOrder\s*=\s*2\s*;/,
|
||||
'fret dividers must use renderOrder = 2, above lane (1)',
|
||||
);
|
||||
});
|
||||
|
||||
test('fret inlay dots use renderOrder 3, above lane (1) and dividers (2)', () => {
|
||||
// The translucent lane would otherwise paint over and hide the inlay.
|
||||
// The dots must draw after the lane/dividers but stay below the depth-layer stack.
|
||||
assert.match(
|
||||
src(),
|
||||
/d\.renderOrder\s*=\s*3\s*;/,
|
||||
'fret inlay dots must use renderOrder = 3 so the lane no longer hides them',
|
||||
);
|
||||
});
|
||||
|
||||
test('string-line glows use renderOrder 7, above sus-rails (4/5)', () => {
|
||||
// The in-lane string glow lines sit at 7 — above sus-rail bloom (4) and
|
||||
// core (5) so the glow is visible, but below chord fill (chordFrameRenderOrder-4,
|
||||
// min=44) so chord interiors don't disappear behind glow overdraw.
|
||||
assert.match(
|
||||
src(),
|
||||
/line\.renderOrder\s*=\s*7\s*;/,
|
||||
'string glow lines must use renderOrder = 7',
|
||||
);
|
||||
});
|
||||
|
||||
test('board-projection frame mesh uses renderOrder 14', () => {
|
||||
// The fretboard projection plane sits above string glows (7) but below
|
||||
// chord fill (min 44). Value 14 keeps it sandwiched cleanly.
|
||||
// Anchor to the board-projection pool (projMeshArr = activePalette.map(...))
|
||||
// so the assertion only passes when THAT block seeds renderOrder = 14 —
|
||||
// not any unrelated renderOrder = 14 elsewhere in the source.
|
||||
const boardProjRO = /projMeshArr\s*=\s*activePalette\.map\b[\s\S]{0,1200}?m\.renderOrder\s*=\s*14\s*;/;
|
||||
assert.match(
|
||||
src(),
|
||||
boardProjRO,
|
||||
'board-projection pool (projMeshArr) must seed meshes with renderOrder = 14',
|
||||
);
|
||||
const boardMatch = src().match(boardProjRO);
|
||||
assert.ok(boardMatch, 'board projection mesh must be assigned renderOrder = 14');
|
||||
});
|
||||
|
||||
test('string mesh in buildBoard uses the named board-string layer', () => {
|
||||
// The physical string cylinders/planes rendered on the fretboard sit above
|
||||
// the note-gem layers but below fret wires.
|
||||
assert.match(
|
||||
src(),
|
||||
/mesh\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*0\s*,\s*'BOARD_STRING'\s*\)\s*;/,
|
||||
'buildBoard string mesh must use BOARD_STRING',
|
||||
);
|
||||
assert.ok(layerIndex('BOARD_STRING') > layerIndex('TECHNIQUE_MARKER'));
|
||||
assert.ok(layerIndex('BOARD_STRING') < layerIndex('BOARD_FRET_WIRE'));
|
||||
});
|
||||
|
||||
test('static fret wires use bowed TubeGeometry + MeshStandardMaterial, named board-fret-wire layer, depthTest+depthWrite false, idle tier FRET_WIRE_IDLE_HEX', () => {
|
||||
// Fret wires are a single shared, bowed TubeGeometry (backported from
|
||||
// highway_babylon): a CatmullRom curve whose middle pushes away from the
|
||||
// camera by FRET_BOW_DZ so the row of frets reads as wrapping a cylindrical
|
||||
// neck. T.Line is avoided — WebGL ignores linewidth > 1px so a Line always
|
||||
// renders as a hairline. The lit MeshStandardMaterial lets scene light glint
|
||||
// across the rounded surface (gold in-anchor → brass). depthTest:false is
|
||||
// required: the string BoxGeometry (MeshStandardMaterial, depthWrite:true)
|
||||
// writes depth at Z = +STR_THICK/2, so fret wires near Z=0 would fail the
|
||||
// depth test at string pixels despite the higher layer; depthWrite:false
|
||||
// keeps the transparent fret from polluting depth for later overlays.
|
||||
const s = src();
|
||||
assert.match(
|
||||
s,
|
||||
/new\s+T\.TubeGeometry\(\s*tubeCurve\s*,\s*FRET_TUBE_SEG\s*,\s*FRET_TUBE_RADIUS\s*,\s*FRET_TUBE_RADIAL\s*,\s*false\s*,?\s*\)/,
|
||||
'buildBoard fret wires must use a TubeGeometry built from tubeCurve + FRET_TUBE_* params',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/new\s+T\.CatmullRomCurve3\(\s*tubePath\s*\)/,
|
||||
'buildBoard fret tube must follow a CatmullRomCurve3 through the bowed path',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/FRET_BOW_DZ\s*\*\s*zm/,
|
||||
'fret tube path must bow in Z by FRET_BOW_DZ so the neck reads as curved',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/new\s+T\.Mesh\(\s*fretTubeGeo\s*,\s*mat\s*\)/,
|
||||
'buildBoard fret wires must reuse the shared fretTubeGeo (not T.Line)',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/fw\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*0\s*,\s*'BOARD_FRET_WIRE'\s*\)\s*;/,
|
||||
'buildBoard fret wire mesh must use BOARD_FRET_WIRE',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/new\s+T\.MeshStandardMaterial\(/,
|
||||
'fret wires must use MeshStandardMaterial so scene light shades the metal',
|
||||
);
|
||||
// The wire tiers moved to named constants (feedBack#969): idle is the
|
||||
// dimmed 0x4A4A60 so the neck recedes and the anchor lane reads as the
|
||||
// focus cue. Assert the material uses the constant AND pin the constant's
|
||||
// value, so a retune is a deliberate two-line change here.
|
||||
assert.match(
|
||||
s,
|
||||
/color\s*:\s*FRET_WIRE_IDLE_HEX/,
|
||||
'fret wire material must take its default color from FRET_WIRE_IDLE_HEX',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/FRET_WIRE_IDLE_HEX\s*=\s*0x4A4A60/,
|
||||
'FRET_WIRE_IDLE_HEX must be the dimmed idle gray-violet 0x4A4A60',
|
||||
);
|
||||
// Both depth flags anchored to the fret-wire material literal (via its
|
||||
// FRET_WIRE_IDLE_HEX color, unique to it) — an unscoped match would pass
|
||||
// off any other depthTest:false material in the file. Asserted as two
|
||||
// separate anchored matches so property order inside the literal still
|
||||
// isn't pinned.
|
||||
assert.match(
|
||||
s,
|
||||
/color\s*:\s*FRET_WIRE_IDLE_HEX[\s\S]{0,400}?depthTest\s*:\s*false/,
|
||||
'the fret wire material itself must set depthTest: false',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/color\s*:\s*FRET_WIRE_IDLE_HEX[\s\S]{0,400}?depthWrite\s*:\s*false/,
|
||||
'the fret wire material itself must set depthWrite: false (no z-buffer pollution)',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/fretWireMats\s*\[\s*f\s*\]\s*=\s*mat\s*;/,
|
||||
'buildBoard must store each wire material in fretWireMats[f]',
|
||||
);
|
||||
});
|
||||
|
||||
test('update() sets fret wire FRET_WIRE_ACTIVE_HEX (gold) for in-anchor frets, FRET_WIRE_IDLE_HEX otherwise', () => {
|
||||
// Uses anchorLaneBoundsAt() — the same helper the dynamic lane uses —
|
||||
// so fret wire highlight aligns exactly with the lane edges:
|
||||
// dMin = fret - 1, dMax = fret + width - 1
|
||||
// Example: { fret: 3, width: 4 } → dMin=2, dMax=6 → wires 2..6 gold.
|
||||
const s = src();
|
||||
assert.match(
|
||||
s,
|
||||
/fretWireMats\.length/,
|
||||
'update() must guard the per-frame fret wire loop on fretWireMats.length',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/anchorLaneBoundsAt\(\s*anchors\s*,\s*now\s*\)/,
|
||||
'update() must use anchorLaneBoundsAt(anchors, now) to get fret wire range',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/_m\.color\.setHex\(\s*FRET_WIRE_ACTIVE_HEX\s*\)/,
|
||||
'update() must set FRET_WIRE_ACTIVE_HEX for in-anchor fret wires',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/FRET_WIRE_ACTIVE_HEX\s*=\s*0xD8A636/,
|
||||
'FRET_WIRE_ACTIVE_HEX must stay the anchor-lane gold 0xD8A636',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/_m\.color\.setHex\(\s*FRET_WIRE_IDLE_HEX\s*\)/,
|
||||
'update() must set FRET_WIRE_IDLE_HEX for out-of-anchor fret wires',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/_fwBounds\.dMin/,
|
||||
'update() must use dMin from anchorLaneBoundsAt (= fret - 1)',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/_fwBounds\.dMax/,
|
||||
'update() must use dMax from anchorLaneBoundsAt (= fret + width - 1)',
|
||||
);
|
||||
});
|
||||
|
||||
test('fret-column markers use Z-proportional renderOrder between chord frame and gem', () => {
|
||||
// pFretColMarker labels use the named stack: one step above chord frame
|
||||
// and one step below note gems at the same depth.
|
||||
// This ensures chord frame borders never overdraw the label and the label
|
||||
// never overdraws gems, at every Z position across the lookahead window.
|
||||
assert.match(
|
||||
src(),
|
||||
/sp\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'FRET_COLUMN'\s*\)\s*;/,
|
||||
'pFretColMarker renderOrder must use renderOrderForLayerAtZ(z, FRET_COLUMN)',
|
||||
);
|
||||
assert.ok(layerIndex('FRET_COLUMN') > layerIndex('CHORD_FRAME'));
|
||||
assert.ok(layerIndex('FRET_COLUMN') < layerIndex('NOTE_OUTLINE'));
|
||||
});
|
||||
|
||||
test('technique labels and ghost-fret overlay use renderOrder 1000', () => {
|
||||
// 1000 is well above the entire Z-proportional range and the
|
||||
// string/cadence layer — labels must always be readable.
|
||||
const matches = src().match(/m\.renderOrder\s*=\s*1000\s*;/g) || [];
|
||||
assert.ok(
|
||||
matches.length >= 2,
|
||||
'at least two renderOrder = 1000 assignments must exist (technique labels + ghost fret)',
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Z-proportional formulas — chord frame / note gem / technique marker
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('chordFrameRenderOrder uses renderOrderForLayerAtZ(z, CHORD_FRAME)', () => {
|
||||
// Per-chord frame renderOrder mirrors the note-gem scale with an earlier
|
||||
// layer from RENDER_ORDER_LAYER_STACK.
|
||||
assert.match(
|
||||
src(),
|
||||
/const\s+chordFrameRenderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_FRAME'\s*\)\s*;/,
|
||||
'chordFrameRenderOrder must use renderOrderForLayerAtZ(z, CHORD_FRAME)',
|
||||
);
|
||||
// renderOrderForLayerAtZ implementation lives in geometry.js since h3d-carve-1b.
|
||||
assert.match(geo(), /const\s+RENDER_ORDER_LAYER_INDEX\s*=\s*Object\.freeze\(\s*RENDER_ORDER_LAYER_STACK\.reduce\(/);
|
||||
assert.match(geo(), /const\s+layerIndex\s*=\s*RENDER_ORDER_LAYER_INDEX\[layerName\]\s*;/);
|
||||
assert.match(geo(), /if\s*\(\s*layerIndex\s*===\s*undefined\s*\)\s*throw\s+new\s+Error\(`Unknown 3D highway depth layer: \$\{layerName\}`\)\s*;/);
|
||||
assert.match(geo(), /const\s+depthRenderOrder\s*=\s*Math\.max\(\s*RENDER_ORDER_FAR_CLAMP\s*,\s*Math\.round\(\s*RENDER_ORDER_AT_Z_ZERO\s*\+\s*worldZ\s*\/\s*K\s*\)\s*\)\s*;/);
|
||||
// Layer is a sub-unit fraction so the integer depth bucket strictly
|
||||
// dominates (a farther object can't outrank a nearer one via a higher
|
||||
// layer); the layer only breaks ties within the same depth bucket.
|
||||
assert.match(geo(), /return\s+depthRenderOrder\s*\+\s*layerIndex\s*\/\s*RENDER_ORDER_LAYER_STACK\.length\s*;/);
|
||||
assert.ok(layerIndex('CHORD_FRAME') < layerIndex('NOTE_OUTLINE'));
|
||||
});
|
||||
|
||||
test('note outline uses renderOrderForLayerAtZ(noteZ, NOTE_OUTLINE)', () => {
|
||||
// Per-note gem renderOrder. noteZ is negative (ahead of hit line → negative
|
||||
// Z in world space). At noteZ=0 (on the hit line), the note outline uses
|
||||
// the near render-order base plus its layer index; far notes clamp to the
|
||||
// far render-order base plus that same layer index.
|
||||
// The ordered layer list keeps gems above chord frames everywhere.
|
||||
assert.match(
|
||||
src(),
|
||||
/outline\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*noteZ\s*,\s*'NOTE_OUTLINE'\s*\)\s*;/,
|
||||
'note outline must use renderOrderForLayerAtZ(noteZ, NOTE_OUTLINE)',
|
||||
);
|
||||
assert.strictEqual(layerIndex('CHORD_FILL'), 0);
|
||||
});
|
||||
|
||||
test('techniqueMarkerRenderOrder uses the named technique marker layer above gem core', () => {
|
||||
// Technique markers (PM cross, bend arrow, H/P chevron, etc.) must overlay
|
||||
// the gem itself.
|
||||
assert.match(
|
||||
src(),
|
||||
/const\s+techniqueMarkerRenderOrder\s*=\s*renderOrderForLayerAtZ\(\s*noteZ\s*,\s*'TECHNIQUE_MARKER'\s*\)/,
|
||||
'techniqueMarkerRenderOrder must use TECHNIQUE_MARKER',
|
||||
);
|
||||
assert.ok(layerIndex('TECHNIQUE_MARKER') > layerIndex('NOTE_CORE'));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Intra-chord layering (chord fill < PM/FH fill < PM/FH lines < frame edge)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('chord fill interior uses the named layer below chord frame', () => {
|
||||
// The translucent chord-box fill sits below the frame edge so the edge
|
||||
// always wins when both cover the same pixel.
|
||||
assert.match(
|
||||
src(),
|
||||
/fill\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_FILL'\s*\)\s*;/,
|
||||
'chord fill must use CHORD_FILL',
|
||||
);
|
||||
assert.ok(layerIndex('CHORD_FILL') < layerIndex('CHORD_FRAME'));
|
||||
});
|
||||
|
||||
test('PM/FH X fill (pPMXFill / pFHXFill) uses its ordered layer', () => {
|
||||
// The black background fill of the muted-note X symbol is above chord fill
|
||||
// but below the X lines — same chord, so same chord-frame renderOrder base.
|
||||
const matches = src().match(/xf\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_STRUM_FILL'\s*\)\s*;/g) || [];
|
||||
assert.ok(
|
||||
matches.length >= 2,
|
||||
'both PM and FH X-fill meshes must use CHORD_STRUM_FILL (found ' + matches.length + ')',
|
||||
);
|
||||
assert.ok(layerIndex('CHORD_FILL') < layerIndex('CHORD_STRUM_FILL'));
|
||||
assert.ok(layerIndex('CHORD_STRUM_FILL') < layerIndex('CHORD_STRUM_LINE'));
|
||||
});
|
||||
|
||||
test('PM/FH X lines (pMuteXLines / pFHXLines) use their ordered layer', () => {
|
||||
// The coloured X stroke lines are above the black fill but below
|
||||
// the chord frame border edge, so they don't escape the box.
|
||||
const matches = src().match(/xl\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_STRUM_LINE'\s*\)\s*;/g) || [];
|
||||
assert.ok(
|
||||
matches.length >= 2,
|
||||
'both PM and FH X-line meshes must use CHORD_STRUM_LINE (found ' + matches.length + ')',
|
||||
);
|
||||
assert.ok(layerIndex('CHORD_STRUM_LINE') < layerIndex('CHORD_FRAME'));
|
||||
});
|
||||
|
||||
test('chord frame glow uses the layer after chord frame', () => {
|
||||
// Accent glow draws after the frame while still remaining below connectors
|
||||
// and note symbols in the ordered layer list.
|
||||
assert.match(
|
||||
src(),
|
||||
/b\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_EDGE_GLOW'\s*\)\s*;/,
|
||||
'chord frame edge slabs must use CHORD_EDGE_GLOW',
|
||||
);
|
||||
assert.ok(layerIndex('CHORD_EDGE_GLOW') > layerIndex('CHORD_FRAME'));
|
||||
assert.ok(layerIndex('CHORD_EDGE_GLOW') < layerIndex('CONNECTOR_LINE'));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sustain-trail strip & ribbon — always below chord frame of same depth
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('sus-trail strip renderOrder formula keeps trails strictly below chord frames at same Z', () => {
|
||||
// Sustain trails use the ordered layer immediately below chord frames at
|
||||
// the same depth.
|
||||
assert.match(
|
||||
src(),
|
||||
/const\s+trailRenderOrder\s*=\s*renderOrderForLayerAtZ\(\s*Math\.min\(\s*0\s*,\s*zCenter\s*\)\s*,\s*'SUSTAIN_TRAIL'\s*\)\s*;/,
|
||||
'sus-trail strip renderOrder must use renderOrderForLayerAtZ(min zCenter, SUSTAIN_TRAIL)',
|
||||
);
|
||||
assert.ok(layerIndex('SUSTAIN_TRAIL') < layerIndex('CHORD_FRAME'));
|
||||
});
|
||||
|
||||
test('sus-trail ribbon renderOrder formula mirrors strip formula using time-based depth', () => {
|
||||
// Ribbons use _ribDt (time from now to ribbon midpoint) converted to the
|
||||
// same Z scale as dZ() on the sustain-trail layer.
|
||||
assert.match(
|
||||
src(),
|
||||
/const\s+ribbonRenderOrder\s*=\s*renderOrderForLayerAtZ\(\s*-\s*_ribDt\s*\*\s*TS\s*,\s*'SUSTAIN_TRAIL'\s*\)\s*;/,
|
||||
'sus-trail ribbon renderOrder must use renderOrderForLayerAtZ on the sustain-trail layer',
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Note gem ordering (outline < core, both driven by named depth layers)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('note gem outline uses the named outline layer', () => {
|
||||
assert.match(
|
||||
src(),
|
||||
/outline\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*noteZ\s*,\s*'NOTE_OUTLINE'\s*\)\s*;/,
|
||||
'note gem outline must use NOTE_OUTLINE',
|
||||
);
|
||||
assert.ok(layerIndex('NOTE_OUTLINE') > layerIndex('FRET_COLUMN'));
|
||||
});
|
||||
|
||||
test('note gem core uses the named layer above outline', () => {
|
||||
assert.match(
|
||||
src(),
|
||||
/core\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*noteZ\s*,\s*'NOTE_CORE'\s*\)\s*;/,
|
||||
'note gem core must use NOTE_CORE',
|
||||
);
|
||||
assert.ok(layerIndex('NOTE_CORE') > layerIndex('NOTE_OUTLINE'));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Key relative-ordering invariants (derived constants)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('chord frame layer is below note outline layer', () => {
|
||||
// Chord frames must always render below note gems, even at maximum depth
|
||||
// (far end of the lookahead).
|
||||
//
|
||||
assert.ok(layerIndex('CHORD_FRAME') < layerIndex('NOTE_OUTLINE'));
|
||||
});
|
||||
|
||||
test('fret labels are above note symbols in the named stack', () => {
|
||||
assert.ok(layerIndex('NOTE_FRET_LABEL') > layerIndex('NOTE_CORE'), 'note fret labels must draw above gem core');
|
||||
assert.ok(layerIndex('NOTE_FRET_LABEL') > layerIndex('TECHNIQUE_MARKER'), 'note fret labels must draw above technique markers');
|
||||
assert.ok(layerIndex('ARP_NOTE_FRET_LABEL') > layerIndex('NOTE_FRET_LABEL'), 'arp labels retain a one-layer tie-breaker');
|
||||
assert.ok(layerIndex('CHORD_FRET_LABEL') > layerIndex('NOTE_CORE'), 'chord-loop fret labels must draw above gem core at the same depth');
|
||||
assert.ok(layerIndex('NOTE_FRET_LABEL') > layerIndex('BOARD_FRET_WIRE'), 'note fret labels must clear static fret wires');
|
||||
assert.ok(layerIndex('CHORD_FRET_LABEL') > layerIndex('BOARD_FRET_WIRE'), 'chord fret labels must clear static fret wires');
|
||||
});
|
||||
|
||||
test('string mesh layer is above note symbols and below labels', () => {
|
||||
// Board strings are never occluded by flying gems, but labels still appear above strings.
|
||||
const s = src();
|
||||
assert.match(s, /mesh\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*0\s*,\s*'BOARD_STRING'\s*\)\s*;/, 'string mesh must use BOARD_STRING');
|
||||
// Confirm 1000 also exists (labels above strings)
|
||||
assert.match(s, /m\.renderOrder\s*=\s*1000\s*;/, 'technique label renderOrder 1000 must exist');
|
||||
assert.ok(layerIndex('BOARD_STRING') > layerIndex('TECHNIQUE_MARKER'), 'string mesh layer must be above note symbols');
|
||||
assert.ok(layerIndex('BOARD_STRING') < layerIndex('NOTE_FRET_LABEL'), 'string mesh layer must be below fret labels');
|
||||
});
|
||||
|
||||
test('fret-column marker layer is above chord frame and below gem outline', () => {
|
||||
assert.ok(layerIndex('FRET_COLUMN') > layerIndex('CHORD_FRAME'), 'fret-column marker layer must be above chord frame');
|
||||
assert.ok(layerIndex('FRET_COLUMN') < layerIndex('NOTE_OUTLINE'), 'fret-column marker layer must be below gem outline');
|
||||
assert.match(src(), /renderOrderForLayerAtZ\(\s*z\s*,\s*'FRET_COLUMN'\s*\)/);
|
||||
});
|
||||
|
||||
test('static fret wire layer is above string mesh and note symbols', () => {
|
||||
// Structural invariant: fret wires must always draw after (on top of) strings.
|
||||
assert.ok(layerIndex('BOARD_FRET_WIRE') > layerIndex('BOARD_STRING'), 'fret wire must be above string mesh');
|
||||
assert.ok(layerIndex('BOARD_FRET_WIRE') > layerIndex('TECHNIQUE_MARKER'), 'fret wire must be above note symbols');
|
||||
assert.ok(zZeroRenderOrder() + layerIndex('BOARD_FRET_WIRE') < 1000, 'fret wire must be below technique labels (1000)');
|
||||
assert.match(src(), /fw\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*0\s*,\s*'BOARD_FRET_WIRE'\s*\)\s*;/, 'buildBoard fret wire must use BOARD_FRET_WIRE');
|
||||
assert.match(src(), /mesh\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*0\s*,\s*'BOARD_STRING'\s*\)\s*;/, 'string mesh must use BOARD_STRING');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
// h3d-carve-15: U-section (per-frame renderer) pin tests.
|
||||
//
|
||||
// Guards:
|
||||
// 1. Wiring: createRenderer factory exists in renderer.js and screen.js
|
||||
// imports + calls it with the expected DI param count (241).
|
||||
// 2. Kill tests: extracted private helpers are live in renderer.js; gut and
|
||||
// restore proves RED.
|
||||
// 3. Export contract: { update } returned by createRenderer.
|
||||
// 4. Caller-list corrections: _applyNoteCamTargets and lookaheadSmoothCamStep
|
||||
// have exactly the audited caller counts.
|
||||
// 5. screen.js tombstone: original U-section bodies are absent from screen.js.
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const RENDERER_JS = path.join(
|
||||
__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'renderer.js'
|
||||
);
|
||||
const SCREEN_JS = path.join(
|
||||
__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js'
|
||||
);
|
||||
|
||||
const src = fs.readFileSync(RENDERER_JS, 'utf8');
|
||||
const screenSrc = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
|
||||
// ── 1. Wiring guard ──────────────────────────────────────────────────────────
|
||||
|
||||
test('createRenderer is exported from renderer.js', () => {
|
||||
assert.match(src, /export function createRenderer/,
|
||||
'renderer.js must export createRenderer');
|
||||
});
|
||||
|
||||
test('screen.js imports createRenderer from renderer.js', () => {
|
||||
assert.match(screenSrc, /import.*createRenderer.*from.*renderer\.js/,
|
||||
'screen.js must import createRenderer');
|
||||
});
|
||||
|
||||
test('screen.js wiring block contains expected DI param count (241)', () => {
|
||||
const wiringMatch = screenSrc.match(/createRenderer\(\{([\s\S]*?)\}\)/);
|
||||
assert.ok(wiringMatch, 'screen.js must contain createRenderer({...}) call');
|
||||
const body = wiringMatch[1];
|
||||
|
||||
const getterCount = (body.match(/\bget[A-Z]\w+\s*:/g) || []).length;
|
||||
const setterCount = (body.match(/\bset[A-Z]\w+\s*:/g) || []).length;
|
||||
const shorthandCount = body.split('\n').reduce((acc, line) => {
|
||||
const t = line.trim();
|
||||
if (!t || t.startsWith('//') || t.includes('=>')) return acc;
|
||||
return acc + (t.match(/\b[A-Za-z_][A-Za-z0-9_]*\b(?=\s*,)/g) || []).length;
|
||||
}, 0);
|
||||
|
||||
const total = getterCount + setterCount + shorthandCount;
|
||||
assert.strictEqual(total, 241,
|
||||
`DI param count mismatch: got ${total} (getters=${getterCount}, setters=${setterCount}, shorthands=${shorthandCount})`);
|
||||
});
|
||||
|
||||
// ── 2. Tombstone — original bodies must be absent from screen.js ─────────────
|
||||
|
||||
test('screen.js does not contain function lookaheadSmoothCamStep body', () => {
|
||||
// Body was: Math.min(0.2, Math.max(1e-4, dtSec))
|
||||
assert.doesNotMatch(screenSrc, /function lookaheadSmoothCamStep/,
|
||||
'lookaheadSmoothCamStep body must be in renderer.js, not screen.js');
|
||||
});
|
||||
|
||||
test('screen.js does not contain function _applyNoteCamTargets body', () => {
|
||||
assert.doesNotMatch(screenSrc, /function _applyNoteCamTargets/,
|
||||
'_applyNoteCamTargets body must be in renderer.js, not screen.js');
|
||||
});
|
||||
|
||||
test('screen.js does not contain function _buildFretLabelSet body', () => {
|
||||
assert.doesNotMatch(screenSrc, /function _buildFretLabelSet/,
|
||||
'_buildFretLabelSet body must be in renderer.js, not screen.js');
|
||||
});
|
||||
|
||||
test('screen.js does not contain function smoothNow body', () => {
|
||||
// The name smoothNow also appears in camera.js; key is it should not
|
||||
// appear in screen.js after the carve.
|
||||
assert.doesNotMatch(screenSrc, /function smoothNow\b/,
|
||||
'smoothNow body must be in renderer.js, not screen.js');
|
||||
});
|
||||
|
||||
test('screen.js does not contain function update body (per-frame draw loop)', () => {
|
||||
// The IIFE-level update() is gone. Key distinctive pattern: the region C
|
||||
// song-change detection block (const newSongKey) only appears inside update().
|
||||
// The wiring call has `const { update } = createRenderer(...)` not `function update(`.
|
||||
assert.doesNotMatch(screenSrc, /function update\s*\(bundle\)/,
|
||||
'function update(bundle) body must not appear in screen.js');
|
||||
});
|
||||
|
||||
// ── 3. Renderer exports update ───────────────────────────────────────────────
|
||||
|
||||
test('renderer.js return value exports update function', () => {
|
||||
assert.match(src, /return\s*\{\s*update\s*\}/,
|
||||
'createRenderer must return { update }');
|
||||
});
|
||||
|
||||
// ── 4. Caller-list corrections (contract §6) ─────────────────────────────────
|
||||
|
||||
test('_applyNoteCamTargets has exactly 2 call sites in renderer.js', () => {
|
||||
const calls = src.match(/_applyNoteCamTargets\s*\(/g) || [];
|
||||
// Subtract 1 for the function declaration itself
|
||||
const callSites = calls.length - 1;
|
||||
assert.strictEqual(callSites, 2,
|
||||
`_applyNoteCamTargets must have exactly 2 caller sites; found ${callSites}`);
|
||||
});
|
||||
|
||||
test('lookaheadSmoothCamStep has exactly 3 call sites in renderer.js', () => {
|
||||
// Strip comment lines before counting to avoid matching the comment mention.
|
||||
const noComments = src.split('\n').filter(l => !l.trim().startsWith('//')).join('\n');
|
||||
const calls = noComments.match(/lookaheadSmoothCamStep\s*\(/g) || [];
|
||||
const callSites = calls.length - 1; // subtract function declaration
|
||||
assert.strictEqual(callSites, 3,
|
||||
`lookaheadSmoothCamStep must have exactly 3 caller sites (9963/9974/9978); found ${callSites}`);
|
||||
});
|
||||
|
||||
// ── 5. smoothNow return-value semantics (correction 3) ───────────────────────
|
||||
|
||||
test('smoothNow setter-return pattern: no bare return (_frameNow = ...) in renderer.js', () => {
|
||||
// Must not use compound-assignment return; must use const v / setFrameNow / return v
|
||||
assert.doesNotMatch(src, /return\s*\(\s*_frameNow\s*=/,
|
||||
'smoothNow must not use return (_frameNow = raw); use setFrameNow + return v');
|
||||
});
|
||||
|
||||
test('smoothNow uses setFrameNow before return in renderer.js', () => {
|
||||
assert.match(src, /setFrameNow\(/,
|
||||
'smoothNow must call setFrameNow() to persist frameNow');
|
||||
});
|
||||
|
||||
// ── 6. Structural guard: createRenderer is after sub-factories in screen.js ──
|
||||
|
||||
test('createRenderer wiring is after createNoteRenderer in screen.js', () => {
|
||||
const nrPos = screenSrc.indexOf('createNoteRenderer({');
|
||||
const renPos = screenSrc.indexOf('createRenderer({');
|
||||
assert.ok(renPos > nrPos,
|
||||
'createRenderer({}) wiring must appear after createNoteRenderer({}) in screen.js');
|
||||
});
|
||||
|
||||
test('createRenderer wiring is after createCamera in screen.js', () => {
|
||||
const camPos = screenSrc.indexOf('createCamera({');
|
||||
const renPos = screenSrc.indexOf('createRenderer({');
|
||||
assert.ok(renPos > camPos,
|
||||
'createRenderer({}) wiring must appear after createCamera({}) in screen.js');
|
||||
});
|
||||
@@ -17,10 +17,11 @@ const path = require('node:path');
|
||||
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||
// h3d-carve-14: V-section moved to note-renderer.js
|
||||
const NOTE_RENDERER_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'note-renderer.js');
|
||||
const RENDERER_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'renderer.js');
|
||||
const _noteSrc = fs.readFileSync(NOTE_RENDERER_JS, 'utf8');
|
||||
|
||||
test('a _slideTargetSet pre-pass builds the suppressed-gem set from bundle.notes', () => {
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + _noteSrc;
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + _noteSrc + '\n' + fs.readFileSync(RENDERER_JS, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/const\s+checkSrc\s*=\s*\([^)]*\)\s*=>\s*\{[\s\S]*?stSet\.add\(/,
|
||||
@@ -34,7 +35,7 @@ test('a _slideTargetSet pre-pass builds the suppressed-gem set from bundle.notes
|
||||
});
|
||||
|
||||
test('_isSlideTgt is derived from _slideTargetSet membership', () => {
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + _noteSrc;
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + _noteSrc + '\n' + fs.readFileSync(RENDERER_JS, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/_isSlideTgt\s*=\s*!!\(\s*_slideTargetSet\s*&&\s*_slideTargetSet\.has\(/,
|
||||
@@ -45,7 +46,7 @@ test('_isSlideTgt is derived from _slideTargetSet membership', () => {
|
||||
test('_isSlideTgt is threaded into drawNote as the skipBody argument', () => {
|
||||
// drawNote(n, now, openX, skipLabel, skipBody, ...) — _isSlideTgt sits in
|
||||
// the 5th (skipBody) position so the gem body is suppressed.
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + _noteSrc;
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + _noteSrc + '\n' + fs.readFileSync(RENDERER_JS, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/drawNote\(\s*n\s*,\s*now\s*,\s*singleOpenX\s*,\s*skipLabel\s*,\s*_isSlideTgt\s*,/,
|
||||
@@ -56,7 +57,7 @@ test('_isSlideTgt is threaded into drawNote as the skipBody argument', () => {
|
||||
test('the sustain trail renders for all notes, including skipBody slide targets', () => {
|
||||
// The trail block must stay outside the !skipBody gem gate so suppressed
|
||||
// slide-target gems still show their slide trail.
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + _noteSrc;
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + _noteSrc + '\n' + fs.readFileSync(RENDERER_JS, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/Rendered for ALL notes with sustain, including skipBody=true/,
|
||||
|
||||
@@ -20,6 +20,7 @@ const path = require('node:path');
|
||||
|
||||
const highwayJs = path.join(__dirname, '..', '..', 'static', 'highway.js');
|
||||
const highway3dJs = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||
const RENDERER_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'renderer.js');
|
||||
|
||||
// Brace-balanced extraction (same helper shape as highway_note_state.test.js).
|
||||
function extractBlock(src, signature) {
|
||||
@@ -60,7 +61,7 @@ test('core _makeBundle exposes isPlaying derived from the chart-clock anchor', (
|
||||
});
|
||||
|
||||
test('smoothNow returns raw and re-anchors when the host reports not playing', () => {
|
||||
const src = fs.readFileSync(highway3dJs, 'utf8');
|
||||
const src = fs.readFileSync(highway3dJs, 'utf8') + '\n' + fs.readFileSync(RENDERER_JS, 'utf8');
|
||||
const fn = extractBlock(src, 'function smoothNow(bundle)');
|
||||
// Strict === false so downlevel hosts (isPlaying undefined) fall through
|
||||
// to the existing staleness-based interpolation cap.
|
||||
@@ -69,14 +70,16 @@ test('smoothNow returns raw and re-anchors when the host reports not playing', (
|
||||
|
||||
// The pause branch re-anchors the clock state and returns the raw sample
|
||||
// (no forward extrapolation).
|
||||
// h3d-carve-15: bare assignments → DI setter calls in renderer.js
|
||||
const branch = fn.slice(guardIdx);
|
||||
assert.match(branch, /_clkAudioT\s*=\s*raw/, 'pause branch must re-anchor _clkAudioT to raw');
|
||||
assert.match(branch, /_clkPerf\s*=\s*p/, 'pause branch must re-anchor _clkPerf to now');
|
||||
assert.match(branch, /return\s*\(\s*_frameNow\s*=\s*raw\s*\)/, 'pause branch must return raw');
|
||||
assert.match(branch, /setClkAudioT\s*\(\s*raw\s*\)/, 'pause branch must re-anchor _clkAudioT to raw');
|
||||
assert.match(branch, /setClkPerf\s*\(\s*p\s*\)/, 'pause branch must re-anchor _clkPerf to now');
|
||||
assert.match(branch, /setFrameNow\s*\([^)]+\)/, 'pause branch must call setFrameNow (return raw)');
|
||||
|
||||
// The pause gate must come before the new-sample re-anchor / interpolation
|
||||
// path so a frozen clock never extrapolates forward.
|
||||
const newSampleIdx = fn.search(/if\s*\(\s*raw\s*!==\s*_clkAudioT\s*\)/);
|
||||
// h3d-carve-15: _clkAudioT accessed via getClkAudioT() getter in renderer.js
|
||||
const newSampleIdx = fn.search(/if\s*\(\s*raw\s*!==\s*(?:_clkAudioT|getClkAudioT\s*\(\s*\))\s*\)/);
|
||||
assert.ok(newSampleIdx !== -1, 'smoothNow new-sample branch not found');
|
||||
assert.ok(guardIdx < newSampleIdx, 'isPlaying pause gate must precede the interpolation path');
|
||||
});
|
||||
|
||||
@@ -13,23 +13,26 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||
const RENDERER_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'renderer.js');
|
||||
|
||||
test('sustain rails are gated on multi-note chords with a known box width within AHEAD', () => {
|
||||
// Each chord in a sequence (including repeats) draws a rail from its onset
|
||||
// to the next chord's onset, chaining together to cover the full handshape
|
||||
// duration visually. Single notes have no chord frame to anchor a rail to.
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
// h3d-carve-15: sustain-rail block moved to renderer.js
|
||||
const rendererSrc = fs.readFileSync(RENDERER_JS, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
rendererSrc,
|
||||
/if\s*\(\s*chShape\.size\s*>\s*1\s*&&\s*chordOpenBoxW\s*!=\s*null\s*&&\s*chDt\s*<\s*AHEAD\s*\)/,
|
||||
'sustain-rail block must stay gated on chShape.size > 1, chordOpenBoxW and chDt < AHEAD',
|
||||
);
|
||||
});
|
||||
|
||||
test('sustain rails pick arpeggio color for arpeggio frames, teal otherwise', () => {
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
// h3d-carve-15: rail color expression moved to renderer.js
|
||||
const rendererSrc = fs.readFileSync(RENDERER_JS, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
rendererSrc,
|
||||
/chordHighwayLavenderArpVisual\s*\?\s*ARPEGGIO_RIM_BLUE_HEX\s*:\s*CHORD_BOX_TEAL_HEX/,
|
||||
'rail color must select ARPEGGIO_RIM_BLUE_HEX for arpeggio frames and CHORD_BOX_TEAL_HEX for chords',
|
||||
);
|
||||
|
||||
@@ -331,7 +331,12 @@ test('default 2D draw path prefers the staged views (drawNotes/drawChords/drawSu
|
||||
});
|
||||
|
||||
test('highway_3d nut labels prefer the transform-aware bundle tuning/capo', () => {
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js'), 'utf8');
|
||||
// h3d-carve-3: _openStringPitchLabelsForTuning (let tuning / let cap) moved to src/utils.js
|
||||
const RENDERER_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'renderer.js');
|
||||
const UTILS_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'utils.js');
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js'), 'utf8')
|
||||
+ '\n' + fs.readFileSync(RENDERER_JS, 'utf8')
|
||||
+ '\n' + fs.readFileSync(UTILS_JS, 'utf8');
|
||||
assert.match(src, /let tuning = Array\.isArray\(bundle\.tuning\) \? bundle\.tuning : \(songInfo && songInfo\.tuning\)/,
|
||||
'label derivation reads a well-formed bundle.tuning first, songInfo otherwise');
|
||||
assert.match(src, /let cap = bundle\.capo;/,
|
||||
|
||||
@@ -17,6 +17,7 @@ const highwayJs = path.join(__dirname, '..', '..', 'static', 'highway.js');
|
||||
// cannot import per-instance state without two panels sharing it.
|
||||
const primitivesJs = path.join(__dirname, '..', '..', 'static', 'js', 'highway-state-primitives.js');
|
||||
const highway3dJs = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||
const RENDERER_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'renderer.js');
|
||||
// h3d-carve-14: V-section (drawNote) moved to note-renderer.js; tests that
|
||||
// pin its patterns must now also search note-renderer.js.
|
||||
const _h3dNoteRendererJs = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'note-renderer.js');
|
||||
@@ -144,7 +145,8 @@ test('default 2D renderer threads note state into drawNote / drawSustains / chor
|
||||
test('3D highway captures bundle.getNoteState and overrides legacy hit/miss with the provider verdict', () => {
|
||||
// h3d-carve-14: _ndGetNoteState captured in update() (screen.js); _showHit
|
||||
// and its drawNote body are now in note-renderer.js — search both.
|
||||
const src = fs.readFileSync(highway3dJs, 'utf8') + '\n' + _h3dNoteRendererSrc;
|
||||
// h3d-carve-15: _ndGetNoteState / _ndHasProvider captures moved to renderer.js update()
|
||||
const src = fs.readFileSync(highway3dJs, 'utf8') + '\n' + _h3dNoteRendererSrc + '\n' + fs.readFileSync(RENDERER_JS, 'utf8');
|
||||
assert.match(src, /_ndGetNoteState\s*=\s*\(bundle\s*&&\s*typeof\s+bundle\.getNoteState\s*===\s*['"]function['"]\)\s*\?\s*bundle\.getNoteState\s*:\s*null/, 'update() must capture bundle.getNoteState into _ndGetNoteState');
|
||||
// Provider verdict wins: miss => not _showHit; otherwise provider state
|
||||
// or the legacy fallback (`hit`) plus the pre-hit ghost window preview.
|
||||
@@ -152,7 +154,8 @@ test('3D highway captures bundle.getNoteState and overrides legacy hit/miss with
|
||||
});
|
||||
|
||||
test('3D highway captures _ndHasProvider via bundle.getNoteStateProvider (feedBack#254)', () => {
|
||||
const src = fs.readFileSync(highway3dJs, 'utf8') + '\n' + _h3dNoteRendererSrc;
|
||||
// h3d-carve-15: _ndGetNoteState / _ndHasProvider captures moved to renderer.js update()
|
||||
const src = fs.readFileSync(highway3dJs, 'utf8') + '\n' + _h3dNoteRendererSrc + '\n' + fs.readFileSync(RENDERER_JS, 'utf8');
|
||||
// Detect-mode behavior — verdict-window cull extension, chord-frame
|
||||
// hold floor, and the smart drawNote cull — must be gated on a real
|
||||
// provider being registered, not on the always-present bundle.
|
||||
|
||||
Reference in New Issue
Block a user