Files
advanced-camera-card/tests/camera-manager/manager.test.ts
T
Dermot DuffyandClaude Opus 4.8 b701366762 feat: Align automations with Home Assistant triggers and conditions (#2527)
Split automations into HA-style `triggers`, ongoing `conditions`, and
`actions`, with compatibility migrations for existing Advanced Camera
Card configs.

## Summary

At a glance (details below):

- **Added** `triggers:` -- a required, HA-shaped block: stock `state` /
`numeric_state` / `template` plus card-specific triggers (`camera`,
`view`, `fullscreen`, ...).
- **Added** the HA-native `if` / `then` / `else` action.
- **Removed** `actions_not` (replaced by `if` / `then` / `else`).
- **Removed** the ambient `advanced_camera_card` template namespace (use
`acc` instead).
- **Changed** the trigger template surface to a top-level `trigger.*`
variable (as in HA); the nested `acc.trigger.*` paths are removed.
- **Changed** `conditions:` to ongoing gates only -- they no longer wake
an automation, and change-only forms (`config`, valueless `camera` /
`view` / `state`) become triggers, not conditions.
- **Changed** action templates to render per step, so a later action
sees state an earlier one changed.
- **Compatibility:** HA-shaped YAML is accepted (singular keys,
single-or-list, `and` / `or` / `not` shorthand, `entity` / `entity_id`).
- **Migration:** existing configs upgrade automatically; anything that
cannot be converted faithfully is recorded under `__UPGRADE_FAILURE__`
for manual fixup.

## Breaking Changes

### 1. Automations now require triggers

Before this PR, `automations[].conditions` served two roles:

- They decided whether the automation should run.
- They also acted as the thing that woke the automation up.

After this PR:

- `triggers` wake the automation.
- `conditions` only gate it at the instant a trigger fires.

Most existing automations are migrated automatically from `conditions:`
to `triggers:`.

### 2. `actions_not` is retired

Legacy `actions_not` is replaced by an HA-style `if` action with `then`
/ `else`.

Faithful conversions are automatic. Cases that cannot be faithfully
converted are recorded under `__UPGRADE_FAILURE__.automations` and must
be migrated manually.

### 3. Template surface aligned with Home Assistant

Two related template changes, both auto-migrated:

- **Top-level `trigger.*`.** Automation actions now receive a top-level
`trigger` template variable, like Home Assistant. Legacy nested paths
such as `acc.trigger.state.to` and
`advanced_camera_card.trigger.camera.to` are migrated automatically when
they appear inside template strings.
- **The ambient `advanced_camera_card` template namespace is removed.**
The long-form ambient namespace (`advanced_camera_card.camera`,
`advanced_camera_card.view`, `advanced_camera_card.config`) is retired
in favour of its shorter `acc` alias -- supported since v7.1.0, and the
only spelling the new trigger surface uses. Existing templates are
migrated automatically by rewriting the `advanced_camera_card.` prefix
to `acc.`.

### 4. Trigger-only condition forms are no longer valid conditions

Some legacy "conditions" were really change detectors. These are now
triggers only:

- `condition: config`
- valueless `camera`
- valueless `view`
- valueless `state` / picture-elements state condition with neither
`state` nor `state_not`

These are automatically promoted in automations and stripped from
overrides/elements where they would no longer be meaningful as ongoing
conditions.

### 5. Template truthiness now follows Home Assistant behavior

Template conditions and template triggers intentionally use different
truthiness rules, matching HA:

- A template condition passes only when the rendered value is `true`
(case-insensitive), matching HA's `condition.py`.
- A template trigger uses HA's broader `result_as_boolean` coercion: a
non-zero number, or `1` / `true` / `yes` / `on` / `enable`
(case-insensitive), counts as true.

### 6. Action templates render when each action executes

Action templates are now rendered per action step, not once for the
whole sequence. This means a later action can see card-local state
changed by an earlier action in the same sequence.

The `trigger` context is fixed for the automation run. HA entity state
updates still depend on the frontend receiving updated HASS state over
the websocket.

## Automatic Migrations

### Automation `conditions:` to `triggers:`

Simple legacy automation:

```yaml
# Before
automations:
  - conditions:
      - condition: fullscreen
        fullscreen: true
    actions:
      - action: custom:advanced-camera-card-action
        advanced_camera_card_action: substream_on
```

```yaml
# After, automatic
automations:
  - triggers:
      - trigger: fullscreen
        fullscreen: true
    actions:
      - action: custom:advanced-camera-card-action
        advanced_camera_card_action: substream_on
```

State conditions become HA-style state triggers:

```yaml
# Before
automations:
  - conditions:
      - condition: state
        entity_id: binary_sensor.front_door
        state: 'on'
    actions:
      - action: custom:advanced-camera-card-action
        advanced_camera_card_action: live
```

```yaml
# After, automatic
automations:
  - triggers:
      - trigger: state
        entity_id: binary_sensor.front_door
        to: 'on'
    actions:
      - action: custom:advanced-camera-card-action
        advanced_camera_card_action: live
```

Multiple conditions become both triggers and ongoing conditions:

```yaml
# Before
automations:
  - conditions:
      - condition: camera
        cameras: [front_door]
      - condition: fullscreen
        fullscreen: true
    actions:
      - action: custom:advanced-camera-card-action
        advanced_camera_card_action: substream_on
```

```yaml
# After, automatic
automations:
  - triggers:
      - trigger: camera
        cameras: [front_door]
      - trigger: fullscreen
        fullscreen: true
    conditions:
      - condition: camera
        cameras: [front_door]
      - condition: fullscreen
        fullscreen: true
    actions:
      - action: custom:advanced-camera-card-action
        advanced_camera_card_action: substream_on
```

The flattened trigger list is an implicit OR. The retained `conditions:`
list is an implicit AND checked when any trigger fires.

### Trigger-only legacy conditions

Legacy `config` conditions become `config` triggers:

```yaml
# Before
automations:
  - conditions:
      - condition: config
        paths: [menu.style]
    actions:
      - action: custom:advanced-camera-card-action
        advanced_camera_card_action: status_bar
```

```yaml
# After, automatic
automations:
  - triggers:
      - trigger: config
        paths: [menu.style]
    actions:
      - action: custom:advanced-camera-card-action
        advanced_camera_card_action: status_bar
```

Trigger-only leaves are removed from retained `conditions:` blocks
because they no longer describe an ongoing state.

### `actions_not` to `if` / `then` / `else`

```yaml
# Before
automations:
  - conditions:
      - condition: state
        entity_id: input_boolean.camera_alerts
        state: 'on'
    actions:
      - action: custom:advanced-camera-card-action
        advanced_camera_card_action: live
    actions_not:
      - action: none
```

```yaml
# After, automatic
automations:
  - triggers:
      - trigger: state
        entity_id: input_boolean.camera_alerts
    actions:
      - if:
          - condition: state
            entity_id: input_boolean.camera_alerts
            state: 'on'
        then:
          - action: custom:advanced-camera-card-action
            advanced_camera_card_action: live
        else:
          - action: none
```

If the legacy automation had no conditions, or only trigger-only
conditions, `actions_not` is dropped because the old `else` branch could
not be reproduced as an ongoing predicate.

### Trigger template paths

```yaml
# Before
message: 'Door is {{ acc.trigger.state.to }} from {{ acc.trigger.state.from }}'
```

```yaml
# After, automatic
message: 'Door is {{ trigger.to_state.state }} from {{ trigger.from_state.state }}'
```

Path rewrites performed automatically:

| Old path                   | New path                   |
| -------------------------- | -------------------------- |
| `acc.trigger.state.entity` | `trigger.entity_id`        |
| `acc.trigger.state.from`   | `trigger.from_state.state` |
| `acc.trigger.state.to`     | `trigger.to_state.state`   |
| `acc.trigger.camera.from`  | `trigger.from_acc.camera`  |
| `acc.trigger.camera.to`    | `trigger.to_acc.camera`    |
| `acc.trigger.view.from`    | `trigger.from_acc.view`    |
| `acc.trigger.view.to`      | `trigger.to_acc.view`      |
| `acc.trigger.config.from`  | `trigger.from_acc.config`  |
| `acc.trigger.config.to`    | `trigger.to_acc.config`    |

The same rewrites are applied for the older
`advanced_camera_card.trigger.*` namespace.

### Ambient template namespace

Any remaining long-form ambient `advanced_camera_card.*` references
(outside the trigger surface) are rewritten to the `acc.*` alias:

```yaml
# Before
title: 'Now viewing {{ advanced_camera_card.camera }}'
```

```yaml
# After, automatic
title: 'Now viewing {{ acc.camera }}'
```

## Manual Migration Cases

### `__UPGRADE_FAILURE__.automations`

If a legacy automation cannot be converted faithfully, the original
automation is recorded under:

```yaml
__UPGRADE_FAILURE__:
  automations:
    - ...
```

These entries require manual migration.

The main known case is legacy `actions_not` with a condition whose
trigger can only fire on a rising edge, such as:

- `condition: template`
- `condition: screen`
- `condition: numeric_state` without an entity-backed state to watch

Those conditions can start the `then` branch, but cannot reliably start
the `else` branch when they stop matching.

### Unsupported HA conditions and triggers

This PR aligns the card with HA where supported, but it is not a full HA
automation engine.

Unsupported HA condition families include:

- `time`
- `zone`
- `sun`
- `location`
- `device`
- `condition: trigger`

Unsupported HA trigger platforms include:

- `event`
- `time`
- `time_pattern`
- `sun`
- `zone`
- `calendar`
- `webhook`
- `tag`
- `device`
- `mqtt`

The card-specific camera `triggers:` feature (which auto-selects and
wakes the card on camera events such as motion) is a separate feature
from automation `triggers:`, despite the shared word.

### Trigger IDs and variables

HA keys such as `id`, `alias`, and `variables` are accepted so pasted HA
YAML validates, but they are ignored by the card. There is no
`trigger.id` support in this PR.

## New Compatibility Features

This PR also makes card config more forgiving for HA-style YAML:

- `trigger`, `condition`, and `action` singular keys are accepted and
normalized to `triggers`, `conditions`, and `actions`.
- Single trigger, condition, and action objects are accepted where lists
are expected.
- `if`, `then`, and `else` accept a single item or a list.
- Composite condition shorthand is accepted:
  - `{ and: [...] }`
  - `{ or: [...] }`
  - `{ not: [...] }`
  - `{ condition: [...] }` as an implicit AND
- State conditions resolve expected state values that name another
entity, matching HA/Lovelace behavior.
- Both `entity` and `entity_id` are accepted on state and numeric
conditions and triggers (a superset of HA's two dialects), so there is
no forced rename.
- `state_not` remains supported as a card/Lovelace-friendly extension.

## Trigger Payloads

Automation action templates receive a top-level `trigger` object.

For stock `state` and `numeric_state` triggers:

```yaml
trigger.platform
trigger.entity_id
trigger.entity
trigger.from_state
trigger.to_state
```

For template triggers:

```yaml
trigger.platform
```

For card-specific triggers:

```yaml
trigger.platform # "acc"
trigger.type
trigger.from_acc
trigger.to_acc
```

The card does not currently expose HA's `id`, `idx`, `for`, `attribute`,
`above`, `below`, or `alias` trigger fields.

BREAKING CHANGE: Automations now follow Home Assistant's `triggers:` /
`conditions:` / `actions:` model. Automations require a `triggers:`
block and `conditions:` no longer wake an automation; `actions_not` is
removed in favour of an `if` / `then` / `else` action; the nested
`acc.trigger.*` template paths and the ambient `advanced_camera_card`
template namespace are removed (use the top-level `trigger.*` surface
and the `acc` alias); trigger-only condition forms (`config`, valueless
`camera` / `view` / `state`) are no longer valid conditions; and
template-condition vs template-trigger truthiness now follow HA.
Existing configs are upgraded automatically where a faithful conversion
exists; anything that cannot be converted is recorded under
`__UPGRADE_FAILURE__` for manual migration.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:45:13 -07:00

1701 lines
56 KiB
TypeScript

import { add } from 'date-fns';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { Camera } from '../../src/camera-manager/camera.js';
import { Capabilities } from '../../src/camera-manager/capabilities.js';
import { CameraManagerEngineFactory } from '../../src/camera-manager/engine-factory.js';
import { CameraManagerEngine } from '../../src/camera-manager/engine.js';
import {
CameraDuplicateIDError,
CameraNoEngineError,
CameraNoIDError,
} from '../../src/camera-manager/error.js';
import {
CameraManager,
CameraQueryClassifier,
QueryResultClassifier,
} from '../../src/camera-manager/manager.js';
import {
CameraEvent,
CameraManagerCameraMetadata,
Engine,
EventQuery,
EventQueryResults,
MediaMetadata,
QueryResults,
QueryResultsType,
QueryType,
} from '../../src/camera-manager/types.js';
import { CardController } from '../../src/card-controller/controller.js';
import { StateWatcherSubscriptionInterface } from '../../src/card-controller/hass/state-watcher.js';
import { sortItems } from '../../src/card-controller/view/sort.js';
import { CameraConfig } from '../../src/config/schema/cameras.js';
import { advancedCameraCardConfigSchema } from '../../src/config/schema/types.js';
import { HomeAssistant } from '../../src/ha/types.js';
import { QuerySource } from '../../src/query-source.js';
import { Endpoint, PTZMovementType } from '../../src/types.js';
import { ViewFolder, ViewItem, ViewMedia } from '../../src/view/item.js';
import { ViewItemCapabilities } from '../../src/view/types.js';
import {
TestViewMedia,
createCameraConfig,
createCapabilities,
createCardAPI,
createConfig,
createFolder,
createHASS,
createInitializedCamera,
generateViewMediaArray,
} from '../test-utils.js';
describe('QueryClassifier', () => {
it('should classify event query', () => {
expect(CameraQueryClassifier.isEventQuery({ type: QueryType.Event })).toBeTruthy();
expect(
CameraQueryClassifier.isEventQuery({ type: QueryType.Recording }),
).toBeFalsy();
expect(
CameraQueryClassifier.isEventQuery({ type: QueryType.RecordingSegments }),
).toBeFalsy();
expect(
CameraQueryClassifier.isEventQuery({ type: QueryType.MediaMetadata }),
).toBeFalsy();
});
it('should classify recording query', () => {
expect(
CameraQueryClassifier.isRecordingQuery({ type: QueryType.Event }),
).toBeFalsy();
expect(
CameraQueryClassifier.isRecordingQuery({ type: QueryType.Recording }),
).toBeTruthy();
expect(
CameraQueryClassifier.isRecordingQuery({ type: QueryType.RecordingSegments }),
).toBeFalsy();
expect(
CameraQueryClassifier.isRecordingQuery({ type: QueryType.MediaMetadata }),
).toBeFalsy();
});
it('should classify recording segments query', () => {
expect(
CameraQueryClassifier.isRecordingSegmentsQuery({ type: QueryType.Event }),
).toBeFalsy();
expect(
CameraQueryClassifier.isRecordingSegmentsQuery({ type: QueryType.Recording }),
).toBeFalsy();
expect(
CameraQueryClassifier.isRecordingSegmentsQuery({
type: QueryType.RecordingSegments,
}),
).toBeTruthy();
expect(
CameraQueryClassifier.isRecordingSegmentsQuery({ type: QueryType.MediaMetadata }),
).toBeFalsy();
});
it('should classify media metadata query', () => {
expect(
CameraQueryClassifier.isMediaMetadataQuery({ type: QueryType.Event }),
).toBeFalsy();
expect(
CameraQueryClassifier.isMediaMetadataQuery({ type: QueryType.Recording }),
).toBeFalsy();
expect(
CameraQueryClassifier.isMediaMetadataQuery({ type: QueryType.RecordingSegments }),
).toBeFalsy();
expect(
CameraQueryClassifier.isMediaMetadataQuery({ type: QueryType.MediaMetadata }),
).toBeTruthy();
});
it('should classify review query', () => {
expect(CameraQueryClassifier.isReviewQuery({ type: QueryType.Event })).toBeFalsy();
expect(
CameraQueryClassifier.isReviewQuery({ type: QueryType.Recording }),
).toBeFalsy();
expect(
CameraQueryClassifier.isReviewQuery({ type: QueryType.RecordingSegments }),
).toBeFalsy();
expect(
CameraQueryClassifier.isReviewQuery({ type: QueryType.MediaMetadata }),
).toBeFalsy();
expect(CameraQueryClassifier.isReviewQuery({ type: QueryType.Review })).toBeTruthy();
});
});
describe('QueryResultClassifier', async () => {
const createResults = (type: Partial<QueryResultsType>): QueryResults => {
return {
type: type,
engine: Engine.Generic,
};
};
it('should classify event query result', async () => {
expect(
QueryResultClassifier.isEventQueryResult(createResults(QueryResultsType.Event)),
).toBeTruthy();
expect(
QueryResultClassifier.isEventQueryResult(
createResults(QueryResultsType.Recording),
),
).toBeFalsy();
expect(
QueryResultClassifier.isEventQueryResult(
createResults(QueryResultsType.RecordingSegments),
),
).toBeFalsy();
expect(
QueryResultClassifier.isEventQueryResult(
createResults(QueryResultsType.MediaMetadata),
),
).toBeFalsy();
});
it('should classify recording query result', async () => {
expect(
QueryResultClassifier.isRecordingQueryResult(
createResults(QueryResultsType.Event),
),
).toBeFalsy();
expect(
QueryResultClassifier.isRecordingQueryResult(
createResults(QueryResultsType.Recording),
),
).toBeTruthy();
expect(
QueryResultClassifier.isRecordingQueryResult(
createResults(QueryResultsType.RecordingSegments),
),
).toBeFalsy();
expect(
QueryResultClassifier.isRecordingQueryResult(
createResults(QueryResultsType.MediaMetadata),
),
).toBeFalsy();
});
it('should classify recording segments query result', async () => {
expect(
QueryResultClassifier.isRecordingSegmentsQueryResult(
createResults(QueryResultsType.Event),
),
).toBeFalsy();
expect(
QueryResultClassifier.isRecordingSegmentsQueryResult(
createResults(QueryResultsType.Recording),
),
).toBeFalsy();
expect(
QueryResultClassifier.isRecordingSegmentsQueryResult(
createResults(QueryResultsType.RecordingSegments),
),
).toBeTruthy();
expect(
QueryResultClassifier.isRecordingSegmentsQueryResult(
createResults(QueryResultsType.MediaMetadata),
),
).toBeFalsy();
});
it('should classify media metadata query result', async () => {
expect(
QueryResultClassifier.isMediaMetadataQueryResult(
createResults(QueryResultsType.Event),
),
).toBeFalsy();
expect(
QueryResultClassifier.isMediaMetadataQueryResult(
createResults(QueryResultsType.Recording),
),
).toBeFalsy();
expect(
QueryResultClassifier.isMediaMetadataQueryResult(
createResults(QueryResultsType.RecordingSegments),
),
).toBeFalsy();
expect(
QueryResultClassifier.isMediaMetadataQueryResult(
createResults(QueryResultsType.MediaMetadata),
),
).toBeTruthy();
});
it('should classify review query result', async () => {
expect(
QueryResultClassifier.isReviewQueryResult(createResults(QueryResultsType.Event)),
).toBeFalsy();
expect(
QueryResultClassifier.isReviewQueryResult(
createResults(QueryResultsType.Recording),
),
).toBeFalsy();
expect(
QueryResultClassifier.isReviewQueryResult(
createResults(QueryResultsType.RecordingSegments),
),
).toBeFalsy();
expect(
QueryResultClassifier.isReviewQueryResult(
createResults(QueryResultsType.MediaMetadata),
),
).toBeFalsy();
expect(
QueryResultClassifier.isReviewQueryResult(createResults(QueryResultsType.Review)),
).toBeTruthy();
});
});
describe('CameraManager', () => {
const baseCameraConfig = {
id: 'id',
camera_entity: 'camera.foo',
engine: 'generic',
};
const baseEventQuery: EventQuery = {
source: QuerySource.Camera,
type: QueryType.Event as const,
cameraIDs: new Set(['id']),
};
const baseEventQueryResults: EventQueryResults = {
type: QueryResultsType.Event as const,
engine: Engine.Generic,
};
const baseRecordingQuery = {
source: QuerySource.Camera as const,
type: QueryType.Recording as const,
cameraIDs: new Set(['id']),
};
const baseRecordingQueryResults = {
type: QueryResultsType.Recording as const,
engine: Engine.Generic,
};
const createCameraManager = (
api: CardController,
engine?: CameraManagerEngine,
cameras: {
config?: CameraConfig;
engineType?: Engine | null;
capabilties?: Capabilities;
stateWatcher?: StateWatcherSubscriptionInterface;
}[] = [{}],
factory?: CameraManagerEngineFactory,
): CameraManager => {
const camerasConfig = cameras?.map(
(camera) => camera.config ?? createCameraConfig(baseCameraConfig),
);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
cameras: camerasConfig,
}),
);
const mockFactory = factory ?? mock<CameraManagerEngineFactory>();
const mockEngine = engine ?? mock<CameraManagerEngine>();
vi.mocked(mockFactory.createEngine).mockResolvedValueOnce(mockEngine);
for (const camera of cameras ?? []) {
const engineType =
camera.engineType === undefined ? Engine.Generic : camera.engineType;
if (engineType) {
vi.mocked(mockEngine.createCamera).mockImplementationOnce(
async (_hass: HomeAssistant, cameraConfig: CameraConfig): Promise<Camera> =>
await createInitializedCamera(
cameraConfig,
mockEngine,
camera.capabilties ?? createCapabilities(),
camera.stateWatcher,
),
);
}
vi.mocked(mockFactory.getEngineForCamera).mockResolvedValueOnce(engineType);
}
return new CameraManager(api, { factory: mockFactory });
};
it('should construct', () => {
const manager = new CameraManager(createCardAPI());
expect(manager.getStore()).toBeTruthy();
});
describe('should initialize cameras from config', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should succeed', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const manager = createCameraManager(api);
await manager.initializeCamerasFromConfig();
expect(manager.getStore().getCameraCount()).toBe(1);
expect(manager.isInitialized()).toBeTruthy();
});
it('should handle missing hass', async () => {
const manager = createCameraManager(createCardAPI());
await manager.initializeCamerasFromConfig();
expect(manager.getStore().getCameraCount()).toBe(0);
});
it('should handle missing config', async () => {
const api = createCardAPI();
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(null);
const manager = createCameraManager(api);
await manager.initializeCamerasFromConfig();
expect(manager.getStore().getCameraCount()).toBe(0);
});
it('should handle missing cameras in config', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
advancedCameraCardConfigSchema.parse({
type: 'advanced-camera-card' as const,
}),
);
const manager = new CameraManager(api);
await manager.initializeCamerasFromConfig();
expect(manager.getStore().getCameraCount()).toBe(0);
});
it('should reject missing id', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const manager = createCameraManager(api, mock<CameraManagerEngine>(), [
{
config: createCameraConfig({
// No id.
engine: 'generic',
}),
},
]);
await expect(manager.initializeCamerasFromConfig()).rejects.toThrow(
CameraNoIDError,
);
});
it('should reject duplicate id', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const cameraConfig = createCameraConfig({
id: 'DUPLICATE',
engine: 'generic',
});
const manager = createCameraManager(api, mock<CameraManagerEngine>(), [
{
config: cameraConfig,
},
{
config: cameraConfig,
},
]);
await expect(manager.initializeCamerasFromConfig()).rejects.toThrow(
CameraDuplicateIDError,
);
});
it('should await camera destruction before throwing on duplicate id', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const cameraConfig = createCameraConfig({
id: 'DUPLICATE',
engine: 'generic',
});
// Camera.destroy() awaits its _destroyCallbacks, one of which is the
// trigger-path unsubscribe. By returning a deferred Promise from each
// camera's stateWatcher.unsubscribe, we make destroy completion
// externally observable without spying on any Camera method.
const order: string[] = [];
const buildStateWatcher = (): StateWatcherSubscriptionInterface => {
const watcher = mock<StateWatcherSubscriptionInterface>();
vi.mocked(watcher.unsubscribe).mockImplementation(
() =>
new Promise<void>((resolve) =>
setTimeout(() => {
order.push('destroy-done');
resolve();
}, 0),
),
);
return watcher;
};
const cameraEntry = {
config: cameraConfig,
capabilties: createCapabilities({ trigger: true }),
};
const manager = createCameraManager(api, mock<CameraManagerEngine>(), [
{ ...cameraEntry, stateWatcher: buildStateWatcher() },
{ ...cameraEntry, stateWatcher: buildStateWatcher() },
]);
await manager.initializeCamerasFromConfig().catch(() => order.push('throw'));
expect(order).toEqual(['destroy-done', 'destroy-done', 'throw']);
});
it('should reject missing engine', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const manager = createCameraManager(api, mock<CameraManagerEngine>(), [
{
config: createCameraConfig({
id: 'id',
}),
engineType: null,
},
]);
await expect(manager.initializeCamerasFromConfig()).rejects.toThrow(
CameraNoEngineError,
);
});
it('should pass events to triggers manager', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const factory = mock<CameraManagerEngineFactory>();
const manager = createCameraManager(
api,
mock<CameraManagerEngine>(),
[{}],
factory,
);
await manager.initializeCamerasFromConfig();
const eventCallback = factory.createEngine.mock.calls[0][1].eventCallback;
const cameraEvent: CameraEvent = {
cameraID: 'camera',
id: 'event-1',
type: 'new',
};
eventCallback?.(cameraEvent);
expect(api.getCameraTriggersManager().handleCameraEvent).toBeCalledWith(
cameraEvent,
);
});
describe('should fetch entity list when required', () => {
it.each([['occupancy' as const], ['motion' as const], ['doorbell' as const]])(
'should fetch with %s trigger',
async (triggerKey) => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const manager = createCameraManager(api, mock<CameraManagerEngine>(), [
{
config: createCameraConfig({
...baseCameraConfig,
triggers: {
[triggerKey]: true,
},
}),
},
]);
await manager.initializeCamerasFromConfig();
expect(api.getEntityRegistryManager().fetchEntityList).toBeCalled();
},
);
it('should skip without entity based trigger', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const manager = createCameraManager(api);
await manager.initializeCamerasFromConfig();
expect(api.getEntityRegistryManager().fetchEntityList).not.toBeCalled();
});
});
describe('generate default queries', () => {
it.each([
[
QueryType.Event as const,
'generateDefaultEventQuery',
'generateDefaultEventQueries',
],
[
QueryType.Recording as const,
'generateDefaultRecordingQuery',
'generateDefaultRecordingQueries',
],
[
QueryType.RecordingSegments as const,
'generateDefaultRecordingSegmentsQuery',
'generateDefaultRecordingSegmentsQueries',
],
[
QueryType.Review as const,
'generateDefaultReviewQuery',
'generateDefaultReviewQueries',
],
])(
'basic %s',
async (
queryType: string,
engineMethodName: string,
managerMethodName: string,
) => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const engine = mock<CameraManagerEngine>();
const manager = createCameraManager(api, engine);
await manager.initializeCamerasFromConfig();
const queries = [{ type: queryType, cameraIDs: new Set(['id']) }];
engine[engineMethodName].mockReturnValue(queries);
expect(manager[managerMethodName]('id')).toEqual(queries);
},
);
it('should handle missing camera', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const manager = createCameraManager(api, mock<CameraManagerEngine>());
expect(manager.generateDefaultEventQueries('not_a_camera')).toBeNull();
});
it('should handle missing queries', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const engine = mock<CameraManagerEngine>();
const manager = createCameraManager(api, engine);
await manager.initializeCamerasFromConfig();
engine.generateDefaultEventQuery.mockReturnValue(null);
expect(manager.generateDefaultEventQueries('id')).toBeNull();
});
});
describe('getDefaultQueryParameters', () => {
it('should return empty object for non-existent camera', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const manager = createCameraManager(api, mock<CameraManagerEngine>());
expect(
manager.getDefaultQueryParameters('not_a_camera', QueryType.Event),
).toEqual({});
});
it('should return parameters from engine for existing camera', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const engine = mock<CameraManagerEngine>();
const manager = createCameraManager(api, engine);
await manager.initializeCamerasFromConfig();
engine.getDefaultQueryParameters.mockReturnValue({ what: new Set(['person']) });
expect(manager.getDefaultQueryParameters('id', QueryType.Event)).toEqual({
what: new Set(['person']),
});
});
});
it('should merge defaults correctly', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const engine = mock<CameraManagerEngine>();
const manager = createCameraManager(api, engine, [
{
config: createCameraConfig({
...baseCameraConfig,
triggers: {
media_events: ['snapshots'],
},
}),
},
]);
await manager.initializeCamerasFromConfig();
expect(
manager.getStore().getCamera('id')?.getConfig().triggers.media_events,
).toEqual(['snapshots']);
});
});
describe('should get media metadata', () => {
const query = {
type: QueryType.MediaMetadata as const,
cameraIDs: new Set('id'),
};
it('should handle empty metadata', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const engine = mock<CameraManagerEngine>();
const manager = createCameraManager(api, engine);
await manager.initializeCamerasFromConfig();
const queryResults = {
type: QueryResultsType.MediaMetadata as const,
engine: Engine.Generic,
metadata: {},
};
engine.getMediaMetadata.mockResolvedValue(new Map([[query, queryResults]]));
expect(await manager.getMediaMetadata()).toBeNull();
});
it.each([['days'], ['tags'], ['where'], ['what']])(
'with %s',
async (metadataType: string) => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const engine = mock<CameraManagerEngine>();
const manager = createCameraManager(api, engine);
await manager.initializeCamerasFromConfig();
const metadata: MediaMetadata = {
[metadataType]: new Set(['data']),
};
const queryResults = {
type: QueryResultsType.MediaMetadata as const,
engine: Engine.Generic,
metadata: metadata,
};
engine.getMediaMetadata.mockResolvedValue(new Map([[query, queryResults]]));
expect(await manager.getMediaMetadata()).toEqual(metadata);
},
);
});
describe('should get events', () => {
it('should handle missing hass', async () => {
const manager = createCameraManager(createCardAPI());
expect(await manager.getEvents(baseEventQuery)).toEqual(new Map());
});
it('should handle missing cameras', async () => {
const api = createCardAPI();
const hass = createHASS();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
const manager = createCameraManager(api);
expect(await manager.getEvents(baseEventQuery)).toEqual(new Map());
});
it('should succeed', async () => {
const api = createCardAPI();
const hass = createHASS();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
const engine = mock<CameraManagerEngine>();
const manager = createCameraManager(api, engine);
await manager.initializeCamerasFromConfig();
const engineOptions = {};
const results = new Map([[baseEventQuery, baseEventQueryResults]]);
engine.getEvents.mockResolvedValue(results);
expect(await manager.getEvents(baseEventQuery, engineOptions)).toEqual(results);
expect(engine.getEvents).toBeCalledWith(
hass,
expect.anything(),
baseEventQuery,
engineOptions,
);
});
});
describe('should review media', () => {
it('should handle missing camera', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const manager = createCameraManager(api);
const media = new TestViewMedia();
await manager.reviewMedia(media, true);
});
it('should succeed', async () => {
const api = createCardAPI();
const hass = createHASS();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
const engine = mock<CameraManagerEngine>();
vi.mocked(engine.getEngineType).mockReturnValue(Engine.Generic);
const manager = createCameraManager(api, engine);
await manager.initializeCamerasFromConfig();
const media = new TestViewMedia({ cameraID: 'id' });
await manager.reviewMedia(media, true);
expect(engine.reviewMedia).toBeCalledWith(hass, expect.anything(), media, true);
});
});
describe('should get recordings', () => {
it('should succeed', async () => {
const api = createCardAPI();
const hass = createHASS();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
const engine = mock<CameraManagerEngine>();
const manager = createCameraManager(api, engine);
await manager.initializeCamerasFromConfig();
const engineOptions = {};
const results = new Map([[baseRecordingQuery, baseRecordingQueryResults]]);
engine.getRecordings.mockResolvedValue(results);
expect(await manager.getRecordings(baseRecordingQuery, engineOptions)).toEqual(
results,
);
});
});
describe('should get recording segments', () => {
const query = {
type: QueryType.RecordingSegments as const,
cameraIDs: new Set(['id']),
start: new Date(),
end: new Date(),
};
const queryResults = {
type: QueryResultsType.RecordingSegments as const,
engine: Engine.Generic,
segments: [],
};
it('should succeed', async () => {
const api = createCardAPI();
const hass = createHASS();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
const engine = mock<CameraManagerEngine>();
const manager = createCameraManager(api, engine);
await manager.initializeCamerasFromConfig();
const engineOptions = {};
const results = new Map([[query, queryResults]]);
engine.getRecordingSegments.mockResolvedValue(results);
expect(await manager.getRecordingSegments(query, engineOptions)).toEqual(results);
});
});
describe('should execute media queries', () => {
it('should handle events', async () => {
const api = createCardAPI();
const hass = createHASS();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
const engine = mock<CameraManagerEngine>();
vi.mocked(engine.getEngineType).mockReturnValue(Engine.Generic);
const manager = createCameraManager(api, engine);
await manager.initializeCamerasFromConfig();
const results = new Map([[baseEventQuery, baseEventQueryResults]]);
engine.getEvents.mockResolvedValue(results);
const media = sortItems(generateViewMediaArray({ count: 5 }));
engine.generateMediaFromEvents.mockReturnValue(media);
expect(await manager.executeMediaQueries([baseEventQuery])).toEqual(media);
});
it('should handle no converted media', async () => {
const api = createCardAPI();
const hass = createHASS();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
const engine = mock<CameraManagerEngine>();
vi.mocked(engine.getEngineType).mockReturnValue(Engine.Generic);
const manager = createCameraManager(api, engine);
await manager.initializeCamerasFromConfig();
const results = new Map([[baseEventQuery, baseEventQueryResults]]);
engine.getEvents.mockResolvedValue(results);
engine.generateMediaFromEvents.mockReturnValue(null);
expect(await manager.executeMediaQueries([baseEventQuery])).toEqual([]);
});
it('should handle missing camera engine during conversion', async () => {
const api = createCardAPI();
const hass = createHASS();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
const engine = mock<CameraManagerEngine>();
vi.mocked(engine.getEngineType).mockReturnValue(Engine.Generic);
const manager = createCameraManager(api, engine);
await manager.initializeCamerasFromConfig();
const results = new Map([
[baseEventQuery, { ...baseEventQueryResults, engine: Engine.MotionEye }],
]);
engine.getEvents.mockResolvedValue(results);
expect(await manager.executeMediaQueries([baseEventQuery])).toEqual([]);
});
it('should handle recordings', async () => {
const api = createCardAPI();
const hass = createHASS();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
const engine = mock<CameraManagerEngine>();
vi.mocked(engine.getEngineType).mockReturnValue(Engine.Generic);
const manager = createCameraManager(api, engine);
await manager.initializeCamerasFromConfig();
const results = new Map([[baseRecordingQuery, baseRecordingQueryResults]]);
engine.getRecordings.mockResolvedValue(results);
const media = sortItems(generateViewMediaArray({ count: 5 }));
engine.generateMediaFromRecordings.mockReturnValue(media);
expect(await manager.executeMediaQueries([baseRecordingQuery])).toEqual(media);
});
it('should handle missing hass', async () => {
const engine = mock<CameraManagerEngine>();
vi.mocked(engine.getEngineType).mockReturnValue(Engine.Generic);
const manager = createCameraManager(createCardAPI(), engine);
const results = new Map([[baseEventQuery, baseEventQueryResults]]);
engine.getEvents.mockResolvedValue(results);
expect(await manager.executeMediaQueries([baseEventQuery])).toEqual([]);
});
describe('should merge compatible queries', () => {
it('should merge queries with identical properties', async () => {
const api = createCardAPI();
const engine = mock<CameraManagerEngine>();
vi.mocked(engine.getEngineType).mockReturnValue(Engine.Generic);
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const manager = createCameraManager(api, engine, [
{ config: createCameraConfig({ ...baseCameraConfig, id: 'cam1' }) },
{ config: createCameraConfig({ ...baseCameraConfig, id: 'cam2' }) },
]);
await manager.initializeCamerasFromConfig();
const query1: EventQuery = {
source: QuerySource.Camera,
type: QueryType.Event,
cameraIDs: new Set(['cam1']),
reviewed: false,
};
const query2: EventQuery = {
source: QuerySource.Camera,
type: QueryType.Event,
cameraIDs: new Set(['cam2']),
reviewed: false,
};
engine.getEvents.mockResolvedValue(new Map());
engine.generateMediaFromEvents.mockReturnValue([]);
await manager.executeMediaQueries([query1, query2]);
// Should be called once with merged cameraIDs
expect(engine.getEvents).toHaveBeenCalledTimes(1);
expect(engine.getEvents).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
cameraIDs: new Set(['cam1', 'cam2']),
reviewed: false,
}),
undefined,
);
});
it('should not merge queries with different properties', async () => {
const api = createCardAPI();
const engine = mock<CameraManagerEngine>();
vi.mocked(engine.getEngineType).mockReturnValue(Engine.Generic);
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const manager = createCameraManager(api, engine, [
{ config: createCameraConfig({ ...baseCameraConfig, id: 'cam1' }) },
{ config: createCameraConfig({ ...baseCameraConfig, id: 'cam2' }) },
]);
await manager.initializeCamerasFromConfig();
const query1: EventQuery = {
source: QuerySource.Camera,
type: QueryType.Event,
cameraIDs: new Set(['cam1']),
reviewed: false,
};
const query2: EventQuery = {
source: QuerySource.Camera,
type: QueryType.Event,
cameraIDs: new Set(['cam2']),
reviewed: true,
};
engine.getEvents.mockResolvedValue(new Map());
engine.generateMediaFromEvents.mockReturnValue([]);
await manager.executeMediaQueries([query1, query2]);
// Should be called twice for different queries
expect(engine.getEvents).toHaveBeenCalledTimes(2);
});
it('should handle single query without merging', async () => {
const api = createCardAPI();
const engine = mock<CameraManagerEngine>();
vi.mocked(engine.getEngineType).mockReturnValue(Engine.Generic);
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const manager = createCameraManager(api, engine);
await manager.initializeCamerasFromConfig();
engine.getEvents.mockResolvedValue(
new Map([[baseEventQuery, baseEventQueryResults]]),
);
engine.generateMediaFromEvents.mockReturnValue([]);
await manager.executeMediaQueries([baseEventQuery]);
expect(engine.getEvents).toHaveBeenCalledTimes(1);
expect(engine.getEvents).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
baseEventQuery,
undefined,
);
});
});
});
describe('should extend media queries', () => {
const dateBase = new Date('2024-03-01T20:01:00');
const mediaTwoCameras = generateViewMediaArray({ count: 5 });
const mediaMixedStart: ViewItem[] = [
new TestViewMedia({
startTime: dateBase,
}),
new TestViewMedia({
startTime: add(dateBase, { days: 1 }),
}),
new TestViewMedia({
startTime: add(dateBase, { days: 2 }),
}),
new ViewFolder(createFolder(), []),
];
it('should handle missing hass', async () => {
const engine = mock<CameraManagerEngine>();
vi.mocked(engine.getEngineType).mockReturnValue(Engine.Generic);
const manager = createCameraManager(createCardAPI(), engine);
expect(await manager.extendMediaQueries([baseEventQuery], [], 'later')).toBeNull();
});
it.each([
['empty query and results', new Map(), [], [], [], null],
[
'query without existing media',
new Map([[baseEventQuery, baseEventQueryResults]]),
[],
[{ ...baseEventQuery, limit: 50 }],
generateViewMediaArray({ count: 5 }),
{
queries: [{ ...baseEventQuery, limit: 50 }],
results: sortItems(generateViewMediaArray({ count: 5 })),
},
],
[
'query that extends existing results',
new Map([[baseEventQuery, baseEventQueryResults]]),
generateViewMediaArray({ count: 5, cameraIDs: ['kitchen'] }),
[{ ...baseEventQuery, limit: 50 }],
generateViewMediaArray({ count: 5, cameraIDs: ['office'] }),
{
queries: [{ ...baseEventQuery, limit: 50 }],
results: sortItems(mediaTwoCameras),
},
],
[
'query with existing media but no new media',
new Map([[baseEventQuery, baseEventQueryResults]]),
mediaTwoCameras,
[
{
...baseEventQuery,
limit: 50,
},
],
// Fetch identical media again.
mediaTwoCameras,
// Returns null to signify nothing new.
null,
],
[
'query fetching later',
new Map([[{ ...baseEventQuery, start: dateBase }, baseEventQueryResults]]),
mediaMixedStart,
[
{
...baseEventQuery,
limit: 50,
start: add(dateBase, { days: 2 }),
},
],
mediaTwoCameras,
{
queries: [{ ...baseEventQuery, limit: 50, start: dateBase }],
results: sortItems(mediaMixedStart.concat(mediaTwoCameras)),
},
'later' as const,
],
[
'query fetching earlier',
new Map([[{ ...baseEventQuery, start: dateBase }, baseEventQueryResults]]),
mediaMixedStart,
[
{
...baseEventQuery,
limit: 50,
end: dateBase,
},
],
mediaTwoCameras,
{
queries: [{ ...baseEventQuery, limit: 50, start: dateBase }],
results: sortItems(mediaMixedStart.concat(mediaTwoCameras)),
},
'earlier' as const,
],
])(
'handles %s',
async (
_name: string,
// The previously submitted query & results.
inputQueries: Map<EventQuery, EventQueryResults>,
// The previously received media.
inputResults: ViewItem[],
// The queries expected to be dispatched.
newChunkQueries: EventQuery[],
// The media received from the new queries.
outputMediaResults: ViewMedia[],
// The expect extended queries and results.
expected?: {
queries: EventQuery[];
results: ViewItem[];
} | null,
direction?: 'earlier' | 'later',
) => {
const engine = mock<CameraManagerEngine>();
vi.mocked(engine.getEngineType).mockReturnValue(Engine.Generic);
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const manager = createCameraManager(api, engine);
await manager.initializeCamerasFromConfig();
engine.getEvents.mockResolvedValue(inputQueries);
engine.generateMediaFromEvents.mockReturnValue(outputMediaResults);
expect(
await manager.extendMediaQueries(
[...inputQueries.keys()],
inputResults,
direction ?? 'later',
),
).toEqual(expected);
// Make sure the issued queries are correct.
for (const query of newChunkQueries) {
expect(engine.getEvents).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
query,
undefined,
);
}
},
);
});
describe('should get media download path', () => {
it('should handle missing camera', async () => {
const manager = createCameraManager(createCardAPI());
expect(await manager.getMediaDownloadPath(new TestViewMedia())).toBeNull();
});
it('should handle missing hass', async () => {
const api = createCardAPI();
const manager = createCameraManager(api);
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
await manager.initializeCamerasFromConfig();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(null);
expect(await manager.getMediaDownloadPath(new TestViewMedia())).toBeNull();
});
it('should succeed', async () => {
const api = createCardAPI();
const engine = mock<CameraManagerEngine>();
const manager = createCameraManager(api, engine);
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
await manager.initializeCamerasFromConfig();
const result: Endpoint = {
endpoint: 'http://localhost/path/to/media',
};
vi.mocked(engine.getMediaDownloadPath).mockResolvedValue(result);
expect(
await manager.getMediaDownloadPath(new TestViewMedia({ cameraID: 'id' })),
).toBe(result);
});
});
describe('should get media capabilities', () => {
it('should handle missing camera', async () => {
const manager = createCameraManager(createCardAPI());
expect(manager.getMediaCapabilities(new TestViewMedia())).toBeNull();
});
it('should succeed', async () => {
const api = createCardAPI();
const engine = mock<CameraManagerEngine>();
const manager = createCameraManager(api, engine);
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
await manager.initializeCamerasFromConfig();
const result: ViewItemCapabilities = {
canFavorite: false,
canDownload: false,
};
vi.mocked(engine.getMediaCapabilities).mockReturnValue(result);
expect(manager.getMediaCapabilities(new TestViewMedia({ cameraID: 'id' }))).toBe(
result,
);
});
});
describe('should favorite media', () => {
it('should handle missing camera', async () => {
const engine = mock<CameraManagerEngine>();
const manager = createCameraManager(createCardAPI(), engine);
manager.favoriteMedia(new TestViewMedia(), true);
expect(engine.favoriteMedia).not.toBeCalled();
});
it('should succeed', async () => {
const api = createCardAPI();
const engine = mock<CameraManagerEngine>();
const manager = createCameraManager(api, engine);
const hass = createHASS();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
await manager.initializeCamerasFromConfig();
const media = new TestViewMedia({ cameraID: 'id' });
manager.favoriteMedia(media, true);
expect(engine.favoriteMedia).toBeCalledWith(hass, expect.anything(), media, true);
});
});
describe('should get camera endpoints', () => {
it('should handle missing camera', () => {
const manager = createCameraManager(createCardAPI());
expect(manager.getCameraEndpoints('BAD')).toBeNull();
});
it('should succeed', async () => {
const api = createCardAPI();
const engine = mock<CameraManagerEngine>();
const manager = createCameraManager(api, engine);
const hass = createHASS();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
await manager.initializeCamerasFromConfig();
expect(manager.getCameraEndpoints('id', { view: 'live' })).toBeDefined();
});
});
describe('should get camera metadata', () => {
it('should handle missing camera', () => {
const manager = createCameraManager(createCardAPI());
expect(manager.getCameraMetadata('BAD')).toBeNull();
});
it('should succeed', async () => {
const api = createCardAPI();
const engine = mock<CameraManagerEngine>();
const manager = createCameraManager(api, engine);
const hass = createHASS();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
await manager.initializeCamerasFromConfig();
const result: CameraManagerCameraMetadata = {
title: 'My Camera',
icon: {
icon: 'mdi:camera',
},
};
vi.mocked(engine.getCameraMetadata).mockReturnValue(result);
expect(manager.getCameraMetadata('id')).toBe(result);
});
});
describe('should get camera capabilities', () => {
it('should handle missing camera', () => {
const manager = createCameraManager(createCardAPI());
expect(manager.getCameraCapabilities('BAD')).toBeNull();
});
it('should succeed', async () => {
const api = createCardAPI();
const engine = mock<CameraManagerEngine>();
const manager = createCameraManager(api, engine);
const hass = createHASS();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
await manager.initializeCamerasFromConfig();
expect(manager.getCameraCapabilities('id')).toEqual(createCapabilities());
});
});
describe('should get aggregate camera capabilities', () => {
it('should handle missing camera', () => {
const manager = createCameraManager(createCardAPI());
const capabilities = manager.getAggregateCameraCapabilities();
expect(capabilities.has('favorite-events')).toBeFalsy();
expect(capabilities.has('favorite-recordings')).toBeFalsy();
expect(capabilities.has('seek')).toBeFalsy();
expect(capabilities.has('live')).toBeFalsy();
expect(capabilities.has('clips')).toBeFalsy();
expect(capabilities.has('recordings')).toBeFalsy();
expect(capabilities.has('snapshots')).toBeFalsy();
});
it('should succeed', async () => {
const api = createCardAPI();
const manager = createCameraManager(api, mock<CameraManagerEngine>(), [
{
capabilties: new Capabilities({
'favorite-events': false,
'favorite-recordings': false,
seek: false,
live: false,
clips: false,
recordings: false,
snapshots: false,
}),
},
{
config: createCameraConfig({ baseCameraConfig, id: 'another' }),
capabilties: new Capabilities({
'favorite-events': true,
'favorite-recordings': true,
seek: true,
live: true,
clips: true,
recordings: true,
snapshots: true,
ptz: {
left: [PTZMovementType.Continuous],
},
}),
},
]);
const hass = createHASS();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
await manager.initializeCamerasFromConfig();
const capabilities = manager.getAggregateCameraCapabilities();
expect(capabilities.has('favorite-events')).toBeTruthy();
expect(capabilities.has('favorite-recordings')).toBeTruthy();
expect(capabilities.has('seek')).toBeTruthy();
expect(capabilities.has('live')).toBeTruthy();
expect(capabilities.has('clips')).toBeTruthy();
expect(capabilities.has('recordings')).toBeTruthy();
expect(capabilities.has('snapshots')).toBeTruthy();
});
});
describe('should execute PTZ action', () => {
it('should handle missing camera', () => {
const engine = mock<CameraManagerEngine>();
const manager = createCameraManager(createCardAPI(), engine);
manager.executePTZAction('id', 'left', {});
// No visible action.
});
it('should succeed with null hass', async () => {
const api = createCardAPI();
const engine = mock<CameraManagerEngine>();
const hass = createHASS();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
const action = {
action: 'perform-action' as const,
perform_action: 'action',
};
const manager = createCameraManager(api, engine, [
{
config: createCameraConfig({
baseCameraConfig,
id: 'another',
ptz: {
actions_left: action,
},
}),
},
]);
await manager.initializeCamerasFromConfig();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(null);
manager.executePTZAction('another', 'left');
expect(api.getActionsManager().executeActions).toBeCalledWith({
actions: action,
});
});
it('should succeed', async () => {
const api = createCardAPI();
const engine = mock<CameraManagerEngine>();
const hass = createHASS();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
const action = {
action: 'perform-action' as const,
perform_action: 'action',
};
const manager = createCameraManager(api, engine, [
{
config: createCameraConfig({
baseCameraConfig,
id: 'another',
ptz: {
actions_left: action,
},
}),
},
]);
await manager.initializeCamerasFromConfig();
manager.executePTZAction('another', 'left');
expect(api.getActionsManager().executeActions).toBeCalledWith({ actions: action });
});
describe('with rotation', () => {
it.each([
// No rotation
[undefined, 'left', 'left', undefined],
[undefined, 'right', 'right', undefined],
[undefined, 'up', 'up', undefined],
[undefined, 'down', 'down', undefined],
[0, 'left', 'left', undefined],
[0, 'right', 'right', undefined],
[0, 'up', 'up', undefined],
[0, 'down', 'down', undefined],
// 90° rotation (clockwise view rotation means controls rotate counter-clockwise)
[90, 'left', 'down', undefined],
[90, 'right', 'up', undefined],
[90, 'up', 'left', undefined],
[90, 'down', 'right', undefined],
// 180° rotation
[180, 'left', 'right', undefined],
[180, 'right', 'left', undefined],
[180, 'up', 'down', undefined],
[180, 'down', 'up', undefined],
// 270° rotation
[270, 'left', 'up', undefined],
[270, 'right', 'down', undefined],
[270, 'up', 'right', undefined],
[270, 'down', 'left', undefined],
// Non-directional actions should pass through unchanged
[90, 'zoom_in', 'zoom_in', undefined],
[90, 'zoom_out', 'zoom_out', undefined],
[90, 'preset', 'preset', 'test-preset'],
[180, 'zoom_in', 'zoom_in', undefined],
[180, 'zoom_out', 'zoom_out', undefined],
[180, 'preset', 'preset', 'test-preset'],
[270, 'zoom_in', 'zoom_in', undefined],
[270, 'zoom_out', 'zoom_out', undefined],
[270, 'preset', 'preset', 'test-preset'],
] as const)(
'rotates %s° %s to %s',
async (rotation, inputAction, expectedAction, preset) => {
const api = createCardAPI();
const engine = mock<CameraManagerEngine>();
const hass = createHASS();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
const leftAction = {
action: 'perform-action' as const,
perform_action: 'left-action',
};
const rightAction = {
action: 'perform-action' as const,
perform_action: 'right-action',
};
const upAction = {
action: 'perform-action' as const,
perform_action: 'up-action',
};
const downAction = {
action: 'perform-action' as const,
perform_action: 'down-action',
};
const zoomInAction = {
action: 'perform-action' as const,
perform_action: 'zoom-in-action',
};
const zoomOutAction = {
action: 'perform-action' as const,
perform_action: 'zoom-out-action',
};
const presetAction = {
action: 'perform-action' as const,
perform_action: 'preset-action',
};
const manager = createCameraManager(api, engine, [
{
config: createCameraConfig({
baseCameraConfig,
id: 'rotated-camera',
dimensions: rotation !== undefined ? { rotation } : undefined,
ptz: {
actions_left: leftAction,
actions_right: rightAction,
actions_up: upAction,
actions_down: downAction,
actions_zoom_in: zoomInAction,
actions_zoom_out: zoomOutAction,
presets: {
'test-preset': presetAction,
},
},
}),
},
]);
await manager.initializeCamerasFromConfig();
manager.executePTZAction(
'rotated-camera',
inputAction,
preset ? { preset } : undefined,
);
// Map expected action to the corresponding action object
const expectedActionMap = {
left: leftAction,
right: rightAction,
up: upAction,
down: downAction,
zoom_in: zoomInAction,
zoom_out: zoomOutAction,
preset: presetAction,
};
expect(api.getActionsManager().executeActions).toBeCalledWith({
actions: expectedActionMap[expectedAction],
});
},
);
});
});
describe('should determine if queries are fresh', () => {
beforeAll(() => {
const start = new Date('2024-03-02T20:35:00');
vi.useFakeTimers();
vi.setSystemTime(start);
});
afterAll(() => {
vi.useRealTimers();
});
it.each([
['not fresh', new Date('2024-03-02T20:32:00'), false],
['fresh on lower bound', new Date('2024-03-02T20:34:00'), true],
['fresh at current time', new Date('2024-03-02T20:35:00'), true],
['fresh in the future', new Date('2024-03-02T20:40:00'), true],
[
'unknown camera',
new Date('2024-03-02T20:35:00'),
// Default assumed to be fresh.
true,
[
{
...baseEventQuery,
cameraIDs: new Set(['BAD']),
},
],
],
])(
'%s',
async (
_name: string,
resultsTimestamp: Date,
expectedFresh: boolean,
queries: EventQuery[] = [baseEventQuery],
) => {
const api = createCardAPI();
const engine = mock<CameraManagerEngine>();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const manager = createCameraManager(api, engine);
await manager.initializeCamerasFromConfig();
engine.getQueryResultMaxAge.mockReturnValue(60);
expect(manager.areMediaQueriesResultsFresh(resultsTimestamp, queries)).toBe(
expectedFresh,
);
},
);
it('should always return false for null queries', () => {
const manager = createCameraManager(createCardAPI(), mock<CameraManagerEngine>());
expect(manager.areMediaQueriesResultsFresh(new Date(), null)).toBe(false);
});
});
describe('should get media seek time', () => {
const startTime = new Date('2024-03-02T20:52:00');
const endTime = new Date('2024-03-02T20:53:00');
const middleTime = new Date('2024-03-02T20:52:30');
describe('invalid requests', () => {
it.each([
['null start and end', null, null, middleTime],
['no start', null, endTime, middleTime],
['no end', startTime, null, middleTime],
['target < start', endTime, endTime, startTime],
['target > end', startTime, startTime, endTime],
])(
'%s',
async (_name: string, start: Date | null, end: Date | null, target: Date) => {
const api = createCardAPI();
const engine = mock<CameraManagerEngine>();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const manager = createCameraManager(api, engine);
await manager.initializeCamerasFromConfig();
expect(
await manager.getMediaSeekTime(
new TestViewMedia({ startTime: start, endTime: end }),
target,
),
).toBeNull();
},
);
});
it('should succeed', async () => {
const api = createCardAPI();
const engine = mock<CameraManagerEngine>();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const manager = createCameraManager(api, engine);
await manager.initializeCamerasFromConfig();
engine.getMediaSeekTime.mockResolvedValue(42);
const media = new TestViewMedia({
cameraID: 'id',
startTime: startTime,
endTime: endTime,
});
expect(await manager.getMediaSeekTime(media, middleTime)).toBe(42);
expect(engine.getMediaSeekTime).toBeCalledWith(
expect.anything(),
expect.anything(),
media,
middleTime,
);
});
it('should handle null return value', async () => {
const api = createCardAPI();
const engine = mock<CameraManagerEngine>();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const manager = createCameraManager(api, engine);
await manager.initializeCamerasFromConfig();
engine.getMediaSeekTime.mockResolvedValue(null);
const media = new TestViewMedia({
cameraID: 'id',
startTime: startTime,
endTime: endTime,
});
expect(await manager.getMediaSeekTime(media, middleTime)).toBeNull();
});
});
it('should destroy', async () => {
const api = createCardAPI();
const engine = mock<CameraManagerEngine>();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const manager = createCameraManager(api, engine);
await manager.initializeCamerasFromConfig();
expect(manager.getStore().getCameraCount()).toBe(1);
await manager.destroy();
expect(manager.getStore().getCameraCount()).toBe(0);
});
});