Files
next-tjwater-frontend/src/features/agent/hooks/use-agent-voice.test.tsx
T
jiang c0e4231b83
Generic Container CI/CD / test-build-publish (push) Successful in 1m52s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m52s
feat: expand intelligent investigation workspace
2026-10-30 17:30:19 +08:00

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