## 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` |
449 lines
16 KiB
TypeScript
449 lines
16 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { mock } from 'vitest-mock-extended';
|
|
import { CardController } from '../../src/card-controller/controller';
|
|
import { QueryStringManager } from '../../src/card-controller/query-string-manager';
|
|
import { SubstreamViewModifier } from '../../src/card-controller/view/modifiers/substream';
|
|
import { createCardAPI, createConfig } from '../test-utils';
|
|
|
|
const setQueryString = (qs: string): void => {
|
|
const location: Location = mock<Location>();
|
|
location.search = qs;
|
|
|
|
vi.spyOn(window, 'location', 'get').mockReturnValue(location);
|
|
};
|
|
|
|
const setCardID = (api: CardController, cardID: string): void => {
|
|
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
|
|
createConfig({
|
|
card_id: cardID,
|
|
}),
|
|
);
|
|
};
|
|
|
|
// @vitest-environment jsdom
|
|
describe('QueryStringManager', () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('should reject malformed query string', async () => {
|
|
setQueryString('BOGUS_KEY=BOGUS_VALUE');
|
|
const api = createCardAPI();
|
|
vi.mocked(api.getIssueManager().getStateManager().hasFullCardIssue).mockReturnValue(
|
|
true,
|
|
);
|
|
const manager = new QueryStringManager(api);
|
|
|
|
expect(manager.hasViewRelatedActionsToRun()).toBeFalsy();
|
|
await manager.executeIfNecessary();
|
|
|
|
expect(api.getActionsManager().executeActions).not.toBeCalled();
|
|
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
|
|
});
|
|
|
|
describe('should execute view name action from query string', () => {
|
|
it.each([
|
|
['clip' as const],
|
|
['clips' as const],
|
|
['diagnostics' as const],
|
|
['image' as const],
|
|
['live' as const],
|
|
['recording' as const],
|
|
['recordings' as const],
|
|
['snapshot' as const],
|
|
['snapshots' as const],
|
|
['timeline' as const],
|
|
])('%s', async (viewName: string) => {
|
|
setQueryString(`?advanced-camera-card-action.id.${viewName}=`);
|
|
const api = createCardAPI();
|
|
setCardID(api, 'id');
|
|
|
|
// View actions do not need the card to have been updated.
|
|
vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(false);
|
|
const manager = new QueryStringManager(api);
|
|
|
|
expect(manager.hasViewRelatedActionsToRun()).toBeTruthy();
|
|
await manager.executeIfNecessary();
|
|
expect(manager.hasViewRelatedActionsToRun()).toBeFalsy();
|
|
|
|
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({
|
|
params: {
|
|
view: viewName,
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('should execute non-view action from query string', () => {
|
|
it.each([
|
|
['camera_ui' as const],
|
|
['download' as const],
|
|
['expand' as const],
|
|
['menu_toggle' as const],
|
|
])('%s', async (action: string) => {
|
|
setQueryString(`?advanced-camera-card-action.id.${action}=`);
|
|
const api = createCardAPI();
|
|
setCardID(api, 'id');
|
|
vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true);
|
|
const manager = new QueryStringManager(api);
|
|
|
|
expect(manager.hasViewRelatedActionsToRun()).toBeFalsy();
|
|
await manager.executeIfNecessary();
|
|
|
|
expect(api.getActionsManager().executeActions).toBeCalledWith({
|
|
actions: [
|
|
{
|
|
action: 'fire-dom-event',
|
|
card_id: 'id',
|
|
advanced_camera_card_action: action,
|
|
},
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
it('should execute view default action', async () => {
|
|
setQueryString('?advanced-camera-card-action.id.default=');
|
|
const api = createCardAPI();
|
|
setCardID(api, 'id');
|
|
// View actions do not need the card to have been updated.
|
|
vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(false);
|
|
|
|
const manager = new QueryStringManager(api);
|
|
|
|
expect(manager.hasViewRelatedActionsToRun()).toBeTruthy();
|
|
await manager.executeIfNecessary();
|
|
expect(manager.hasViewRelatedActionsToRun()).toBeFalsy();
|
|
|
|
expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalled();
|
|
expect(api.getActionsManager().executeActions).not.toBeCalled();
|
|
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
|
|
});
|
|
|
|
it('should execute camera_select action', async () => {
|
|
setQueryString('?advanced-camera-card-action.id.camera_select=camera.office');
|
|
const api = createCardAPI();
|
|
setCardID(api, 'id');
|
|
vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true);
|
|
const manager = new QueryStringManager(api);
|
|
|
|
expect(manager.hasViewRelatedActionsToRun()).toBeTruthy();
|
|
await manager.executeIfNecessary();
|
|
expect(manager.hasViewRelatedActionsToRun()).toBeFalsy();
|
|
|
|
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({
|
|
params: {
|
|
camera: 'camera.office',
|
|
},
|
|
});
|
|
expect(api.getActionsManager().executeActions).not.toBeCalled();
|
|
expect(api.getViewManager().setViewDefault).not.toBeCalled();
|
|
});
|
|
|
|
it('should execute substream_on with a stream value as a view modifier', async () => {
|
|
setQueryString('?advanced-camera-card-action.id.substream_on=camera.office_hd');
|
|
const api = createCardAPI();
|
|
setCardID(api, 'id');
|
|
vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true);
|
|
const manager = new QueryStringManager(api);
|
|
|
|
expect(manager.hasViewRelatedActionsToRun()).toBeTruthy();
|
|
await manager.executeIfNecessary();
|
|
expect(manager.hasViewRelatedActionsToRun()).toBeFalsy();
|
|
|
|
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({
|
|
modifiers: [new SubstreamViewModifier({ stream: 'camera.office_hd' })],
|
|
params: {},
|
|
});
|
|
|
|
expect(api.getActionsManager().executeActions).not.toBeCalled();
|
|
expect(api.getViewManager().setViewDefault).not.toBeCalled();
|
|
});
|
|
|
|
it('should dispatch substream_on without a value as a non-view action', async () => {
|
|
setQueryString('?advanced-camera-card-action.id.substream_on=');
|
|
const api = createCardAPI();
|
|
setCardID(api, 'id');
|
|
vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true);
|
|
const manager = new QueryStringManager(api);
|
|
|
|
expect(manager.hasViewRelatedActionsToRun()).toBeFalsy();
|
|
await manager.executeIfNecessary();
|
|
|
|
expect(api.getActionsManager().executeActions).toBeCalledWith({
|
|
actions: [
|
|
{
|
|
action: 'fire-dom-event',
|
|
card_id: 'id',
|
|
advanced_camera_card_action: 'substream_on',
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it('should execute substream_off as a view modifier that clears the override', async () => {
|
|
setQueryString('?advanced-camera-card-action.id.substream_off=');
|
|
const api = createCardAPI();
|
|
setCardID(api, 'id');
|
|
vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true);
|
|
const manager = new QueryStringManager(api);
|
|
|
|
expect(manager.hasViewRelatedActionsToRun()).toBeTruthy();
|
|
await manager.executeIfNecessary();
|
|
expect(manager.hasViewRelatedActionsToRun()).toBeFalsy();
|
|
|
|
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({
|
|
modifiers: [new SubstreamViewModifier()],
|
|
params: {},
|
|
});
|
|
expect(api.getActionsManager().executeActions).not.toBeCalled();
|
|
});
|
|
|
|
it('should warn on the legacy live_substream_select URL form', async () => {
|
|
const consoleSpy = vi.spyOn(global.console, 'warn').mockReturnValue(undefined);
|
|
|
|
setQueryString(
|
|
'?advanced-camera-card-action.id.live_substream_select=camera.office_hd',
|
|
);
|
|
const api = createCardAPI();
|
|
setCardID(api, 'id');
|
|
vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true);
|
|
const manager = new QueryStringManager(api);
|
|
|
|
expect(manager.hasViewRelatedActionsToRun()).toBeFalsy();
|
|
await manager.executeIfNecessary();
|
|
|
|
expect(api.getActionsManager().executeActions).not.toBeCalled();
|
|
expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled();
|
|
expect(consoleSpy).toBeCalledWith(expect.stringContaining('live_substream_select'));
|
|
});
|
|
|
|
it('should ignore camera_select without a value', async () => {
|
|
setQueryString('?advanced-camera-card-action.id.camera_select=');
|
|
const api = createCardAPI();
|
|
setCardID(api, 'id');
|
|
vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true);
|
|
const manager = new QueryStringManager(api);
|
|
|
|
expect(manager.hasViewRelatedActionsToRun()).toBeFalsy();
|
|
await manager.executeIfNecessary();
|
|
|
|
expect(api.getActionsManager().executeActions).not.toBeCalled();
|
|
expect(api.getViewManager().setViewDefault).not.toBeCalled();
|
|
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
|
|
});
|
|
|
|
it('should handle unknown action', async () => {
|
|
const consoleSpy = vi.spyOn(global.console, 'warn').mockReturnValue(undefined);
|
|
|
|
setQueryString('?advanced-camera-card-action.id.not_an_action=value');
|
|
const api = createCardAPI();
|
|
setCardID(api, 'id');
|
|
vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true);
|
|
const manager = new QueryStringManager(api);
|
|
|
|
expect(manager.hasViewRelatedActionsToRun()).toBeFalsy();
|
|
await manager.executeIfNecessary();
|
|
|
|
expect(api.getActionsManager().executeActions).not.toBeCalled();
|
|
expect(api.getViewManager().setViewDefault).not.toBeCalled();
|
|
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
|
|
expect(consoleSpy).toBeCalled();
|
|
});
|
|
|
|
describe('should execute view name action from query string', () => {
|
|
it.each([
|
|
['clip' as const],
|
|
['clips' as const],
|
|
['diagnostics' as const],
|
|
['image' as const],
|
|
['live' as const],
|
|
['recording' as const],
|
|
['recordings' as const],
|
|
['snapshot' as const],
|
|
['snapshots' as const],
|
|
['timeline' as const],
|
|
])('%s', async (viewName: string) => {
|
|
setQueryString(`?advanced-camera-card-action.id.${viewName}=`);
|
|
const api = createCardAPI();
|
|
setCardID(api, 'id');
|
|
vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true);
|
|
const manager = new QueryStringManager(api);
|
|
|
|
expect(manager.hasViewRelatedActionsToRun()).toBeTruthy();
|
|
await manager.executeIfNecessary();
|
|
expect(manager.hasViewRelatedActionsToRun()).toBeFalsy();
|
|
|
|
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({
|
|
params: {
|
|
view: viewName,
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('should handle conflicting but valid actions', () => {
|
|
it('should handle view and default with camera and substream specified', async () => {
|
|
setQueryString(
|
|
'?advanced-camera-card-action.id.clips=' +
|
|
'&advanced-camera-card-action.id.substream_on=camera.kitchen_hd' +
|
|
'&advanced-camera-card-action.id.default=' +
|
|
'&advanced-camera-card-action.id.camera_select=camera.kitchen',
|
|
);
|
|
const api = createCardAPI();
|
|
setCardID(api, 'id');
|
|
vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true);
|
|
const manager = new QueryStringManager(api);
|
|
|
|
await manager.executeIfNecessary();
|
|
|
|
expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalledWith({
|
|
params: {
|
|
camera: 'camera.kitchen',
|
|
},
|
|
modifiers: [new SubstreamViewModifier({ stream: 'camera.kitchen_hd' })],
|
|
});
|
|
expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled();
|
|
});
|
|
|
|
it('should handle multiple cameras specified', async () => {
|
|
setQueryString(
|
|
'?advanced-camera-card-action.id.camera_select=camera.kitchen' +
|
|
'&advanced-camera-card-action.id.camera_select=camera.office',
|
|
);
|
|
const api = createCardAPI();
|
|
setCardID(api, 'id');
|
|
vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true);
|
|
const manager = new QueryStringManager(api);
|
|
|
|
await manager.executeIfNecessary();
|
|
|
|
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({
|
|
params: {
|
|
camera: 'camera.office',
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
it('should only execute when needed', async () => {
|
|
setQueryString('?advanced-camera-card-action.id.substream_on=camera.office_hd');
|
|
const api = createCardAPI();
|
|
setCardID(api, 'id');
|
|
vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true);
|
|
const manager = new QueryStringManager(api);
|
|
|
|
expect(manager.hasViewRelatedActionsToRun()).toBeTruthy();
|
|
await manager.executeIfNecessary();
|
|
expect(manager.hasViewRelatedActionsToRun()).toBeFalsy();
|
|
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledTimes(1);
|
|
|
|
await manager.executeIfNecessary();
|
|
expect(manager.hasViewRelatedActionsToRun()).toBeFalsy();
|
|
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledTimes(1);
|
|
|
|
manager.requestExecution();
|
|
|
|
expect(manager.hasViewRelatedActionsToRun()).toBeTruthy();
|
|
await manager.executeIfNecessary();
|
|
expect(manager.hasViewRelatedActionsToRun()).toBeFalsy();
|
|
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledTimes(2);
|
|
});
|
|
|
|
it('should execute actions with old frigate-card-action key', async () => {
|
|
setQueryString(`?frigate-card-action.id.clips=`);
|
|
const api = createCardAPI();
|
|
setCardID(api, 'id');
|
|
|
|
// View actions do not need the card to have been updated.
|
|
vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(false);
|
|
const manager = new QueryStringManager(api);
|
|
|
|
expect(manager.hasViewRelatedActionsToRun()).toBeTruthy();
|
|
await manager.executeIfNecessary();
|
|
expect(manager.hasViewRelatedActionsToRun()).toBeFalsy();
|
|
|
|
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({
|
|
params: {
|
|
view: 'clips',
|
|
},
|
|
});
|
|
});
|
|
|
|
describe('should filter by card_id', () => {
|
|
it('should execute view action when card_id matches', async () => {
|
|
setQueryString('?advanced-camera-card-action.my_card.clips=');
|
|
const api = createCardAPI();
|
|
setCardID(api, 'my_card');
|
|
vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(false);
|
|
const manager = new QueryStringManager(api);
|
|
|
|
expect(manager.hasViewRelatedActionsToRun()).toBeTruthy();
|
|
await manager.executeIfNecessary();
|
|
|
|
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({
|
|
params: {
|
|
view: 'clips',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should NOT execute view action when card_id does not match', async () => {
|
|
setQueryString('?advanced-camera-card-action.other_card.clips=');
|
|
const api = createCardAPI();
|
|
setCardID(api, 'my_card');
|
|
vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(false);
|
|
const manager = new QueryStringManager(api);
|
|
|
|
expect(manager.hasViewRelatedActionsToRun()).toBeFalsy();
|
|
await manager.executeIfNecessary();
|
|
|
|
expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled();
|
|
expect(api.getActionsManager().executeActions).not.toBeCalled();
|
|
});
|
|
|
|
it('should execute action without card_id on any card', async () => {
|
|
setQueryString('?advanced-camera-card-action.clips=');
|
|
const api = createCardAPI();
|
|
setCardID(api, 'my_card');
|
|
vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(false);
|
|
const manager = new QueryStringManager(api);
|
|
|
|
expect(manager.hasViewRelatedActionsToRun()).toBeTruthy();
|
|
await manager.executeIfNecessary();
|
|
|
|
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({
|
|
params: {
|
|
view: 'clips',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should NOT execute non-view action when card_id does not match', async () => {
|
|
setQueryString('?advanced-camera-card-action.other_card.menu_toggle=');
|
|
const api = createCardAPI();
|
|
setCardID(api, 'my_card');
|
|
vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true);
|
|
const manager = new QueryStringManager(api);
|
|
|
|
expect(manager.hasViewRelatedActionsToRun()).toBeFalsy();
|
|
await manager.executeIfNecessary();
|
|
|
|
expect(api.getActionsManager().executeActions).not.toBeCalled();
|
|
});
|
|
|
|
it('should execute action when card has no card_id and URL has card_id', async () => {
|
|
setQueryString('?advanced-camera-card-action.some_card.clips=');
|
|
const api = createCardAPI();
|
|
vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(false);
|
|
const manager = new QueryStringManager(api);
|
|
|
|
// Should NOT execute since URL targets 'some_card' but this card has no card_id
|
|
expect(manager.hasViewRelatedActionsToRun()).toBeFalsy();
|
|
await manager.executeIfNecessary();
|
|
|
|
expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled();
|
|
});
|
|
});
|
|
});
|