test(webui): cover xiaomi mimo wav recording path

This commit is contained in:
Xubin Ren
2026-06-25 22:52:55 +08:00
parent 28c8c89a42
commit 4636c78100
+66 -1
View File
@@ -226,7 +226,20 @@ function mockVoiceRecorder(blob = new Blob(["voice"], { type: "audio/webm" })) {
return { getUserMedia, stopTrack };
}
function mockVoiceAudioInput(sample = 128, state: AudioContextState = "running") {
function mockVoiceAudioInput(
sample = 128,
state: AudioContextState = "running",
decodedChannels?: Float32Array[],
) {
const decodeAudioDataMock = vi.fn(async () => {
if (!decodedChannels) throw new Error("decodeAudioData not mocked");
return {
numberOfChannels: decodedChannels.length,
sampleRate: 16_000,
getChannelData: (channel: number) => decodedChannels[channel],
} as AudioBuffer;
});
class FakeAudioContext {
state = state;
@@ -244,6 +257,7 @@ function mockVoiceAudioInput(sample = 128, state: AudioContextState = "running")
}
close = vi.fn(async () => undefined);
decodeAudioData = decodeAudioDataMock;
resume = vi.fn(async () => undefined);
}
@@ -254,6 +268,7 @@ function mockVoiceAudioInput(sample = 128, state: AudioContextState = "running")
vi.spyOn(window, "cancelAnimationFrame").mockImplementation((id) =>
window.clearTimeout(id as unknown as number)
);
return { decodeAudioData: decodeAudioDataMock };
}
async function waitForVoiceCapture(): Promise<void> {
@@ -262,6 +277,15 @@ async function waitForVoiceCapture(): Promise<void> {
});
}
function bytesFromDataUrl(dataUrl: string): Uint8Array {
const [, base64 = ""] = dataUrl.split(",");
return Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
}
function ascii(bytes: Uint8Array, offset: number, length: number): string {
return String.fromCharCode(...bytes.slice(offset, offset + length));
}
describe("ThreadComposer", () => {
it("renders a readonly hero model composer when provided", () => {
render(
@@ -337,6 +361,47 @@ describe("ThreadComposer", () => {
expect(onSend).not.toHaveBeenCalled();
});
it("converts voice recordings to wav for Xiaomi MiMo transcription", async () => {
mockVoiceRecorder(new Blob([new Uint8Array([1, 2, 3, 4])], { type: "audio/webm" }));
const { decodeAudioData } = mockVoiceAudioInput(
180,
"running",
[new Float32Array([0, 0.5, -0.5])],
);
const onTranscribeAudio = vi.fn(async () => "mimo voice");
render(
<ThreadComposer
onSend={vi.fn()}
onTranscribeAudio={onTranscribeAudio}
placeholder="Type your message..."
transcriptionProvider="xiaomi_mimo"
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Voice input" }));
expect(await screen.findByLabelText("Recording 0:00")).toBeInTheDocument();
await waitForVoiceCapture();
fireEvent.click(await screen.findByRole("button", { name: "Stop recording" }));
await waitFor(() => expect(onTranscribeAudio).toHaveBeenCalledTimes(1));
const [dataUrl, options] = onTranscribeAudio.mock.calls[0];
expect(dataUrl).toMatch(/^data:audio\/wav;base64,/);
expect(options).toEqual(expect.objectContaining({ durationMs: expect.any(Number) }));
expect(decodeAudioData).toHaveBeenCalledTimes(1);
const bytes = bytesFromDataUrl(dataUrl);
const view = new DataView(bytes.buffer);
expect(ascii(bytes, 0, 4)).toBe("RIFF");
expect(ascii(bytes, 8, 4)).toBe("WAVE");
expect(ascii(bytes, 12, 4)).toBe("fmt ");
expect(view.getUint16(20, true)).toBe(1);
expect(view.getUint16(22, true)).toBe(1);
expect(view.getUint32(24, true)).toBe(16_000);
expect(view.getUint16(34, true)).toBe(16);
expect(ascii(bytes, 36, 4)).toBe("data");
await waitFor(() => expect(screen.getByLabelText("Message input")).toHaveValue("mimo voice"));
});
it("does not start duplicate voice recordings while microphone access is pending", async () => {
const { getUserMedia, stopTrack } = mockVoiceRecorder();
let resolveStream: ((stream: MediaStream) => void) | undefined;