chore(frontend): validate migration and document development
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
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<string, Set<(value: unknown) => 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<Response>>;
|
||||
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<Response>((resolve) => {
|
||||
pendingReasoning = resolve;
|
||||
});
|
||||
}
|
||||
const pathname = new URL(path, "http://localhost").pathname;
|
||||
const responses: Record<string, unknown> = {
|
||||
"/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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user