feat(highway): render teaching marks fg/ch/sd on 2D + 3D (§6.2.2) (#538)

Render the three per-note teaching marks on both highways, mirroring the
bend-curve render (#532). Display only — no scoring / NoteVerifier coupling.

- 2D static/highway.js: fg renders by default as a small finger numeral hugging
  the gem (T = thumb, 1..4); sd (degree label) and ch (strum bracket connecting
  notes that share a ch key, arrow direction from pkd) are opt-in behind a new
  `showTeachingMarks` toggle (exposed via toggle/get/set + the bundle's
  `teachingMarksVisible` flag). Pure helpers teachingFingerLabel /
  teachingDegreeLabel / strumGroupBuckets drive the glyphs. ch bracket is
  note-stream-only (chord notes already read as one gesture).
- 3D plugins/highway_3d/screen.js: fg (default) + sd (opt-in, mirrors the 2D
  toggle via bundle.teachingMarksVisible) render next to the per-note fret label
  via a new pooled sprite (pTeachMarkLbl); _scrChordNote resets fg/sd so chord
  notes don't inherit stale marks. ch strum brackets are deferred in 3D (no
  cross-note batch pass in the per-note render); 2D covers ch.

Tests: tests/js/highway_teaching_marks.test.js extracts the pure helpers from
both files (extract-and-eval) and asserts label mapping + strum-group bucketing.

Part of got-feedback/feedback#334

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Byron Gamatos
2026-06-21 07:58:04 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 6ee5da3d8b
commit 0f1006972b
3 changed files with 276 additions and 2 deletions
+64 -1
View File
@@ -2534,6 +2534,9 @@
let _drawRecentByString = null; let _drawRecentByString = null;
/** Snapshotted in update() — drawNote() is a sibling of update(), not nested in its closure. */ /** Snapshotted in update() — drawNote() is a sibling of update(), not nested in its closure. */
let _drawChordTemplates = null; let _drawChordTemplates = null;
/** Teaching marks sd/ch overlay pref (§6.2.2), mirrored from the 2D
* highway's `teachingMarksVisible` bundle flag. fg renders regardless. */
let _drawTeachingMarks = false;
let _laneTargetColor = null; let _laneTargetColor = null;
let _renderScale = 1; let _renderScale = 1;
let lyricsCanvas = null, lyricsCtx = null; let lyricsCanvas = null, lyricsCtx = null;
@@ -3017,6 +3020,7 @@
let gPMXLines = null, pMuteXLines = null; // PM X lines combined geometry (8 segs as quads) let gPMXLines = null, pMuteXLines = null; // PM X lines combined geometry (8 segs as quads)
let gFHXLines = null, pFHXLines = null; // FH X lines combined geometry let gFHXLines = null, pFHXLines = null; // FH X lines combined geometry
let pNoteFretLabel, pConnectorLine, pDropLine, pTapChevron, pAccentHalo; let pNoteFretLabel, pConnectorLine, pDropLine, pTapChevron, pAccentHalo;
let pTeachMarkLbl; // teaching marks fg/sd label sprites (§6.2.2)
let pHaloBar = null, gHaloBar = null; // gradient halo bar geometry — replaces per-shell pChordAccentHalo let pHaloBar = null, gHaloBar = null; // gradient halo bar geometry — replaces per-shell pChordAccentHalo
let gArpBracket = null; // shared 1×1×1 box geometry for pArpBracket; built once, disposed in teardown let gArpBracket = null; // shared 1×1×1 box geometry for pArpBracket; built once, disposed in teardown
let pSusRibbon = null, pSusRibbonOl = null; let pSusRibbon = null, pSusRibbonOl = null;
@@ -6095,6 +6099,14 @@
_nfl.material.depthTest = false; _nfl.material.depthTest = false;
return _nfl; return _nfl;
}); });
// Teaching marks fg/sd labels (§6.2.2). One pool, two get()s per note
// (finger + degree); the texture is swapped per draw via material.map.
pTeachMarkLbl = pool(lblG, () => {
const _tml = new T.Sprite(txtMat('0', '#7fd1ff', false, 'teachMark').clone());
_tml.material.fog = false;
_tml.material.depthTest = false;
return _tml;
});
pConnectorLine = pool(noteG, () => new T.Line( pConnectorLine = pool(noteG, () => new T.Line(
new T.BufferGeometry().setFromPoints([new T.Vector3(0, 0, 0), new T.Vector3(0, 1, 0)]), new T.BufferGeometry().setFromPoints([new T.Vector3(0, 0, 0), new T.Vector3(0, 1, 0)]),
new T.LineBasicMaterial({ color: 0xaaaaaa, transparent: true, opacity: 0.5, depthTest: false }), new T.LineBasicMaterial({ color: 0xaaaaaa, transparent: true, opacity: 0.5, depthTest: false }),
@@ -6153,6 +6165,7 @@
pSusRailBloom.warm(_WARM_CHORD); pSusRailBloom.warm(_WARM_CHORD);
pTechPlane.warm(_WARM_CHORD); pTechPlane.warm(_WARM_CHORD);
pNoteFretLabel.warm(_WARM_NOTE); pNoteFretLabel.warm(_WARM_NOTE);
pTeachMarkLbl.warm(_WARM_NOTE);
pChordFrameFill.warm(_WARM_CHORD); pChordFrameFill.warm(_WARM_CHORD);
pChordBox.warm(_WARM_CHORD); pChordBox.warm(_WARM_CHORD);
pChordLbl.warm(_WARM_CHORD); pChordLbl.warm(_WARM_CHORD);
@@ -8353,6 +8366,7 @@
if (pMuteXLines) pMuteXLines.reset(); if (pMuteXLines) pMuteXLines.reset();
if (pFHXLines) pFHXLines.reset(); if (pFHXLines) pFHXLines.reset();
pNoteFretLabel.reset(); pConnectorLine.reset(); pDropLine.reset(); pNoteFretLabel.reset(); pConnectorLine.reset(); pDropLine.reset();
pTeachMarkLbl.reset();
pFretColMarker.reset(); pSusRail.reset(); pSusRailBloom.reset(); pTechPlane.reset(); pFretColMarker.reset(); pSusRail.reset(); pSusRailBloom.reset(); pTechPlane.reset();
// Clear per-frame queues in-place (avoid reallocating the array object). // Clear per-frame queues in-place (avoid reallocating the array object).
_ndLabels.length = 0; _ndLabels.length = 0;
@@ -8816,6 +8830,7 @@
_drawNextByString = nextNoteByString; _drawNextByString = nextNoteByString;
_drawChordTemplates = bundle.chordTemplates ?? null; _drawChordTemplates = bundle.chordTemplates ?? null;
_drawTeachingMarks = !!bundle.teachingMarksVisible;
// ── Recent-past event per string (for _nextAnyT deadline) ───── // ── Recent-past event per string (for _nextAnyT deadline) ─────
// Once a note/chord passes `now` it leaves _drawNextByString, // Once a note/chord passes `now` it leaves _drawNextByString,
@@ -9830,6 +9845,12 @@
// apply the wrong contour). Reset explicitly. // apply the wrong contour). Reset explicitly.
_scrChordNote.bnv = Array.isArray(cn.bnv) ? cn.bnv : undefined; _scrChordNote.bnv = Array.isArray(cn.bnv) ? cn.bnv : undefined;
_scrChordNote.bt = cn.bt || 0; _scrChordNote.bt = cn.bt || 0;
// Same stale-scratch hazard for the teaching marks
// (§6.2.2): fg/sd are omit-when-default on the wire,
// so a chord note without them must reset to -1 or it
// inherits the previous note's finger/degree label.
_scrChordNote.fg = Number.isInteger(cn.fg) ? cn.fg : -1;
_scrChordNote.sd = Number.isInteger(cn.sd) ? cn.sd : -1;
drawNote( drawNote(
_scrChordNote, _scrChordNote,
now, now,
@@ -11316,6 +11337,22 @@
return visualIdx >= (nStr - 1) * 0.5 ? -1 : 1; return visualIdx >= (nStr - 1) * 0.5 ? -1 : 1;
} }
// Teaching marks (§6.2.2) — display only, never grading. Pure label
// helpers, mirroring static/highway.js so the two highways agree;
// node-tested via tests/js/highway_teaching_marks.test.js.
function teachingFingerLabel(fg) {
// fret-hand finger: '' when unset/out of range; 0 -> 'T' (thumb),
// 1..4 -> '1'..'4'.
if (!Number.isInteger(fg) || fg < 0 || fg > 4) return '';
return fg === 0 ? 'T' : String(fg);
}
function teachingDegreeLabel(sd) {
// scale degree: chromatic 0..11 above the active key tonic; '' when
// unset/out of range.
if (!Number.isInteger(sd) || sd < 0 || sd > 11) return '';
return String(sd);
}
function bnvSampleAt(bnv, t) { function bnvSampleAt(bnv, t) {
// Linear interpolation of a bend curve [{t, v}] (§6.2.1; t is // Linear interpolation of a bend curve [{t, v}] (§6.2.1; t is
// seconds from the note onset) at elapsed time t. Clamps to the // seconds from the note onset) at elapsed time t. Clamps to the
@@ -12260,6 +12297,32 @@
fretLabel.scale.set(flS, flS, 1); fretLabel.scale.set(flS, flS, 1);
fretLabel.material.opacity = alpha; fretLabel.material.opacity = alpha;
} }
// Teaching marks (§6.2.2) — display only, never grading. The
// fret-hand finger (fg) renders by default to the right of the
// fret label; the scale degree (sd) is opt-in (mirrors the 2D
// `teachingMarksVisible` toggle) and renders to the left.
if (alpha > 0 && n.f > 0) {
const _tmS = 5.0 * K * _textSizeMul * fretLabelScaleForFret(n.f);
const _drawTeachMark = (text, colorHex, dx, cacheKey) => {
if (!text) return;
const spr = pTeachMarkLbl.get();
const m = txtMat(text, colorHex, false, cacheKey);
if (spr.material.map !== m.map) {
spr.material.map = m.map;
spr.material.needsUpdate = true;
}
spr.position.set(x + dx, labelY, noteZ);
spr.renderOrder = renderOrderForLayerAtZ(noteZ,
_isArpNote ? 'ARP_NOTE_FRET_LABEL' : 'NOTE_FRET_LABEL');
spr.scale.set(_tmS, _tmS, 1);
spr.material.opacity = alpha;
};
_drawTeachMark(teachingFingerLabel(n.fg), '#7fd1ff', NW * 0.95, 'teachFg');
if (_drawTeachingMarks) {
_drawTeachMark(teachingDegreeLabel(n.sd), '#ffcc66', -NW * 0.95, 'teachSd');
}
}
} }
} }
@@ -12973,7 +13036,7 @@
_renderScale = 1; _renderScale = 1;
mBeatM = mBeatQ = null; mBeatM = mBeatQ = null;
pNote = pNoteEdge = pSus = pSusOutline = pSusRibbon = pSusRibbonOl = pLbl = pBeat = pSec = null; pNote = pNoteEdge = pSus = pSusOutline = pSusRibbon = pSusRibbonOl = pLbl = pBeat = pSec = null;
pFretLbl = pLane = pLaneDivider = pGhostFretLbl = pChordBox = pChordFrameFill = pChordLbl = pBarreLine = pArpBracket = pNoteFretLabel = pConnectorLine = pDropLine = pTapChevron = pAccentHalo = pHaloBar = pPMXFill = pFHXFill = pMuteXLines = pFHXLines = null; pFretLbl = pLane = pLaneDivider = pGhostFretLbl = pChordBox = pChordFrameFill = pChordLbl = pBarreLine = pArpBracket = pNoteFretLabel = pConnectorLine = pDropLine = pTapChevron = pAccentHalo = pHaloBar = pPMXFill = pFHXFill = pMuteXLines = pFHXLines = pTeachMarkLbl = null;
if (gPMXFill) { gPMXFill.dispose(); gPMXFill = null; } if (gPMXFill) { gPMXFill.dispose(); gPMXFill = null; }
if (gFHXFill) { gFHXFill.dispose(); gFHXFill = null; } if (gFHXFill) { gFHXFill.dispose(); gFHXFill = null; }
if (gPMXLines) { gPMXLines.dispose(); gPMXLines = null; } if (gPMXLines) { gPMXLines.dispose(); gPMXLines = null; }
+120 -1
View File
@@ -205,6 +205,11 @@ function createHighway() {
// have any. // have any.
let _phrasesHaveHandShapes = false; let _phrasesHaveHandShapes = false;
let showLyrics = localStorage.getItem('showLyrics') !== 'false'; let showLyrics = localStorage.getItem('showLyrics') !== 'false';
// Teaching marks (§6.2.2): the fret-hand finger numeral (fg) renders by
// default (small, on the gem), but the scale-degree (sd) + strum-group (ch)
// overlays are opt-in so the default highway stays uncluttered. Display only
// — never used for grading.
let _showTeachingMarks = localStorage.getItem('showTeachingMarks') === 'true';
let _drawHooks = []; // plugin draw callbacks: fn(ctx, W, H) let _drawHooks = []; // plugin draw callbacks: fn(ctx, W, H)
// slopsmith#254 — per-note judgment overlay. A plugin (note_detect) // slopsmith#254 — per-note judgment overlay. A plugin (note_detect)
// registers fn(note, chartTime) -> 'hit' | 'active' | 'miss' | null // registers fn(note, chartTime) -> 'hit' | 'active' | 'miss' | null
@@ -485,6 +490,37 @@ function createHighway() {
return bnv.map(p => ({ x: span > 0 ? (p.t - t0) / span : 0, v: p.v })); return bnv.map(p => ({ x: span > 0 ? (p.t - t0) / span : 0, v: p.v }));
} }
/** Teaching mark (§6.2.2): fret-hand-finger label for a note's `fg`.
* '' when unset/out of range; 0 → 'T' (thumb), 1..4 → '1'..'4'. Pure. */
function teachingFingerLabel(fg) {
if (!Number.isInteger(fg) || fg < 0 || fg > 4) return '';
return fg === 0 ? 'T' : String(fg);
}
/** Teaching mark (§6.2.2): scale-degree label for a note's `sd` (chromatic
* 0..11 above the active key tonic). '' when unset/out of range. Pure. */
function teachingDegreeLabel(sd) {
if (!Number.isInteger(sd) || sd < 0 || sd > 11) return '';
return String(sd);
}
/** Teaching mark (§6.2.2): bucket drawn notes by their strum-group key `ch`.
* Returns the groups (in first-seen order) for each ch value >= 0 that has
* at least two members — a lone note is not a strum gesture. Pure; drives
* the strum-bracket overlay and is node-tested. */
function strumGroupBuckets(items) {
if (!Array.isArray(items)) return [];
const order = [];
const byKey = new Map();
for (const it of items) {
const ch = it && Number.isInteger(it.ch) ? it.ch : -1;
if (ch < 0) continue;
if (!byKey.has(ch)) { byKey.set(ch, []); order.push(ch); }
byKey.get(ch).push(it);
}
return order.map(k => byKey.get(k)).filter(g => g.length >= 2);
}
/** Call while lefty mirror transform is active; keeps glyphs readable. */ /** Call while lefty mirror transform is active; keeps glyphs readable. */
function fillTextReadable(text, x, y) { function fillTextReadable(text, x, y) {
// ctx may be null when the 2D context was never acquired // ctx may be null when the 2D context was never acquired
@@ -737,6 +773,9 @@ function createHighway() {
lefty: _lefty, lefty: _lefty,
renderScale: _effectiveRenderScale(), renderScale: _effectiveRenderScale(),
lyricsVisible: showLyrics, lyricsVisible: showLyrics,
// Teaching marks sd/ch overlay pref (§6.2.2) so custom renderers
// (e.g. the 3D highway) can mirror the 2D opt-in toggle.
teachingMarksVisible: _showTeachingMarks,
// 2D-style helpers (renderers that don't need these can ignore). // 2D-style helpers (renderers that don't need these can ignore).
// `fillTextUnmirrored` is deliberately NOT exposed here — // `fillTextUnmirrored` is deliberately NOT exposed here —
@@ -1687,6 +1726,29 @@ function createHighway() {
if (sz < 14) return; // Skip small technique labels if (sz < 14) return; // Skip small technique labels
// Teaching marks (§6.2.2) — display only, never grading. The fret-hand
// finger (fg) renders by default as a small numeral hugging the gem's
// right edge (T = thumb, 1..4); the scale degree (sd) is opt-in and sits
// on the left edge so the two never collide with the centred fret number.
const fgLabel = teachingFingerLabel(opts?.fg);
if (fgLabel) {
ctx.fillStyle = '#7fd1ff';
ctx.font = `bold ${Math.max(8, sz * 0.26) | 0}px sans-serif`;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
fillTextReadable(fgLabel, x + half + 2, y + half * 0.5);
}
if (_showTeachingMarks) {
const sdLabel = teachingDegreeLabel(opts?.sd);
if (sdLabel) {
ctx.fillStyle = '#ffcc66';
ctx.font = `bold ${Math.max(8, sz * 0.26) | 0}px sans-serif`;
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
fillTextReadable(sdLabel, x - half - 2, y + half * 0.5);
}
}
// Slide indicator (diagonal arrow). Pitched (sl) draws a solid arrow to // Slide indicator (diagonal arrow). Pitched (sl) draws a solid arrow to
// the target fret; unpitched (slu) draws a dashed diagonal with no // the target fret; unpitched (slu) draws a dashed diagonal with no
// arrowhead (no definite target pitch). The two are mutually exclusive // arrowhead (no definite target pitch). The two are mutually exclusive
@@ -1879,11 +1941,56 @@ function createHighway() {
const x = fretX(n.f, p.scale, W); const x = fretX(n.f, p.scale, W);
drawNote(W, H, x, p.y * H, p.scale, n.s, n.f, n, _noteStateProvider ? _noteState(n, n.t) : null); drawNote(W, H, x, p.y * H, p.scale, n.s, n.f, n, _noteStateProvider ? _noteState(n, n.t) : null);
drawnNotes.push({ t: n.t, s: n.s, f: n.f, bn: n.bn || 0, x, y: p.y * H, scale: p.scale }); drawnNotes.push({
t: n.t, s: n.s, f: n.f, bn: n.bn || 0, x, y: p.y * H, scale: p.scale,
ch: Number.isInteger(n.ch) ? n.ch : -1,
pkd: Number.isInteger(n.pkd) ? n.pkd : -1,
});
} }
// Draw unison bend connectors // Draw unison bend connectors
drawUnisonBends(W, H, drawnNotes); drawUnisonBends(W, H, drawnNotes);
// Strum-group brackets (teaching mark ch, §6.2.2) — opt-in overlay.
// Scoped to standalone notes (the stream drawNotes renders); chord-note
// strum groups aren't bracketed (the editor authors ch over single-note
// selections, and chord notes already read as one simultaneous gesture).
if (_showTeachingMarks) drawStrumGroups(W, H, drawnNotes);
}
function drawStrumGroups(W, H, drawnNotes) {
// Teaching mark (§6.2.2): notes sharing a `ch` key >= 0 are one
// strum/rake gesture. Connect each group's gems with a bracket and a
// single arrowhead whose direction comes from `pkd` (0 = down-strum,
// 1 = up-strum). Display only — never grading.
for (const group of strumGroupBuckets(drawnNotes)) {
const pts = group.slice().sort((a, b) => a.y - b.y || a.x - b.x);
const scale = pts[0].scale;
const sz = Math.max(12, 80 * scale * (H / 900));
if (sz < 14) continue;
const pkd = (group.find(p => p.pkd === 0 || p.pkd === 1) || {}).pkd;
ctx.save();
ctx.strokeStyle = '#c89bff';
ctx.lineWidth = Math.max(2, sz / 12);
ctx.lineJoin = 'round';
ctx.beginPath();
pts.forEach((p, i) => (i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y)));
ctx.stroke();
// Arrowhead at the gesture start: down-strum (pkd 0) points toward
// the last gem, up-strum (pkd 1) toward the first.
if (pkd === 0 || pkd === 1) {
const head = pkd === 1 ? pts[0] : pts[pts.length - 1];
const from = pkd === 1 ? pts[1] : pts[pts.length - 2];
const dy = Math.sign(head.y - from.y) || 1;
const a = sz * 0.18;
ctx.beginPath();
ctx.moveTo(head.x - a, head.y - dy * a);
ctx.lineTo(head.x, head.y);
ctx.lineTo(head.x + a, head.y - dy * a);
ctx.stroke();
}
ctx.restore();
}
} }
function drawUnisonBends(W, H, drawnNotes) { function drawUnisonBends(W, H, drawnNotes) {
@@ -3675,6 +3782,18 @@ function createHighway() {
}, },
setOnLyricsChange(fn) { _onLyricsChange = fn; }, setOnLyricsChange(fn) { _onLyricsChange = fn; },
// Teaching marks (§6.2.2): toggle the opt-in sd/ch overlays. The fg
// numeral is unaffected (always on). Persisted to localStorage.
getTeachingMarksVisible() { return _showTeachingMarks; },
toggleTeachingMarks() {
_showTeachingMarks = !_showTeachingMarks;
localStorage.setItem('showTeachingMarks', String(_showTeachingMarks));
},
setTeachingMarksVisible(v) {
_showTeachingMarks = !!v;
localStorage.setItem('showTeachingMarks', String(_showTeachingMarks));
},
reconnect(filename, arrangement) { reconnect(filename, arrangement) {
// Close old WS but keep audio + animation running // Close old WS but keep audio + animation running
if (ws) { ws.close(); ws = null; } if (ws) { ws.close(); ws = null; }
+92
View File
@@ -0,0 +1,92 @@
// 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};`)();
}
const fingerLabel2D = loadFn('static/highway.js', 'teachingFingerLabel');
const degreeLabel2D = loadFn('static/highway.js', 'teachingDegreeLabel');
const fingerLabel3D = loadFn('plugins/highway_3d/screen.js', 'teachingFingerLabel');
const degreeLabel3D = loadFn('plugins/highway_3d/screen.js', 'teachingDegreeLabel');
const strumGroupBuckets = loadFn('static/highway.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), []);
});