1import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
2import type { PreparedVoiceTranscription } from "@t3tools/client-runtime/voice-input";
3import { Atom, AtomRegistry } from "effect/reactivity";
4import { resetVoiceInputGlobalsForTests } from "../../../../../packages/client-runtime/src/voice-input/controller";
5
6import {
7 createVoiceInputTarget,
8 VoiceInputSession,
9 type VoiceInputTarget,
10} from "./voiceInputSession";
11
12function createTarget(
13 ownerKey: string,
14 readText: () => string | null,
15 commit: VoiceInputTarget["commitDraft"],
16 selection: { start: number; end: number },
17) {
18 return createVoiceInputTarget(ownerKey, readText, commit, selection, () => () => {});
19}
20
21function createSession() {
22 const recorder = {
23 uri: "file:///voice.m4a",
24 prepareToRecordAsync: vi.fn(async () => {}),
25 record: vi.fn(),
26 stop: vi.fn(async () => {}),
27 };
28 const prepare = vi.fn(async (): Promise<PreparedVoiceTranscription> => ({
29 locale: "en-US",
30 transcribe: async () => "spoken text",
31 }));
32 const session = new VoiceInputSession({
33 recorder,
34 getTranscriber: () => ({ prepare }),
35 requestPermission: async () => ({ granted: true, canAskAgain: true }),
36 configureRecording: async () => {},
37 releaseRecording: vi.fn(async () => {}),
38 deleteRecording: vi.fn(),
39 onStateChange: vi.fn(),
40 });
41 return { session, recorder, prepare };
42}
43
44describe("global voice input", () => {
45 beforeEach(() => resetVoiceInputGlobalsForTests());
46
47 it.each([
48 { selection: { start: 6, end: 11 }, expected: "hello spoken text", cursor: 17 },
49 { selection: { start: 6, end: 6 }, expected: "hello spoken text world", cursor: 18 },
50 ])(
51 "keeps the starting selection $selection after navigation",
52 async ({ selection, expected, cursor }) => {
53 const { session } = createSession();
54 const commit = vi.fn();
55 await session.start(createTarget("first", () => "hello world", commit, selection));
56 await session.start(
57 createTarget("second", () => "other prompt", vi.fn(), { start: 12, end: 12 }),
58 );
59 await session.controller.stop();
60 expect(commit).toHaveBeenCalledWith(expected, {
61 start: cursor,
62 end: cursor,
63 });
64 },
65 );
66
67 it("appends to the starting draft after its screen leaves and another draft opens", async () => {
68 const { session, recorder } = createSession();
69 const drafts = new Map([
70 ["first", "original prompt"],
71 ["second", "other prompt"],
72 ]);
73 let visibleDraft = "first";
74 const targetKey = visibleDraft;
75 await session.start(
76 createTarget(
77 targetKey,
78 () => drafts.get(targetKey) ?? null,
79 (text) => drafts.set(targetKey, text),
80 { start: 15, end: 15 },
81 ),
82 );
83 visibleDraft = "second";
84 expect(session.controller.currentState.phase).toBe("recording");
85 expect(recorder.stop).not.toHaveBeenCalled();
86 await session.controller.stop();
87
88 expect(drafts.get("first")).toBe("original prompt spoken text");
89 expect(drafts.get(visibleDraft)).toBe("other prompt");
90 expect(session.controller.currentState.phase).toBe("idle");
91 });
92
93 it.each(["preparing", "recording", "transcribing"] as const)(
94 "keeps one recorder and its original target during %s",
95 async (phase) => {
96 const preparation = Promise.withResolvers<PreparedVoiceTranscription>();
97 const preparationEntered = Promise.withResolvers<void>();
98 const transcription = Promise.withResolvers<string>();
99 const transcriptionEntered = Promise.withResolvers<void>();
100 const { session, recorder, prepare } = createSession();
101 prepare.mockImplementationOnce(() => {
102 preparationEntered.resolve();
103 return preparation.promise;
104 });
105 const firstCommit = vi.fn();
106 const secondCommit = vi.fn();
107 const starting = session.start(
108 createTarget("first", () => "first", firstCommit, { start: 5, end: 5 }),
109 );
110 await preparationEntered.promise;
111 let stopping: Promise<void> | null = null;
112 if (phase !== "preparing") {
113 preparation.resolve({
114 locale: "en-US",
115 transcribe: () => {
116 transcriptionEntered.resolve();
117 return transcription.promise;
118 },
119 });
120 await starting;
121 }
122 if (phase === "transcribing") {
123 stopping = session.controller.stop();
124 await transcriptionEntered.promise;
125 }
126 await session.start(
127 createTarget("second", () => "second", secondCommit, { start: 6, end: 6 }),
128 );
129 expect(session.ownerKey).toBe("first");
130 expect(session.controller.currentState.phase).toBe(phase);
131 expect(prepare).toHaveBeenCalledTimes(1);
132 preparation.resolve({ locale: "en-US", transcribe: async () => "spoken text" });
133 await starting;
134 transcription.resolve("spoken text");
135 await (stopping ?? session.controller.stop());
136 expect(recorder.record).toHaveBeenCalledTimes(1);
137 expect(firstCommit).toHaveBeenCalledWith("first spoken text", { start: 17, end: 17 });
138 expect(secondCommit).not.toHaveBeenCalled();
139 },
140 );
141
142 it.each(["changed", "removed"] as const)(
143 "does not overwrite a %s starting draft",
144 async (change) => {
145 const { session } = createSession();
146 let text: string | null = "first";
147 const commit = vi.fn();
148 await session.start(createTarget("first", () => text, commit, { start: 5, end: 5 }));
149 text = change === "removed" ? null : "edited prompt";
150 await session.controller.stop();
151 expect(commit).not.toHaveBeenCalled();
152 expect(session.controller.currentState.error).toContain("draft changed");
153 },
154 );
155
156 it("finishes the original draft at the recording limit while it is off screen", async () => {
157 const { session, recorder } = createSession();
158 const commit = vi.fn();
159 await session.start(createTarget("first", () => "first", commit, { start: 5, end: 5 }));
160 await session.controller.handleRecorderStatus({
161 isFinished: true,
162 hasError: false,
163 error: null,
164 url: recorder.uri,
165 });
166 expect(commit).toHaveBeenCalledWith("first spoken text", { start: 17, end: 17 });
167 expect(session.controller.currentState.phase).toBe("idle");
168 });
169
170 it("rejects a transcript when its off-screen draft changes and returns to the original text", async () => {
171 const registry = AtomRegistry.make();
172 const draft = Atom.make("hello world");
173 const unsubscribe = vi.fn();
174 const { session } = createSession();
175 const commit = vi.fn();
176 const target = createVoiceInputTarget(
177 "first",
178 () => registry.get(draft),
179 commit,
180 { start: 6, end: 6 },
181 (onChange) => {
182 const stop = registry.subscribe(draft, onChange);
183 return () => {
184 stop();
185 unsubscribe();
186 };
187 },
188 );
189 await session.start(target);
190 registry.set(draft, "changed");
191 registry.set(draft, "hello world");
192 await session.controller.stop();
193 expect(commit).not.toHaveBeenCalled();
194 expect(session.controller.currentState.error).toContain("draft changed");
195 expect(unsubscribe).toHaveBeenCalledTimes(1);
196 registry.dispose();
197 });
198
199 it("stops recording when its queued edit is discarded", async () => {
200 const { session, recorder } = createSession();
201 const commit = vi.fn();
202 let draft: string | null = "queued prompt";
203 const ownerKey = "thread~queued-edit~run";
204 await session.start(createTarget(ownerKey, () => draft, commit, { start: 13, end: 13 }));
205 session.cancel(ownerKey);
206 draft = null;
207 await session.controller.stop();
208 expect(recorder.stop).toHaveBeenCalledTimes(1);
209 expect(session.controller.currentState).toEqual({
210 phase: "idle",
211 error: null,
212 errorAction: null,
213 });
214 expect(commit).not.toHaveBeenCalled();
215 });
216
217 it.each(["complete", "cancel"] as const)(
218 "releases draft observation after %s",
219 async (finish) => {
220 const { session } = createSession();
221 const unsubscribe = vi.fn();
222 const subscribe = vi.fn(() => unsubscribe);
223 await session.start(
224 createVoiceInputTarget("first", () => "first", vi.fn(), { start: 5, end: 5 }, subscribe),
225 );
226 expect(subscribe).toHaveBeenCalledTimes(1);
227 if (finish === "complete") await session.controller.stop();
228 else session.cancel("first");
229 expect(unsubscribe).toHaveBeenCalledTimes(1);
230 },
231 );
232
233 it("keeps another prompt's recording when a queued edit is discarded", async () => {
234 const { session, recorder } = createSession();
235 const commit = vi.fn();
236 await session.start(createTarget("other prompt", () => "hello", commit, { start: 5, end: 5 }));
237 session.cancel("thread~queued-edit~run");
238 expect(recorder.stop).not.toHaveBeenCalled();
239 expect(session.controller.currentState.phase).toBe("recording");
240 await session.controller.stop();
241 expect(commit).toHaveBeenCalledWith("hello spoken text", { start: 17, end: 17 });
242 });
243
244 it("waits for canceled native work before starting a recording for another draft", async () => {
245 const preparation = Promise.withResolvers<PreparedVoiceTranscription>();
246 const preparationEntered = Promise.withResolvers<void>();
247 const { session, recorder, prepare } = createSession();
248 prepare.mockImplementationOnce(() => {
249 preparationEntered.resolve();
250 return preparation.promise;
251 });
252 const oldCommit = vi.fn();
253 const nextCommit = vi.fn();
254 const firstStart = session.start(
255 createTarget("first", () => "first", oldCommit, { start: 5, end: 5 }),
256 );
257 await preparationEntered.promise;
258 session.cancel("first");
259 const nextStart = session.start(
260 createTarget("second", () => "second", nextCommit, { start: 6, end: 6 }),
261 );
262 expect(prepare).toHaveBeenCalledTimes(1);
263 expect(recorder.record).not.toHaveBeenCalled();
264 preparation.resolve({ locale: "en-US", transcribe: async () => "old transcript" });
265 await firstStart;
266 await nextStart;
267 await session.controller.stop();
268 expect(recorder.record).toHaveBeenCalledTimes(1);
269 expect(oldCommit).not.toHaveBeenCalled();
270 expect(nextCommit).toHaveBeenCalledWith("second spoken text", { start: 18, end: 18 });
271 });
272});