Files
suaveplan/packages/ui/react/react-midi/src/test-utils.tsx
2026-05-27 23:47:53 +01:00

137 lines
4.0 KiB
TypeScript

import { type ReactNode, useMemo } from "react";
import type {
WebMIDIAccess,
WebMIDIConnectionEvent,
WebMIDIInput,
WebMIDIMessageEvent,
WebMIDIOutput,
WebMIDIRequestFn,
} from "./port/web-midi.types.js";
import { MidiRequestContext } from "./provider/midi-context.js";
import { MidiProvider } from "./provider/midi-provider.js";
export interface FakeInput extends WebMIDIInput {
fire(bytes: number[]): void;
}
export interface FakeOutput extends WebMIDIOutput {
readonly sent: number[][];
}
export interface FakeAccess extends WebMIDIAccess {
addInput(input: FakeInput): void;
addOutput(output: FakeOutput): void;
removeInput(id: string): void;
}
export function makeFakeInput(id: string, name = `input-${id}`): FakeInput {
let handler: ((ev: WebMIDIMessageEvent) => void) | null = null;
return {
id,
name,
manufacturer: "Suaveplan",
state: "connected",
type: "input",
get onmidimessage() {
return handler;
},
set onmidimessage(fn) {
handler = fn;
},
fire(bytes) {
handler?.({ data: new Uint8Array(bytes), timeStamp: 0 });
},
};
}
export function makeFakeOutput(id: string, name = `output-${id}`): FakeOutput {
const sent: number[][] = [];
return {
id,
name,
manufacturer: "Suaveplan",
state: "connected",
type: "output",
send(bytes) {
sent.push(Array.from(bytes));
},
sent,
};
}
export function makeFakeAccess(opts?: {
inputs?: FakeInput[];
outputs?: FakeOutput[];
sysex?: boolean;
}): FakeAccess {
const inputs = new Map<string, FakeInput>();
const outputs = new Map<string, FakeOutput>();
let stateChange: ((ev: WebMIDIConnectionEvent) => void) | null = null;
for (const i of opts?.inputs ?? []) inputs.set(i.id, i);
for (const o of opts?.outputs ?? []) outputs.set(o.id, o);
const access: FakeAccess = {
get inputs() {
return inputs as ReadonlyMap<string, WebMIDIInput>;
},
get outputs() {
return outputs as ReadonlyMap<string, WebMIDIOutput>;
},
sysexEnabled: opts?.sysex ?? false,
get onstatechange() {
return stateChange;
},
set onstatechange(fn) {
stateChange = fn;
},
addInput(input) {
inputs.set(input.id, input);
stateChange?.({ port: input });
},
addOutput(output) {
outputs.set(output.id, output);
stateChange?.({ port: output });
},
removeInput(id) {
const port = inputs.get(id);
if (port !== undefined) {
inputs.delete(id);
stateChange?.({
port: { ...port, state: "disconnected" },
});
}
},
};
return access;
}
export interface FakeProviderProps {
readonly access: FakeAccess;
readonly requestImpl?: WebMIDIRequestFn;
readonly sysex?: boolean;
readonly children?: ReactNode;
readonly requestSpy?: (opts?: { sysex?: boolean }) => void;
}
/**
* Renders `<MidiProvider>` with a deterministic fake access injected
* through `MidiRequestContext`. The `requestSpy` callback is invoked
* once per request, allowing tests to assert call counts (e.g.
* exactly-once under StrictMode).
*/
export function FakeMidiProvider(props: FakeProviderProps): ReactNode {
const request: WebMIDIRequestFn = useMemo(() => {
if (props.requestImpl !== undefined) {
return props.requestImpl;
}
return (opts) => {
props.requestSpy?.(opts);
return Promise.resolve(props.access);
};
}, [props.access, props.requestImpl, props.requestSpy]);
return (
<MidiRequestContext.Provider value={request}>
<MidiProvider sysex={props.sysex}>{props.children}</MidiProvider>
</MidiRequestContext.Provider>
);
}