feedBack/tests/js/highway_teaching_marks.test.js
Byron Gamatos 36cf77dc44
refactor(highway): carve the 2D drawing layer into highway-draw.js (R3c) (#917)
18 functions, 1,245 lines. highway.js 3,972 -> 2,727 (-31%). The biggest R3c slice: notes,
sustains, chords, strum groups, unison bends and lyrics — everything the default renderer
paints each frame.

━━━ MUTABILITY, NOT LOCATION, DECIDES WHERE A THING BELONGS ━━━

Three per-instance caches came out with this slice, and they are why it needed care:

    _frameMismatchWarned   a warn-once Set of chord ids     (feedBack#88)
    _chordRenderInfo       a WeakMap of chord -> chain info
    _lyricMeasureCache     Map<fontSize, Map<text, width>>

All three are MUTATED. Left at module scope they would be SHARED ACROSS PANELS — one
highway's lyric widths and chord chains stomping another's, silently, with nothing throwing.
createHighway() is a factory (the constitution publishes window.createHighway so a plugin can
build a second highway), so they are lifted onto hwState, which is exactly what hwState is for.

The shimmer LUT went the OTHER way — to MODULE scope in highway-geometry.js. It is a
deterministic xorshift table, byte-for-byte identical for every instance, so sharing it is not
merely safe but BETTER: built once for the page rather than once per panel.

Same slice, opposite directions, decided entirely by whether the thing mutates.

━━━ MY SCRIPT WAS WRONG TWICE. THE GATES CAUGHT BOTH. ━━━

1. HAND-LISTED THE MOVE SET. I listed 10 functions and missed six that drawChords needs
   (_ensureChordRenderCache, bsearchChords, getChordTemplateInfo, _computeChordBox,
   _updateFretLinePreview, _drawFretLineChordPreview). The no-undef gate named every one. The
   set is now DERIVED from the dependency closure — 18, not 10.

2. JUDGED PURITY TOO EARLY, and this one is subtle. I classified _computeChordBox as pure
   because its ORIGINAL body never mentions hwState. Then the call-site rewriter injected
   `fretX(hwState, …)` INTO it — fretX takes hwState now (#916) — leaving a function that
   references an hwState it was never given. Purity has to be judged from the body AS IT WILL
   BE, so the classifier iterates to a fixed point: a function needs hwState if it mentions it,
   OR calls anything that now takes it. That moved _computeChordBox to the stateful side.

VERIFIED. A/B against origin/main: IDENTICAL, zero page errors. The PLUGIN BUNDLE contract is
byte-identical (b.fretX arity 3, b.getNoteState arity 2, both stable references, both correct
under the old calling convention). PERF GATE PASSES AT 1.92ms against its 12ms budget — and
this is the slice that could really have cost something: the ENTIRE per-frame drawing path is
now cross-module. It costs nothing measurable.

TESTS. highway_teaching_marks follows strumGroupBuckets to the new module. The two source-shape
harnesses now read highway.js AND every static/js/highway-*.js, rather than being re-pinned at
whichever file currently holds a function — re-pinning breaks again next time, and a shape
assertion that silently stops finding its target is indistinguishable from one that passes.

node 1045, pytest 2416, ESLint 0, no-undef 0, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 13:00:29 +02:00

95 lines
4.3 KiB
JavaScript

// Behavioural tests for the teaching-marks (§6.2.2) render helpers:
// teachingFingerLabel / teachingDegreeLabel (both highways) and
// strumGroupBuckets (2D, drives the strum bracket). All pure, so we extract
// the function source by brace-matching and eval it in isolation — same
// pattern as highway_bend_curve.test.js.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
function extractFn(src, name) {
const start = src.indexOf('function ' + name);
assert.ok(start >= 0, `function ${name} must exist`);
const open = src.indexOf('{', start);
let depth = 0;
for (let i = open; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}' && --depth === 0) return src.slice(start, i + 1);
}
throw new Error(`unbalanced braces extracting ${name}`);
}
function loadFn(file, name) {
const src = fs.readFileSync(path.join(__dirname, '..', '..', file), 'utf8');
return new Function('"use strict";' + extractFn(src, name) + `\nreturn ${name};`)();
}
// R3c: the PURE geometry/label primitives were carved out of highway.js into
// static/js/highway-geometry.js. Same bodies, byte-for-byte — only the file moved.
const fingerLabel2D = loadFn('static/js/highway-geometry.js', 'teachingFingerLabel');
const degreeLabel2D = loadFn('static/js/highway-geometry.js', 'teachingDegreeLabel');
const fingerLabel3D = loadFn('plugins/highway_3d/screen.js', 'teachingFingerLabel');
const degreeLabel3D = loadFn('plugins/highway_3d/screen.js', 'teachingDegreeLabel');
const strumGroupBuckets = loadFn('static/js/highway-draw.js', 'strumGroupBuckets');
// ── teachingFingerLabel (fg) ─────────────────────────────────────────────────
for (const [name, fn] of [['2D', fingerLabel2D], ['3D', fingerLabel3D]]) {
test(`teachingFingerLabel (${name}) maps 0->T, 1..4->digit, else ''`, () => {
assert.equal(fn(0), 'T'); // thumb
assert.equal(fn(1), '1');
assert.equal(fn(4), '4'); // pinky
assert.equal(fn(-1), ''); // unset
assert.equal(fn(5), ''); // out of range
assert.equal(fn(1.5), ''); // non-integer
assert.equal(fn(undefined), '');
assert.equal(fn(null), '');
});
}
// ── teachingDegreeLabel (sd) ─────────────────────────────────────────────────
for (const [name, fn] of [['2D', degreeLabel2D], ['3D', degreeLabel3D]]) {
test(`teachingDegreeLabel (${name}) shows 0..11, else ''`, () => {
assert.equal(fn(0), '0'); // tonic
assert.equal(fn(7), '7'); // fifth
assert.equal(fn(11), '11');
assert.equal(fn(-1), ''); // unset
assert.equal(fn(12), ''); // out of range
assert.equal(fn(3.2), ''); // non-integer
assert.equal(fn(undefined), '');
});
}
// ── strumGroupBuckets (ch) ───────────────────────────────────────────────────
test('strumGroupBuckets groups notes sharing a ch >= 0, dropping lone notes', () => {
const items = [
{ id: 'a', ch: 5 },
{ id: 'b', ch: -1 }, // ungrouped
{ id: 'c', ch: 5 },
{ id: 'd', ch: 7 }, // lone group (only one member) -> dropped
{ id: 'e', ch: 5 },
];
const groups = strumGroupBuckets(items);
assert.equal(groups.length, 1);
assert.deepEqual(groups[0].map(n => n.id), ['a', 'c', 'e']);
});
test('strumGroupBuckets preserves first-seen group order and handles multiple groups', () => {
const items = [
{ id: 'a', ch: 2 }, { id: 'b', ch: 9 },
{ id: 'c', ch: 2 }, { id: 'd', ch: 9 },
];
const groups = strumGroupBuckets(items);
assert.deepEqual(groups.map(g => g.map(n => n.id)), [['a', 'c'], ['b', 'd']]);
});
test('strumGroupBuckets ignores non-integer / negative ch and bad input', () => {
assert.deepEqual(strumGroupBuckets([{ ch: -1 }, { ch: 1.5 }, { ch: null }, {}]), []);
assert.deepEqual(strumGroupBuckets([]), []);
assert.deepEqual(strumGroupBuckets(null), []);
});