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.
130 lines
4.3 KiB
TypeScript
130 lines
4.3 KiB
TypeScript
/**
|
|
* BackendManager.setActive regression (simplification audit L19): a switch
|
|
* requested while another is already in flight must NOT be silently dropped.
|
|
* Run with `bun test tests/backend-manager-switch.test.ts` (see NOTE at the
|
|
* bottom of this file re: `bun run test` wiring).
|
|
*/
|
|
import { expect, test } from 'bun:test';
|
|
import { BackendManager, type ManagerEngine } from '../src/backends/manager';
|
|
import type { BackendContext, BackendStatus, OutputBackend } from '../src/backends/backend';
|
|
import type { BackendId } from '../src/dock/output-state';
|
|
|
|
function makeCtx(): BackendContext {
|
|
return { modeId: 'test', outputCount: 0, mappings: [], names: [] };
|
|
}
|
|
|
|
function makeEngine(): ManagerEngine {
|
|
return {
|
|
subscribe: () => () => {},
|
|
routedOutput: () => null,
|
|
audio: { setMuted: () => {} },
|
|
};
|
|
}
|
|
|
|
/** A fake backend whose `start()` can be held open until the test releases it,
|
|
* so we can deterministically land a second `setActive` call WHILE the first
|
|
* is still in flight. */
|
|
class FakeBackend implements OutputBackend {
|
|
readonly id: BackendId;
|
|
startCalls = 0;
|
|
teardownCalls = 0;
|
|
private release: (() => void) | null = null;
|
|
private hold: boolean;
|
|
|
|
constructor(id: BackendId, hold = false) {
|
|
this.id = id;
|
|
this.hold = hold;
|
|
}
|
|
|
|
isAvailable(): boolean {
|
|
return true;
|
|
}
|
|
|
|
async start(_ctx: BackendContext): Promise<void> {
|
|
this.startCalls++;
|
|
if (this.hold) {
|
|
await new Promise<void>((resolve) => {
|
|
this.release = resolve;
|
|
});
|
|
}
|
|
}
|
|
|
|
/** Let a held `start()` resolve (simulates the backend becoming ready). */
|
|
releaseStart(): void {
|
|
this.release?.();
|
|
this.release = null;
|
|
}
|
|
|
|
async teardown(): Promise<void> {
|
|
this.teardownCalls++;
|
|
}
|
|
|
|
send(): void {}
|
|
|
|
status(): BackendStatus {
|
|
return { state: 'ready', message: 'fake' };
|
|
}
|
|
|
|
onStatusChange(): () => void {
|
|
return () => {};
|
|
}
|
|
}
|
|
|
|
test('a switch requested mid-switch is queued and applied, not dropped (L19)', async () => {
|
|
const midi = new FakeBackend('midi', /* hold */ true);
|
|
const osc = new FakeBackend('osc');
|
|
const manager = new BackendManager(makeEngine(), { midi, osc });
|
|
manager.setContext(makeCtx());
|
|
|
|
// Kick off a switch to 'midi' whose start() we hold open, simulating a
|
|
// switch genuinely in flight (e.g. an async backend.start()).
|
|
const first = manager.setActive('midi');
|
|
|
|
// While 'midi' is still starting, request 'osc'. Pre-fix this silently
|
|
// returned (BackendManager.setActive:131 `if (... || this.switching) return`)
|
|
// and 'osc' was never applied once 'midi' finished starting.
|
|
const second = manager.setActive('osc');
|
|
|
|
expect(manager.getActiveId()).toBe('midi'); // still mid-switch
|
|
midi.releaseStart();
|
|
await first;
|
|
await second;
|
|
// Give the queued re-run (kicked off in setActive's `finally`) a tick to
|
|
// settle — its own start() is not held, so one microtask flush suffices.
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
expect(manager.getActiveId()).toBe('osc');
|
|
expect(osc.startCalls).toBe(1);
|
|
});
|
|
|
|
test('rapid repeated switches to the SAME pending id only apply it once', async () => {
|
|
const midi = new FakeBackend('midi', /* hold */ true);
|
|
const osc = new FakeBackend('osc');
|
|
const manager = new BackendManager(makeEngine(), { midi, osc });
|
|
manager.setContext(makeCtx());
|
|
|
|
const first = manager.setActive('midi');
|
|
void manager.setActive('osc');
|
|
void manager.setActive('osc'); // repeated request for the same pending id
|
|
void manager.setActive('osc');
|
|
|
|
midi.releaseStart();
|
|
await first;
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
expect(manager.getActiveId()).toBe('osc');
|
|
// Only ONE extra switch should have actually run (no repeated re-queueing
|
|
// beyond the single pending slot — the infinite-loop guard).
|
|
expect(osc.startCalls).toBe(1);
|
|
});
|
|
|
|
// NOTE: `manifold/package.json`'s `test` script is `bun test src
|
|
// tests/pipeline-golden.test.ts` — an explicit file list, not a directory
|
|
// glob, so this file (like any other new file under tests/) is NOT picked up
|
|
// by `bun run test` as currently wired. Verified directly with
|
|
// `bun test tests/backend-manager-switch.test.ts`. Wiring `tests/` in as a
|
|
// whole is a one-line package.json change outside this group's file scope
|
|
// (see the handoff note in the accompanying report).
|