117 lines
5.6 KiB
TypeScript
117 lines
5.6 KiB
TypeScript
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
import { useAgentSpeechSynthesis } from "./use-agent-voice";
|
|
import { setSelectedSpeechVoice } from "../speech-voices";
|
|
|
|
class MockAudio {
|
|
static instances: MockAudio[] = [];
|
|
onended: (() => void) | null = null;
|
|
onerror: (() => void) | null = null;
|
|
play = vi.fn(async () => undefined);
|
|
pause = vi.fn();
|
|
load = vi.fn();
|
|
removeAttribute = vi.fn();
|
|
constructor() { MockAudio.instances.push(this); }
|
|
}
|
|
|
|
const fetchMock = vi.fn<typeof fetch>();
|
|
const audioResponse = () => new Response(new Blob(["audio"], { type: "audio/mpeg" }));
|
|
const errorResponse = () => new Response(JSON.stringify({ error: "语音生成失败" }), { status: 502 });
|
|
|
|
beforeEach(() => {
|
|
setSelectedSpeechVoice("");
|
|
MockAudio.instances = [];
|
|
fetchMock.mockReset();
|
|
vi.stubGlobal("Audio", MockAudio);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:audio");
|
|
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined);
|
|
});
|
|
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); });
|
|
|
|
it("keeps one chosen voice for all chunks, then applies a changed choice to the next playback", async () => {
|
|
fetchMock.mockImplementation(async () => audioResponse());
|
|
setSelectedSpeechVoice("zh-CN-YunyangNeural");
|
|
const { result, unmount } = renderHook(() => useAgentSpeechSynthesis());
|
|
await act(() => result.current.speak("message", `${"甲".repeat(300)}。${"乙".repeat(300)}。${"丙".repeat(300)}。`));
|
|
setSelectedSpeechVoice("zh-CN-XiaoyiNeural");
|
|
await act(async () => { MockAudio.instances[0].onended?.(); });
|
|
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3));
|
|
expect(fetchMock.mock.calls.map(([, options]) => JSON.parse(String(options?.body)).voice))
|
|
.toEqual(["zh-CN-YunyangNeural", "zh-CN-YunyangNeural", "zh-CN-YunyangNeural"]);
|
|
await act(() => result.current.speak("new", "Hello"));
|
|
expect(JSON.parse(String(fetchMock.mock.lastCall?.[1]?.body)).voice).toBe("zh-CN-XiaoyiNeural");
|
|
unmount();
|
|
});
|
|
|
|
it("reports synthesis failure and allows a successful retry", async () => {
|
|
const onError = vi.fn();
|
|
fetchMock.mockResolvedValueOnce(errorResponse()).mockResolvedValueOnce(audioResponse());
|
|
const { result, unmount } = renderHook(() => useAgentSpeechSynthesis(onError));
|
|
await act(() => result.current.speak("message", "你好"));
|
|
expect(onError).toHaveBeenCalledWith("语音生成失败");
|
|
expect(result.current.speechState).toBe("idle");
|
|
await act(() => result.current.speak("message", "你好"));
|
|
expect(result.current.speechState).toBe("playing");
|
|
unmount();
|
|
});
|
|
|
|
it("handles failure of a later chunk without leaving playback loading", async () => {
|
|
const onError = vi.fn();
|
|
fetchMock.mockResolvedValueOnce(audioResponse()).mockImplementation(async () => errorResponse());
|
|
const { result, unmount } = renderHook(() => useAgentSpeechSynthesis(onError));
|
|
await act(() => result.current.speak("message", `${"甲".repeat(300)}。${"乙".repeat(300)}。`));
|
|
await act(async () => { MockAudio.instances[0].onended?.(); });
|
|
await waitFor(() => expect(onError).toHaveBeenCalledWith("语音生成失败"));
|
|
expect(result.current.speechState).toBe("idle");
|
|
expect(result.current.speakingMessageId).toBeNull();
|
|
unmount();
|
|
});
|
|
|
|
it("ignores a late failure belonging to a replaced playback", async () => {
|
|
const onError = vi.fn();
|
|
let failOld!: (reason: Error) => void;
|
|
fetchMock.mockImplementationOnce(() => new Promise((_resolve, reject) => { failOld = reject; }))
|
|
.mockResolvedValueOnce(audioResponse());
|
|
const { result, unmount } = renderHook(() => useAgentSpeechSynthesis(onError));
|
|
let oldPlayback!: Promise<void>;
|
|
act(() => { oldPlayback = result.current.speak("old", "旧回复"); });
|
|
await act(() => result.current.speak("new", "新回复"));
|
|
await act(async () => { failOld(new Error("old request failed")); await oldPlayback; });
|
|
expect(result.current.speechState).toBe("playing");
|
|
expect(result.current.speakingMessageId).toBe("new");
|
|
expect(onError).not.toHaveBeenCalled();
|
|
unmount();
|
|
});
|
|
|
|
it("reports media and resume failures and releases playback", async () => {
|
|
const onError = vi.fn();
|
|
fetchMock.mockImplementation(async () => audioResponse());
|
|
const { result, unmount } = renderHook(() => useAgentSpeechSynthesis(onError));
|
|
await act(() => result.current.speak("message", "你好"));
|
|
act(() => { MockAudio.instances[0].onerror?.(); });
|
|
expect(onError).toHaveBeenLastCalledWith("音频播放失败,请重试");
|
|
expect(result.current.speechState).toBe("idle");
|
|
await act(() => result.current.speak("message", "你好"));
|
|
act(() => result.current.pause());
|
|
MockAudio.instances[1].play.mockRejectedValueOnce(new DOMException("blocked", "NotAllowedError"));
|
|
await act(async () => result.current.resume());
|
|
expect(onError).toHaveBeenLastCalledWith("浏览器阻止了语音播放,请再次点击播放");
|
|
expect(result.current.speechState).toBe("idle");
|
|
unmount();
|
|
});
|
|
|
|
it("cancels a pending request silently when stopped", async () => {
|
|
const onError = vi.fn();
|
|
fetchMock.mockImplementation((_url, options) => new Promise((_resolve, reject) => {
|
|
options?.signal?.addEventListener("abort", () => reject(new DOMException("cancelled", "AbortError")));
|
|
}));
|
|
const { result, unmount } = renderHook(() => useAgentSpeechSynthesis(onError));
|
|
let playback!: Promise<void>;
|
|
act(() => { playback = result.current.speak("message", "你好"); });
|
|
await act(async () => { result.current.stop(); await playback; });
|
|
expect(onError).not.toHaveBeenCalled();
|
|
expect(result.current.speechState).toBe("idle");
|
|
unmount();
|
|
});
|