@@ -2,38 +2,21 @@ import { renderHook, waitFor } from '@testing-library/react-native';
22import { createStore , Provider } from 'jotai' ;
33import React from 'react' ;
44import speechState from '~/store/atoms/speech' ;
5+ import { mockFetch } from '~/utils/test/ttsMocks' ;
56import { useTTS } from './useTTS' ;
67
78jest . mock ( '~/utils/isDevApp' , ( ) => ( {
89 isDevApp : false ,
910} ) ) ;
1011
11- const mockFetch = jest . fn ( ) ;
1212const mockCreateAudioPlayer = jest . fn ( ) ;
1313const mockSetAudioModeAsync = jest . fn ( ) ;
1414
15- jest . mock ( 'expo/fetch' , ( ) => ( {
16- fetch : ( ...args : unknown [ ] ) => mockFetch ( ...args ) ,
17- } ) ) ;
18-
1915jest . 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-
3720jest . 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+
86102describe ( '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