1import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
3import {
4 resetVoiceInputGlobalsForTests,
5 resolveTranscriptCommit,
6 VoiceInputController,
7 VOICE_RECORDING_LIMIT_SECONDS,
8 voiceInputBlocksSubmission,
9 type VoiceDraftSnapshot,
10 type VoiceInputControllerDependencies,
11 type VoiceRecorder,
12} from "./controller.ts";
13import type { PreparedVoiceTranscription, VoiceTranscriber } from "./transcription.ts";
14
15function deferred<T>() {
16 let resolve!: (value: T) => void;
17 let reject!: (error: unknown) => void;
18 const promise = new Promise<T>((resolvePromise, rejectPromise) => {
19 resolve = resolvePromise;
20 reject = rejectPromise;
21 });
22 return { promise, resolve, reject };
23}
24
25class TestRecorder implements VoiceRecorder {
26 uri: string | null = "file:///voice.m4a";
27 readonly prepareToRecordAsync = vi.fn(async () => undefined);
28 readonly record = vi.fn();
29 readonly stop = vi.fn(async () => undefined);
30}
31
32function preparedTranscription(
33 transcribe: PreparedVoiceTranscription["transcribe"] = async () => "new text",
34): PreparedVoiceTranscription {
35 return { locale: "en-US", transcribe };
36}
37
38function draft(overrides: Partial<VoiceDraftSnapshot> = {}): VoiceDraftSnapshot {
39 return {
40 ownerKey: "environment:thread",
41 text: "hello world",
42 selection: { start: 6, end: 11 },
43 revision: 1,
44 ...overrides,
45 };
46}
47
48function createHarness(
49 overrides: Partial<VoiceInputControllerDependencies> = {},
50 initialDraft = draft(),
51) {
52 const recorder = new TestRecorder();
53 let currentDraft: VoiceDraftSnapshot | null = initialDraft;
54 const commits: Array<{ text: string; selection: { start: number; end: number } }> = [];
55 const deleted: string[] = [];
56 const dependencies: VoiceInputControllerDependencies = {
57 recorder,
58 getTranscriber: () => ({ prepare: async () => preparedTranscription() }),
59 requestPermission: async () => ({ granted: true, canAskAgain: true }),
60 configureRecording: async () => undefined,
61 releaseRecording: async () => undefined,
62 deleteRecording: (uri) => deleted.push(uri),
63 readDraft: () => currentDraft,
64 commitDraft: (text, selection) => commits.push({ text, selection }),
65 onStateChange: vi.fn(),
66 ...overrides,
67 };
68 return {
69 controller: new VoiceInputController(dependencies),
70 recorder,
71 commits,
72 deleted,
73 setDraft: (next: VoiceDraftSnapshot | null) => {
74 currentDraft = next;
75 },
76 };
77}
78
79describe("resolveTranscriptCommit", () => {
80 it("replaces the recorded UTF-16 selection around emoji and composer tokens", () => {
81 const text = "Fix 🧪 then $review please";
82 const tokenStart = text.indexOf("$review");
83 const captured = draft({
84 text,
85 selection: { start: tokenStart, end: tokenStart + "$review".length },
86 });
87
88 expect(resolveTranscriptCommit(captured, captured, "use the mobile skill", "en-US")).toEqual({
89 kind: "commit",
90 text: "Fix 🧪 then use the mobile skill please",
91 selection: { start: tokenStart + "use the mobile skill".length, end: tokenStart + 20 },
92 });
93 });
94
95 it("does not replace text after the owner, text, or revision changes", () => {
96 const captured = draft();
97 expect(
98 resolveTranscriptCommit(captured, draft({ ownerKey: "other" }), "text", "en-US"),
99 ).toEqual({
100 kind: "stale",
101 });
102 expect(resolveTranscriptCommit(captured, draft({ text: "newer" }), "text", "en-US")).toEqual({
103 kind: "stale",
104 });
105 expect(resolveTranscriptCommit(captured, draft({ revision: 2 }), "text", "en-US")).toEqual({
106 kind: "stale",
107 });
108 });
109
110 it("adds English spacing at empty start, middle, and end caret boundaries", () => {
111 const atEnd = draft({
112 text: "Fix cache.",
113 selection: { start: "Fix cache.".length, end: "Fix cache.".length },
114 });
115 expect(resolveTranscriptCommit(atEnd, atEnd, "Also fix tests.", "en-US")).toMatchObject({
116 kind: "commit",
117 text: "Fix cache. Also fix tests.",
118 });
119 expect(resolveTranscriptCommit(atEnd, atEnd, "Also fix tests.", "en_US")).toMatchObject({
120 kind: "commit",
121 text: "Fix cache. Also fix tests.",
122 });
123
124 const atStart = draft({ text: "Fix cache.", selection: { start: 0, end: 0 } });
125 expect(resolveTranscriptCommit(atStart, atStart, "First", "en-US")).toMatchObject({
126 kind: "commit",
127 text: "First Fix cache.",
128 });
129
130 const inMiddle = draft({ text: "Fix cache.", selection: { start: 4, end: 4 } });
131 expect(resolveTranscriptCommit(inMiddle, inMiddle, "also", "en-US")).toMatchObject({
132 kind: "commit",
133 text: "Fix also cache.",
134 });
135 });
136
137 it("does not add English boundary spaces to CJK or selected inline text", () => {
138 const cjk = draft({ text: "修正キャッシュ", selection: { start: 8, end: 8 } });
139 expect(resolveTranscriptCommit(cjk, cjk, "テストも", "ja-JP")).toMatchObject({
140 kind: "commit",
141 text: "修正キャッシュテストも",
142 });
143
144 const selected = draft({ text: "one $skill two", selection: { start: 4, end: 10 } });
145 expect(resolveTranscriptCommit(selected, selected, "new", "en-US")).toMatchObject({
146 kind: "commit",
147 text: "one new two",
148 });
149 });
150});
151
152describe("VoiceInputController", () => {
153 beforeEach(() => resetVoiceInputGlobalsForTests());
154
155 it("checks support and permission before recording", async () => {
156 const unsupported = createHarness({ getTranscriber: () => null });
157 await unsupported.controller.start();
158 expect(unsupported.controller.currentState.error).toContain("not available");
159 expect(unsupported.recorder.record).not.toHaveBeenCalled();
160
161 const denied = createHarness({
162 requestPermission: async () => ({ granted: false, canAskAgain: false }),
163 });
164 await denied.controller.start();
165 expect(denied.controller.currentState.errorAction).toBe("settings");
166 expect(denied.recorder.record).not.toHaveBeenCalled();
167 });
168
169 it.each(["permission", "transcription"] as const)(
170 "clears %s errors when switching to another draft",
171 async (failure) => {
172 const harness = createHarness(
173 failure === "permission"
174 ? { requestPermission: async () => ({ granted: false, canAskAgain: false }) }
175 : {
176 getTranscriber: () => ({
177 prepare: async () =>
178 preparedTranscription(async () => {
179 throw new Error("Transcription failed");
180 }),
181 }),
182 },
183 );
184 await harness.controller.start();
185 await harness.controller.stop();
186 expect(harness.controller.currentState).toMatchObject({
187 phase: "error",
188 error: expect.any(String),
189 errorAction: failure === "permission" ? "settings" : "retry",
190 });
191
192 harness.setDraft(draft({ ownerKey: "environment:other-thread" }));
193 harness.controller.ownerChanged();
194
195 expect(harness.controller.currentState).toEqual({
196 phase: "idle",
197 error: null,
198 errorAction: null,
199 });
200 expect(harness.commits).toEqual([]);
201 },
202 );
203
204 it.each(["permission", "preparation", "recording"] as const)(
205 "keeps the selected transcriber when preferences change during %s",
206 async (changeDuring) => {
207 const permission = deferred<{ granted: boolean; canAskAgain: boolean }>();
208 const permissionEntered = deferred<void>();
209 const preparation = deferred<void>();
210 const preparationEntered = deferred<void>();
211 const preparationSignals: AbortSignal[] = [];
212 const transcriptionSignals: AbortSignal[] = [];
213 const transcriber = (text: string): VoiceTranscriber => ({
214 prepare: async ({ signal }) => {
215 preparationSignals.push(signal);
216 preparationEntered.resolve(undefined);
217 await preparation.promise;
218 return preparedTranscription(async (_uri, { signal }) => {
219 transcriptionSignals.push(signal);
220 return text;
221 });
222 },
223 });
224 const first = transcriber("first choice");
225 const second = transcriber("second choice");
226 let selected = first;
227 const harness = createHarness({
228 getTranscriber: () => selected,
229 requestPermission: () => {
230 permissionEntered.resolve(undefined);
231 return permission.promise;
232 },
233 });
234
235 const starting = harness.controller.start();
236 await permissionEntered.promise;
237 if (changeDuring === "permission") selected = second;
238 permission.resolve({ granted: true, canAskAgain: true });
239 await preparationEntered.promise;
240 if (changeDuring === "preparation") selected = second;
241 preparation.resolve(undefined);
242 await starting;
243 if (changeDuring === "recording") selected = second;
244 await harness.controller.stop();
245
246 expect(harness.commits.map((commit) => commit.text)).toEqual(["hello first choice"]);
247
248 await harness.controller.start();
249 await harness.controller.stop();
250
251 expect(harness.commits.map((commit) => commit.text)).toEqual([
252 "hello first choice",
253 "hello second choice",
254 ]);
255 expect(preparationSignals).toHaveLength(2);
256 expect(transcriptionSignals).toHaveLength(2);
257 expect(transcriptionSignals[0]).toBe(preparationSignals[0]);
258 expect(transcriptionSignals[1]).toBe(preparationSignals[1]);
259 expect(preparationSignals[1]).not.toBe(preparationSignals[0]);
260 },
261 );
262
263 it("blocks submit while voice input can still change the draft", () => {
264 expect(voiceInputBlocksSubmission({ phase: "preparing", error: null, errorAction: null })).toBe(
265 true,
266 );
267 expect(voiceInputBlocksSubmission({ phase: "recording", error: null, errorAction: null })).toBe(
268 true,
269 );
270 expect(
271 voiceInputBlocksSubmission({ phase: "transcribing", error: null, errorAction: null }),
272 ).toBe(true);
273 expect(voiceInputBlocksSubmission({ phase: "idle", error: null, errorAction: null })).toBe(
274 false,
275 );
276 });
277
278 it("uses the native five-minute cap and commits one final transcript", async () => {
279 const harness = createHarness();
280 await harness.controller.start();
281 expect(harness.recorder.record).toHaveBeenCalledWith({
282 forDuration: VOICE_RECORDING_LIMIT_SECONDS,
283 });
284
285 const stopping = harness.controller.stop();
286 harness.controller.handleRecorderStatus({
287 isFinished: true,
288 hasError: false,
289 error: null,
290 url: "file:///voice.m4a",
291 });
292 await stopping;
293
294 expect(harness.commits).toEqual([
295 { text: "hello new text", selection: { start: 14, end: 14 } },
296 ]);
297 expect(harness.deleted).toEqual(["file:///voice.m4a"]);
298 });
299
300 it.each(["cancel", "dispose", "ownerChanged"] as const)(
301 "queues the next recording after %s until transcription and audio cleanup settle",
302 async (action) => {
303 const transcription = deferred<string>();
304 const transcriptionEntered = deferred<AbortSignal>();
305 const audioRelease = deferred<void>();
306 const audioReleaseEntered = deferred<void>();
307 const releaseRecording = vi
308 .fn<() => Promise<void>>()
309 .mockRejectedValueOnce(new Error("busy"))
310 .mockImplementationOnce(() => {
311 audioReleaseEntered.resolve(undefined);
312 return audioRelease.promise;
313 });
314 const harness = createHarness({
315 releaseRecording,
316 getTranscriber: () => ({
317 prepare: async () =>
318 preparedTranscription((_uri, { signal }) => {
319 transcriptionEntered.resolve(signal);
320 return transcription.promise;
321 }),
322 }),
323 });
324 await harness.controller.start();
325 const stopping = harness.controller.stop();
326 const signal = await transcriptionEntered.promise;
327 expect(signal.aborted).toBe(false);
328 if (action === "ownerChanged") {
329 harness.setDraft(draft({ ownerKey: "environment:other-thread" }));
330 }
331 harness.controller[action]();
332 expect(signal.aborted).toBe(true);
333
334 const prepare = vi.fn(async () => preparedTranscription());
335 const next = createHarness({ getTranscriber: () => ({ prepare }) });
336 const nextStart = next.controller.start();
337 expect(next.controller.currentState).toEqual({
338 phase: "preparing",
339 error: null,
340 errorAction: null,
341 });
342 expect(prepare).not.toHaveBeenCalled();
343 expect(next.recorder.record).not.toHaveBeenCalled();
344
345 transcription.resolve("late text");
346 await audioReleaseEntered.promise;
347 expect(prepare).not.toHaveBeenCalled();
348 expect(next.recorder.prepareToRecordAsync).not.toHaveBeenCalled();
349 audioRelease.resolve(undefined);
350 await stopping;
351 await nextStart;
352
353 expect(harness.commits).toEqual([]);
354 expect(harness.deleted).toEqual(["file:///voice.m4a"]);
355 expect(harness.controller.currentState.phase).toBe("idle");
356
357 expect(next.controller.currentState.phase).toBe("recording");
358 expect(prepare).toHaveBeenCalledTimes(1);
359 await next.controller.interruptRecording();
360 },
361 );
362
363 it("cancels an in-flight transcriber that rejects when its signal aborts", async () => {
364 const transcription = deferred<string>();
365 const transcriptionEntered = deferred<AbortSignal>();
366 const harness = createHarness({
367 getTranscriber: () => ({
368 prepare: async () =>
369 preparedTranscription((_uri, { signal }) => {
370 signal.addEventListener("abort", () => transcription.reject(new Error("aborted")), {
371 once: true,
372 });
373 transcriptionEntered.resolve(signal);
374 return transcription.promise;
375 }),
376 }),
377 });
378 await harness.controller.start();
379 const stopping = harness.controller.stop();
380 const signal = await transcriptionEntered.promise;
381 harness.controller.cancel();
382 await stopping;
383
384 expect(signal.aborted).toBe(true);
385 expect(harness.commits).toEqual([]);
386 expect(harness.deleted).toEqual(["file:///voice.m4a"]);
387 expect(harness.controller.currentState.phase).toBe("idle");
388 });
389
390 it("releases the microphone before transcription starts", async () => {
391 const events: string[] = [];
392 const harness = createHarness({
393 releaseRecording: async () => {
394 events.push("released");
395 },
396 getTranscriber: () => ({
397 prepare: async () =>
398 preparedTranscription(async () => {
399 events.push("transcribed");
400 return "done";
401 }),
402 }),
403 });
404 await harness.controller.start();
405 await harness.controller.stop();
406
407 expect(events).toEqual(["released", "transcribed"]);
408 });
409
410 it("retries audio-session release during final cleanup", async () => {
411 const releaseRecording = vi
412 .fn<() => Promise<void>>()
413 .mockRejectedValueOnce(new Error("busy"))
414 .mockResolvedValueOnce(undefined);
415 const harness = createHarness({ releaseRecording });
416 await harness.controller.start();
417 await harness.controller.stop();
418
419 expect(releaseRecording).toHaveBeenCalledTimes(2);
420 });
421
422 it("leaves transcription with an error when recorder finalization fails", async () => {
423 const harness = createHarness();
424 harness.recorder.stop.mockRejectedValueOnce(new Error("stop failed"));
425 await harness.controller.start();
426 await harness.controller.stop();
427
428 expect(harness.controller.currentState.phase).toBe("error");
429 expect(harness.controller.currentState.error).toContain("finish voice recording");
430 });
431
432 it("ignores a late transcript after the draft owner changes", async () => {
433 const transcription = deferred<string>();
434 const transcriptionEntered = deferred<void>();
435 const harness = createHarness({
436 getTranscriber: () => ({
437 prepare: async () =>
438 preparedTranscription(() => {
439 transcriptionEntered.resolve(undefined);
440 return transcription.promise;
441 }),
442 }),
443 });
444 await harness.controller.start();
445 const stopping = harness.controller.stop();
446 await transcriptionEntered.promise;
447 harness.setDraft(draft({ ownerKey: "environment:other-thread" }));
448 transcription.resolve("late text");
449 await stopping;
450
451 expect(harness.commits).toEqual([]);
452 expect(harness.controller.currentState.error).toContain("draft changed");
453 });
454
455 it("queues the next recording until canceled preparation settles", async () => {
456 const preparation = deferred<PreparedVoiceTranscription>();
457 const preparationEntered = deferred<AbortSignal>();
458 const first = createHarness({
459 getTranscriber: () => ({
460 prepare: ({ signal }) => {
461 preparationEntered.resolve(signal);
462 return preparation.promise;
463 },
464 }),
465 });
466 const firstStart = first.controller.start();
467 const signal = await preparationEntered.promise;
468 first.controller.cancel();
469 expect(signal.aborted).toBe(true);
470
471 const next = createHarness();
472 const nextStart = next.controller.start();
473 expect(next.controller.currentState.phase).toBe("preparing");
474 expect(next.recorder.prepareToRecordAsync).not.toHaveBeenCalled();
475
476 preparation.resolve(preparedTranscription());
477 await firstStart;
478 expect(first.recorder.record).not.toHaveBeenCalled();
479 await nextStart;
480 expect(next.controller.currentState.phase).toBe("recording");
481 await next.controller.interruptRecording();
482 });
483
484 it("still rejects a second start while the first recording is active", async () => {
485 const first = createHarness();
486 await first.controller.start();
487 const next = createHarness();
488 await next.controller.start();
489 expect(next.controller.currentState.error).toContain("already active");
490 expect(next.recorder.record).not.toHaveBeenCalled();
491 await first.controller.interruptRecording();
492 });
493
494 it("does not start a canceled waiter or let it release another recording", async () => {
495 const preparation = deferred<PreparedVoiceTranscription>();
496 const preparationEntered = deferred<void>();
497 const first = createHarness({
498 getTranscriber: () => ({
499 prepare: () => {
500 preparationEntered.resolve(undefined);
501 return preparation.promise;
502 },
503 }),
504 });
505 const firstStart = first.controller.start();
506 await preparationEntered.promise;
507 first.controller.dispose();
508
509 const next = createHarness();
510 const canceledStart = next.controller.start();
511 next.controller.cancel();
512 const nextStart = next.controller.start();
513 preparation.resolve(preparedTranscription());
514 await Promise.all([firstStart, canceledStart, nextStart]);
515
516 expect(next.recorder.record).toHaveBeenCalledTimes(1);
517 expect(next.deleted).toEqual([]);
518 const blocked = createHarness();
519 await blocked.controller.start();
520 expect(blocked.controller.currentState.error).toContain("already active");
521 await next.controller.interruptRecording();
522 });
523
524 it("releases an abandoned recording even if its native recorder was already disposed", async () => {
525 const harness = createHarness();
526 await harness.controller.start();
527 harness.recorder.stop.mockRejectedValueOnce(new Error("recorder released"));
528 Object.defineProperty(harness.recorder, "uri", {
529 get() {
530 throw new Error("recorder released");
531 },
532 });
533
534 await expect(harness.controller.interruptRecording()).resolves.toBeUndefined();
535 expect(harness.deleted).toEqual(["file:///voice.m4a"]);
536 const next = createHarness();
537 await next.controller.start();
538 expect(next.controller.currentState.phase).toBe("recording");
539 await next.controller.interruptRecording();
540 });
541
542 it("keeps the new abort controller when restarting the same composer after cancellation", async () => {
543 const transcription = deferred<string>();
544 const transcriptionEntered = deferred<void>();
545 const prepare = vi
546 .fn<VoiceTranscriber["prepare"]>()
547 .mockResolvedValueOnce(
548 preparedTranscription(() => {
549 transcriptionEntered.resolve(undefined);
550 return transcription.promise;
551 }),
552 )
553 .mockResolvedValue(preparedTranscription());
554 const harness = createHarness({ getTranscriber: () => ({ prepare }) });
555 await harness.controller.start();
556 const stopping = harness.controller.stop();
557 await transcriptionEntered.promise;
558 harness.controller.cancel();
559 const restarting = harness.controller.start();
560 transcription.resolve("late text");
561 await Promise.all([stopping, restarting]);
562 await harness.controller.stop();
563
564 expect(harness.commits).toEqual([
565 { text: "hello new text", selection: { start: 14, end: 14 } },
566 ]);
567 expect(harness.controller.currentState.phase).toBe("idle");
568 });
569
570 it("does not start the microphone for an owner that changed during preparation", async () => {
571 const preparation = deferred<PreparedVoiceTranscription>();
572 const preparationEntered = deferred<void>();
573 const harness = createHarness({
574 getTranscriber: () => ({
575 prepare: () => {
576 preparationEntered.resolve(undefined);
577 return preparation.promise;
578 },
579 }),
580 });
581 const starting = harness.controller.start();
582 await preparationEntered.promise;
583 harness.setDraft(draft({ ownerKey: "environment:other-thread", text: "other draft" }));
584 preparation.resolve(preparedTranscription());
585 await starting;
586
587 expect(harness.recorder.record).not.toHaveBeenCalled();
588 expect(harness.controller.currentState.error).toContain("no longer available");
589 });
590
591 it("discards recorder errors and audio interruptions without transcribing", async () => {
592 const transcribe = vi.fn(async () => "ignored");
593 const preparationEntered = deferred<AbortSignal>();
594 const harness = createHarness({
595 getTranscriber: () => ({
596 prepare: async ({ signal }) => {
597 preparationEntered.resolve(signal);
598 return preparedTranscription(transcribe);
599 },
600 }),
601 });
602 await harness.controller.start();
603 const signal = await preparationEntered.promise;
604 harness.recorder.uri = "file:///reset-empty.m4a";
605 await harness.controller.handleRecorderStatus({
606 isFinished: true,
607 hasError: true,
608 error: "Audio route changed",
609 url: "file:///voice.m4a",
610 });
611
612 expect(harness.commits).toEqual([]);
613 expect(transcribe).not.toHaveBeenCalled();
614 expect(signal.aborted).toBe(true);
615 expect(harness.controller.currentState.error).toBe("Audio route changed");
616 expect(harness.deleted).toEqual(["file:///voice.m4a", "file:///reset-empty.m4a"]);
617 });
618
619 it("cancels preparation when the app reaches the background", async () => {
620 const preparation = deferred<PreparedVoiceTranscription>();
621 const preparationEntered = deferred<AbortSignal>();
622 const harness = createHarness({
623 getTranscriber: () => ({
624 prepare: ({ signal }) => {
625 preparationEntered.resolve(signal);
626 return preparation.promise;
627 },
628 }),
629 });
630 const starting = harness.controller.start();
631 const signal = await preparationEntered.promise;
632 harness.controller.appMovedToBackground();
633 expect(signal.aborted).toBe(true);
634 preparation.resolve(preparedTranscription());
635 await starting;
636
637 expect(harness.recorder.record).not.toHaveBeenCalled();
638 expect(harness.controller.currentState.error).toContain("background");
639 });
640});