Files
advanced-camera-card/tests/card-controller/microphone-manager.test.ts
T
Dermot Duffy abcba884e5 feat: Add 'call' support to improve 2-way audio experience (#2486)
Draws significant inspiration (and direct styling) from
https://github.com/dermotduffy/advanced-camera-card/pull/2447 . Thank
you @Maudfer !

BREAKING CHANGE:

The microphone condition previously bundled two unrelated signals —
whether a two-way-audio session was connected and whether the microphone
was muted. Connection state is now its own dedicated call condition, and
microphone is reserved purely for mute state. Configs are upgraded
automatically (the card rewrites affected conditions under overrides,
elements, and automations). If you maintain config by hand, convert as
follows:

If you only used connected:

# Before
```yaml
condition: microphone
connected: true
```

# After
```yaml
condition: call
call: true
```
If you used both connected and muted — they must be split into two
conditions, since they no longer live together:

# Before
```yaml
condition: microphone
connected: true
muted: false
```

# After
```yaml
condition: and
conditions:
  - condition: call
    call: true
  - condition: microphone
    muted: false
```
2026-06-30 17:45:13 -07:00

379 lines
11 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 { MicrophoneState } from '../../src/card-controller/types';
import { createCardAPI, createConfig } 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);
vi.useRealTimers();
});
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]);
return stream;
};
it('should be muted on creation', () => {
const manager = new MicrophoneManager(createCardAPI());
expect(manager).toBeTruthy();
expect(manager.isMuted()).toBeTruthy();
});
it('should be undefined without creation', () => {
const manager = new MicrophoneManager(createCardAPI());
expect(manager.getStream()).toBeUndefined();
});
it('should connect', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
const stream = createMockStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream);
await manager.connect();
expect(manager.isConnected()).toBeTruthy();
expect(manager.getStream()).toBe(stream);
expect(manager.isMuted()).toBeTruthy();
expect(api.getCardElementManager().update).toBeCalled();
});
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).toBeCalled();
});
it('should mute and unmute', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
createMockStream(),
);
await manager.connect();
expect(manager.isMuted()).toBeTruthy();
expect(api.getCardElementManager().update).toBeCalledTimes(1);
manager.mute();
expect(manager.isMuted()).toBeTruthy();
expect(api.getCardElementManager().update).toBeCalledTimes(2);
await manager.unmute();
expect(manager.isMuted()).toBeFalsy();
expect(api.getCardElementManager().update).toBeCalledTimes(3);
});
it('should not unmute when microphone forbidden', 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.isMuted()).toBeTruthy();
expect(api.getCardElementManager().update).toBeCalledTimes(1);
await manager.unmute();
expect(manager.isMuted()).toBeTruthy();
expect(api.getCardElementManager().update).toBeCalledTimes(1);
});
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', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
createMockStream(),
);
expect(manager.isConnected()).toBeFalsy();
await manager.unmute();
expect(manager.isConnected()).toBeTruthy();
expect(manager.isMuted()).toBeFalsy();
expect(api.getCardElementManager().update).toBeCalled();
});
it('should disconnect', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
createMockStream(),
);
await manager.connect();
expect(manager.isConnected()).toBeTruthy();
expect(api.getCardElementManager().update).toBeCalledTimes(1);
manager.disconnect();
expect(manager.isConnected()).toBeFalsy();
expect(api.getCardElementManager().update).toBeCalledTimes(2);
});
it('should automatically disconnect', async () => {
vi.useFakeTimers();
const disconnectSeconds = 10;
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: false,
disconnect_seconds: disconnectSeconds,
},
},
}),
);
await manager.connect();
expect(manager.isConnected()).toBeTruthy();
expect(api.getCardElementManager().update).toBeCalledTimes(1);
vi.advanceTimersByTime(disconnectSeconds * 1000);
expect(manager.isConnected()).toBeFalsy();
expect(api.getCardElementManager().update).toBeCalledTimes(2);
});
it('should not automatically disconnect when always connected', async () => {
vi.useFakeTimers();
const disconnectSeconds = 10;
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,
disconnect_seconds: disconnectSeconds,
},
},
}),
);
await manager.connect();
expect(manager.isConnected()).toBeTruthy();
expect(api.getCardElementManager().update).toBeCalledTimes(1);
vi.advanceTimersByTime(disconnectSeconds * 1000);
expect(manager.isConnected()).toBeTruthy();
expect(api.getCardElementManager().update).toBeCalledTimes(1);
});
describe('should require initialization', async () => {
it('should require when configured and supported', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
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).toBeCalledWith({
microphone: { connected: false, muted: true, forbidden: false, stream: undefined },
});
});
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.toBeCalled();
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.disconnect();
expectedState = {
forbidden: false,
stream: undefined,
connected: false,
muted: true,
};
expect(manager.getState()).toEqual(expectedState);
expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith(
expect.objectContaining({
microphone: expectedState,
}),
);
});
});