import { Injector, runInInjectionContext } from '@angular/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { VoiceAudioDeviceService } from './voice-audio-device.service'; import { VoiceActivityService, VoiceConnectionFacade, VoicePlaybackService } from '../../../voice-connection'; import { saveVoiceSettingsToStorage, loadVoiceSettingsFromStorage } from '../../infrastructure/util/voice-settings-storage.util'; const BUILT_IN_MIC = { kind: 'audioinput', deviceId: 'default', label: 'Built-in Mic' }; const HEADSET_MIC = { kind: 'audioinput', deviceId: 'headset', label: 'Headset' }; const SPEAKERS = { kind: 'audiooutput', deviceId: 'speakers', label: 'Speakers' }; const HDMI_OUTPUT = { kind: 'audiooutput', deviceId: 'hdmi', label: 'HDMI Output' }; interface DeviceHarness { service: VoiceAudioDeviceService; voiceConnection: { isVoiceConnected: ReturnType; switchInputDevice: ReturnType; }; voicePlayback: { applyOutputDevice: ReturnType }; voiceActivity: { refreshLocalMicStream: ReturnType }; emitDeviceChange: () => Promise; setDevices: (devices: { kind: string; deviceId: string; label: string }[]) => void; } describe('VoiceAudioDeviceService', () => { let harness: DeviceHarness; beforeEach(() => { harness = createHarness(); }); afterEach(() => { vi.unstubAllGlobals(); }); it('lists the microphones and speakers the browser reports', async () => { await harness.service.refreshDevices(); expect(harness.service.inputDevices().map((device) => device.deviceId)).toEqual(['default', 'headset']); expect(harness.service.outputDevices().map((device) => device.deviceId)).toEqual(['speakers']); }); it('switches the live microphone instead of rejoining voice', async () => { harness.voiceConnection.isVoiceConnected.mockReturnValue(true); await harness.service.selectInputDevice('headset'); expect(harness.voiceConnection.switchInputDevice).toHaveBeenCalledWith('headset'); expect(harness.service.selectedInputDeviceId()).toBe('headset'); expect(loadVoiceSettingsFromStorage().inputDevice).toBe('headset'); }); it('keeps the local speaking indicator on the new microphone stream', async () => { const replacementStream = { id: 'replacement' } as MediaStream; harness.voiceConnection.isVoiceConnected.mockReturnValue(true); harness.voiceConnection.switchInputDevice.mockResolvedValue(replacementStream); await harness.service.selectInputDevice('headset'); expect(harness.voiceActivity.refreshLocalMicStream).toHaveBeenCalledWith(replacementStream); }); it('applies a speaker choice to the live playback pipelines', async () => { await harness.service.selectOutputDevice('speakers'); expect(harness.voicePlayback.applyOutputDevice).toHaveBeenCalledWith('speakers'); expect(loadVoiceSettingsFromStorage().outputDevice).toBe('speakers'); }); it('falls back to the system default when the selected microphone is unplugged', async () => { harness.voiceConnection.isVoiceConnected.mockReturnValue(true); await harness.service.selectInputDevice('headset'); harness.voiceConnection.switchInputDevice.mockClear(); harness.setDevices([BUILT_IN_MIC, SPEAKERS]); await harness.emitDeviceChange(); expect(harness.voiceConnection.switchInputDevice).toHaveBeenCalledWith(''); expect(harness.service.selectedInputDeviceId()).toBe(''); expect(harness.service.deviceNotice()).toBe('voice.devices.inputFellBack'); }); it('falls back to the system default when the selected speaker is unplugged', async () => { await harness.service.selectOutputDevice('speakers'); harness.voicePlayback.applyOutputDevice.mockClear(); harness.setDevices([BUILT_IN_MIC, HDMI_OUTPUT]); await harness.emitDeviceChange(); expect(harness.voicePlayback.applyOutputDevice).toHaveBeenCalledWith(''); expect(harness.service.deviceNotice()).toBe('voice.devices.outputFellBack'); }); it('does not report a speaker fallback when the browser lists no speakers at all', async () => { await harness.service.selectOutputDevice('speakers'); harness.voicePlayback.applyOutputDevice.mockClear(); harness.setDevices([BUILT_IN_MIC]); await harness.emitDeviceChange(); expect(harness.voicePlayback.applyOutputDevice).not.toHaveBeenCalled(); expect(harness.service.deviceNotice()).toBeNull(); }); it('leaves a selection alone when a device list arrives empty', async () => { harness.voiceConnection.isVoiceConnected.mockReturnValue(true); await harness.service.selectInputDevice('headset'); harness.voiceConnection.switchInputDevice.mockClear(); harness.setDevices([]); await harness.emitDeviceChange(); expect(harness.voiceConnection.switchInputDevice).not.toHaveBeenCalled(); expect(harness.service.selectedInputDeviceId()).toBe('headset'); expect(harness.service.deviceNotice()).toBeNull(); }); it('records a device choice made before joining voice without capturing', async () => { harness.voiceConnection.isVoiceConnected.mockReturnValue(false); await harness.service.selectInputDevice('headset'); expect(harness.voiceConnection.switchInputDevice).not.toHaveBeenCalled(); expect(loadVoiceSettingsFromStorage().inputDevice).toBe('headset'); }); }); function createHarness(): DeviceHarness { saveVoiceSettingsToStorage({ inputDevice: '', outputDevice: '' }); let devices = [ BUILT_IN_MIC, HEADSET_MIC, SPEAKERS ]; const listeners: (() => void)[] = []; vi.stubGlobal('navigator', { mediaDevices: { enumerateDevices: vi.fn(async () => devices), addEventListener: vi.fn((type: string, listener: () => void) => { if (type === 'devicechange') { listeners.push(listener); } }), removeEventListener: vi.fn() } }); const voiceConnection = { isVoiceConnected: vi.fn(() => false), switchInputDevice: vi.fn(async () => null) }; const voicePlayback = { applyOutputDevice: vi.fn() }; const voiceActivity = { refreshLocalMicStream: vi.fn() }; const injector = Injector.create({ providers: [ { provide: VoiceConnectionFacade, useValue: voiceConnection }, { provide: VoicePlaybackService, useValue: voicePlayback }, { provide: VoiceActivityService, useValue: voiceActivity } ] }); const service = runInInjectionContext(injector, () => new VoiceAudioDeviceService()); return { service, voiceConnection, voicePlayback, voiceActivity, emitDeviceChange: async () => { listeners.forEach((listener) => listener()); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); }, setDevices: (next) => { devices = next; } }; }