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 createNativeRecorder(uri: string) {
11 const listeners = new Set<(status: Status) => void>();
12 // Like expo-audio on Android, preparing again requires a stop in between.
13 let isPrepared = false;
14 return {
15 uri: null as string | null,
16 prepared: uri,
17 prepareToRecordAsync: vi.fn(async function (this: { uri: string | null; prepared: string }) {
18 if (isPrepared) throw new Error("Recorder is already prepared");
19 isPrepared = true;
20 this.uri = this.prepared;
21 }),
22 record: vi.fn(),
23 stop: vi.fn(async (): Promise<void> => {
24 isPrepared = false;
25 }),
26 getStatus: vi.fn((): State => ({ isRecording: true })),
27 addListener: vi.fn((_event: "recordingStatusUpdate", listener: (status: Status) => void) => {
28 listeners.add(listener);
29 return { remove: () => listeners.delete(listener) };
30 }),
31 getAvailableInputs: vi.fn((): ReadonlyArray<VoiceRecordingInput> => [
32 { uid: "car", name: "CarPlay", type: "CarAudio" },
33 { uid: "built-in", name: "iPhone Microphone", type: "MicrophoneBuiltIn" },
34 ]),
35 setInput: vi.fn((_uid: string): void | Promise<void> => undefined),
36 cancelInputSelection: vi.fn((): void => undefined),
37 release: vi.fn(),
38 emit: (status: Status) => listeners.forEach((listener) => listener(status)),
39 };
40}
41
42function createHarness(
43 selectInput?: (inputs: ReadonlyArray<VoiceRecordingInput>) => VoiceRecordingInput | null,
44 configure?: (native: ReturnType<typeof createNativeRecorder>) => void,
45) {
46 const created: Array<ReturnType<typeof createNativeRecorder>> = [];
47 const statuses: Status[] = [];
48 const recorder = createLazyVoiceRecorder({
49 getDestination: () => "local",
50 create: () => {
51 const native = createNativeRecorder(`file:///voice-${created.length}.m4a`);
52 configure?.(native);
53 created.push(native);
54 return native;
55 },
56 onStatus: (status) => statuses.push(status),
57 ...(selectInput ? { selectInput } : {}),
58 });
59 return { recorder, created, statuses };
60}
61
62describe("createLazyVoiceRecorder", () => {
63 it("switches between local live capture and an encoded file only for the next recording", async () => {
64 let destination: "local" | "environment" = "local";
65 const local = createNativeRecorder("file:///local.pcm");
66 const nextLocal = createNativeRecorder("file:///next-local.pcm");
67 const encoded = createNativeRecorder("file:///environment.m4a");
68 const localRecorders = [local, nextLocal];
69 const recorder = createLazyVoiceRecorder({
70 getDestination: () => destination,
71 create: (mode) => (mode === "local" ? localRecorders.shift()! : encoded),
72 onStatus: () => {},
73 });
74 await recorder.prepareToRecordAsync();
75 destination = "environment";
76 expect(recorder.uri).toBe(local.prepared);
77 await recorder.stop();
78 await recorder.prepareToRecordAsync();
79 expect(recorder.uri).toBe(encoded.prepared);
80 expect(local.release).toHaveBeenCalledOnce();
81 destination = "local";
82 await recorder.stop();
83 await recorder.prepareToRecordAsync();
84 expect(recorder.uri).toBe(nextLocal.prepared);
85 expect(encoded.release).toHaveBeenCalledOnce();
86 recorder.release();
87 });
88
89 it("keeps the system input when none is preferred or the preferred one disconnects", async () => {
90 const unpreferred = createHarness(() => null);
91 await unpreferred.recorder.prepareToRecordAsync();
92 expect(unpreferred.created[0]!.setInput).not.toHaveBeenCalled();
93
94 const disconnected = createHarness((inputs) => inputs[1] ?? null);
95 await disconnected.recorder.prepareToRecordAsync();
96 await disconnected.recorder.stop();
97 disconnected.created[0]!.setInput.mockImplementationOnce(() => {
98 throw new Error("Preferred input not found");
99 });
100 await expect(disconnected.recorder.prepareToRecordAsync()).resolves.toBeUndefined();
101
102 const timedOut = createHarness(
103 (inputs) => inputs[1] ?? null,
104 (native) => {
105 native.setInput.mockImplementationOnce(() =>
106 Promise.reject(new Error("Routing timed out")),
107 );
108 },
109 );
110 await expect(timedOut.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 describe("with the voice input controller", () => {
126 function createController(
127 granted: boolean,
128 selectInput?: (inputs: ReadonlyArray<VoiceRecordingInput>) => VoiceRecordingInput | null,
129 configure?: (native: ReturnType<typeof createNativeRecorder>) => void,
130 ) {
131 const harness = createHarness(selectInput, configure);
132 const deleted: string[] = [];
133 const controller = new VoiceInputController({
134 recorder: harness.recorder,
135 getTranscriber: () => ({
136 prepare: async () => ({ locale: "en-US", transcribe: async () => "text" }),
137 }),
138 requestPermission: async () => ({ granted, canAskAgain: true }),
139 configureRecording: async () => undefined,
140 releaseRecording: async () => undefined,
141 deleteRecording: (uri) => deleted.push(uri),
142 readDraft: () => ({
143 ownerKey: "thread",
144 text: "",
145 selection: { start: 0, end: 0 },
146 }),
147 commitDraft: () => undefined,
148 onStateChange: () => undefined,
149 });
150 return { ...harness, controller, deleted };
151 }
152
153 it("does not create a native recorder when microphone permission is denied", async () => {
154 const { controller, created } = createController(false);
155
156 await controller.start();
157
158 expect(controller.currentState.phase).toBe("error");
159 expect(created).toHaveLength(0);
160 });
161
162 it("abandons a pending input route when dictation is canceled", async () => {
163 const routing = Promise.withResolvers<void>();
164 const { controller, created } = createController(
165 true,
166 (inputs) => inputs[1] ?? null,
167 (native) => {
168 // Expo drops the promise of a canceled coroutine function, so it never settles.
169 native.setInput.mockImplementationOnce(() => {
170 routing.resolve();
171 return new Promise(() => undefined);
172 });
173 },
174 );
175 const starting = controller.start();
176 await routing.promise;
177 controller.cancel();
178 await starting;
179 expect(created[0]!.cancelInputSelection).toHaveBeenCalled();
180 expect(controller.currentState.phase).toBe("idle");
181
182 await controller.start();
183 expect(controller.currentState.phase).toBe("recording");
184 });
185 });
186});