Skip to content

Commit 4116698

Browse files
committed
test(store): fix audioSlice tests, ensure type safety, and mock SpeechSynthesisUtterance
1 parent e952ae2 commit 4116698

File tree

7 files changed

+246
-152
lines changed

7 files changed

+246
-152
lines changed

app/components/TextGenerator/TextGeneratorContainer.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ const TextGeneratorContainer = () => {
3232
const store: Partial<ReturnType<typeof useTextGeneratorStore.getState>> =
3333
useTextGeneratorStore.getState();
3434
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
35-
if (store && typeof store._setIsSpeechSupported === 'function') {
36-
store._setIsSpeechSupported(isSpeechSupported);
35+
if (store && typeof store.setIsSpeechSupported === 'function') {
36+
store.setIsSpeechSupported(isSpeechSupported);
3737
}
3838
}
3939

app/store/audioSlice.test.ts

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { create, StoreApi } from 'zustand';
2+
import { immer } from 'zustand/middleware/immer';
3+
import { createAudioSlice, AudioSlice } from './audioSlice';
4+
import type { TextGeneratorState } from './textGeneratorStore';
5+
import { vi, describe, it, expect, beforeEach } from 'vitest';
6+
7+
type MinimalTextGeneratorState = Pick<
8+
TextGeneratorState,
9+
'quizData' | 'generatedPassageLanguage' | 'passageLanguage' | 'setError'
10+
>;
11+
12+
const minimalTextGeneratorState: MinimalTextGeneratorState = {
13+
quizData: { paragraph: 'Hello world', question: '', options: { A: '', B: '', C: '', D: '' } },
14+
generatedPassageLanguage: 'en',
15+
passageLanguage: 'en',
16+
setError: vi.fn(),
17+
};
18+
19+
type AudioTestStore = MinimalTextGeneratorState & AudioSlice;
20+
21+
type TextGeneratorSetState = StoreApi<TextGeneratorState>['setState'];
22+
type TextGeneratorGetState = StoreApi<TextGeneratorState>['getState'];
23+
type TextGeneratorStoreApi = StoreApi<TextGeneratorState>;
24+
25+
const getTestStore = (overrides: Partial<AudioSlice> = {}) =>
26+
create<AudioTestStore>()(
27+
immer((set, get, api) => ({
28+
...minimalTextGeneratorState,
29+
...createAudioSlice(
30+
set as TextGeneratorSetState,
31+
get as TextGeneratorGetState,
32+
api as TextGeneratorStoreApi
33+
),
34+
...overrides,
35+
}))
36+
);
37+
38+
type SpeechSynthesisMock = {
39+
speak: ReturnType<typeof vi.fn>;
40+
cancel: ReturnType<typeof vi.fn>;
41+
pause: ReturnType<typeof vi.fn>;
42+
resume: ReturnType<typeof vi.fn>;
43+
getVoices: ReturnType<typeof vi.fn>;
44+
speaking: boolean;
45+
onvoiceschanged: null | (() => void);
46+
};
47+
48+
class MockSpeechSynthesisUtterance {
49+
text = '';
50+
lang = '';
51+
volume = 1;
52+
voice: SpeechSynthesisVoice | null = null;
53+
onboundary: ((event: any) => void) | null = null;
54+
onend: (() => void) | null = null;
55+
onerror: ((event: any) => void) | null = null;
56+
57+
constructor(text: string) {
58+
this.text = text;
59+
}
60+
}
61+
global.SpeechSynthesisUtterance = MockSpeechSynthesisUtterance as any;
62+
63+
describe('audioSlice', () => {
64+
let store: ReturnType<typeof getTestStore>;
65+
let speechSynthesisMock: SpeechSynthesisMock;
66+
beforeEach(() => {
67+
speechSynthesisMock = {
68+
speak: vi.fn(),
69+
cancel: vi.fn(),
70+
pause: vi.fn(),
71+
resume: vi.fn(),
72+
getVoices: vi.fn(() => [
73+
{ voiceURI: 'voice1', name: 'Voice 1', lang: 'en-US' },
74+
{ voiceURI: 'voice2', name: 'Voice 2', lang: 'en-GB' },
75+
]),
76+
speaking: false,
77+
onvoiceschanged: null,
78+
};
79+
(window as any).speechSynthesis = speechSynthesisMock;
80+
store = getTestStore({
81+
isSpeechSupported: true,
82+
passageUtteranceRef: null,
83+
availableVoices: [],
84+
selectedVoiceURI: null,
85+
translationCache: new Map(),
86+
wordsRef: [],
87+
isSpeakingPassage: false,
88+
isPaused: false,
89+
volume: 0.5,
90+
currentWordIndex: null,
91+
});
92+
speechSynthesisMock.speak.mockClear();
93+
speechSynthesisMock.cancel.mockClear();
94+
speechSynthesisMock.pause.mockClear();
95+
speechSynthesisMock.resume.mockClear();
96+
});
97+
98+
it('sets volume level', () => {
99+
store.getState().setVolumeLevel(0.8);
100+
expect(store.getState().volume).toBe(0.8);
101+
});
102+
103+
it('stops passage speech', () => {
104+
store.setState({ isSpeakingPassage: true, isPaused: true, currentWordIndex: 1 });
105+
store.getState().stopPassageSpeech();
106+
expect(store.getState().isSpeakingPassage).toBe(false);
107+
expect(store.getState().isPaused).toBe(false);
108+
expect(store.getState().currentWordIndex).toBe(null);
109+
});
110+
111+
it('handles play/pause toggle', () => {
112+
store.setState({ isSpeakingPassage: false, isPaused: false });
113+
store.getState().handlePlayPause();
114+
expect(store.getState().isSpeakingPassage).toBe(true);
115+
store.setState({ isSpeakingPassage: true, isPaused: false });
116+
store.getState().handlePlayPause();
117+
expect(store.getState().isPaused).toBe(true);
118+
store.setState({ isSpeakingPassage: true, isPaused: true });
119+
store.getState().handlePlayPause();
120+
expect(store.getState().isPaused).toBe(false);
121+
});
122+
123+
it('handles stop', () => {
124+
store.setState({ isSpeakingPassage: true });
125+
store.getState().handleStop();
126+
expect(store.getState().isSpeakingPassage).toBe(false);
127+
});
128+
129+
it('sets selected voice URI and restarts if speaking', () => {
130+
store.setState({ isSpeakingPassage: true });
131+
store.getState().setSelectedVoiceURI('voice2');
132+
expect(store.getState().selectedVoiceURI).toBe('voice2');
133+
expect(store.getState().isSpeakingPassage).toBe(false);
134+
});
135+
136+
it('updates available voices and selects default if needed', () => {
137+
store.setState({ selectedVoiceURI: 'notfound' });
138+
store.getState().updateAvailableVoices('en');
139+
expect(store.getState().availableVoices.length).toBe(2);
140+
expect(store.getState().selectedVoiceURI).toBe('voice1');
141+
});
142+
143+
it('caches translation result', () => {
144+
store.getState().translationCache.set('en:es:hello', 'hola');
145+
const cached = store.getState().translationCache.get('en:es:hello');
146+
expect(cached).toBe('hola');
147+
});
148+
149+
it('returns null for empty or same language translation', async () => {
150+
const result1 = await store.getState().getTranslation('', 'en', 'es');
151+
expect(result1).toBeNull();
152+
const result2 = await store.getState().getTranslation('hello', 'en', 'en');
153+
expect(result2).toBeNull();
154+
});
155+
156+
it('speakText does nothing if not supported or no text', () => {
157+
store.setState({ isSpeechSupported: false });
158+
store.getState().speakText('hi', 'en');
159+
expect(speechSynthesisMock.speak).not.toHaveBeenCalled();
160+
store.setState({ isSpeechSupported: true });
161+
store.getState().speakText(null, 'en');
162+
expect(speechSynthesisMock.speak).not.toHaveBeenCalled();
163+
});
164+
});

app/store/audioSlice.ts

Lines changed: 8 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { type Language, SPEECH_LANGUAGES } from '@/lib/domain/language';
33
import type { TextGeneratorState } from './textGeneratorStore';
44
import { translateWordWithGoogle } from '../actions/translate';
55
import type { VoiceInfo } from '@/lib/domain/schemas';
6+
import { filterAndFormatVoices } from '@/lib/utils/speech';
67

78
export interface AudioSlice {
89
isSpeechSupported: boolean;
@@ -22,8 +23,8 @@ export interface AudioSlice {
2223
handleStop: () => void;
2324
getTranslation: (word: string, sourceLang: string, targetLang: string) => Promise<string | null>;
2425
speakText: (text: string | null, lang: Language) => void;
25-
_setIsSpeechSupported: (supported: boolean) => void;
26-
_updateAvailableVoices: (lang: Language) => void;
26+
setIsSpeechSupported: (supported: boolean) => void;
27+
updateAvailableVoices: (lang: Language) => void;
2728
setSelectedVoiceURI: (uri: string | null) => void;
2829
}
2930

@@ -44,14 +45,14 @@ export const createAudioSlice: StateCreator<
4445
selectedVoiceURI: null,
4546
translationCache: new Map<string, string>(),
4647

47-
_setIsSpeechSupported: (supported) => {
48+
setIsSpeechSupported: (supported) => {
4849
set((state) => {
4950
state.isSpeechSupported = supported;
5051
if (supported && typeof window !== 'undefined') {
5152
window.speechSynthesis.onvoiceschanged = () => {
52-
get()._updateAvailableVoices(get().passageLanguage);
53+
get().updateAvailableVoices(get().passageLanguage);
5354
};
54-
get()._updateAvailableVoices(get().passageLanguage);
55+
get().updateAvailableVoices(get().passageLanguage);
5556
}
5657
});
5758
},
@@ -263,90 +264,14 @@ export const createAudioSlice: StateCreator<
263264
}
264265
},
265266

266-
_updateAvailableVoices: (lang) => {
267+
updateAvailableVoices: (lang) => {
267268
if (!get().isSpeechSupported || typeof window === 'undefined') return;
268-
const speechLang = SPEECH_LANGUAGES[lang];
269-
const baseLangCode = speechLang.split('-')[0];
270-
271-
const getPlatformInfo = () => {
272-
const ua = navigator.userAgent;
273-
const nav = navigator as Navigator & { userAgentData?: { platform: string } };
274-
if (typeof nav.userAgentData?.platform === 'string') {
275-
const platform = nav.userAgentData.platform.toUpperCase();
276-
return {
277-
isIOS: platform === 'IOS' || platform === 'IPADOS',
278-
isMac: platform === 'MACOS',
279-
isWindows: platform === 'WINDOWS',
280-
platformString: platform,
281-
};
282-
}
283-
// Fallback using userAgent string parsing
284-
const upperUA = ua.toUpperCase();
285-
return {
286-
isIOS: /IPHONE|IPAD|IPOD/.test(upperUA),
287-
isMac: /MACINTOSH|MAC OS X/.test(upperUA),
288-
isWindows: /WIN/.test(upperUA),
289-
platformString: upperUA, // Less reliable, just use UA for filters if needed
290-
};
291-
};
292-
293-
const { isIOS, isMac, isWindows } = getPlatformInfo();
294-
295-
let voices = window.speechSynthesis.getVoices();
296-
297-
// Filter voices based on language
298-
if (isIOS) {
299-
voices = voices.filter((voice) => voice.lang === speechLang);
300-
} else {
301-
voices = voices.filter(
302-
(voice) =>
303-
typeof baseLangCode === 'string' &&
304-
baseLangCode &&
305-
(voice.lang.startsWith(String(baseLangCode) + '-') || voice.lang === String(baseLangCode))
306-
);
307-
}
308-
309-
// Filter out macOS default voices with the pattern "Name (Language (Region))"
310-
voices = voices.filter((voice) => !isMac || !/\s\(.*\s\(.*\)\)$/.test(voice.name));
311-
312-
const processedVoices = voices.map(
313-
(voice): { uri: string; displayName: string; originalLang: string } => {
314-
let displayName = voice.name;
315-
316-
if (isWindows && displayName.startsWith('Microsoft ')) {
317-
const match = displayName.match(/^Microsoft\s+([^\s]+)\s+-/);
318-
if (match && match[1]) {
319-
displayName = match[1];
320-
}
321-
}
322-
// Simplify iOS voice names
323-
else if (isIOS) {
324-
// Try removing everything from the first space and opening parenthesis onwards
325-
const parenIndex = typeof displayName === 'string' ? displayName.indexOf(' (') : -1;
326-
if (parenIndex !== -1) {
327-
displayName = displayName.substring(0, parenIndex);
328-
}
329-
}
330-
331-
return { uri: voice.voiceURI, displayName, originalLang: voice.lang };
332-
}
333-
);
334-
335-
// Deduplication logic: Keep only the first voice for each unique simplified display name
336-
const uniqueVoicesMap = new Map<string, { uri: string; displayName: string }>();
337-
for (const voice of processedVoices) {
338-
if (!uniqueVoicesMap.has(voice.displayName)) {
339-
uniqueVoicesMap.set(voice.displayName, { uri: voice.uri, displayName: voice.displayName });
340-
}
341-
}
342-
const finalUniqueVoices = Array.from(uniqueVoicesMap.values());
343-
269+
const finalUniqueVoices = filterAndFormatVoices(lang);
344270
set((state) => {
345271
state.availableVoices = finalUniqueVoices;
346272
const currentSelectedVoiceAvailable = finalUniqueVoices.some(
347273
(v) => v.uri === state.selectedVoiceURI
348274
);
349-
350275
if (!currentSelectedVoiceAvailable) {
351276
state.selectedVoiceURI = finalUniqueVoices.length > 0 ? finalUniqueVoices[0].uri : null;
352277
}

app/store/quizSlice.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export interface QuizSlice extends BaseSlice {
5454
submitFeedback: (is_good: boolean) => Promise<void>;
5555
resetQuizState: () => void;
5656
resetQuizWithNewData: (newQuizData: PartialQuizData, quizId: number) => void;
57-
_setNextQuizAvailable: (info: NextQuizInfo | null) => void;
57+
setNextQuizAvailable: (info: NextQuizInfo | null) => void;
5858
loadNextQuiz: () => void;
5959

6060
useHoverCredit: () => boolean;
@@ -124,7 +124,7 @@ export const createQuizSlice: StateCreator<
124124
});
125125
},
126126

127-
_setNextQuizAvailable: (info) => {
127+
setNextQuizAvailable: (info) => {
128128
set((state) => {
129129
state.nextQuizAvailable = info;
130130
});
@@ -233,7 +233,7 @@ export const createQuizSlice: StateCreator<
233233
}
234234

235235
if (isPrefetch) {
236-
get()._setNextQuizAvailable({
236+
get().setNextQuizAvailable({
237237
quizData: quizData,
238238
quizId: response.quizId,
239239
});

app/store/regex.test.ts

Lines changed: 0 additions & 63 deletions
This file was deleted.

0 commit comments

Comments
 (0)