| 1 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 2 | |
| 3 | import { |
| 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"; |
| 13 | import type { PreparedVoiceTranscription, VoiceTranscriber } from "./transcription.ts"; |
| 14 | |
| 15 | function 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 | |
| 25 | class 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 | |
| 32 | function preparedTranscription( |
| 33 | transcribe: PreparedVoiceTranscription["transcribe"] = async () => "new text", |
| 34 | ): PreparedVoiceTranscription { |
| 35 | return { locale: "en-US", transcribe }; |
| 36 | } |
| 37 | |
| 38 | function 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 | |
| 48 | function 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 | |
| 79 | describe("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 | |
| 152 | describe("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 | }); |