1import { describe, expect, it, vi } from "vite-plus/test";
2
3import { VoiceInputController } from "@t3tools/client-runtime/voice-input";
4
5import { createLazyVoiceRecorder, type VoiceRecordingInput } from "./lazyVoiceRecorder";
6
7type Status = { readonly isFinished: boolean };
8type State = { readonly isRecording: boolean };
9
10function deferred() {
11 let resolve!: () => void;
12 const promise = new Promise<void>((resolvePromise) => {
13 resolve = resolvePromise;
14 });
15 return { promise, resolve };
16}
17
18function createNativeRecorder(uri: string) {
19 const listeners = new Set<(status: Status) => void>();
20 return {
21 uri: null as string | null,
22 prepared: uri,
23 prepareToRecordAsync: vi.fn(async function (this: { uri: string | null; prepared: string }) {
24 this.uri = this.prepared;
25 }),
26 record: vi.fn(),
27 stop: vi.fn(async (): Promise<void> => undefined),
28 getStatus: vi.fn((): State => ({ isRecording: true })),
29 addListener: vi.fn((_event: "recordingStatusUpdate", listener: (status: Status) => void) => {
30 listeners.add(listener);
31 return { remove: () => listeners.delete(listener) };
32 }),
33 getAvailableInputs: vi.fn((): ReadonlyArray<VoiceRecordingInput> => [
34 { uid: "car", name: "CarPlay", type: "CarAudio" },
35 { uid: "built-in", name: "iPhone Microphone", type: "MicrophoneBuiltIn" },
36 ]),
37 setInput: vi.fn((_uid: string) => undefined),
38 release: vi.fn(),
39 emit: (status: Status) => listeners.forEach((listener) => listener(status)),
40 };
41}
42
43function createHarness(
44 selectInput?: (inputs: ReadonlyArray<VoiceRecordingInput>) => VoiceRecordingInput | null,
45) {
46 const created: Array<ReturnType<typeof createNativeRecorder>> = [];
47 const statuses: Status[] = [];
48 const recorder = createLazyVoiceRecorder({
49 create: () => {
50 const native = createNativeRecorder(`file:///voice-${created.length}.m4a`);
51 created.push(native);
52 return native;
53 },
54 onStatus: (status) => statuses.push(status),
55 ...(selectInput ? { selectInput } : {}),
56 });
57 return { recorder, created, statuses };
58}
59
60describe("createLazyVoiceRecorder", () => {
61 it("does not create a native recorder until a recording prepares", async () => {
62 const { recorder, created } = createHarness();
63
64 expect(recorder.uri).toBeNull();
65 expect(recorder.getStatus()).toBeNull();
66 await recorder.stop();
67 recorder.release();
68 expect(created).toHaveLength(0);
69
70 await recorder.prepareToRecordAsync();
71 recorder.record({ forDuration: 300 });
72
73 expect(created).toHaveLength(1);
74 expect(created[0]!.record).toHaveBeenCalledWith({ forDuration: 300 });
75 expect(recorder.uri).toBe("file:///voice-0.m4a");
76 expect(recorder.getStatus()).toEqual({ isRecording: true });
77 });
78
79 it("reuses one native recorder across recordings", async () => {
80 const { recorder, created } = createHarness();
81
82 await recorder.prepareToRecordAsync();
83 await recorder.stop();
84 await recorder.prepareToRecordAsync();
85
86 expect(created).toHaveLength(1);
87 expect(created[0]!.prepareToRecordAsync).toHaveBeenCalledTimes(2);
88 });
89
90 it("selects the preferred input after preparing, when the session lists inputs", async () => {
91 const { recorder, created } = createHarness(
92 (inputs) => inputs.find((input) => input.type === "MicrophoneBuiltIn") ?? null,
93 );
94
95 await recorder.prepareToRecordAsync();
96
97 expect(created[0]!.setInput).toHaveBeenCalledWith("built-in");
98 });
99
100 it("keeps the system input when none is preferred or the preferred one disconnects", async () => {
101 const unpreferred = createHarness(() => null);
102 await unpreferred.recorder.prepareToRecordAsync();
103 expect(unpreferred.created[0]!.setInput).not.toHaveBeenCalled();
104
105 const disconnected = createHarness((inputs) => inputs[1] ?? null);
106 await disconnected.recorder.prepareToRecordAsync();
107 disconnected.created[0]!.setInput.mockImplementationOnce(() => {
108 throw new Error("Preferred input not found");
109 });
110 await expect(disconnected.recorder.prepareToRecordAsync()).resolves.toBeUndefined();
111 });
112
113 it("forwards status events until the recorder is released", async () => {
114 const { recorder, created, statuses } = createHarness();
115 await recorder.prepareToRecordAsync();
116 const native = created[0]!;
117
118 native.emit({ isFinished: true });
119 recorder.release();
120 native.emit({ isFinished: false });
121
122 expect(statuses).toEqual([{ isFinished: true }]);
123 });
124
125 it("releases only after a pending stop settles and keeps the final uri", async () => {
126 const { recorder, created } = createHarness();
127 await recorder.prepareToRecordAsync();
128 const native = created[0]!;
129 const stopped = deferred();
130 native.stop.mockReturnValueOnce(stopped.promise);
131
132 const stopping = recorder.stop();
133 recorder.release();
134 await Promise.resolve();
135 expect(native.release).not.toHaveBeenCalled();
136 expect(recorder.uri).toBe("file:///voice-0.m4a");
137
138 stopped.resolve();
139 await stopping;
140 await vi.waitFor(() => expect(native.release).toHaveBeenCalledTimes(1));
141 expect(recorder.uri).toBe("file:///voice-0.m4a");
142 expect(recorder.getStatus()).toBeNull();
143 });
144
145 it("releases after a failed stop and creates a fresh recorder for the next start", async () => {
146 const { recorder, created } = createHarness();
147 await recorder.prepareToRecordAsync();
148 const first = created[0]!;
149 first.stop.mockRejectedValueOnce(new Error("not recording"));
150
151 await expect(recorder.stop()).rejects.toThrow("not recording");
152 recorder.release();
153 await vi.waitFor(() => expect(first.release).toHaveBeenCalledTimes(1));
154 expect(() => recorder.record({ forDuration: 1 })).toThrow("not prepared");
155
156 await recorder.prepareToRecordAsync();
157 expect(created).toHaveLength(2);
158 expect(recorder.uri).toBe("file:///voice-1.m4a");
159 });
160
161 describe("with the voice input controller", () => {
162 function createController(granted: boolean) {
163 const harness = createHarness();
164 const deleted: string[] = [];
165 const controller = new VoiceInputController({
166 recorder: harness.recorder,
167 getTranscriber: () => ({
168 prepare: async () => ({ locale: "en-US", transcribe: async () => "text" }),
169 }),
170 requestPermission: async () => ({ granted, canAskAgain: true }),
171 configureRecording: async () => undefined,
172 releaseRecording: async () => undefined,
173 deleteRecording: (uri) => deleted.push(uri),
174 readDraft: () => ({
175 ownerKey: "thread",
176 text: "",
177 selection: { start: 0, end: 0 },
178 revision: 0,
179 }),
180 commitDraft: () => undefined,
181 onStateChange: () => undefined,
182 });
183 return { ...harness, controller, deleted };
184 }
185
186 it("does not create a native recorder when microphone permission is denied", async () => {
187 const { controller, created } = createController(false);
188
189 await controller.start();
190
191 expect(controller.currentState.phase).toBe("error");
192 expect(created).toHaveLength(0);
193 });
194
195 it("stops, deletes, and releases the recorder created by a cancelled recording", async () => {
196 const { controller, recorder, created, deleted } = createController(true);
197
198 await controller.start();
199 expect(controller.currentState.phase).toBe("recording");
200 expect(created).toHaveLength(1);
201 const native = created[0]!;
202
203 controller.cancel();
204 recorder.release();
205 await vi.waitFor(() => expect(native.release).toHaveBeenCalledTimes(1));
206
207 expect(native.stop).toHaveBeenCalledTimes(1);
208 expect(deleted).toEqual(["file:///voice-0.m4a"]);
209 expect(controller.currentState.phase).toBe("idle");
210 });
211 });
212});