memlnaut-nisps/manifold/tests/midi-notify-churn.test.ts
monkey-w1n5t0n 75e0e58067 fix(manifold): full-width input vector, dropped switches, MIDI churn, re-subscribe churn
Phase 2 (S10, L19, L18, L24).

- S10: EngineApi.inputVector() returned a freshly allocated [lastRawX, lastRawY]
  on every spine tick, so VCV bridged mode silently truncated gamepad/MIDI input
  to 2-D while the spine already held the full N-dim raw vector. It now returns
  spine.lastRawInputs (ArrayLike<number>, documented as a live reused buffer —
  copy, don't retain; VcvBackend already copies), and VcvBackend tracks and
  dead-zones the full length. Audit correction: "32-input head" is not a
  constant — 32 is DEFAULT_MODE_ML.inputSize, the over-provisioned default
  before any mode is chosen; real per-mode widths come from the schemas.
- L19: BackendManager.setActive silently dropped a switch requested while
  another was in flight. Now stores the latest requested id and re-runs it in
  the finally block (latest-caller-wins).
- L18: MIDI CC messages triggered a React state update plus a snapshot
  allocation each. notifyBindings now fires only when the binding LIST changes.
- L24: two ConsoleApp global-listener effects had no dependency array and so
  re-subscribed on every render, including every pointer frame. Both now read
  through a single ref assigned in the render body, matching the existing
  onMoveRef pattern. Audit correction: its suggested `[inputs]` dep would not
  have worked — useInputLayer returns a fresh object literal each call, so that
  dep changes every render too.

Regression tests: input-vector-truncation.test.ts, backend-manager-switch.test.ts
(a fake backend whose start() is held open, to make the in-flight switch real),
midi-notify-churn.test.ts (fail-before confirmed: 51 notifications vs 1).
L24 has no test — this repo has no DOM render harness to count re-subscriptions
against a mounted component; verified by reading and reference-stability tracing.

ALSO: manifold/package.json's test script named its test files explicitly
("bun test src tests/pipeline-golden.test.ts"), so the three new files were not
run by `bun run test` or CI — regression tests that never execute. Now a glob.
Deliberately `tests/*.test.ts` rather than `tests`: bun's discovery matches
*.spec.ts too, which would drag the Playwright e2e specs into the unit run
(verified — it fails). Unit tests go 9 -> 17.

Gates: run-all-tests.sh ALL GREEN.
2026-07-21 13:22:38 +02:00

86 lines
3.3 KiB
TypeScript

/**
* L18 regression (simplification audit): `WebMidiInputSource` must notify
* `onBindingsChange` listeners only when the binding LIST changes (learn
* capture, clearBinding, clearAllBindings) — NOT on every incoming CC value
* update. Pre-fix, every CC message on an already-learned axis pushed a full
* bindings-array snapshot to every listener (a React state update per MIDI
* message in the real app, via useInputLayer.ts's `onBindingsChange`
* subscription).
*
* `onMessage` is private — there is no other public seam to feed synthetic
* MIDI bytes in, so this test invokes it through a narrow cast (a normal
* white-box technique for a class with no public message-injection API).
*
* Run with `bun test tests/midi-notify-churn.test.ts` (see the `bun run test`
* wiring note in tests/backend-manager-switch.test.ts — the same applies).
*/
import { expect, test } from 'bun:test';
import { WebMidiInputSource } from '../src/inputs/midi-input-source';
function ccMessage(cc: number, channel1based: number, value7bit: number): MIDIMessageEvent {
const statusByte = 0xb0 | ((channel1based - 1) & 0x0f);
return { data: new Uint8Array([statusByte, cc, value7bit]) } as unknown as MIDIMessageEvent;
}
function feedCc(midi: WebMidiInputSource, cc: number, channel: number, value7bit: number): void {
(midi as unknown as { onMessage(e: MIDIMessageEvent): void }).onMessage(ccMessage(cc, channel, value7bit));
}
test('repeated CC value updates on an EXISTING binding do not re-notify', () => {
const midi = new WebMidiInputSource();
let notifyCount = 0;
midi.onBindingsChange(() => {
notifyCount++;
});
midi.armLearn(true);
feedCc(midi, 74, 1, 10); // new axis captured — the list changed → 1 notify
expect(notifyCount).toBe(1);
midi.armLearn(false);
// Flood the SAME learned CC with 50 more value updates (the "wiggle a
// fader" case that used to flood React with one state update each).
for (let v = 0; v < 50; v++) feedCc(midi, 74, 1, v);
expect(notifyCount).toBe(1); // still just the one list-change notify
expect(midi.getBindings()[0].value).toBeCloseTo(49 / 127, 5); // value still latches
});
test('learn capture / clearBinding / clearAllBindings still notify (the list DID change)', () => {
const midi = new WebMidiInputSource();
let notifyCount = 0;
midi.onBindingsChange(() => {
notifyCount++;
});
midi.armLearn(true);
feedCc(midi, 1, 1, 10);
feedCc(midi, 2, 1, 20);
expect(notifyCount).toBe(2); // two NEW axes captured
midi.armLearn(false);
midi.clearBinding(0);
expect(notifyCount).toBe(3);
midi.clearAllBindings();
expect(notifyCount).toBe(4);
});
test('note gate updates never notify (no note binding can ever be created)', () => {
const midi = new WebMidiInputSource();
let notifyCount = 0;
midi.onBindingsChange(() => {
notifyCount++;
});
// Note ON then OFF, repeatedly — no path creates a 'note' binding, so the
// list never changes and this must never notify.
const noteOn = { data: new Uint8Array([0x90, 60, 100]) } as unknown as MIDIMessageEvent;
const noteOff = { data: new Uint8Array([0x80, 60, 0]) } as unknown as MIDIMessageEvent;
const inject = midi as unknown as { onMessage(e: MIDIMessageEvent): void };
for (let i = 0; i < 10; i++) {
inject.onMessage(noteOn);
inject.onMessage(noteOff);
}
expect(notifyCount).toBe(0);
});