1import type { TranscriptionResult } from "@react-native-ai/apple/src/NativeAppleTranscription";
2import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
3
4import { VoiceTranscriptionError } from "@t3tools/client-runtime/voice-input";
5
6const mocks = vi.hoisted(() => ({
7 isAvailable: vi.fn<(locale: string) => boolean>(),
8 prepare: vi.fn<(locale: string) => Promise<string>>(),
9 transcribe: vi.fn<(audio: ArrayBufferLike, locale: string) => Promise<TranscriptionResult>>(),
10 readAudio: vi.fn<() => Promise<ArrayBuffer>>(),
11}));
12
13vi.mock("@react-native-ai/apple/src/NativeAppleTranscription", () => ({
14 default: {
15 isAvailable: mocks.isAvailable,
16 prepare: mocks.prepare,
17 transcribe: mocks.transcribe,
18 },
19}));
20
21vi.mock("expo-file-system", () => ({
22 File: class {
23 arrayBuffer = mocks.readAudio;
24 },
25}));
26
27import { getLocalVoiceTranscriber } from "./voiceTranscription.ios";
28
29const audio = new ArrayBuffer(4);
30const nativeTranscript: TranscriptionResult = {
31 duration: 2,
32 segments: [
33 { text: " Hej", startSecond: 0, endSecond: 1 },
34 { text: "världen. ", startSecond: 1, endSecond: 2 },
35 ],
36};
37
38function deferred<T>() {
39 let resolve!: (value: T) => void;
40 const promise = new Promise<T>((next) => {
41 resolve = next;
42 });
43 return { promise, resolve };
44}
45
46beforeEach(() => {
47 vi.resetAllMocks();
48 mocks.isAvailable.mockReturnValue(true);
49 mocks.prepare.mockResolvedValue("sv-SE");
50 mocks.readAudio.mockResolvedValue(audio);
51 mocks.transcribe.mockResolvedValue(nativeTranscript);
52});
53
54afterEach(() => {
55 vi.restoreAllMocks();
56});
57
58describe("getLocalVoiceTranscriber", () => {
59 it("keeps the selected language and Apple's resolved locale when the device language changes", async () => {
60 const resolvedOptions = Intl.DateTimeFormat().resolvedOptions();
61 const deviceLocale = vi
62 .spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions")
63 .mockReturnValue({ ...resolvedOptions, locale: "sv-FI" });
64 const transcriber = getLocalVoiceTranscriber()!;
65 const options = { signal: new AbortController().signal };
66
67 deviceLocale.mockReturnValue({ ...resolvedOptions, locale: "de-DE" });
68 const prepared = await transcriber.prepare(options);
69 deviceLocale.mockReturnValue({ ...resolvedOptions, locale: "en-US" });
70
71 await expect(prepared.transcribe("file:///voice.m4a", options)).resolves.toBe("Hej världen.");
72 expect(mocks.prepare).toHaveBeenCalledWith("sv-FI");
73 expect(prepared.locale).toBe("sv-SE");
74 expect(mocks.transcribe).toHaveBeenCalledWith(audio, "sv-SE");
75 });
76
77 it("does not start native transcription after cancellation during a file read", async () => {
78 const enteredRead = deferred<void>();
79 const readResult = deferred<ArrayBuffer>();
80 mocks.readAudio.mockImplementation(() => {
81 enteredRead.resolve();
82 return readResult.promise;
83 });
84 const controller = new AbortController();
85 const options = { signal: controller.signal };
86 const prepared = await getLocalVoiceTranscriber()!.prepare(options);
87 const result = prepared
88 .transcribe("file:///voice.m4a", options)
89 .catch((error: unknown) => error);
90
91 await enteredRead.promise;
92 controller.abort();
93 readResult.resolve(audio);
94
95 const error = await result;
96 expect(error).toBeInstanceOf(VoiceTranscriptionError);
97 expect(error).toMatchObject({ code: "cancelled" });
98 expect(mocks.transcribe).not.toHaveBeenCalled();
99 });
100
101 it.each(["prepare", "transcribe"] as const)(
102 "waits for native %s to finish before settling cancellation",
103 async (phase) => {
104 const enteredNative = deferred<void>();
105 const finishNative = deferred<void>();
106 if (phase === "prepare") {
107 mocks.prepare.mockImplementation(async () => {
108 enteredNative.resolve();
109 await finishNative.promise;
110 return "sv-SE";
111 });
112 } else {
113 mocks.transcribe.mockImplementation(async () => {
114 enteredNative.resolve();
115 await finishNative.promise;
116 return nativeTranscript;
117 });
118 }
119 const controller = new AbortController();
120 const options = { signal: controller.signal };
121 const transcriber = getLocalVoiceTranscriber()!;
122 const operation =
123 phase === "prepare"
124 ? transcriber.prepare(options)
125 : (await transcriber.prepare(options)).transcribe("file:///voice.m4a", options);
126 const settled = vi.fn((value: unknown) => value);
127 const result = operation.then(settled, settled);
128
129 await enteredNative.promise;
130 controller.abort();
131 await new Promise<void>((resolve) => setImmediate(resolve));
132 expect(settled).not.toHaveBeenCalled();
133 finishNative.resolve();
134
135 const error = await result;
136 expect(error).toBeInstanceOf(VoiceTranscriptionError);
137 expect(error).toMatchObject({ code: "cancelled" });
138 },
139 );
140});