Open History to see the last commit
1import {
2 BearerConnectionTarget,
3 PrimaryConnectionTarget,
4 RelayConnectionTarget,
5 type PreparedConnection,
6} from "@t3tools/client-runtime/connection";
7import type * as Effect from "effect/Effect";
8import {
9 EnvironmentId,
10 type EnvironmentSpeechModel,
11 type EnvironmentSpeechStatus,
12} from "@t3tools/contracts";
13import { expect, it, vi } from "vite-plus/test";
14import { userEvent } from "vite-plus/test/browser";
15import { render } from "../../../test/render";
16import { __setClientSettingsForTests, getClientSettings } from "../../hooks/useSettings";
17import { chooseRecordingMicrophone } from "../../speech/browserMicrophones";
18import {
19 useEnvironmentSpeechInput,
20 useVoiceTranscriptionDestination,
21} from "../../speech/useEnvironmentSpeechInput";
22import { chooseAudioDestination, chooseTranscriptCleanup } from "../../speech/voiceConsent";
23import { useEnvironmentScope } from "../../state/session";
24import { toastManager } from "../ui/toast";
25import { SettingsScopeProvider } from "./SettingsScopeContext";
26import { SpeechLanguageSelect } from "./SpeechLanguageSelect";
27import { VoiceSettingsPanel } from "./VoiceSettingsPanel";
28
29vi.mock(import("../../speech/useEnvironmentSpeechInput"), { spy: true });
30vi.mock(import("../../state/session"), { spy: true });
31
32// The real connection runtime authorizes relay requests with a signed-in cloud session.
33vi.mock("../../connection/runtime", async () => {
34 const [{ ManagedRelay }, rpc, { Atom }, Context, Effect, Layer] = await Promise.all([
35 import("@t3tools/client-runtime/relay"),
36 import("@t3tools/client-runtime/rpc"),
37 import("effect/reactivity"),
38 import("effect/Context"),
39 import("effect/Effect"),
40 import("effect/Layer"),
41 ]);
42 type AuthorizedHttp =
43 import("@t3tools/client-runtime/authorization").AuthorizedRemoteHttpEnvironment;
44 // Services resolve by key, and client-runtime does not export this one.
45 class RemoteEnvironmentAuthorization extends Context.Service<
46 RemoteEnvironmentAuthorization,
47 {
48 readonly authorizeDpopHttp: (input: {
49 readonly expectedEnvironmentId: AuthorizedHttp["environmentId"];
50 }) => Effect.Effect<AuthorizedHttp>;
51 }
52 // @effect-diagnostics-next-line deterministicKeys:off
53 >()("@t3tools/client-runtime/authorization/service/RemoteEnvironmentAuthorization") {}
54 return {
55 connectionAtomRuntime: Atom.runtime(
56 Layer.mergeAll(
57 rpc.layerRemoteHttpClient((input, init) => globalThis.fetch(input, init)),
58 Layer.succeed(ManagedRelay.ManagedRelayDpopSigner, {
59 thumbprint: Effect.succeed("thumbprint"),
60 createProof: () => Effect.succeed("proof"),
61 }),
62 Layer.succeed(RemoteEnvironmentAuthorization, {
63 authorizeDpopHttp: ({ expectedEnvironmentId }) =>
64 Effect.succeed({
65 environmentId: expectedEnvironmentId,
66 label: "MacBook Pro",
67 httpBaseUrl: window.location.origin,
68 httpAuthorization: {
69 _tag: "Dpop",
70 accessToken: "relay-access-token",
71 expiresAtEpochMs: 4_102_444_800_000,
72 },
73 }),
74 }),
75 ),
76 ),
77 };
78});
79
80function deferred<T>() {
81 let resolve!: (value: T) => void;
82 const promise = new Promise<T>((done) => {
83 resolve = done;
84 });
85 return { promise, resolve };
86}
87
88it.each([{ cpu: null, accelerated: 2.1 }, { cpu: 5, accelerated: null }, { accelerated: 2.1 }])(
89 "uses the destination environment's benchmark tier across switches with measurements %j",
90 async (secondsPer30s) => {
91 const target = new PrimaryConnectionTarget({
92 environmentId: EnvironmentId.make("voice-local"),
93 label: "Local CPU",
94 httpBaseUrl: window.location.origin,
95 wsBaseUrl: "ws://localhost",
96 });
97 const local: PreparedConnection = {
98 ...target,
99 target,
100 socketUrl: target.wsBaseUrl,
101 httpAuthorization: null,
102 };
103 const remote: PreparedConnection = {
104 environmentId: EnvironmentId.make("voice-remote"),
105 label: "Remote GPU",
106 httpBaseUrl: "https://voice.example.test",
107 socketUrl: "wss://voice.example.test/ws",
108 httpAuthorization: { _tag: "Bearer", token: "test-token" },
109 target: new BearerConnectionTarget({
110 environmentId: EnvironmentId.make("voice-remote"),
111 label: "Remote GPU",
112 connectionId: "remote",
113 }),
114 };
115 let prepared = local;
116 const remoteStatus = deferred<Response>();
117 const remoteRequested = deferred<void>();
118 const status = {
119 supported: true,
120 state: "ready",
121 modelId: "both",
122 model: "Bilingual",
123 size: 100,
124 supportsStreaming: false,
125 supportsTranslation: false,
126 acceleration: "auto",
127 modelUnloadTimeout: "never",
128 gpuDevices: [],
129 benchmarkTier: "cpu",
130 } satisfies EnvironmentSpeechStatus;
131 const model: EnvironmentSpeechModel = {
132 id: "both",
133 name: "Bilingual",
134 description: "",
135 size: 100,
136 languages: ["en"],
137 accuracy: 1,
138 speed: 1,
139 recommended: true,
140 supportsStreaming: false,
141 supportsTranslation: false,
142 supportsLanguageDetection: false,
143 active: false,
144 state: "downloadable",
145 benchmark: {
146 accuracy: { en: { grade: "A-", errorPercent: 4 } },
147 secondsPer30s,
148 },
149 };
150 const cpuBenchmark = `Speed: ${secondsPer30s.cpu === undefined ? "update server" : secondsPer30s.cpu === null ? "not benchmarked" : `${secondsPer30s.cpu} s / 30 s`}`;
151 const gpuBenchmark = `Speed: ${secondsPer30s.accelerated === null ? "not benchmarked" : `${secondsPer30s.accelerated} s / 30 s`}`;
152 const fetch = globalThis.fetch;
153 using _network = vi.spyOn(globalThis, "fetch").mockImplementation((input, init) => {
154 const url = new URL(input instanceof Request ? input.url : input, window.location.href);
155 if (url.pathname === "/api/voice/status") {
156 if (url.origin === remote.httpBaseUrl) {
157 remoteRequested.resolve();
158 return remoteStatus.promise;
159 }
160 return Promise.resolve(Response.json(status));
161 }
162 if (url.pathname === "/api/voice/models")
163 return Promise.resolve(Response.json({ models: [model] }));
164 return fetch(input, init);
165 });
166 await vi.mocked(useVoiceTranscriptionDestination).withImplementation(
167 () => ({
168 environmentId: prepared.environmentId,
169 label: prepared.label,
170 grantId: "test",
171 prepared,
172 }),
173 async () => {
174 const panel = () => (
175 <SettingsScopeProvider search={{}} onChange={() => {}}>
176 <VoiceSettingsPanel />
177 </SettingsScopeProvider>
178 );
179 await using screen = await render(panel());
180 await expect.element(screen.getByText(cpuBenchmark)).toBeVisible();
181 prepared = remote;
182 await screen.rerender(panel());
183 await remoteRequested.promise;
184 await expect.element(screen.getByText(cpuBenchmark)).not.toBeInTheDocument();
185 remoteStatus.resolve(
186 Response.json({
187 ...status,
188 benchmarkTier: "accelerated",
189 gpuDevices: [{ id: "gpu", name: "Remote GPU" }],
190 }),
191 );
192 await expect.element(screen.getByText(gpuBenchmark)).toBeVisible();
193 prepared = local;
194 await screen.rerender(panel());
195 await expect.element(screen.getByText(cpuBenchmark)).toBeVisible();
196 await expect.element(screen.getByText(gpuBenchmark)).not.toBeInTheDocument();
197 },
198 );
199 },
200);
201
202it("tests the selected model after download and preserves its transcript across filters", async () => {
203 const target = new PrimaryConnectionTarget({
204 environmentId: EnvironmentId.make("voice-filter"),
205 label: "Test environment",
206 httpBaseUrl: window.location.origin,
207 wsBaseUrl: "ws://localhost",
208 });
209 const prepared = { ...target, target, socketUrl: target.wsBaseUrl, httpAuthorization: null };
210 const model: EnvironmentSpeechModel = {
211 id: "tiny",
212 name: "Tiny",
213 description: "",
214 size: 100,
215 languages: ["en"],
216 accuracy: 1,
217 speed: 1,
218 recommended: false,
219 supportsStreaming: false,
220 supportsTranslation: false,
221 supportsLanguageDetection: false,
222 active: true,
223 state: "installed",
224 };
225 const status = {
226 supported: true,
227 state: "ready",
228 modelId: model.id,
229 model: model.name,
230 size: model.size,
231 supportsStreaming: false,
232 supportsTranslation: false,
233 acceleration: "cpu",
234 modelUnloadTimeout: "never",
235 gpuDevices: [],
236 benchmarkTier: "cpu",
237 } satisfies EnvironmentSpeechStatus;
238 let installed = false;
239 const fetch = globalThis.fetch;
240 using _network = vi.spyOn(globalThis, "fetch").mockImplementation((input, init) => {
241 const url = new URL(input instanceof Request ? input.url : input, window.location.href);
242 if (url.pathname === "/api/voice/status")
243 return Promise.resolve(
244 Response.json(installed ? status : { ...status, state: "missing-model" }),
245 );
246 if (url.pathname === "/api/voice/models/download") {
247 installed = true;
248 return Promise.resolve(Response.json({ models: [model] }));
249 }
250 if (url.pathname === "/api/voice/models")
251 return Promise.resolve(
252 Response.json({
253 models: [
254 installed ? model : { ...model, state: "downloadable", active: false },
255 {
256 ...model,
257 id: "both",
258 name: "Bilingual",
259 active: false,
260 languages: ["en", "fr"],
261 ...(url.searchParams.has("benchmarks")
262 ? {
263 benchmark: {
264 accuracy: {
265 en: { grade: "A-", errorPercent: 4 },
266 fr: { grade: "B+", errorPercent: 5.5 },
267 },
268 secondsPer30s: { cpu: 5, accelerated: 2.1 },
269 },
270 }
271 : {}),
272 },
273 ],
274 }),
275 );
276 return fetch(input, init);
277 });
278 const transcript = "Keep this transcript.";
279 using _scope = vi.mocked(useEnvironmentScope).mockReturnValue(true);
280 await vi.mocked(useVoiceTranscriptionDestination).withImplementation(
281 () => ({ environmentId: target.environmentId, label: target.label, grantId: "test", prepared }),
282 async () => {
283 await vi.mocked(useEnvironmentSpeechInput).withImplementation(
284 (input) => ({
285 available: true,
286 status,
287 transcriptionEnvironmentId: target.environmentId,
288 transcriptionEnvironmentLabel: target.label,
289 setup: {
290 open: false,
291 step: 0,
292 model: null,
293 language: null,
294 downloading: false,
295 catalogFailed: false,
296 retryCatalog: null,
297 error: null,
298 setOpen: () => {},
299 download: async () => {},
300 cancelDownload: async () => {},
301 startRecording: async () => {},
302 },
303 state: { phase: "idle", error: null, errorAction: null },
304 preview: null,
305 level: 0,
306 preparing: false,
307 blocksSubmission: false,
308 freezesEditor: false,
309 start: async () => {
310 input.commitDraft(transcript, { start: transcript.length, end: transcript.length });
311 },
312 submitAfterDictation: () => {},
313 stop: async () => {},
314 cancel: () => {},
315 skipPostProcessing: () => {},
316 }),
317 async () => {
318 await using screen = await render(
319 <SettingsScopeProvider search={{}} onChange={() => {}}>
320 <VoiceSettingsPanel />
321 </SettingsScopeProvider>,
322 );
323 const testModel = screen.getByRole("button", { name: "Test model", exact: true });
324 const download = screen.getByRole("button", { name: "Download", exact: true });
325 await expect.element(download).toBeVisible();
326 expect(testModel.query()).toBeNull();
327 expect(screen.getByRole("button", { name: "Set up voice input" }).query()).toBeNull();
328 await userEvent.click(download);
329 await expect.element(testModel).toBeVisible();
330 expect(screen.getByRole("button", { name: "Set up voice input" }).query()).toBeNull();
331 await userEvent.click(testModel);
332 const result = screen.getByRole("status", { name: "Test transcript" });
333 await expect.element(result).toHaveTextContent(transcript);
334
335 const search = screen.getByRole("searchbox", { name: "Search transcription models" });
336 await userEvent.fill(search, "no matching model");
337 await expect.element(screen.getByText("No models match.")).toBeVisible();
338 await userEvent.clear(search);
339 const languages = screen.getByRole("button", { name: /^Filter by language/ });
340 await userEvent.click(languages);
341 await userEvent.fill(screen.getByRole("textbox", { name: "Search languages" }), "fr");
342 const french = screen.getByRole("menuitemcheckbox", { name: "French", exact: true });
343 await userEvent.click(french);
344 await userEvent.keyboard("{Escape}");
345 await expect.element(screen.getByText("Bilingual", { exact: true })).toBeVisible();
346 await expect.element(screen.getByText("B+", { exact: true })).toBeVisible();
347 expect(screen.container.querySelector("#transcription-test")).toHaveAttribute("hidden");
348 await userEvent.click(languages);
349 await userEvent.fill(screen.getByRole("textbox", { name: "Search languages" }), "fr");
350 await userEvent.click(french);
351 await userEvent.keyboard("{Escape}");
352 await expect.element(result).toBeVisible();
353 await vi.waitFor(() =>
354 expect(
355 screen.container.querySelector('[aria-label="Test transcript"]')?.textContent,
356 ).toBe(transcript),
357 );
358 },
359 );
360 },
361 );
362});
363
364it("loads the models of a relay-connected destination", async () => {
365 const target = new RelayConnectionTarget({
366 environmentId: EnvironmentId.make("voice-relay"),
367 label: "MacBook Pro",
368 });
369 const prepared = {
370 environmentId: target.environmentId,
371 label: target.label,
372 httpBaseUrl: window.location.origin,
373 socketUrl: "ws://localhost",
374 httpAuthorization: {
375 _tag: "Dpop" as const,
376 accessToken: "stale-access-token",
377 expiresAtEpochMs: 4_102_444_800_000,
378 },
379 target,
380 };
381 const model: EnvironmentSpeechModel = {
382 id: "tiny",
383 name: "Tiny",
384 description: "",
385 size: 100,
386 languages: ["en"],
387 accuracy: 1,
388 speed: 1,
389 recommended: false,
390 supportsStreaming: false,
391 supportsTranslation: false,
392 supportsLanguageDetection: false,
393 active: false,
394 state: "downloadable",
395 };
396 const status = {
397 supported: true,
398 state: "missing-model",
399 modelId: model.id,
400 model: model.name,
401 size: model.size,
402 supportsStreaming: false,
403 supportsTranslation: false,
404 acceleration: "cpu",
405 modelUnloadTimeout: "never",
406 gpuDevices: [],
407 benchmarkTier: "cpu",
408 } satisfies EnvironmentSpeechStatus;
409 const authorizations: Array<string | null> = [];
410 const fetch = globalThis.fetch;
411 using _network = vi.spyOn(globalThis, "fetch").mockImplementation((input, init) => {
412 const url = new URL(input instanceof Request ? input.url : input, window.location.href);
413 if (url.pathname === "/api/voice/status") {
414 authorizations.push(new Headers(init?.headers).get("authorization"));
415 return Promise.resolve(Response.json(status));
416 }
417 if (url.pathname === "/api/voice/models")
418 return Promise.resolve(Response.json({ models: [model] }));
419 return fetch(input, init);
420 });
421 using notify = vi.spyOn(toastManager, "add").mockReturnValue("test");
422 // Restored even when an assertion fails, unlike `withImplementation`.
423 using _destination = vi.mocked(useVoiceTranscriptionDestination).mockImplementation(() => ({
424 environmentId: target.environmentId,
425 label: target.label,
426 grantId: "test",
427 prepared,
428 }));
429 await using screen = await render(
430 <SettingsScopeProvider search={{}} onChange={() => {}}>
431 <VoiceSettingsPanel />
432 </SettingsScopeProvider>,
433 );
434 await vi.waitFor(() => expect(authorizations.length + notify.mock.calls.length).not.toBe(0));
435 expect(notify).not.toHaveBeenCalled();
436 expect(authorizations).toEqual(["DPoP relay-access-token"]);
437 await expect.element(screen.getByText("Tiny", { exact: true })).toBeVisible();
438});
439
440it("recovers from a failed download progress poll without notifying, but reports a failed initial load", async () => {
441 const target = new PrimaryConnectionTarget({
442 environmentId: EnvironmentId.make("voice-progress"),
443 label: "Test environment",
444 httpBaseUrl: window.location.origin,
445 wsBaseUrl: "ws://localhost",
446 });
447 const prepared = { ...target, target, socketUrl: target.wsBaseUrl, httpAuthorization: null };
448 const model: EnvironmentSpeechModel = {
449 id: "tiny",
450 name: "Tiny",
451 description: "",
452 size: 100,
453 languages: ["en"],
454 accuracy: 1,
455 speed: 1,
456 recommended: false,
457 supportsStreaming: false,
458 supportsTranslation: false,
459 supportsLanguageDetection: false,
460 active: false,
461 state: "downloading",
462 };
463 const status = {
464 supported: true,
465 state: "missing-model",
466 modelId: model.id,
467 model: model.name,
468 size: model.size,
469 supportsStreaming: false,
470 supportsTranslation: false,
471 acceleration: "cpu",
472 modelUnloadTimeout: "never",
473 gpuDevices: [],
474 benchmarkTier: "cpu",
475 };
476 const unavailable = () => new Response(null, { status: 503 });
477 const readModels = vi
478 .fn((_withBenchmarks: boolean) => unavailable())
479 .mockImplementationOnce(() => Response.json({ models: [model] }))
480 .mockImplementationOnce(unavailable)
481 .mockImplementationOnce(() => Response.json({ models: [{ ...model, state: "installed" }] }));
482 const fetch = globalThis.fetch;
483 using _network = vi.spyOn(globalThis, "fetch").mockImplementation((input, init) => {
484 const url = new URL(input instanceof Request ? input.url : input, window.location.href);
485 if (url.pathname === "/api/voice/status") return Promise.resolve(Response.json(status));
486 if (url.pathname === "/api/voice/models")
487 return Promise.resolve(readModels(url.searchParams.has("benchmarks")));
488 return fetch(input, init);
489 });
490 using notify = vi.spyOn(toastManager, "add").mockReturnValue("test");
491 await vi.mocked(useVoiceTranscriptionDestination).withImplementation(
492 () => ({ environmentId: target.environmentId, label: target.label, grantId: "test", prepared }),
493 async () => {
494 const panel = (key: string) => (
495 <SettingsScopeProvider search={{}} onChange={() => {}}>
496 <VoiceSettingsPanel key={key} />
497 </SettingsScopeProvider>
498 );
499 await using screen = await render(panel("first"));
500 await expect.element(screen.getByRole("button", { name: "Use model" })).toBeVisible();
501 expect(readModels.mock.calls.map(([withBenchmarks]) => withBenchmarks)).toEqual([
502 true,
503 false,
504 false,
505 ]);
506 expect(notify).not.toHaveBeenCalled();
507 await screen.rerender(panel("reopened"));
508 await vi.waitFor(() =>
509 expect(notify).toHaveBeenCalledWith(
510 expect.objectContaining({ title: "Could not load transcription models" }),
511 ),
512 );
513 },
514 );
515});
516
517it.each([
518 ["speech-models", "voice-transcription-destination"],
519 ["speech-hardware", "voice-transcription-destination"],
520 ["transcription-test", "voice-transcription-destination"],
521 ["speech-correction-word", "speech-post-processing"],
522 ["speech-post-processing-model", "speech-post-processing"],
523])(
524 "searching for disabled %s focuses its opt-in without enabling it",
525 async (target, prerequisite) => {
526 chooseAudioDestination(null);
527 chooseTranscriptCleanup(false);
528 await using screen = await render(
529 <SettingsScopeProvider search={{}} onChange={() => {}}>
530 <VoiceSettingsPanel />
531 </SettingsScopeProvider>,
532 { router: `/#${target}` },
533 );
534 await vi.waitFor(() => expect(document.activeElement?.id).toBe(prerequisite));
535 await expect
536 .element(screen.getByRole("combobox", { name: "Transcribe with" }))
537 .toHaveTextContent("Off");
538 await expect
539 .element(screen.getByRole("switch", { name: "Clean up transcripts" }))
540 .not.toBeChecked();
541 },
542);
543
544it("keeps a missing destination selected without claiming it cannot transcribe", async () => {
545 __setClientSettingsForTests(getClientSettings());
546 using cleanup = new DisposableStack();
547 cleanup.defer(() => chooseAudioDestination(null));
548 chooseAudioDestination({ kind: "environment", environmentId: EnvironmentId.make("missing") });
549 await using screen = await render(
550 <SettingsScopeProvider search={{}} onChange={() => {}}>
551 <VoiceSettingsPanel />
552 </SettingsScopeProvider>,
553 );
554 await expect
555 .element(screen.getByRole("combobox", { name: "Transcribe with" }))
556 .toHaveTextContent("Chosen environment (unavailable)");
557 await expect.element(screen.getByText(/can’t transcribe audio/)).not.toBeInTheDocument();
558});
559
560it("shows the model's effective language and restores the saved preference when supported again", async () => {
561 const previous = getClientSettings();
562 using cleanup = new DisposableStack();
563 cleanup.defer(() => __setClientSettingsForTests(previous));
564 __setClientSettingsForTests({ ...previous, speechLanguage: "fr" });
565 await using screen = await render(<SpeechLanguageSelect model={{ languages: ["en"] }} />);
566 const picker = screen.getByRole("combobox", { name: "Transcription language" });
567 await expect.element(picker).toHaveTextContent("English (French unavailable)");
568 await screen.rerender(
569 <SpeechLanguageSelect model={{ languages: ["en", "de"], supportsLanguageDetection: true }} />,
570 );
571 await expect.element(picker).toHaveTextContent("Auto-detect (French unavailable)");
572 await screen.rerender(<SpeechLanguageSelect model={{ languages: ["en", "fr"] }} />);
573 await expect.element(picker).toHaveTextContent("French");
574 expect(getClientSettings().speechLanguage).toBe("fr");
575});
576
577it("keeps punctuation in a saved dictionary term and its alias", async () => {
578 const previous = getClientSettings();
579 using cleanup = new DisposableStack();
580 cleanup.defer(() => __setClientSettingsForTests(previous));
581 __setClientSettingsForTests({ ...previous, speechCustomWords: [] });
582 await using screen = await render(
583 <SettingsScopeProvider search={{}} onChange={() => {}}>
584 <VoiceSettingsPanel />
585 </SettingsScopeProvider>,
586 );
587 await userEvent.type(
588 screen.getByRole("textbox", { name: "Add a word or phrase to the dictionary" }),
589 "O'Reilly{Enter}",
590 );
591 expect(getClientSettings().speechCustomWords[0]?.term).toBe("O'Reilly");
592 await userEvent.click(screen.getByRole("button", { name: "Add an alias for O'Reilly" }));
593 await userEvent.type(
594 screen.getByRole("textbox", { name: "Alias for O'Reilly" }),
595 '"Oh Really"{Enter}',
596 );
597 expect(getClientSettings().speechCustomWords).toEqual([
598 { term: "O'Reilly", aliases: ['"Oh Really"'] },
599 ]);
600});
601
602it("keeps a dictionary term and alias draft on the Enter that confirms an IME candidate", async () => {
603 const previous = getClientSettings();
604 using cleanup = new DisposableStack();
605 cleanup.defer(() => __setClientSettingsForTests(previous));
606 __setClientSettingsForTests({
607 ...previous,
608 speechCustomWords: [{ term: "日本語", aliases: [] }],
609 });
610 await using screen = await render(
611 <SettingsScopeProvider search={{}} onChange={() => {}}>
612 <VoiceSettingsPanel />
613 </SettingsScopeProvider>,
614 );
615 const composingEnter = () =>
616 new KeyboardEvent("keydown", { key: "Enter", isComposing: true, bubbles: true });
617 const term = screen.getByRole("textbox", { name: "Add a word or phrase to the dictionary" });
618 await userEvent.fill(term, "にほん");
619 term.element().dispatchEvent(composingEnter());
620 await expect.element(term).toHaveValue("にほん");
621 await userEvent.click(screen.getByRole("button", { name: "Add an alias for 日本語" }));
622 const alias = screen.getByRole("textbox", { name: "Alias for 日本語" });
623 await userEvent.fill(alias, "にほん");
624 alias.element().dispatchEvent(composingEnter());
625 expect(getClientSettings().speechCustomWords).toEqual([{ term: "日本語", aliases: [] }]);
626 await expect.element(alias).toHaveValue("にほん");
627});
628
629it("records from the first connected microphone in the saved order", async () => {
630 const previous = getClientSettings();
631 using cleanup = new DisposableStack();
632 cleanup.defer(() => __setClientSettingsForTests(previous));
633 // A single saved microphone from before the order existed comes first.
634 __setClientSettingsForTests({ ...previous, voiceMicrophone: "yeti", voiceMicrophones: [] });
635 const input = (deviceId: string, label: string) =>
636 ({ deviceId, label, groupId: deviceId, kind: "audioinput" }) as MediaDeviceInfo;
637 const yeti = input("yeti", "Blue Yeti");
638 const airpods = input("airpods", "AirPods Pro");
639 using devices = vi
640 .spyOn(navigator.mediaDevices, "enumerateDevices")
641 .mockResolvedValue([yeti, airpods]);
642 const saved = () =>
643 getClientSettings().voiceMicrophones.map((entry) =>
644 entry.kind === "default" ? "System default" : entry.label,
645 );
646 await using screen = await render(
647 <SettingsScopeProvider search={{}} onChange={() => {}}>
648 <VoiceSettingsPanel />
649 </SettingsScopeProvider>,
650 );
651 const list = screen.getByRole("list", { name: "Microphones, preferred first" });
652 await expect.poll(saved).toEqual(["Blue Yeti", "System default", "AirPods Pro"]);
653 const row = (index: number) => list.getByRole("listitem").nth(index);
654 await expect.element(row(0)).toHaveTextContent("1Blue YetiIn use");
655 expect(await chooseRecordingMicrophone()).toBe("yeti");
656
657 devices.mockResolvedValue([airpods]);
658 await userEvent.click(screen.getByRole("button", { name: "Refresh microphones" }));
659 await expect.element(row(0)).toHaveTextContent("1Blue YetiNot connected");
660 await expect.element(row(1)).toHaveTextContent("2System defaultIn use");
661 expect(await chooseRecordingMicrophone()).toBe("");
662
663 await userEvent.click(screen.getByRole("button", { name: "Move AirPods Pro up" }));
664 await userEvent.click(screen.getByRole("button", { name: "Move AirPods Pro up" }));
665 expect(saved()).toEqual(["AirPods Pro", "Blue Yeti", "System default"]);
666 await expect.element(screen.getByRole("button", { name: "Move AirPods Pro up" })).toBeDisabled();
667 expect(await chooseRecordingMicrophone()).toBe("airpods");
668
669 await userEvent.click(screen.getByRole("button", { name: "Forget Blue Yeti" }));
670 expect(saved()).toEqual(["AirPods Pro", "System default"]);
671 await expect
672 .element(screen.getByRole("button", { name: "Forget System default" }))
673 .not.toBeInTheDocument();
674});