import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest"; import type { ClientOptions } from "./client"; import { VoiceController } from "./controller"; const { MockClient, closeContext } = vi.hoisted(() => { const closeContext = vi.fn().mockResolvedValue(undefined); class MockClient { static instances: MockClient[] = []; readonly options: ClientOptions; private listeners = new Map void>>(); constructor(options: ClientOptions) { this.options = options; MockClient.instances.push(this); } on(name: string, listener: (value: unknown) => void) { const listeners = this.listeners.get(name) ?? new Set(); listeners.add(listener); this.listeners.set(name, listeners); return () => { listeners.delete(listener); }; } emit(name: string, value: unknown) { this.listeners.get(name)?.forEach((listener) => { listener(value); }); } connect = vi.fn(() => { this.emit("status", "listening"); return Promise.resolve(); }); close = vi.fn(() => { this.emit("status", "closed"); return Promise.resolve(); }); updateSession = vi.fn(); setGate = vi.fn(); setMuted = vi.fn(); renderExternalAnswer = vi.fn(); sendToolOutput = vi.fn(); join = vi.fn(); } return { MockClient, closeContext }; }); vi.mock("./client", async (importOriginal) => ({ ...(await importOriginal()), RealtimeClient: MockClient })); vi.mock("../audio/engine", () => ({ createAudioContext: () => ({ state: "running", close: closeContext }) })); let pendingReasoning: ((value: Response) => void) | undefined; let controller: VoiceController; let fetchMock: Mock<(path: string, init?: RequestInit) => Promise>; beforeEach(() => { vi.useFakeTimers(); vi.clearAllMocks(); MockClient.instances = []; pendingReasoning = undefined; vi.stubGlobal("AudioContext", vi.fn()); vi.stubGlobal("navigator", { mediaDevices: { getUserMedia: vi.fn() } }); fetchMock = vi.fn((path: string) => { if (path === "/api/reasoning") { return new Promise((resolve) => { pendingReasoning = resolve; }); } const pathname = new URL(path, "http://localhost").pathname; const responses: Record = { "/api/config": { reasoningType: "NODE_RED", injectProfile: false, s2sUrl: "ws://localhost/v1/realtime" }, "/api/me": { enabled: false }, "/api/user/phone": { result: { name: "Ana" } }, "/api/user/reports": { count: 0 }, }; return Promise.resolve(new Response(JSON.stringify(responses[pathname] ?? {}))); }); vi.stubGlobal("fetch", fetchMock); controller = new VoiceController({ phoneNumber: "1234", episodeId: "episode1" }); }); afterEach(async () => { await controller.stop(); vi.useRealTimers(); vi.unstubAllGlobals(); }); async function start() { await controller.initialize(new AbortController().signal); await controller.start(); const client = MockClient.instances[0]; if (!client) { throw new Error("No client created"); } return client; } describe("voice application lifecycle", () => { it("keeps LOG_ONLY reasoning on the native voice path and preserves separate profile context", async () => { const client = await start(); client.emit("transcript", { id: "u1", role: "user", text: "Hola", partial: false }); pendingReasoning?.(new Response(JSON.stringify({ log_only: true, answer: "" }))); await vi.advanceTimersByTimeAsync(1); const request = fetchMock.mock.calls.find(([path]) => path === "/api/reasoning"); expect(request?.[1]?.body).toContain('"phoneNumber":"1234"'); expect(request?.[1]?.body).toContain("Caller profile resolved"); expect(client.options.instructions).not.toContain("Caller profile resolved"); expect(client.renderExternalAnswer).not.toHaveBeenCalled(); expect(controller.getSnapshot().messages).toHaveLength(1); }); it("discards external reasoning that finishes after the session was stopped", async () => { const client = await start(); client.emit("transcript", { id: "u1", role: "user", text: "Consulta", partial: false }); await controller.stop(); pendingReasoning?.(new Response(JSON.stringify({ answer: "Respuesta tardía" }))); await vi.advanceTimersByTimeAsync(1); expect(client.renderExternalAnswer).not.toHaveBeenCalled(); expect(controller.getSnapshot().messages.map((message) => message.text)).toEqual(["Consulta"]); }); it("applies and removes RAG context in the live session", async () => { const client = await start(); controller.setRag("Datos importantes"); expect(client.updateSession).toHaveBeenLastCalledWith(expect.stringContaining("Datos importantes"), "Aiden", []); controller.setRag(""); expect(client.updateSession).toHaveBeenLastCalledWith(expect.not.stringContaining("CUSTOM_RAG_BEGIN"), "Aiden", []); }); it("closes after silence but does not cut off the assistant while speaking", async () => { const client = await start(); client.emit("status", "ai-speaking"); await vi.advanceTimersByTimeAsync(60000); expect(client.close).not.toHaveBeenCalled(); client.emit("status", "listening"); await vi.advanceTimersByTimeAsync(30000); expect(client.close).toHaveBeenCalledOnce(); expect(controller.getSnapshot().status).toBe("closed"); }); });