Skip to content
Source
Sign inSign up
Open History to see the last commit

voiceInputSession.test.ts

399 lines15.4 KB

1import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
2import type { PreparedVoiceTranscription } from "@t3tools/client-runtime/voice-input";
3import { resetVoiceInputGlobalsForTests } from "../../../../../packages/client-runtime/src/voice-input/controller";
4import {
5 acquireAudioSession,
6 activateAudioSession,
7 isAudioSessionOwner,
8 releaseAudioSession,
9} from "../../lib/audioSession";
10​
11const audio = vi.hoisted(() => ({
12 setMode: vi.fn(async (_mode: unknown) => {}),
13 setActive: vi.fn(async (_active: boolean) => {}),
14}));
15vi.mock("expo-audio", () => ({
16 AudioModule: {},
17 setAudioModeAsync: audio.setMode,
18 setIsAudioActiveAsync: audio.setActive,
19}));
20​
21import {
22 createVoiceInputTarget,
23 VoiceInputSession,
24 type VoiceInputSessionDependencies,
25 type VoiceRecordingSample,
26} from "./voiceInputSession";
27​
28function createSession(
29 overrides?: Partial<
30 Pick<
31 VoiceInputSessionDependencies,
32 "configureRecording" | "releaseRecording" | "sampleRecording"
33 >
34 >,
35) {
36 const releaseKeepAwake = vi.fn();
37 const keepAwake = vi.fn(() => releaseKeepAwake);
38 const publishAudioLevels = vi.fn();
39 const recorder = {
40 uri: "file:///voice.m4a",
41 prepareToRecordAsync: vi.fn(async () => {}),
42 record: vi.fn(),
43 stop: vi.fn(async () => {}),
44 };
45 const prepare = vi.fn(async (): Promise<PreparedVoiceTranscription> => ({
46 locale: "en-US",
47 transcribe: async () => "spoken text",
48 }));
49 const session = new VoiceInputSession({
50 recorder,
51 getTranscriber: () => ({ prepare }),
52 requestPermission: async () => ({ granted: true, canAskAgain: true }),
53 configureRecording: async () => {},
54 releaseRecording: vi.fn(async () => {}),
55 deleteRecording: vi.fn(),
56 sampleRecording: () => null,
57 publishAudioLevels,
58 keepAwake,
59 ...overrides,
60 });
61 return { session, recorder, prepare, keepAwake, releaseKeepAwake, publishAudioLevels };
63​
64describe("global voice input", () => {
65 beforeEach(() => resetVoiceInputGlobalsForTests());
66​
67 it.each(["retry", "takeover"])(
68 "handles failed deactivation with %s without notifying a released owner",
69 async (action) => {
70 const pause = vi.fn();
71 const playback = acquireAudioSession("recording", pause);
72 await activateAudioSession(playback);
73 audio.setActive.mockRejectedValueOnce(new Error("Deactivation failed"));
74 await expect(releaseAudioSession(playback)).rejects.toThrow("Deactivation failed");
75 if (action === "retry") {
76 await releaseAudioSession(playback);
77 expect(audio.setActive).toHaveBeenLastCalledWith(false);
78 }
79 const recording = acquireAudioSession("recording");
80 expect(pause).not.toHaveBeenCalled();
81 await releaseAudioSession(recording);
82 vi.clearAllMocks();
83 },
84 );
85​
86 it.each(["complete", "activation failure"])(
87 "protects recording from inline playback and releases its session after %s",
88 async (outcome) => {
89 const pause = vi.fn();
90 const playback = acquireAudioSession("playback", pause);
91 await activateAudioSession(playback);
92 let recording!: ReturnType<typeof acquireAudioSession>;
93 const { session, recorder } = createSession({
94 configureRecording: async () => {
95 recording = acquireAudioSession("recording");
96 try {
97 await activateAudioSession(recording);
98 } catch (error) {
99 await releaseAudioSession(recording);
100 throw error;
101 }
102 },
103 releaseRecording: () => releaseAudioSession(recording),
104 });
105 if (outcome === "activation failure") {
106 audio.setActive.mockRejectedValueOnce(new Error("Audio activation failed"));
107 }
108 await session.start(createVoiceInputTarget("first", () => "", vi.fn(), { start: 0, end: 0 }));
109 expect(pause).toHaveBeenCalledOnce();
110 expect(isAudioSessionOwner(playback)).toBe(false);
111 if (outcome === "complete") {
112 expect(recorder.record).toHaveBeenCalledOnce();
113 expect(session.controller.currentState.phase).toBe("recording");
114 audio.setMode.mockClear();
115 audio.setActive.mockClear();
116 await releaseAudioSession(playback);
117 const blockedPlayback = acquireAudioSession("playback");
118 expect(await activateAudioSession(blockedPlayback)).toBe(false);
119 await releaseAudioSession(blockedPlayback);
120 expect(audio.setMode).not.toHaveBeenCalled();
121 expect(audio.setActive).not.toHaveBeenCalled();
122 expect(isAudioSessionOwner(recording)).toBe(true);
123 await session.controller.stop();
124 } else {
125 expect(recorder.record).not.toHaveBeenCalled();
126 expect(session.controller.currentState.phase).toBe("error");
127 }
128 expect(audio.setActive).toHaveBeenLastCalledWith(false);
129 expect(isAudioSessionOwner(recording)).toBe(false);
130 const replay = acquireAudioSession("playback");
131 expect(await activateAudioSession(replay)).toBe(true);
132 expect(audio.setActive).toHaveBeenLastCalledWith(true);
133 await releaseAudioSession(replay);
134 vi.clearAllMocks();
135 },
136 );
137​
138 it.each([
139 { selection: { start: 6, end: 11 }, expected: "hello spoken text", cursor: 17 },
140 { selection: { start: 6, end: 6 }, expected: "hello spoken text world", cursor: 18 },
141 ])(
142 "keeps the starting selection $selection after navigation",
143 async ({ selection, expected, cursor }) => {
144 const { session } = createSession();
145 const commit = vi.fn();
146 await session.start(createVoiceInputTarget("first", () => "hello world", commit, selection));
147 await session.start(
148 createVoiceInputTarget("second", () => "other prompt", vi.fn(), { start: 12, end: 12 }),
149 );
150 await session.controller.stop();
151 expect(commit).toHaveBeenCalledWith(expected, {
152 start: cursor,
153 end: cursor,
154 });
155 },
156 );
157​
158 it("appends to the starting draft after its screen leaves and another draft opens", async () => {
159 const { session, recorder } = createSession();
160 const drafts = new Map([
161 ["first", "original prompt"],
162 ["second", "other prompt"],
163 ]);
164 let visibleDraft = "first";
165 const targetKey = visibleDraft;
166 await session.start(
167 createVoiceInputTarget(
168 targetKey,
169 () => drafts.get(targetKey) ?? null,
170 (text) => drafts.set(targetKey, text),
171 { start: 15, end: 15 },
172 ),
173 );
174 visibleDraft = "second";
175 expect(session.controller.currentState.phase).toBe("recording");
176 expect(recorder.stop).not.toHaveBeenCalled();
177 await session.controller.stop();
178​
179 expect(drafts.get("first")).toBe("original prompt spoken text");
180 expect(drafts.get(visibleDraft)).toBe("other prompt");
181 expect(session.controller.currentState.phase).toBe("idle");
182 });
183​
184 it.each(["preparing", "recording", "transcribing"] as const)(
185 "keeps one recorder and its original target during %s",
186 async (phase) => {
187 const preparation = Promise.withResolvers<PreparedVoiceTranscription>();
188 const preparationEntered = Promise.withResolvers<void>();
189 const transcription = Promise.withResolvers<string>();
190 const transcriptionEntered = Promise.withResolvers<void>();
191 const { session, recorder, prepare } = createSession();
192 prepare.mockImplementationOnce(() => {
193 preparationEntered.resolve();
194 return preparation.promise;
195 });
196 const firstCommit = vi.fn();
197 const secondCommit = vi.fn();
198 const starting = session.start(
199 createVoiceInputTarget("first", () => "first", firstCommit, { start: 5, end: 5 }),
200 );
201 await preparationEntered.promise;
202 let stopping: Promise<void> | null = null;
203 if (phase !== "preparing") {
204 preparation.resolve({
205 locale: "en-US",
206 transcribe: () => {
207 transcriptionEntered.resolve();
208 return transcription.promise;
209 },
210 });
211 await starting;
212 }
213 if (phase === "transcribing") {
214 stopping = session.controller.stop();
215 await transcriptionEntered.promise;
216 }
217 await session.start(
218 createVoiceInputTarget("second", () => "second", secondCommit, { start: 6, end: 6 }),
219 );
220 expect(session.ownerKey).toBe("first");
221 expect(session.controller.currentState.phase).toBe(phase);
222 expect(prepare).toHaveBeenCalledTimes(1);
223 preparation.resolve({ locale: "en-US", transcribe: async () => "spoken text" });
224 await starting;
225 transcription.resolve("spoken text");
226 await (stopping ?? session.controller.stop());
227 expect(recorder.record).toHaveBeenCalledTimes(1);
228 expect(firstCommit).toHaveBeenCalledWith("first spoken text", { start: 17, end: 17 });
229 expect(secondCommit).not.toHaveBeenCalled();
230 },
231 );
232​
233 it.each(["changed", "removed"] as const)(
234 "does not overwrite a %s starting draft",
235 async (change) => {
236 const { session } = createSession();
237 let text: string | null = "first";
238 const commit = vi.fn();
239 await session.start(
240 createVoiceInputTarget("first", () => text, commit, { start: 5, end: 5 }),
241 );
242 text = change === "removed" ? null : "edited prompt";
243 await session.controller.stop();
244 expect(commit).not.toHaveBeenCalled();
245 expect(session.controller.currentState.error).toContain("draft changed");
246 },
247 );
248​
249 it("retries a failed start against the draft as edited since", async () => {
250 const { session, prepare } = createSession();
251 prepare.mockRejectedValueOnce(new Error("model unavailable"));
252 let text = "first";
253 const commit = vi.fn();
254 await session.start(createVoiceInputTarget("first", () => text, commit, { start: 5, end: 5 }));
255 expect(session.controller.currentState.phase).toBe("error");
256 text = "first draft";
257 await session.retry();
258 await session.controller.stop();
259 expect(prepare).toHaveBeenCalledTimes(2);
260 expect(commit).toHaveBeenCalledWith("first spoken text draft", { start: 17, end: 17 });
261 });
262​
263 it("finishes the original draft at the recording limit while it is off screen", async () => {
264 const { session, recorder } = createSession();
265 const commit = vi.fn();
266 await session.start(
267 createVoiceInputTarget("first", () => "first", commit, { start: 5, end: 5 }),
268 );
269 await session.controller.handleRecorderStatus({
270 isFinished: true,
271 hasError: false,
272 error: null,
273 url: recorder.uri,
274 });
275 expect(commit).toHaveBeenCalledWith("first spoken text", { start: 17, end: 17 });
276 expect(session.controller.currentState.phase).toBe("idle");
277 });
278​
279 it("stops recording when its queued edit is discarded", async () => {
280 const { session, recorder } = createSession();
281 const commit = vi.fn();
282 let draft: string | null = "queued prompt";
283 const ownerKey = "thread~queued-edit~run";
284 await session.start(
285 createVoiceInputTarget(ownerKey, () => draft, commit, { start: 13, end: 13 }),
286 );
287 session.cancel(ownerKey);
288 draft = null;
289 await session.controller.stop();
290 expect(recorder.stop).toHaveBeenCalledTimes(1);
291 expect(session.controller.currentState).toEqual({
292 phase: "idle",
293 error: null,
294 errorAction: null,
295 });
296 expect(commit).not.toHaveBeenCalled();
297 });
298​
299 it("keeps another prompt's recording when a queued edit is discarded", async () => {
300 const { session, recorder } = createSession();
301 const commit = vi.fn();
302 await session.start(
303 createVoiceInputTarget("other prompt", () => "hello", commit, { start: 5, end: 5 }),
304 );
305 session.cancel("thread~queued-edit~run");
306 expect(recorder.stop).not.toHaveBeenCalled();
307 expect(session.controller.currentState.phase).toBe("recording");
308 await session.controller.stop();
309 expect(commit).toHaveBeenCalledWith("hello spoken text", { start: 17, end: 17 });
310 });
311​
312 it("waits for canceled native work before starting a recording for another draft", async () => {
313 const preparation = Promise.withResolvers<PreparedVoiceTranscription>();
314 const preparationEntered = Promise.withResolvers<void>();
315 const { session, recorder, prepare } = createSession();
316 prepare.mockImplementationOnce(() => {
317 preparationEntered.resolve();
318 return preparation.promise;
319 });
320 const oldCommit = vi.fn();
321 const nextCommit = vi.fn();
322 const firstStart = session.start(
323 createVoiceInputTarget("first", () => "first", oldCommit, { start: 5, end: 5 }),
324 );
325 await preparationEntered.promise;
326 session.cancel("first");
327 const nextStart = session.start(
328 createVoiceInputTarget("second", () => "second", nextCommit, { start: 6, end: 6 }),
329 );
330 expect(prepare).toHaveBeenCalledTimes(1);
331 expect(recorder.record).not.toHaveBeenCalled();
332 preparation.resolve({ locale: "en-US", transcribe: async () => "old transcript" });
333 await firstStart;
334 await nextStart;
335 await session.controller.stop();
336 expect(recorder.record).toHaveBeenCalledTimes(1);
337 expect(oldCommit).not.toHaveBeenCalled();
338 expect(nextCommit).toHaveBeenCalledWith("second spoken text", { start: 18, end: 18 });
339 });
340​
341 it("holds the screen awake and meters only while recording", async () => {
342 vi.useFakeTimers();
343 try {
344 let sample: VoiceRecordingSample = { durationMillis: 0, metering: -100 };
345 const { session, keepAwake, releaseKeepAwake, publishAudioLevels } = createSession({
346 sampleRecording: () => sample,
347 });
348 const snapshots: number[] = [];
349 session.subscribe(() => snapshots.push(session.getSnapshot().elapsedSeconds));
350​
351 await session.start(
352 createVoiceInputTarget("first", () => "first", vi.fn(), { start: 5, end: 5 }),
353 );
354 expect(session.getSnapshot().state.phase).toBe("recording");
355 expect(keepAwake).toHaveBeenCalledTimes(1);
356 // Silence on a silent history publishes nothing.
357 expect(publishAudioLevels).not.toHaveBeenCalled();
358​
359 sample = { durationMillis: 2_400, metering: 0 };
360 vi.advanceTimersByTime(80);
361 expect(session.getSnapshot().elapsedSeconds).toBe(2);
362 expect(publishAudioLevels).toHaveBeenLastCalledWith(expect.arrayContaining([1]));
363​
364 await session.controller.stop();
365 expect(releaseKeepAwake).toHaveBeenCalledTimes(1);
366 const publishedLevels = publishAudioLevels.mock.calls.length;
367 const publishedSnapshots = snapshots.length;
368 vi.advanceTimersByTime(800);
369 expect(publishAudioLevels).toHaveBeenCalledTimes(publishedLevels);
370 expect(snapshots).toHaveLength(publishedSnapshots);
371 // The finished recording's time stays readable until the next one starts.
372 expect(session.getSnapshot().elapsedSeconds).toBe(2);
373​
374 sample = { durationMillis: 0, metering: -100 };
375 await session.start(
376 createVoiceInputTarget("first", () => "first", vi.fn(), { start: 5, end: 5 }),
377 );
378 expect(session.getSnapshot().elapsedSeconds).toBe(0);
379 expect(publishAudioLevels).toHaveBeenLastCalledWith(expect.not.arrayContaining([1]));
380 expect(keepAwake).toHaveBeenCalledTimes(2);
381 session.dispose();
382 expect(releaseKeepAwake).toHaveBeenCalledTimes(2);
383 } finally {
384 vi.useRealTimers();
385 }
386 });
387​
388 it("publishes which owners are focused", () => {
389 const { session } = createSession();
390 const listener = vi.fn();
391 session.subscribe(listener);
392 session.setOwnerFocused("first", true);
393 session.setOwnerFocused("first", true);
394 expect(session.getSnapshot().focusedOwners).toEqual(new Set(["first"]));
395 expect(listener).toHaveBeenCalledTimes(1);
396 session.setOwnerFocused("first", false);
397 expect(session.getSnapshot().focusedOwners.size).toBe(0);
398 });
399});