Files
advanced-camera-card/tests/card-controller/microphone-manager.test.ts
T
Filip Pytlounanddermotduffy 3ee6b6059e feat: Add microphone audio processing constraints (#2708)
## Summary

- add optional microphone constraints for echo cancellation, noise
suppression, automatic gain control, and channel count
- request configured values as non-mandatory `ideal` constraints
- expose privacy-safe microphone capabilities, requested constraints,
and applied settings in card diagnostics
- document the new configuration and add schema, microphone manager, and
diagnostics tests

## Motivation

The card currently calls `getUserMedia()` with `audio: true`. This
leaves echo cancellation, noise suppression, automatic gain control, and
channel count implicit.

Browser and device behavior differs. Explicit processing defaults can
regress microphone gain or amplify noise on some devices. This change
therefore keeps all processing constraints optional and configurable.

## Configuration

```yaml
live:
  microphone:
    constraints:
      echo_cancellation: true
      noise_suppression: true
      auto_gain_control: false
      channel_count: 1
```

Configured values use `ideal` constraints. A browser can ignore
unsupported values. Card diagnostics show the browser capabilities, the
requested constraints, and the reported applied settings.

## Backward compatibility

- existing configurations still use `audio: true`
- no audio-processing defaults are added
- explicit `false` values are preserved
- diagnostic output excludes device and group identifiers

## Validation

- focused microphone, schema, and diagnostics tests: 46 passed
- full test suite: 7,177 passed
- lint passed
- format check passed
- typecheck passed
- unused-code check passed
- production build passed

The optional constraints were also tested successfully with an iOS Home
Assistant Companion client and a go2rtc-based full-duplex intercom. This
is a client microphone-processing change only. It does not add backend
audio denoise.

---------

Co-authored-by: dermotduffy <dermot.duffy@gmail.com>
2026-08-24 07:38:33 -07:00

852 lines
28 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import {
MicrophoneManager,
MicrophoneNotSupportedError,
} from '../../src/card-controller/microphone-manager';
import type { MicrophoneState } from '../../src/card-controller/types';
import { createConfig } from '../config/test-utils';
import { createCardAPI } from '../test-utils';
const navigatorMock: Navigator = {
...mock<Navigator>(),
mediaDevices: {
...mock<MediaDevices>(),
getUserMedia: vi.fn(),
},
};
const medialessNavigatorMock: Navigator = {
...navigatorMock,
// Some browser will set mediaDevices to undefined when access over http.
mediaDevices: undefined as unknown as MediaDevices,
};
// @vitest-environment jsdom
describe('MicrophoneManager', () => {
beforeEach(() => {
vi.stubGlobal('navigator', navigatorMock);
});
afterEach(() => {
vi.resetAllMocks();
vi.unstubAllGlobals();
});
const createMockStream = (mute?: boolean): MediaStream => {
const stream = mock<MediaStream>();
const track = mock<MediaStreamTrack>();
track.enabled = !mute;
stream.getTracks.mockImplementation(() => [track]);
stream.getAudioTracks.mockImplementation(() => [track]);
return stream;
};
// Streams from `createMockStream` carry exactly one track.
const getTrack = (stream: MediaStream): MediaStreamTrack => stream.getTracks()[0];
const createDeferredStream = (): {
promise: Promise<MediaStream>;
resolve: (stream: MediaStream) => void;
reject: (error: Error) => void;
} => {
let resolveStream: ((stream: MediaStream) => void) | null = null;
let rejectStream: ((error: Error) => void) | null = null;
const promise = new Promise<MediaStream>((resolve, reject) => {
resolveStream = resolve;
rejectStream = reject;
});
return {
promise,
resolve: (stream: MediaStream) => resolveStream?.(stream),
reject: (error: Error) => rejectStream?.(error),
};
};
it('should be muted on creation', () => {
const manager = new MicrophoneManager(createCardAPI());
expect(manager).toBeTruthy();
expect(manager.isMuted()).toBeTruthy();
});
it('should have no stream before connecting', () => {
const manager = new MicrophoneManager(createCardAPI());
expect(manager.getStream()).toBeNull();
});
it('should connect while transmission is active', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
const stream = createMockStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream);
manager.setTransmissionActive(true);
expect(await manager.connect()).toBe(true);
expect(manager.isConnected()).toBeTruthy();
expect(manager.getStream()).toBe(stream);
expect(manager.isMuted()).toBeTruthy();
expect(api.getCardElementManager().update).toHaveBeenCalled();
expect(navigatorMock.mediaDevices.getUserMedia).toHaveBeenCalledWith({
audio: true,
video: false,
});
});
it('should request configured ideal audio constraints', async () => {
const api = createCardAPI();
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
live: {
microphone: {
audio_processing: {
echo_cancellation: true,
noise_suppression: false,
auto_gain_control: false,
channel_count: 1,
},
},
},
}),
);
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
createMockStream(),
);
const manager = new MicrophoneManager(api);
manager.setTransmissionActive(true);
await manager.connect();
expect(navigatorMock.mediaDevices.getUserMedia).toHaveBeenCalledWith({
audio: {
autoGainControl: { ideal: false },
channelCount: { ideal: 1 },
echoCancellation: { ideal: true },
noiseSuppression: { ideal: false },
},
video: false,
});
});
it('should request no constraint for an audio processing option left to the browser', async () => {
const api = createCardAPI();
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
live: {
microphone: {
audio_processing: {
auto_gain_control: 'auto',
echo_cancellation: 'auto',
noise_suppression: true,
},
},
},
}),
);
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
createMockStream(),
);
const manager = new MicrophoneManager(api);
manager.setTransmissionActive(true);
await manager.connect();
expect(navigatorMock.mediaDevices.getUserMedia).toHaveBeenCalledWith({
audio: {
noiseSuppression: { ideal: true },
},
video: false,
});
});
it('should expose microphone diagnostics without device identifiers', async () => {
const api = createCardAPI();
const stream = createMockStream();
const track = getTrack(stream);
vi.mocked(track.getCapabilities).mockReturnValue({
echoCancellation: [true, false],
deviceId: 'secret-device',
});
vi.mocked(track.getSettings).mockReturnValue({
echoCancellation: true,
deviceId: 'secret-device',
groupId: 'secret-group',
});
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream);
const manager = new MicrophoneManager(api);
manager.setTransmissionActive(true);
await manager.connect();
manager.setTransmissionActive(false);
expect(manager.getDiagnostics()).toEqual({
capabilities: {
echoCancellation: [true, false],
},
settings: {
echoCancellation: true,
},
});
});
it('should omit a diagnostic that holds nothing but device identifiers', async () => {
const api = createCardAPI();
const stream = createMockStream();
const track = getTrack(stream);
vi.mocked(track.getCapabilities).mockReturnValue({ deviceId: 'secret-device' });
vi.mocked(track.getSettings).mockReturnValue({ echoCancellation: true });
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream);
const manager = new MicrophoneManager(api);
manager.setTransmissionActive(true);
await manager.connect();
expect(manager.getDiagnostics()).toEqual({
settings: { echoCancellation: true },
});
});
it('should support a browser without the capabilities method', async () => {
const api = createCardAPI();
const stream = createMockStream();
const track = getTrack(stream);
track.getCapabilities = undefined as unknown as MediaStreamTrack['getCapabilities'];
vi.mocked(track.getSettings).mockReturnValue({});
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream);
const manager = new MicrophoneManager(api);
manager.setTransmissionActive(true);
expect(await manager.connect()).toBeTruthy();
expect(manager.getDiagnostics()).toBeNull();
});
it('should report no diagnostics for a stream without an audio track', async () => {
const api = createCardAPI();
const stream = createMockStream();
vi.mocked(stream.getAudioTracks).mockImplementation(() => []);
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream);
const manager = new MicrophoneManager(api);
manager.setTransmissionActive(true);
expect(await manager.connect()).toBeTruthy();
expect(manager.getDiagnostics()).toBeNull();
});
it('should release a stream that connects without active transmission', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
const stream = createMockStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream);
expect(await manager.connect()).toBe(false);
expect(manager.isConnected()).toBeFalsy();
expect(manager.isForbidden()).toBeFalsy();
expect(getTrack(stream).stop).toHaveBeenCalled();
expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith(
expect.objectContaining({
microphone: expect.objectContaining({ connected: false, muted: true }),
}),
);
});
it('should hold a muted stream without transmission when always connected', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
live: {
microphone: {
always_connected: true,
},
},
}),
);
const stream = createMockStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream);
await manager.connect();
expect(manager.isConnected()).toBeTruthy();
expect(manager.isMuted()).toBeTruthy();
expect(getTrack(stream).stop).not.toHaveBeenCalled();
manager.mute();
expect(manager.isConnected()).toBeTruthy();
expect(manager.isMuted()).toBeTruthy();
expect(getTrack(stream).stop).not.toHaveBeenCalled();
});
it('should be unsupported without browser support', () => {
vi.stubGlobal('navigator', medialessNavigatorMock);
const manager = new MicrophoneManager(createCardAPI());
expect(manager.isSupported()).toBeFalsy();
});
it('should not connect when not supported', async () => {
vi.stubGlobal('navigator', medialessNavigatorMock);
const api = createCardAPI();
const manager = new MicrophoneManager(api);
const stream = createMockStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream);
await expect(manager.connect()).rejects.toThrow(MicrophoneNotSupportedError);
expect(manager.isConnected()).toBeFalsy();
});
it('should be forbidden when permission denied', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockRejectedValue(new Error());
await expect(manager.connect()).rejects.toThrow(Error);
expect(manager.isConnected()).toBeFalsy();
expect(manager.isForbidden()).toBeTruthy();
expect(api.getCardElementManager().update).toHaveBeenCalled();
});
it('should mute and unmute while transmission is active', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
createMockStream(),
);
manager.setTransmissionActive(true);
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1);
await manager.connect();
expect(manager.isMuted()).toBeTruthy();
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(2);
await manager.unmute();
expect(manager.isMuted()).toBeFalsy();
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(3);
manager.mute();
expect(manager.isMuted()).toBeTruthy();
expect(manager.isConnected()).toBeTruthy();
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(4);
// Unmuting again reuses the held stream rather than re-requesting the
// device.
await manager.unmute();
expect(manager.isMuted()).toBeFalsy();
expect(navigatorMock.mediaDevices.getUserMedia).toHaveBeenCalledTimes(1);
});
it('should not unmute without active transmission', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
createMockStream(),
);
await manager.unmute();
expect(navigatorMock.mediaDevices.getUserMedia).not.toHaveBeenCalled();
expect(manager.isConnected()).toBeFalsy();
expect(manager.isMuted()).toBeTruthy();
expect(api.getCardElementManager().update).not.toHaveBeenCalled();
});
it('should not unmute without active transmission when always connected', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
live: {
microphone: {
always_connected: true,
},
},
}),
);
const stream = createMockStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream);
await manager.connect();
expect(manager.isMuted()).toBeTruthy();
await manager.unmute();
expect(manager.isConnected()).toBeTruthy();
expect(manager.isMuted()).toBeTruthy();
});
it('should not unmute when microphone forbidden', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockRejectedValue(new Error());
manager.setTransmissionActive(true);
await expect(manager.connect()).rejects.toThrow(Error);
expect(manager.isMuted()).toBeTruthy();
await manager.unmute();
expect(manager.isForbidden()).toBeTruthy();
expect(manager.isMuted()).toBeTruthy();
});
it('should not unmute when not supported', async () => {
vi.stubGlobal('navigator', medialessNavigatorMock);
const manager = new MicrophoneManager(createCardAPI());
await manager.unmute();
expect(manager.isConnected()).toBeFalsy();
expect(manager.isMuted()).toBeTruthy();
});
it('should connect on unmute while transmission is active', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
createMockStream(),
);
manager.setTransmissionActive(true);
expect(manager.isConnected()).toBeFalsy();
await manager.unmute();
expect(manager.isConnected()).toBeTruthy();
expect(manager.isMuted()).toBeFalsy();
expect(api.getCardElementManager().update).toHaveBeenCalled();
});
describe('should follow transmission state', () => {
it('should release the stream and reset mute when transmission ends', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
const stream = createMockStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream);
manager.setTransmissionActive(true);
await manager.connect();
await manager.unmute();
expect(manager.isMuted()).toBeFalsy();
manager.setTransmissionActive(false);
expect(manager.isConnected()).toBeFalsy();
expect(getTrack(stream).stop).toHaveBeenCalled();
expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith(
expect.objectContaining({
microphone: expect.objectContaining({ connected: false, muted: true }),
}),
);
// The next transmission must not start with a hot microphone: the desired mute
// was reset when the previous transmission ended.
const newStream = createMockStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(newStream);
manager.setTransmissionActive(true);
await manager.connect();
expect(manager.isConnected()).toBeTruthy();
expect(manager.isMuted()).toBeTruthy();
});
it('should keep the stream muted when transmission ends and always connected', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
live: {
microphone: {
always_connected: true,
},
},
}),
);
const stream = createMockStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream);
manager.setTransmissionActive(true);
await manager.connect();
await manager.unmute();
expect(manager.isMuted()).toBeFalsy();
manager.setTransmissionActive(false);
expect(manager.isConnected()).toBeTruthy();
expect(manager.isMuted()).toBeTruthy();
expect(getTrack(stream).stop).not.toHaveBeenCalled();
});
it('should ignore the end of a transmission that never started', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
live: {
microphone: {
always_connected: true,
},
},
}),
);
const stream = createMockStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream);
await manager.connect();
expect(manager.isConnected()).toBeTruthy();
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1);
manager.setTransmissionActive(false);
expect(manager.isConnected()).toBeTruthy();
expect(getTrack(stream).stop).not.toHaveBeenCalled();
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1);
});
it('should ignore a transmission report that changes nothing', () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
manager.setTransmissionActive(true);
manager.setTransmissionActive(true);
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1);
});
});
describe('should guard in-flight connections', () => {
it('should release a stream whose connect completes after a mute', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
const stream = createMockStream();
const deferred = createDeferredStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockReturnValue(
deferred.promise,
);
const connectPromise = manager.connect();
manager.mute();
deferred.resolve(stream);
await connectPromise;
expect(manager.isConnected()).toBeFalsy();
expect(getTrack(stream).stop).toHaveBeenCalled();
});
it('should release a stream whose connect completes after transmission ends', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
const stream = createMockStream();
const deferred = createDeferredStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockReturnValue(
deferred.promise,
);
manager.setTransmissionActive(true);
const connectPromise = manager.connect();
manager.setTransmissionActive(false);
deferred.resolve(stream);
await connectPromise;
expect(manager.isConnected()).toBeFalsy();
expect(getTrack(stream).stop).toHaveBeenCalled();
});
it('should not install a stream whose connect completes after the held stream is released', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
const held = createMockStream();
const late = createMockStream();
const deferred = createDeferredStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia)
.mockResolvedValueOnce(held)
.mockReturnValueOnce(deferred.promise);
manager.setTransmissionActive(true);
await manager.connect();
const connectPromise = manager.connect();
// Releasing the held stream also invalidates the connect still in
// flight.
manager.setTransmissionActive(false);
deferred.resolve(late);
await connectPromise;
expect(manager.isConnected()).toBeFalsy();
expect(getTrack(held).stop).toHaveBeenCalled();
expect(getTrack(late).stop).toHaveBeenCalled();
});
it('should keep only the newest of overlapping connects', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
const streamA = createMockStream();
const streamB = createMockStream();
const deferredA = createDeferredStream();
const deferredB = createDeferredStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia)
.mockReturnValueOnce(deferredA.promise)
.mockReturnValueOnce(deferredB.promise);
manager.setTransmissionActive(true);
const connectA = manager.connect();
const connectB = manager.connect();
deferredA.resolve(streamA);
deferredB.resolve(streamB);
expect(await connectA).toBe(false);
expect(await connectB).toBe(true);
expect(manager.isConnected()).toBeTruthy();
expect(manager.getStream()).toBe(streamB);
expect(getTrack(streamA).stop).toHaveBeenCalled();
expect(getTrack(streamB).stop).not.toHaveBeenCalled();
});
it('should not mark forbidden on a stale connect rejection', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
const streamB = createMockStream();
const deferredA = createDeferredStream();
const deferredB = createDeferredStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia)
.mockReturnValueOnce(deferredA.promise)
.mockReturnValueOnce(deferredB.promise);
manager.setTransmissionActive(true);
const connectA = manager.connect();
const connectB = manager.connect();
deferredA.reject(new Error());
deferredB.resolve(streamB);
await expect(connectA).rejects.toThrow(Error);
await connectB;
expect(manager.isForbidden()).toBeFalsy();
expect(manager.isConnected()).toBeTruthy();
expect(manager.getStream()).toBe(streamB);
});
it('should stop the replaced stream when connecting over an existing stream', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
const streamA = createMockStream();
const streamB = createMockStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia)
.mockResolvedValueOnce(streamA)
.mockResolvedValueOnce(streamB);
manager.setTransmissionActive(true);
await manager.connect();
expect(manager.getStream()).toBe(streamA);
await manager.connect();
expect(manager.getStream()).toBe(streamB);
expect(getTrack(streamA).stop).toHaveBeenCalled();
expect(getTrack(streamB).stop).not.toHaveBeenCalled();
});
});
describe('should handle the device disappearing', () => {
const endTrack = (stream: MediaStream): void => {
const track = getTrack(stream);
vi.mocked(track.addEventListener)
.mock.calls.filter(([type]) => type === 'ended')
.forEach(([, listener]) => (listener as EventListener)(new Event('ended')));
};
it('should release a stream whose track ends', async () => {
const stream = createMockStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream);
const api = createCardAPI();
const manager = new MicrophoneManager(api);
manager.setTransmissionActive(true);
await manager.connect();
expect(manager.isConnected()).toBeTruthy();
endTrack(stream);
expect(manager.isConnected()).toBeFalsy();
expect(manager.getStream()).toBeNull();
});
it('should stop listening to a stream that has been replaced', async () => {
const streamA = createMockStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(streamA);
const api = createCardAPI();
const manager = new MicrophoneManager(api);
manager.setTransmissionActive(true);
await manager.connect();
const streamB = createMockStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(streamB);
await manager.connect();
const track = getTrack(streamA);
const added = vi
.mocked(track.addEventListener)
.mock.calls.find(([type]) => type === 'ended');
const removed = vi
.mocked(track.removeEventListener)
.mock.calls.find(([type]) => type === 'ended');
expect(added).toBeDefined();
expect(removed?.[1]).toBe(added?.[1]);
expect(manager.getStream()).toBe(streamB);
});
});
describe('should require initialization', async () => {
it('should require when configured and supported', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
createMockStream(),
);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
live: {
microphone: {
always_connected: true,
},
},
}),
);
await manager.connect();
expect(manager.shouldConnectOnInitialization()).toBeTruthy();
});
it('should not require when configured but not supported', async () => {
vi.stubGlobal('navigator', medialessNavigatorMock);
const api = createCardAPI();
const manager = new MicrophoneManager(api);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
live: {
microphone: {
always_connected: true,
},
},
}),
);
await expect(manager.connect()).rejects.toThrow(MicrophoneNotSupportedError);
expect(manager.shouldConnectOnInitialization()).toBeFalsy();
});
it('should not require when neither configured nor supported', async () => {
vi.stubGlobal('navigator', medialessNavigatorMock);
const api = createCardAPI();
const manager = new MicrophoneManager(api);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
await expect(manager.connect()).rejects.toThrow(MicrophoneNotSupportedError);
expect(manager.shouldConnectOnInitialization()).toBeFalsy();
});
});
it('should initialize', () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
manager.initialize();
expect(api.getConditionStateManager().setState).toHaveBeenCalledWith({
microphone: { connected: false, muted: true, forbidden: false, stream: null },
});
});
it('should set state', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
const stream = createMockStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream);
expect(api.getConditionStateManager().setState).not.toHaveBeenCalled();
manager.setTransmissionActive(true);
await manager.connect();
let expectedState: MicrophoneState = {
forbidden: false,
stream: stream,
connected: true,
muted: true,
};
expect(manager.getState()).toEqual(expectedState);
expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith(
expect.objectContaining({
microphone: expectedState,
}),
);
await manager.unmute();
expectedState = {
forbidden: false,
stream: stream,
connected: true,
muted: false,
};
expect(manager.getState()).toEqual(expectedState);
expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith(
expect.objectContaining({
microphone: expectedState,
}),
);
manager.mute();
expectedState = {
forbidden: false,
stream: stream,
connected: true,
muted: true,
};
expect(manager.getState()).toEqual(expectedState);
expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith(
expect.objectContaining({
microphone: expectedState,
}),
);
manager.setTransmissionActive(false);
expectedState = {
forbidden: false,
stream: null,
connected: false,
muted: true,
};
expect(manager.getState()).toEqual(expectedState);
expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith(
expect.objectContaining({
microphone: expectedState,
}),
);
});
});