## Summary
- Collapse `live_substream_{on,off,select}` into a unified
`substream_{on,off}` pair, symmetric with `call_{start,end}`.
- Rename the `camera` field on the former `live_substream_select` to
`stream` (it always was a stream ID).
- Add optional `camera` field to both new actions for targeting a
non-selected base camera.
- YAML configs are migrated automatically; URL bookmarks must be updated
by hand.
## Migration
### Cycling between camera and substream (toggle button)
Before:
```yaml
tap_action:
action: custom:advanced-camera-card-action
advanced_camera_card_action: live_substream_on
```
After:
```yaml
tap_action:
action: custom:advanced-camera-card-action
advanced_camera_card_action: substream_on
```
### Selecting a specific substream
Before:
```yaml
tap_action:
action: custom:advanced-camera-card-action
advanced_camera_card_action: live_substream_select
camera: camera.front_door_hd
```
After:
```yaml
tap_action:
action: custom:advanced-camera-card-action
advanced_camera_card_action: substream_on
stream: camera.front_door_hd
```
### Turning the substream off
Before:
```yaml
tap_action:
action: custom:advanced-camera-card-action
advanced_camera_card_action: live_substream_off
```
After:
```yaml
tap_action:
action: custom:advanced-camera-card-action
advanced_camera_card_action: substream_off
```
### URL querystrings (manual update required)
| Before | After |
| --- | --- |
|
`?advanced-camera-card-action.live_substream_select=camera.front_door_hd`
| `?advanced-camera-card-action.substream_on=camera.front_door_hd` |
169 lines
5.5 KiB
TypeScript
169 lines
5.5 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
import { CardController } from '../../../src/card-controller/controller';
|
|
import { LockManager } from '../../../src/card-controller/lock/manager';
|
|
import {
|
|
createCameraAction,
|
|
createDisplayModeAction,
|
|
createGeneralAction,
|
|
createLogAction,
|
|
createMediaPlayerAction,
|
|
createSubstreamOffAction,
|
|
createSubstreamOnAction,
|
|
createViewAction,
|
|
} from '../../../src/utils/action';
|
|
import { createCardAPI, createConfig } from '../../test-utils';
|
|
|
|
const setCallLock = (api: CardController, lock: boolean): void => {
|
|
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
|
|
createConfig({ live: { controls: { call: { lock } } } }),
|
|
);
|
|
};
|
|
|
|
describe('LockManager', () => {
|
|
it('should report unlocked when no lock source is active', () => {
|
|
const api = createCardAPI();
|
|
setCallLock(api, true);
|
|
vi.mocked(api.getCallManager().isActive).mockReturnValue(false);
|
|
|
|
expect(new LockManager(api).isLocked()).toBeFalsy();
|
|
});
|
|
|
|
it('should report locked when a call is active', () => {
|
|
const api = createCardAPI();
|
|
setCallLock(api, true);
|
|
vi.mocked(api.getCallManager().isActive).mockReturnValue(true);
|
|
|
|
expect(new LockManager(api).isLocked()).toBeTruthy();
|
|
});
|
|
|
|
it('should report unlocked when a call is active but the lock is disabled', () => {
|
|
const api = createCardAPI();
|
|
setCallLock(api, false);
|
|
vi.mocked(api.getCallManager().isActive).mockReturnValue(true);
|
|
|
|
expect(new LockManager(api).isLocked()).toBeFalsy();
|
|
});
|
|
|
|
it('should reuse lock manager epoch until the lock state changes', () => {
|
|
const api = createCardAPI();
|
|
setCallLock(api, true);
|
|
vi.mocked(api.getCallManager().isActive).mockReturnValue(false);
|
|
const manager = new LockManager(api);
|
|
|
|
const unlockedEpoch = manager.getEpoch();
|
|
expect(manager.getEpoch()).toBe(unlockedEpoch);
|
|
expect(unlockedEpoch.locked).toBeFalsy();
|
|
|
|
vi.mocked(api.getCallManager().isActive).mockReturnValue(true);
|
|
const lockedEpoch = manager.getEpoch();
|
|
expect(lockedEpoch).not.toBe(unlockedEpoch);
|
|
expect(lockedEpoch.locked).toBeTruthy();
|
|
expect(manager.getEpoch()).toBe(lockedEpoch);
|
|
});
|
|
|
|
it('should not filter actions when unlocked', () => {
|
|
const api = createCardAPI();
|
|
setCallLock(api, true);
|
|
vi.mocked(api.getCallManager().isActive).mockReturnValue(false);
|
|
|
|
const actions = [createGeneralAction('reload'), createLogAction('Allowed')];
|
|
|
|
expect(new LockManager(api).getAllowedActions(actions)).toBe(actions);
|
|
});
|
|
|
|
it('should reject call-disruptive actions when locked', () => {
|
|
const api = createCardAPI();
|
|
setCallLock(api, true);
|
|
vi.mocked(api.getCallManager().isActive).mockReturnValue(true);
|
|
const manager = new LockManager(api);
|
|
|
|
for (const action of [
|
|
createViewAction('clips'),
|
|
createCameraAction('cam-1'),
|
|
createSubstreamOnAction({ stream: 'cam-1' }),
|
|
createSubstreamOnAction(),
|
|
createSubstreamOffAction(),
|
|
createGeneralAction('default'),
|
|
createGeneralAction('pause'),
|
|
createGeneralAction('reload'),
|
|
createMediaPlayerAction('media_player.living_room', 'play'),
|
|
]) {
|
|
expect(manager.getAllowedActions(action)).toEqual([]);
|
|
}
|
|
});
|
|
|
|
it('should preserve non-disruptive actions when locked', () => {
|
|
const api = createCardAPI();
|
|
setCallLock(api, true);
|
|
vi.mocked(api.getCallManager().isActive).mockReturnValue(true);
|
|
const manager = new LockManager(api);
|
|
|
|
for (const action of [
|
|
createGeneralAction('play'),
|
|
createGeneralAction('fullscreen'),
|
|
createGeneralAction('expand'),
|
|
createGeneralAction('pip'),
|
|
createGeneralAction('screenshot'),
|
|
createGeneralAction('mute'),
|
|
createGeneralAction('unmute'),
|
|
createGeneralAction('microphone_unmute'),
|
|
createDisplayModeAction('grid'),
|
|
{ action: 'none' as const },
|
|
]) {
|
|
expect(manager.getAllowedActions(action)).toEqual([action]);
|
|
}
|
|
});
|
|
|
|
it('should preserve non-disruptive actions from a mixed action list when locked', () => {
|
|
const api = createCardAPI();
|
|
setCallLock(api, true);
|
|
vi.mocked(api.getCallManager().isActive).mockReturnValue(true);
|
|
|
|
const manager = new LockManager(api);
|
|
const allowedAction = createLogAction('Allowed');
|
|
|
|
expect(
|
|
manager.getAllowedActions([createGeneralAction('reload'), allowedAction]),
|
|
).toEqual([allowedAction]);
|
|
});
|
|
|
|
it('should report whether all configured actions are blocked', () => {
|
|
const api = createCardAPI();
|
|
setCallLock(api, true);
|
|
vi.mocked(api.getCallManager().isActive).mockReturnValue(true);
|
|
|
|
const manager = new LockManager(api);
|
|
|
|
expect(
|
|
manager.areAllActionsBlocked({
|
|
tap_action: createGeneralAction('reload'),
|
|
hold_action: createGeneralAction('pause'),
|
|
}),
|
|
).toBeTruthy();
|
|
expect(
|
|
manager.areAllActionsBlocked({
|
|
tap_action: createGeneralAction('reload'),
|
|
hold_action: createLogAction('Allowed'),
|
|
}),
|
|
).toBeFalsy();
|
|
expect(manager.areAllActionsBlocked({})).toBeFalsy();
|
|
});
|
|
|
|
it('should never report all-actions-blocked when unlocked', () => {
|
|
const api = createCardAPI();
|
|
setCallLock(api, true);
|
|
vi.mocked(api.getCallManager().isActive).mockReturnValue(false);
|
|
|
|
const manager = new LockManager(api);
|
|
|
|
// Even when every action would be blocked under an active policy, an
|
|
// inactive lock short-circuits to false.
|
|
expect(
|
|
manager.areAllActionsBlocked({
|
|
tap_action: createGeneralAction('reload'),
|
|
hold_action: createGeneralAction('pause'),
|
|
}),
|
|
).toBeFalsy();
|
|
});
|
|
});
|