Files
advanced-camera-card/tests/card-controller/call/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

1685 lines
57 KiB
TypeScript

// @vitest-environment jsdom
import { PartialDeep } from 'type-fest';
import { assert, beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { CameraManagerStore } from '../../../src/camera-manager/store';
import { CallManager } from '../../../src/card-controller/call/manager';
import { Ringtone } from '../../../src/card-controller/call/ringtone';
import { CardController } from '../../../src/card-controller/controller';
import { SubstreamViewModifier } from '../../../src/card-controller/view/modifiers/substream';
import { ConditionStateChange } from '../../../src/condition-trigger/conditions/types';
import { RingtoneConfig } from '../../../src/config/schema/live';
import { AdvancedCameraCardConfig } from '../../../src/config/schema/types';
import { View } from '../../../src/view/view';
import {
createCameraConfig,
createCameraManager,
createCapabilities,
createCardAPI,
createConfig,
createStore,
createView,
} from '../../test-utils';
// Replace Ringtone with a fresh `mock<Ringtone>()` per construction so each
// CallManager gets an isolated, type-safe ringtone we can assert on. The
// real Ringtone creates an AudioContext, which we never want in tests.
vi.mock('../../../src/card-controller/call/ringtone', () => ({
Ringtone: vi.fn().mockImplementation(() => mock<Ringtone>()),
}));
// Each test creates a new CallManager which constructs a new Ringtone, so the
// most recent constructor result is always this test's mock.
const getRingtone = (): Ringtone => {
const results = vi.mocked(Ringtone).mock.results;
const last = results.at(-1);
assert(last);
return last.value;
};
// A store with a single 2-way-audio-capable camera.
const createCallableStore = (cameraID = 'camera.office'): CameraManagerStore =>
createStore([
{
cameraID,
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
},
]);
const createAPI = (options?: {
view?: View | null;
store?: CameraManagerStore;
microphoneSupported?: boolean;
microphoneForbidden?: boolean;
microphoneConnected?: boolean;
microphoneMuted?: boolean;
config?: PartialDeep<AdvancedCameraCardConfig>;
}): CardController => {
const api = createCardAPI();
vi.mocked(api.getViewManager().getView).mockReturnValue(options?.view ?? null);
vi.mocked(api.getCameraManager).mockReturnValue(
createCameraManager(options?.store ?? createCallableStore()),
);
vi.mocked(api.getMicrophoneManager().isSupported).mockReturnValue(
options?.microphoneSupported ?? true,
);
vi.mocked(api.getMicrophoneManager().isForbidden).mockReturnValue(
options?.microphoneForbidden ?? false,
);
vi.mocked(api.getMicrophoneManager().isConnected).mockReturnValue(
options?.microphoneConnected ?? true,
);
vi.mocked(api.getMicrophoneManager().isMuted).mockReturnValue(
options?.microphoneMuted ?? true,
);
if (options?.config) {
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig(options.config),
);
}
return api;
};
// The condition-state listener a CallManager registers in its constructor.
const getConditionStateListener = (
api: CardController,
): ((change: ConditionStateChange) => void) => {
const listener = vi.mocked(api.getConditionStateManager().addListener).mock
.calls[0]?.[0];
assert(listener);
return listener;
};
describe('isActive', () => {
it('should report inactive before a call starts', () => {
expect(new CallManager(createCardAPI()).isActive()).toBe(false);
});
it('should report active during a call', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
const manager = new CallManager(api);
expect(await manager.start()).toBe(true);
expect(manager.isActive()).toBe(true);
// The call runs on the parent camera's own stream, so callCameraID is
// absent. Outbound calls are answered by construction.
expect(manager.getCall()).toEqual({
cameraID: 'camera.office',
previousView: expect.any(View),
inbound: false,
answered: true,
});
expect(manager.getCall()?.previousView?.view).toBe('live');
});
});
describe('start', () => {
it('should do nothing without a view camera', async () => {
const api = createAPI({ view: createView({ camera: null }) });
expect(await new CallManager(api).start()).toBe(false);
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
});
it('should do nothing when already active for the camera', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
const manager = new CallManager(api);
expect(await manager.start()).toBe(true);
vi.mocked(api.getViewManager().setViewByParameters).mockClear();
expect(await manager.start()).toBe(true);
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
});
it('should start a call on the selected camera', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
expect(await new CallManager(api).start()).toBe(true);
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
modifiers: [expect.any(SubstreamViewModifier)],
force: true,
});
expect(api.getConditionStateManager().setState).toBeCalledWith({ call: true });
});
it('should navigate to the live view when started from elsewhere', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office', view: 'clips' }),
});
expect(await new CallManager(api).start()).toBe(true);
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
params: { view: 'live', camera: 'camera.office' },
modifiers: [expect.any(SubstreamViewModifier)],
force: true,
});
});
it('should evolve the current view without params when already in live', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office', view: 'live' }),
});
expect(await new CallManager(api).start()).toBe(true);
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
modifiers: [expect.any(SubstreamViewModifier)],
force: true,
});
expect(api.getViewManager().setViewByParameters).not.toBeCalledWith(
expect.objectContaining({ params: expect.anything() }),
);
});
it('should record the view present when the call started', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office', view: 'clips' }),
});
const manager = new CallManager(api);
expect(await manager.start()).toBe(true);
const call = manager.getCall();
expect(call?.previousView?.view).toBe('clips');
expect(call?.previousView?.camera).toBe('camera.office');
// Query results are dropped so they are re-fetched fresh on restore.
expect(call?.previousView?.queryResults).toBeNull();
});
it('should record the live view when the call starts from live', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office', view: 'live' }),
});
const manager = new CallManager(api);
expect(await manager.start()).toBe(true);
const call = manager.getCall();
expect(call?.previousView?.view).toBe('live');
expect(call?.previousView?.camera).toBe('camera.office');
});
it('should keep the original pre-call view when a call supersedes another', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office', view: 'clips' }),
store: createStore([
{
cameraID: 'camera.office',
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
},
{
cameraID: 'camera.garage',
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
},
]),
});
const manager = new CallManager(api);
expect(await manager.start()).toBe(true);
expect(await manager.start({ cameraID: 'camera.garage' })).toBe(true);
const call = manager.getCall();
expect(call?.cameraID).toBe('camera.garage');
expect(call?.previousView?.view).toBe('clips');
expect(call?.previousView?.camera).toBe('camera.office');
});
it('should start a call from a non-camera view when a camera is explicit', async () => {
const api = createAPI({
view: createView({ camera: null, view: 'folder' }),
store: createCallableStore('camera.office'),
});
const manager = new CallManager(api);
expect(await manager.start({ cameraID: 'camera.office' })).toBe(true);
const call = manager.getCall();
expect(call?.cameraID).toBe('camera.office');
expect(call?.previousView?.view).toBe('folder');
expect(call?.previousView?.camera).toBeNull();
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
params: { view: 'live', camera: 'camera.office' },
modifiers: [expect.any(SubstreamViewModifier)],
force: true,
});
});
it('should start the call on an explicit camera and navigate there', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
store: createStore([
{
cameraID: 'camera.office',
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
},
{
cameraID: 'camera.garage',
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
},
]),
});
const manager = new CallManager(api);
expect(await manager.start({ cameraID: 'camera.garage' })).toBe(true);
const call = manager.getCall();
expect(call?.cameraID).toBe('camera.garage');
expect(call?.previousView?.view).toBe('live');
expect(call?.previousView?.camera).toBe('camera.office');
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
params: { view: 'live', camera: 'camera.garage' },
modifiers: [expect.any(SubstreamViewModifier)],
force: true,
});
});
it('should start a call on an explicit stream of the parent camera', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
store: createStore([
{
cameraID: 'camera.office',
config: createCameraConfig({
dependencies: { cameras: ['camera.doorbell'] },
}),
capabilities: createCapabilities({ live: true }),
},
{
cameraID: 'camera.doorbell',
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
},
]),
});
const manager = new CallManager(api);
expect(
await manager.start({ cameraID: 'camera.office', streamID: 'camera.doorbell' }),
).toBe(true);
const call = manager.getCall();
expect(call?.cameraID).toBe('camera.office');
expect(call?.callCameraID).toBe('camera.doorbell');
expect(call?.previousView?.view).toBe('live');
});
it('should abort when the requested camera is not a live camera', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
expect(await new CallManager(api).start({ cameraID: 'camera.unknown' })).toBe(false);
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
expect(api.getNotificationManager().setNotification).toBeCalled();
});
it('should abort when the requested stream is not 2-way audio of the parent camera', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
store: createStore([
{
cameraID: 'camera.office',
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
},
{
cameraID: 'camera.unrelated',
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
},
]),
});
expect(
await new CallManager(api).start({
cameraID: 'camera.office',
streamID: 'camera.unrelated',
}),
).toBe(false);
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
expect(api.getNotificationManager().setNotification).toBeCalled();
});
it('should supersede an active call on a different camera', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
store: createStore([
{
cameraID: 'camera.office',
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
},
{
cameraID: 'camera.garage',
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
},
]),
});
const manager = new CallManager(api);
expect(await manager.start()).toBe(true);
expect(await manager.start({ cameraID: 'camera.garage' })).toBe(true);
const call = manager.getCall();
expect(call?.cameraID).toBe('camera.garage');
expect(call?.previousView?.view).toBe('live');
expect(call?.previousView?.camera).toBe('camera.office');
expect(api.getConditionStateManager().setState).toBeCalledWith({ call: false });
expect(api.getConditionStateManager().setState).toBeCalledWith({ call: true });
});
it('should restart on the same camera with a different stream', async () => {
const api = createAPI({
store: createStore([
{
cameraID: 'camera.office',
config: createCameraConfig({
dependencies: { cameras: ['camera.doorbell', 'camera.intercom'] },
}),
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
},
{
cameraID: 'camera.doorbell',
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
},
{
cameraID: 'camera.intercom',
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
},
]),
});
// The first call engages `camera.doorbell`; the view then reflects that
// substream, as it would at runtime when the second call_start arrives.
vi.mocked(api.getViewManager().getView)
.mockReturnValueOnce(createView({ camera: 'camera.office' }))
.mockReturnValue(
createView({
camera: 'camera.office',
context: {
live: { overrides: new Map([['camera.office', 'camera.doorbell']]) },
},
}),
);
const manager = new CallManager(api);
expect(
await manager.start({ cameraID: 'camera.office', streamID: 'camera.doorbell' }),
).toBe(true);
expect(
await manager.start({ cameraID: 'camera.office', streamID: 'camera.intercom' }),
).toBe(true);
// The restarted call carries the new stream; the recorded pre-call view
// keeps the genuine pre-call substream (none -- the camera's own stream), not the
// superseded call's engaged `camera.doorbell`.
const call = manager.getCall();
expect(call?.cameraID).toBe('camera.office');
expect(call?.callCameraID).toBe('camera.intercom');
expect(
call?.previousView?.context?.live?.overrides?.get('camera.office'),
).toBeUndefined();
});
it('should abort when no stream supports 2-way audio', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
store: createStore([
{ cameraID: 'camera.office', capabilities: createCapabilities({ live: true }) },
]),
});
expect(await new CallManager(api).start()).toBe(false);
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
expect(api.getNotificationManager().setNotification).toBeCalled();
});
it('should engage the active substream when it is call-capable', async () => {
const api = createAPI({
view: createView({
camera: 'camera.office',
context: { live: { overrides: new Map([['camera.office', 'camera.sub']]) } },
}),
store: createStore([
{
cameraID: 'camera.office',
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
},
{
cameraID: 'camera.sub',
capabilities: createCapabilities({ '2-way-audio': true }),
},
]),
});
const manager = new CallManager(api);
expect(await manager.start()).toBe(true);
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
modifiers: [expect.any(SubstreamViewModifier)],
force: true,
});
// The pre-call substream is captured in the recorded view's context so it
// can be restored on call end.
expect(
manager.getCall()?.previousView?.context?.live?.overrides?.get('camera.office'),
).toBe('camera.sub');
});
it('should fall back to a call-capable dependency when the parent lacks audio', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
store: createStore([
{
cameraID: 'camera.office',
config: createCameraConfig({ dependencies: { cameras: ['camera.doorbell'] } }),
capabilities: createCapabilities({ live: true }),
},
{
cameraID: 'camera.doorbell',
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
},
]),
});
expect(await new CallManager(api).start()).toBe(true);
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
modifiers: [expect.any(SubstreamViewModifier)],
force: true,
});
});
it('should abort when the microphone is unsupported', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
microphoneSupported: false,
});
expect(await new CallManager(api).start()).toBe(false);
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
expect(api.getNotificationManager().setNotification).toBeCalled();
});
it('should abort when the microphone is forbidden', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
microphoneForbidden: true,
});
expect(await new CallManager(api).start()).toBe(false);
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
expect(api.getNotificationManager().setNotification).toBeCalled();
});
it('should connect the microphone when not already connected', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
microphoneConnected: false,
});
vi.mocked(api.getMicrophoneManager().connect).mockResolvedValue();
expect(await new CallManager(api).start()).toBe(true);
expect(api.getMicrophoneManager().connect).toBeCalled();
expect(api.getViewManager().setViewByParameters).toBeCalled();
});
it('should abort when connecting the microphone fails', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
microphoneConnected: false,
});
vi.mocked(api.getMicrophoneManager().connect).mockRejectedValue(new Error());
expect(await new CallManager(api).start()).toBe(false);
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
expect(api.getNotificationManager().setNotification).toBeCalled();
});
});
// An inbound start request must not displace a call the user cares about
// (manual call, or an answered inbound call). Newer unanswered inbound rings
// still replace older ones. Manual (user-initiated) start keeps its full
// supersede authority.
describe('inbound supersede policy', () => {
const twoCameraStore = createStore([
{
cameraID: 'camera.office',
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
},
{
cameraID: 'camera.garage',
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
},
]);
it('should skip an inbound start when an answered call is active on another camera', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
store: twoCameraStore,
});
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
expect(manager.answer()).toBe(true);
expect(manager.getCall()?.answered).toBe(true);
expect(await manager.start({ cameraID: 'camera.garage', inbound: true })).toBe(
false,
);
// Call still on the original camera, untouched.
expect(manager.getCall()?.cameraID).toBe('camera.office');
expect(manager.getCall()?.answered).toBe(true);
});
it('should skip an inbound start request when a manual call is active on another camera', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
store: twoCameraStore,
});
const manager = new CallManager(api);
expect(await manager.start()).toBe(true);
expect(manager.getCall()?.inbound).toBe(false);
expect(await manager.start({ cameraID: 'camera.garage', inbound: true })).toBe(
false,
);
expect(manager.getCall()?.cameraID).toBe('camera.office');
expect(manager.getCall()?.inbound).toBe(false);
});
it('should supersede an unanswered inbound call with another inbound on a different camera', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
store: twoCameraStore,
});
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
expect(manager.getCall()?.answered).toBe(false);
expect(await manager.start({ cameraID: 'camera.garage', inbound: true })).toBe(true);
expect(manager.getCall()?.cameraID).toBe('camera.garage');
expect(manager.getCall()?.inbound).toBe(true);
});
it('should let a manual start supersede an answered inbound call', async () => {
// Manual start (inbound: false) retains full supersede authority --
// explicit user intent wins.
const api = createAPI({
view: createView({ camera: 'camera.office' }),
store: twoCameraStore,
});
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
expect(manager.answer()).toBe(true);
expect(await manager.start({ cameraID: 'camera.garage' })).toBe(true);
expect(manager.getCall()?.cameraID).toBe('camera.garage');
expect(manager.getCall()?.inbound).toBe(false);
});
});
describe('end', () => {
it('should do nothing when no call is active', () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
expect(new CallManager(api).end()).toBe(false);
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
});
it('should end an active call', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
const manager = new CallManager(api);
expect(await manager.start()).toBe(true);
vi.mocked(api.getViewManager().setViewByParameters).mockClear();
expect(manager.end()).toBe(true);
expect(manager.isActive()).toBe(false);
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
modifiers: [expect.any(SubstreamViewModifier)],
force: true,
});
expect(api.getConditionStateManager().setState).toBeCalledWith({ call: false });
});
it('should restore the pre-call substream when ending', async () => {
const api = createAPI({
view: createView({
camera: 'camera.office',
context: { live: { overrides: new Map([['camera.office', 'camera.sub']]) } },
}),
store: createStore([
{
cameraID: 'camera.office',
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
},
{
cameraID: 'camera.sub',
capabilities: createCapabilities({ '2-way-audio': true }),
},
]),
});
const manager = new CallManager(api);
expect(await manager.start()).toBe(true);
vi.mocked(api.getViewManager().setViewByParameters).mockClear();
expect(manager.end()).toBe(true);
// The recorded pre-call substream (`camera.sub`) is reinstated.
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
modifiers: [expect.any(SubstreamViewModifier)],
force: true,
});
});
it('should return to the pre-call view on an explicit end', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office', view: 'clips' }),
});
const manager = new CallManager(api);
expect(await manager.start()).toBe(true);
expect(manager.end()).toBe(true);
expect(api.getViewManager().setViewByParametersWithExistingQuery).toBeCalledWith({
baseView: expect.any(View),
force: true,
});
const restored = vi.mocked(api.getViewManager().setViewByParametersWithExistingQuery)
.mock.calls[0]?.[0];
expect(restored?.baseView?.view).toBe('clips');
expect(restored?.baseView?.camera).toBe('camera.office');
});
it('should not navigate on an explicit end when the call started from live', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office', view: 'live' }),
});
const manager = new CallManager(api);
expect(await manager.start()).toBe(true);
vi.mocked(api.getViewManager().setViewByParameters).mockClear();
expect(manager.end()).toBe(true);
// No navigation: only the substream is undone.
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
modifiers: [expect.any(SubstreamViewModifier)],
force: true,
});
expect(api.getViewManager().setViewByParametersWithExistingQuery).not.toBeCalled();
});
it('should return to a camera-less pre-call view on an explicit end', async () => {
const api = createAPI({
view: createView({ camera: null, view: 'folder' }),
store: createCallableStore('camera.office'),
});
const manager = new CallManager(api);
expect(await manager.start({ cameraID: 'camera.office' })).toBe(true);
expect(manager.end()).toBe(true);
const restored = vi.mocked(api.getViewManager().setViewByParametersWithExistingQuery)
.mock.calls[0]?.[0];
expect(restored?.baseView?.view).toBe('folder');
expect(restored?.baseView?.camera).toBeNull();
});
});
// `endIf` is the predicate-driven conditional end: ends the active call iff
// every supplied option matches its corresponding field on the session.
// Fields left `undefined` are not gated on.
describe('endIf', () => {
it('should no-op when there is no active call', () => {
const api = createAPI();
const manager = new CallManager(api);
expect(manager.endIf({ cameraID: 'camera.office' })).toBe(false);
expect(manager.isActive()).toBe(false);
});
it('should end unconditionally when no options are supplied', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
const manager = new CallManager(api);
expect(await manager.start()).toBe(true);
expect(manager.isActive()).toBe(true);
expect(manager.endIf({})).toBe(true);
expect(manager.isActive()).toBe(false);
});
describe('cameraID gate', () => {
it('should end when cameraID matches', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
const manager = new CallManager(api);
expect(await manager.start()).toBe(true);
expect(manager.endIf({ cameraID: 'camera.office' })).toBe(true);
expect(manager.isActive()).toBe(false);
});
it('should not end when cameraID does not match', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
const manager = new CallManager(api);
expect(await manager.start()).toBe(true);
expect(manager.endIf({ cameraID: 'camera.other' })).toBe(false);
expect(manager.isActive()).toBe(true);
});
});
describe('inbound gate', () => {
it('should end an inbound call when inbound: true is required', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
const manager = new CallManager(api);
expect(await manager.start({ inbound: true })).toBe(true);
expect(manager.endIf({ inbound: true })).toBe(true);
expect(manager.isActive()).toBe(false);
});
it('should not end a manual call when inbound: true is required', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
const manager = new CallManager(api);
expect(await manager.start()).toBe(true);
expect(manager.endIf({ inbound: true })).toBe(false);
expect(manager.isActive()).toBe(true);
});
it('should end a manual call when inbound: false is required', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
const manager = new CallManager(api);
expect(await manager.start()).toBe(true);
expect(manager.endIf({ inbound: false })).toBe(true);
expect(manager.isActive()).toBe(false);
});
});
describe('answered gate', () => {
it('should end an unanswered call when answered: false is required', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
expect(manager.getCall()?.answered).toBe(false);
expect(manager.endIf({ answered: false })).toBe(true);
expect(manager.isActive()).toBe(false);
});
it('should not end an answered call when answered: false is required', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
expect(manager.answer()).toBe(true);
expect(manager.getCall()?.answered).toBe(true);
expect(manager.endIf({ answered: false })).toBe(false);
expect(manager.isActive()).toBe(true);
});
});
describe('combined gates (the trigger-untrigger predicate)', () => {
// The single composite predicate the `untrigger: 'call'` action uses:
// end iff it's an inbound, unanswered call on this same camera.
const triggerPredicate = (cameraID: string) => ({
cameraID,
inbound: true,
answered: false,
});
it('should end an unanswered inbound call on the matching camera', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
expect(manager.endIf(triggerPredicate('camera.office'))).toBe(true);
expect(manager.isActive()).toBe(false);
});
it('should not end when the camera differs', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
expect(manager.endIf(triggerPredicate('camera.other'))).toBe(false);
expect(manager.isActive()).toBe(true);
});
it('should not end a manual call even on the matching camera', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start()).toBe(true);
expect(manager.endIf(triggerPredicate('camera.office'))).toBe(false);
expect(manager.isActive()).toBe(true);
});
});
});
describe('condition state changes', () => {
it('should end the call when the selected camera changes away', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start()).toBe(true);
vi.mocked(api.getViewManager().setViewByParameters).mockClear();
getConditionStateListener(api)({
old: { camera: 'camera.office', view: 'live' },
change: { camera: 'camera.other' },
new: { camera: 'camera.other', view: 'live' },
});
expect(manager.isActive()).toBe(false);
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
modifiers: [expect.any(SubstreamViewModifier)],
force: true,
});
});
it('should not restore the pre-call view when the call auto-ends', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office', view: 'clips' }),
});
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start()).toBe(true);
vi.mocked(api.getViewManager().setViewByParameters).mockClear();
getConditionStateListener(api)({
old: { camera: 'camera.office', view: 'live' },
change: { camera: 'camera.other' },
new: { camera: 'camera.other', view: 'live' },
});
expect(manager.isActive()).toBe(false);
expect(api.getViewManager().setViewByParametersWithExistingQuery).not.toBeCalled();
});
it('should end the call when the view leaves live', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start()).toBe(true);
vi.mocked(api.getViewManager().setViewByParameters).mockClear();
getConditionStateListener(api)({
old: { camera: 'camera.office', view: 'live' },
change: { view: 'clips' },
new: { camera: 'camera.office', view: 'clips' },
});
expect(manager.isActive()).toBe(false);
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
modifiers: [expect.any(SubstreamViewModifier)],
force: true,
});
});
it('should keep the call when the selected camera is unchanged', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start()).toBe(true);
getConditionStateListener(api)({
old: { camera: 'camera.office' },
change: { view: 'live' },
new: { camera: 'camera.office', view: 'live' },
});
expect(manager.isActive()).toBe(true);
});
it('should no-op when no call is active', () => {
const api = createAPI();
new CallManager(api).initialize();
getConditionStateListener(api)({
old: {},
change: { camera: 'camera.other' },
new: { camera: 'camera.other' },
});
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
});
it('should end the call when the substream changes away', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start()).toBe(true);
getConditionStateListener(api)({
old: { camera: 'camera.office', view: 'live' },
change: { substreamID: 'camera.sub' },
new: { camera: 'camera.office', substreamID: 'camera.sub', view: 'live' },
});
expect(manager.isActive()).toBe(false);
});
it('should keep the call when the substream is unchanged', async () => {
const api = createAPI({
view: createView({
camera: 'camera.office',
context: { live: { overrides: new Map([['camera.office', 'camera.sub']]) } },
}),
store: createStore([
{
cameraID: 'camera.office',
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
},
{
cameraID: 'camera.sub',
capabilities: createCapabilities({ '2-way-audio': true }),
},
]),
});
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start()).toBe(true);
getConditionStateListener(api)({
old: { camera: 'camera.office', substreamID: 'camera.sub' },
change: { view: 'live' },
new: { camera: 'camera.office', substreamID: 'camera.sub', view: 'live' },
});
expect(manager.isActive()).toBe(true);
});
it('should ignore unrelated setState calls during a camera supersede', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
const manager = new CallManager(api);
manager.initialize();
await manager.start({ inbound: true });
// Simulate the new session having been swapped to a different camera.
const call = manager.getCall();
assert(call);
call.cameraID = 'camera.garage';
// Fire a setState that does NOT change view/camera/substream -- only
// `mediaLoadedInfo`. The listener must not end the call.
getConditionStateListener(api)({
old: { view: 'live', camera: 'camera.office' },
change: { mediaLoadedInfo: null },
new: { view: 'live', camera: 'camera.office', mediaLoadedInfo: null },
});
expect(manager.isActive()).toBe(true);
});
});
describe('initialize / uninitialize', () => {
it('should not register the condition state listener until initialize', () => {
const api = createAPI();
new CallManager(api);
expect(api.getConditionStateManager().addListener).not.toBeCalled();
});
it('should register the condition state listener on initialize', () => {
const api = createAPI();
const manager = new CallManager(api);
manager.initialize();
expect(api.getConditionStateManager().addListener).toBeCalled();
});
it('should remove the condition state listener on uninitialize', () => {
const api = createAPI();
const manager = new CallManager(api);
manager.initialize();
const listener = getConditionStateListener(api);
manager.uninitialize();
expect(api.getConditionStateManager().removeListener).toBeCalledWith(listener);
});
it('should tear down any active call session on uninitialize', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start()).toBe(true);
expect(manager.isActive()).toBe(true);
manager.uninitialize();
expect(manager.isActive()).toBe(false);
expect(api.getConditionStateManager().setState).toBeCalledWith({ call: false });
});
it('should ignore further condition state changes after uninitialize', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start()).toBe(true);
const listener = getConditionStateListener(api);
manager.uninitialize();
vi.mocked(api.getViewManager().setViewByParameters).mockClear();
// Even if a stale ref to the listener fires it, the call session is
// already torn down so no view-change happens.
listener({
old: { camera: 'camera.office', view: 'live' },
change: { camera: 'camera.other' },
new: { camera: 'camera.other', view: 'live' },
});
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
});
});
// `inbound: true` suppresses each of the preflight/validation notifications.
// Every path that would call `_notifyError` is exercised under both the
// non-inbound case (notification surfaced) and the inbound case (silent). The
// non-inbound coverage already lives in the `start` describe above; here we
// assert the inbound paths stay silent.
describe('inbound option', () => {
it('should suppress notification when the camera lacks live capability', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
store: createStore([
{
cameraID: 'camera.office',
capabilities: createCapabilities({ live: false, '2-way-audio': true }),
},
]),
});
expect(await new CallManager(api).start({ inbound: true })).toBe(false);
expect(api.getNotificationManager().setNotification).not.toBeCalled();
});
it('should suppress notification when the microphone is unsupported', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
microphoneSupported: false,
});
expect(await new CallManager(api).start({ inbound: true })).toBe(false);
expect(api.getNotificationManager().setNotification).not.toBeCalled();
});
it('should suppress notification when the microphone is forbidden', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
microphoneForbidden: true,
});
expect(await new CallManager(api).start({ inbound: true })).toBe(false);
expect(api.getNotificationManager().setNotification).not.toBeCalled();
});
it('should suppress notification when microphone connect rejects', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
microphoneConnected: false,
});
vi.mocked(api.getMicrophoneManager().connect).mockRejectedValue(new Error('denied'));
expect(await new CallManager(api).start({ inbound: true })).toBe(false);
expect(api.getNotificationManager().setNotification).not.toBeCalled();
});
it('should suppress notification when an explicit stream is not 2-way audio', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
expect(
await new CallManager(api).start({
inbound: true,
streamID: 'camera.unrelated',
}),
).toBe(false);
expect(api.getNotificationManager().setNotification).not.toBeCalled();
});
it('should suppress notification when no stream supports 2-way audio', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
store: createStore([
{
cameraID: 'camera.office',
capabilities: createCapabilities({ live: true, '2-way-audio': false }),
},
]),
});
expect(await new CallManager(api).start({ inbound: true })).toBe(false);
expect(api.getNotificationManager().setNotification).not.toBeCalled();
});
it('should record the call as inbound on the session', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
const manager = new CallManager(api);
expect(await manager.start({ inbound: true })).toBe(true);
expect(manager.getCall()?.inbound).toBe(true);
expect(manager.getCall()?.answered).toBe(false);
});
});
describe('answer', () => {
const inboundConfig = {
live: { controls: { call: { ringtone: { type: 'chime' as const } } } },
};
it('should default to unanswered for an inbound start', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
config: inboundConfig,
});
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
expect(manager.getCall()?.answered).toBe(false);
});
it('should default to answered for an outbound start', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start()).toBe(true);
// Outbound calls are answered by construction -- the user initiated them.
expect(manager.getCall()?.answered).toBe(true);
});
it('should default to unanswered for inbound even if the mic is already un-muted', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
microphoneMuted: false,
config: inboundConfig,
});
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
expect(manager.getCall()?.answered).toBe(false);
expect(getRingtone().start).toBeCalled();
});
it('should no-op when no call is active', () => {
const api = createAPI();
const manager = new CallManager(api);
expect(manager.answer()).toBe(false);
});
it('should no-op when the call is already answered', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
config: inboundConfig,
});
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
expect(manager.answer()).toBe(true);
vi.mocked(getRingtone().stop).mockClear();
vi.mocked(api.getCardElementManager().update).mockClear();
expect(manager.answer()).toBe(false);
expect(getRingtone().stop).not.toBeCalled();
expect(api.getCardElementManager().update).not.toBeCalled();
});
it('should mark answered and replace the session immutably', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
config: inboundConfig,
});
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
const before = manager.getCall();
expect(manager.answer()).toBe(true);
const after = manager.getCall();
expect(after?.answered).toBe(true);
// New object identity so Lit consumers re-render on the prop change.
expect(after).not.toBe(before);
expect(after?.cameraID).toBe(before?.cameraID);
expect(after?.inbound).toBe(before?.inbound);
});
it('should stop the ringtone and unanswered timer on answer', async () => {
vi.useFakeTimers();
try {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
config: {
live: {
controls: {
call: {
ringtone: { type: 'chime' as const },
unanswered_timeout_seconds: 60,
},
},
},
},
});
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
vi.mocked(getRingtone().stop).mockClear();
expect(manager.answer()).toBe(true);
expect(getRingtone().stop).toBeCalled();
// Timer was armed and should now be cancelled: advancing past the
// timeout must not end the (now-answered) call.
vi.advanceTimersByTime(60_000);
expect(manager.isActive()).toBe(true);
} finally {
vi.useRealTimers();
}
});
it('should force a card re-render on answer', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
config: inboundConfig,
});
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
vi.mocked(api.getCardElementManager().update).mockClear();
expect(manager.answer()).toBe(true);
// The card subtree depends on `getCall().answered`, which the manager
// mutates outside the view-manager epoch -- so `update()` is what drives
// the re-render through to the call-controls overlay.
expect(api.getCardElementManager().update).toBeCalled();
});
it('should not mark non-inbound (outbound) calls via answer (already answered)', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start()).toBe(true);
// Outbound starts answered, so `answer()` is a no-op.
expect(manager.answer()).toBe(false);
expect(manager.getCall()?.answered).toBe(true);
});
it('should not flip answered on mic mute/unmute transitions', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
config: inboundConfig,
});
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
const listener = getConditionStateListener(api);
// Mic mute -> unmute during the pre-answer ring: must NOT auto-answer.
listener({
old: {
camera: 'camera.office',
view: 'live',
microphone: { connected: true, muted: true, forbidden: false },
},
change: { microphone: { connected: true, muted: false, forbidden: false } },
new: {
camera: 'camera.office',
view: 'live',
microphone: { connected: true, muted: false, forbidden: false },
},
});
expect(manager.getCall()?.answered).toBe(false);
});
});
// Ringtone integration: started only for inbound + unanswered + a configured
// ringtone other than 'none'; stopped on end / uninitialize.
describe('ringtone', () => {
it('should start the ringtone for an inbound call with a configured tone', async () => {
const ringtone: RingtoneConfig = { type: 'chime', repeat: 0 };
const api = createAPI({
view: createView({ camera: 'camera.office' }),
config: { live: { controls: { call: { ringtone: ringtone } } } },
});
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
expect(getRingtone().start).toBeCalledWith(expect.objectContaining(ringtone));
});
it('should not start the ringtone for a non-inbound call', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
config: { live: { controls: { call: { ringtone: { type: 'chime' } } } } },
});
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start()).toBe(true);
expect(getRingtone().start).not.toBeCalled();
});
it("should not start the ringtone when type is 'none'", async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
config: { live: { controls: { call: { ringtone: { type: 'none' } } } } },
});
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
expect(getRingtone().start).not.toBeCalled();
});
it('should stop the ringtone when the call ends', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
config: { live: { controls: { call: { ringtone: { type: 'chime' } } } } },
});
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
vi.mocked(getRingtone().stop).mockClear();
expect(manager.end()).toBe(true);
expect(getRingtone().stop).toBeCalled();
});
it('should stop the ringtone on uninitialize', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
config: { live: { controls: { call: { ringtone: { type: 'chime' } } } } },
});
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
vi.mocked(getRingtone().stop).mockClear();
manager.uninitialize();
expect(getRingtone().stop).toBeCalled();
});
});
// Unanswered-call timeout: when configured, arms a timer on inbound start,
// fires `end()` if no answer arrives, and cancels on answer, explicit end, or
// uninitialize.
describe('unanswered timeout', () => {
beforeEach(() => {
vi.useFakeTimers();
});
const inboundConfig = (unanswered_timeout_seconds: number) => ({
live: { controls: { call: { unanswered_timeout_seconds } } },
});
it('should auto-end an unanswered inbound call after the timeout', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
config: inboundConfig(60),
});
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
expect(manager.isActive()).toBe(true);
vi.advanceTimersByTime(60_000);
expect(manager.isActive()).toBe(false);
});
it('should not arm the timer when the timeout is 0', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
config: inboundConfig(0),
});
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
vi.advanceTimersByTime(60_000);
expect(manager.isActive()).toBe(true);
});
it('should not arm the timer for non-inbound calls', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
config: inboundConfig(60),
});
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start()).toBe(true);
vi.advanceTimersByTime(60_000);
expect(manager.isActive()).toBe(true);
});
it('should cancel the timer when the call is answered', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
config: inboundConfig(60),
});
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
expect(manager.answer()).toBe(true);
vi.advanceTimersByTime(60_000);
expect(manager.isActive()).toBe(true);
});
it('should cancel the timer on explicit end', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
config: inboundConfig(60),
});
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
expect(manager.end()).toBe(true);
// The timer firing after end() would be a no-op (no active call) -- the
// important thing is that it does not throw or affect any state.
vi.advanceTimersByTime(60_000);
expect(manager.isActive()).toBe(false);
});
it('should cancel the timer on uninitialize', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
config: inboundConfig(60),
});
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
manager.uninitialize();
vi.advanceTimersByTime(60_000);
expect(manager.isActive()).toBe(false);
});
});
// `start()` calls `setState({ call: true })` to broadcast the new call status;
// a listener that responds by navigating away will fire the manager's own
// condition listener and end the call before `start()` returns. Verify the
// post-setState re-read of the session prevents follow-up work (ringtone /
// unanswered timer) on a session that is already gone.
describe('session end during setState', () => {
const inboundConfig = {
live: {
controls: {
call: {
ringtone: { type: 'chime' as const },
unanswered_timeout_seconds: 60,
},
},
},
};
it('should skip ringtone and unanswered timer when a listener ends the call', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
config: inboundConfig,
});
const manager = new CallManager(api);
manager.initialize();
const listener = getConditionStateListener(api);
vi.mocked(api.getConditionStateManager().setState).mockImplementation((state) => {
// Simulate a downstream listener that responds to `call: true` by
// navigating away. The manager's own listener then ends the call,
// nulling the session before `start()` finishes.
if (state.call === true) {
listener({
old: { camera: 'camera.office', view: 'live' },
change: { view: 'clips' },
new: { camera: 'camera.office', view: 'clips' },
});
}
return true;
});
expect(await manager.start({ inbound: true })).toBe(false);
expect(getRingtone().start).not.toBeCalled();
expect(manager.isActive()).toBe(false);
});
});
describe('uninitialize during in-flight start', () => {
it('should not install a session or ring when uninitialized mid-await', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
microphoneConnected: false,
config: { live: { controls: { call: { ringtone: { type: 'chime' } } } } },
});
let resolveConnect: () => void = () => {};
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
new Promise<void>((resolve) => {
resolveConnect = resolve;
}),
);
const manager = new CallManager(api);
manager.initialize();
const startPromise = manager.start({ inbound: true });
manager.uninitialize();
resolveConnect();
expect(await startPromise).toBe(false);
expect(getRingtone().start).not.toBeCalled();
expect(manager.isActive()).toBe(false);
});
it('should not install a session or ring when uninitialized and re-initialized mid-await', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
microphoneConnected: false,
config: { live: { controls: { call: { ringtone: { type: 'chime' } } } } },
});
let resolveConnect: () => void = () => {};
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
new Promise<void>((resolve) => {
resolveConnect = resolve;
}),
);
const manager = new CallManager(api);
manager.initialize();
const startPromise = manager.start({ inbound: true });
manager.uninitialize();
manager.initialize();
resolveConnect();
expect(await startPromise).toBe(false);
expect(getRingtone().start).not.toBeCalled();
expect(manager.isActive()).toBe(false);
});
it('should suppress the microphone-failure notification when uninitialized mid-await', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
microphoneConnected: false,
});
let rejectConnect: (reason: unknown) => void = () => {};
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
new Promise<void>((_, reject) => {
rejectConnect = reject;
}),
);
const manager = new CallManager(api);
manager.initialize();
const startPromise = manager.start();
manager.uninitialize();
rejectConnect(new Error('denied'));
expect(await startPromise).toBe(false);
expect(api.getNotificationManager().setNotification).not.toBeCalled();
});
});