feedBack/plugins/keys_highway_3d/tests/data_layer.test.js
Byron Gamatos 1e2d5a6162
Bundle drum_highway_3d + keys_highway_3d as in-tree core plugins (#693)
* Initial commit — clean relaunch

* Initial commit — clean relaunch

* Remove external game/format terminology from docs and code

Reword references to the external game and its proprietary file formats
in comments, docstrings, UI text and identifiers; no behaviour change.

* Repoint dead slopsmith URLs -> got-feedback

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(midi): consume core midi-input domain + session robustness fixes (#2)

* feat(midi): consume the core midi-input domain instead of private requestMIDIAccess (#881)

Route Web-MIDI device access through window.slopsmith.midiInput
(discover/list/select/open/close) rather than a private
navigator.requestMIDIAccess(). One shared device-access boundary with
piano/drums/onboarding; retires this plugin's private Web-MIDI. The
note-detection 'midi' exact-verdict provider role and the audio-input
source export are preserved (now backed by midi-input source data). Saved
pick ({id,name}) stays compatible — domain sourceId == the old MIDIInput.id.
Live listener is the domain handle's addListener/removeListener; the async
init/resume/pause gate and device-vanish handling are kept.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(midi): stop exporting MIDI into the audio-input domain

Keys MIDI now lives in the dedicated midi-input domain (#881). Continuing to
register pseudonymized 'midi-input-N' sources into audio-input polluted audio
device pickers — notably the onboarding guitar audio-input dropdown showed MIDI
devices under cryptic names. Unregister any leftovers and register none.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(midi): guard async connect races, denied-discovery latch, key consistency

Codex preflight findings on the midi-input consumption:
- Don't latch `_midiReady` when discover() resolves denied/unavailable — only
  on a handled outcome — so reopening retries the permission prompt.
- Add a generation guard to async `_midiConnect`: a slower open() from an
  earlier selection can resolve after a device/None switch and install a stale
  handle/listener. Discard (and close) superseded opens.
- Carry the domain `logicalSourceKey` on the source/selection descriptor and use
  it for select/open/close instead of synthesizing `web-midi::<id>`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(midi): invalidate pending opens on detach; clear phantom selection; manifest

Codex re-review (round 2):
- A detach driven by device removal (sources-changed) didn't advance
  _midiConnectSeq, so a pending _midiConnect open could resume and install a
  handle/listener for a now-gone source. Bump the generation inside _midiDetach
  and capture myGen after it, so any later detach supersedes an in-flight open.
- If mi.open() yields no handle, _midiInput stayed set — a phantom connected
  device the render loop's miss-sweeping would penalize. Clear it on the
  no-handle and catch paths.
- plugin.json: replace the stale audio-input provider role (the removed
  MIDI-into-audio-input export bridge) with the midi-input requester it now uses.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(midi): make _midiResume idempotent under multiple live instances

Codex re-review (round 3): with the domain addListener API, a second live
renderer instance (splitscreen/overlapping lifetimes) calling _midiResume() while
already active could register the same listener again and double-deliver a MIDI
note to the focused instance (hit + duplicate misses). Return early when already
active rather than relying on the provider's Set-backed de-dup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(midi): release domain session on teardown; counter-free legacy source cleanup

Codex re-review (round 4):
- [P2] Last-instance teardown only detached the listener and never released the
  midi-input domain session, leaking the requester ref. Add _midiReleaseSession()
  (delegates to _midiDetach: close + null + generation bump) at the destroy site;
  re-mount's _midiInit auto-connects from the saved pick.
- [P3] The legacy audio-input MIDI-source cleanup looped over the module-local
  _aiRegisteredCount, which resets to 0 on an in-page upgrade so the prior build's
  'keys-midi:input-N' entries were never unregistered. Iterate a fixed bound over
  the known sourceId pattern instead (unregister of an absent source is a no-op).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(midi): gate miss-sweep on live handle; don't open without a live renderer

Codex re-review (round 5):
- Miss-sweeping was gated on _midiInput, set as soon as a device is picked, but
  the async mi.open() may still be pending (slow / permission prompt) — notes
  passing during that window banked false misses. Gate on _midiHandle, truthy
  only after a handle is opened and wired.
- A settings-only ensure-init (or a discover resolving after the last instance
  was torn down) could open a midi-input session with no renderer to release it.
  Gate mi.open() on _instances.size > 0; the pick is saved and a later mount
  re-runs auto-connect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(midi): guard open-failure clobber + don't clobber saved device on unplug

Bringing keys-highway-3d in line with the piano/drums hardening:
- The _midiConnect catch cleared _midiInput unconditionally; a stale older open's
  rejection could wipe a newer connect's _midiInput/_midiHandle (and leak the
  handle). Only clear when myGen === _midiConnectSeq.
- The sources-changed recovery called _midiAutoConnect(), whose fallback persists
  a substitute device — overwriting the user's saved pick on a transient
  multi-device unplug. Parameterize _midiAutoConnect(allowFallback); recovery
  passes false (reconnect the saved device only, never a persisted fallback).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(midi): keep first-hotplug working; don't tear down a live session on re-init

Codex round-11:
- The unplug-recovery _midiAutoConnect(false) skipped ALL fallbacks, which broke
  first-hotplug (open visualizer with no keyboard, plug one in → never picked →
  no connect). Skip the substitute only when a saved pick exists but is absent
  (preserve it); allow the fallback when nothing was ever picked.
- _midiInit re-ran _midiAutoConnect on every ready re-init (settings/splitscreen),
  tearing down the live handle + releasing held keys. Only re-connect when there's
  no live handle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(midi): consume core midi-input domain + session robustness fixes (#2)

* feat(midi): consume the core midi-input domain instead of private requestMIDIAccess (#881)

Route Web-MIDI device access through window.slopsmith.midiInput
(discover/list/select/open/close) rather than a private
navigator.requestMIDIAccess(). One shared device-access boundary with
piano/drums/keys/onboarding. Saved pick ({id,name}, with legacy id-only
fallback) stays compatible — domain sourceId == the old MIDIInput.id. The
async in-flight init guard, _midiActive teardown gate, device-vanish handling,
and settings device APIs are preserved; the live listener is the domain
handle's addListener/removeListener. Degrades to no-MIDI when the domain
is absent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(midi): guard async connect races, denied-discovery latch, key consistency

Codex preflight findings on the midi-input consumption:
- Don't latch `_midiReady` when discover() resolves denied/unavailable — only
  on a handled outcome — so reopening retries the permission prompt.
- Add a generation guard to async `_midiConnect`: a slower open() from an
  earlier selection can resolve after a device/None switch and install a stale
  handle/listener. Discard (and close) superseded opens.
- Carry the domain `logicalSourceKey` on the source/selection descriptor and use
  it for select/open/close instead of synthesizing `web-midi::<id>`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(midi): invalidate pending opens on detach; clear phantom selection; manifest

Codex re-review (round 2):
- A detach driven by device removal (sources-changed) didn't advance
  _midiConnectSeq, so a pending _midiConnect open could resume and install a
  handle/listener for a now-gone source. Bump the generation inside _midiDetach
  and capture myGen after it, so any later detach supersedes an in-flight open.
- If mi.open() yields no handle, _midiInput stayed set — a phantom connected
  device the render loop's miss-sweeping would penalize. Clear it on the
  no-handle and catch paths.
- Declare the midi-input requester capability (degrade-noop) in plugin.json.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(midi): make _midiResume idempotent under multiple live instances

Proactively mirror the keys-highway-3d round-3 fix (same multi-instance lifecycle
and identical code): a second live renderer instance calling _midiResume() while
already active could double-register the listener and double-deliver a MIDI hit.
Return early when already active.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(midi): release the domain session on final teardown

Mirror the keys-highway-3d round-4 fix: last-instance teardown only detached the
listener and never released the midi-input domain session, leaking the requester
ref. Add _midiReleaseSession() (delegates to _midiDetach: close + null +
generation bump) at the destroy site; re-mount's _midiInit auto-connects.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(midi): gate miss-sweep on live handle; don't open without a live renderer

Codex re-review (round 5):
- [P2] drumH3dEnsureMidiInit (settings-only, no renderer) auto-connected and
  opened a midi-input session that destroy()/_midiReleaseSession would never run
  to release — held until reload. Gate mi.open() on _instances.size > 0; the pick
  is saved and a later renderer mount re-runs auto-connect.
- Mirror the keys-highway-3d miss-sweep fix: gate accumulation on _midiHandle
  (live wired session), not _midiInput (set before the async open resolves), so
  notes during a pending open don't bank false misses.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(midi): guard open-failure clobber + reconnect saved device on replug

Bringing drum-highway-3d in line with the piano/drums hardening:
- The _midiConnect catch cleared _midiInput unconditionally; a stale older open's
  rejection could wipe a newer connect's _midiInput/_midiHandle. Only clear when
  myGen === _midiConnectSeq.
- The sources-changed handler only refreshed the list (never reconnected), so a
  replug of the saved device didn't reattach. Reconnect on sources-changed via
  _midiAutoConnect(false) — saved device only, no fallback, so a transient unplug
  can't switch to / persist another input.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(midi): keep first-hotplug working; don't tear down a live session on re-init

Same two round-11 fixes as keys-highway-3d (shared structure): the recovery
_midiAutoConnect(false) now allows a fallback when nothing was ever picked
(first-hotplug) while still preserving a saved-but-absent pick; and _midiInit
only re-connects when there's no live handle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(midi): select by logicalSourceKey, not provider-local sourceId (#4)

Codex review of the midi-input migration: resolving a selection with
find(s => s.id === id) treats the provider-local sourceId as globally unique,
so when the core midi-input domain aggregates multiple providers two devices
sharing a sourceId can't be distinguished — the wrong device opens and unplug
detection can miss that the selected key vanished.

Thread the globally-unique logicalSourceKey through save/read, _midiConnect,
auto-connect reconnect, the public keysH3dSetMidiInput entry, and the
capability _aiOpen path, preferring the key and keeping the bare sourceId /
name only as a legacy fallback. Mirrors the already-merged drums/piano
migration (_midiResolveSaved). Self-reviewed against that reference.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(midi): select by logicalSourceKey, not provider-local sourceId (#3)

Codex review of the midi-input migration (same issue as keys-highway-3d):
resolving a selection with find(s => s.id === id) treats the provider-local
sourceId as globally unique, so when the core midi-input domain aggregates
multiple providers two devices sharing a sourceId can't be distinguished —
the wrong device opens and unplug detection can miss the vanished key.

Thread the globally-unique logicalSourceKey through save/read, _midiConnect,
the auto-connect reconnect, and the public drumH3dSetMidiInput entry,
preferring the key with the bare sourceId / name only as a legacy fallback.
Mirrors the already-merged drums/piano migration. Self-reviewed.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: add plugin metadata (category, description, thumbnail) (#5)

* chore: add plugin metadata (category/description/thumbnail)

Refs got-feedback/feedBack#571

* chore: add placeholder thumbnail

Refs got-feedback/feedBack#571

* chore: add plugin metadata (category, description, thumbnail) (#4)

* chore: add plugin metadata (category/description/thumbnail)

Refs got-feedback/feedBack#571

* chore: add placeholder thumbnail

Refs got-feedback/feedBack#571

* fix(viz): register feedBackViz_<id> so the player viz picker lists this plugin (#5)

After the slopsmith->feedBack rename the factory was still registered as
window.slopsmithViz_<id>; the host viz picker only looks up
window.feedBackViz_<id> (no slopsmithViz alias exists) and silently skipped
this plugin. Add the feedBackViz_ global (aliased to the existing
slopsmithViz_ one for back-compat).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(viz): register feedBackViz_<id> so the player viz picker lists this plugin (#6)

After the slopsmith->feedBack rename the factory was still registered as
window.slopsmithViz_<id>; the host viz picker only looks up
window.feedBackViz_<id> (no slopsmithViz alias exists) and silently skipped
this plugin. Add the feedBackViz_ global (aliased to the existing
slopsmithViz_ one for back-compat).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(plugins): bundle drum_highway_3d + keys_highway_3d as in-tree core plugins

Import both 3D highway plugins from their standalone repos via git subtree
(history preserved), ahead of the visual-parity epic that ports the guitar
highway's polish to them.

- .gitignore: !plugins/drum_highway_3d/ + !plugins/keys_highway_3d/ exceptions
- keys plugin.json: "bundled": true (drum already had it)
- CI: JS test step gains 'plugins/*/tests/*.test.js' (+20 keys tests)
- static/tailwind.min.css regenerated (core build scans plugins/**)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(drum_highway_3d): narrow Auto-mode predicate so bundling can't steal guitar arrangements

has_drum_tab is pack-level; first-match-wins Auto order sorts this plugin
before highway_3d. Claim only drum arrangements, or packs nothing more
specific can render (Codex preflight P2).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: CHANGELOG — note the deliberate Auto-predicate narrowing

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(drum_highway_3d): MIDI lifecycle — no false misses on mid-song connect, promote survivor on destroy

- _missSweepFloor exempts notes that passed before the device wired up
  from the miss sweep (lowered on seek-back; cleared with scoring resets)
- destroy() promotes a surviving instance to _activeInstance so
  splitscreen panel teardown doesn't drop all MIDI routing
  (Codex preflight round 2, both pre-existing in the imported code)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Sin <deathlysin@outlook.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 00:25:31 +02:00

191 lines
7.5 KiB
JavaScript

// Pure data-layer tests: load screen.js in a bare vm window and exercise the
// __test exports (no DOM, no WebGL, no network).
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
function load() {
const window = {
console,
location: { protocol: 'http:', host: 'localhost' },
slopsmith: {},
};
window.window = window;
window.globalThis = window;
const context = vm.createContext(window);
const src = fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8');
vm.runInContext(src, context, { filename: 'screen.js' });
return window.slopsmithViz_keys_highway_3d.__test;
}
test('beatDurSec: base, dotted, double-dotted, tuplet', () => {
const { beatDurSec } = load();
// 120 BPM → quarter = 0.5s
assert.equal(beatDurSec({ dur: 4 }, 120), 0.5);
assert.equal(beatDurSec({ dur: 2 }, 120), 1.0);
assert.equal(beatDurSec({ dur: 8 }, 120), 0.25);
// dotted quarter = 0.75s; double-dotted = 0.875s
assert.equal(beatDurSec({ dur: 4, dot: 1 }, 120), 0.75);
assert.equal(beatDurSec({ dur: 4, dot: 2 }, 120), 0.875);
// triplet eighth: 0.25 * 2/3
assert.ok(Math.abs(beatDurSec({ dur: 8, tu: [3, 2] }, 120) - 0.25 * 2 / 3) < 1e-9);
// invalid → null
assert.equal(beatDurSec({ dur: 0 }, 120), null);
assert.equal(beatDurSec({ dur: 4 }, null), null);
});
function measure(idx, t, opts = {}) {
return { idx, t, ...opts };
}
test('flattenNotation: basic two-hand flatten, sorted, durSec from tempo', () => {
const { flattenNotation } = load();
const notes = flattenNotation([
{
idx: 1, t: 0, tempo: 120,
staves: {
rh: { voices: [{ v: 1, beats: [
{ t: 0.5, dur: 4, notes: [{ midi: 64 }] },
{ t: 1.0, dur: 8, notes: [{ midi: 67 }] },
] }] },
lh: { voices: [{ v: 1, beats: [
{ t: 0.0, dur: 2, notes: [{ midi: 48 }] },
] }] },
},
},
]);
assert.equal(notes.length, 3);
assert.deepEqual(JSON.parse(JSON.stringify(notes.map(n => n.midi))), [48, 64, 67]); // time-sorted
assert.equal(notes[0].hand, 'lh');
assert.equal(notes[0].durSec, 1.0); // half at 120
assert.equal(notes[1].durSec, 0.5); // quarter
assert.equal(notes[2].durSec, 0.25); // eighth
assert.equal(notes[1].measureIdx, 1);
});
test('flattenNotation: tempo state carries across measures and changes apply', () => {
const { flattenNotation } = load();
const notes = flattenNotation([
measure(1, 0, { tempo: 120, staves: { rh: { voices: [{ v: 1, beats: [{ t: 0, dur: 4, notes: [{ midi: 60 }] }] }] } } }),
measure(2, 2, { staves: { rh: { voices: [{ v: 1, beats: [{ t: 2, dur: 4, notes: [{ midi: 62 }] }] }] } } }),
measure(3, 4, { tempo: 60, staves: { rh: { voices: [{ v: 1, beats: [{ t: 4, dur: 4, notes: [{ midi: 64 }] }] }] } } }),
]);
assert.equal(notes[0].durSec, 0.5); // 120 BPM
assert.equal(notes[1].durSec, 0.5); // tempo carried
assert.equal(notes[2].durSec, 1.0); // 60 BPM
});
test('flattenNotation: tied notes extend instead of emitting a new block', () => {
const { flattenNotation } = load();
const notes = flattenNotation([
{
idx: 1, t: 0, tempo: 120,
staves: { rh: { voices: [{ v: 1, beats: [
{ t: 0.0, dur: 2, notes: [{ midi: 60 }] },
{ t: 1.0, dur: 2, notes: [{ midi: 60, tied: true }] },
] }] } },
},
]);
assert.equal(notes.length, 1);
assert.equal(notes[0].durSec, 2.0); // half + tied half
});
test('flattenNotation: no tempo anywhere falls back to next-onset gap', () => {
const { flattenNotation } = load();
const notes = flattenNotation([
{
idx: 1, t: 0,
staves: { rh: { voices: [{ v: 1, beats: [
{ t: 0.0, dur: 4, notes: [{ midi: 60 }] },
{ t: 0.8, dur: 4, notes: [{ midi: 62 }] },
] }] } },
},
]);
assert.ok(Math.abs(notes[0].durSec - 0.8) < 1e-9);
assert.equal(notes[1].durSec, 2.0); // final-beat fallback
});
test('flattenNotation: overlap clamp against next same-hand same-midi onset', () => {
const { flattenNotation } = load();
const notes = flattenNotation([
{
idx: 1, t: 0, tempo: 30, // whole note = 8s — way past the next onset
staves: { rh: { voices: [{ v: 1, beats: [
{ t: 0.0, dur: 1, notes: [{ midi: 60 }] },
{ t: 1.0, dur: 1, notes: [{ midi: 60 }] },
] }] } },
},
]);
assert.equal(notes[0].durSec, 1.0); // clamped to next onset
});
test('flattenNotation: rests, malformed beats, and out-of-range midi are skipped', () => {
const { flattenNotation } = load();
const notes = flattenNotation([
{
idx: 1, t: 0, tempo: 120,
staves: { rh: { voices: [{ v: 1, beats: [
{ t: 0.0, dur: 4, rest: true },
{ t: 0.5, dur: 4, notes: [{ midi: 200 }] },
null,
{ t: 1.0, dur: 4, notes: [{ midi: 64 }] },
] }] } },
},
]);
assert.equal(notes.length, 1);
assert.equal(notes[0].midi, 64);
});
test('keyRange pads and clamps to the 88-key piano, keeps active span explicit', () => {
const { keyRange } = load();
assert.deepEqual(
JSON.parse(JSON.stringify(keyRange([{ midi: 60 }, { midi: 72 }]))),
{ low: 58, high: 74, activeLow: 60, activeHigh: 72 },
);
// At the clamp edges the active span still reflects the chart extremes
// (not low+pad — that would mark A0/C8 inactive when actually played).
assert.deepEqual(
JSON.parse(JSON.stringify(keyRange([{ midi: 21 }, { midi: 108 }]))),
{ low: 21, high: 108, activeLow: 21, activeHigh: 108 },
);
const empty = keyRange([]);
assert.ok(empty.low < 60 && empty.high > 60);
assert.ok(empty.activeLow > empty.activeHigh, 'empty chart has an empty active span');
});
test('noteLetter maps midi to pitch-class letters', () => {
const { noteLetter } = load();
assert.equal(noteLetter(60), 'C');
assert.equal(noteLetter(61), 'C#');
assert.equal(noteLetter(69), 'A');
assert.equal(noteLetter(71), 'B');
assert.equal(noteLetter(72), 'C'); // octave wraps
assert.equal(noteLetter(21), 'A'); // A0
});
test('scrollZ: events sit at hitZ exactly at their time and approach from -Z', () => {
const { scrollZ } = load();
const hitZ = -0.5, speed = 2.0;
// At now === eventT the event is exactly on the hit-line.
assert.equal(scrollZ(10, 10, hitZ, speed), hitZ);
// 1s before its time it is `speed` units further away (towards -Z).
assert.equal(scrollZ(10, 9, hitZ, speed), hitZ - speed);
// After its time it has moved past the hit-line (towards +Z).
assert.equal(scrollZ(10, 11, hitZ, speed), hitZ + speed);
// Marker and note-front-edge maths agree by construction: a note of
// length L positioned at scrollZ(t) - L/2 has its front edge at
// scrollZ(t).
const len = 0.8;
assert.equal(scrollZ(10, 10, hitZ, speed) - len / 2 + len / 2, hitZ);
});
test('measureMarkers extracts idx/t pairs', () => {
const { measureMarkers } = load();
assert.deepEqual(
JSON.parse(JSON.stringify(measureMarkers([{ idx: 1, t: 0 }, { idx: 2, t: 2.5 }, { bogus: true }]))),
[{ idx: 1, t: 0 }, { idx: 2, t: 2.5 }],
);
});