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(); 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; 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; 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(); });