Skip to content

Commit fe48221

Browse files
TinyKittenclaudecoderabbitai[bot]
authored
useTTSフックをリファクタリングして独立テスト可能なモジュールに分離 (#5344)
* useTTSフックをリファクタリングして独立テスト可能なモジュールに分離 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * ttsAudioPlayerテストの型キャストを修正 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Update src/utils/ttsAudioPlayer.test.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update src/utils/ttsSpeechFetcher.test.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update src/utils/base64ToUint8Array.test.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * expo/fetchとexpo-file-systemのJestモックを共有ヘルパーに抽出 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent 388accb commit fe48221

File tree

9 files changed

+880
-410
lines changed

9 files changed

+880
-410
lines changed

src/hooks/useTTS.test.ts

Lines changed: 253 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -2,38 +2,21 @@ import { renderHook, waitFor } from '@testing-library/react-native';
22
import { createStore, Provider } from 'jotai';
33
import React from 'react';
44
import speechState from '~/store/atoms/speech';
5+
import { mockFetch } from '~/utils/test/ttsMocks';
56
import { useTTS } from './useTTS';
67

78
jest.mock('~/utils/isDevApp', () => ({
89
isDevApp: false,
910
}));
1011

11-
const mockFetch = jest.fn();
1212
const mockCreateAudioPlayer = jest.fn();
1313
const mockSetAudioModeAsync = jest.fn();
1414

15-
jest.mock('expo/fetch', () => ({
16-
fetch: (...args: unknown[]) => mockFetch(...args),
17-
}));
18-
1915
jest.mock('expo-audio', () => ({
2016
createAudioPlayer: (...args: unknown[]) => mockCreateAudioPlayer(...args),
2117
setAudioModeAsync: (...args: unknown[]) => mockSetAudioModeAsync(...args),
2218
}));
2319

24-
jest.mock('expo-file-system', () => ({
25-
Paths: { cache: '/tmp' },
26-
File: class {
27-
public uri: string;
28-
29-
constructor(basePath: string, name: string) {
30-
this.uri = `${basePath}/${name}`;
31-
}
32-
33-
write() {}
34-
},
35-
}));
36-
3720
jest.mock('./useCurrentLine', () => ({
3821
useCurrentLine: jest.fn(() => undefined),
3922
}));
@@ -58,50 +41,75 @@ jest.mock('./useCachedAnonymousUser', () => ({
5841
useCachedInitAnonymousUser: jest.fn(() => ({ uid: 'test-user' })),
5942
}));
6043

61-
const createMockPlayer = () => {
62-
let playbackStatusListener:
63-
| ((status: { didJustFinish: boolean }) => void)
64-
| null = null;
44+
jest.mock('./useStoppingState', () => ({
45+
useStoppingState: jest.fn(() => 'CURRENT'),
46+
}));
47+
48+
type StatusCallback = (status: {
49+
didJustFinish?: boolean;
50+
error?: string;
51+
}) => void;
52+
53+
const createMockPlayer = (opts?: { autoFinish?: boolean }) => {
54+
let playbackStatusListener: StatusCallback | null = null;
55+
const autoFinish = opts?.autoFinish ?? true;
6556

6657
return {
67-
addListener: jest.fn(
68-
(
69-
_event: string,
70-
callback: (status: { didJustFinish: boolean }) => void
71-
) => {
72-
playbackStatusListener = callback;
73-
return { remove: jest.fn() };
74-
}
75-
),
58+
addListener: jest.fn((_event: string, callback: StatusCallback) => {
59+
playbackStatusListener = callback;
60+
return { remove: jest.fn() };
61+
}),
7662
play: jest.fn(() => {
77-
setTimeout(() => {
78-
playbackStatusListener?.({ didJustFinish: true });
79-
}, 0);
63+
if (autoFinish) {
64+
setTimeout(() => {
65+
playbackStatusListener?.({ didJustFinish: true });
66+
}, 0);
67+
}
8068
}),
8169
pause: jest.fn(),
8270
remove: jest.fn(),
71+
emitStatus: (status: { didJustFinish?: boolean; error?: string }) => {
72+
playbackStatusListener?.(status);
73+
},
8374
};
8475
};
8576

77+
const defaultSpeechState = {
78+
enabled: true,
79+
backgroundEnabled: false,
80+
ttsEnabledLanguages: ['JA', 'EN'] as ('JA' | 'EN')[],
81+
monetizedPlanEnabled: false,
82+
};
83+
84+
const createWrapper =
85+
(store: ReturnType<typeof createStore>) =>
86+
({ children }: { children: React.ReactNode }) =>
87+
React.createElement(Provider, { store }, children);
88+
89+
const mockSuccessfulFetch = () => {
90+
mockFetch.mockResolvedValue({
91+
ok: true,
92+
json: async () => ({
93+
result: {
94+
id: 'tts-id',
95+
jaAudioContent: 'QQ==',
96+
enAudioContent: 'QQ==',
97+
},
98+
}),
99+
});
100+
};
101+
86102
describe('useTTS', () => {
87103
beforeEach(() => {
88104
jest.useFakeTimers();
89105
jest.clearAllMocks();
90-
91-
mockFetch.mockResolvedValue({
92-
ok: true,
93-
json: async () => ({
94-
result: {
95-
id: 'tts-id',
96-
jaAudioContent: 'QQ==',
97-
enAudioContent: 'QQ==',
98-
},
99-
}),
100-
});
101-
102-
mockCreateAudioPlayer.mockImplementation((_source: { uri: string }) =>
103-
createMockPlayer()
104-
);
106+
mockSuccessfulFetch();
107+
mockCreateAudioPlayer.mockImplementation(() => createMockPlayer());
108+
// テスト間で useTTSText の mock を復元
109+
const { useTTSText } = jest.requireMock('./useTTSText') as {
110+
useTTSText: jest.Mock;
111+
};
112+
useTTSText.mockReturnValue(['ja text', 'en text']);
105113
});
106114

107115
afterEach(() => {
@@ -113,16 +121,11 @@ describe('useTTS', () => {
113121
it('英語のみ有効時は英語音声のみ再生プレイヤーを生成する', async () => {
114122
const store = createStore();
115123
store.set(speechState, {
116-
enabled: true,
117-
backgroundEnabled: false,
124+
...defaultSpeechState,
118125
ttsEnabledLanguages: ['EN'],
119-
monetizedPlanEnabled: false,
120126
});
121127

122-
const wrapper = ({ children }: { children: React.ReactNode }) =>
123-
React.createElement(Provider, { store }, children);
124-
125-
renderHook(() => useTTS(), { wrapper });
128+
renderHook(() => useTTS(), { wrapper: createWrapper(store) });
126129

127130
await waitFor(() => {
128131
expect(mockFetch).toHaveBeenCalled();
@@ -138,4 +141,198 @@ describe('useTTS', () => {
138141
uri: '/tmp/tts-id_en.mp3',
139142
});
140143
});
144+
145+
it('JA+EN有効時はJA→ENの順に再生する', async () => {
146+
const store = createStore();
147+
store.set(speechState, defaultSpeechState);
148+
149+
const calls: string[] = [];
150+
mockCreateAudioPlayer.mockImplementation((source: { uri: string }) => {
151+
calls.push(source.uri);
152+
return createMockPlayer();
153+
});
154+
155+
renderHook(() => useTTS(), { wrapper: createWrapper(store) });
156+
157+
await waitFor(() => {
158+
expect(mockFetch).toHaveBeenCalled();
159+
});
160+
161+
// JA プレイヤーが先に生成される
162+
jest.runAllTimers();
163+
164+
await waitFor(() => {
165+
expect(calls.length).toBeGreaterThanOrEqual(1);
166+
});
167+
168+
expect(calls[0]).toBe('/tmp/tts-id_ja.mp3');
169+
170+
// EN_PLAYBACK_DELAY_MS 後に EN プレイヤーが生成される
171+
jest.runAllTimers();
172+
173+
await waitFor(() => {
174+
expect(calls.length).toBe(2);
175+
});
176+
177+
expect(calls[1]).toBe('/tmp/tts-id_en.mp3');
178+
});
179+
180+
it('JAのみ有効時はJAプレイヤーのみ生成する', async () => {
181+
const store = createStore();
182+
store.set(speechState, {
183+
...defaultSpeechState,
184+
ttsEnabledLanguages: ['JA'],
185+
});
186+
187+
renderHook(() => useTTS(), { wrapper: createWrapper(store) });
188+
189+
await waitFor(() => {
190+
expect(mockFetch).toHaveBeenCalled();
191+
});
192+
193+
jest.runAllTimers();
194+
195+
await waitFor(() => {
196+
expect(mockCreateAudioPlayer).toHaveBeenCalledTimes(1);
197+
});
198+
199+
expect(mockCreateAudioPlayer).toHaveBeenCalledWith({
200+
uri: '/tmp/tts-id_ja.mp3',
201+
});
202+
});
203+
204+
it('無効時はfetch/playしない', async () => {
205+
const store = createStore();
206+
store.set(speechState, {
207+
...defaultSpeechState,
208+
enabled: false,
209+
});
210+
211+
renderHook(() => useTTS(), { wrapper: createWrapper(store) });
212+
213+
jest.runAllTimers();
214+
215+
await waitFor(() => {
216+
expect(mockFetch).not.toHaveBeenCalled();
217+
expect(mockCreateAudioPlayer).not.toHaveBeenCalled();
218+
});
219+
});
220+
221+
it('テキスト空時にpendingをクリアする', async () => {
222+
const { useTTSText } = jest.requireMock('./useTTSText') as {
223+
useTTSText: jest.Mock;
224+
};
225+
226+
const store = createStore();
227+
store.set(speechState, defaultSpeechState);
228+
229+
// 最初は有効なテキストで再生開始
230+
useTTSText.mockReturnValue(['ja text', 'en text']);
231+
232+
const { rerender } = renderHook(() => useTTS(), {
233+
wrapper: createWrapper(store),
234+
});
235+
236+
await waitFor(() => {
237+
expect(mockFetch).toHaveBeenCalled();
238+
});
239+
240+
// テキストを空にして再描画
241+
useTTSText.mockReturnValue(['', '']);
242+
rerender({});
243+
244+
jest.runAllTimers();
245+
246+
// 空テキストではfetchが追加で呼ばれない
247+
expect(mockFetch).toHaveBeenCalledTimes(1);
248+
});
249+
250+
it('APIエラー時にfinishPlayingが呼ばれる', async () => {
251+
mockFetch.mockResolvedValue({
252+
ok: false,
253+
status: 500,
254+
statusText: 'Internal Server Error',
255+
});
256+
257+
const store = createStore();
258+
store.set(speechState, defaultSpeechState);
259+
260+
renderHook(() => useTTS(), { wrapper: createWrapper(store) });
261+
262+
await waitFor(() => {
263+
expect(mockFetch).toHaveBeenCalled();
264+
});
265+
266+
jest.runAllTimers();
267+
268+
// APIエラー後、プレイヤーは生成されない
269+
expect(mockCreateAudioPlayer).not.toHaveBeenCalled();
270+
});
271+
272+
it('タイムアウト後に強制リセットされる', async () => {
273+
const store = createStore();
274+
store.set(speechState, {
275+
...defaultSpeechState,
276+
ttsEnabledLanguages: ['EN'],
277+
});
278+
279+
// didJustFinish を発火しないプレイヤー
280+
mockCreateAudioPlayer.mockImplementation(() =>
281+
createMockPlayer({ autoFinish: false })
282+
);
283+
284+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation();
285+
286+
renderHook(() => useTTS(), { wrapper: createWrapper(store) });
287+
288+
await waitFor(() => {
289+
expect(mockFetch).toHaveBeenCalled();
290+
});
291+
292+
// プレイヤーが生成されるまで待つ
293+
jest.advanceTimersByTime(100);
294+
295+
await waitFor(() => {
296+
expect(mockCreateAudioPlayer).toHaveBeenCalledTimes(1);
297+
});
298+
299+
// 60秒のタイムアウトを発火
300+
jest.advanceTimersByTime(60_000);
301+
302+
expect(warnSpy).toHaveBeenCalledWith(
303+
'[useTTS] Playback safety timeout reached, force resetting'
304+
);
305+
306+
warnSpy.mockRestore();
307+
});
308+
309+
it('アンマウント時にクリーンアップされる', async () => {
310+
const store = createStore();
311+
store.set(speechState, {
312+
...defaultSpeechState,
313+
ttsEnabledLanguages: ['EN'],
314+
});
315+
316+
const mockPlayer = createMockPlayer({ autoFinish: false });
317+
mockCreateAudioPlayer.mockReturnValue(mockPlayer);
318+
319+
const { unmount } = renderHook(() => useTTS(), {
320+
wrapper: createWrapper(store),
321+
});
322+
323+
await waitFor(() => {
324+
expect(mockFetch).toHaveBeenCalled();
325+
});
326+
327+
jest.advanceTimersByTime(100);
328+
329+
await waitFor(() => {
330+
expect(mockCreateAudioPlayer).toHaveBeenCalled();
331+
});
332+
333+
unmount();
334+
335+
expect(mockPlayer.pause).toHaveBeenCalled();
336+
expect(mockPlayer.remove).toHaveBeenCalled();
337+
});
141338
});

0 commit comments

Comments
 (0)