Skip to content

Commit 9d05a5a

Browse files
author
HugoFara
committed
test(frontend): add missing unit tests for vocabulary and core modules
Add comprehensive tests for previously uncovered frontend components: - audio_feedback.ts: success/failure sound functions - language_config.ts: language configuration store - multi_word_modal.ts: Alpine.js multi-word expression modal - word_modal.ts: Alpine.js word edit modal component - word_popover.ts: Alpine.js word popover component Extend existing tests: - review_header.ts: event delegation and DOMContentLoaded init - tts_settings.ts: init() and initVoices() functions Improves overall frontend coverage from 64.31% to 66.59% statements.
1 parent dc1f048 commit 9d05a5a

File tree

7 files changed

+2493
-0
lines changed

7 files changed

+2493
-0
lines changed

tests/frontend/admin/tts_settings.test.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,5 +298,133 @@ describe('tts_settings.ts', () => {
298298
expect(component.getVoiceDisplayName(voice)).toBe('Test Voice');
299299
});
300300
});
301+
302+
describe('init', () => {
303+
it('calls autoSetCurrentLanguage, loadSavedSettings, and initVoices', () => {
304+
const config: TTSSettingsConfig = { currentLanguageCode: 'en' };
305+
const component = ttsSettingsApp(config);
306+
307+
const autoSetSpy = vi.spyOn(component, 'autoSetCurrentLanguage');
308+
const loadSpy = vi.spyOn(component, 'loadSavedSettings');
309+
const initVoicesSpy = vi.spyOn(component, 'initVoices');
310+
311+
component.init();
312+
313+
expect(autoSetSpy).toHaveBeenCalled();
314+
expect(loadSpy).toHaveBeenCalled();
315+
expect(initVoicesSpy).toHaveBeenCalled();
316+
});
317+
318+
it('initializes component state in correct order', () => {
319+
mockGetTTSSettingsWithMigration.mockReturnValue({
320+
voice: 'Saved Voice',
321+
rate: 1.2,
322+
pitch: 0.9
323+
});
324+
325+
Object.defineProperty(window, 'location', {
326+
value: { search: '?lang=de' },
327+
writable: true
328+
});
329+
330+
const config: TTSSettingsConfig = { currentLanguageCode: 'en' };
331+
const component = ttsSettingsApp(config);
332+
333+
component.init();
334+
335+
// After init, language should be set from URL
336+
expect(component.currentLanguage).toBe('de');
337+
// Settings should be loaded for the correct language
338+
expect(mockGetTTSSettingsWithMigration).toHaveBeenCalledWith('de');
339+
});
340+
});
341+
342+
describe('initVoices', () => {
343+
it('sets voicesLoading to false when speechSynthesis is undefined', () => {
344+
delete (window as any).speechSynthesis;
345+
346+
const component = ttsSettingsApp();
347+
component.initVoices();
348+
349+
expect(component.voicesLoading).toBe(false);
350+
});
351+
352+
it('loads voices immediately when available', () => {
353+
(window as any).speechSynthesis = {
354+
getVoices: vi.fn().mockReturnValue([
355+
{ name: 'Voice 1', lang: 'en', default: false }
356+
]),
357+
onvoiceschanged: null
358+
};
359+
360+
const component = ttsSettingsApp({ currentLanguageCode: '' });
361+
const populateSpy = vi.spyOn(component, 'populateVoiceList');
362+
363+
component.initVoices();
364+
365+
expect(populateSpy).toHaveBeenCalled();
366+
expect(component.voicesLoading).toBe(false);
367+
});
368+
369+
it('waits for onvoiceschanged when voices not immediately available', () => {
370+
let onvoiceschangedCallback: (() => void) | null = null;
371+
(window as any).speechSynthesis = {
372+
getVoices: vi.fn().mockReturnValue([]),
373+
set onvoiceschanged(cb: (() => void) | null) {
374+
onvoiceschangedCallback = cb;
375+
},
376+
get onvoiceschanged() {
377+
return onvoiceschangedCallback;
378+
}
379+
};
380+
381+
const component = ttsSettingsApp({ currentLanguageCode: '' });
382+
const populateSpy = vi.spyOn(component, 'populateVoiceList');
383+
384+
component.initVoices();
385+
386+
// Should not call populate yet, still loading
387+
expect(populateSpy).not.toHaveBeenCalled();
388+
expect(component.voicesLoading).toBe(true);
389+
390+
// Simulate voices becoming available
391+
if (onvoiceschangedCallback) {
392+
onvoiceschangedCallback();
393+
}
394+
395+
expect(populateSpy).toHaveBeenCalled();
396+
expect(component.voicesLoading).toBe(false);
397+
});
398+
});
399+
400+
describe('saveSettings with empty voice', () => {
401+
it('saves undefined voice when selectedVoice is empty', () => {
402+
const config: TTSSettingsConfig = { currentLanguageCode: 'en' };
403+
const component = ttsSettingsApp(config);
404+
component.selectedVoice = '';
405+
component.rate = 1.0;
406+
component.pitch = 1.0;
407+
408+
component.saveSettings();
409+
410+
expect(mockSaveTTSSettings).toHaveBeenCalledWith('en', {
411+
voice: undefined,
412+
rate: 1.0,
413+
pitch: 1.0
414+
});
415+
});
416+
});
417+
418+
describe('saveSettings error case', () => {
419+
it('logs error when no language is set', () => {
420+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
421+
const component = ttsSettingsApp();
422+
423+
component.saveSettings();
424+
425+
expect(consoleSpy).toHaveBeenCalledWith('Cannot save TTS settings: no language selected');
426+
expect(mockSaveTTSSettings).not.toHaveBeenCalled();
427+
});
428+
});
301429
});
302430
});
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
/**
2+
* Tests for audio_feedback.ts - Audio feedback utility functions.
3+
*/
4+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
5+
import { successSound, failureSound } from '../../../src/frontend/js/shared/utils/audio_feedback';
6+
7+
describe('audio_feedback.ts', () => {
8+
let mockSuccessAudio: HTMLAudioElement;
9+
let mockFailureAudio: HTMLAudioElement;
10+
11+
beforeEach(() => {
12+
vi.clearAllMocks();
13+
document.body.innerHTML = '';
14+
15+
// Create mock audio elements with mocked methods
16+
mockSuccessAudio = document.createElement('audio');
17+
mockSuccessAudio.id = 'success_sound';
18+
mockSuccessAudio.pause = vi.fn();
19+
mockSuccessAudio.play = vi.fn().mockResolvedValue(undefined);
20+
21+
mockFailureAudio = document.createElement('audio');
22+
mockFailureAudio.id = 'failure_sound';
23+
mockFailureAudio.pause = vi.fn();
24+
mockFailureAudio.play = vi.fn().mockResolvedValue(undefined);
25+
});
26+
27+
afterEach(() => {
28+
document.body.innerHTML = '';
29+
});
30+
31+
describe('successSound', () => {
32+
it('plays success sound when both audio elements exist', async () => {
33+
document.body.appendChild(mockSuccessAudio);
34+
document.body.appendChild(mockFailureAudio);
35+
36+
await successSound();
37+
38+
expect(mockSuccessAudio.pause).toHaveBeenCalled();
39+
expect(mockFailureAudio.pause).toHaveBeenCalled();
40+
expect(mockSuccessAudio.play).toHaveBeenCalled();
41+
});
42+
43+
it('pauses failure audio before playing success', async () => {
44+
document.body.appendChild(mockSuccessAudio);
45+
document.body.appendChild(mockFailureAudio);
46+
const callOrder: string[] = [];
47+
mockSuccessAudio.pause = vi.fn(() => callOrder.push('success.pause'));
48+
mockFailureAudio.pause = vi.fn(() => callOrder.push('failure.pause'));
49+
mockSuccessAudio.play = vi.fn(() => {
50+
callOrder.push('success.play');
51+
return Promise.resolve();
52+
});
53+
54+
await successSound();
55+
56+
expect(callOrder).toContain('failure.pause');
57+
expect(callOrder.indexOf('failure.pause')).toBeLessThan(callOrder.indexOf('success.play'));
58+
});
59+
60+
it('returns resolved promise when success audio element exists', async () => {
61+
document.body.appendChild(mockSuccessAudio);
62+
63+
const result = successSound();
64+
65+
await expect(result).resolves.toBeUndefined();
66+
});
67+
68+
it('returns resolved promise when success audio element does not exist', async () => {
69+
// No audio elements in DOM
70+
71+
const result = successSound();
72+
73+
await expect(result).resolves.toBeUndefined();
74+
});
75+
76+
it('handles only success audio element existing', async () => {
77+
document.body.appendChild(mockSuccessAudio);
78+
79+
await successSound();
80+
81+
expect(mockSuccessAudio.pause).toHaveBeenCalled();
82+
expect(mockSuccessAudio.play).toHaveBeenCalled();
83+
});
84+
85+
it('handles only failure audio element existing', async () => {
86+
document.body.appendChild(mockFailureAudio);
87+
88+
const result = successSound();
89+
90+
expect(mockFailureAudio.pause).toHaveBeenCalled();
91+
await expect(result).resolves.toBeUndefined();
92+
});
93+
94+
it('propagates play promise rejection', async () => {
95+
const playError = new Error('Playback failed');
96+
mockSuccessAudio.play = vi.fn().mockRejectedValue(playError);
97+
document.body.appendChild(mockSuccessAudio);
98+
99+
await expect(successSound()).rejects.toThrow('Playback failed');
100+
});
101+
});
102+
103+
describe('failureSound', () => {
104+
it('plays failure sound when both audio elements exist', async () => {
105+
document.body.appendChild(mockSuccessAudio);
106+
document.body.appendChild(mockFailureAudio);
107+
108+
await failureSound();
109+
110+
expect(mockSuccessAudio.pause).toHaveBeenCalled();
111+
expect(mockFailureAudio.pause).toHaveBeenCalled();
112+
expect(mockFailureAudio.play).toHaveBeenCalled();
113+
});
114+
115+
it('pauses success audio before playing failure', async () => {
116+
document.body.appendChild(mockSuccessAudio);
117+
document.body.appendChild(mockFailureAudio);
118+
const callOrder: string[] = [];
119+
mockSuccessAudio.pause = vi.fn(() => callOrder.push('success.pause'));
120+
mockFailureAudio.pause = vi.fn(() => callOrder.push('failure.pause'));
121+
mockFailureAudio.play = vi.fn(() => {
122+
callOrder.push('failure.play');
123+
return Promise.resolve();
124+
});
125+
126+
await failureSound();
127+
128+
expect(callOrder).toContain('success.pause');
129+
expect(callOrder.indexOf('success.pause')).toBeLessThan(callOrder.indexOf('failure.play'));
130+
});
131+
132+
it('returns resolved promise when failure audio element exists', async () => {
133+
document.body.appendChild(mockFailureAudio);
134+
135+
const result = failureSound();
136+
137+
await expect(result).resolves.toBeUndefined();
138+
});
139+
140+
it('returns resolved promise when failure audio element does not exist', async () => {
141+
// No audio elements in DOM
142+
143+
const result = failureSound();
144+
145+
await expect(result).resolves.toBeUndefined();
146+
});
147+
148+
it('handles only failure audio element existing', async () => {
149+
document.body.appendChild(mockFailureAudio);
150+
151+
await failureSound();
152+
153+
expect(mockFailureAudio.pause).toHaveBeenCalled();
154+
expect(mockFailureAudio.play).toHaveBeenCalled();
155+
});
156+
157+
it('handles only success audio element existing', async () => {
158+
document.body.appendChild(mockSuccessAudio);
159+
160+
const result = failureSound();
161+
162+
expect(mockSuccessAudio.pause).toHaveBeenCalled();
163+
await expect(result).resolves.toBeUndefined();
164+
});
165+
166+
it('propagates play promise rejection', async () => {
167+
const playError = new Error('Playback failed');
168+
mockFailureAudio.play = vi.fn().mockRejectedValue(playError);
169+
document.body.appendChild(mockFailureAudio);
170+
171+
await expect(failureSound()).rejects.toThrow('Playback failed');
172+
});
173+
});
174+
175+
describe('interaction between success and failure sounds', () => {
176+
it('calling success then failure stops success and plays failure', async () => {
177+
document.body.appendChild(mockSuccessAudio);
178+
document.body.appendChild(mockFailureAudio);
179+
180+
await successSound();
181+
vi.clearAllMocks();
182+
183+
await failureSound();
184+
185+
expect(mockSuccessAudio.pause).toHaveBeenCalled();
186+
expect(mockFailureAudio.play).toHaveBeenCalled();
187+
});
188+
189+
it('calling failure then success stops failure and plays success', async () => {
190+
document.body.appendChild(mockSuccessAudio);
191+
document.body.appendChild(mockFailureAudio);
192+
193+
await failureSound();
194+
vi.clearAllMocks();
195+
196+
await successSound();
197+
198+
expect(mockFailureAudio.pause).toHaveBeenCalled();
199+
expect(mockSuccessAudio.play).toHaveBeenCalled();
200+
});
201+
});
202+
});

0 commit comments

Comments
 (0)