import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { resetVoiceInputGlobalsForTests, resolveTranscriptCommit, VoiceInputController, VOICE_RECORDING_LIMIT_SECONDS, voiceInputBlocksSubmission, type VoiceDraftSnapshot, type VoiceInputControllerDependencies, type VoiceRecorder,} from "./controller.ts";import type { PreparedVoiceTranscription, VoiceTranscriber } from "./transcription.ts"; function deferred<T>() { let resolve!: (value: T) => void; let reject!: (error: unknown) => void; const promise = new Promise<T>((resolvePromise, rejectPromise) => { resolve = resolvePromise; reject = rejectPromise; }); return { promise, resolve, reject };} class TestRecorder implements VoiceRecorder { uri: string | null = "file:///voice.m4a"; readonly prepareToRecordAsync = vi.fn(async () => undefined); readonly record = vi.fn(); readonly stop = vi.fn(async () => undefined);} function preparedTranscription( transcribe: PreparedVoiceTranscription["transcribe"] = async () => "new text",): PreparedVoiceTranscription { return { locale: "en-US", transcribe };} function draft(overrides: Partial<VoiceDraftSnapshot> = {}): VoiceDraftSnapshot { return { ownerKey: "environment:thread", text: "hello world", selection: { start: 6, end: 11 }, revision: 1, ...overrides, };} function createHarness( overrides: Partial<VoiceInputControllerDependencies> = {}, initialDraft = draft(),) { const recorder = new TestRecorder(); let currentDraft: VoiceDraftSnapshot | null = initialDraft; const commits: Array<{ text: string; selection: { start: number; end: number } }> = []; const deleted: string[] = []; const dependencies: VoiceInputControllerDependencies = { recorder, getTranscriber: () => ({ prepare: async () => preparedTranscription() }), requestPermission: async () => ({ granted: true, canAskAgain: true }), configureRecording: async () => undefined, releaseRecording: async () => undefined, deleteRecording: (uri) => deleted.push(uri), readDraft: () => currentDraft, commitDraft: (text, selection) => commits.push({ text, selection }), onStateChange: vi.fn(), ...overrides, }; return { controller: new VoiceInputController(dependencies), recorder, commits, deleted, setDraft: (next: VoiceDraftSnapshot | null) => { currentDraft = next; }, };} describe("resolveTranscriptCommit", () => { it("replaces the recorded UTF-16 selection around emoji and composer tokens", () => { const text = "Fix 🧪 then $review please"; const tokenStart = text.indexOf("$review"); const captured = draft({ text, selection: { start: tokenStart, end: tokenStart + "$review".length }, }); expect(resolveTranscriptCommit(captured, captured, "use the mobile skill", "en-US")).toEqual({ kind: "commit", text: "Fix 🧪 then use the mobile skill please", selection: { start: tokenStart + "use the mobile skill".length, end: tokenStart + 20 }, }); }); it("does not replace text after the owner, text, or revision changes", () => { const captured = draft(); expect( resolveTranscriptCommit(captured, draft({ ownerKey: "other" }), "text", "en-US"), ).toEqual({ kind: "stale", }); expect(resolveTranscriptCommit(captured, draft({ text: "newer" }), "text", "en-US")).toEqual({ kind: "stale", }); expect(resolveTranscriptCommit(captured, draft({ revision: 2 }), "text", "en-US")).toEqual({ kind: "stale", }); }); it("adds English spacing at empty start, middle, and end caret boundaries", () => { const atEnd = draft({ text: "Fix cache.", selection: { start: "Fix cache.".length, end: "Fix cache.".length }, }); expect(resolveTranscriptCommit(atEnd, atEnd, "Also fix tests.", "en-US")).toMatchObject({ kind: "commit", text: "Fix cache. Also fix tests.", }); expect(resolveTranscriptCommit(atEnd, atEnd, "Also fix tests.", "en_US")).toMatchObject({ kind: "commit", text: "Fix cache. Also fix tests.", }); const atStart = draft({ text: "Fix cache.", selection: { start: 0, end: 0 } }); expect(resolveTranscriptCommit(atStart, atStart, "First", "en-US")).toMatchObject({ kind: "commit", text: "First Fix cache.", }); const inMiddle = draft({ text: "Fix cache.", selection: { start: 4, end: 4 } }); expect(resolveTranscriptCommit(inMiddle, inMiddle, "also", "en-US")).toMatchObject({ kind: "commit", text: "Fix also cache.", }); }); it("does not add English boundary spaces to CJK or selected inline text", () => { const cjk = draft({ text: "修正キャッシュ", selection: { start: 8, end: 8 } }); expect(resolveTranscriptCommit(cjk, cjk, "テストも", "ja-JP")).toMatchObject({ kind: "commit", text: "修正キャッシュテストも", }); const selected = draft({ text: "one $skill two", selection: { start: 4, end: 10 } }); expect(resolveTranscriptCommit(selected, selected, "new", "en-US")).toMatchObject({ kind: "commit", text: "one new two", }); });}); describe("VoiceInputController", () => { beforeEach(() => resetVoiceInputGlobalsForTests()); it("checks support and permission before recording", async () => { const unsupported = createHarness({ getTranscriber: () => null }); await unsupported.controller.start(); expect(unsupported.controller.currentState.error).toContain("not available"); expect(unsupported.recorder.record).not.toHaveBeenCalled(); const denied = createHarness({ requestPermission: async () => ({ granted: false, canAskAgain: false }), }); await denied.controller.start(); expect(denied.controller.currentState.errorAction).toBe("settings"); expect(denied.recorder.record).not.toHaveBeenCalled(); }); it.each(["permission", "transcription"] as const)( "clears %s errors when switching to another draft", async (failure) => { const harness = createHarness( failure === "permission" ? { requestPermission: async () => ({ granted: false, canAskAgain: false }) } : { getTranscriber: () => ({ prepare: async () => preparedTranscription(async () => { throw new Error("Transcription failed"); }), }), }, ); await harness.controller.start(); await harness.controller.stop(); expect(harness.controller.currentState).toMatchObject({ phase: "error", error: expect.any(String), errorAction: failure === "permission" ? "settings" : "retry", }); harness.setDraft(draft({ ownerKey: "environment:other-thread" })); harness.controller.ownerChanged(); expect(harness.controller.currentState).toEqual({ phase: "idle", error: null, errorAction: null, }); expect(harness.commits).toEqual([]); }, ); it.each(["permission", "preparation", "recording"] as const)( "keeps the selected transcriber when preferences change during %s", async (changeDuring) => { const permission = deferred<{ granted: boolean; canAskAgain: boolean }>(); const permissionEntered = deferred<void>(); const preparation = deferred<void>(); const preparationEntered = deferred<void>(); const preparationSignals: AbortSignal[] = []; const transcriptionSignals: AbortSignal[] = []; const transcriber = (text: string): VoiceTranscriber => ({ prepare: async ({ signal }) => { preparationSignals.push(signal); preparationEntered.resolve(undefined); await preparation.promise; return preparedTranscription(async (_uri, { signal }) => { transcriptionSignals.push(signal); return text; }); }, }); const first = transcriber("first choice"); const second = transcriber("second choice"); let selected = first; const harness = createHarness({ getTranscriber: () => selected, requestPermission: () => { permissionEntered.resolve(undefined); return permission.promise; }, }); const starting = harness.controller.start(); await permissionEntered.promise; if (changeDuring === "permission") selected = second; permission.resolve({ granted: true, canAskAgain: true }); await preparationEntered.promise; if (changeDuring === "preparation") selected = second; preparation.resolve(undefined); await starting; if (changeDuring === "recording") selected = second; await harness.controller.stop(); expect(harness.commits.map((commit) => commit.text)).toEqual(["hello first choice"]); await harness.controller.start(); await harness.controller.stop(); expect(harness.commits.map((commit) => commit.text)).toEqual([ "hello first choice", "hello second choice", ]); expect(preparationSignals).toHaveLength(2); expect(transcriptionSignals).toHaveLength(2); expect(transcriptionSignals[0]).toBe(preparationSignals[0]); expect(transcriptionSignals[1]).toBe(preparationSignals[1]); expect(preparationSignals[1]).not.toBe(preparationSignals[0]); }, ); it("blocks submit while voice input can still change the draft", () => { expect(voiceInputBlocksSubmission({ phase: "preparing", error: null, errorAction: null })).toBe( true, ); expect(voiceInputBlocksSubmission({ phase: "recording", error: null, errorAction: null })).toBe( true, ); expect( voiceInputBlocksSubmission({ phase: "transcribing", error: null, errorAction: null }), ).toBe(true); expect(voiceInputBlocksSubmission({ phase: "idle", error: null, errorAction: null })).toBe( false, ); }); it("uses the native five-minute cap and commits one final transcript", async () => { const harness = createHarness(); await harness.controller.start(); expect(harness.recorder.record).toHaveBeenCalledWith({ forDuration: VOICE_RECORDING_LIMIT_SECONDS, }); const stopping = harness.controller.stop(); harness.controller.handleRecorderStatus({ isFinished: true, hasError: false, error: null, url: "file:///voice.m4a", }); await stopping; expect(harness.commits).toEqual([ { text: "hello new text", selection: { start: 14, end: 14 } }, ]); expect(harness.deleted).toEqual(["file:///voice.m4a"]); }); it.each(["cancel", "dispose", "ownerChanged"] as const)( "holds the session after %s until non-abortable transcription settles", async (action) => { const transcription = deferred<string>(); const transcriptionEntered = deferred<AbortSignal>(); const harness = createHarness({ getTranscriber: () => ({ prepare: async () => preparedTranscription((_uri, { signal }) => { transcriptionEntered.resolve(signal); return transcription.promise; }), }), }); await harness.controller.start(); const stopping = harness.controller.stop(); const signal = await transcriptionEntered.promise; expect(signal.aborted).toBe(false); if (action === "ownerChanged") { harness.setDraft(draft({ ownerKey: "environment:other-thread" })); } harness.controller[action](); expect(signal.aborted).toBe(true); const next = createHarness(); await next.controller.start(); expect(next.controller.currentState.error).toContain("already active"); expect(next.recorder.record).not.toHaveBeenCalled(); transcription.resolve("late text"); await stopping; expect(harness.commits).toEqual([]); expect(harness.deleted).toEqual(["file:///voice.m4a"]); expect(harness.controller.currentState.phase).toBe("idle"); await next.controller.start(); expect(next.controller.currentState.phase).toBe("recording"); await next.controller.interruptRecording(); }, ); it("cancels an in-flight transcriber that rejects when its signal aborts", async () => { const transcription = deferred<string>(); const transcriptionEntered = deferred<AbortSignal>(); const harness = createHarness({ getTranscriber: () => ({ prepare: async () => preparedTranscription((_uri, { signal }) => { signal.addEventListener("abort", () => transcription.reject(new Error("aborted")), { once: true, }); transcriptionEntered.resolve(signal); return transcription.promise; }), }), }); await harness.controller.start(); const stopping = harness.controller.stop(); const signal = await transcriptionEntered.promise; harness.controller.cancel(); await stopping; expect(signal.aborted).toBe(true); expect(harness.commits).toEqual([]); expect(harness.deleted).toEqual(["file:///voice.m4a"]); expect(harness.controller.currentState.phase).toBe("idle"); }); it("releases the microphone before transcription starts", async () => { const events: string[] = []; const harness = createHarness({ releaseRecording: async () => { events.push("released"); }, getTranscriber: () => ({ prepare: async () => preparedTranscription(async () => { events.push("transcribed"); return "done"; }), }), }); await harness.controller.start(); await harness.controller.stop(); expect(events).toEqual(["released", "transcribed"]); }); it("retries audio-session release during final cleanup", async () => { const releaseRecording = vi .fn<() => Promise<void>>() .mockRejectedValueOnce(new Error("busy")) .mockResolvedValueOnce(undefined); const harness = createHarness({ releaseRecording }); await harness.controller.start(); await harness.controller.stop(); expect(releaseRecording).toHaveBeenCalledTimes(2); }); it("leaves transcription with an error when recorder finalization fails", async () => { const harness = createHarness(); harness.recorder.stop.mockRejectedValueOnce(new Error("stop failed")); await harness.controller.start(); await harness.controller.stop(); expect(harness.controller.currentState.phase).toBe("error"); expect(harness.controller.currentState.error).toContain("finish voice recording"); }); it("ignores a late transcript after the draft owner changes", async () => { const transcription = deferred<string>(); const transcriptionEntered = deferred<void>(); const harness = createHarness({ getTranscriber: () => ({ prepare: async () => preparedTranscription(() => { transcriptionEntered.resolve(undefined); return transcription.promise; }), }), }); await harness.controller.start(); const stopping = harness.controller.stop(); await transcriptionEntered.promise; harness.setDraft(draft({ ownerKey: "environment:other-thread" })); transcription.resolve("late text"); await stopping; expect(harness.commits).toEqual([]); expect(harness.controller.currentState.error).toContain("draft changed"); }); it("keeps the app-wide session locked until canceled preparation settles", async () => { const preparation = deferred<PreparedVoiceTranscription>(); const preparationEntered = deferred<AbortSignal>(); const first = createHarness({ getTranscriber: () => ({ prepare: ({ signal }) => { preparationEntered.resolve(signal); return preparation.promise; }, }), }); const firstStart = first.controller.start(); const signal = await preparationEntered.promise; first.controller.cancel(); expect(signal.aborted).toBe(true); const blocked = createHarness(); await blocked.controller.start(); expect(blocked.controller.currentState.error).toContain("already active"); preparation.resolve(preparedTranscription()); await firstStart; expect(first.recorder.record).not.toHaveBeenCalled(); blocked.controller.cancel(); const next = createHarness(); await next.controller.start(); expect(next.controller.currentState.phase).toBe("recording"); await next.controller.interruptRecording(); }); it("does not start the microphone for an owner that changed during preparation", async () => { const preparation = deferred<PreparedVoiceTranscription>(); const preparationEntered = deferred<void>(); const harness = createHarness({ getTranscriber: () => ({ prepare: () => { preparationEntered.resolve(undefined); return preparation.promise; }, }), }); const starting = harness.controller.start(); await preparationEntered.promise; harness.setDraft(draft({ ownerKey: "environment:other-thread", text: "other draft" })); preparation.resolve(preparedTranscription()); await starting; expect(harness.recorder.record).not.toHaveBeenCalled(); expect(harness.controller.currentState.error).toContain("no longer available"); }); it("discards recorder errors and audio interruptions without transcribing", async () => { const transcribe = vi.fn(async () => "ignored"); const preparationEntered = deferred<AbortSignal>(); const harness = createHarness({ getTranscriber: () => ({ prepare: async ({ signal }) => { preparationEntered.resolve(signal); return preparedTranscription(transcribe); }, }), }); await harness.controller.start(); const signal = await preparationEntered.promise; harness.recorder.uri = "file:///reset-empty.m4a"; await harness.controller.handleRecorderStatus({Lines 1–500 of 535. Load more
import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { resetVoiceInputGlobalsForTests, resolveTranscriptCommit, VoiceInputController, VOICE_RECORDING_LIMIT_SECONDS, voiceInputBlocksSubmission, type VoiceDraftSnapshot, type VoiceInputControllerDependencies, type VoiceRecorder, } from "./controller.ts"; import type { PreparedVoiceTranscription, VoiceTranscriber } from "./transcription.ts"; function deferred<T>() { let resolve!: (value: T) => void; let reject!: (error: unknown) => void; const promise = new Promise<T>((resolvePromise, rejectPromise) => { resolve = resolvePromise; reject = rejectPromise; }); return { promise, resolve, reject }; } class TestRecorder implements VoiceRecorder { uri: string | null = "file:///voice.m4a"; readonly prepareToRecordAsync = vi.fn(async () => undefined); readonly record = vi.fn(); readonly stop = vi.fn(async () => undefined); } function preparedTranscription( transcribe: PreparedVoiceTranscription["transcribe"] = async () => "new text", ): PreparedVoiceTranscription { return { locale: "en-US", transcribe }; } function draft(overrides: Partial<VoiceDraftSnapshot> = {}): VoiceDraftSnapshot { return { ownerKey: "environment:thread", text: "hello world", selection: { start: 6, end: 11 }, revision: 1, ...overrides, }; } function createHarness( overrides: Partial<VoiceInputControllerDependencies> = {}, initialDraft = draft(), ) { const recorder = new TestRecorder(); let currentDraft: VoiceDraftSnapshot | null = initialDraft; const commits: Array<{ text: string; selection: { start: number; end: number } }> = []; const deleted: string[] = []; const dependencies: VoiceInputControllerDependencies = { recorder, getTranscriber: () => ({ prepare: async () => preparedTranscription() }), requestPermission: async () => ({ granted: true, canAskAgain: true }), configureRecording: async () => undefined, releaseRecording: async () => undefined, deleteRecording: (uri) => deleted.push(uri), readDraft: () => currentDraft, commitDraft: (text, selection) => commits.push({ text, selection }), onStateChange: vi.fn(), ...overrides, }; return { controller: new VoiceInputController(dependencies), recorder, commits, deleted, setDraft: (next: VoiceDraftSnapshot | null) => { currentDraft = next; }, }; } describe("resolveTranscriptCommit", () => { it("replaces the recorded UTF-16 selection around emoji and composer tokens", () => { const text = "Fix 🧪 then $review please"; const tokenStart = text.indexOf("$review"); const captured = draft({ text, selection: { start: tokenStart, end: tokenStart + "$review".length }, }); expect(resolveTranscriptCommit(captured, captured, "use the mobile skill", "en-US")).toEqual({ kind: "commit", text: "Fix 🧪 then use the mobile skill please", selection: { start: tokenStart + "use the mobile skill".length, end: tokenStart + 20 }, }); }); it("does not replace text after the owner, text, or revision changes", () => { const captured = draft(); expect( resolveTranscriptCommit(captured, draft({ ownerKey: "other" }), "text", "en-US"), ).toEqual({ kind: "stale", }); expect(resolveTranscriptCommit(captured, draft({ text: "newer" }), "text", "en-US")).toEqual({ kind: "stale", }); expect(resolveTranscriptCommit(captured, draft({ revision: 2 }), "text", "en-US")).toEqual({ kind: "stale", }); }); it("adds English spacing at empty start, middle, and end caret boundaries", () => { const atEnd = draft({ text: "Fix cache.", selection: { start: "Fix cache.".length, end: "Fix cache.".length }, }); expect(resolveTranscriptCommit(atEnd, atEnd, "Also fix tests.", "en-US")).toMatchObject({ kind: "commit", text: "Fix cache. Also fix tests.", }); expect(resolveTranscriptCommit(atEnd, atEnd, "Also fix tests.", "en_US")).toMatchObject({ kind: "commit", text: "Fix cache. Also fix tests.", }); const atStart = draft({ text: "Fix cache.", selection: { start: 0, end: 0 } }); expect(resolveTranscriptCommit(atStart, atStart, "First", "en-US")).toMatchObject({ kind: "commit", text: "First Fix cache.", }); const inMiddle = draft({ text: "Fix cache.", selection: { start: 4, end: 4 } }); expect(resolveTranscriptCommit(inMiddle, inMiddle, "also", "en-US")).toMatchObject({ kind: "commit", text: "Fix also cache.", }); }); it("does not add English boundary spaces to CJK or selected inline text", () => { const cjk = draft({ text: "修正キャッシュ", selection: { start: 8, end: 8 } }); expect(resolveTranscriptCommit(cjk, cjk, "テストも", "ja-JP")).toMatchObject({ kind: "commit", text: "修正キャッシュテストも", }); const selected = draft({ text: "one $skill two", selection: { start: 4, end: 10 } }); expect(resolveTranscriptCommit(selected, selected, "new", "en-US")).toMatchObject({ kind: "commit", text: "one new two", }); }); }); describe("VoiceInputController", () => { beforeEach(() => resetVoiceInputGlobalsForTests()); it("checks support and permission before recording", async () => { const unsupported = createHarness({ getTranscriber: () => null }); await unsupported.controller.start(); expect(unsupported.controller.currentState.error).toContain("not available"); expect(unsupported.recorder.record).not.toHaveBeenCalled(); const denied = createHarness({ requestPermission: async () => ({ granted: false, canAskAgain: false }), }); await denied.controller.start(); expect(denied.controller.currentState.errorAction).toBe("settings"); expect(denied.recorder.record).not.toHaveBeenCalled(); }); it.each(["permission", "transcription"] as const)( "clears %s errors when switching to another draft", async (failure) => { const harness = createHarness( failure === "permission" ? { requestPermission: async () => ({ granted: false, canAskAgain: false }) } : { getTranscriber: () => ({ prepare: async () => preparedTranscription(async () => { throw new Error("Transcription failed"); }), }), }, ); await harness.controller.start(); await harness.controller.stop(); expect(harness.controller.currentState).toMatchObject({ phase: "error", error: expect.any(String), errorAction: failure === "permission" ? "settings" : "retry", }); harness.setDraft(draft({ ownerKey: "environment:other-thread" })); harness.controller.ownerChanged(); expect(harness.controller.currentState).toEqual({ phase: "idle", error: null, errorAction: null, }); expect(harness.commits).toEqual([]); }, ); it.each(["permission", "preparation", "recording"] as const)( "keeps the selected transcriber when preferences change during %s", async (changeDuring) => { const permission = deferred<{ granted: boolean; canAskAgain: boolean }>(); const permissionEntered = deferred<void>(); const preparation = deferred<void>(); const preparationEntered = deferred<void>(); const preparationSignals: AbortSignal[] = []; const transcriptionSignals: AbortSignal[] = []; const transcriber = (text: string): VoiceTranscriber => ({ prepare: async ({ signal }) => { preparationSignals.push(signal); preparationEntered.resolve(undefined); await preparation.promise; return preparedTranscription(async (_uri, { signal }) => { transcriptionSignals.push(signal); return text; }); }, }); const first = transcriber("first choice"); const second = transcriber("second choice"); let selected = first; const harness = createHarness({ getTranscriber: () => selected, requestPermission: () => { permissionEntered.resolve(undefined); return permission.promise; }, }); const starting = harness.controller.start(); await permissionEntered.promise; if (changeDuring === "permission") selected = second; permission.resolve({ granted: true, canAskAgain: true }); await preparationEntered.promise; if (changeDuring === "preparation") selected = second; preparation.resolve(undefined); await starting; if (changeDuring === "recording") selected = second; await harness.controller.stop(); expect(harness.commits.map((commit) => commit.text)).toEqual(["hello first choice"]); await harness.controller.start(); await harness.controller.stop(); expect(harness.commits.map((commit) => commit.text)).toEqual([ "hello first choice", "hello second choice", ]); expect(preparationSignals).toHaveLength(2); expect(transcriptionSignals).toHaveLength(2); expect(transcriptionSignals[0]).toBe(preparationSignals[0]); expect(transcriptionSignals[1]).toBe(preparationSignals[1]); expect(preparationSignals[1]).not.toBe(preparationSignals[0]); }, ); it("blocks submit while voice input can still change the draft", () => { expect(voiceInputBlocksSubmission({ phase: "preparing", error: null, errorAction: null })).toBe( true, ); expect(voiceInputBlocksSubmission({ phase: "recording", error: null, errorAction: null })).toBe( true, ); expect( voiceInputBlocksSubmission({ phase: "transcribing", error: null, errorAction: null }), ).toBe(true); expect(voiceInputBlocksSubmission({ phase: "idle", error: null, errorAction: null })).toBe( false, ); }); it("uses the native five-minute cap and commits one final transcript", async () => { const harness = createHarness(); await harness.controller.start(); expect(harness.recorder.record).toHaveBeenCalledWith({ forDuration: VOICE_RECORDING_LIMIT_SECONDS, }); const stopping = harness.controller.stop(); harness.controller.handleRecorderStatus({ isFinished: true, hasError: false, error: null, url: "file:///voice.m4a", }); await stopping; expect(harness.commits).toEqual([ { text: "hello new text", selection: { start: 14, end: 14 } }, ]); expect(harness.deleted).toEqual(["file:///voice.m4a"]); }); it.each(["cancel", "dispose", "ownerChanged"] as const)( "holds the session after %s until non-abortable transcription settles", async (action) => { const transcription = deferred<string>(); const transcriptionEntered = deferred<AbortSignal>(); const harness = createHarness({ getTranscriber: () => ({ prepare: async () => preparedTranscription((_uri, { signal }) => { transcriptionEntered.resolve(signal); return transcription.promise; }), }), }); await harness.controller.start(); const stopping = harness.controller.stop(); const signal = await transcriptionEntered.promise; expect(signal.aborted).toBe(false); if (action === "ownerChanged") { harness.setDraft(draft({ ownerKey: "environment:other-thread" })); } harness.controller[action](); expect(signal.aborted).toBe(true); const next = createHarness(); await next.controller.start(); expect(next.controller.currentState.error).toContain("already active"); expect(next.recorder.record).not.toHaveBeenCalled(); transcription.resolve("late text"); await stopping; expect(harness.commits).toEqual([]); expect(harness.deleted).toEqual(["file:///voice.m4a"]); expect(harness.controller.currentState.phase).toBe("idle"); await next.controller.start(); expect(next.controller.currentState.phase).toBe("recording"); await next.controller.interruptRecording(); }, ); it("cancels an in-flight transcriber that rejects when its signal aborts", async () => { const transcription = deferred<string>(); const transcriptionEntered = deferred<AbortSignal>(); const harness = createHarness({ getTranscriber: () => ({ prepare: async () => preparedTranscription((_uri, { signal }) => { signal.addEventListener("abort", () => transcription.reject(new Error("aborted")), { once: true, }); transcriptionEntered.resolve(signal); return transcription.promise; }), }), }); await harness.controller.start(); const stopping = harness.controller.stop(); const signal = await transcriptionEntered.promise; harness.controller.cancel(); await stopping; expect(signal.aborted).toBe(true); expect(harness.commits).toEqual([]); expect(harness.deleted).toEqual(["file:///voice.m4a"]); expect(harness.controller.currentState.phase).toBe("idle"); }); it("releases the microphone before transcription starts", async () => { const events: string[] = []; const harness = createHarness({ releaseRecording: async () => { events.push("released"); }, getTranscriber: () => ({ prepare: async () => preparedTranscription(async () => { events.push("transcribed"); return "done"; }), }), }); await harness.controller.start(); await harness.controller.stop(); expect(events).toEqual(["released", "transcribed"]); }); it("retries audio-session release during final cleanup", async () => { const releaseRecording = vi .fn<() => Promise<void>>() .mockRejectedValueOnce(new Error("busy")) .mockResolvedValueOnce(undefined); const harness = createHarness({ releaseRecording }); await harness.controller.start(); await harness.controller.stop(); expect(releaseRecording).toHaveBeenCalledTimes(2); }); it("leaves transcription with an error when recorder finalization fails", async () => { const harness = createHarness(); harness.recorder.stop.mockRejectedValueOnce(new Error("stop failed")); await harness.controller.start(); await harness.controller.stop(); expect(harness.controller.currentState.phase).toBe("error"); expect(harness.controller.currentState.error).toContain("finish voice recording"); }); it("ignores a late transcript after the draft owner changes", async () => { const transcription = deferred<string>(); const transcriptionEntered = deferred<void>(); const harness = createHarness({ getTranscriber: () => ({ prepare: async () => preparedTranscription(() => { transcriptionEntered.resolve(undefined); return transcription.promise; }), }), }); await harness.controller.start(); const stopping = harness.controller.stop(); await transcriptionEntered.promise; harness.setDraft(draft({ ownerKey: "environment:other-thread" })); transcription.resolve("late text"); await stopping; expect(harness.commits).toEqual([]); expect(harness.controller.currentState.error).toContain("draft changed"); }); it("keeps the app-wide session locked until canceled preparation settles", async () => { const preparation = deferred<PreparedVoiceTranscription>(); const preparationEntered = deferred<AbortSignal>(); const first = createHarness({ getTranscriber: () => ({ prepare: ({ signal }) => { preparationEntered.resolve(signal); return preparation.promise; }, }), }); const firstStart = first.controller.start(); const signal = await preparationEntered.promise; first.controller.cancel(); expect(signal.aborted).toBe(true); const blocked = createHarness(); await blocked.controller.start(); expect(blocked.controller.currentState.error).toContain("already active"); preparation.resolve(preparedTranscription()); await firstStart; expect(first.recorder.record).not.toHaveBeenCalled(); blocked.controller.cancel(); const next = createHarness(); await next.controller.start(); expect(next.controller.currentState.phase).toBe("recording"); await next.controller.interruptRecording(); }); it("does not start the microphone for an owner that changed during preparation", async () => { const preparation = deferred<PreparedVoiceTranscription>(); const preparationEntered = deferred<void>(); const harness = createHarness({ getTranscriber: () => ({ prepare: () => { preparationEntered.resolve(undefined); return preparation.promise; }, }), }); const starting = harness.controller.start(); await preparationEntered.promise; harness.setDraft(draft({ ownerKey: "environment:other-thread", text: "other draft" })); preparation.resolve(preparedTranscription()); await starting; expect(harness.recorder.record).not.toHaveBeenCalled(); expect(harness.controller.currentState.error).toContain("no longer available"); }); it("discards recorder errors and audio interruptions without transcribing", async () => { const transcribe = vi.fn(async () => "ignored"); const preparationEntered = deferred<AbortSignal>(); const harness = createHarness({ getTranscriber: () => ({ prepare: async ({ signal }) => { preparationEntered.resolve(signal); return preparedTranscription(transcribe); }, }), }); await harness.controller.start(); const signal = await preparationEntered.promise; harness.recorder.uri = "file:///reset-empty.m4a"; await harness.controller.handleRecorderStatus({