From 47bcce93d30f11a7f53c368ff3df1be3f1312476 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Thu, 23 Apr 2026 21:35:56 -0700 Subject: [PATCH] feat: Add hardened error handling and retries (#2451) - Closes #1830 - Closes #2099 --- docs/configuration/actions/custom/README.md | 24 +- docs/configuration/status-bar.md | 47 +- docs/configuration/view.md | 21 + docs/troubleshooting.md | 17 +- src/camera-manager/engine-factory.ts | 8 +- src/camera-manager/entity-camera.ts | 5 +- src/camera-manager/error.ts | 34 +- src/camera-manager/frigate/camera.ts | 5 +- src/camera-manager/manager.ts | 40 +- src/camera-manager/reolink/camera.ts | 10 +- .../actions/actions-manager.ts | 3 +- src/card-controller/actions/actions/info.ts | 8 +- src/card-controller/automations-manager.ts | 11 +- src/card-controller/card-element-manager.ts | 19 +- src/card-controller/config/config-manager.ts | 41 +- src/card-controller/config/error.ts | 5 + src/card-controller/config/load-folders.ts | 2 +- .../config/overrides-manager.ts | 19 +- src/card-controller/controller.ts | 21 +- src/card-controller/hass/hass-manager.ts | 62 +- src/card-controller/initialization-manager.ts | 157 ++-- src/card-controller/interaction-manager.ts | 5 + src/card-controller/issues/factory.ts | 30 + src/card-controller/issues/issue-manager.ts | 246 +++++ .../issues/issues/abstract-error-issue.ts | 30 + .../issues/issues/config-error.ts | 28 + .../issues}/config-upgrade.ts | 24 +- .../issues/issues/connection.ts | 79 ++ .../issues/issues/initialization.ts | 68 ++ .../issues}/legacy-resource.ts | 37 +- .../issues/issues/media-load.ts | 238 +++++ .../issues/issues/media-query.ts | 57 ++ .../issues/issues/view-incompatible.ts | 48 + src/card-controller/issues/retry-control.ts | 17 + src/card-controller/issues/state-manager.ts | 172 ++++ src/card-controller/issues/types.ts | 96 ++ src/card-controller/keyboard-state-manager.ts | 15 +- src/card-controller/media-player-manager.ts | 9 +- src/card-controller/message-manager.ts | 88 -- src/card-controller/microphone-manager.ts | 18 +- src/card-controller/problems/manager.ts | 143 --- .../problems/problems/stream-not-loading.ts | 122 --- src/card-controller/problems/types.ts | 57 -- .../status-bar-item-manager.ts | 36 +- src/card-controller/triggers-manager.ts | 16 +- src/card-controller/types.ts | 39 +- src/card-controller/view/factory.ts | 12 +- src/card-controller/view/item-manager.ts | 11 +- src/card-controller/view/types.ts | 6 +- src/card-controller/view/view-manager.ts | 62 +- src/card.ts | 64 +- src/components-lib/live/live-controller.ts | 5 +- ...ntroller.ts => notification-controller.ts} | 26 +- src/components-lib/message/controller.ts | 45 - src/components-lib/message/dispatch.ts | 53 -- src/components-lib/notification/action.ts | 23 + .../notification/data-to-context.ts | 12 + src/components-lib/notification/factory.ts | 56 ++ src/components-lib/notification/summarize.ts | 10 + src/components-lib/status-bar-controller.ts | 30 +- src/components/diagnostics.ts | 17 +- src/components/elements.ts | 16 +- src/components/gallery/gallery-core.ts | 1 - src/components/gallery/gallery.ts | 15 +- src/components/image-updating-player.ts | 67 +- src/components/image.ts | 38 +- src/components/live/carousel.ts | 51 +- src/components/live/provider.ts | 60 +- src/components/live/providers/go2rtc/index.ts | 17 +- src/components/live/providers/jsmpeg.ts | 44 +- src/components/live/providers/webrtc-card.ts | 37 +- src/components/message.ts | 73 -- src/components/notification.ts | 180 ---- src/components/notification/block.ts | 88 ++ .../notification/common-rendering.ts | 94 ++ src/components/notification/no-media.ts | 31 + src/components/notification/popup.ts | 111 +++ src/components/progress-indicator.ts | 6 +- src/components/thumbnail/details.ts | 10 +- src/components/thumbnail/feature/feature.ts | 10 +- src/components/timeline-core.ts | 17 +- src/components/viewer/carousel.ts | 42 +- src/components/viewer/index.ts | 16 +- src/components/viewer/provider.ts | 16 +- src/components/views.ts | 6 +- src/conditions/state-manager.ts | 10 +- src/conditions/types.ts | 5 + src/config/schema/actions/types.ts | 8 +- src/config/schema/cameras.ts | 32 +- src/config/schema/common/status-bar.ts | 1 + src/config/schema/status-bar.ts | 35 +- src/config/schema/view.ts | 14 + src/const.ts | 13 +- src/declarations.d.ts | 4 + src/editor.ts | 162 ++-- src/ha/has-hass-connection-changed.ts | 14 - src/ha/side-load-ha-elements.ts | 17 +- src/localize/languages/ca.json | 27 +- src/localize/languages/de.json | 23 +- src/localize/languages/en.json | 97 +- src/localize/languages/fr.json | 27 +- src/localize/languages/it.json | 14 +- src/localize/languages/pl.json | 27 +- src/localize/languages/pt-BR.json | 14 +- src/localize/languages/pt-PT.json | 14 +- src/localize/localize.ts | 3 +- src/patches/ha-hls-player.ts | 10 +- src/patches/ha-web-rtc-player.ts | 10 +- src/scss/dotdotdot.scss | 23 - src/scss/editor.scss | 5 +- src/scss/live-provider.scss | 4 + src/scss/message.scss | 80 -- src/scss/notification-block.scss | 129 +++ src/scss/notification-common.scss | 105 +++ src/scss/notification-popup.scss | 228 +++++ src/scss/notification.scss | 310 ------ src/scss/progress-indicator.scss | 15 + src/scss/themes/base.scss | 46 +- src/scss/themes/light.scss | 20 +- src/types.ts | 12 - src/utils/diagnostics.ts | 8 +- src/utils/error-context.ts | 12 + src/utils/initializer/initializer.ts | 22 +- src/utils/ptz.ts | 39 +- src/view/target-id.ts | 27 + src/view/view.ts | 55 +- tests/camera-manager/manager.test.ts | 199 ++-- .../actions/actions-manager.test.ts | 6 +- tests/card-controller/actions/factory.test.ts | 4 +- .../automations-manager.test.ts | 20 +- .../config/config-manager.test.ts | 21 +- .../config/load-folders.test.ts | 4 +- .../config/overrides-manager.test.ts | 107 ++- tests/card-controller/controller.test.ts | 91 +- .../card-controller/hass/hass-manager.test.ts | 171 ++-- .../initialization-manager.test.ts | 217 +++-- .../interaction-manager.test.ts | 31 +- tests/card-controller/issues/factory.test.ts | 91 ++ .../issues/issue-manager.test.ts | 884 ++++++++++++++++++ .../issues/issues/config-error.test.ts | 71 ++ .../issues/issues/config-upgrade.test.ts | 71 ++ .../issues/issues/connection.test.ts | 127 +++ .../issues/issues/initialization.test.ts | 153 +++ .../issues}/legacy-resource.test.ts | 172 ++-- .../issues/issues/media-load.test.ts | 623 ++++++++++++ .../issues/issues/media-query.test.ts | 150 +++ .../issues/issues/view-incompatible.test.ts | 109 +++ .../issues/retry-control.test.ts | 25 + .../issues/state-manager.test.ts | 555 +++++++++++ .../keyboard-state-manager.test.ts | 26 + .../media-player-manager.test.ts | 74 +- tests/card-controller/message-manager.test.ts | 176 ---- .../microphone-manager.test.ts | 24 +- .../notification-manager.test.ts | 4 +- .../card-controller/problems/manager.test.ts | 365 -------- .../problems/problems/config-upgrade.test.ts | 63 -- .../problems/stream-not-loading.test.ts | 311 ------ .../query-string-manager.test.ts | 8 +- .../status-bar-item-manager.test.ts | 52 +- .../card-controller/triggers-manager.test.ts | 52 +- tests/card-controller/view/factory.test.ts | 10 +- .../card-controller/view/item-manager.test.ts | 20 +- .../card-controller/view/view-manager.test.ts | 220 ++++- ...est.ts => notification-controller.test.ts} | 104 +-- .../components-lib/message/controller.test.ts | 157 ---- tests/components-lib/message/dispatch.test.ts | 50 - .../notification/action.test.ts | 93 ++ .../notification/data-to-context.test.ts | 32 + .../notification/factory.test.ts | 165 ++++ .../notification/summarize.test.ts | 48 + .../status-bar-controller.test.ts | 99 ++ .../components/notification/no-media.test.ts | 74 ++ tests/conditions/state-manager.test.ts | 42 +- tests/config/types.test.ts | 58 +- tests/ha/has-hass-connection-changed.test.ts | 34 - tests/ha/side-load-ha-elements.test.ts | 12 +- tests/test-utils.ts | 17 +- tests/utils/action.test.ts | 4 +- tests/utils/diagnostics.test.ts | 23 +- tests/utils/error-context.test.ts | 37 + tests/utils/initializer.test.ts | 45 +- tests/utils/ptz.test.ts | 43 +- 182 files changed, 7877 insertions(+), 4043 deletions(-) create mode 100644 src/card-controller/config/error.ts create mode 100644 src/card-controller/issues/factory.ts create mode 100644 src/card-controller/issues/issue-manager.ts create mode 100644 src/card-controller/issues/issues/abstract-error-issue.ts create mode 100644 src/card-controller/issues/issues/config-error.ts rename src/card-controller/{problems/problems => issues/issues}/config-upgrade.ts (54%) create mode 100644 src/card-controller/issues/issues/connection.ts create mode 100644 src/card-controller/issues/issues/initialization.ts rename src/card-controller/{problems/problems => issues/issues}/legacy-resource.ts (76%) create mode 100644 src/card-controller/issues/issues/media-load.ts create mode 100644 src/card-controller/issues/issues/media-query.ts create mode 100644 src/card-controller/issues/issues/view-incompatible.ts create mode 100644 src/card-controller/issues/retry-control.ts create mode 100644 src/card-controller/issues/state-manager.ts create mode 100644 src/card-controller/issues/types.ts delete mode 100644 src/card-controller/message-manager.ts delete mode 100644 src/card-controller/problems/manager.ts delete mode 100644 src/card-controller/problems/problems/stream-not-loading.ts delete mode 100644 src/card-controller/problems/types.ts rename src/components-lib/media/{details-controller.ts => notification-controller.ts} (94%) delete mode 100644 src/components-lib/message/controller.ts delete mode 100644 src/components-lib/message/dispatch.ts create mode 100644 src/components-lib/notification/action.ts create mode 100644 src/components-lib/notification/data-to-context.ts create mode 100644 src/components-lib/notification/factory.ts create mode 100644 src/components-lib/notification/summarize.ts delete mode 100644 src/components/message.ts delete mode 100644 src/components/notification.ts create mode 100644 src/components/notification/block.ts create mode 100644 src/components/notification/common-rendering.ts create mode 100644 src/components/notification/no-media.ts create mode 100644 src/components/notification/popup.ts delete mode 100644 src/ha/has-hass-connection-changed.ts delete mode 100644 src/scss/dotdotdot.scss delete mode 100644 src/scss/message.scss create mode 100644 src/scss/notification-block.scss create mode 100644 src/scss/notification-common.scss create mode 100644 src/scss/notification-popup.scss delete mode 100644 src/scss/notification.scss create mode 100644 src/scss/progress-indicator.scss create mode 100644 src/utils/error-context.ts create mode 100644 src/view/target-id.ts create mode 100644 tests/card-controller/issues/factory.test.ts create mode 100644 tests/card-controller/issues/issue-manager.test.ts create mode 100644 tests/card-controller/issues/issues/config-error.test.ts create mode 100644 tests/card-controller/issues/issues/config-upgrade.test.ts create mode 100644 tests/card-controller/issues/issues/connection.test.ts create mode 100644 tests/card-controller/issues/issues/initialization.test.ts rename tests/card-controller/{problems/problems => issues/issues}/legacy-resource.test.ts (71%) create mode 100644 tests/card-controller/issues/issues/media-load.test.ts create mode 100644 tests/card-controller/issues/issues/media-query.test.ts create mode 100644 tests/card-controller/issues/issues/view-incompatible.test.ts create mode 100644 tests/card-controller/issues/retry-control.test.ts create mode 100644 tests/card-controller/issues/state-manager.test.ts delete mode 100644 tests/card-controller/message-manager.test.ts delete mode 100644 tests/card-controller/problems/manager.test.ts delete mode 100644 tests/card-controller/problems/problems/config-upgrade.test.ts delete mode 100644 tests/card-controller/problems/problems/stream-not-loading.test.ts rename tests/components-lib/media/{details-controller.test.ts => notification-controller.test.ts} (83%) delete mode 100644 tests/components-lib/message/controller.test.ts delete mode 100644 tests/components-lib/message/dispatch.test.ts create mode 100644 tests/components-lib/notification/action.test.ts create mode 100644 tests/components-lib/notification/data-to-context.test.ts create mode 100644 tests/components-lib/notification/factory.test.ts create mode 100644 tests/components-lib/notification/summarize.test.ts create mode 100644 tests/components/notification/no-media.test.ts delete mode 100644 tests/ha/has-hass-connection-changed.test.ts create mode 100644 tests/utils/error-context.test.ts diff --git a/docs/configuration/actions/custom/README.md b/docs/configuration/actions/custom/README.md index dfe1b6e5..bdd04b2f 100644 --- a/docs/configuration/actions/custom/README.md +++ b/docs/configuration/actions/custom/README.md @@ -361,14 +361,17 @@ advanced_camera_card_action: notification ### `notification` -| Parameter | Description | -| ---------- | ------------------------------------------------------------------------------------- | -| `heading` | An optional heading. Uses the same format as [`details`](README.md?id=details) below. | -| `text` | An optional text string to display as the notification body. | -| `details` | An optional list of metadata. See [`details`](README.md?id=details) below. | -| `controls` | An optional list of controls. See [`controls`](README.md?id=controls) below. | +| Parameter | Description | +| ------------- | ---------------------------------------------------------------------------------------------------------- | +| `heading` | An optional heading. Uses the same format as [`metadata`](README.md?id=metadata) below. | +| `body` | An optional body. Uses the same format as [`metadata`](README.md?id=metadata) below. | +| `metadata` | An optional list of metadata to show with the notification. See [`metadata`](README.md?id=metadata) below. | +| `context` | An optional list of preformatted text strings shown as a technical detail block (e.g. YAML). | +| `link` | An optional link displayed below the body. See [`link`](#link) below. | +| `in_progress` | If `true`, shows a loading indicator. | +| `controls` | An optional list of controls. See [`controls`](README.md?id=controls) below. | -### Details +### Metadata | Parameter | Description | | ---------- | ------------------------------------------------------------- | @@ -377,6 +380,13 @@ advanced_camera_card_action: notification | `tooltip` | An optional tooltip string (shown on hover). | | `severity` | An optional severity level, one of `low`, `medium` or `high`. | +### Link + +| Parameter | Description | +| --------- | ------------------------------------ | +| `url` | The link URL. | +| `title` | The link text displayed to the user. | + ### Controls | Parameter | Default | Description | diff --git a/docs/configuration/status-bar.md b/docs/configuration/status-bar.md index 51d20d03..5f8f1654 100644 --- a/docs/configuration/status-bar.md +++ b/docs/configuration/status-bar.md @@ -28,23 +28,28 @@ status_bar: ### Available Items -| Button name | Description | -| ---------------------------- | ------------------------------------------------------------------------------------------- | -| `engine` | The icon of the camera engine for the relevant camera. | -| `problem_config_upgrade` | An indicator that appears when a configuration upgrade is available. | -| `problem_legacy_resource` | An indicator that appears when a legacy `frigate-hass-card` resource is still registered. | -| `problem_stream_not_loading` | An indicator that appears when a live stream has not loaded within 10 seconds. | -| `resolution` | The detected media resolution (if any). | -| `severity` | The media severity indicator (if any) for review severity (e.g. Frigate alerts/detections). | -| `technology` | The detected media technology (if any). | -| `title` | The media title. | +| Button name | Description | +| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `engine` | The icon of the camera engine for the relevant camera. | +| `issues` | Indicator icons that appear while any card issue is active — e.g. configuration error, configuration upgrade available, Home Assistant connection lost, camera initialization failed, legacy `frigate-hass-card` resource detected, media (live/recorded/image) not loading, media query failed, or view cannot be resolved. See the warning below about disabling this item. | +| `resolution` | The detected media resolution (if any). | +| `severity` | The media severity indicator (if any) for review severity (e.g. Frigate alerts/detections). | +| `technology` | The detected media technology (if any). | +| `title` | The media title. | ### Options for each item -| Option | Default | Description | -| ---------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `enabled` | `true` | Whether or not to show the item. | -| `priority` | `50` | The item priority. Higher priority items are ordered closer to the start of the status bar (i.e. an item with priority `70` will order further to the left than an item with priority `60`). Minimum `0`, maximum `100`. | +| Option | Default | Description | +| ----------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `enabled` | `true` | Whether or not to show the item. | +| `permanent` | `true` for `issues`, `false` otherwise | When `true`, the status bar stays visible in `popup` mode as long as this item is present (instead of auto-hiding after `popup_seconds`). The `issues` item defaults to `true` so errors remain visible. | +| `priority` | `50` | The item priority. Higher priority items are ordered closer to the start of the status bar (i.e. an item with priority `70` will order further to the left than an item with priority `60`). Minimum `0`, maximum `100`. | + +> [!WARNING] +> The status bar is the only UI surface for minor issues (i.e. that don't render +> the card entirely inoperable). If you set `status_bar.style: none` or +> `status_bar.items.issues.enabled: false`, these issues will not be visible to +> you, except through card diagnostics. ## `style` @@ -75,26 +80,26 @@ status_bar: items: engine: enabled: true + permanent: false priority: 50 - problem_config_upgrade: - enabled: true - priority: 50 - problem_legacy_resource: - enabled: true - priority: 50 - problem_stream_not_loading: + issues: enabled: true + permanent: true priority: 50 resolution: enabled: true + permanent: false priority: 50 severity: enabled: true + permanent: false priority: 50 technology: enabled: true + permanent: false priority: 50 title: enabled: true + permanent: false priority: 50 ``` diff --git a/docs/configuration/view.md b/docs/configuration/view.md index 5f068621..b241dc8f 100644 --- a/docs/configuration/view.md +++ b/docs/configuration/view.md @@ -15,6 +15,7 @@ view: | `default` | `auto` | The view to show in the card by default. If `auto`, the card will choose `live` when cameras are configured, `folders` when folders are configured, or `image` otherwise (default embedded image). The default camera is the first one listed. See [Supported Views](view.md?id=supported-views). | | `default_reset` | | The circumstances and behavior that cause the card to reset to the default view. See [`default_reset`](#default_reset). | | `interaction_seconds` | `300` | After a mouse/touch interaction with the card, it will be considered "interacted with" until this number of seconds elapses without further interaction. May be used as part of an [interaction condition](conditions.md?id=interaction) or with `default_reset.after_interaction` to reset the view after the interaction is complete. | +| `issues` | | How the card handles issues and retries. See [`issues`](#issues). | | `keyboard_shortcuts` | See [usage](../usage/keyboard-shortcuts.md) for defaults. | Configure keyboard shortcuts. See [`keyboard_shortcuts`](#keyboard_shortcuts). | | `render_entities` | | **YAML only**: A list of entity ids that should cause the card to re-render 'in-place'. The view/camera is not changed. This should **very** rarely be needed, but could be useful if the card is both setting and changing HA state of the same object as could be the case for some complex `card_mod` scenarios ([example](https://github.com/dermotduffy/advanced-camera-card/issues/343)). | | `theme` | | How the card is themed. See [`theme`](#theme-🎨). | @@ -37,6 +38,23 @@ view: | `interaction_mode` | `inactive` | Whether the default reset should happen when the card is being interacted with. If `all`, the reset will always happen regardless. If `inactive` the reset will only be taken if the card has _not_ had human interaction recently (as defined by `view.interaction_seconds`). If `active` the reset will only be happen if the card _has_ had human interaction recently. This controls resets triggered by `entities` and `every_seconds`, but not `after_interaction` which by definition requires no interaction. | | `every_seconds` | `0` | A number of seconds after which to automatically reset to the default view. `0` disables this functionality. | +## `issues` + +Configure how the card handles errors/issues and automatic retries. + +All configuration is under: + +```yaml +view: + issues: + # [...] +``` + +| Option | Default | Description | +| ------------------ | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `interaction_mode` | `all` | Whether scheduled retries should happen when the card is being interacted with. If `all`, retries will always happen regardless. If `inactive` retries will only happen if the card has _not_ had human interaction recently (as defined by `view.interaction_seconds`). If `active` retries will only happen if the card _has_ had human interaction recently. User-initiated retries are always allowed. | +| `retry_seconds` | `auto` | Controls automatic retry attempts when an issue is detected (e.g. media not loading, query error). When `auto`, the card uses an exponential backoff schedule starting at ~30 seconds and capped at 10 minutes, with jitter to avoid multiple cards retrying in lockstep. A positive number sets a fixed retry interval in seconds. `0` disables automatic retries entirely. User-initiated retries (e.g. clicking a notification) always run regardless. | + ## `keyboard_shortcuts` All configuration is under: @@ -193,6 +211,9 @@ view: interaction_mode: inactive render_entities: - switch.render_card + issues: + interaction_mode: all + retry_seconds: auto dim: false triggers: show_trigger_status: false diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index f75759f6..85a02bd2 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -43,26 +43,27 @@ To upgrade: If the automatic upgrade button is not visible, your configuration may already be up to date. Try clearing your browser cache and reloading. -### Stream does not load +### Media does not load -Stream not loading? Permanent "loading circle"? +Media not loading? Permanent "loading circle"? -A stream not loading is a relatively common error, but can be caused by any +Media failing to load is a relatively common error, but can be caused by any number of issues (e.g. installation problems, networking problems, video/codec -problems, a Home Assistant bug or card bug). +problems, a Home Assistant bug or card bug). This applies to live streams, +recorded media in the viewer, and image views. -During the stream load, the card will show a "loading circle" icon and, for +During a live stream load, the card will show a "loading circle" icon and, for cameras with a `camera_entity` configured, will show images refreshing once per second until the stream has fully loaded (unless `live.show_image_during_load` is set to false). -Debugging broken streams: +Debugging steps: 1. If you're using the default `auto` live provider, or explicitly setting the `ha` live provider, try opening the `camera_entity` in Home Assistant and verifying whether the stream loads there. You can press the `e` key on any Home Assistant dashboard, choose the relevant entity, and see if the stream - loads. If it does not, you have a upstream installation issue with your + loads. If it does not, you have an upstream installation issue with your camera / the integration for the camera, and need to resolve that first. Your issue is not related to the card itself. 1. Check whether any URLs specified in your card configuration are accessible @@ -81,7 +82,7 @@ Debugging broken streams: you find any prior relevant discussions. If you're happy with just using an image stream but want the small circle to go -away, use the [`image live provider`](./configuration/cameras/live-provider.md?id=image) . +away, use the [`image live provider`](./configuration/cameras/live-provider.md?id=image). ### Unknown Command diff --git a/src/camera-manager/engine-factory.ts b/src/camera-manager/engine-factory.ts index 59e3a36c..a21406d0 100644 --- a/src/camera-manager/engine-factory.ts +++ b/src/camera-manager/engine-factory.ts @@ -5,10 +5,9 @@ import { DeviceRegistryManager } from '../ha/registry/device'; import { EntityRegistryManager } from '../ha/registry/entity/types'; import { ResolvedMediaCache } from '../ha/resolved-media'; import { HomeAssistant } from '../ha/types'; -import { localize } from '../localize/localize'; import { RecordingSegmentsCache } from './cache'; import { CameraManagerEngine } from './engine'; -import { CameraInitializationError } from './error'; +import { CameraNoEntityError } from './error'; import { CameraEventCallback, CameraManagerRequestCache, Engine } from './types'; import { getCameraEntityFromConfig } from './utils/camera-entity-from-config'; @@ -117,10 +116,7 @@ export class CameraManagerEngineFactory { return Engine.Generic; } // Otherwise, it's probably a typo so throw an exception. - throw new CameraInitializationError( - localize('error.no_camera_entity'), - cameraConfig, - ); + throw new CameraNoEntityError(cameraConfig); } switch (entity?.platform) { diff --git a/src/camera-manager/entity-camera.ts b/src/camera-manager/entity-camera.ts index c279cc9d..8c4ed521 100644 --- a/src/camera-manager/entity-camera.ts +++ b/src/camera-manager/entity-camera.ts @@ -1,7 +1,6 @@ import { Entity, EntityRegistryManager } from '../ha/registry/entity/types'; -import { localize } from '../localize/localize'; import { Camera, CameraInitializationOptions } from './camera'; -import { CameraInitializationError } from './error'; +import { CameraNoEntityError } from './error'; import { getCameraEntityFromConfig } from './utils/camera-entity-from-config'; export interface EntityCameraInitializationOptions extends CameraInitializationOptions { @@ -19,7 +18,7 @@ export class EntityCamera extends Camera { : null; if (!entity || !cameraEntityID) { - throw new CameraInitializationError(localize('error.no_camera_entity'), config); + throw new CameraNoEntityError(config); } this._entity = entity; return await super.initialize(options); diff --git a/src/camera-manager/error.ts b/src/camera-manager/error.ts index d026f4af..1afdd6ee 100644 --- a/src/camera-manager/error.ts +++ b/src/camera-manager/error.ts @@ -1,4 +1,34 @@ +import { localize } from '../localize/localize.js'; import { AdvancedCameraCardError } from '../types.js'; -export class CameraInitializationError extends AdvancedCameraCardError {} -export class CameraNoIDError extends AdvancedCameraCardError {} +class CameraInitializationError extends AdvancedCameraCardError {} + +export class CameraNoEngineError extends CameraInitializationError { + constructor(context?: unknown) { + super(localize('error.no_camera_engine'), context); + } +} + +export class CameraNoIDError extends CameraInitializationError { + constructor(context?: unknown) { + super(localize('error.no_camera_id'), context); + } +} + +export class CameraDuplicateIDError extends CameraInitializationError { + constructor(context?: unknown) { + super(localize('error.duplicate_camera_id'), context); + } +} + +export class CameraNoEntityError extends CameraInitializationError { + constructor(context?: unknown) { + super(localize('error.no_camera_entity'), context); + } +} + +export class ReolinkInitializationError extends CameraInitializationError { + constructor(context?: unknown) { + super(localize('error.camera_initialization_reolink'), context); + } +} diff --git a/src/camera-manager/frigate/camera.ts b/src/camera-manager/frigate/camera.ts index 3c3503e2..13ea710b 100644 --- a/src/camera-manager/frigate/camera.ts +++ b/src/camera-manager/frigate/camera.ts @@ -5,7 +5,6 @@ import { PTZAction, PTZActionPhase } from '../../config/schema/actions/custom/pt import { CameraConfig } from '../../config/schema/cameras'; import { Entity, EntityRegistryManager } from '../../ha/registry/entity/types'; import { HomeAssistant } from '../../ha/types'; -import { localize } from '../../localize/localize'; import { SEVERITIES } from '../../severity'; import { CapabilitiesRaw, @@ -15,7 +14,7 @@ import { } from '../../types'; import { errorToConsole } from '../../utils/basic'; import { Camera, CameraInitializationOptions } from '../camera'; -import { CameraInitializationError } from '../error'; +import { CameraNoEntityError } from '../error'; import { CameraEndpoints, CameraEndpointsContext } from '../types'; import { getCameraEntityFromConfig } from '../utils/camera-entity-from-config'; import { @@ -123,7 +122,7 @@ export class FrigateCamera extends Camera { if (cameraEntity && (!hasCameraName || hasAutoTriggers)) { entity = await entityRegistryManager.getEntity(hass, cameraEntity); if (!entity) { - throw new CameraInitializationError(localize('error.no_camera_entity'), config); + throw new CameraNoEntityError(config); } } diff --git a/src/camera-manager/manager.ts b/src/camera-manager/manager.ts index 70f3832b..ba6b50ec 100644 --- a/src/camera-manager/manager.ts +++ b/src/camera-manager/manager.ts @@ -11,7 +11,6 @@ import { } from '../config/schema/actions/custom/ptz.js'; import { CameraConfig, Rotation } from '../config/schema/cameras.js'; import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const.js'; -import { localize } from '../localize/localize.js'; import { Endpoint } from '../types.js'; import { allPromises, @@ -28,7 +27,11 @@ import { ViewItemCapabilities } from '../view/types.js'; import { Capabilities } from './capabilities.js'; import { CameraManagerEngineFactory } from './engine-factory.js'; import { CameraManagerEngine } from './engine.js'; -import { CameraInitializationError } from './error.js'; +import { + CameraDuplicateIDError, + CameraNoEngineError, + CameraNoIDError, +} from './error.js'; import { CameraManagerReadOnlyConfigStore, CameraManagerStore } from './store.js'; import { CameraEndpoints, @@ -150,12 +153,12 @@ export class CameraManager { this._store = options?.store ?? new CameraManagerStore(); } - public async initializeCamerasFromConfig(): Promise { + public async initializeCamerasFromConfig(): Promise { const config = this._api.getConfigManager().getConfig(); const hass = this._api.getHASSManager().getHASS(); if (!config || !hass) { - return false; + return; } this._requestLimit.concurrency = @@ -169,15 +172,7 @@ export class CameraManager { recursivelyMergeObjectsNotArrays(config?.cameras_global, camera), ); - try { - await this._initializeCameras(cameras); - } catch (e: unknown) { - this._api - .getMessageManager() - .setErrorIfHigherPriority(e, localize('error.camera_initialization')); - return false; - } - return true; + await this._initializeCameras(cameras); } public async destroy(): Promise { @@ -214,12 +209,9 @@ export class CameraManager { })) : null; if (!engine || !engineType) { - throw new CameraInitializationError( - localize('error.no_camera_engine'), - // Camera initialization may modify the configuration. Keep the - // original config unchanged. - cloneDeep(cameraConfig), - ); + // Camera initialization may modify the configuration. Keep the + // original config unchanged. + throw new CameraNoEngineError(cloneDeep(cameraConfig)); } engines.set(engineType, engine); output.set(cameraConfig, engine); @@ -272,18 +264,12 @@ export class CameraManager { if (!cameraID) { await destroyCameras(); - throw new CameraInitializationError( - localize('error.no_camera_id'), - camera.getConfig(), - ); + throw new CameraNoIDError(camera.getConfig()); } if (cameraIDs.has(cameraID)) { await destroyCameras(); - throw new CameraInitializationError( - localize('error.duplicate_camera_id'), - camera.getConfig(), - ); + throw new CameraDuplicateIDError(camera.getConfig()); } // Always ensure the actual ID used in the card is in the configuration itself. diff --git a/src/camera-manager/reolink/camera.ts b/src/camera-manager/reolink/camera.ts index 096bbda0..5fb9a540 100644 --- a/src/camera-manager/reolink/camera.ts +++ b/src/camera-manager/reolink/camera.ts @@ -7,7 +7,6 @@ import { import { DeviceRegistryManager } from '../../ha/registry/device/index'; import { Entity, EntityRegistryManager } from '../../ha/registry/entity/types'; import { HomeAssistant } from '../../ha/types'; -import { localize } from '../../localize/localize'; import { CapabilitiesRaw, Endpoint, @@ -17,7 +16,7 @@ import { import { createSelectOptionAction } from '../../utils/action.js'; import { Camera, CameraInitializationOptions } from '../camera'; import { EntityCamera } from '../entity-camera'; -import { CameraInitializationError } from '../error'; +import { ReolinkInitializationError } from '../error'; import { CameraEndpointsContext, CameraProxyConfig } from '../types'; import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz'; @@ -44,8 +43,6 @@ interface ReolinkCameraInitializationOptions extends CameraInitializationOptions deviceRegistryManager: DeviceRegistryManager; } -class ReolinkInitializationError extends CameraInitializationError {} - // Button entities for continuous PTZ movement (press to start, press stop to // end). Discovered from button.{name}_ptz_{action} entities. Zoom button // entities are disabled by default in the Reolink integration. @@ -153,10 +150,7 @@ export class ReolinkCamera extends EntityCamera { const channelOrUID = match?.groups?.channel_or_uid ?? null; if (hostid === null || channelOrUID === null) { - throw new ReolinkInitializationError( - localize('error.camera_initialization_reolink'), - this.getConfig(), - ); + throw new ReolinkInitializationError(this.getConfig()); } const channelCandidate = Number(channelOrUID); diff --git a/src/card-controller/actions/actions-manager.ts b/src/card-controller/actions/actions-manager.ts index b111ac0a..f2b3491f 100644 --- a/src/card-controller/actions/actions-manager.ts +++ b/src/card-controller/actions/actions-manager.ts @@ -45,7 +45,8 @@ export class ActionsManager implements ActionsExecutor { */ public getMergedActions(): ActionsConfig { const view = this._api.getViewManager().getView(); - if (this._api.getMessageManager().hasMessage()) { + // Don't apply view actions when there are full-card/serious issues. + if (this._api.getIssueManager().getStateManager().hasFullCardIssue()) { return {}; } diff --git a/src/card-controller/actions/actions/info.ts b/src/card-controller/actions/actions/info.ts index b299888d..c8f43056 100644 --- a/src/card-controller/actions/actions/info.ts +++ b/src/card-controller/actions/actions/info.ts @@ -1,4 +1,4 @@ -import { MediaDetailsController } from '../../../components-lib/media/details-controller'; +import { MediaNotificationController } from '../../../components-lib/media/notification-controller'; import { GeneralActionConfig } from '../../../config/schema/actions/custom/general'; import { ViewItemClassifier } from '../../../view/item-classifier'; import { CardActionsAPI } from '../../types'; @@ -13,11 +13,11 @@ export class InfoAction extends AdvancedCameraCardAction { return; } - const controller = new MediaDetailsController(); - controller.calculate(api.getCameraManager(), item); + const notificationController = new MediaNotificationController(); + notificationController.calculate(api.getCameraManager(), item); api.getNotificationManager().setNotification( - controller.getNotification({ + notificationController.getNotification({ hass: api.getHASSManager().getHASS() ?? undefined, viewItemManager: api.getViewItemManager(), viewManagerEpoch: api.getViewManager().getEpoch(), diff --git a/src/card-controller/automations-manager.ts b/src/card-controller/automations-manager.ts index 25a8575f..033d7e27 100644 --- a/src/card-controller/automations-manager.ts +++ b/src/card-controller/automations-manager.ts @@ -50,7 +50,7 @@ export class AutomationsManager { !this._api.getInitializationManager().isInitializedMandatory() || // Never execute automations if there's an error (as our automation loop // avoidance -- which shows as an error -- would not work!). - this._api.getMessageManager().hasErrorMessage() + this._api.getIssueManager().getStateManager().hasFullCardIssue() ) { return; } @@ -66,9 +66,12 @@ export class AutomationsManager { ++this._nestedAutomationExecutions; if (this._nestedAutomationExecutions > MAX_NESTED_AUTOMATION_EXECUTIONS) { - this._api.getMessageManager().setMessageIfHigherPriority({ - type: 'error', - message: localize('error.too_many_automations'), + this._api.getNotificationManager().setNotification({ + heading: { + text: localize('error.too_many_automations'), + icon: 'mdi:alert', + severity: 'high', + }, }); return; } diff --git a/src/card-controller/card-element-manager.ts b/src/card-controller/card-element-manager.ts index 276b99c0..6ef34ea2 100644 --- a/src/card-controller/card-element-manager.ts +++ b/src/card-controller/card-element-manager.ts @@ -70,7 +70,6 @@ export class CardElementManager { this._api.getMediaLoadedInfoManager().initialize(); this._api.getMicrophoneManager().initialize(); this._api.getPIPManager().initialize(); - this._api.getProblemManager().initialize(); this._api.getKeyboardStateManager().initialize(); // These initializers are called when the config is updated, but on initial @@ -159,6 +158,8 @@ export class CardElementManager { // disconnected/reconnected when dashboard 'tab' changes happen within HA. this._api.getQueryStringManager().requestExecution(); + this._api.getIssueManager().resume(); + // Make sure reconnections call the initialization code. this._element.requestUpdate(); } @@ -168,20 +169,30 @@ export class CardElementManager { setOrRemoveAttribute(this._element, false, 'tabindex'); setOrRemoveAttribute(this._element, false, 'casted'); + // Suspend issue evaluation so state changes below (e.g. clearing + // mediaLoadedInfo) don't arm timers while the card is detached. Issue state + // is preserved; evaluation resumes via resume() on reconnect. + this._api.getIssueManager().suspend(); + // When the dashboard 'tab' is changed, the media is effectively unloaded. this._api.getMediaLoadedInfoManager().clear(); this._api.getFullscreenManager().disconnect(); this._api.getPIPManager().uninitialize(); - this._api.getProblemManager().uninitialize(); this._api.getKeyboardStateManager().uninitialize(); this._api.getActionsManager().uninitialize(); + this._api.getInteractionManager().uninitialize(); this._api.getDefaultManager().uninitialize(); this._api.getHASSManager().getStateWatcher()?.unsubscribe(this.update); - // Uninitialize cameras to cause them to reinitialize on + // Uninitialize cameras and triggers to cause them to reinitialize on // reconnection, to ensure the state subscription/unsubscription works - // correctly for triggers. + // correctly and triggers that changed while detached are picked up. + // Reset trigger state first to stop stale timers and clear condition state. + this._api.getTriggersManager().reset(); this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS); + this._api + .getInitializationManager() + .uninitialize(InitializationAspect.INITIAL_TRIGGER); this._api.getCameraManager().destroy(); this._element.removeEventListener( diff --git a/src/card-controller/config/config-manager.ts b/src/card-controller/config/config-manager.ts index edfaba73..13bec749 100644 --- a/src/card-controller/config/config-manager.ts +++ b/src/card-controller/config/config-manager.ts @@ -1,4 +1,5 @@ import { isEqual } from 'lodash-es'; +import { ConfigParseError } from './error.js'; import { copyConfig, isConfigUpgradeable } from '../../config/management.js'; import { setProfiles } from '../../config/profiles/set-profiles.js'; import { @@ -56,7 +57,7 @@ export class ConfigManager { public setConfig(inputConfig?: RawAdvancedCameraCardConfig): void { if (!inputConfig) { - throw new Error(localize('error.invalid_configuration')); + throw new ConfigParseError(localize('error.invalid_configuration')); } const parseResult = advancedCameraCardConfigSchema.safeParse(inputConfig); @@ -67,7 +68,7 @@ export class ConfigManager { if (isConfigUpgradeable(inputConfig)) { upgradeMessage = `${localize('error.upgrade_available')}. `; } - throw new Error( + throw new ConfigParseError( upgradeMessage + `${localize('error.invalid_configuration')}: ` + (hint ?? localize('error.invalid_configuration_no_hint')), @@ -109,7 +110,6 @@ export class ConfigManager { this._api.getInitializationManager().uninitialize(InitializationAspect.VIEW); this._api.getViewManager().reset(); - this._api.getMessageManager().reset(); this._api.getStatusBarItemManager().removeAllDynamicStatusBarItems(); this._processOverrideConfig(); @@ -118,11 +118,28 @@ export class ConfigManager { } private _processOverrideConfig(): void { - const overriddenConfig = this._getOverriddenConfig(); + /* istanbul ignore if: No (current) way to reach this code -- @preserve */ + if (!this._config) { + return; + } + + let overriddenConfig: AdvancedCameraCardConfig; + try { + overriddenConfig = this._overridesManager.getConfig(this._config); + } catch (ev) { + this._api.getIssueManager().trigger('config_error', { error: ev }); + return; + } + + // Clear any prior config error on success. This method runs both from + // setConfig() and from condition-change-driven override recomputations; + // resetting here ensures a transient override error is cleared when + // conditions change to produce a valid config again. + this._api.getIssueManager().reset('config_error'); // Save on Lit re-rendering costs by only updating the configuration if it // actually changes. - if (!overriddenConfig || isEqual(overriddenConfig, this._overriddenConfig)) { + if (isEqual(overriddenConfig, this._overriddenConfig)) { return; } @@ -189,20 +206,6 @@ export class ConfigManager { /* async */ this._initializeBackgroundAndUpdate(previousConfig); } - private _getOverriddenConfig(): AdvancedCameraCardConfig | null { - /* istanbul ignore if: No (current) way to reach this code -- @preserve */ - if (!this._config) { - return null; - } - - try { - return this._overridesManager.getConfig(this._config); - } catch (ev) { - this._api.getMessageManager().setErrorIfHigherPriority(ev); - return null; - } - } - /** * Initialize config dependent items in the background. For items that the * card hard requires, use InitializationManager instead. diff --git a/src/card-controller/config/error.ts b/src/card-controller/config/error.ts new file mode 100644 index 00000000..a1365b7b --- /dev/null +++ b/src/card-controller/config/error.ts @@ -0,0 +1,5 @@ +import { AdvancedCameraCardError } from '../../types.js'; + +class ConfigError extends AdvancedCameraCardError {} + +export class ConfigParseError extends ConfigError {} diff --git a/src/card-controller/config/load-folders.ts b/src/card-controller/config/load-folders.ts index 09b9ad31..0473d261 100644 --- a/src/card-controller/config/load-folders.ts +++ b/src/card-controller/config/load-folders.ts @@ -7,6 +7,6 @@ export const setFoldersFromConfig = (api: CardConfigLoaderAPI): void => { .getFoldersManager() .addFolders(api.getConfigManager().getConfig()?.folders ?? []); } catch (ev) { - api.getMessageManager().setErrorIfHigherPriority(ev); + api.getIssueManager().trigger('config_error', { error: ev }); } }; diff --git a/src/card-controller/config/overrides-manager.ts b/src/card-controller/config/overrides-manager.ts index 33487035..775d9f4e 100644 --- a/src/card-controller/config/overrides-manager.ts +++ b/src/card-controller/config/overrides-manager.ts @@ -1,4 +1,4 @@ -import { merge } from 'lodash-es'; +import { get, merge } from 'lodash-es'; import { ConditionsManager } from '../../conditions/conditions-manager'; import { ConditionStateManagerReadonlyInterface } from '../../conditions/types'; import { @@ -92,9 +92,24 @@ export class OverridesManager { const parseResult = advancedCameraCardConfigSchema.safeParse(output); if (!parseResult.success) { + // Surface one co-located failure object per Zod issue — path, the value + // the user actually wrote, and the most informative "expected" field for + // this issue code. Avoids dumping the full merged config (which is + // mostly schema defaults the user never wrote) and keeps the reader from + // cross-referencing two parallel arrays. + const failures = parseResult.error.issues.map((issue) => ({ + path: issue.path.map(String).join('.'), + received: get(output, issue.path), + expected: + issue.code === 'invalid_value' + ? issue.values + : issue.code === 'invalid_type' + ? issue.expected + : issue.message, + })); throw new OverrideConfigurationError( localize('error.invalid_configuration_override'), - [parseResult.error.issues, output], + { failures }, ); } return parseResult.data; diff --git a/src/card-controller/controller.ts b/src/card-controller/controller.ts index 36daa899..ee35b809 100644 --- a/src/card-controller/controller.ts +++ b/src/card-controller/controller.ts @@ -29,11 +29,11 @@ import { InteractionManager } from './interaction-manager'; import { KeyboardStateManager } from './keyboard-state-manager'; import { MediaLoadedInfoManager } from './media-info-manager'; import { MediaPlayerManager } from './media-player-manager'; -import { MessageManager } from './message-manager'; import { MicrophoneManager } from './microphone-manager'; import { NotificationManager } from './notification-manager'; import { PIPManager } from './pip-manager'; -import { ProblemManager } from './problems/manager'; +import { createIssueManager } from './issues/factory'; +import { IssueManager } from './issues/issue-manager'; import { QueryStringManager } from './query-string-manager'; import { StatusBarItemManager } from './status-bar-item-manager'; import { StyleManager } from './style-manager'; @@ -57,11 +57,10 @@ import { CardKeyboardStateAPI, CardMediaLoadedAPI, CardMediaPlayerAPI, - CardMessageAPI, CardMicrophoneAPI, CardNotificationAPI, CardPIPAPI, - CardProblemAPI, + CardIssueManagerAPI, CardQueryStringAPI, CardStyleAPI, CardTriggersAPI, @@ -85,13 +84,12 @@ export class CardController CardFullscreenAPI, CardHASSAPI, CardPIPAPI, - CardProblemAPI, + CardIssueManagerAPI, CardInitializerAPI, CardInteractionAPI, CardKeyboardStateAPI, CardMediaLoadedAPI, CardMediaPlayerAPI, - CardMessageAPI, CardMicrophoneAPI, CardNotificationAPI, CardQueryStringAPI, @@ -126,11 +124,10 @@ export class CardController private _mediaLoadedInfoManager = new MediaLoadedInfoManager(this); private _mediaPlayerManager = new MediaPlayerManager(this); - private _messageManager = new MessageManager(this); private _microphoneManager = new MicrophoneManager(this); private _notificationManager = new NotificationManager(this); private _pipManager = new PIPManager(this); - private _problemManager = new ProblemManager(this); + private _issueManager = createIssueManager(this); private _queryStringManager = new QueryStringManager(this); private _statusBarItemManager = new StatusBarItemManager(this); private _styleManager = new StyleManager(this); @@ -245,10 +242,6 @@ export class CardController return this._mediaPlayerManager; } - public getMessageManager(): MessageManager { - return this._messageManager; - } - public getMicrophoneManager(): MicrophoneManager { return this._microphoneManager; } @@ -264,8 +257,8 @@ export class CardController return this._pipManager; } - public getProblemManager(): ProblemManager { - return this._problemManager; + public getIssueManager(): IssueManager { + return this._issueManager; } public getQueryStringManager(): QueryStringManager { diff --git a/src/card-controller/hass/hass-manager.ts b/src/card-controller/hass/hass-manager.ts index 719c3e10..03e95cc2 100644 --- a/src/card-controller/hass/hass-manager.ts +++ b/src/card-controller/hass/hass-manager.ts @@ -1,6 +1,5 @@ -import { hasHAConnectionStateChanged } from '../../ha/has-hass-connection-changed'; +import { STATE_RUNNING } from 'home-assistant-js-websocket'; import { HomeAssistant } from '../../ha/types'; -import { localize } from '../../localize/localize'; import { log } from '../../utils/debug'; import { InitializationAspect } from '../initialization-manager'; import { CardHASSAPI } from '../types'; @@ -28,39 +27,30 @@ export class HASSManager { } public setHASS(hass?: HomeAssistant | null): void { - if (hasHAConnectionStateChanged(this._hass, hass)) { - if (!hass?.connected) { - this._api.getMessageManager().setMessageIfHigherPriority({ - message: localize('error.reconnecting'), - icon: 'mdi:lan-disconnect', - type: 'connection', - dotdotdot: true, - }); - } else { - this._api.getMessageManager().resetType('connection'); + // When HA transitions from "not ready" to "ready" (WebSocket reconnected + // AND all integrations finished loading), reinitialize cameras and the + // view. This is necessary because event subscriptions (e.g. Frigate + // WebSocket subscriptions via hass.connection.subscribeMessage) are tied to + // the old connection and are lost when it drops. Without reinitialization, + // triggers and thumbnail updates stop working. + // + // We deliberately wait for hass.config.state === STATE_RUNNING rather than + // just hass.connected, because HA exposes the WebSocket before integrations + // have finished loading. Triggering re-init too early would race against + // integration startup and fail with "Unknown command" on + // integration-specific WS calls. + if (this._hass && !this._isReady(this._hass) && this._isReady(hass)) { + log( + this._api.getConfigManager().getCardWideConfig(), + 'Advanced Camera Card: HA fully ready, reinitializing...', + ); - // When the HA WebSocket connection is restored after a drop, - // reinitialize cameras and the view. This is necessary because - // event subscriptions (e.g. Frigate WebSocket subscriptions via - // hass.connection.subscribeMessage) are tied to the old connection - // and are lost when it drops. Without reinitialization, triggers - // and thumbnail updates stop working. - if (this._hass) { - log( - this._api.getConfigManager().getCardWideConfig(), - 'Advanced Camera Card: HA connection restored, reinitializing...', - ); - - this._api - .getInitializationManager() - .uninitialize(InitializationAspect.CAMERAS); - this._api.getCameraManager().destroy(); - this._api.getInitializationManager().uninitialize(InitializationAspect.VIEW); - this._api - .getInitializationManager() - .uninitialize(InitializationAspect.INITIAL_TRIGGER); - } - } + this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS); + this._api.getCameraManager().destroy(); + this._api.getInitializationManager().uninitialize(InitializationAspect.VIEW); + this._api + .getInitializationManager() + .uninitialize(InitializationAspect.INITIAL_TRIGGER); } if (!hass) { @@ -79,4 +69,8 @@ export class HASSManager { this._stateWatcher.setHASS(oldHass, hass); } + + private _isReady(hass?: HomeAssistant | null): boolean { + return !!hass?.connected && hass.config?.state === STATE_RUNNING; + } } diff --git a/src/card-controller/initialization-manager.ts b/src/card-controller/initialization-manager.ts index 54132e36..98681a2f 100644 --- a/src/card-controller/initialization-manager.ts +++ b/src/card-controller/initialization-manager.ts @@ -1,6 +1,8 @@ +import { STATE_RUNNING } from 'home-assistant-js-websocket'; import PQueue from 'p-queue'; import { sideLoadHomeAssistantElements } from '../ha/side-load-ha-elements'; import { loadLanguages } from '../localize/localize'; +import { errorToConsole } from '../utils/basic'; import { Initializer } from '../utils/initializer/initializer'; import { CardInitializerAPI } from './types'; @@ -9,7 +11,6 @@ export enum InitializationAspect { SIDE_LOAD_ELEMENTS = 'side-load-elements', CAMERAS = 'cameras', MICROPHONE_CONNECT = 'microphone-connect', - PROBLEMS = 'problems', VIEW = 'view', // The initial triggering must happen after both the config is set (and @@ -51,10 +52,6 @@ export class InitializationManager { return this._initializer.isInitialized(aspect); } - public isInitializedBackground(): boolean { - return this._initializer.isInitialized(InitializationAspect.PROBLEMS); - } - public isInitializedMandatory(): boolean { const config = this._api.getConfigManager().getConfig(); if (!config) { @@ -87,14 +84,28 @@ export class InitializationManager { return; } + // Wait until HA has finished loading integrations before attempting init. + // Otherwise integration-specific WS calls (e.g. Frigate event + // subscriptions) fail with "Unknown command" against a half-loaded HA. The + // HASSManager will trigger another init attempt as soon as + // hass.config.state transitions to RUNNING. + if (hass.config?.state !== STATE_RUNNING) { + return; + } + if ( - !(await this._initializer.initializeMultipleIfNecessary({ - // Caution: Ensure nothing in this set of initializers requires - // config or languages since they will not yet have been initialized. - [InitializationAspect.LANGUAGES]: async () => await loadLanguages(hass), - [InitializationAspect.SIDE_LOAD_ELEMENTS]: async () => - await sideLoadHomeAssistantElements(), - })) + !(await this._tryInitialize(() => + this._initializer.initializeMultipleIfNecessary({ + // Caution: Ensure nothing in this set of initializers requires + // config or languages since they will not yet have been initialized. + [InitializationAspect.LANGUAGES]: async () => { + await loadLanguages(hass); + }, + [InitializationAspect.SIDE_LOAD_ELEMENTS]: async () => { + await sideLoadHomeAssistantElements(); + }, + }), + )) ) { return; } @@ -105,52 +116,56 @@ export class InitializationManager { } if ( - !(await this._initializer.initializeMultipleIfNecessary({ - [InitializationAspect.CAMERAS]: async () => { - // Recreate the camera manager to guarantee an immediate re-render. - // See: https://github.com/dermotduffy/advanced-camera-card/issues/1811 - // See: https://github.com/dermotduffy/advanced-camera-card/issues/1769 - this._api.createCameraManager(); - return await this._api.getCameraManager().initializeCamerasFromConfig(); - }, - - // Connecting the microphone (if configured) is considered mandatory to - // avoid issues with some cameras that only allow 2-way audio on the - // first stream initialized. - // See: https://github.com/dermotduffy/advanced-camera-card/issues/1235 - ...(this._api.getMicrophoneManager().shouldConnectOnInitialization() && { - [InitializationAspect.MICROPHONE_CONNECT]: async () => { - // Recreate the microphone manager to guarantee an immediate - // re-render. - this._api.createMicrophoneManager(); - return await this._api.getMicrophoneManager().connect(); + !(await this._tryInitialize(() => + this._initializer.initializeMultipleIfNecessary({ + [InitializationAspect.CAMERAS]: async () => { + // Recreate the camera manager to guarantee an immediate re-render. + // See: https://github.com/dermotduffy/advanced-camera-card/issues/1811 + // See: https://github.com/dermotduffy/advanced-camera-card/issues/1769 + this._api.createCameraManager(); + await this._api.getCameraManager().initializeCamerasFromConfig(); }, - }), - })) - ) { - return; - } - if ( - this._api.getMessageManager().hasMessage() || - !(await this._initializer.initializeIfNecessary( - InitializationAspect.VIEW, - this._api.getViewManager().initialize, + // Connecting the microphone (if configured) is considered mandatory to + // avoid issues with some cameras that only allow 2-way audio on the + // first stream initialized. + // See: https://github.com/dermotduffy/advanced-camera-card/issues/1235 + ...(this._api.getMicrophoneManager().shouldConnectOnInitialization() && { + [InitializationAspect.MICROPHONE_CONNECT]: async () => { + // Recreate the microphone manager to guarantee an immediate + // re-render. + this._api.createMicrophoneManager(); + await this._api.getMicrophoneManager().connect(); + }, + }), + }), )) ) { return; } if ( - !(await this._initializer.initializeIfNecessary( - InitializationAspect.INITIAL_TRIGGER, - async (): Promise => { - await this._api.getTriggersManager().handleInitialCameraTriggers(); + !(await this._tryInitialize(() => + this._initializer.initializeIfNecessary( + InitializationAspect.VIEW, + this._api.getViewManager().initialize, + ), + )) + ) { + return; + } - // Force a card update to continue the initialization. - this._api.getCardElementManager().update(); - return true; - }, + if ( + !(await this._tryInitialize(() => + this._initializer.initializeIfNecessary( + InitializationAspect.INITIAL_TRIGGER, + async () => { + await this._api.getTriggersManager().handleInitialCameraTriggers(); + + // Force a card update to continue the initialization. + this._api.getCardElementManager().update(); + }, + ), )) ) { return; @@ -172,26 +187,40 @@ export class InitializationManager { this._api.getCardElementManager().update(); } - public async initializeBackground(): Promise { - await this._initializationQueue.add(() => this._initializeBackground()); - } - - private async _initializeBackground(): Promise { - const hass = this._api.getHASSManager().getHASS(); - if (!hass) { - return; + private async _tryInitialize(fn: () => Promise): Promise { + try { + await fn(); + } catch (e: unknown) { + if (e instanceof Error) { + errorToConsole(e); + } + this._setInitializationIssue(e); + return false; } - await this._initializer.initializeIfNecessary( - InitializationAspect.PROBLEMS, - async () => { - await this._api.getProblemManager().detectStatic(hass); - return true; - }, - ); + if (this._api.getIssueManager().getStateManager().hasFullCardIssue()) { + return false; + } + + return true; + } + + private _setInitializationIssue(error: unknown): void { + this._api.getIssueManager().trigger('initialization', { error }); } public uninitialize(aspect: InitializationAspect): void { this._initializer.uninitialize(aspect); } + + public uninitializeMandatory(): void { + for (const aspect of [ + InitializationAspect.CAMERAS, + InitializationAspect.MICROPHONE_CONNECT, + InitializationAspect.VIEW, + InitializationAspect.INITIAL_TRIGGER, + ]) { + this._initializer.uninitialize(aspect); + } + } } diff --git a/src/card-controller/interaction-manager.ts b/src/card-controller/interaction-manager.ts index ad0e1b35..704302b2 100644 --- a/src/card-controller/interaction-manager.ts +++ b/src/card-controller/interaction-manager.ts @@ -22,6 +22,11 @@ export class InteractionManager { this._setInteraction(false); } + public uninitialize(): void { + this._timer.stop(); + this.reportInteraction.cancel(); + } + public hasInteraction(): boolean { return this._interacted; } diff --git a/src/card-controller/issues/factory.ts b/src/card-controller/issues/factory.ts new file mode 100644 index 00000000..17f40d4a --- /dev/null +++ b/src/card-controller/issues/factory.ts @@ -0,0 +1,30 @@ +import { CardIssueManagerAPI } from '../types'; +import { IssueManager } from './issue-manager'; +import { ConfigErrorIssue } from './issues/config-error'; +import { ConfigUpgradeIssue } from './issues/config-upgrade'; +import { ConnectionIssue } from './issues/connection'; +import { InitializationIssue } from './issues/initialization'; +import { LegacyResourceIssue } from './issues/legacy-resource'; +import { MediaLoadIssue } from './issues/media-load'; +import { MediaQueryIssue } from './issues/media-query'; +import { ViewIncompatibleIssue } from './issues/view-incompatible'; + +export const createIssueManager = (api: CardIssueManagerAPI): IssueManager => { + const manager = new IssueManager(api); + const changeCallback = () => manager.evaluate(); + + // Registration order determines both retry priority and full-card display + // priority. For retries, issues are retried in order and an exclusive retry + // stops the loop. For display, getFullCardIssue() returns the first active + // full-card issue. Register broader/more critical issues first. + manager.addIssue(new ConfigErrorIssue()); + manager.addIssue(new ConfigUpgradeIssue(api)); + manager.addIssue(new ViewIncompatibleIssue(api)); + manager.addIssue(new ConnectionIssue()); + manager.addIssue(new InitializationIssue(api)); + manager.addIssue(new LegacyResourceIssue(changeCallback)); + manager.addIssue(new MediaQueryIssue(api)); + manager.addIssue(new MediaLoadIssue(api, changeCallback)); + + return manager; +}; diff --git a/src/card-controller/issues/issue-manager.ts b/src/card-controller/issues/issue-manager.ts new file mode 100644 index 00000000..44b01af4 --- /dev/null +++ b/src/card-controller/issues/issue-manager.ts @@ -0,0 +1,246 @@ +import type { IssueTriggerContext } from 'issue'; +import { ConditionStateChange } from '../../conditions/types'; +import { isActionAllowedBasedOnInteractionState } from '../../utils/interaction-mode'; +import { Timer } from '../../utils/timer'; +import { CardIssueManagerAPI } from '../types'; +import { IssueStateManager } from './state-manager'; +import { Issue, IssueKey, IssueReadOnlyState, IssueTriggerContextKey } from './types'; + +// Exponential backoff schedule for 'auto' retry. The base is set above the +// per-media retry threshold (~10s) so the issue-level backoff kicks in *after* +// lower-level recovery has had a chance to work, not in parallel with it. +export const RETRY_EXPONENTIAL_BASE_SECONDS = 30; +export const RETRY_EXPONENTIAL_MAX_SECONDS = 600; +const RETRY_EXPONENTIAL_JITTER_MIN = 0.5; +const RETRY_EXPONENTIAL_JITTER_MAX = 1.0; + +// Wraps the passive IssueStateManager with reaction logic. A single +// condition-state listener drives everything: it runs one-shot static +// detection when mandatory-init completes (`initialized` transitions to +// true), then evaluates dynamic issues on every subsequent state change, +// schedules retries, and updates the card. Full-card issues are rendered by +// card.ts via getStateManager().getFullCardIssue(). Non-full-card issue +// notifications are shown on demand via showNotification(). +export class IssueManager { + private _api: CardIssueManagerAPI; + private _stateManager = new IssueStateManager(); + private _retryTimer = new Timer(); + private _retryAttempt = 0; + private _suspended = false; + + // Reentrancy guard: evaluate() calls setState() on the condition state + // manager, which fires listeners synchronously — including the one + // registered in this constructor. Without this guard, detectDynamic() + // and presence computation would run twice per evaluation. + private _evaluating = false; + + constructor(api: CardIssueManagerAPI) { + this._api = api; + api.getConditionStateManager().addListener((change) => this._onStateChange(change)); + } + + // ========================================================================= + // Setup. + // ========================================================================= + + public addIssue(issue: Issue): void { + this._stateManager.addIssue(issue); + } + + public getStateManager(): IssueReadOnlyState { + return this._stateManager; + } + + // ========================================================================= + // Detection & reaction. + // ========================================================================= + + // Called by components that detect an issue directly (e.g. a provider + // error event), bypassing the condition-state polling loop. + public trigger( + key: K, + context: IssueTriggerContext[K], + ): void { + this._stateManager.trigger(key, context); + this.evaluate(); + } + + // Evaluate all dynamic issues against current state, then react to any + // changes: notify, update condition state, and schedule retries. + // + // Detection of "anything changed" is delegated to the condition state + // manager: IssuePresence is a Map, so its + // deep equality check naturally catches both presence-set churn (issues + // appearing/disappearing) and content-level churn (an issue swapping + // sub-states without changing its key, e.g. ConnectionIssue going from + // 'lost' to 'starting'). + public evaluate(): void { + if (this._suspended || this._evaluating) { + return; + } + + this._evaluating = true; + try { + const state = this._api.getConditionStateManager().getState(); + this._stateManager.detectDynamic(state); + + if ( + this._api.getConditionStateManager().setState({ + issues: this._stateManager.getIssuePresence(), + }) + ) { + this._api.getCardElementManager().update(); + } + + this._scheduleRetryIfNeeded(); + } finally { + this._evaluating = false; + } + } + + // Attempts a retry for the given issue. Pass `force = true` for user- + // initiated retries (e.g. clicking the retry button on a notification): it + // bypasses the `needsRetry()` gate that scheduled auto-retries must + // respect, so even an issue that doesn't currently want a retry will run + // its `retry()` method. Also stops the pending auto-retry timer so the + // user action resets the backoff schedule. + public retry(key: IssueKey, force?: boolean): void { + this._stateManager.retry(key, force); + this._retryTimer.stop(); + this.evaluate(); + } + + // Show the notification for an issue on demand (e.g. user clicks a loading + // icon) regardless of whether the issue is currently active. + public showNotification(key: IssueKey): void { + const notification = this._stateManager.getNotification(key); + if (notification) { + this._api.getNotificationManager().setNotification(notification); + } + } + + // ========================================================================= + // Lifecycle. + // ========================================================================= + + public reset(key?: IssueKey): void { + // When resetting a specific key that has no active issue, skip the + // reset+evaluate cycle entirely to avoid unnecessary work. + if (key && !this._stateManager.getIssuePresence().has(key)) { + return; + } + this._stateManager.reset(key); + this.evaluate(); + } + + // Gate evaluation while the card is detached so timers don't arm or mature + // offscreen. Issue state is preserved (including full-card issues like + // config_error). Issue-internal timers are stopped via Issue.suspend so + // offscreen time doesn't count against age-based thresholds (e.g. media + // loading timeout). Evaluation resumes on resume(). + public suspend(): void { + this._suspended = true; + this._retryTimer.stop(); + this._stateManager.suspend(); + } + + public resume(): void { + this._suspended = false; + this.evaluate(); + } + + public destroy(): void { + this._retryTimer.stop(); + this._stateManager.destroy(); + } + + // ========================================================================= + // Private helpers. + // ========================================================================= + + // Drives both one-shot static detection (on mandatory-init completion) and + // normal re-evaluation (on any condition-state change). + // + // `initialized: true` in the change payload means mandatory initialization + // just finished — see InitializationManager._initializeMandatory. That's + // also the earliest point at which the full HASS object is guaranteed + // ready for websocket calls (e.g. LegacyResourceIssue's lovelace/resources + // fetch). Because `initialized` is latched (its comment notes it never + // changes again), this block fires exactly once per IssueManager life. + private _onStateChange(change: ConditionStateChange): void { + if (change.change.initialized === true && change.new.hass) { + /* async */ this._stateManager + .detectStatic(change.new.hass) + .then(() => this.evaluate()); + } + this.evaluate(); + } + + private _scheduleRetryIfNeeded(): void { + if (!this._stateManager.needsRetry()) { + this._retryTimer.stop(); + this._retryAttempt = 0; + return; + } + if (this._retryTimer.isRunning()) { + return; + } + + const config = this._api.getConfigManager().getConfig(); + if (!config) { + this._retryAttempt = 0; + return; + } + const delaySeconds = this._nextRetryDelaySeconds(config.view.issues.retry_seconds); + if (delaySeconds === null) { + this._retryAttempt = 0; + return; + } + + this._retryTimer.start(delaySeconds, () => { + if (!this._stateManager.needsRetry()) { + this._retryAttempt = 0; + return; + } + if (this._isScheduledRetryAllowed()) { + this._stateManager.retry(); + this._retryAttempt++; + // evaluate() re-arms the timer via _scheduleRetryIfNeeded. + this.evaluate(); + } else { + // Retry was gated (e.g. user interaction). This isn't a failed attempt + // so don't increment — re-arm at the same delay. + this._scheduleRetryIfNeeded(); + } + }); + } + + private _nextRetryDelaySeconds(retryConfig: 'auto' | number): number | null { + if (typeof retryConfig === 'number') { + return retryConfig === 0 ? null : retryConfig; + } + + // 'auto': exponential backoff, capped, with jitter to avoid thundering-herd + // when multiple cards retry the same backend in lockstep. + const exp = Math.min( + RETRY_EXPONENTIAL_MAX_SECONDS, + RETRY_EXPONENTIAL_BASE_SECONDS * 2 ** this._retryAttempt, + ); + const jitter = + RETRY_EXPONENTIAL_JITTER_MIN + + Math.random() * (RETRY_EXPONENTIAL_JITTER_MAX - RETRY_EXPONENTIAL_JITTER_MIN); + return exp * jitter; + } + + private _isScheduledRetryAllowed(): boolean { + const interactionMode = this._api.getConfigManager().getConfig()?.view + .issues.interaction_mode; + return ( + !!interactionMode && + isActionAllowedBasedOnInteractionState( + interactionMode, + this._api.getInteractionManager().hasInteraction(), + ) + ); + } +} diff --git a/src/card-controller/issues/issues/abstract-error-issue.ts b/src/card-controller/issues/issues/abstract-error-issue.ts new file mode 100644 index 00000000..60bcfe61 --- /dev/null +++ b/src/card-controller/issues/issues/abstract-error-issue.ts @@ -0,0 +1,30 @@ +import { Issue, IssueDescription, IssueKey } from '../types.js'; + +// Shared base for issues whose only state is a single triggered error. Subclasses +// define the key and how an error renders; the base handles trigger/reset and +// gates getIssue() on error presence. +export abstract class AbstractErrorIssue implements Issue { + public abstract readonly key: IssueKey; + protected _error: unknown = null; + + public trigger(context: { error: unknown }): void { + this._error = context.error ?? null; + } + + public hasIssue(): boolean { + return this._error !== null; + } + + public getIssue(): IssueDescription | null { + if (this._error == null) { + return null; + } + return this._buildDescription(this._error); + } + + public reset(): void { + this._error = null; + } + + protected abstract _buildDescription(error: NonNullable): IssueDescription; +} diff --git a/src/card-controller/issues/issues/config-error.ts b/src/card-controller/issues/issues/config-error.ts new file mode 100644 index 00000000..c3e1fe73 --- /dev/null +++ b/src/card-controller/issues/issues/config-error.ts @@ -0,0 +1,28 @@ +import { createNotificationFromError } from '../../../components-lib/notification/factory.js'; +import { localize } from '../../../localize/localize.js'; +import { IssueDescription } from '../types.js'; +import { AbstractErrorIssue } from './abstract-error-issue.js'; + +declare module 'issue' { + interface IssueTriggerContext { + config_error: { error: unknown }; + } +} + +export class ConfigErrorIssue extends AbstractErrorIssue { + public readonly key = 'config_error' as const; + + public isFullCardIssue(): boolean { + return true; + } + + protected _buildDescription(error: NonNullable): IssueDescription { + return { + icon: 'mdi:alert', + severity: 'high', + notification: createNotificationFromError(error, { + heading: { text: localize('issues.config_error.heading') }, + }), + }; + } +} diff --git a/src/card-controller/problems/problems/config-upgrade.ts b/src/card-controller/issues/issues/config-upgrade.ts similarity index 54% rename from src/card-controller/problems/problems/config-upgrade.ts rename to src/card-controller/issues/issues/config-upgrade.ts index 70fe2b6f..9c188f3d 100644 --- a/src/card-controller/problems/problems/config-upgrade.ts +++ b/src/card-controller/issues/issues/config-upgrade.ts @@ -1,29 +1,29 @@ import { isConfigUpgradeable } from '../../../config/management.js'; -import { RawAdvancedCameraCardConfig } from '../../../config/types.js'; import { TROUBLESHOOTING_CONFIG_UPGRADE_URL } from '../../../const.js'; import { localize } from '../../../localize/localize.js'; -import { Problem, ProblemResult } from '../types'; +import { CardIssueManagerAPI } from '../../types'; +import { Issue, IssueDescription } from '../types'; -export class ConfigUpgradeProblem implements Problem { +export class ConfigUpgradeIssue implements Issue { public readonly key = 'config_upgrade' as const; + private _api: CardIssueManagerAPI; private _upgradeable = false; - private _getRawConfig: () => RawAdvancedCameraCardConfig | null; - constructor(getRawConfig: () => RawAdvancedCameraCardConfig | null) { - this._getRawConfig = getRawConfig; + constructor(api: CardIssueManagerAPI) { + this._api = api; } public async detectStatic(): Promise { - const rawConfig = this._getRawConfig(); + const rawConfig = this._api.getConfigManager().getRawConfig(); this._upgradeable = !!rawConfig && isConfigUpgradeable(rawConfig); } - public hasResult(): boolean { + public hasIssue(): boolean { return this._upgradeable; } - public getResult(): ProblemResult | null { + public getIssue(): IssueDescription | null { if (!this._upgradeable) { return null; } @@ -32,14 +32,14 @@ export class ConfigUpgradeProblem implements Problem { severity: 'medium', notification: { heading: { - text: localize('problems.config_upgrade.heading'), + text: localize('issues.config_upgrade.heading'), icon: 'mdi:update', severity: 'medium', }, - text: localize('problems.config_upgrade.text'), + body: { text: localize('issues.config_upgrade.text') }, link: { url: TROUBLESHOOTING_CONFIG_UPGRADE_URL, - title: localize('problems.troubleshooting_guide'), + title: localize('issues.troubleshooting_guide'), }, }, }; diff --git a/src/card-controller/issues/issues/connection.ts b/src/card-controller/issues/issues/connection.ts new file mode 100644 index 00000000..0c0d89c6 --- /dev/null +++ b/src/card-controller/issues/issues/connection.ts @@ -0,0 +1,79 @@ +import { STATE_RUNNING } from 'home-assistant-js-websocket'; +import { ConditionState } from '../../../conditions/types.js'; +import { localize } from '../../../localize/localize.js'; +import { Issue, IssueDescription } from '../types.js'; + +// Tracks "is HA fully ready to talk to". Active in two sub-states: +// - 'lost' : the WebSocket is disconnected +// - 'starting' : the WebSocket is connected but HA hasn't finished loading +// integrations yet (hass.config.state !== STATE_RUNNING) +// Both sub-states render as a full-card notification with a spinner. The card +// only attempts re-initialization when the issue clears (i.e. HA is fully +// ready), so integration-specific WS calls (e.g. Frigate event subscriptions) +// don't fail with "Unknown command" against a half-loaded HA. +type ConnectionState = 'ready' | 'lost' | 'starting'; + +export class ConnectionIssue implements Issue { + public readonly key = 'connection' as const; + + private _state: ConnectionState = 'ready'; + + public detectDynamic(state: ConditionState): void { + // Before HASS is ever provided, leave state untouched — undefined hass is + // not a disconnection, just "not yet initialized". + if (state.hass === undefined) { + return; + } + if (!state.hass.connected) { + this._state = 'lost'; + } else if (state.hass.config?.state !== STATE_RUNNING) { + this._state = 'starting'; + } else { + this._state = 'ready'; + } + } + + public hasIssue(): boolean { + return this._state !== 'ready'; + } + + public isFullCardIssue(): boolean { + return true; + } + + public getIssue(): IssueDescription | null { + return this._state === 'lost' + ? { + icon: 'mdi:lan-disconnect', + severity: 'high', + notification: { + heading: { + text: localize('issues.connection.lost.heading'), + icon: 'mdi:lan-disconnect', + severity: 'high', + }, + body: { text: localize('issues.connection.lost.text') }, + in_progress: true, + }, + } + : this._state === 'starting' + ? { + icon: 'mdi:home-assistant', + severity: 'medium', + notification: { + heading: { + text: localize('issues.connection.starting.heading'), + icon: 'mdi:home-assistant', + severity: 'medium', + }, + body: { text: localize('issues.connection.starting.text') }, + in_progress: true, + }, + } + : null; + } + + public reset(): void { + this._state = 'ready'; + } +} diff --git a/src/card-controller/issues/issues/initialization.ts b/src/card-controller/issues/issues/initialization.ts new file mode 100644 index 00000000..e7b9e9d6 --- /dev/null +++ b/src/card-controller/issues/issues/initialization.ts @@ -0,0 +1,68 @@ +import { createNotificationFromError } from '../../../components-lib/notification/factory.js'; +import { localize } from '../../../localize/localize.js'; +import { CardIssueManagerAPI } from '../../types'; +import { createRetryControl } from '../retry-control.js'; +import { IssueDescription } from '../types'; +import { AbstractErrorIssue } from './abstract-error-issue.js'; + +declare module 'issue' { + interface IssueTriggerContext { + initialization: { error: unknown }; + } +} + +export class InitializationIssue extends AbstractErrorIssue { + public readonly key = 'initialization' as const; + + private _api: CardIssueManagerAPI; + + constructor(api: CardIssueManagerAPI) { + super(); + this._api = api; + } + + public detectDynamic(): void { + if ( + this._error !== null && + this._api.getInitializationManager().isInitializedMandatory() + ) { + this._error = null; + } + } + + public needsRetry(): boolean { + return this._error !== null; + } + + public retry(): boolean { + // Clear the error so the full-card issue is removed and shouldUpdate() + // no longer short-circuits before initializeMandatory(). + this._error = null; + + // Reset init state so initializeMandatory() re-attempts on the next + // render cycle. destroy() releases the existing CameraManager's held + // resources (WebSocket subscriptions, listeners) before the CAMERAS + // init aspect replaces the instance via createCameraManager(). + this._api.getInitializationManager().uninitializeMandatory(); + this._api.getCameraManager().destroy(); + return false; + } + + public isFullCardIssue(): boolean { + return true; + } + + protected _buildDescription(error: NonNullable): IssueDescription { + const notification = createNotificationFromError(error, { + heading: { text: localize('issues.initialization.heading') }, + }); + return { + icon: 'mdi:alert', + severity: 'high', + notification: { + ...notification, + controls: [createRetryControl(this.key)], + }, + }; + } +} diff --git a/src/card-controller/problems/problems/legacy-resource.ts b/src/card-controller/issues/issues/legacy-resource.ts similarity index 76% rename from src/card-controller/problems/problems/legacy-resource.ts rename to src/card-controller/issues/issues/legacy-resource.ts index a66112a1..bb96c367 100644 --- a/src/card-controller/problems/problems/legacy-resource.ts +++ b/src/card-controller/issues/issues/legacy-resource.ts @@ -4,7 +4,7 @@ import { HomeAssistant } from '../../../ha/types'; import { localize } from '../../../localize/localize'; import { createInternalCallbackAction } from '../../../utils/action'; import { CardActionsAPI } from '../../types'; -import { Problem, ProblemResult } from '../types'; +import { Issue, IssueDescription } from '../types'; const LEGACY_RESOURCE_FILENAME = 'frigate-hass-card.js'; @@ -28,16 +28,16 @@ const resourcesSchema = z.array( }), ); -export class LegacyResourceProblem implements Problem { +export class LegacyResourceIssue implements Issue { public readonly key = 'legacy_resource' as const; private _legacyResourceIDs: string[] = []; private _hasCorrectResource = false; private _checked = false; - private _triggerUpdate: () => void; + private _changeCallback: (() => void) | null; - constructor(triggerUpdate: () => void) { - this._triggerUpdate = triggerUpdate; + constructor(changeCallback?: () => void) { + this._changeCallback = changeCallback ?? null; } public async detectStatic(hass: HomeAssistant): Promise { @@ -76,38 +76,38 @@ export class LegacyResourceProblem implements Problem { } } - public hasResult(): boolean { + public hasIssue(): boolean { return this._checked && this._legacyResourceIDs.length > 0; } - public getResult(): ProblemResult | null { - if (!this.hasResult()) { + public getIssue(): IssueDescription | null { + if (!this.hasIssue()) { return null; } const text = this._hasCorrectResource - ? localize('problems.legacy_resource.text_both') - : localize('problems.legacy_resource.text_only_legacy'); + ? localize('issues.legacy_resource.text_both') + : localize('issues.legacy_resource.text_only_legacy'); return { icon: 'mdi:alert', severity: 'high', notification: { heading: { - text: localize('problems.legacy_resource.heading'), + text: localize('issues.legacy_resource.heading'), icon: 'mdi:alert', severity: 'high', }, - text, + body: { text }, link: { url: TROUBLESHOOTING_LEGACY_RESOURCE_URL, - title: localize('problems.troubleshooting_guide'), + title: localize('issues.troubleshooting_guide'), }, ...(this._hasCorrectResource ? { controls: [ { - tooltip: localize('problems.legacy_resource.remove'), + tooltip: localize('issues.legacy_resource.remove'), icon: 'mdi:delete', severity: 'high', actions: { @@ -152,9 +152,14 @@ export class LegacyResourceProblem implements Problem { this._checked = false; await this.detectStatic(hass); - const fixed = !this.hasResult(); + // Success requires: detection completed cleanly AND found zero legacy + // resources. detectStatic swallows WS / schema failures and leaves + // _checked false; treating that as "issue gone" would let a failed + // verification fetch masquerade as a successful fix. Demand the + // positive signal instead. + const fixed = this._checked && this._legacyResourceIDs.length === 0; if (fixed) { - this._triggerUpdate(); + this._changeCallback?.(); } return fixed; } catch { diff --git a/src/card-controller/issues/issues/media-load.ts b/src/card-controller/issues/issues/media-load.ts new file mode 100644 index 00000000..ff2c6b00 --- /dev/null +++ b/src/card-controller/issues/issues/media-load.ts @@ -0,0 +1,238 @@ +import type { IssueTriggerContext } from 'issue'; +import { ConditionState } from '../../../conditions/types.js'; +import { Notification } from '../../../config/schema/actions/types.js'; +import { TROUBLESHOOTING_MEDIA_URL } from '../../../const.js'; +import { localize } from '../../../localize/localize.js'; +import { Timer } from '../../../utils/timer.js'; +import { IMAGE_VIEW_TARGET_ID_SENTINEL } from '../../../view/target-id.js'; +import { isAnyMediaViewName } from '../../../view/view.js'; +import { CardIssueManagerAPI } from '../../types.js'; +import { createRetryControl } from '../retry-control.js'; +import { Issue, IssueDescription } from '../types.js'; + +declare module 'issue' { + interface IssueTriggerContext { + media_load: { targetID: string }; + } +} + +const MEDIA_LOADING_TIMEOUT_SECONDS = 10; + +export class MediaLoadIssue implements Issue { + public readonly key = 'media_load' as const; + + private _issueActive = false; + private _erroredTargetIDs = new Set(); + + // Timer fires when a target has been loading too long without success. + private _timer = new Timer(); + private _timerTargetID: string | null = null; + + private _api: CardIssueManagerAPI; + private _onChange: (() => void) | null; + + constructor(api: CardIssueManagerAPI, onChange?: () => void) { + this._api = api; + this._onChange = onChange ?? null; + } + + // ========================================================================= + // Explicit trigger — called when a component fires an issue:trigger event. + // ========================================================================= + + public trigger(context: IssueTriggerContext['media_load']): void { + this._erroredTargetIDs.add(context.targetID); + } + + // ========================================================================= + // Detection — called by the manager on every state change. + // ========================================================================= + + public detectDynamic(state: ConditionState): void { + if (!isAnyMediaViewName(state.view)) { + this._deactivate(); + return; + } + + if (state.mediaLoadedInfo) { + this._handleMediaLoaded(state); + } else { + this._handleMediaNotLoaded(state); + } + } + + // ========================================================================= + // State queries — called by the manager to read current state. + // ========================================================================= + + public hasIssue(): boolean { + return this._issueActive; + } + + public getIssue(): IssueDescription | null { + if (!this._issueActive) { + return null; + } + return { + icon: 'mdi:cctv-off', + severity: 'high', + notification: this.getNotification(), + }; + } + + public getNotification(): Notification { + const targets = new Set(this._erroredTargetIDs); + if (this._timerTargetID) { + targets.add(this._timerTargetID); + } + + return { + heading: { + text: localize('issues.media_load.heading'), + icon: 'mdi:cctv-off', + severity: 'high' as const, + }, + body: { + text: localize('issues.media_load.text'), + }, + ...(targets.size && { + metadata: Array.from(targets).map((id) => ({ + text: + id === IMAGE_VIEW_TARGET_ID_SENTINEL + ? localize('editor.image') + : this._api.getCameraManager().getCameraMetadata(id)?.title ?? id, + icon: id === IMAGE_VIEW_TARGET_ID_SENTINEL ? 'mdi:image' : 'mdi:cctv', + })), + }), + link: { + url: TROUBLESHOOTING_MEDIA_URL, + title: localize('issues.troubleshooting_guide'), + }, + controls: [createRetryControl(this.key)], + }; + } + + // ========================================================================= + // Retry — called by the manager to schedule a media reload. + // ========================================================================= + + public needsRetry(): boolean { + return this._issueActive; + } + + public retry(): boolean { + // Build the set of targets to retry: all errored targets plus the + // target the pending timer was tracking (so a user-initiated retry + // works even before the timeout fires). + const retryTargets = new Set(this._erroredTargetIDs); + if (this._timerTargetID) { + retryTargets.add(this._timerTargetID); + } + + if (!retryTargets.size) { + return false; + } + + const view = this._api.getViewManager().getView(); + const mediaEpoch = { ...(view?.context?.mediaEpoch ?? {}) }; + for (const id of retryTargets) { + mediaEpoch[id] = (mediaEpoch[id] ?? 0) + 1; + } + + // Intentionally keep _issueActive, _erroredTargetIDs, and the pending + // timer in place. The issue stays visible while the provider + // re-attempts loading underneath. If the retry succeeds, + // _handleMediaLoaded will clear everything when media:loaded fires. If + // it fails silently (e.g. bogus stream name), the error stays visible + // immediately — no new 10s grace period. + this._api.getViewManager().setViewWithMergedContext({ mediaEpoch }); + return false; + } + + // ========================================================================= + // Lifecycle. + // ========================================================================= + + public reset(): void { + this._deactivate(); + this._erroredTargetIDs.clear(); + } + + // Stop the pending-load timer so offscreen time doesn't count toward the + // 10s threshold. Preserve _issueActive, _erroredTargetIDs, and + // _timerTargetID: already-visible errors remain visible on reattach, and + // retaining _timerTargetID lets the existing active/target-mismatch guard + // in _handleMediaNotLoaded avoid spuriously deactivating the preserved + // issue when the same target is still loading on resume. The timer is + // re-armed with a fresh window by the next detectDynamic pass. + public suspend(): void { + this._timer.stop(); + } + + // ========================================================================= + // Private helpers. + // ========================================================================= + + // Media loaded successfully: deactivate and clear the error for this target + // so it won't immediately re-trigger on the next evaluation. + private _handleMediaLoaded(state: ConditionState): void { + this._deactivate(); + if (state.targetID) { + this._erroredTargetIDs.delete(state.targetID); + } + } + + // Media not yet loaded: activate immediately if there is a known provider + // error for this target, otherwise start a timeout to detect slow loads. + private _handleMediaNotLoaded(state: ConditionState): void { + // No targetID means no provider is actively rendering media (e.g. the + // viewer shows "No media to display" instead of showing a player). Don't + // start the timeout as there's nothing to wait for. + if (!state.targetID) { + this._deactivate(); + return; + } + + if (this._hasError(state)) { + this._activate(); + return; + } + + const targetID = state.targetID; + + // When the target changes to one without a known error, clear the active + // state so the new target gets its own timeout window instead of + // inheriting the previous target's error. + if (this._issueActive && this._timerTargetID !== targetID) { + this._deactivate(); + } + + // Start (or restart) the timer for this target. + if (!this._timer.isRunning() || this._timerTargetID !== targetID) { + this._timerTargetID = targetID; + this._timer.start(MEDIA_LOADING_TIMEOUT_SECONDS, () => { + // Record the error on timeout so retry() knows which epoch to bump. + // targetID is guaranteed non-null here — the null case bails at the + // top of _handleMediaNotLoaded. + this._erroredTargetIDs.add(targetID); + this._activate(); + this._onChange?.(); + }); + } + } + + private _hasError(state: ConditionState): boolean { + return !!state.targetID && this._erroredTargetIDs.has(state.targetID); + } + + private _activate(): void { + this._timer.stop(); + this._issueActive = true; + } + + private _deactivate(): void { + this._timer.stop(); + this._timerTargetID = null; + this._issueActive = false; + } +} diff --git a/src/card-controller/issues/issues/media-query.ts b/src/card-controller/issues/issues/media-query.ts new file mode 100644 index 00000000..a7a30228 --- /dev/null +++ b/src/card-controller/issues/issues/media-query.ts @@ -0,0 +1,57 @@ +import { createNotificationFromError } from '../../../components-lib/notification/factory.js'; +import { Notification } from '../../../config/schema/actions/types.js'; +import { localize } from '../../../localize/localize.js'; +import { CardIssueManagerAPI } from '../../types.js'; +import { createRetryControl } from '../retry-control.js'; +import { IssueDescription } from '../types.js'; +import { AbstractErrorIssue } from './abstract-error-issue.js'; + +declare module 'issue' { + interface IssueTriggerContext { + media_query: { error: unknown }; + } +} + +export class MediaQueryIssue extends AbstractErrorIssue { + public readonly key = 'media_query' as const; + + private _api: CardIssueManagerAPI; + + constructor(api: CardIssueManagerAPI) { + super(); + this._api = api; + } + + public needsRetry(): boolean { + return this._error !== null; + } + + public retry(): boolean { + if (this._error === null) { + return false; + } + this._error = null; + this._api.getViewManager().setViewByParametersWithNewQuery(); + + // Exclusive retry. No other issue should attempt to retry until the next + // evaluation cycle, when we'll know if this was successful. + return true; + } + + public getNotification(): Notification | null { + return this.getIssue()?.notification ?? null; + } + + protected _buildDescription(error: NonNullable): IssueDescription { + return { + icon: 'mdi:alert', + severity: 'high', + notification: { + ...createNotificationFromError(error, { + heading: { text: localize('issues.media_query.heading') }, + }), + controls: [createRetryControl(this.key)], + }, + }; + } +} diff --git a/src/card-controller/issues/issues/view-incompatible.ts b/src/card-controller/issues/issues/view-incompatible.ts new file mode 100644 index 00000000..042c0307 --- /dev/null +++ b/src/card-controller/issues/issues/view-incompatible.ts @@ -0,0 +1,48 @@ +import { createNotificationFromText } from '../../../components-lib/notification/factory.js'; +import { localize } from '../../../localize/localize.js'; +import { getContextFromError } from '../../../utils/error-context.js'; +import { CardIssueManagerAPI } from '../../types.js'; +import { IssueDescription } from '../types.js'; +import { AbstractErrorIssue } from './abstract-error-issue.js'; + +declare module 'issue' { + interface IssueTriggerContext { + view_incompatible: { error: unknown }; + } +} + +export class ViewIncompatibleIssue extends AbstractErrorIssue { + public readonly key = 'view_incompatible' as const; + + private _api: CardIssueManagerAPI; + + constructor(api: CardIssueManagerAPI) { + super(); + this._api = api; + } + + // Full-card when no view is available to anchor a popup to (initial load + // with an unrealizable default view); popup when an existing view remains + // visible underneath (mid-session user action that can't resolve). + public isFullCardIssue(): boolean { + return !this._api.getViewManager().getView(); + } + + protected _buildDescription(error: NonNullable): IssueDescription { + return { + icon: 'mdi:video-off', + severity: 'high', + notification: createNotificationFromText( + localize('issues.view_incompatible.text'), + { + heading: { + text: localize('issues.view_incompatible.heading'), + icon: 'mdi:video-off', + severity: 'high', + }, + context: getContextFromError(error) ?? undefined, + }, + ), + }; + } +} diff --git a/src/card-controller/issues/retry-control.ts b/src/card-controller/issues/retry-control.ts new file mode 100644 index 00000000..e6d048ad --- /dev/null +++ b/src/card-controller/issues/retry-control.ts @@ -0,0 +1,17 @@ +import { NotificationControl } from '../../config/schema/actions/types.js'; +import { localize } from '../../localize/localize.js'; +import { createInternalCallbackAction } from '../../utils/action.js'; +import { IssueKey } from './types.js'; + +export function createRetryControl(key: IssueKey): NotificationControl { + return { + icon: 'mdi:refresh', + tooltip: localize('common.retry'), + dismiss: true, + actions: { + tap_action: createInternalCallbackAction(async (api) => { + api.getIssueManager().retry(key, true); + }), + }, + }; +} diff --git a/src/card-controller/issues/state-manager.ts b/src/card-controller/issues/state-manager.ts new file mode 100644 index 00000000..9ea735a4 --- /dev/null +++ b/src/card-controller/issues/state-manager.ts @@ -0,0 +1,172 @@ +import type { IssueTriggerContext } from 'issue'; +import { summarizeNotification } from '../../components-lib/notification/summarize'; +import { ConditionState } from '../../conditions/types'; +import { Notification } from '../../config/schema/actions/types'; +import { HomeAssistant } from '../../ha/types'; +import { isTruthy } from '../../utils/basic'; +import { + Issue, + IssueDescription, + IssueKey, + IssuePresence, + IssueReadOnlyState, + IssueTriggerContextKey, + KeyedIssueDescription, +} from './types'; + +export class IssueStateManager implements IssueReadOnlyState { + private _issues = new Map(); + private _loggedKeys = new Set(); + + // ========================================================================= + // Setup. + // ========================================================================= + + public addIssue(issue: Issue): void { + this._issues.set(issue.key, issue); + } + + // ========================================================================= + // Detection — static (one-shot on init) and dynamic (on every state change). + // ========================================================================= + + public async detectStatic(hass: HomeAssistant): Promise { + for (const issue of this._issues.values()) { + await issue.detectStatic?.(hass); + this._logIfNew(issue); + } + } + + public trigger( + key: K, + context: IssueTriggerContext[K], + ): void { + const issue = this._issues.get(key); + if (!issue) { + return; + } + issue.trigger?.(context); + this._logIfNew(issue); + } + + public detectDynamic(context: ConditionState): void { + for (const issue of this._issues.values()) { + issue.detectDynamic?.(context); + this._logIfNew(issue); + } + } + + // ========================================================================= + // Queries — read active issue state. + // ========================================================================= + + public getFullCardIssue(): IssueDescription | null { + for (const issue of this._issues.values()) { + if (issue.hasIssue() && issue.isFullCardIssue?.()) { + return issue.getIssue(); + } + } + return null; + } + + public hasFullCardIssue(): boolean { + return !!this.getFullCardIssue(); + } + + public getIssueDescriptions(): KeyedIssueDescription[] { + const descriptions: KeyedIssueDescription[] = []; + for (const issue of this._issues.values()) { + const description = issue.getIssue(); + if (description) { + descriptions.push({ key: issue.key, issue: description }); + } + } + return descriptions; + } + + public getIssuePresence(): IssuePresence { + const presence: IssuePresence = new Map(); + for (const issue of this._issues.values()) { + const description = issue.getIssue(); + if (description) { + presence.set(issue.key, description); + } + } + return presence; + } + + public getNotification(key: IssueKey): Notification | null { + return this._issues.get(key)?.getNotification?.() ?? null; + } + + // ========================================================================= + // Retry. + // ========================================================================= + + public needsRetry(): boolean { + return [...this._issues.values()].some((issue) => issue.needsRetry?.()); + } + + public retry(key?: IssueKey, force?: boolean): void { + const issues = key + ? [this._issues.get(key)].filter(isTruthy) + : [...this._issues.values()]; + + for (const issue of issues) { + if (!force && !issue.needsRetry?.()) { + continue; + } + if (issue.retry?.()) { + return; + } + } + } + + // ========================================================================= + // Lifecycle. + // ========================================================================= + + public reset(key?: IssueKey): void { + const issues = key + ? [this._issues.get(key)].filter(isTruthy) + : [...this._issues.values()]; + + for (const issue of issues) { + issue.reset?.(); + } + } + + public suspend(): void { + for (const issue of this._issues.values()) { + issue.suspend?.(); + } + } + + public destroy(): void { + this.reset(); + this._issues.clear(); + this._loggedKeys.clear(); + } + + // ========================================================================= + // Private helpers. + // ========================================================================= + + private _logIfNew(issue: Issue): void { + const description = issue.getIssue(); + if (!description) { + // Issue cleared (explicit reset, or self-clear via detectDynamic). + // Release the dedupe so the next activation is logged as a new episode. + this._loggedKeys.delete(issue.key); + return; + } + if (this._loggedKeys.has(issue.key)) { + return; + } + this._loggedKeys.add(issue.key); + const summary = summarizeNotification(description.notification); + if (summary) { + console.warn(`Advanced Camera Card [issue=${issue.key}]: ${summary}`); + } + } +} diff --git a/src/card-controller/issues/types.ts b/src/card-controller/issues/types.ts new file mode 100644 index 00000000..f021b0f4 --- /dev/null +++ b/src/card-controller/issues/types.ts @@ -0,0 +1,96 @@ +import type { IssueTriggerContext } from 'issue'; +import { ConditionState } from '../../conditions/types'; +import { Notification } from '../../config/schema/actions/types'; +import { HomeAssistant } from '../../ha/types'; +import { Severity } from '../../severity'; + +export type IssueKey = + | 'config_error' + | 'config_upgrade' + | 'connection' + | 'initialization' + | 'legacy_resource' + | 'media_load' + | 'media_query' + | 'view_incompatible'; + +export interface IssueDescription { + icon: string; + severity: Severity; + notification: Notification; +} + +export interface KeyedIssueDescription { + key: IssueKey; + issue: IssueDescription; +} + +// Map of currently active issues keyed by IssueKey, with each entry's value +// being the issue's current rendered description. Stored as a Map (not just +// a Set of keys) so that sub-state changes within an issue — e.g. +// ConnectionIssue swapping between 'lost' and 'starting' — are reflected as +// real value-level diffs to the condition state, triggering re-renders and +// any user-defined conditions that depend on issue state. +export type IssuePresence = Map; +export interface IssueReadOnlyState { + hasFullCardIssue(): boolean; + getFullCardIssue(): IssueDescription | null; + getIssueDescriptions(): KeyedIssueDescription[]; + getIssuePresence(): IssuePresence; + getNotification(key: IssueKey): Notification | null; +} + +export type IssueTriggerContextKey = keyof IssueTriggerContext; +export type IssueTriggerEventData = { + [K in IssueTriggerContextKey]: { key: K } & IssueTriggerContext[K]; +}[IssueTriggerContextKey]; + +export interface Issue { + readonly key: IssueKey; + + // One-time async detection (WS calls, config checks). + detectStatic?(hass?: HomeAssistant): Promise; + + // Ongoing sync evaluation, called on state changes. + detectDynamic?(context: ConditionState): void; + + // Explicitly trigger this issue with key-specific context. + trigger?(context: IssueTriggerContext[IssueTriggerContextKey]): void; + + hasIssue(): boolean; + getIssue(): IssueDescription | null; + + // Whether this issue renders as a full-card display when active. Defaults + // to false when absent (popup notification). Issues that take over the + // entire card must explicitly return true. + isFullCardIssue?(): boolean; + + // Return notification content regardless of active state, for user-initiated + // queries (e.g. clicking a loading icon). May return null when content + // depends on transient state (e.g. no current error to show). + getNotification?(): Notification | null; + + // Whether this issue wants the manager to schedule a retry. Gates + // scheduled retries; user-initiated (forced) retries bypass this check. + needsRetry?(): boolean; + + // Called by the manager when a retry is due. Returns true to stop the retry + // loop (exclusive), false to allow subsequent issues to also retry. + retry?(): boolean; + + // Optional user-initiated fix. Not called by the issue infrastructure — + // callers (e.g. notification control actions) invoke this directly. + fix?(hass: HomeAssistant): Promise; + + // Reset internal state (clear errors, stop timers, etc.). + reset?(): void; + + // Called when the card is detached. Issues with age-based timers (e.g. + // loading-timeout timers) must stop them here so that time spent offscreen + // doesn't count against the user. Must preserve already-active issue state + // — a full-card issue visible at detach should still be visible on + // reattach. No `resume` hook: IssueManager.resume() triggers a normal + // evaluate(), so any timer that should restart is re-armed via + // detectDynamic against the current condition state. + suspend?(): void; +} diff --git a/src/card-controller/keyboard-state-manager.ts b/src/card-controller/keyboard-state-manager.ts index fd6bb2a0..6f0adddb 100644 --- a/src/card-controller/keyboard-state-manager.ts +++ b/src/card-controller/keyboard-state-manager.ts @@ -21,6 +21,14 @@ export class KeyboardStateManager { element.removeEventListener('keydown', this._handleKeydown); element.removeEventListener('keyup', this._handleKeyup); element.removeEventListener('blur', this._handleBlur); + + // Clear state on disconnect. Without listeners the card cannot know + // whether a key was released while detached, and stale "down" state + // would suppress the next real keydown (e.g. PTZ stop shortcuts). + if (Object.keys(this._state).length) { + this._state = {}; + this._processStateChange(); + } } private _handleKeydown = (ev: KeyboardEvent): void => { @@ -40,7 +48,7 @@ export class KeyboardStateManager { private _handleKeyup = (ev: KeyboardEvent): void => { if (ev.key in this._state && this._state[ev.key].state === 'down') { - this._state[ev.key].state = 'up'; + this._state[ev.key] = { ...this._state[ev.key], state: 'up' as const }; this._processStateChange(); } }; @@ -53,7 +61,10 @@ export class KeyboardStateManager { } }; + // Clone before passing to ConditionStateManager so that subsequent + // in-place mutations to this._state don't affect the stored reference, + // which would make isEqual comparisons always see the same object. private _processStateChange(): void { - this._api.getConditionStateManager().setState({ keys: this._state }); + this._api.getConditionStateManager().setState({ keys: { ...this._state } }); } } diff --git a/src/card-controller/media-player-manager.ts b/src/card-controller/media-player-manager.ts index 81f2426f..b535546f 100644 --- a/src/card-controller/media-player-manager.ts +++ b/src/card-controller/media-player-manager.ts @@ -7,7 +7,6 @@ import { } from '../const'; import { Entity } from '../ha/registry/entity/types'; import { supportsFeature } from '../ha/supports-feature'; -import { localize } from '../localize/localize'; import { errorToConsole } from '../utils/basic'; import { ViewMedia } from '../view/item'; import { ViewItemClassifier } from '../view/item-classifier'; @@ -165,12 +164,10 @@ export class MediaPlayerManager { } const dashboardConfig = cameraConfig.cast?.dashboard; + + // Guaranteed by schema refinement when cast.method is 'dashboard', but + // needed for TypeScript narrowing since refine() doesn't narrow types. if (!dashboardConfig?.dashboard_path || !dashboardConfig?.view_path) { - this._api.getMessageManager().setMessageIfHigherPriority({ - type: 'error', - icon: 'mdi:cast', - message: localize('error.no_dashboard_or_view'), - }); return; } diff --git a/src/card-controller/message-manager.ts b/src/card-controller/message-manager.ts deleted file mode 100644 index a5666190..00000000 --- a/src/card-controller/message-manager.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { AdvancedCameraCardError, Message, MessageType } from '../types'; -import { errorToConsole } from '../utils/basic'; -import { CardMessageAPI } from './types'; - -type MessagePriority = { - [type in MessageType]: number; -}; - -const MESSAGE_TYPE_PRIORITIES: MessagePriority = { - info: 10, - error: 20, - connection: 30, - diagnostics: 40, -}; - -export class MessageManager { - private _message: Message | null = null; - private _api: CardMessageAPI; - - constructor(api: CardMessageAPI) { - this._api = api; - } - - public getMessage(): Message | null { - return this._message; - } - - public hasMessage(): boolean { - return !!this._message; - } - - public hasErrorMessage(): boolean { - return this._message?.type === 'error'; - } - - public reset(): void { - const hadMessage = this.hasMessage(); - this._message = null; - - if (hadMessage) { - this._api.getCardElementManager().update(); - } - } - - public resetType(type: MessageType): void { - if (this._message?.type === type) { - this.reset(); - } - } - - public setErrorIfHigherPriority(error: unknown, prefix?: string): void { - // This object should accept unknown objects to be able to seamlessly - // process arguments to catch() which can only be unknown/any. HA may throw - // non Error() based errors. - if (!error || typeof error !== 'object' || !('message' in error)) { - return; - } - - errorToConsole(error); - this.setMessageIfHigherPriority({ - message: prefix ? `${prefix}: ${error.message}` : String(error.message), - type: 'error', - ...(error instanceof AdvancedCameraCardError && { context: error.context }), - }); - } - - public setMessageIfHigherPriority(message: Message): boolean { - const resolveMessageType = (message: Message): MessageType => { - return message.type ?? 'info'; - }; - const currentPriority = this._message - ? MESSAGE_TYPE_PRIORITIES[resolveMessageType(this._message)] - : 0; - const newPriority = MESSAGE_TYPE_PRIORITIES[resolveMessageType(message)]; - - if (this._message && newPriority < currentPriority) { - return false; - } - - this._message = message; - - // When a message is displayed it effectively unloads the media. - this._api.getMediaLoadedInfoManager().clear(); - this._api.getCardElementManager().scrollReset(); - this._api.getCardElementManager().update(); - return true; - } -} diff --git a/src/card-controller/microphone-manager.ts b/src/card-controller/microphone-manager.ts index 718896c3..5b8845dd 100644 --- a/src/card-controller/microphone-manager.ts +++ b/src/card-controller/microphone-manager.ts @@ -1,7 +1,14 @@ -import { errorToConsole } from '../utils/basic'; +import { localize } from '../localize/localize'; +import { AdvancedCameraCardError } from '../types'; import { Timer } from '../utils/timer'; import { CardMicrophoneAPI, MicrophoneState } from './types'; +export class MicrophoneNotSupportedError extends AdvancedCameraCardError { + constructor() { + super(localize('error.microphone_not_supported')); + } +} + export class MicrophoneManager { private _api: CardMicrophoneAPI; private _stream?: MediaStream | null; @@ -46,9 +53,9 @@ export class MicrophoneManager { return !!navigator.mediaDevices?.getUserMedia; } - public async connect(): Promise { + public async connect(): Promise { if (!this.isSupported()) { - return false; + throw new MicrophoneNotSupportedError(); } try { @@ -57,15 +64,12 @@ export class MicrophoneManager { video: false, }); } catch (e: unknown) { - errorToConsole(e as Error); - this._stream = null; this._setState(); - return false; + throw e; } this._setDesiredMuteOnStream(); this._setState(); - return true; } public disconnect(): void { diff --git a/src/card-controller/problems/manager.ts b/src/card-controller/problems/manager.ts deleted file mode 100644 index e5f71e70..00000000 --- a/src/card-controller/problems/manager.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { ConditionStateChange } from '../../conditions/types'; -import { HomeAssistant } from '../../ha/types'; -import { CardProblemAPI } from '../types'; -import { ConfigUpgradeProblem } from './problems/config-upgrade'; -import { LegacyResourceProblem } from './problems/legacy-resource'; -import { StreamNotLoadingProblem } from './problems/stream-not-loading'; -import { - KeyedProblemResult, - Problem, - ProblemDynamicContext, - ProblemKey, - ProblemPresence, - ProblemTriggerContext, -} from './types'; - -export class ProblemManager { - private _api: CardProblemAPI; - private _problems = new Map(); - private _loggedKeys = new Set(); - - constructor(api: CardProblemAPI) { - this._api = api; - - this._addProblem( - new ConfigUpgradeProblem(() => this._api.getConfigManager().getRawConfig()), - ); - this._addProblem( - new LegacyResourceProblem(() => this._api.getCardElementManager().update()), - ); - this._addProblem( - new StreamNotLoadingProblem(() => this._api.getCardElementManager().update()), - ); - } - - public initialize(): void { - this._api.getConditionStateManager().addListener(this._stateChangeHandler); - } - - public uninitialize(): void { - this._api.getConditionStateManager().removeListener(this._stateChangeHandler); - } - - private _addProblem(problem: Problem): void { - this._problems.set(problem.key, problem); - } - - public async detectStatic(hass: HomeAssistant): Promise { - for (const problem of this._problems.values()) { - await problem.detectStatic?.(hass); - this._logIfNew(problem); - } - this._api.getCardElementManager().update(); - } - - // Silently trigger a problem by key, updating state without user - // interaction. Use this for system-originated events (e.g. provider errors). - public trigger(key: ProblemKey, context?: ProblemTriggerContext): void { - const problem = this._problems.get(key); - if (!problem) { - return; - } - problem.trigger?.(context); - - // Re-evaluate dynamic state so the trigger could take effect immediately. - // trigger() only records context (e.g. marking a camera as errored); - // detectDynamic() decides whether to activate based on current state (e.g. - // whether it is the selected camera with the error). - const state = this._api.getConditionStateManager().getState(); - this._detectAllDynamic({ - cameraID: state.camera, - view: state.view, - mediaLoaded: !!state.mediaLoadedInfo, - }); - } - - // Show the notification popup for a problem, regardless of whether or not - // that problem has triggered (example usecase: the stream is loading and the - // user clicks the blue loading icon). - public forceNotify(key: ProblemKey): void { - const notification = this._problems.get(key)?.getNotification?.(); - if (notification) { - this._api.getNotificationManager().setNotification(notification); - } - } - - public getProblemResults(): KeyedProblemResult[] { - const results: KeyedProblemResult[] = []; - for (const problem of this._problems.values()) { - const result = problem.getResult(); - if (result) { - results.push({ key: problem.key, problem: result }); - } - } - return results; - } - - public getProblemPresence(): ProblemPresence { - const presence: ProblemPresence = {}; - for (const problem of this._problems.values()) { - presence[problem.key] = problem.hasResult(); - } - return presence; - } - - public destroy(): void { - this.uninitialize(); - for (const problem of this._problems.values()) { - problem.destroy?.(); - } - this._problems.clear(); - } - - private _stateChangeHandler = (change: ConditionStateChange): void => { - this._detectAllDynamic({ - cameraID: change.new.camera, - view: change.new.view, - mediaLoaded: !!change.new.mediaLoadedInfo, - }); - }; - - private _detectAllDynamic(context: ProblemDynamicContext): void { - let stateChanged = false; - for (const problem of this._problems.values()) { - const hadResult = problem.hasResult(); - problem.detectDynamic?.(context); - stateChanged ||= problem.hasResult() !== hadResult; - this._logIfNew(problem); - } - if (stateChanged) { - this._api.getCardElementManager().update(); - } - } - - private _logIfNew(problem: Problem): void { - if (problem.hasResult() && !this._loggedKeys.has(problem.key)) { - this._loggedKeys.add(problem.key); - const text = problem.getResult()?.notification.text; - if (text) { - console.warn(`Advanced Camera Card: ${text}`); - } - } - } -} diff --git a/src/card-controller/problems/problems/stream-not-loading.ts b/src/card-controller/problems/problems/stream-not-loading.ts deleted file mode 100644 index caa84b5d..00000000 --- a/src/card-controller/problems/problems/stream-not-loading.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { Notification } from '../../../config/schema/actions/types.js'; -import { TROUBLESHOOTING_STREAM_URL } from '../../../const.js'; -import { localize } from '../../../localize/localize.js'; -import { Timer } from '../../../utils/timer.js'; -import { - Problem, - ProblemDynamicContext, - ProblemResult, - ProblemTriggerContext, -} from '../types.js'; - -const STREAM_LOADING_TIMEOUT_SECONDS = 10; - -export class StreamNotLoadingProblem implements Problem { - public readonly key = 'stream_not_loading' as const; - - private _problemActive = false; - private _cameraIDsWithErrors = new Set(); - private _timer = new Timer(); - private _timerCameraID: string | null = null; - private _triggerUpdate: () => void; - - constructor(triggerUpdate: () => void) { - this._triggerUpdate = triggerUpdate; - } - - public trigger(context?: ProblemTriggerContext): void { - if (context?.cameraID) { - this._cameraIDsWithErrors.add(context.cameraID); - } - } - - public detectDynamic(context: ProblemDynamicContext): void { - if (context.view !== 'live') { - this._deactivate(); - return; - } - - if (context.mediaLoaded) { - this._handleStreamLoaded(context.cameraID); - } else { - this._handleStreamNotLoaded(context.cameraID); - } - } - - // Stream loaded successfully. Deactivate and clear any prior provider error - // for this camera so it won't re-trigger on the next evaluation. - private _handleStreamLoaded(cameraID?: string): void { - this._deactivate(); - if (cameraID) { - this._cameraIDsWithErrors.delete(cameraID); - } - } - - // Stream not yet loaded. Activate immediately if this camera has a known - // provider error, otherwise start a timeout to detect slow loads. - private _handleStreamNotLoaded(cameraID?: string): void { - if (this._hasCameraError(cameraID)) { - this._activate(); - } else if (!this._problemActive) { - // Restart the timer when the selected camera changes so each camera - // gets its own timeout window. - if (!this._timer.isRunning() || this._timerCameraID !== (cameraID ?? null)) { - this._timerCameraID = cameraID ?? null; - this._timer.start(STREAM_LOADING_TIMEOUT_SECONDS, () => { - this._activate(); - this._triggerUpdate(); - }); - } - } - } - - public hasResult(): boolean { - return this._problemActive; - } - - public getNotification(): Notification { - return { - heading: { - text: localize('problems.stream_not_loading.heading'), - icon: 'mdi:cctv-off', - severity: 'high', - }, - text: localize('problems.stream_not_loading.text'), - link: { - url: TROUBLESHOOTING_STREAM_URL, - title: localize('problems.troubleshooting_guide'), - }, - }; - } - - public getResult(): ProblemResult | null { - if (!this._problemActive) { - return null; - } - return { - icon: 'mdi:cctv-off', - severity: 'high', - notification: this.getNotification(), - }; - } - - public destroy(): void { - this._deactivate(); - this._cameraIDsWithErrors.clear(); - } - - private _activate(): void { - this._timer.stop(); - this._problemActive = true; - } - - private _deactivate(): void { - this._timer.stop(); - this._timerCameraID = null; - this._problemActive = false; - } - - private _hasCameraError(camera?: string): boolean { - return !!camera && this._cameraIDsWithErrors.has(camera); - } -} diff --git a/src/card-controller/problems/types.ts b/src/card-controller/problems/types.ts deleted file mode 100644 index 5e8a001a..00000000 --- a/src/card-controller/problems/types.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { Notification } from '../../config/schema/actions/types'; -import { AdvancedCameraCardView } from '../../config/schema/common/const'; -import { HomeAssistant } from '../../ha/types'; -import { Severity } from '../../severity'; - -export type ProblemKey = 'config_upgrade' | 'legacy_resource' | 'stream_not_loading'; - -export interface ProblemResult { - icon: string; - severity: Severity; - notification: Notification; -} - -export interface KeyedProblemResult { - key: ProblemKey; - problem: ProblemResult; -} - -export type ProblemPresence = Partial>; - -export interface ProblemDynamicContext { - cameraID?: string; - view?: AdvancedCameraCardView; - mediaLoaded: boolean; -} - -export interface ProblemTriggerContext { - cameraID?: string; -} - -export type ProblemTriggerEventData = { key: ProblemKey } & ProblemTriggerContext; - -export interface Problem { - readonly key: ProblemKey; - - // One-time async detection (WS calls, config checks). - detectStatic?(hass?: HomeAssistant): Promise; - - // Ongoing sync evaluation, called on state changes. - detectDynamic?(context: ProblemDynamicContext): void; - - // Explicitly trigger this problem. - trigger?(context?: ProblemTriggerContext): void; - - hasResult(): boolean; - getResult(): ProblemResult | null; - - // Return notification content regardless of active state, for - // user-initiated queries (e.g. clicking a loading icon). - getNotification?(): Notification | null; - - // Optional automatic fixing. - fix?(hass: HomeAssistant): Promise; - - // Cleanup. - destroy?(): void; -} diff --git a/src/card-controller/status-bar-item-manager.ts b/src/card-controller/status-bar-item-manager.ts index 4e5ebf4e..07b2577a 100644 --- a/src/card-controller/status-bar-item-manager.ts +++ b/src/card-controller/status-bar-item-manager.ts @@ -5,15 +5,11 @@ import { StatusBarConfig } from '../config/schema/status-bar'; import { MediaLoadedInfo } from '../types'; import { createNotificationAction } from '../utils/action'; import { View } from '../view/view'; -import { KeyedProblemResult, ProblemKey } from './problems/types'; +import { KeyedIssueDescription } from './issues/types'; import { CardStatusBarAPI } from './types'; const RESOLUTION_TOLERANCE_PCT = 0.01; -const problemKeyToStatusBarKey = (key: ProblemKey): keyof StatusBarConfig['items'] => { - return `problem_${key}`; -}; - export class StatusBarItemManager { private _api: CardStatusBarAPI; @@ -48,7 +44,7 @@ export class StatusBarItemManager { cameraManager?: CameraManager | null; view?: View | null; mediaLoadedInfo?: MediaLoadedInfo | null; - problems?: KeyedProblemResult[] | null; + issues?: KeyedIssueDescription[] | null; }): StatusBarItem[] { const cameraMetadata = options?.view?.camera ? options?.cameraManager?.getCameraMetadata(options.view.camera) @@ -132,22 +128,18 @@ export class StatusBarItemManager { ] : []), - ...(options?.problems ?? []) - .filter( - ({ key }) => - options?.statusConfig?.items[problemKeyToStatusBarKey(key)]?.enabled !== - false, - ) - .map(({ key, problem }) => ({ - type: 'custom:advanced-camera-card-status-bar-icon' as const, - icon: problem.icon, - severity: problem.severity, - title: problem.notification.heading?.text, - actions: { - tap_action: createNotificationAction(problem.notification), - }, - ...options?.statusConfig?.items[problemKeyToStatusBarKey(key)], - })), + ...(options?.statusConfig?.items.issues?.enabled === false + ? [] + : (options?.issues ?? []).map(({ issue }) => ({ + type: 'custom:advanced-camera-card-status-bar-icon' as const, + icon: issue.icon, + severity: issue.severity, + title: issue.notification.heading?.text, + actions: { + tap_action: createNotificationAction(issue.notification), + }, + ...options?.statusConfig?.items.issues, + }))), ...this._dynamicItems, ]; } diff --git a/src/card-controller/triggers-manager.ts b/src/card-controller/triggers-manager.ts index 48c91b71..ebee0507 100644 --- a/src/card-controller/triggers-manager.ts +++ b/src/card-controller/triggers-manager.ts @@ -64,7 +64,7 @@ export class TriggersManager { const hass = this._api.getHASSManager().getHASS(); let triggered = false; let startupActionEvent: CameraEvent | null = null; - this._states.clear(); + this.reset(); for (const [cameraID, camera] of this._api .getCameraManager() @@ -395,6 +395,20 @@ export class TriggersManager { } } + private _stopAllTimers(): void { + for (const [cameraID] of this._states) { + this._deleteUntriggerDelayTimer(cameraID); + this._deleteForceUntriggerTimer(cameraID); + } + } + + public reset(): void { + this._throttledTriggerAction.cancel(); + this._stopAllTimers(); + this._states.clear(); + this._setConditionStateIfNecessary(); + } + private _isStateTriggered(state: CameraTriggerState): boolean { return !!(state.sources.size || state.untriggerDelayTimer); } diff --git a/src/card-controller/types.ts b/src/card-controller/types.ts index 6b857c62..3c59195e 100644 --- a/src/card-controller/types.ts +++ b/src/card-controller/types.ts @@ -17,14 +17,13 @@ import type { FullscreenManager } from './fullscreen/fullscreen-manager'; import type { HASSManager } from './hass/hass-manager'; import type { InitializationManager } from './initialization-manager'; import type { InteractionManager } from './interaction-manager'; +import type { IssueManager } from './issues/issue-manager'; import type { KeyboardStateManager } from './keyboard-state-manager'; import type { MediaLoadedInfoManager } from './media-info-manager'; import type { MediaPlayerManager } from './media-player-manager'; -import type { MessageManager } from './message-manager'; import type { MicrophoneManager } from './microphone-manager'; import type { NotificationManager } from './notification-manager'; import type { PIPManager } from './pip-manager'; -import type { ProblemManager } from './problems/manager'; import type { QueryStringManager } from './query-string-manager'; import type { StatusBarItemManager } from './status-bar-item-manager'; import type { StyleManager } from './style-manager'; @@ -53,10 +52,10 @@ export interface CardActionsAPI { getHASSManager(): HASSManager; getMediaLoadedInfoManager(): MediaLoadedInfoManager; getMediaPlayerManager(): MediaPlayerManager; - getMessageManager(): MessageManager; getMicrophoneManager(): MicrophoneManager; getNotificationManager(): NotificationManager; getPIPManager(): PIPManager; + getIssueManager(): IssueManager; getStatusBarItemManager(): StatusBarItemManager; getTriggersManager(): TriggersManager; getViewItemManager(): ViewItemManager; @@ -70,7 +69,8 @@ export interface CardAutomationsAPI { getConditionStateManager(): ConditionStateManager; getHASSManager(): HASSManager; getInitializationManager(): InitializationManager; - getMessageManager(): MessageManager; + getNotificationManager(): NotificationManager; + getIssueManager(): IssueManager; } export interface CardCameraAPI { @@ -79,7 +79,6 @@ export interface CardCameraAPI { getDeviceRegistryManager(): DeviceRegistryManager; getEntityRegistryManager(): EntityRegistryManager; getHASSManager(): HASSManager; - getMessageManager(): MessageManager; getResolvedMediaCache(): ResolvedMediaCache; getTriggersManager(): TriggersManager; } @@ -106,8 +105,8 @@ export interface CardConfigAPI { getInitializationManager(): InitializationManager; getMediaLoadedInfoManager(): MediaLoadedInfoManager; getMediaPlayerManager(): MediaPlayerManager; - getMessageManager(): MessageManager; getMicrophoneManager(): MicrophoneManager; + getIssueManager(): IssueManager; getStatusBarItemManager(): StatusBarItemManager; getStyleManager(): StyleManager; getViewManager(): ViewManager; @@ -118,7 +117,7 @@ export interface CardConfigLoaderAPI { getConfigManager(): ConfigManager; getFoldersManager(): FoldersManager; getHASSManager(): HASSManager; - getMessageManager(): MessageManager; + getIssueManager(): IssueManager; } export interface CardDefaultManagerAPI { @@ -134,7 +133,7 @@ export interface CardDownloadAPI { getCameraManager(): CameraManager; getHASSManager(): HASSManager; getMediaLoadedInfoManager(): MediaLoadedInfoManager; - getMessageManager(): MessageManager; + getNotificationManager(): NotificationManager; getViewManager(): ViewManager; } @@ -154,8 +153,9 @@ export interface CardElementAPI { getMediaPlayerManager(): MediaPlayerManager; getMicrophoneManager(): MicrophoneManager; getPIPManager(): PIPManager; - getProblemManager(): ProblemManager; + getIssueManager(): IssueManager; getQueryStringManager(): QueryStringManager; + getTriggersManager(): TriggersManager; getViewManager(): ViewManager; } @@ -193,7 +193,6 @@ export interface CardHASSAPI { getInitializationManager(): InitializationManager; getInteractionManager(): InteractionManager; getMediaPlayerManager(): MediaPlayerManager; - getMessageManager(): MessageManager; getStyleManager(): StyleManager; getTriggersManager(): TriggersManager; getViewManager(): ViewManager; @@ -209,13 +208,11 @@ export interface CardInitializerAPI { getCardElementManager(): CardElementManager; getConditionStateManager(): ConditionStateManager; getConfigManager(): ConfigManager; - getConditionStateManager(): ConditionStateManager; getDefaultManager(): DefaultManager; getEntityRegistryManager(): EntityRegistryManager; getHASSManager(): HASSManager; getMediaPlayerManager(): MediaPlayerManager; - getMessageManager(): MessageManager; - getProblemManager(): ProblemManager; + getIssueManager(): IssueManager; getQueryStringManager(): QueryStringManager; getResolvedMediaCache(): ResolvedMediaCache; getTriggersManager(): TriggersManager; @@ -250,25 +247,22 @@ export interface CardMediaPlayerAPI { getConfigManager(): ConfigManager; getEntityRegistryManager(): EntityRegistryManager; getHASSManager(): HASSManager; - getMessageManager(): MessageManager; getQueryStringManager(): QueryStringManager; } -export interface CardMessageAPI { - getCardElementManager(): CardElementManager; - getConditionStateManager(): ConditionStateManager; - getMediaLoadedInfoManager(): MediaLoadedInfoManager; -} - export interface CardNotificationAPI { getCardElementManager(): CardElementManager; } -export interface CardProblemAPI { +export interface CardIssueManagerAPI { + getCameraManager(): CameraManager; getCardElementManager(): CardElementManager; getConditionStateManager(): ConditionStateManager; getConfigManager(): ConfigManager; + getInitializationManager(): InitializationManager; + getInteractionManager(): InteractionManager; getNotificationManager(): NotificationManager; + getViewManager(): ViewManager; } export interface CardMicrophoneAPI { @@ -318,7 +312,8 @@ export interface CardViewAPI { getHASSManager(): HASSManager; getInitializationManager(): InitializationManager; getMediaLoadedInfoManager(): MediaLoadedInfoManager; - getMessageManager(): MessageManager; + getNotificationManager(): NotificationManager; + getIssueManager(): IssueManager; getQueryStringManager(): QueryStringManager; getStyleManager(): StyleManager; getTriggersManager(): TriggersManager; diff --git a/src/card-controller/view/factory.ts b/src/card-controller/view/factory.ts index b758150c..de279b4d 100644 --- a/src/card-controller/view/factory.ts +++ b/src/card-controller/view/factory.ts @@ -1,7 +1,6 @@ import { AdvancedCameraCardView } from '../../config/schema/common/const'; import { ViewDisplayMode } from '../../config/schema/common/display'; import { AdvancedCameraCardConfig } from '../../config/schema/types'; -import { localize } from '../../localize/localize'; import { resolveViewName } from '../../view/utils/resolve-default'; import { View, ViewParameters } from '../../view/view'; import { @@ -182,16 +181,16 @@ export class ViewFactory { if (options?.failSafe && !doesViewRequireCamera(defaultViewName)) { return { viewName: defaultViewName, cameraID: null }; } + const cameraID = this._api.getCameraManager().getStore().getDefaultCameraID(); if (options?.failSafe) { return { viewName: defaultViewName, - cameraID: this._api.getCameraManager().getStore().getDefaultCameraID(), + cameraID, }; } - throw new ViewIncompatible(localize('error.no_supported_cameras'), { + throw new ViewIncompatible({ view: viewName, - camera: null, - default_view: defaultViewName, + camera: cameraID, }); } @@ -221,10 +220,9 @@ export class ViewFactory { ?.getCapabilities() ?.getRawCapabilities(); - throw new ViewIncompatible(localize('error.no_supported_camera'), { + throw new ViewIncompatible({ view: viewName, camera: cameraID, - default_view: defaultViewName, ...(capabilities && { camera_capabilities: capabilities }), }); } diff --git a/src/card-controller/view/item-manager.ts b/src/card-controller/view/item-manager.ts index 7dcb484b..8452e12f 100644 --- a/src/card-controller/view/item-manager.ts +++ b/src/card-controller/view/item-manager.ts @@ -1,4 +1,5 @@ import { format } from 'date-fns'; +import { createNotificationFromError } from '../../components-lib/notification/factory'; import { homeAssistantGetSignedURLIfNecessary } from '../../ha/sign-path'; import { localize } from '../../localize/localize'; import { AdvancedCameraCardError } from '../../types'; @@ -37,7 +38,15 @@ export class ViewItemManager { try { await this._download(item); } catch (error: unknown) { - this._api.getMessageManager().setErrorIfHigherPriority(error); + /* istanbul ignore if: catch always provides a non-null error -- @preserve */ + if (error == null) { + return false; + } + this._api.getNotificationManager().setNotification( + createNotificationFromError(error, { + heading: { text: localize('error.download_failed'), icon: 'mdi:download-off' }, + }), + ); return false; } return true; diff --git a/src/card-controller/view/types.ts b/src/card-controller/view/types.ts index 60973f87..69657c82 100644 --- a/src/card-controller/view/types.ts +++ b/src/card-controller/view/types.ts @@ -75,4 +75,8 @@ export interface ViewManagerInterface { hasMajorMediaChange(oldView?: View | null, newView?: View | null): boolean; } -export class ViewIncompatible extends AdvancedCameraCardError {} +export class ViewIncompatible extends AdvancedCameraCardError { + constructor(context?: unknown) { + super('', context); + } +} diff --git a/src/card-controller/view/view-manager.ts b/src/card-controller/view/view-manager.ts index 5b8ded02..2f40f59b 100644 --- a/src/card-controller/view/view-manager.ts +++ b/src/card-controller/view/view-manager.ts @@ -2,6 +2,7 @@ import { ViewContext } from 'view'; import { log } from '../../utils/debug'; import { getStreamCameraID } from '../../utils/substream'; import { View } from '../../view/view'; +import { getViewTargetID } from '../../view/target-id'; import { InitializationAspect } from '../initialization-manager'; import { CardViewAPI } from '../types'; import { ViewFactory } from './factory'; @@ -111,19 +112,39 @@ export class ViewManager implements ViewManagerInterface { baseView: this._view, ...options, }); + // A non-throwing factory call clears any prior view_incompatible / + // media_query state — ensures a previously-dismissed mid-session popup + // does not linger invisibly and re-pop on the next evaluation cycle, + // and that a stale media_query failure from an abandoned gallery / + // viewer doesn't follow the user into an unrelated view. + this._api.getIssueManager().reset('view_incompatible'); + this._api.getIssueManager().reset('media_query'); } catch (e) { - this._api.getMessageManager().setErrorIfHigherPriority(e); + if (!this._view) { + view = this._getFailSafeView(viewFactoryFunc); + } + this._api.getIssueManager().trigger('view_incompatible', { error: e }); } if (view) { this._setView(view); } } - private _markViewLoadingQuery(view: View, index: number): View { - return view.mergeInContext({ loading: { query: index } }); + private _getFailSafeView( + viewFactoryFunc: (options?: ViewFactoryOptions) => View | null, + ): View | null { + try { + return viewFactoryFunc({ baseView: null, failSafe: true }); + } catch { + return null; + } } - private _markViewAsNotLoadingQuery(view: View): View { - return view.removeContextProperty('loading', 'query'); + + private _markViewLoadingQuery(view: View, index: number): void { + view.mergeInContext({ loading: { query: index } }); + } + private _markViewAsNotLoadingQuery(view: View): void { + view.removeContextProperty('loading', 'query'); } private _isAllowedToSetView(): boolean { @@ -162,8 +183,16 @@ export class ViewManager implements ViewManagerInterface { ...options?.params, }, }); + this._api.getIssueManager().reset('view_incompatible'); + // A new query is about to run, so any stale media_query error from a + // previous attempt is no longer meaningful. If this new query also + // fails, it will re-trigger below. + this._api.getIssueManager().reset('media_query'); } catch (e) { - this._api.getMessageManager().setErrorIfHigherPriority(e); + if (!this._view) { + initialView = this._getFailSafeView(viewFactoryFunc); + } + this._api.getIssueManager().trigger('view_incompatible', { error: e }); } if (!initialView) { @@ -205,13 +234,23 @@ export class ViewManager implements ViewManagerInterface { // the contrary, small changes such as the user zooming in are fine to // merge into the resultant view. if (this._view.context?.loading?.query === loadingIndex) { - this._setView(this._markViewAsNotLoadingQuery(this._view.clone())); + const view = this._view.clone(); + this._markViewAsNotLoadingQuery(view); + this._setView(view); } return; } if (error) { - this._api.getMessageManager().setErrorIfHigherPriority(error); + // Clear the loading flag before surfacing the error. Otherwise the + // view stays marked in-flight and components (gallery, viewer) keep + // rendering "Awaiting media" on top of the error notification. + if (this._view?.context?.loading?.query === loadingIndex) { + const view = this._view.clone(); + this._markViewAsNotLoadingQuery(view); + this._setView(view); + } + this._api.getIssueManager().trigger('media_query', { error }); return; } @@ -221,6 +260,8 @@ export class ViewManager implements ViewManagerInterface { return; } + this._api.getIssueManager().reset('media_query'); + const newView = this._view.clone(); if (this._view.context?.loading?.query === loadingIndex) { this._markViewAsNotLoadingQuery(newView); @@ -279,7 +320,7 @@ export class ViewManager implements ViewManagerInterface { ); } - public initialize = async (): Promise => { + public initialize = async (): Promise => { // If the query string contains a view related action, we don't set any view // here and allow that action to be triggered by the next call of to execute // query actions (called at least once per render cycle). @@ -289,7 +330,6 @@ export class ViewManager implements ViewManagerInterface { // query is answered. this.setViewDefaultWithNewQuery({ failSafe: true }); } - return true; }; private _setView(view: Readonly | null): void { @@ -312,13 +352,13 @@ export class ViewManager implements ViewManagerInterface { this._api.getCardElementManager().scrollReset(); } - this._api.getMessageManager().reset(); this._api.getStyleManager().setExpandedMode(); this._api.getConditionStateManager()?.setState({ view: view?.view, camera: view?.camera ?? undefined, displayMode: view?.displayMode ?? undefined, + targetID: view ? getViewTargetID(view) ?? undefined : undefined, }); this._api.getCardElementManager().update(); diff --git a/src/card.ts b/src/card.ts index 181e106e..b0f6a399 100644 --- a/src/card.ts +++ b/src/card.ts @@ -7,10 +7,7 @@ import { styleMap } from 'lit/directives/style-map.js'; import 'web-dialog'; import { actionHandler } from './action-handler-directive.js'; import { CardController } from './card-controller/controller'; -import type { - ProblemKey, - ProblemTriggerEventData, -} from './card-controller/problems/types.js'; +import type { IssueKey, IssueTriggerEventData } from './card-controller/issues/types.js'; import { MenuButtonController } from './components-lib/menu-button-controller'; import './components/effects/effects'; import './components/elements.js'; @@ -18,9 +15,9 @@ import { AdvancedCameraCardElements } from './components/elements.js'; import './components/loading.js'; import './components/menu.js'; import { AdvancedCameraCardMenu } from './components/menu.js'; -import './components/message.js'; -import { renderMessage } from './components/message.js'; -import './components/notification.js'; +import './components/notification/block.js'; +import { renderNotificationBlock } from './components/notification/block.js'; +import './components/notification/popup.js'; import './components/overlay.js'; import { AdvancedCameraCardOverlay } from './components/overlay.js'; import './components/status-bar'; @@ -36,7 +33,7 @@ import { REPO_URL } from './const.js'; import { HomeAssistant, LovelaceCardEditor } from './ha/types.js'; import { localize } from './localize/localize.js'; import cardStyle from './scss/card.scss'; -import { MediaLoadedInfo, Message } from './types.js'; +import { MediaLoadedInfo } from './types.js'; import { hasAction } from './utils/action.js'; import { getReleaseVersion } from './utils/diagnostics'; @@ -167,16 +164,14 @@ class AdvancedCameraCard extends LitElement { return false; } - // Always allow messages to render, as a message may be generated during + // Always allow blocking issues to render, as they may be generated during // initialization. - if (this._controller.getMessageManager().hasMessage()) { + if (this._controller.getIssueManager().getStateManager().hasFullCardIssue()) { return true; } if (!this._controller.getInitializationManager().isInitializedMandatory()) { /* async */ this._controller.getInitializationManager().initializeMandatory(); - } else if (!this._controller.getInitializationManager().isInitializedBackground()) { - /* async */ this._controller.getInitializationManager().initializeBackground(); } return true; } @@ -286,6 +281,15 @@ class AdvancedCameraCard extends LitElement { return; } + // Suppress the status bar entirely while a full-card issue (config + // error, lost connection, failed initialization) is rendering. Those + // take over the card and the status bar would be either redundant + // chrome (showing the engine icon etc. over an error) or physically + // obscuring the notification (in popup/overlay styles). + if (this._controller.getIssueManager().getStateManager().hasFullCardIssue()) { + return; + } + return html` @@ -346,10 +353,14 @@ class AdvancedCameraCard extends LitElement { const actions = this._controller.getActionsManager().getMergedActions(); const cameraManager = this._controller.getCameraManager(); + const fullCardIssue = this._controller + .getIssueManager() + .getStateManager() + .getFullCardIssue(); const showLoading = this._config?.performance?.features.card_loading_indicator !== false && - !this._controller.getMessageManager().hasMessage(); + !fullCardIssue; // Caution: Keep the main div and the menu next to one another in order to // ensure the hover menu styling continues to work. @@ -364,19 +375,17 @@ class AdvancedCameraCard extends LitElement { hasDoubleClick: hasAction(actions.double_tap_action), })} style="${styleMap(this._controller.getStyleManager().getAspectRatioStyle())}" - @advanced-camera-card:message=${(ev: CustomEvent) => - this._controller.getMessageManager().setMessageIfHigherPriority(ev.detail)} @advanced-camera-card:media:loaded=${(ev: CustomEvent) => { this._controller.getMediaLoadedInfoManager().set(ev.detail); }} @advanced-camera-card:media:unloaded=${() => this._controller.getMediaLoadedInfoManager().clear()} - @advanced-camera-card:problem:notify=${(ev: CustomEvent) => - this._controller.getProblemManager().forceNotify(ev.detail)} - @advanced-camera-card:problem:trigger=${({ + @advanced-camera-card:issue:notify=${(ev: CustomEvent) => + this._controller.getIssueManager().showNotification(ev.detail)} + @advanced-camera-card:issue:trigger=${({ detail: { key, ...context }, - }: CustomEvent) => - this._controller.getProblemManager().trigger(key, context)} + }: CustomEvent) => + this._controller.getIssueManager().trigger(key, context)} @advanced-camera-card:media:volumechange=${ () => this.requestUpdate() /* Refresh mute menu button */ } @@ -416,20 +425,19 @@ class AdvancedCameraCard extends LitElement { .cardWideConfig=${this._controller.getConfigManager().getCardWideConfig()} .rawConfig=${this._controller.getConfigManager().getRawConfig()} .configManager=${this._controller.getConfigManager()} - .hide=${!!this._controller.getMessageManager().hasMessage()} + .hide=${!!fullCardIssue} .microphoneState=${this._controller.getMicrophoneManager().getState()} .conditionStateManager=${this._controller.getConditionStateManager()} .triggeredCameraIDs=${this._config?.view.triggers.show_trigger_status ? this._controller.getTriggersManager().getTriggeredCameraIDs() : undefined} .deviceRegistryManager=${this._controller.getDeviceRegistryManager()} - .problems=${this._controller.getProblemManager().getProblemPresence()} + .issues=${this._controller + .getIssueManager() + .getStateManager() + .getIssuePresence()} > - ${this._controller.getMessageManager().hasMessage() - ? // Keep message rendering to last to show messages that may have been - // generated during the render. - renderMessage(this._controller.getMessageManager().getMessage()) - : ''} + ${fullCardIssue ? renderNotificationBlock(fullCardIssue.notification) : ''} ${this._renderMenuStatusContainer('bottom')} ${this._config?.elements diff --git a/src/components-lib/live/live-controller.ts b/src/components-lib/live/live-controller.ts index ca5260d1..73c375fe 100644 --- a/src/components-lib/live/live-controller.ts +++ b/src/components-lib/live/live-controller.ts @@ -4,7 +4,6 @@ import { AdvancedCameraCardMediaLoadedEventTarget, dispatchExistingMediaLoadedInfoAsEvent, } from '../../utils/media-info.js'; -import { AdvancedCameraCardMessageEventTarget } from '../message/dispatch.js'; interface LiveViewContext { // A cameraID override (used for dependencies/substreams to force a different @@ -23,9 +22,7 @@ interface LastMediaLoadedInfo { source: EventTarget; } -type LiveControllerHost = LitElement & - AdvancedCameraCardMediaLoadedEventTarget & - AdvancedCameraCardMessageEventTarget; +type LiveControllerHost = LitElement & AdvancedCameraCardMediaLoadedEventTarget; export class LiveController implements ReactiveController { private _host: LiveControllerHost; diff --git a/src/components-lib/media/details-controller.ts b/src/components-lib/media/notification-controller.ts similarity index 94% rename from src/components-lib/media/details-controller.ts rename to src/components-lib/media/notification-controller.ts index 88888d1c..d33e8544 100644 --- a/src/components-lib/media/details-controller.ts +++ b/src/components-lib/media/notification-controller.ts @@ -33,8 +33,8 @@ export interface NotificationControlsContext { filterReviewed?: boolean; } -export class MediaDetailsController { - private _details: NotificationDetail[] = []; +export class MediaNotificationController { + private _metadata: NotificationDetail[] = []; private _heading: NotificationDetail | null = null; private _item: ViewItem | null = null; @@ -50,7 +50,7 @@ export class MediaDetailsController { : null; this._calculateHeading(cameraMetadata, item); - this._calculateDetails(cameraMetadata, item, seek); + this._calculateMetadata(cameraMetadata, item, seek); } private _calculateHeading( @@ -99,7 +99,7 @@ export class MediaDetailsController { this._heading = null; } - private _calculateDetails( + private _calculateMetadata( cameraMetadata: CameraManagerCameraMetadata | null, item?: ViewItem, seek?: Date, @@ -111,7 +111,7 @@ export class MediaDetailsController { const duration = startTime && endTime ? getDurationString(startTime, endTime) : null; const inProgress = ViewItemClassifier.isMedia(item) ? item.inProgress() - ? localize('thumbnail.in_progress') + ? localize('common.in_progress') : null : null; const where = ViewItemClassifier.isMedia(item) @@ -184,7 +184,7 @@ export class MediaDetailsController { const includeTitle = (!ViewItemClassifier.isEvent(item) && !ViewItemClassifier.isReview(item)) || !startTime; - this._details = [ + this._metadata = [ ...(includeTitle && itemTitle ? [ { @@ -204,18 +204,20 @@ export class MediaDetailsController { return this._heading; } - public getDetails(): NotificationDetail[] { - return this._details; + public getMetadata(): NotificationDetail[] { + return this._metadata; } public getNotification(context?: NotificationControlsContext): Notification { + const description = ViewItemClassifier.isMedia(this._item) + ? this._item.getDescription() + : null; + return { heading: this._heading ?? undefined, controls: context ? this._getControls(context) : undefined, - details: this._details, - text: ViewItemClassifier.isMedia(this._item) - ? this._item.getDescription() ?? undefined - : undefined, + metadata: this._metadata, + body: description ? { text: description } : undefined, }; } diff --git a/src/components-lib/message/controller.ts b/src/components-lib/message/controller.ts deleted file mode 100644 index a55bc576..00000000 --- a/src/components-lib/message/controller.ts +++ /dev/null @@ -1,45 +0,0 @@ -import yaml from 'js-yaml'; -import { Link } from '../../config/schema/common/link.js'; -import { TROUBLESHOOTING_URL } from '../../const'; -import { localize } from '../../localize/localize.js'; -import { Message } from '../../types'; - -export class MessageController { - public getMessageString(message: Message): string { - return ( - message.message + - (message.context && typeof message.context === 'string' - ? ': ' + message.context - : '') - ); - } - - public getIcon(message: Message): string { - return message.icon - ? message.icon - : message.type === 'error' - ? 'mdi:alert-circle' - : 'mdi:information-outline'; - } - - public getLink(message: Message): Link | null { - return message.link - ? message.link - : message.type === 'error' - ? { url: TROUBLESHOOTING_URL, title: localize('error.troubleshooting') } - : null; - } - - public getContextStrings(message: Message): string[] { - if (Array.isArray(message.context)) { - return message.context.map((contextItem) => yaml.dump(contextItem)); - } - if (typeof message.context === 'object') { - return [yaml.dump(message.context)]; - } - if (typeof message.context === 'string') { - return [message.context]; - } - return []; - } -} diff --git a/src/components-lib/message/dispatch.ts b/src/components-lib/message/dispatch.ts deleted file mode 100644 index 77045902..00000000 --- a/src/components-lib/message/dispatch.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { AdvancedCameraCardError, Message } from '../../types'; -import { fireAdvancedCameraCardEvent } from '../../utils/fire-advanced-camera-card-event'; - -// Facilitates correct typing of event handlers. -export interface AdvancedCameraCardMessageEventTarget extends EventTarget { - addEventListener( - event: 'advanced-camera-card:message', - listener: ( - this: AdvancedCameraCardMessageEventTarget, - ev: CustomEvent, - ) => void, - options?: AddEventListenerOptions | boolean, - ): void; - addEventListener( - type: string, - callback: EventListenerOrEventListenerObject, - options?: AddEventListenerOptions | boolean, - ): void; - removeEventListener( - event: 'advanced-camera-card:message', - listener: ( - this: AdvancedCameraCardMessageEventTarget, - ev: CustomEvent, - ) => void, - options?: boolean | EventListenerOptions, - ): void; - removeEventListener( - type: string, - callback: EventListenerOrEventListenerObject, - options?: boolean | EventListenerOptions, - ): void; -} - -/** - * Dispatch an event with an error message to show to the user. Calling this - * method will grind the card to a halt, so should only be used for "global" / - * critical errors (i.e. not for individual errors with a given camera, since - * there may be multiple correctly functioning cameras in a grid). - * @param element The element to send the event. - * @param message The message to show. - */ -export const dispatchAdvancedCameraCardErrorEvent = ( - element: EventTarget, - error: unknown, -): void => { - if (error instanceof Error) { - fireAdvancedCameraCardEvent(element, 'message', { - message: error.message, - type: 'error', - ...(error instanceof AdvancedCameraCardError && { context: error.context }), - }); - } -}; diff --git a/src/components-lib/notification/action.ts b/src/components-lib/notification/action.ts new file mode 100644 index 00000000..5f8df195 --- /dev/null +++ b/src/components-lib/notification/action.ts @@ -0,0 +1,23 @@ +import { dispatchActionExecutionRequest } from '../../card-controller/actions/utils/execution-request.js'; +import { NotificationControl } from '../../config/schema/actions/types.js'; +import { + getActionConfigGivenAction, + stopEventFromActivatingCardWideActions, +} from '../../utils/action.js'; +import { arrayify } from '../../utils/basic.js'; + +export function handleControlAction( + ev: CustomEvent<{ action: string }>, + control: NotificationControl, + host: HTMLElement, + onDismiss?: () => void, +): void { + stopEventFromActivatingCardWideActions(ev); + const action = getActionConfigGivenAction(ev.detail.action, control.actions); + if (action) { + dispatchActionExecutionRequest(host, { actions: arrayify(action) }); + } + if (onDismiss && control.dismiss !== false) { + onDismiss(); + } +} diff --git a/src/components-lib/notification/data-to-context.ts b/src/components-lib/notification/data-to-context.ts new file mode 100644 index 00000000..1ee49383 --- /dev/null +++ b/src/components-lib/notification/data-to-context.ts @@ -0,0 +1,12 @@ +import yaml from 'js-yaml'; + +// Converts a structured data object into preformatted text strings for a +// notification's context section. Arrays produce one string per item (strings +// pass through; objects are YAML-dumped); all other objects produce a single +// YAML-dumped string. +export const dataToContext = (data: object): string[] => { + if (Array.isArray(data)) { + return data.map((item) => (typeof item === 'string' ? item : yaml.dump(item))); + } + return [yaml.dump(data)]; +}; diff --git a/src/components-lib/notification/factory.ts b/src/components-lib/notification/factory.ts new file mode 100644 index 00000000..3acb32d0 --- /dev/null +++ b/src/components-lib/notification/factory.ts @@ -0,0 +1,56 @@ +import { Notification, NotificationDetail } from '../../config/schema/actions/types.js'; +import { Link } from '../../config/schema/common/link.js'; +import { getContextFromError } from '../../utils/error-context.js'; +import { dataToContext } from './data-to-context.js'; + +const DEFAULT_ERROR_ICON = 'mdi:alert'; + +export interface NotificationOptions { + heading?: NotificationDetail; + icon?: string; + link?: Link; + metadata?: NotificationDetail[]; + context?: object; + in_progress?: boolean; +} + +export const createNotificationFromText = ( + text: string, + options?: NotificationOptions, +): Notification => ({ + ...(options?.heading && { heading: options.heading }), + body: { + text, + ...((options?.icon || !options?.heading) && { + icon: options?.icon ?? DEFAULT_ERROR_ICON, + }), + }, + ...(options?.metadata && { metadata: options.metadata }), + ...(options?.link && { link: options.link }), + ...(options?.context && { + context: dataToContext(options.context), + }), + ...(options?.in_progress !== undefined && { in_progress: options.in_progress }), +}); + +export const createNotificationFromError = ( + error: NonNullable, + options?: NotificationOptions, +): Notification => { + const text = + typeof error === 'object' && 'message' in error + ? String(error.message) + : typeof error === 'object' + ? JSON.stringify(error) + : String(error); + + const context = options?.context ?? getContextFromError(error) ?? undefined; + + return createNotificationFromText(text, { + ...options, + context, + heading: options?.heading + ? { icon: DEFAULT_ERROR_ICON, severity: 'high', ...options.heading } + : undefined, + }); +}; diff --git a/src/components-lib/notification/summarize.ts b/src/components-lib/notification/summarize.ts new file mode 100644 index 00000000..bbfb020f --- /dev/null +++ b/src/components-lib/notification/summarize.ts @@ -0,0 +1,10 @@ +import { Notification } from '../../config/schema/actions/types.js'; + +export const summarizeNotification = (notification: Notification): string | null => { + const text = notification.body?.text ?? notification.heading?.text ?? null; + if (!text) { + return null; + } + const metadata = notification.metadata?.map((m) => m.text).join(', '); + return metadata ? `${text} [${metadata}]` : text; +}; diff --git a/src/components-lib/status-bar-controller.ts b/src/components-lib/status-bar-controller.ts index 8debe13c..47a2ebe1 100644 --- a/src/components-lib/status-bar-controller.ts +++ b/src/components-lib/status-bar-controller.ts @@ -35,11 +35,25 @@ export class StatusBarController { const sufficientBefore = this._getSufficientValues(this._items); const sufficientAfter = this._getSufficientValues(newItems); + const hadPermanent = this._hasPermanentItems(this._items); this._items = newItems; - if (this._config?.style === 'popup' && !isEqual(sufficientBefore, sufficientAfter)) { - this._show(); - this._popupTimer.start(this._config.popup_seconds, () => this._hide()); + if (this._config?.style === 'popup') { + const hasPermanent = this._hasPermanentItems(newItems); + const sufficientChanged = !isEqual(sufficientBefore, sufficientAfter); + + if (hasPermanent) { + // Permanent items present: show and cancel any running timer. + this._show(); + this._popupTimer.stop(); + } else if (sufficientChanged) { + // Sufficient items changed without permanent items: normal popup. + this._show(); + this._popupTimer.start(this._config.popup_seconds, () => this._hide()); + } else if (hadPermanent && !hasPermanent) { + // Permanent items just removed: start the popup timer to fade out. + this._popupTimer.start(this._config.popup_seconds, () => this._hide()); + } } this._host.requestUpdate(); @@ -55,7 +69,7 @@ export class StatusBarController { this._host.setAttribute('data-style', config.style); this._host.setAttribute('data-position', config.position); - if (this._config?.style !== 'popup') { + if (this._config?.style !== 'popup' || this._hasPermanentItems(this._items)) { this._show(); } @@ -67,7 +81,9 @@ export class StatusBarController { } public shouldRender(): boolean { - return this._items.some((item) => item.enabled !== false && item.sufficient); + return this._items.some( + (item) => item.enabled !== false && (item.sufficient || item.permanent), + ); } public actionHandler( @@ -109,6 +125,10 @@ export class StatusBarController { .map((item) => this._getSufficientValue(item)); } + private _hasPermanentItems(items: StatusBarItem[]): boolean { + return items.some((item) => item.enabled !== false && item.permanent); + } + private _show(): void { setOrRemoveAttribute(this._host, false, 'hide'); } diff --git a/src/components/diagnostics.ts b/src/components/diagnostics.ts index aa01a1f0..a2265daf 100644 --- a/src/components/diagnostics.ts +++ b/src/components/diagnostics.ts @@ -1,15 +1,14 @@ import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { until } from 'lit/directives/until.js'; -import { ProblemPresence } from '../card-controller/problems/types'; +import { IssuePresence } from '../card-controller/issues/types'; import { RawAdvancedCameraCardConfig } from '../config/types'; import { DeviceRegistryManager } from '../ha/registry/device'; import { HomeAssistant } from '../ha/types'; import { localize } from '../localize/localize'; import basicBlockStyle from '../scss/basic-block.scss'; import { getDiagnostics } from '../utils/diagnostics'; -import { renderMessage } from './message'; - +import { renderNotificationBlockFromText } from './notification/block'; @customElement('advanced-camera-card-diagnostics') export class AdvancedCameraCardDiagnostics extends LitElement { // Not a reactive property to avoid multiple diagnostics fetches. @@ -22,18 +21,17 @@ export class AdvancedCameraCardDiagnostics extends LitElement { public rawConfig?: RawAdvancedCameraCardConfig; @property({ attribute: false }) - public problems?: ProblemPresence; + public issues?: IssuePresence; private async _renderDiagnostics(): Promise { const diagnostics = await getDiagnostics( this.hass, this.deviceRegistryManager, this.rawConfig, - this.problems, + this.issues, ); - return renderMessage({ - message: localize('error.diagnostics'), + return renderNotificationBlockFromText(localize('error.diagnostics'), { icon: 'mdi:cogs', context: diagnostics, }); @@ -42,10 +40,9 @@ export class AdvancedCameraCardDiagnostics extends LitElement { protected render(): TemplateResult | void { return html`${until( this._renderDiagnostics(), - renderMessage({ - message: localize('error.fetching_diagnostics'), - dotdotdot: true, + renderNotificationBlockFromText(localize('error.fetching_diagnostics'), { icon: 'mdi:cogs', + in_progress: true, }), )}`; } diff --git a/src/components/elements.ts b/src/components/elements.ts index 74eed6f4..b005ad96 100644 --- a/src/components/elements.ts +++ b/src/components/elements.ts @@ -8,8 +8,8 @@ import { } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { isEqual } from 'lodash-es'; +import { IssueTriggerEventData } from '../card-controller/issues/types.js'; import { TemplateRenderer } from '../card-controller/templates/index.js'; -import { dispatchAdvancedCameraCardErrorEvent } from '../components-lib/message/dispatch.js'; import { ConditionsManager } from '../conditions/conditions-manager.js'; import { getConditionStateManagerViaEvent } from '../conditions/state-manager-via-event.js'; import { ConditionStateManager } from '../conditions/state-manager.js'; @@ -35,6 +35,12 @@ import { AdvancedCameraCardError } from '../types.js'; import { errorToConsole } from '../utils/basic.js'; import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-event.js'; +class ElementsCreationError extends AdvancedCameraCardError { + constructor(context?: unknown) { + super(localize('error.could_not_create_elements'), context); + } +} + /* A note on picture element rendering: * * To avoid needing to deal with the rendering of all the picture elements @@ -106,7 +112,7 @@ export class AdvancedCameraCardElementsCore extends LitElement { private _createRoot(): HuiConditionalElement { const elementConstructor = customElements.get('hui-conditional-element'); if (!elementConstructor || !this.hass) { - throw new Error(localize('error.could_not_render_elements')); + throw new ElementsCreationError(this._renderedElements); } const element = new elementConstructor() as HuiConditionalElement; @@ -145,7 +151,11 @@ export class AdvancedCameraCardElementsCore extends LitElement { this._renderedElements = elements; this._root = this._createRoot(); } catch (e) { - return dispatchAdvancedCameraCardErrorEvent(this, e as AdvancedCameraCardError); + errorToConsole(e as Error); + fireAdvancedCameraCardEvent(this, 'issue:trigger', { + key: 'config_error', + error: new ElementsCreationError(elements), + }); } }; diff --git a/src/components/gallery/gallery-core.ts b/src/components/gallery/gallery-core.ts index 33abbfa5..1e3d630d 100644 --- a/src/components/gallery/gallery-core.ts +++ b/src/components/gallery/gallery-core.ts @@ -16,7 +16,6 @@ import { THUMBNAIL_WIDTH_DEFAULT } from '../../config/schema/common/controls/thu import { CardWideConfig } from '../../config/schema/types.js'; import { HomeAssistant } from '../../ha/types.js'; import galleryCoreStyle from '../../scss/gallery-core.scss'; -import '../message.js'; import '../progress-indicator.js'; import { renderProgressIndicator } from '../progress-indicator.js'; diff --git a/src/components/gallery/gallery.ts b/src/components/gallery/gallery.ts index f92b8c67..e36e3582 100644 --- a/src/components/gallery/gallery.ts +++ b/src/components/gallery/gallery.ts @@ -27,7 +27,6 @@ import { MediaGalleryConfig } from '../../config/schema/media-gallery.js'; import { CardWideConfig } from '../../config/schema/types.js'; import { MEDIA_CHUNK_SIZE_DEFAULT } from '../../const.js'; import { HomeAssistant } from '../../ha/types.js'; -import { localize } from '../../localize/localize.js'; import galleryStyle from '../../scss/gallery.scss'; import { stopEventFromActivatingCardWideActions } from '../../utils/action.js'; import { ViewItemClassifier } from '../../view/item-classifier.js'; @@ -36,8 +35,7 @@ import { UnifiedQueryBuilder } from '../../view/unified-query-builder.js'; import { UnifiedQueryRunner } from '../../view/unified-query-runner.js'; import { getReviewedQueryFilterFromQuery } from '../../view/utils/query-filter.js'; import '../media-filter.js'; -import '../message.js'; -import { renderMessage } from '../message.js'; +import { renderNoMedia } from '../notification/no-media.js'; import '../surround-basic.js'; import '../thumbnail/thumbnail.js'; import './gallery-core.js'; @@ -216,13 +214,10 @@ export class AdvancedCameraCardGallery extends LitElement { ` : ''} ${!hasItems - ? renderMessage({ - type: 'info', - message: isLoading - ? localize('error.awaiting_media') - : localize('common.no_media'), - icon: 'mdi:multimedia', - dotdotdot: isLoading, + ? renderNoMedia({ + cameraID: this.viewManagerEpoch?.manager.getView()?.camera ?? null, + cameraManager: this.cameraManager ?? null, + loading: isLoading, }) : html` this._getImageSource(), () => dispatchMediaPlayEvent(this), () => dispatchMediaPauseEvent(this), - // Clear image load errors on each timer tick so the next render retries the - // . Retries are bounded by refresh_seconds, not a tight loop. + // Clear image load errors on each timer tick so the next render retries + // the — but only for modes where the underlying URL genuinely + // changes between ticks (camera/entity snapshots). For mode: url, the + // same static URL will fail the same way every time, so clearing the + // error just causes a visible flicker (notification → blank → + // notification) every refresh_seconds. URL-mode retries are driven by + // mediaEpoch (user-initiated or auto-retry) instead. () => { - this._imageLoadError = false; + const mode = resolveImageMode({ + imageConfig: this.imageConfig, + cameraConfig: this.cameraConfig, + }); + if (mode !== 'url') { + this._imageLoadError = false; + } }, ); @@ -393,31 +405,35 @@ export class AdvancedCameraCardImageUpdatingPlayer } } - private _getDisplayMessage(): Message | null { + private _getDisplayNotification(): Notification | null { const error = this._signedURLController.getError(); if (error) { return { - type: 'error', - message: localize( - error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign', - ), - context: this.proxyConfig, + heading: { + text: localize(error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign'), + icon: 'mdi:alert-circle', + }, + link: { url: TROUBLESHOOTING_URL, title: localize('error.troubleshooting') }, + context: this.proxyConfig ? dataToContext(this.proxyConfig) : undefined, }; } if (this._imageLoadError) { return { - type: 'error', - message: localize('error.image_load_error'), - context: this.imageConfig, + heading: { + text: localize('error.image_load_error'), + icon: 'mdi:alert-circle', + }, + link: { url: TROUBLESHOOTING_URL, title: localize('error.troubleshooting') }, + context: this.imageConfig ? dataToContext(this.imageConfig) : undefined, }; } return null; } protected render(): TemplateResult | void { - const message = this._getDisplayMessage(); - if (message) { - return renderMessage(message); + const notification = this._getDisplayNotification(); + if (notification) { + return renderNotificationBlock(notification); } const src = this._cachedValueController?.getValue(); @@ -449,13 +465,14 @@ export class AdvancedCameraCardImageUpdatingPlayer cameraConfig: this.cameraConfig, }); if (mode === 'camera' || mode === 'entity' || mode === 'screensaver') { - // In camera, entity, or screensaver mode the user has likely - // not made an error, but the source may be unavailable, so show - // the stock image. this._forceSafeImage(true); } else if (mode === 'url') { this._imageLoadError = true; } + fireAdvancedCameraCardEvent(this, 'issue:trigger', { + key: 'media_load', + targetID: IMAGE_VIEW_TARGET_ID_SENTINEL, + }); }} /> ` diff --git a/src/components/image.ts b/src/components/image.ts index e9ef1984..3cb6da79 100644 --- a/src/components/image.ts +++ b/src/components/image.ts @@ -1,6 +1,7 @@ import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { guard } from 'lit/directives/guard.js'; +import { keyed } from 'lit/directives/keyed.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js'; import { CameraManager } from '../camera-manager/manager'; import { ViewManagerEpoch } from '../card-controller/view/types'; @@ -12,15 +13,15 @@ import { resolveProxyConfig, } from '../config/schema/common/proxy'; import { ImageViewConfig, type ImageViewProxyConfig } from '../config/schema/image'; -import { IMAGE_VIEW_ZOOM_TARGET_SENTINEL } from '../const'; import { HomeAssistant } from '../ha/types'; import { localize } from '../localize/localize.js'; import imageStyle from '../scss/image.scss'; import { MediaPlayer, MediaPlayerController, MediaPlayerElement } from '../types.js'; +import { IMAGE_VIEW_TARGET_ID_SENTINEL } from '../view/target-id.js'; import './image-updating-player'; import { resolveImageMode } from './image-updating-player'; import './media-dimensions-container'; -import { renderMessage } from './message.js'; +import { renderNotificationBlockFromText } from './notification/block.js'; import './zoomer.js'; @customElement('advanced-camera-card-image') @@ -48,7 +49,7 @@ export class AdvancedCameraCardImage extends LitElement implements MediaPlayer { } private _renderContainer(template: TemplateResult): TemplateResult { - const zoomTarget = IMAGE_VIEW_ZOOM_TARGET_SENTINEL; + const zoomTarget = IMAGE_VIEW_TARGET_ID_SENTINEL; const view = this.viewManagerEpoch?.manager.getView(); const mode = resolveImageMode({ imageConfig: this.imageConfig, @@ -108,23 +109,30 @@ export class AdvancedCameraCardImage extends LitElement implements MediaPlayer { }); if (mode === 'camera' && !this.cameraConfig) { - return renderMessage({ - type: 'info', - message: localize('error.no_camera_for_image'), + return renderNotificationBlockFromText(localize('error.no_camera_for_image'), { icon: 'mdi:camera-off', }); } + const view = this.viewManagerEpoch?.manager.getView(); + const mediaEpoch = view?.context?.mediaEpoch?.[IMAGE_VIEW_TARGET_ID_SENTINEL] ?? 0; + return this._renderContainer(html` - - + ${keyed( + mediaEpoch, + html` + + + `, + )} `); } diff --git a/src/components/live/carousel.ts b/src/components/live/carousel.ts index a40e9c11..c42c4b6b 100644 --- a/src/components/live/carousel.ts +++ b/src/components/live/carousel.ts @@ -8,6 +8,7 @@ import { } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { guard } from 'lit/directives/guard.js'; +import { keyed } from 'lit/directives/keyed.js'; import { createRef, Ref, ref } from 'lit/directives/ref.js'; import { CameraManager } from '../../camera-manager/manager.js'; import { CameraManagerCameraMetadata } from '../../camera-manager/types.js'; @@ -220,32 +221,38 @@ export class AdvancedCameraCardLiveCarousel extends LitElement { const cameraMetadata = this.cameraManager.getCameraMetadata(cameraID); const view = this.viewManagerEpoch?.manager.getView(); + const mediaEpoch = view?.context?.mediaEpoch?.[cameraID] ?? 0; return html`
- this.cameraManager?.getCameraEndpoints(cameraID) ?? undefined, - )} - .label=${cameraMetadata?.title ?? ''} - .liveConfig=${this.liveConfig} - .hass=${this.hass} - .cardWideConfig=${this.cardWideConfig} - .zoomSettings=${view?.context?.zoom?.[cameraID]?.requested} - .zoom=${!this._isGesturesPTZActive(view, cameraID)} - @advanced-camera-card:zoom:change=${(ev: CustomEvent) => - handleZoomSettingsObservedEvent( - ev, - this.viewManagerEpoch?.manager, - cameraID, + ${keyed( + mediaEpoch, + html` this.cameraManager?.getCameraEndpoints(cameraID) ?? undefined, )} - > - + .label=${cameraMetadata?.title ?? ''} + .liveConfig=${this.liveConfig} + .hass=${this.hass} + .cardWideConfig=${this.cardWideConfig} + .zoomSettings=${view?.context?.zoom?.[cameraID]?.requested} + .zoom=${!this._isGesturesPTZActive(view, cameraID)} + @advanced-camera-card:zoom:change=${( + ev: CustomEvent, + ) => + handleZoomSettingsObservedEvent( + ev, + this.viewManagerEpoch?.manager, + cameraID, + )} + > + `, + )}
`; } diff --git a/src/components/live/provider.ts b/src/components/live/provider.ts index 7717ae6f..058b946b 100644 --- a/src/components/live/provider.ts +++ b/src/components/live/provider.ts @@ -31,7 +31,7 @@ import { fireAdvancedCameraCardEvent } from '../../utils/fire-advanced-camera-ca import { getResolvedLiveProvider } from '../../utils/live-provider.js'; import { dispatchMediaUnloadedEvent } from '../../utils/media-info.js'; import '../icon.js'; -import { renderMessage } from '../message.js'; +import { renderNotificationBlockFromText } from '../notification/block.js'; import './../media-dimensions-container'; @customElement('advanced-camera-card-live-provider') @@ -73,6 +73,12 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP @state() private _hasProviderError = false; + // Whether the camera entity has ever been in a non-unavailable state. Used + // to suppress transient unavailability errors for entities that were + // previously working (e.g. during PTZ operations). + // See: https://github.com/dermotduffy/advanced-camera-card/issues/2124 + private _entityHasBeenAvailable = false; + private _refProvider: Ref = createRef(); private _lazyLoadController: LazyLoadController = new LazyLoadController(this); @@ -121,6 +127,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP public disconnectedCallback(): void { this._isVideoMediaLoaded = false; + this._entityHasBeenAvailable = false; super.disconnectedCallback(); } @@ -132,11 +139,12 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP ev.stopPropagation(); this._hasProviderError = true; - const cameraID = this.camera?.getID(); - if (cameraID) { - fireAdvancedCameraCardEvent(this, 'problem:trigger', { - key: 'stream_not_loading' as const, - cameraID, + // this.camera is already substream-aware (resolved by the carousel layer). + const targetID = this.camera?.getID(); + if (targetID) { + fireAdvancedCameraCardEvent(this, 'issue:trigger', { + key: 'media_load' as const, + targetID, }); } } @@ -162,6 +170,10 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP } if (changedProps.has('camera')) { + this._isVideoMediaLoaded = false; + this._hasProviderError = false; + this._entityHasBeenAvailable = false; + const provider = getResolvedLiveProvider(this.camera?.getConfig()); if (provider === 'jsmpeg') { this._importPromises.push(import('./providers/jsmpeg.js')); @@ -256,9 +268,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP ) { if (!cameraConfig?.camera_entity) { dispatchLiveErrorEvent(this); - return renderMessage({ - message: localize('error.no_live_camera'), - type: 'error', + return renderNotificationBlockFromText(localize('error.no_live_camera'), { icon: 'mdi:camera', context: cameraConfig, }); @@ -267,25 +277,28 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP const stateObj = this.hass.states[cameraConfig.camera_entity]; if (!stateObj) { dispatchLiveErrorEvent(this); - return renderMessage({ - message: localize('error.live_camera_not_found'), - type: 'error', + return renderNotificationBlockFromText(localize('error.live_camera_not_found'), { icon: 'mdi:camera', context: cameraConfig, }); } if (stateObj.state === 'unavailable') { - dispatchLiveErrorEvent(this); - dispatchMediaUnloadedEvent(this); - return renderMessage({ - message: `${localize('error.live_camera_unavailable')}${ - this.label ? `: ${this.label}` : '' - }`, - type: 'info', - icon: 'mdi:cctv-off', - dotdotdot: true, - }); + if ( + !this._entityHasBeenAvailable || + cameraConfig.always_error_if_entity_unavailable + ) { + dispatchLiveErrorEvent(this); + dispatchMediaUnloadedEvent(this); + return renderNotificationBlockFromText( + `${localize('error.live_camera_unavailable')}${ + this.label ? `: ${this.label}` : '' + }`, + { icon: 'mdi:cctv-off', in_progress: true }, + ); + } + } else { + this._entityHasBeenAvailable = true; } } @@ -373,8 +386,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP ? html` - fireAdvancedCameraCardEvent(this, 'problem:notify', 'stream_not_loading')} + @click=${() => fireAdvancedCameraCardEvent(this, 'issue:notify', 'media_load')} >` : ''}`; } diff --git a/src/components/live/providers/go2rtc/index.ts b/src/components/live/providers/go2rtc/index.ts index 03345c71..b398c404 100644 --- a/src/components/live/providers/go2rtc/index.ts +++ b/src/components/live/providers/go2rtc/index.ts @@ -18,7 +18,7 @@ import { HomeAssistant } from '../../../../ha/types.js'; import { localize } from '../../../../localize/localize.js'; import liveGo2RTCStyle from '../../../../scss/live-go2rtc.scss'; import { MediaPlayer, MediaPlayerController } from '../../../../types.js'; -import { renderMessage } from '../../../message.js'; +import { renderNotificationBlockFromText } from '../../../notification/block.js'; import { VideoRTC } from './video-rtc.js'; customElements.define('advanced-camera-card-live-go2rtc-player', VideoRTC); @@ -144,18 +144,13 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer protected render(): TemplateResult | void { const error = this._signedURLController.getError(); if (error) { - return renderMessage({ - type: 'error', - message: localize( - error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign', - ), - context: this.camera?.getConfig(), - }); + return renderNotificationBlockFromText( + localize(error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign'), + { context: this.camera?.getConfig() }, + ); } if (!this.cameraEndpoints?.go2rtc) { - return renderMessage({ - type: 'error', - message: localize('error.live_camera_no_endpoint'), + return renderNotificationBlockFromText(localize('error.live_camera_no_endpoint'), { context: this.camera?.getConfig(), }); } diff --git a/src/components/live/providers/jsmpeg.ts b/src/components/live/providers/jsmpeg.ts index a1627b99..1c4400f8 100644 --- a/src/components/live/providers/jsmpeg.ts +++ b/src/components/live/providers/jsmpeg.ts @@ -12,13 +12,15 @@ import { until } from 'lit/directives/until.js'; import { CameraEndpoints } from '../../../camera-manager/types.js'; import { dispatchLiveErrorEvent } from '../../../components-lib/live/utils/dispatch-live-error.js'; import { JSMPEGMediaPlayerController } from '../../../components-lib/media-player/jsmpeg.js'; +import { createNotificationFromText } from '../../../components-lib/notification/factory.js'; +import { Notification } from '../../../config/schema/actions/types.js'; import { CameraConfig } from '../../../config/schema/cameras.js'; import { CardWideConfig } from '../../../config/schema/types.js'; import { homeAssistantGetSignedURLIfNecessary } from '../../../ha/sign-path.js'; import { HomeAssistant } from '../../../ha/types.js'; import { localize } from '../../../localize/localize.js'; import liveJSMPEGStyle from '../../../scss/live-jsmpeg.scss'; -import { MediaPlayer, MediaPlayerController, Message } from '../../../types.js'; +import { MediaPlayer, MediaPlayerController } from '../../../types.js'; import { convertHTTPAdressToWebsocket, errorToConsole } from '../../../utils/basic.js'; import { dispatchMediaLoadedEvent, @@ -26,8 +28,8 @@ import { dispatchMediaPlayEvent, } from '../../../utils/media-info.js'; import { Timer } from '../../../utils/timer.js'; -import '../../message.js'; -import { renderMessage } from '../../message.js'; +import '../../notification/block.js'; +import { renderNotificationBlock } from '../../notification/block.js'; import '../../progress-indicator.js'; import { renderProgressIndicator } from '../../progress-indicator.js'; @@ -51,7 +53,7 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla public cardWideConfig?: CardWideConfig; @state() - private _message: Message | null = null; + private _notification: Notification | null = null; private _jsmpegCanvasElement?: HTMLCanvasElement; private _jsmpegVideoPlayer?: JSMpeg.VideoElement; @@ -71,7 +73,7 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla if ( ['cameraConfig', 'cameraEndpoints'].some((prop) => changedProperties.has(prop)) ) { - this._message = null; + this._notification = null; } } @@ -133,7 +135,7 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla } private _resetPlayer(): void { - this._message = null; + this._notification = null; this._refreshPlayerTimer.stop(); if (this._jsmpegVideoPlayer) { try { @@ -175,11 +177,10 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla const endpoint = this.cameraEndpoints?.jsmpeg; if (!endpoint) { - this._message = { - message: localize('error.live_camera_no_endpoint'), - type: 'error', - context: this.cameraConfig, - }; + this._notification = createNotificationFromText( + localize('error.live_camera_no_endpoint'), + { context: this.cameraConfig }, + ); dispatchLiveErrorEvent(this); return; } @@ -197,11 +198,9 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla const address = response ? convertHTTPAdressToWebsocket(response) : null; if (!address) { - this._message = { - type: 'error', - message: localize('error.failed_sign'), + this._notification = createNotificationFromText(localize('error.failed_sign'), { context: this.cameraConfig, - }; + }); dispatchLiveErrorEvent(this); return; } @@ -214,20 +213,19 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla } protected render(): TemplateResult | void { - if (this._message) { - return renderMessage(this._message); + if (this._notification) { + return renderNotificationBlock(this._notification); } const _render = async (): Promise => { await this._refreshPlayer(); if (!this._jsmpegVideoPlayer || !this._jsmpegCanvasElement) { - if (!this._message) { - this._message = { - message: localize('error.jsmpeg_no_player'), - type: 'error', - context: this.cameraConfig, - }; + if (!this._notification) { + this._notification = createNotificationFromText( + localize('error.jsmpeg_no_player'), + { context: this.cameraConfig }, + ); dispatchLiveErrorEvent(this); } return; diff --git a/src/components/live/providers/webrtc-card.ts b/src/components/live/providers/webrtc-card.ts index a0e550bd..d42e7323 100644 --- a/src/components/live/providers/webrtc-card.ts +++ b/src/components/live/providers/webrtc-card.ts @@ -12,6 +12,8 @@ import { CameraEndpoints } from '../../../camera-manager/types.js'; import { dispatchLiveErrorEvent } from '../../../components-lib/live/utils/dispatch-live-error.js'; import { getTechnologyForVideoRTC } from '../../../components-lib/live/utils/get-technology-for-video-rtc.js'; import { VideoMediaPlayerController } from '../../../components-lib/media-player/video.js'; +import { createNotificationFromText } from '../../../components-lib/notification/factory.js'; +import { Notification } from '../../../config/schema/actions/types.js'; import { CameraConfig } from '../../../config/schema/cameras.js'; import { CardWideConfig } from '../../../config/schema/types.js'; import { HomeAssistant } from '../../../ha/types.js'; @@ -21,7 +23,6 @@ import { AdvancedCameraCardError, MediaPlayer, MediaPlayerController, - Message, } from '../../../types.js'; import { mayHaveAudio } from '../../../utils/audio.js'; import { @@ -29,6 +30,7 @@ import { MEDIA_LOAD_CONTROLS_HIDE_SECONDS, setControlsOnVideo, } from '../../../utils/controls.js'; +import { getContextFromError } from '../../../utils/error-context.js'; import { dispatchMediaLoadedEvent, dispatchMediaPauseEvent, @@ -36,8 +38,8 @@ import { dispatchMediaVolumeChangeEvent, } from '../../../utils/media-info.js'; import { renderTask } from '../../../utils/task.js'; -import '../../message.js'; -import { renderMessage } from '../../message.js'; +import '../../notification/block.js'; +import { renderNotificationBlock } from '../../notification/block.js'; import '../../progress-indicator.js'; import { renderProgressIndicator } from '../../progress-indicator.js'; import { VideoRTC } from './go2rtc/video-rtc.js'; @@ -59,7 +61,7 @@ export class AdvancedCameraCardLiveWebRTCCard extends LitElement implements Medi public controls = false; @state() - private _message: Message | null = null; + private _notification: Notification | null = null; private hass?: HomeAssistant; @@ -88,7 +90,7 @@ export class AdvancedCameraCardLiveWebRTCCard extends LitElement implements Medi disconnectedCallback(): void { this._videoRTC = null; - this._message = null; + this._notification = null; super.disconnectedCallback(); } @@ -96,7 +98,7 @@ export class AdvancedCameraCardLiveWebRTCCard extends LitElement implements Medi if ( ['cameraConfig', 'cameraEndpoints'].some((prop) => changedProperties.has(prop)) ) { - this._message = null; + this._notification = null; } } @@ -148,8 +150,8 @@ export class AdvancedCameraCardLiveWebRTCCard extends LitElement implements Medi } protected render(): TemplateResult | void { - if (this._message) { - return renderMessage(this._message); + if (this._notification) { + return renderNotificationBlock(this._notification); } const render = (): TemplateResult | void => { @@ -157,16 +159,15 @@ export class AdvancedCameraCardLiveWebRTCCard extends LitElement implements Medi try { webrtcElement = this._createWebRTC(); } catch (e) { - this._message = { - type: 'error', - message: - e instanceof AdvancedCameraCardError - ? e.message - : localize('error.webrtc_card_reported_error') + - ': ' + - (e as Error).message, - context: (e as AdvancedCameraCardError).context, - }; + const context = getContextFromError(e); + this._notification = createNotificationFromText( + e instanceof AdvancedCameraCardError + ? e.message + : localize('error.webrtc_card_reported_error') + ': ' + (e as Error).message, + { + ...(context && { context }), + }, + ); dispatchLiveErrorEvent(this); return; } diff --git a/src/components/message.ts b/src/components/message.ts deleted file mode 100644 index b54192e1..00000000 --- a/src/components/message.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; -import { customElement, property } from 'lit/decorators.js'; -import { classMap } from 'lit/directives/class-map.js'; -import { MessageController } from '../components-lib/message/controller.js'; -import messageStyle from '../scss/message.scss'; -import { Message } from '../types.js'; -import './icon.js'; - -export function renderMessage( - message: Message | null, - renderOptions?: { - overlay?: boolean; - }, -): TemplateResult { - return html` `; -} -@customElement('advanced-camera-card-message') -export class AdvancedCameraCardMessage extends LitElement { - @property({ attribute: false }) - public message?: Message; - - @property({ attribute: true, type: Boolean }) - public overlay = false; - - private _controller = new MessageController(); - - protected render(): TemplateResult | void { - if (!this.message) { - return; - } - - const link = this._controller.getLink(this.message); - const messageTemplate = html` - ${this._controller.getMessageString(this.message)} - ${link ? html`. ${link.title}` : ''} - `; - - const icon = this._controller.getIcon(this.message); - const classes = { - dotdotdot: !!this.message?.dotdotdot, - }; - - return html`
-
-
- -
-
- ${messageTemplate} - ${this._controller - .getContextStrings(this.message) - .map((contextItem) => html`
${contextItem}
`)} -
-
-
`; - } - - static get styles(): CSSResultGroup { - return unsafeCSS(messageStyle); - } -} - -declare global { - interface HTMLElementTagNameMap { - 'advanced-camera-card-message': AdvancedCameraCardMessage; - } -} diff --git a/src/components/notification.ts b/src/components/notification.ts deleted file mode 100644 index c74c338b..00000000 --- a/src/components/notification.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; -import { customElement, property } from 'lit/decorators.js'; -import { classMap } from 'lit/directives/class-map.js'; -import { createRef, Ref, ref } from 'lit/directives/ref.js'; -import { actionHandler } from '../action-handler-directive.js'; -import { dispatchActionExecutionRequest } from '../card-controller/actions/utils/execution-request'; -import { - Notification, - NotificationControl, - NotificationDetail, -} from '../config/schema/actions/types.js'; -import notificationStyle from '../scss/notification.scss'; -import { - getActionConfigGivenAction, - hasAction, - stopEventFromActivatingCardWideActions, -} from '../utils/action.js'; -import { arrayify } from '../utils/basic.js'; -import { dispatchDismissNotificationEvent } from '../utils/notification.js'; -import './icon.js'; - -@customElement('advanced-camera-card-notification') -export class AdvancedCameraCardNotification extends LitElement { - @property({ attribute: false }) - public notification: Notification | null = null; - - private _refNotification: Ref = createRef(); - - public connectedCallback(): void { - super.connectedCallback(); - window.addEventListener('click', this._handleOutsideInteraction); - window.addEventListener('focusin', this._handleOutsideInteraction); - window.addEventListener('keydown', this._handleKeyDown); - } - - public disconnectedCallback(): void { - window.removeEventListener('click', this._handleOutsideInteraction); - window.removeEventListener('focusin', this._handleOutsideInteraction); - window.removeEventListener('keydown', this._handleKeyDown); - super.disconnectedCallback(); - } - - protected render(): TemplateResult | void { - if (!this.notification) { - return; - } - - const heading = this.notification.heading; - const details = this.notification.details ?? []; - const text = this.notification.text; - const controls = this.notification.controls ?? []; - - return html` -
-
-
- ${heading ? this._renderDetail(heading, true) : ''} - ${details.map((detail) => this._renderDetail(detail))} - ${text ? html`
${text}
` : ''} - ${this.notification.link - ? html`` - : ''} -
- ${controls.length - ? html`
- ${controls.map((control) => this._renderControl(control))} -
` - : ''} -
- -
-
- `; - } - - private _renderControl(control: NotificationControl): TemplateResult { - const severityClass = control.severity ? `severity-${control.severity}` : ''; - return html` -
this._handleControlAction(ev, control)} - > - ${control.icon - ? html`` - : ''} -
- `; - } - - private _handleControlAction( - ev: CustomEvent<{ action: string }>, - control: NotificationControl, - ): void { - stopEventFromActivatingCardWideActions(ev); - - const action = getActionConfigGivenAction(ev.detail.action, control.actions); - if (action) { - dispatchActionExecutionRequest(this, { - actions: arrayify(action), - }); - } - if (control.dismiss !== false) { - this._dismiss(); - } - } - - private _renderDetail(detail: NotificationDetail, isHeading = false): TemplateResult { - const classes = { - detail: true, - heading: isHeading, - [`severity-${detail.severity}`]: !!detail.severity, - }; - return html` -
- ${detail.icon - ? html`` - : ''} - ${detail.text} -
- `; - } - - private _dismiss = (): void => { - this._refNotification.value?.classList.add('exiting'); - }; - - private _handleAnimationEnd = (ev: AnimationEvent): void => { - if (ev.animationName === 'slideDown') { - dispatchDismissNotificationEvent(this); - } - }; - - private _handleOutsideInteraction = (ev: Event): void => { - if (!ev.composedPath().includes(this)) { - this._dismiss(); - } - }; - - private _handleKeyDown = (ev: KeyboardEvent): void => { - if (ev.key === 'Escape') { - this._dismiss(); - ev.stopPropagation(); - ev.preventDefault(); - } - }; - - static get styles(): CSSResultGroup { - return unsafeCSS(notificationStyle); - } -} - -declare global { - interface HTMLElementTagNameMap { - 'advanced-camera-card-notification': AdvancedCameraCardNotification; - } -} diff --git a/src/components/notification/block.ts b/src/components/notification/block.ts new file mode 100644 index 00000000..7ab3836e --- /dev/null +++ b/src/components/notification/block.ts @@ -0,0 +1,88 @@ +import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { handleControlAction } from '../../components-lib/notification/action.js'; +import { localize } from '../../localize/localize.js'; +import { + createNotificationFromText, + NotificationOptions, +} from '../../components-lib/notification/factory.js'; +import { Notification } from '../../config/schema/actions/types.js'; +import notificationBlockStyle from '../../scss/notification-block.scss'; +import { + renderControl, + renderDetail, + renderNotificationBody, +} from './common-rendering.js'; + +export function renderNotificationBlock( + notification: Notification | null, +): TemplateResult { + return html``; +} + +export function renderNotificationBlockFromText( + text: string, + options?: NotificationOptions, +): TemplateResult { + return renderNotificationBlock(createNotificationFromText(text, options)); +} + +@customElement('advanced-camera-card-notification-block') +export class AdvancedCameraCardNotificationBlock extends LitElement { + @property({ attribute: false }) + public notification: Notification | null = null; + + protected render(): TemplateResult | void { + if (!this.notification) { + return; + } + + const { heading, in_progress } = this.notification; + const controls = this.notification.controls ?? []; + + // Anchor the spinner to whichever status element is most prominent: the + // heading if present, the body icon otherwise. Never render it orphaned + // on its own row (which would float it to the left with no visual tie to + // the text). + const spinner = in_progress + ? html`
+ +
` + : null; + const spinnerInHeadingRow = spinner && (heading || controls.length); + const spinnerInBody = spinner && !spinnerInHeadingRow; + + return html` +
+ ${heading || spinnerInHeadingRow || controls.length + ? html`
+ ${heading ? renderDetail(heading, 'heading') : ''} + ${spinnerInHeadingRow || controls.length + ? html`
+ ${spinnerInHeadingRow ? spinner : ''} + ${controls.map((control) => + renderControl(control, (ev, c) => + handleControlAction(ev, c, this), + ), + )} +
` + : ''} +
` + : ''} + ${renderNotificationBody(this.notification, spinnerInBody ? spinner : undefined)} +
+ `; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(notificationBlockStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'advanced-camera-card-notification-block': AdvancedCameraCardNotificationBlock; + } +} diff --git a/src/components/notification/common-rendering.ts b/src/components/notification/common-rendering.ts new file mode 100644 index 00000000..bd08ad27 --- /dev/null +++ b/src/components/notification/common-rendering.ts @@ -0,0 +1,94 @@ +import { html, TemplateResult } from 'lit'; +import { classMap } from 'lit/directives/class-map.js'; +import { actionHandler } from '../../action-handler-directive.js'; +import { + Notification, + NotificationControl, + NotificationDetail, +} from '../../config/schema/actions/types.js'; +import { + hasAction, + stopEventFromActivatingCardWideActions, +} from '../../utils/action.js'; +import '../icon.js'; + +export function renderDetail( + detail: NotificationDetail, + role: 'heading' | 'body' | 'metadata' = 'metadata', + iconOverride?: TemplateResult, +): TemplateResult { + const classes = { + detail: true, + heading: role === 'heading', + body: role === 'body', + [`severity-${detail.severity}`]: !!detail.severity, + }; + return html` +
+ ${iconOverride ?? + (detail.icon + ? html`` + : '')} + ${detail.text} +
+ `; +} + +export function renderControl( + control: NotificationControl, + onAction: (ev: CustomEvent<{ action: string }>, control: NotificationControl) => void, +): TemplateResult { + const classes = { + control: true, + [`severity-${control.severity}`]: !!control.severity, + }; + return html` +
onAction(ev, control)} + > + ${control.icon + ? html`` + : ''} +
+ `; +} + +export function renderNotificationBody( + notification: Notification, + bodyIconOverride?: TemplateResult, +): TemplateResult { + const { body, link } = notification; + const context = notification.context ?? []; + const metadata = notification.metadata ?? []; + return html` + ${metadata.map((detail) => renderDetail(detail, 'metadata'))} + ${body ? renderDetail(body, 'body', bodyIconOverride) : ''} + ${link + ? html`` + : ''} + ${context.length + ? html`
+ ${context.map((item) => html`
${item}
`)} +
` + : ''} + `; +} diff --git a/src/components/notification/no-media.ts b/src/components/notification/no-media.ts new file mode 100644 index 00000000..4d4a20f1 --- /dev/null +++ b/src/components/notification/no-media.ts @@ -0,0 +1,31 @@ +import { TemplateResult } from 'lit'; +import { CameraManager } from '../../camera-manager/manager.js'; +import { localize } from '../../localize/localize.js'; +import { renderNotificationBlock } from './block.js'; + +interface NoMediaOptions { + cameraID: string | null; + cameraManager: CameraManager | null; + loading?: boolean; +} + +export function renderNoMedia(options: NoMediaOptions): TemplateResult { + const cameraID = + options.cameraID ?? options.cameraManager?.getStore().getDefaultCameraID() ?? null; + const cameraTitle = cameraID + ? options.cameraManager?.getCameraMetadata(cameraID)?.title ?? cameraID + : null; + + return renderNotificationBlock({ + heading: { + text: options.loading + ? localize('error.awaiting_media') + : localize('common.no_media'), + icon: 'mdi:multimedia', + }, + in_progress: options.loading, + ...(cameraTitle && { + metadata: [{ text: cameraTitle, icon: 'mdi:cctv' }], + }), + }); +} diff --git a/src/components/notification/popup.ts b/src/components/notification/popup.ts new file mode 100644 index 00000000..d98bc8e3 --- /dev/null +++ b/src/components/notification/popup.ts @@ -0,0 +1,111 @@ +import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { createRef, Ref, ref } from 'lit/directives/ref.js'; +import { handleControlAction } from '../../components-lib/notification/action.js'; +import { Notification } from '../../config/schema/actions/types.js'; +import { localize } from '../../localize/localize.js'; +import notificationPopupStyle from '../../scss/notification-popup.scss'; +import { dispatchDismissNotificationEvent } from '../../utils/notification.js'; +import { + renderControl, + renderDetail, + renderNotificationBody, +} from './common-rendering.js'; + +@customElement('advanced-camera-card-notification') +export class AdvancedCameraCardNotification extends LitElement { + @property({ attribute: false }) + public notification: Notification | null = null; + + private _refNotification: Ref = createRef(); + + public connectedCallback(): void { + super.connectedCallback(); + window.addEventListener('click', this._handleOutsideInteraction); + window.addEventListener('focusin', this._handleOutsideInteraction); + window.addEventListener('keydown', this._handleKeyDown); + } + + public disconnectedCallback(): void { + window.removeEventListener('click', this._handleOutsideInteraction); + window.removeEventListener('focusin', this._handleOutsideInteraction); + window.removeEventListener('keydown', this._handleKeyDown); + super.disconnectedCallback(); + } + + protected render(): TemplateResult | void { + if (!this.notification) { + return; + } + + const { heading, in_progress } = this.notification; + const controls = this.notification.controls ?? []; + + return html` +
+
+ ${controls.length || in_progress + ? html`
+ ${in_progress + ? html`
+ +
` + : ''} + ${controls.map((control) => + renderControl(control, (ev, c) => + handleControlAction(ev, c, this, this._dismiss), + ), + )} +
` + : ''} +
+ +
+
+ ${heading ? renderDetail(heading, 'heading') : ''} + ${renderNotificationBody(this.notification)} +
+
+ `; + } + + private _dismiss = (): void => { + this._refNotification.value?.classList.add('exiting'); + }; + + private _handleAnimationEnd = (ev: AnimationEvent): void => { + if (ev.animationName === 'slideDown') { + dispatchDismissNotificationEvent(this); + } + }; + + private _handleOutsideInteraction = (ev: Event): void => { + if (!ev.composedPath().includes(this)) { + this._dismiss(); + } + }; + + private _handleKeyDown = (ev: KeyboardEvent): void => { + if (ev.key === 'Escape') { + this._dismiss(); + ev.stopPropagation(); + ev.preventDefault(); + } + }; + + static get styles(): CSSResultGroup { + return unsafeCSS(notificationPopupStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'advanced-camera-card-notification': AdvancedCameraCardNotification; + } +} diff --git a/src/components/progress-indicator.ts b/src/components/progress-indicator.ts index 15b56342..39950fbf 100644 --- a/src/components/progress-indicator.ts +++ b/src/components/progress-indicator.ts @@ -3,7 +3,7 @@ import { customElement, property } from 'lit/decorators.js'; import { ClassInfo, classMap } from 'lit/directives/class-map.js'; import { ref, Ref } from 'lit/directives/ref.js'; import { CardWideConfig } from '../config/schema/types'; -import messageStyle from '../scss/message.scss'; +import progressIndicatorStyle from '../scss/progress-indicator.scss'; import './icon.js'; type AdvancedCameraCardProgressIndicatorSize = 'tiny' | 'small' | 'medium' | 'large'; @@ -40,7 +40,7 @@ export class AdvancedCameraCardProgressIndicator extends LitElement { public size: AdvancedCameraCardProgressIndicatorSize = 'large'; protected render(): TemplateResult { - return html`
+ return html`
${this.animated ? html` ` : html` changedProperties.has(prop))) { - this._controller.calculate(this.cameraManager, this.item, this.seek); + this._notificationController.calculate(this.cameraManager, this.item, this.seek); } } protected render(): TemplateResult | void { - const heading = this._controller.getHeading(); - const details = this._controller.getDetails(); + const heading = this._notificationController.getHeading(); + const details = this._notificationController.getMetadata(); const renderDetail = ( detail: NotificationDetail, diff --git a/src/components/thumbnail/feature/feature.ts b/src/components/thumbnail/feature/feature.ts index ba6b6d80..203e1e75 100644 --- a/src/components/thumbnail/feature/feature.ts +++ b/src/components/thumbnail/feature/feature.ts @@ -13,9 +13,9 @@ import { dispatchActionExecutionRequest } from '../../../card-controller/actions import { ViewItemManager } from '../../../card-controller/view/item-manager'; import { ViewManagerEpoch } from '../../../card-controller/view/types'; import { - MediaDetailsController, + MediaNotificationController, NotificationControlsContext, -} from '../../../components-lib/media/details-controller'; +} from '../../../components-lib/media/notification-controller'; import { ThumbnailFeatureController } from '../../../components-lib/thumbnail/feature/controller'; import { HomeAssistant } from '../../../ha/types'; import { localize } from '../../../localize/localize'; @@ -208,12 +208,12 @@ export class AdvancedCameraCardThumbnailFeature extends LitElement { title=${this.item?.getDescription() ?? ''} @click=${(ev: Event) => { stopEventFromActivatingCardWideActions(ev); - const detailsController = new MediaDetailsController(); - detailsController.calculate(this.cameraManager, this.item); + const notificationController = new MediaNotificationController(); + notificationController.calculate(this.cameraManager, this.item); dispatchActionExecutionRequest(this, { actions: [ createNotificationAction( - detailsController.getNotification(this._getControlContext()), + notificationController.getNotification(this._getControlContext()), ), ], }); diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index 859cb350..c68e65ed 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -30,7 +30,7 @@ import { contentsChanged } from '../utils/basic'; import './date-picker.js'; import { AdvancedCameraCardDatePicker, DatePickerEvent } from './date-picker.js'; import './icon'; -import { renderMessage } from './message'; +import { renderNotificationBlockFromText } from './notification/block'; import './thumbnail/thumbnail.js'; /** @@ -151,11 +151,9 @@ export class AdvancedCameraCardTimelineCore extends LitElement { if (isLoading) { if (!this.mini) { - return renderMessage({ - message: localize('error.awaiting_media'), + return renderNotificationBlockFromText(localize('error.awaiting_media'), { icon: 'mdi:chart-gantt', - type: 'info', - dotdotdot: true, + in_progress: true, }); } return; @@ -163,11 +161,10 @@ export class AdvancedCameraCardTimelineCore extends LitElement { if (!view?.query || !view.query.hasNodes()) { if (!this.mini) { - return renderMessage({ - message: localize('error.no_camera_or_media_for_timeline'), - icon: 'mdi:chart-gantt', - type: 'info', - }); + return renderNotificationBlockFromText( + localize('error.no_camera_or_media_for_timeline'), + { icon: 'mdi:chart-gantt' }, + ); } return; } diff --git a/src/components/viewer/carousel.ts b/src/components/viewer/carousel.ts index 886b1ca9..c95765b8 100644 --- a/src/components/viewer/carousel.ts +++ b/src/components/viewer/carousel.ts @@ -8,6 +8,7 @@ import { } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { guard } from 'lit/directives/guard.js'; +import { keyed } from 'lit/directives/keyed.js'; import { createRef, Ref, ref } from 'lit/directives/ref.js'; import { CameraManager } from '../../camera-manager/manager.js'; import { RemoveContextPropertyViewModifier } from '../../card-controller/view/modifiers/remove-context-property.js'; @@ -32,8 +33,8 @@ import { ViewItemClassifier } from '../../view/item-classifier.js'; import { ViewMedia } from '../../view/item.js'; import '../carousel'; import type { EmblaCarouselPlugins } from '../carousel.js'; -import { renderMessage } from '../message.js'; import '../next-prev-control.js'; +import { renderNoMedia } from '../notification/no-media.js'; import '../ptz.js'; import './provider.js'; @@ -314,15 +315,12 @@ export class AdvancedCameraCardViewerCarousel extends LitElement { protected render(): TemplateResult | void { const mediaCount = this._media?.length ?? 0; if (!this._media || !mediaCount) { - return renderMessage({ - message: localize('common.no_media'), - type: 'info', - icon: 'mdi:multimedia', - ...(this.viewFilterCameraID && { - context: { - camera_id: this.viewFilterCameraID, - }, - }), + return renderNoMedia({ + cameraID: + this.viewFilterCameraID ?? + this.viewManagerEpoch?.manager.getView()?.camera ?? + null, + cameraManager: this.cameraManager ?? null, }); } @@ -464,16 +462,22 @@ export class AdvancedCameraCardViewerCarousel extends LitElement { return null; } + const mediaID = media.getID(); + const mediaEpoch = mediaID ? view.context?.mediaEpoch?.[mediaID] ?? 0 : 0; + return html`
- + ${keyed( + mediaEpoch, + html``, + )}
`; } diff --git a/src/components/viewer/index.ts b/src/components/viewer/index.ts index cc1397ec..9e7c00ec 100644 --- a/src/components/viewer/index.ts +++ b/src/components/viewer/index.ts @@ -14,11 +14,10 @@ import { CardWideConfig } from '../../config/schema/types.js'; import { ViewerConfig } from '../../config/schema/viewer.js'; import { ResolvedMediaCache } from '../../ha/resolved-media.js'; import { HomeAssistant } from '../../ha/types.js'; -import { localize } from '../../localize/localize.js'; import '../../patches/ha-hls-player.js'; import viewerStyle from '../../scss/viewer.scss'; import { ViewItemClassifier } from '../../view/item-classifier.js'; -import { renderMessage } from '../message.js'; +import { renderNoMedia } from '../notification/no-media.js'; import './grid'; export interface MediaViewerViewContext { @@ -81,15 +80,10 @@ export class AdvancedCameraCardViewer extends LitElement { // Directly render an error message (instead of dispatching it upwards) // to preserve the mini-timeline if the user pans into an area with no // media. - const loadingMedia = - !!this.viewManagerEpoch.manager.getView()?.context?.loading?.query; - return renderMessage({ - type: 'info', - message: loadingMedia - ? localize('error.awaiting_media') - : localize('common.no_media'), - icon: 'mdi:multimedia', - dotdotdot: loadingMedia, + return renderNoMedia({ + cameraID: this.viewManagerEpoch.manager.getView()?.camera ?? null, + cameraManager: this.cameraManager ?? null, + loading: !!this.viewManagerEpoch.manager.getView()?.context?.loading?.query, }); } diff --git a/src/components/viewer/provider.ts b/src/components/viewer/provider.ts index cbfc8e85..915e3313 100644 --- a/src/components/viewer/provider.ts +++ b/src/components/viewer/provider.ts @@ -31,7 +31,7 @@ import { ViewItemClassifier } from '../../view/item-classifier.js'; import { VideoContentType, ViewMedia } from '../../view/item.js'; import { UnifiedQueryTransformer } from '../../view/unified-query-transformer.js'; import '../image-player.js'; -import { renderMessage } from '../message.js'; +import { renderNotificationBlockFromText } from '../notification/block.js'; import { renderProgressIndicator } from '../progress-indicator.js'; import '../video-player.js'; import './../media-dimensions-container'; @@ -233,13 +233,13 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi const error = this._signedURLController.getError(); if (error) { - return renderMessage({ - type: 'error', - message: localize( - error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign', - ), - context: this.media?.getContentID(), - }); + const contentID = this.media?.getContentID(); + return renderNotificationBlockFromText( + localize(error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign'), + { + ...(contentID && { metadata: [{ text: contentID, icon: 'mdi:identifier' }] }), + }, + ); } const url = this._signedURLController.getValue(); diff --git a/src/components/views.ts b/src/components/views.ts index 215cc9d7..50d8d7da 100644 --- a/src/components/views.ts +++ b/src/components/views.ts @@ -10,7 +10,7 @@ import { customElement, property } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { CameraManager } from '../camera-manager/manager.js'; import { FoldersManager } from '../card-controller/folders/manager.js'; -import { ProblemPresence } from '../card-controller/problems/types.js'; +import { IssuePresence } from '../card-controller/issues/types.js'; import { MicrophoneState } from '../card-controller/types.js'; import { ViewItemManager } from '../card-controller/view/item-manager.js'; import { ViewManagerEpoch } from '../card-controller/view/types.js'; @@ -70,7 +70,7 @@ export class AdvancedCameraCardViews extends LitElement { public deviceRegistryManager?: DeviceRegistryManager; @property({ attribute: false }) - public problems?: ProblemPresence; + public issues?: IssuePresence; @property({ attribute: false }) public conditionStateManager?: ConditionStateManagerReadonlyInterface; @@ -215,7 +215,7 @@ export class AdvancedCameraCardViews extends LitElement { .hass=${this.hass} .rawConfig=${this.rawConfig} .deviceRegistryManager=${this.deviceRegistryManager} - .problems=${this.problems} + .issues=${this.issues} > ` : ``} diff --git a/src/conditions/state-manager.ts b/src/conditions/state-manager.ts index 61c69c6f..54fdf63f 100644 --- a/src/conditions/state-manager.ts +++ b/src/conditions/state-manager.ts @@ -25,13 +25,10 @@ export class ConditionStateManager implements ConditionStateManagerReadonlyInter return this._state; } - public setState(state: ConditionState): void { - this._processStateChange(this._calculateTrueChange(state)); - } - - private _processStateChange(changeState: ConditionState): void { + public setState(state: ConditionState): boolean { + const changeState = this._calculateTrueChange(state); if (!Object.keys(changeState).length) { - return; + return false; } const oldState = this._state; @@ -40,6 +37,7 @@ export class ConditionStateManager implements ConditionStateManagerReadonlyInter ...changeState, }; this._callListeners({ old: oldState, change: changeState, new: this._state }); + return true; } private _calculateTrueChange(change: ConditionState): ConditionState { diff --git a/src/conditions/types.ts b/src/conditions/types.ts index 54f707fa..f2a75e94 100644 --- a/src/conditions/types.ts +++ b/src/conditions/types.ts @@ -1,3 +1,4 @@ +import { IssuePresence } from '../card-controller/issues/types'; import { KeysState, MicrophoneState } from '../card-controller/types'; import { AdvancedCameraCardView } from '../config/schema/common/const'; import { ViewDisplayMode } from '../config/schema/common/display'; @@ -17,7 +18,11 @@ export interface ConditionState { mediaLoadedInfo?: MediaLoadedInfo | null; microphone?: MicrophoneState; panel?: boolean; + issues?: IssuePresence; hass?: HomeAssistant; + + // Generic media target identifier. See @view/target-id for details. + targetID?: string; triggered?: Set; userAgent?: string; view?: AdvancedCameraCardView; diff --git a/src/config/schema/actions/types.ts b/src/config/schema/actions/types.ts index a6d60410..7b316d77 100644 --- a/src/config/schema/actions/types.ts +++ b/src/config/schema/actions/types.ts @@ -136,10 +136,12 @@ export type NotificationControl = z.infer; const notificationSchema = z.object({ heading: notificationDetailSchema.optional(), - controls: notificationControlSchema.array().optional(), - details: notificationDetailSchema.array().optional(), - text: z.string().optional(), + body: notificationDetailSchema.optional(), + metadata: notificationDetailSchema.array().optional(), + context: z.string().array().optional(), link: linkSchema.optional(), + in_progress: z.boolean().optional(), + controls: notificationControlSchema.array().optional(), }); export type Notification = z.infer; diff --git a/src/config/schema/cameras.ts b/src/config/schema/cameras.ts index cd1e632a..4666e88c 100644 --- a/src/config/schema/cameras.ts +++ b/src/config/schema/cameras.ts @@ -86,15 +86,29 @@ const castConfigDefault = { method: 'standard' as const, }; -const castSchema = z.object({ - method: z.enum(['standard', 'dashboard']).default(castConfigDefault.method).optional(), - dashboard: z - .object({ - dashboard_path: z.string().optional(), - view_path: z.string().optional(), - }) - .optional(), -}); +const castSchema = z + .object({ + method: z + .enum(['standard', 'dashboard']) + .default(castConfigDefault.method) + .optional(), + dashboard: z + .object({ + dashboard_path: z.string().optional(), + view_path: z.string().optional(), + }) + .optional(), + }) + .refine( + (cast) => + cast.method !== 'dashboard' || + (cast.dashboard?.dashboard_path && cast.dashboard?.view_path), + { + message: + 'dashboard_path and view_path are required when cast method is "dashboard"', + path: ['dashboard'], + }, + ); // ************************************************************************* // Camera Configuration diff --git a/src/config/schema/common/status-bar.ts b/src/config/schema/common/status-bar.ts index d7227b7a..86daf077 100644 --- a/src/config/schema/common/status-bar.ts +++ b/src/config/schema/common/status-bar.ts @@ -3,6 +3,7 @@ import { STATUS_BAR_PRIORITY_DEFAULT, STATUS_BAR_PRIORITY_MAX } from './const'; export const statusBarItemBaseSchema = z.object({ enabled: z.boolean().default(true).optional(), + permanent: z.boolean().default(false).optional(), priority: z .number() .min(0) diff --git a/src/config/schema/status-bar.ts b/src/config/schema/status-bar.ts index d1de2bb6..8271b9e4 100644 --- a/src/config/schema/status-bar.ts +++ b/src/config/schema/status-bar.ts @@ -16,21 +16,34 @@ const STATUS_BAR_POSITIONS = ['top', 'bottom'] as const; const statusBarItemDefault = { priority: STATUS_BAR_PRIORITY_DEFAULT, enabled: true, + permanent: false, }; +// Issues share a single status-bar item config so per-issue granularity +// doesn't leak into the schema/editor surface. Defaults to permanent so the +// status bar stays visible while any issue is active, even in popup mode. +const statusBarIssuesItemDefault = { + ...statusBarItemDefault, + permanent: true, +}; + +// Extend the base schema so the `permanent: true` default survives a user +// override that touches only one sibling field (e.g. `issues.enabled: true`). +// With the item-level default alone, zod would reparse with the base schema's +// field-level `permanent: false` and silently flip the behavior. +const statusBarIssuesItemSchema = statusBarItemBaseSchema.extend({ + permanent: z.boolean().default(true).optional(), +}); + export const statusBarConfigDefault = { height: 40, items: { engine: statusBarItemDefault, + issues: statusBarIssuesItemDefault, resolution: statusBarItemDefault, severity: statusBarItemDefault, technology: statusBarItemDefault, title: statusBarItemDefault, - - // Problems. - problem_config_upgrade: statusBarItemDefault, - problem_legacy_resource: statusBarItemDefault, - problem_stream_not_loading: statusBarItemDefault, }, position: 'bottom' as const, style: 'popup' as const, @@ -50,6 +63,7 @@ export const statusBarConfigSchema = z items: z .object({ engine: statusBarItemBaseSchema.default(statusBarConfigDefault.items.engine), + issues: statusBarIssuesItemSchema.default(statusBarConfigDefault.items.issues), resolution: statusBarItemBaseSchema.default( statusBarConfigDefault.items.resolution, ), @@ -58,17 +72,6 @@ export const statusBarConfigSchema = z statusBarConfigDefault.items.technology, ), title: statusBarItemBaseSchema.default(statusBarConfigDefault.items.title), - - // Problems. - problem_config_upgrade: statusBarItemBaseSchema.default( - statusBarConfigDefault.items.problem_config_upgrade, - ), - problem_legacy_resource: statusBarItemBaseSchema.default( - statusBarConfigDefault.items.problem_legacy_resource, - ), - problem_stream_not_loading: statusBarItemBaseSchema.default( - statusBarConfigDefault.items.problem_stream_not_loading, - ), }) .default(statusBarConfigDefault.items), }) diff --git a/src/config/schema/view.ts b/src/config/schema/view.ts index 31f28c04..c2df51df 100644 --- a/src/config/schema/view.ts +++ b/src/config/schema/view.ts @@ -74,6 +74,10 @@ export const viewConfigDefault = { untrigger_force_seconds: 0, }, keyboard_shortcuts: keyboardShortcutsDefault, + issues: { + interaction_mode: 'all' as const, + retry_seconds: 'auto' as const, + }, }; const interactionModeSchema = z @@ -146,6 +150,16 @@ export const viewConfigSchema = z keyboard_shortcuts: keyboardShortcutsSchema.default( viewConfigDefault.keyboard_shortcuts, ), + + // See: https://github.com/dermotduffy/advanced-camera-card/issues/2099 + issues: z + .object({ + interaction_mode: interactionModeSchema, + retry_seconds: z + .union([z.literal('auto'), z.number().min(0)]) + .default(viewConfigDefault.issues.retry_seconds), + }) + .default(viewConfigDefault.issues), }) .extend(actionsSchema.shape) .default(viewConfigDefault); diff --git a/src/const.ts b/src/const.ts index 60241c1b..56188118 100644 --- a/src/const.ts +++ b/src/const.ts @@ -5,10 +5,8 @@ export const TROUBLESHOOTING_CONFIG_UPGRADE_URL = `${TROUBLESHOOTING_URL}?id=configuration-upgrade-available` as const; export const TROUBLESHOOTING_LEGACY_RESOURCE_URL = `${TROUBLESHOOTING_URL}?id=legacy-dashboard-resource-detected` as const; -export const TROUBLESHOOTING_STREAM_URL = - `${TROUBLESHOOTING_URL}?id=stream-does-not-load` as const; -const CONFIGURATION_URL = `${DOCS_URL}/#/configuration`; -export const FOLDERS_CONFIGURATION_URL = `${CONFIGURATION_URL}/folders`; +export const TROUBLESHOOTING_MEDIA_URL = + `${TROUBLESHOOTING_URL}?id=media-does-not-load` as const; export const CONF_AUTOMATIONS = 'automations' as const; @@ -154,6 +152,11 @@ export const CONF_VIEW_CAMERA_SELECT = `${CONF_VIEW}.camera_select` as const; export const CONF_VIEW_DEFAULT = `${CONF_VIEW}.default` as const; export const CONF_VIEW_DIM = `${CONF_VIEW}.dim` as const; export const CONF_VIEW_INTERACTION_SECONDS = `${CONF_VIEW}.interaction_seconds` as const; +export const CONF_VIEW_ISSUES = `${CONF_VIEW}.issues` as const; +export const CONF_VIEW_ISSUES_INTERACTION_MODE = + `${CONF_VIEW_ISSUES}.interaction_mode` as const; +export const CONF_VIEW_ISSUES_RETRY_SECONDS = + `${CONF_VIEW_ISSUES}.retry_seconds` as const; export const CONF_VIEW_KEYBOARD_SHORTCUTS = `${CONF_VIEW}.keyboard_shortcuts` as const; export const CONF_VIEW_KEYBOARD_SHORTCUTS_ENABLED = `${CONF_VIEW}.keyboard_shortcuts.enabled` as const; @@ -453,5 +456,3 @@ export const MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA = 131072; // improved rendering performance. export const MEDIA_CHUNK_SIZE_DEFAULT = 50; export const MEDIA_CHUNK_SIZE_MAX = 1000; - -export const IMAGE_VIEW_ZOOM_TARGET_SENTINEL = '__IMAGE_VIEW_ZOOM__'; diff --git a/src/declarations.d.ts b/src/declarations.d.ts index bb71ccd6..da645057 100644 --- a/src/declarations.d.ts +++ b/src/declarations.d.ts @@ -5,6 +5,10 @@ declare module 'view' { // eslint-disable-next-line @typescript-eslint/no-empty-object-type interface ViewContext {} } +declare module 'issue' { + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + interface IssueTriggerContext {} +} declare module 'action' { // eslint-disable-next-line @typescript-eslint/no-empty-object-type interface ActionContext {} diff --git a/src/editor.ts b/src/editor.ts index 09036540..20203e5c 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -3,7 +3,7 @@ import { customElement, property, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import './components/icon.js'; import './components/key-assigner.js'; -import { renderMessage } from './components/message.js'; +import { renderNotificationBlock } from './components/notification/block.js'; import { copyConfig, deleteConfigValue, @@ -246,6 +246,9 @@ import { CONF_VIEW_DEFAULT_RESET_INTERACTION_MODE, CONF_VIEW_DIM, CONF_VIEW_INTERACTION_SECONDS, + CONF_VIEW_ISSUES, + CONF_VIEW_ISSUES_INTERACTION_MODE, + CONF_VIEW_ISSUES_RETRY_SECONDS, CONF_VIEW_KEYBOARD_SHORTCUTS, CONF_VIEW_KEYBOARD_SHORTCUTS_ENABLED, CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_DOWN, @@ -266,7 +269,6 @@ import { CONF_VIEW_TRIGGERS_UNTRIGGER_DELAY_SECONDS, CONF_VIEW_TRIGGERS_UNTRIGGER_FORCE_SECONDS, DOCS_URL, - FOLDERS_CONFIGURATION_URL, MEDIA_CHUNK_SIZE_MAX, } from './const.js'; import { fireHASSEvent } from './ha/fire-hass-event.js'; @@ -329,6 +331,7 @@ const MENU_STATUS_BAR_ITEMS = 'status_bar.items'; const MENU_TIMELINE_FORMAT = 'timeline.format'; const MENU_TIMELINE_CONTROLS_THUMBNAILS = 'timeline.controls.thumbnails'; const MENU_VIEW_DEFAULT_RESET = 'view.default_reset'; +const MENU_VIEW_ISSUES = 'view.issues'; const MENU_VIEW_KEYBOARD_SHORTCUTS = 'view.keyboard_shortcuts'; const MENU_VIEW_TRIGGERS = 'view.triggers'; const MENU_VIEW_TRIGGERS_ACTIONS = 'view.triggers.actions'; @@ -352,6 +355,7 @@ const SECTION_DOC_LINKS: Record = { const SUBMENU_DOC_LINKS: Record = { [MENU_VIEW_DEFAULT_RESET]: 'configuration/view?id=default_reset', + [MENU_VIEW_ISSUES]: 'configuration/view?id=issues', [MENU_VIEW_TRIGGERS]: 'configuration/view?id=triggers', [MENU_VIEW_TRIGGERS_ACTIONS]: 'configuration/view?id=trigger-action-configuration', [MENU_VIEW_KEYBOARD_SHORTCUTS]: 'configuration/view?id=keyboard_shortcuts', @@ -904,19 +908,19 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard }, ]; - private _triggersActionsInteractionModes: EditorSelectOption[] = [ + private _interactionModes: EditorSelectOption[] = [ { value: '', label: '' }, { value: 'all', - label: localize('config.view.triggers.actions.interaction_modes.all'), + label: localize('config.common.interaction_modes.all'), }, { value: 'inactive', - label: localize('config.view.triggers.actions.interaction_modes.inactive'), + label: localize('config.common.interaction_modes.inactive'), }, { value: 'active', - label: localize('config.view.triggers.actions.interaction_modes.active'), + label: localize('config.common.interaction_modes.active'), }, ]; @@ -1043,22 +1047,6 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard }, ]; - private _defaultResetInteractionModes: EditorSelectOption[] = [ - { value: '', label: '' }, - { - value: 'all', - label: localize('config.view.default_reset.interaction_modes.all'), - }, - { - value: 'inactive', - label: localize('config.view.default_reset.interaction_modes.inactive'), - }, - { - value: 'active', - label: localize('config.view.default_reset.interaction_modes.active'), - }, - ]; - private _proxyModes: EditorSelectOption[] = [ { value: '', label: '' }, { @@ -1180,10 +1168,8 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard protected willUpdate(): void { if (!this._initialized) { - sideLoadHomeAssistantElements().then((success) => { - if (success) { - this._initialized = true; - } + sideLoadHomeAssistantElements().then(() => { + this._initialized = true; }); } } @@ -1467,7 +1453,7 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard ${this._renderNumberInput(CONF_VIEW_DEFAULT_RESET_EVERY_SECONDS)} ${this._renderOptionSelector( CONF_VIEW_DEFAULT_RESET_INTERACTION_MODE, - this._defaultResetInteractionModes, + this._interactionModes, { label: localize('config.view.default_reset.interaction_mode'), }, @@ -1483,6 +1469,25 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard ); } + private _renderViewIssuesMenu(): TemplateResult { + return this._putInSubmenu( + MENU_VIEW_ISSUES, + true, + `config.${CONF_VIEW_ISSUES}.editor_label`, + 'mdi:alert-circle-outline', + html` + ${this._renderOptionSelector( + CONF_VIEW_ISSUES_INTERACTION_MODE, + this._interactionModes, + { + label: localize('config.view.issues.interaction_mode'), + }, + )} + ${this._renderNumberInput(CONF_VIEW_ISSUES_RETRY_SECONDS, { min: 0 })} + `, + ); + } + private _renderViewTriggersMenu(): TemplateResult { return this._putInSubmenu( MENU_VIEW_TRIGGERS, @@ -1531,7 +1536,7 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard )} ${this._renderOptionSelector( CONF_VIEW_TRIGGERS_ACTIONS_INTERACTION_MODE, - this._triggersActionsInteractionModes, + this._interactionModes, { label: localize('config.view.triggers.actions.interaction_mode'), }, @@ -2346,12 +2351,10 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard ${this._renderStringInput( getArrayConfigPath(CONF_FOLDERS_ARRAY_HA_URL, folderIndex), )} - ${renderMessage({ - message: localize('config.folders.ha.path_info'), - icon: 'mdi:information-outline', - link: { - url: FOLDERS_CONFIGURATION_URL, - title: localize('error.configuration'), + ${renderNotificationBlock({ + body: { + text: localize('config.folders.ha.path_info'), + icon: 'mdi:information-outline', }, })} `, @@ -3064,8 +3067,12 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard CONF_VIEW_DEFAULT_CYCLE_CAMERA, this._defaults.view.default_cycle_camera, )} - ${this._renderViewDefaultResetMenu()} ${this._renderViewTriggersMenu()} - ${this._renderViewKeyboardShortcutMenu()} + ${[ + this._renderViewDefaultResetMenu(), + this._renderViewIssuesMenu(), + this._renderViewTriggersMenu(), + this._renderViewKeyboardShortcutMenu(), + ]} ${this._renderOptionSelector(CONF_VIEW_THEME_THEMES, this._themes, { label: localize('config.view.theme.themes.editor_label'), multiple: true, @@ -3086,40 +3093,42 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard ${this._renderNumberInput(CONF_MENU_BUTTON_SIZE, { min: BUTTON_SIZE_MIN, })} - ${this._renderMenuButton('iris') /* */} - ${this._renderMenuButton('camera_ui')} - ${this._renderMenuButton('cameras') /* */} - ${this._renderMenuButton('clips')} - ${this._renderMenuButton('display_mode')} - ${this._renderMenuButton('download') /* */} - ${this._renderMenuButton('expand') /* */} - ${this._renderMenuButton('folders')} - ${this._renderMenuButton('fullscreen')} - ${this._renderMenuButton('gallery')} - ${this._renderMenuButton('image') /* */} - ${this._renderMenuButton('info') /* */} - ${this._renderMenuButton('live')} - ${this._renderMenuButton('media_player')} - ${this._renderMenuButton( - 'microphone', - html`${this._renderOptionSelector( - `${CONF_MENU_BUTTONS}.microphone.type`, - this._microphoneButtonTypes, - { label: localize('config.menu.buttons.type') }, - )}`, - )} - ${this._renderMenuButton('mute') /* */} - ${this._renderMenuButton('pip') /* */} - ${this._renderMenuButton('play')} - ${this._renderMenuButton('ptz_controls')} - ${this._renderMenuButton('ptz_home')} - ${this._renderMenuButton('recordings')} - ${this._renderMenuButton('reviews')} - ${this._renderMenuButton('screenshot')} - ${this._renderMenuButton('set_review')} - ${this._renderMenuButton('snapshots')} - ${this._renderMenuButton('substreams')} - ${this._renderMenuButton('timeline')} + ${[ + this._renderMenuButton('iris'), + this._renderMenuButton('camera_ui'), + this._renderMenuButton('cameras'), + this._renderMenuButton('clips'), + this._renderMenuButton('display_mode'), + this._renderMenuButton('download'), + this._renderMenuButton('expand'), + this._renderMenuButton('folders'), + this._renderMenuButton('fullscreen'), + this._renderMenuButton('gallery'), + this._renderMenuButton('image'), + this._renderMenuButton('info'), + this._renderMenuButton('live'), + this._renderMenuButton('media_player'), + this._renderMenuButton( + 'microphone', + html`${this._renderOptionSelector( + `${CONF_MENU_BUTTONS}.microphone.type`, + this._microphoneButtonTypes, + { label: localize('config.menu.buttons.type') }, + )}`, + ), + this._renderMenuButton('mute'), + this._renderMenuButton('pip'), + this._renderMenuButton('play'), + this._renderMenuButton('ptz_controls'), + this._renderMenuButton('ptz_home'), + this._renderMenuButton('recordings'), + this._renderMenuButton('reviews'), + this._renderMenuButton('screenshot'), + this._renderMenuButton('set_review'), + this._renderMenuButton('snapshots'), + this._renderMenuButton('substreams'), + this._renderMenuButton('timeline'), + ]}
` : ''} @@ -3148,11 +3157,14 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard default: this._defaults.status_bar.popup_seconds, label: localize('config.status_bar.popup_seconds'), })} - ${this._renderStatusBarItem('title') /* */} - ${this._renderStatusBarItem('resolution') /* */} - ${this._renderStatusBarItem('technology') /* */} - ${this._renderStatusBarItem('engine') /* */} - ${this._renderStatusBarItem('upgrade') /* */} + ${[ + this._renderStatusBarItem('title'), + this._renderStatusBarItem('resolution'), + this._renderStatusBarItem('technology'), + this._renderStatusBarItem('engine'), + this._renderStatusBarItem('severity'), + this._renderStatusBarItem('issues'), + ]}
` : ''} diff --git a/src/ha/has-hass-connection-changed.ts b/src/ha/has-hass-connection-changed.ts deleted file mode 100644 index b5d8e6d4..00000000 --- a/src/ha/has-hass-connection-changed.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { HomeAssistant } from './types'; - -/** - * Determine if HA connection state has changed. - * @param newHass The new HA object. - * @param oldHass The old HA object. - * @returns `true` if the connection state has changed. - */ -export const hasHAConnectionStateChanged = ( - oldHass?: HomeAssistant | null, - newHass?: HomeAssistant | null, -): boolean => { - return oldHass?.connected !== newHass?.connected; -}; diff --git a/src/ha/side-load-ha-elements.ts b/src/ha/side-load-ha-elements.ts index 0db569f3..b4c6102b 100644 --- a/src/ha/side-load-ha-elements.ts +++ b/src/ha/side-load-ha-elements.ts @@ -1,12 +1,19 @@ +import { localize } from '../localize/localize'; +import { AdvancedCameraCardError } from '../types'; import { CardHelpers, LovelaceCardWithEditor } from '../types'; +class HomeAssistantElementsLoadError extends AdvancedCameraCardError { + constructor() { + super(localize('error.home_assistant_elements_load_failed')); + } +} + /** * Side loads the HA elements this card needs. This trickery is unfortunate * necessary, see: * - https://github.com/thomasloven/hass-config/wiki/PreLoading-Lovelace-Elements - * @returns `true` if the load is successful, `false` otherwise. */ -export const sideLoadHomeAssistantElements = async (): Promise => { +export const sideLoadHomeAssistantElements = async (): Promise => { const neededElements = [ 'ha-button', 'ha-camera-stream', @@ -27,7 +34,7 @@ export const sideLoadHomeAssistantElements = async (): Promise => { ]; if (neededElements.every((element) => customElements.get(element))) { - return true; + return; } // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -49,12 +56,10 @@ export const sideLoadHomeAssistantElements = async (): Promise => { await customElements.whenDefined('hui-picture-glance-card'); const pgcConstructor = customElements.get('hui-picture-glance-card'); if (!pgcConstructor) { - return false; + throw new HomeAssistantElementsLoadError(); } const pgc = new pgcConstructor() as LovelaceCardWithEditor; await pgc.constructor.getConfigElement(); - - return true; }; diff --git a/src/localize/languages/ca.json b/src/localize/languages/ca.json index 493a79c5..839cc381 100644 --- a/src/localize/languages/ca.json +++ b/src/localize/languages/ca.json @@ -1,5 +1,6 @@ { "common": { + "in_progress": "En procés", "no_media": "No hi ha suport per mostrar" }, "config": { @@ -106,15 +107,6 @@ } }, "common": { - "media_types": { - "events": "Esdeveniments", - "recordings": "Enregistraments" - }, - "events_media_types": { - "all": "Tots els tipus de mitjans", - "clips": "Clips", - "snapshots": "Imatges instantànies" - }, "controls": { "builtin": "Controls de vídeo integrats", "filter": { @@ -177,6 +169,11 @@ "grid_selected_width_factor": "Augmenta l'amplada del suport multimèdia seleccionat en aquest factor", "mode": "Mode" }, + "events_media_types": { + "all": "Tots els tipus de mitjans", + "clips": "Clips", + "snapshots": "Imatges instantànies" + }, "image": { "mode": "Mode de visualització d'imatges", "modes": { @@ -194,6 +191,10 @@ "unselected": "En desselecció", "visible": "En la visualització del navegador/pestanya" }, + "media_types": { + "events": "Esdeveniments", + "recordings": "Enregistraments" + }, "timeline": { "clustering_threshold": "El recompte d'esdeveniments en què s'agrupen (0=sense agrupació)", "show_recordings": "Mostra les gravacions", @@ -365,11 +366,6 @@ "actions": { "editor_label": "Activar accions", "interaction_mode": "Com gestionar les accions quan la targeta té interacció humana", - "interaction_modes": { - "active": "Activa només accions quan la targeta té interacció humana", - "all": "Activa accions independentment de la interacció humana", - "inactive": "Només activa accions quan la targeta no té interacció humana" - }, "trigger": "Activar l'acció", "triggers": { "default": "Canvia o actualitza la vista predeterminada", @@ -450,7 +446,7 @@ }, "error": { "awaiting_live": "S'està esperant que es carregui la transmissió en directe ...", - "could_not_render_elements": "No s'han pogut representar els elements de la imatge", + "could_not_create_elements": "No s'han pogut representar els elements de la imatge", "diagnostics": "Diagnòstic de targetes. Reviseu la informació confidencial abans de compartir-la", "download_no_media": "No hi ha contingut multimèdia per descarregar", "download_sign_failed": "No s'ha pogut signar l'URL multimèdia per baixar-la", @@ -516,7 +512,6 @@ "camera": "Càmera", "download": "Descarregar mitjans multimèdia", "duration": "Durada", - "in_progress": "En procés", "no_thumbnail": "No hi ha cap miniatura disponible", "retain_indefinitely": "Els mitjans multimèdia es conservaran indefinidament", "seek": "Buscar", diff --git a/src/localize/languages/de.json b/src/localize/languages/de.json index 40c543ff..0a400111 100644 --- a/src/localize/languages/de.json +++ b/src/localize/languages/de.json @@ -301,12 +301,7 @@ "after_interaction": "Zurücksetzen auf Standardansicht nach Ende der Benutzerinteraktion", "editor_label": "Verhalten Zurücksetzen auf Standardansicht", "entities": "Nach Zustandsänderung der Entität Zurücksetzen auf Standardansicht", - "interaction_mode": "Wie der Standard-Reset bei Benutzerinteraktion verhalten soll", - "interaction_modes": { - "active": "Zeige nur wenn Karte Nutzerinteraktion erfährt", - "all": "Zurücksetzen unabhängig von Benutzerinteraktion", - "inactive": "Nur zurücksetzen wenn keine Nutzerinteraktion" - } + "interaction_mode": "Wie der Standard-Reset bei Benutzerinteraktion verhalten soll" }, "dim": "Abdimmen wenn keine Interaktion", "interaction_seconds": "Sekunden nach der Interaktion aktiv bleiben (0=niemals)", @@ -334,11 +329,6 @@ "actions": { "editor_label": "Auslöseaktion", "interaction_mode": "Wie Aktion verarbeiten, wenn Karte Nutzerinteraktion hat", - "interaction_modes": { - "active": "Nur auslösen, wenn Karte Nutzerinteraktion hat", - "all": "Auslösen unabhängig von Nutzerinteraktion", - "inactive": "Nur auslösen wenn Karte keine Nutzerinteraktion hat" - }, "trigger": "Auslöseaktion", "triggers": { "default": "Wechseln in oder Anpassen der Standardansicht", @@ -399,11 +389,16 @@ "invalid_configuration_override": "Ungültige Konfiguration für Ersetzung", "no_camera_or_media_for_timeline": "Keine Kamera oder Medium für Zeitleiste", "no_dashboard_or_view": "Beide Prameter 'dashboard_path' und 'view_path' sind für die Sende-Methode 'dashboard' erforderlich", - "no_supported_camera": "Die ausgewählte Kamera unterstützt diese Ansicht nicht", - "no_supported_cameras": "Keine Kameras unterstützen diese Ansicht", - "stream_not_loading": "Der Videostream wurde nicht geladen. Dafür kann es verschiedene Gründe geben. Wenn konfiguriert (Standard), wird ein Bild jede Sekunde angezeigt, bis der Steam richtig lädt", "too_many_automations": "Zu viele automatisierte Anfragen, bitte prüfe deine Konfiguration auf etwaige Schleifen" }, + "issues": { + "media_load": { + "text": "Der Videostream wurde nicht geladen. Dafür kann es verschiedene Gründe geben. Wenn konfiguriert (Standard), wird ein Bild jede Sekunde angezeigt, bis der Steam richtig lädt" + }, + "view_incompatible": { + "text": "Die ausgewählte Kamera unterstützt diese Ansicht nicht" + } + }, "key_assigner": { "assign": "Zuweisen", "modifiers": { diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index da9a0540..8c478755 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -7,7 +7,9 @@ "advanced_camera_card": "Advanced Camera Card", "advanced_camera_card_description": "An Advanced Camera Card", "folder": "Folder", + "in_progress": "In progress...", "no_media": "No media to display", + "retry": "Retry", "set_reviews": { "reviewed": "Mark as reviewed", "unreviewed": "Mark as unreviewed" @@ -302,6 +304,11 @@ "refresh_seconds": "Seconds between refreshes (0=never)", "url": "Static image URL" }, + "interaction_modes": { + "active": "Only when actively interacting", + "all": "Always regardless of interaction", + "inactive": "Only when not interacting" + }, "media_action_conditions": { "hidden": "On browser/tab hiding", "microphone_mute": "On microphone mute", @@ -549,11 +556,12 @@ "items": { "enabled": "Item enabled", "engine": "Camera engine", + "issues": "Issues", "priority": "Item priority", "resolution": "Resolution", + "severity": "Severity", "technology": "Technology", - "title": "Title", - "upgrade": "Configuration upgrade notification" + "title": "Title" }, "popup_seconds": "Status bar popup seconds", "position": "Status bar position", @@ -580,15 +588,15 @@ "editor_label": "Default view reset behavior", "entities": "Reset to the default view on entity state change", "every_seconds": "Reset to default view every X seconds (0=never)", - "interaction_mode": "How default reset behaves when the card has human interaction", - "interaction_modes": { - "active": "Only allow reset when card has active human interaction", - "all": "Reset regardless of human interaction", - "inactive": "Only reset when card has no human interaction" - } + "interaction_mode": "How default reset behaves when the card has human interaction" }, "dim": "Dim when no interaction", "interaction_seconds": "Seconds of inactivity before interaction state ends (0=never)", + "issues": { + "editor_label": "Issues/Errors", + "interaction_mode": "How automatic retries behave when the card has human interaction", + "retry_seconds": "Seconds between automatic retries [default=adaptive, 0=disabled]" + }, "keyboard_shortcuts": { "editor_label": "Keyboard shortcuts", "enabled": "Keyboard shortcuts enabled", @@ -613,11 +621,6 @@ "actions": { "editor_label": "Trigger actions", "interaction_mode": "How to handle actions when the card has human interaction", - "interaction_modes": { - "active": "Only trigger actions when card has human interaction", - "all": "Trigger actions regardless of human interaction", - "inactive": "Only trigger actions when card has no human interaction" - }, "trigger": "Trigger action", "triggers": { "default": "Change to or update default view", @@ -723,8 +726,9 @@ "camera_initialization": "Camera initialization failed", "camera_initialization_reolink": "Could not initialize Reolink camera", "configuration": "Check configuration", - "could_not_render_elements": "Could not render picture elements", + "could_not_create_elements": "Could not create picture elements", "diagnostics": "Card diagnostics. Please review for confidential information prior to sharing", + "download_failed": "Download failed", "download_no_media": "No media to download", "download_sign_failed": "Could not sign media URL for download", "duplicate_camera_id": "Duplicate camera id for the following camera, use the 'id' parameter to uniquely identify cameras", @@ -736,6 +740,7 @@ "failed_retain": "Could not retain event", "failed_sign": "Could not sign Home Assistant URL", "fetching_diagnostics": "Fetching diagnostics", + "home_assistant_elements_load_failed": "Could not load required Home Assistant elements", "image_load_error": "Could not load image", "invalid_configuration": "Invalid configuration", "invalid_configuration_no_hint": "No location hint available (bad or missing type?)", @@ -746,15 +751,13 @@ "live_camera_no_endpoint": "Could not get camera endpoint for this live provider (incomplete configuration?)", "live_camera_not_found": "The configured camera_entity was not found", "live_camera_unavailable": "Camera unavailable", + "microphone_not_supported": "Microphone is not supported in this browser", "no_camera_engine": "Could not determine suitable engine for camera", "no_camera_entity": "Could not find camera entity", "no_camera_for_image": "No camera configured for camera image mode", "no_camera_id": "Could not determine camera id for the following camera, may need to set 'id' parameter manually", "no_camera_or_media_for_timeline": "No camera or media available for timeline", - "no_dashboard_or_view": "Both 'dashboard_path' and 'view_path' parameters are required for the 'dashboard' cast method", "no_live_camera": "The camera_entity parameter must be set and valid for this live provider", - "no_supported_camera": "The selected camera or media does not support this view", - "no_supported_cameras": "No cameras support this view", "reconnecting": "Reconnecting", "too_many_automations": "Too many nested automation calls, please check your configuration for loops", "troubleshooting": "Check troubleshooting", @@ -762,6 +765,48 @@ "webrtc_card_reported_error": "WebRTC Card reported an error", "webrtc_card_waiting": "Waiting for WebRTC Card to load..." }, + "issues": { + "config_error": { + "heading": "Configuration error", + "text": "An error occurred evaluating the card configuration" + }, + "config_upgrade": { + "heading": "Configuration upgrade available", + "text": "A configuration upgrade is available. To upgrade, edit this card (Dashboard pencil icon \u2192 Three-dot menu \u2192 Edit) and click the 'Automatic Upgrade' button in the card editor" + }, + "connection": { + "lost": { + "heading": "Connection lost", + "text": "Connection to Home Assistant lost" + }, + "starting": { + "heading": "Home Assistant is starting", + "text": "Waiting for Home Assistant startup to complete" + } + }, + "initialization": { + "heading": "Initialization failed" + }, + "legacy_resource": { + "heading": "Legacy dashboard resource detected", + "remove": "Remove legacy resource", + "text_both": "The legacy 'frigate-hass-card.js' resource is still registered, please either manually remove it or click the delete icon to automatically remove it. It will be removed in a future release", + "text_only_legacy": "The legacy 'frigate-hass-card.js' resource must be replaced with 'advanced-camera-card.js'. It will be removed in a future release" + }, + "media_load": { + "heading": "Media not loading", + "text": "The media has not yet loaded. This could be for any number of reasons. For live views and if configured (and by default), there will be an image refreshing every second until the stream loads correctly or the media loading is retried" + }, + "media_query": { + "heading": "Media query failed", + "text": "An error occurred fetching media (e.g. thumbnails) for this view." + }, + "troubleshooting_guide": "Troubleshooting guide", + "view_incompatible": { + "heading": "View not supported", + "text": "The selected camera or media does not support this view" + } + }, "key_assigner": { "assign": "Assign", "modifiers": { @@ -809,28 +854,10 @@ "media_viewer": { "unseekable": "Seek time not found in media" }, - "problems": { - "troubleshooting_guide": "Troubleshooting guide", - "config_upgrade": { - "heading": "Configuration upgrade available", - "text": "A configuration upgrade is available. To upgrade, edit this card (Dashboard pencil icon \u2192 Three-dot menu \u2192 Edit) and click the 'Automatic Upgrade' button in the card editor" - }, - "legacy_resource": { - "heading": "Legacy dashboard resource detected", - "text_both": "The legacy 'frigate-hass-card.js' resource is still registered, please either manually remove it or click the delete icon to automatically remove it. It will be removed in a future release", - "text_only_legacy": "The legacy 'frigate-hass-card.js' resource must be replaced with 'advanced-camera-card.js'. It will be removed in a future release", - "remove": "Remove legacy resource" - }, - "stream_not_loading": { - "heading": "Live stream not loading", - "text": "The video stream has not yet loaded. This could be for any number of reasons. If configured (and by default), there will be an image refreshing every second until the stream loads correctly" - } - }, "thumbnail": { "camera": "Camera", "download": "Download media", "duration": "Duration", - "in_progress": "In Progress", "no_thumbnail": "No thumbnail available", "retain_indefinitely": "Retain media indefinitely", "seek": "Seek", diff --git a/src/localize/languages/fr.json b/src/localize/languages/fr.json index 48bdb3e7..341b4516 100644 --- a/src/localize/languages/fr.json +++ b/src/localize/languages/fr.json @@ -1,5 +1,6 @@ { "common": { + "in_progress": "En cours", "no_media": "Aucun média à afficher" }, "config": { @@ -480,12 +481,7 @@ "editor_label": "Comportement de réinitialisation de la vue par défaut", "entities": "Réinitialiser à la vue par défaut lors du changement d'état de l'entité", "every_seconds": "Actualiser la vue par défaut toutes les X secondes (0=jamais)", - "interaction_mode": "Comment la réinitialisation par défaut se comporte lorsque la carte a une interaction humaine", - "interaction_modes": { - "active": "Autoriser la réinitialisation uniquement lorsque la carte a une interaction humaine active", - "all": "Réinitialiser indépendamment de l'interaction humaine", - "inactive": "Réinitialiser uniquement lorsque la carte n'a pas d'interaction humaine" - } + "interaction_mode": "Comment la réinitialisation par défaut se comporte lorsque la carte a une interaction humaine" }, "dim": "Assombrir si pas d'interaction", "interaction_seconds": "Secondes après l'action de l'utilisateur pour maintenir l'interaction (0=jamais)", @@ -513,11 +509,6 @@ "actions": { "editor_label": "Actions de déclenchement", "interaction_mode": "Comment gérer les actions lorsque la carte comporte une interaction humaine ?", - "interaction_modes": { - "active": "Ne déclencher des actions que lorsque la carte a une interaction humaine", - "all": "Déclencher des actions sans interaction humaine", - "inactive": "Ne déclencher des actions que lorsque la carte n'a pas d'interaction humaine" - }, "trigger": "Actions de déclenchement", "triggers": { "default": "Changement vers ou mise à jour de la vue par défaut", @@ -605,7 +596,7 @@ "awaiting_media": "En attente du chargement des médias", "camera_initialization": "Échec de l'initialisation de la caméra", "camera_initialization_reolink": "Impossible d'initialiser la caméra Reolink", - "could_not_render_elements": "Impossible de restituer les éléments de l'image", + "could_not_create_elements": "Impossible de restituer les éléments de l'image", "diagnostics": "Diagnostic de la carte. Veuillez enlever les informations confidentielles avant de les partager", "download_no_media": "Aucun média à télécharger", "download_sign_failed": "Impossible de signer l'URL du média à télécharger", @@ -630,16 +621,21 @@ "no_camera_id": "Impossible de déterminer l'identifiant de la caméra suivante. Il faudra peut-être définir le paramètre « ID » manuellement", "no_dashboard_or_view": "Les paramètres 'dashboard_path' et 'view_path' sont requis pour la méthode de conversion 'dashboard'", "no_live_camera": "Le paramètre camera_entity doit être défini et valide pour ce fournisseur en direct", - "no_supported_camera": "La caméra sélectionnée ne prend pas en charge cette vue", - "no_supported_cameras": "Aucune caméra ne prend en charge cette vue", "reconnecting": "Reconnexion", - "stream_not_loading": "Le flux vidéo n'a pas encore été chargé. Cela peut être du à plusieurs raisons. Si configurée (et par défaut), une image se rafraichira toutes les secondes jusqu'au chargement correct du flux.", "too_many_automations": "Trop d'appels d'automatisation imbriqués, veuillez vérifier votre configuration pour les boucles", "troubleshooting": "Vérifier le dépannage", "upgrade_available": "Une mise à niveau automatisée de la configuration de la carte est disponible, veuillez visiter l'éditeur visuel de la carte", "webrtc_card_reported_error": "La carte WebRTC a signalé une erreur", "webrtc_card_waiting": "En attente du chargement de la carte WebRTC..." }, + "issues": { + "media_load": { + "text": "Le flux vidéo ou le média enregistré n'a pas encore été chargé. Cela peut être du à plusieurs raisons. La carte tentera de recharger automatiquement après un délai d'attente." + }, + "view_incompatible": { + "text": "La caméra sélectionnée ne prend pas en charge cette vue" + } + }, "key_assigner": { "assign": "Assigner", "unassign": "Désassigner" @@ -680,7 +676,6 @@ "camera": "Caméra", "download": "Télécharger des médias", "duration": "Durée", - "in_progress": "En cours", "no_thumbnail": "Aucune vignette disponible", "retain_indefinitely": "Les médias seront conservés indéfiniment", "seek": "Chercher", diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index e89c4271..f4c0bfb3 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -1,5 +1,6 @@ { "common": { + "in_progress": "In corso", "no_media": "Nessun contenuto multimediale da visualizzare" }, "config": { @@ -91,11 +92,6 @@ } }, "common": { - "events_media_types": { - "all": "Tutti i tipi di media", - "clips": "Clip", - "snapshots": "Istantanee" - }, "controls": { "filter": { "editor_label": "Filtro multimediale", @@ -143,6 +139,11 @@ } } }, + "events_media_types": { + "all": "Tutti i tipi di media", + "clips": "Clip", + "snapshots": "Istantanee" + }, "image": { "mode": "Modalità Visualizza immagine", "modes": { @@ -329,7 +330,7 @@ } }, "error": { - "could_not_render_elements": "Impossibile renderizzare gli elementi dell'immagine", + "could_not_create_elements": "Impossibile renderizzare gli elementi dell'immagine", "diagnostics": "Diagnostica delle carte.Si prega di rivedere per informazioni riservate prima di condividere", "download_no_media": "Nessun media da scaricare", "download_sign_failed": "Impossibile firmare URL multimediale per il download", @@ -387,7 +388,6 @@ "thumbnail": { "camera": "Camera", "duration": "Durata", - "in_progress": "In corso", "no_thumbnail": "Nessuna miniatura disponibile", "retain_indefinitely": "L'evento sarà mantenuto indefinitamente", "seek": "Cercare", diff --git a/src/localize/languages/pl.json b/src/localize/languages/pl.json index d4fe694d..b1bf4421 100644 --- a/src/localize/languages/pl.json +++ b/src/localize/languages/pl.json @@ -7,6 +7,7 @@ "advanced_camera_card": "Advanced Camera Card", "advanced_camera_card_description": "Zaawansowana karta kamery", "folder": "Folder", + "in_progress": "W toku", "no_media": "Brak mediów do wyświetlenia" }, "config": { @@ -530,12 +531,7 @@ "editor_label": "Zachowanie resetu widoku domyślnego", "entities": "Resetuj do widoku domyślnego przy zmianie stanu encji", "every_seconds": "Resetuj do widoku domyślnego co X sekund (0=nigdy)", - "interaction_mode": "Jak reset domyślny zachowuje się przy interakcji człowieka", - "interaction_modes": { - "active": "Zezwalaj na reset tylko przy aktywnej interakcji", - "all": "Resetuj niezależnie od interakcji", - "inactive": "Resetuj tylko przy braku interakcji" - } + "interaction_mode": "Jak reset domyślny zachowuje się przy interakcji człowieka" }, "dim": "Przyciemnij przy braku interakcji", "interaction_seconds": "Sekundy podtrzymania interakcji po akcji użytkownika (0=nigdy)", @@ -563,11 +559,6 @@ "actions": { "editor_label": "Akcje wyzwalaczy", "interaction_mode": "Obsługa akcji przy interakcji człowieka", - "interaction_modes": { - "active": "Wyzwalaj tylko przy interakcji człowieka", - "all": "Wyzwalaj niezależnie od interakcji", - "inactive": "Wyzwalaj tylko przy braku interakcji" - }, "trigger": "Akcja aktywacji", "triggers": { "default": "Zmień na lub zaktualizuj widok domyślny", @@ -664,7 +655,7 @@ "camera_initialization": "Inicjalizacja kamery nie powiodła się", "camera_initialization_reolink": "Nie można zainicjować kamery Reolink", "configuration": "Sprawdź konfigurację", - "could_not_render_elements": "Nie można wyrenderować elementów obrazu", + "could_not_create_elements": "Nie można wyrenderować elementów obrazu", "diagnostics": "Diagnostyka karty. Przejrzyj pod kątem poufnych informacji przed udostępnieniem", "download_no_media": "Brak mediów do pobrania", "download_sign_failed": "Nie udało się podpisać URL mediów do pobrania", @@ -693,16 +684,21 @@ "no_camera_or_media_for_timeline": "Brak dostępnej kamery lub mediów dla osi czasu", "no_dashboard_or_view": "Zarówno 'dashboard_path' jak i 'view_path' są wymagane dla metody cast 'dashboard'", "no_live_camera": "Parametr camera_entity musi być ustawiony i prawidłowy dla tego dostawcy", - "no_supported_camera": "Wybrana kamera lub media nie obsługują tego widoku", - "no_supported_cameras": "Żadna kamera nie obsługuje tego widoku", "reconnecting": "Ponowne łączenie", - "stream_not_loading": "Strumień wideo jeszcze się nie załadował. Może to wynikać z wielu przyczyn. Domyślnie będzie wyświetlany odświeżany obraz, dopóki strumień nie załaduje się poprawnie", "too_many_automations": "Zbyt wiele zagnieżdżonych wywołań automatyzacji, sprawdź konfigurację pod kątem pętli", "troubleshooting": "Sprawdź rozwiązywanie problemów", "upgrade_available": "Dostępna jest automatyczna aktualizacja konfiguracji karty, odwiedź wizualny edytor karty", "webrtc_card_reported_error": "Karta WebRTC zgłosiła błąd", "webrtc_card_waiting": "Oczekiwanie na załadowanie karty WebRTC..." }, + "issues": { + "media_load": { + "text": "Strumień wideo jeszcze się nie załadował. Może to wynikać z wielu przyczyn. Domyślnie będzie wyświetlany odświeżany obraz, dopóki strumień nie załaduje się poprawnie" + }, + "view_incompatible": { + "text": "Wybrana kamera lub media nie obsługują tego widoku" + } + }, "key_assigner": { "assign": "Przypisz", "modifiers": { @@ -749,7 +745,6 @@ "camera": "Kamera", "download": "Pobierz media", "duration": "Czas trwania", - "in_progress": "W toku", "no_thumbnail": "Brak dostępnej miniatury", "retain_indefinitely": "Media będą przechowywane bezterminowo", "seek": "Szukaj", diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index 3e96ecbc..c4677822 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -1,5 +1,6 @@ { "common": { + "in_progress": "Em andamento", "no_media": "Nenhuma mídia para exibir" }, "config": { @@ -91,11 +92,6 @@ } }, "common": { - "events_media_types": { - "all": "Todos os tipos de mídia", - "clips": "Clipes", - "snapshots": "Instantâneos" - }, "controls": { "filter": { "editor_label": "Filtro de Mídia", @@ -143,6 +139,11 @@ } } }, + "events_media_types": { + "all": "Todos os tipos de mídia", + "clips": "Clipes", + "snapshots": "Instantâneos" + }, "image": { "mode": "Modo de visualização de imagem", "modes": { @@ -336,7 +337,7 @@ } }, "error": { - "could_not_render_elements": "Não foi possível renderizar os elementos da imagem", + "could_not_create_elements": "Não foi possível renderizar os elementos da imagem", "diagnostics": "Diagnósticos do cartão. Revise as informações confidenciais antes de compartilhar", "download_no_media": "Nenhuma mídia para download", "download_sign_failed": "Não foi possível assinar o URL de mídia para download", @@ -396,7 +397,6 @@ "camera": "Câmera", "download": "Baixar mídia", "duration": "Duração", - "in_progress": "Em andamento", "no_thumbnail": "Nenhuma miniatura disponível", "retain_indefinitely": "Evento será retido por tempo indeterminado", "seek": "Procurar", diff --git a/src/localize/languages/pt-PT.json b/src/localize/languages/pt-PT.json index dcad5d16..672e924d 100644 --- a/src/localize/languages/pt-PT.json +++ b/src/localize/languages/pt-PT.json @@ -1,5 +1,6 @@ { "common": { + "in_progress": "Em andamento", "no_media": "Sem média" }, "config": { @@ -91,11 +92,6 @@ } }, "common": { - "events_media_types": { - "all": "Todos os tipos de mídia", - "clips": "Clipes", - "snapshots": "Instantâneos" - }, "controls": { "filter": { "editor_label": "Editor de titulos", @@ -143,6 +139,11 @@ } } }, + "events_media_types": { + "all": "Todos os tipos de mídia", + "clips": "Clipes", + "snapshots": "Instantâneos" + }, "image": { "mode": "Modo de visualização de imagem", "modes": { @@ -329,7 +330,7 @@ } }, "error": { - "could_not_render_elements": "Não foi possível renderizar os elementos da imagem", + "could_not_create_elements": "Não foi possível renderizar os elementos da imagem", "diagnostics": "Diagnósticos do cartão. Reveja as informações confidenciais antes de partilhar", "download_no_media": "Nenhuma mídia para download", "download_sign_failed": "Não foi possível assinar o URL de mídia para download", @@ -386,7 +387,6 @@ "thumbnail": { "camera": "Camera", "duration": "Duração", - "in_progress": "Em andamento", "no_thumbnail": "Nenhuma miniatura disponível", "retain_indefinitely": "Evento será retido por tempo indeterminado", "seek": "Procurar", diff --git a/src/localize/localize.ts b/src/localize/localize.ts index 2c9d251a..4e793646 100644 --- a/src/localize/localize.ts +++ b/src/localize/localize.ts @@ -52,7 +52,7 @@ export function getLanguage(hass?: HomeAssistant): string { /** * Load required languages. */ -export const loadLanguages = async (hass: HomeAssistant): Promise => { +export const loadLanguages = async (hass: HomeAssistant): Promise => { const lang = getLanguage(hass); if (lang === 'ca') { languages[lang] = await import('./languages/ca.json'); @@ -71,7 +71,6 @@ export const loadLanguages = async (hass: HomeAssistant): Promise => { } advancedCameraCardLanguage = lang; - return true; }; /** diff --git a/src/patches/ha-hls-player.ts b/src/patches/ha-hls-player.ts index 6a688e06..f0ed361a 100644 --- a/src/patches/ha-hls-player.ts +++ b/src/patches/ha-hls-player.ts @@ -14,7 +14,7 @@ import { customElement } from 'lit/decorators.js'; import { query } from 'lit/decorators/query.js'; import { dispatchLiveErrorEvent } from '../components-lib/live/utils/dispatch-live-error.js'; import { VideoMediaPlayerController } from '../components-lib/media-player/video.js'; -import { renderMessage } from '../components/message.js'; +import { renderNotificationBlockFromText } from '../components/notification/block.js'; import liveHAComponentsStyle from '../scss/live-ha-components.scss'; import { MediaPlayer, MediaPlayerController } from '../types.js'; import { mayHaveAudio } from '../utils/audio.js'; @@ -60,12 +60,8 @@ customElements.whenDefined('ha-hls-player').then(() => { if (this._error) { if (this._errorIsFatal) { dispatchLiveErrorEvent(this); - return renderMessage({ - type: 'error', - message: this._error, - context: { - entity_id: this.entityid, - }, + return renderNotificationBlockFromText(this._error, { + metadata: [{ text: this.entityid, icon: 'mdi:cctv' }], }); } else { errorToConsole(this._error, console.error); diff --git a/src/patches/ha-web-rtc-player.ts b/src/patches/ha-web-rtc-player.ts index 7c2bd487..4188ac93 100644 --- a/src/patches/ha-web-rtc-player.ts +++ b/src/patches/ha-web-rtc-player.ts @@ -14,7 +14,7 @@ import { customElement } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { dispatchLiveErrorEvent } from '../components-lib/live/utils/dispatch-live-error.js'; import { VideoMediaPlayerController } from '../components-lib/media-player/video.js'; -import { renderMessage } from '../components/message.js'; +import { renderNotificationBlockFromText } from '../components/notification/block.js'; import liveHAComponentsStyle from '../scss/live-ha-components.scss'; import { MediaPlayer, MediaPlayerController } from '../types.js'; import { @@ -100,12 +100,8 @@ customElements.whenDefined('ha-web-rtc-player').then(() => { protected render(): TemplateResult | void { if (this._error) { dispatchLiveErrorEvent(this); - return renderMessage({ - type: 'error', - message: this._error, - context: { - entity_id: this.entityid, - }, + return renderNotificationBlockFromText(this._error, { + metadata: [{ text: this.entityid, icon: 'mdi:cctv' }], }); } return html` diff --git a/src/scss/dotdotdot.scss b/src/scss/dotdotdot.scss deleted file mode 100644 index 784a6b45..00000000 --- a/src/scss/dotdotdot.scss +++ /dev/null @@ -1,23 +0,0 @@ -.dotdotdot:after { - @keyframes dots { - 0%, - 20% { - content: '.'; - } - 40% { - content: '..'; - } - 60% { - content: '...'; - } - 90%, - 100% { - content: ''; - } - } - - animation: dots 2s linear infinite; - content: ''; - display: inline-block; - width: 3em; -} diff --git a/src/scss/editor.scss b/src/scss/editor.scss index fd5a4af1..528ff6ed 100644 --- a/src/scss/editor.scss +++ b/src/scss/editor.scss @@ -43,6 +43,7 @@ .submenu-header { display: flex; + align-items: center; padding: 10px; cursor: pointer; @@ -107,10 +108,6 @@ ha-selector { border: 1px solid var(--divider-color); } -advanced-camera-card-message::part(icon) { - color: var(--primary-color); -} - .doc-link-row { display: flex; align-items: center; diff --git a/src/scss/live-provider.scss b/src/scss/live-provider.scss index cb7d2c72..a618503e 100644 --- a/src/scss/live-provider.scss +++ b/src/scss/live-provider.scss @@ -1,5 +1,9 @@ @use 'provider.scss'; +:host { + position: relative; +} + .hidden { display: none; } diff --git a/src/scss/message.scss b/src/scss/message.scss deleted file mode 100644 index d191d52d..00000000 --- a/src/scss/message.scss +++ /dev/null @@ -1,80 +0,0 @@ -@use 'dotdotdot.scss'; - -:host { - display: flex; - flex-direction: column; - justify-content: center; - - // Ensure error messages are selectable. - user-select: text; - // Safari only has prefixed support. - -webkit-user-select: text; -} - -:host(:not([overlay])) { - height: 100%; - width: 100%; - - background: var(--advanced-camera-card-message-background); - color: var(--advanced-camera-card-message-color); -} - -:host([overlay]) { - position: absolute; - inset: 5%; - - background: var(--advanced-camera-card-message-overlay-background); - color: var(--advanced-camera-card-message-overlay-color); -} - -div.wrapper { - height: 100%; -} - -div.message { - display: flex; - justify-content: center; - align-items: center; - box-sizing: border-box; - height: 100%; -} - -div.message.padded { - padding: 20px; -} - -div.message div.contents { - display: flex; - flex-direction: column; - padding: 10px; - margin-top: auto; - margin-bottom: auto; - min-width: 0; -} - -div.message div.icon { - padding: 10px; -} - -.vertical { - flex-direction: column; -} - -a { - word-break: break-word; -} - -:host(:not([overlay])) a { - color: var(--advanced-camera-card-message-color); -} - -:host([overlay]) a { - color: var(--advanced-camera-card-message-overlay-color); -} - -.message pre { - margin-top: 20px; - overflow-x: auto; - border: 1px dotted var(--advanced-camera-card-divider-color); - padding: 1em; -} diff --git a/src/scss/notification-block.scss b/src/scss/notification-block.scss new file mode 100644 index 00000000..01c69922 --- /dev/null +++ b/src/scss/notification-block.scss @@ -0,0 +1,129 @@ +@use './z-index.scss' as *; +@use './notification-common.scss'; + +:host { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + height: 100%; + width: 100%; + + background: var(--advanced-camera-card-notification-block-background); + color: var(--advanced-camera-card-notification-block-color); + + --notification-text-color: var(--advanced-camera-card-notification-block-color); + --notification-control-color: var(--advanced-camera-card-notification-block-color); + + // Ensure error messages are selectable. + user-select: text; + // Safari only has prefixed support. + -webkit-user-select: text; +} + +.content { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + padding: 24px; + max-width: min(92%, 500px); + max-height: 100%; + overflow-y: auto; + scrollbar-width: thin; + text-align: center; +} + +.heading-row { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + + .detail.heading { + flex: 1; + } + + .controls { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + border-radius: var(--advanced-camera-card-button-border-radius); + background: color-mix(in srgb, var(--notification-control-color), transparent 92%); + + // When there are no interactive controls (spinner-only case, e.g. + // Connection / Starting full-card notifications) drop the pill chrome so + // the spinner looks like a status indicator, not a clickable button. + &:not(:has(.control)) { + padding: 0; + background: none; + } + } +} + +.metadata { + display: flex; + flex-wrap: wrap; + gap: 6px 16px; + justify-content: center; +} + +.detail { + display: flex; + align-items: center; + gap: 10px; + font-size: 14px; + line-height: 1.5; + + advanced-camera-card-icon { + flex-shrink: 0; + --mdc-icon-size: 20px; + } + + &.heading { + font-weight: 600; + font-size: 16px; + + advanced-camera-card-icon { + --mdc-icon-size: 28px; + } + } + + &.body { + opacity: 0.85; + + // Stretch to the column width (overriding the parent .content's flex + // align-items: center that otherwise shrink-wraps this div) and left- + // align so long error messages read naturally instead of wrapping + // awkwardly around a centered axis. + align-self: stretch; + text-align: left; + + // When the spinner stands in for the body icon (no-heading in_progress + // case), size it to match the body icon (20px, set on .detail above) so + // the row reads as a single status line. + .spinner { + width: 20px; + height: 20px; + } + } +} + +.description { + font-size: 14px; + line-height: 1.6; + opacity: 0.85; + white-space: pre-wrap; + word-break: break-word; +} + +.context { + width: 100%; + text-align: left; + + pre { + margin-top: 8px; + } +} diff --git a/src/scss/notification-common.scss b/src/scss/notification-common.scss new file mode 100644 index 00000000..6e40b50d --- /dev/null +++ b/src/scss/notification-common.scss @@ -0,0 +1,105 @@ +// Shared styles for notification-block and notification-popup. +// Callers must set: +// --notification-text-color (text, link, context border) +// --notification-control-color (controls, spinner) + +.spinner { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + opacity: 0.5; +} + +.controls .spinner { + width: 24px; + height: 24px; + + // Only render the divider when the spinner is followed by controls. + &:not(:last-child) { + padding-right: 8px; + margin-right: 4px; + border-right: 1px solid + color-mix(in srgb, var(--notification-control-color), transparent 70%); + } +} + +.control { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--advanced-camera-card-button-border-radius); + cursor: pointer; + transition: all 0.15s ease; + + color: color-mix(in srgb, var(--notification-control-color), transparent 40%); + + advanced-camera-card-icon { + --mdc-icon-size: 16px; + } + + &:hover { + filter: brightness(1.2); + } + + // Base control hover color transition (from dimmed to full). + &:not(.severity-low):not(.severity-medium):not(.severity-high):hover { + color: var(--notification-control-color); + } + + &.severity-low { + color: var(--advanced-camera-card-foreground-severity-low-color); + } + &.severity-medium { + color: var(--advanced-camera-card-foreground-severity-medium-color); + } + &.severity-high { + color: var(--advanced-camera-card-foreground-severity-high-color); + } +} + +.detail { + &.severity-low advanced-camera-card-icon { + color: var(--advanced-camera-card-foreground-severity-low-color); + opacity: 1; + } + &.severity-medium advanced-camera-card-icon { + color: var(--advanced-camera-card-foreground-severity-medium-color); + opacity: 1; + } + &.severity-high advanced-camera-card-icon { + color: var(--advanced-camera-card-foreground-severity-high-color); + opacity: 1; + } +} + +.url { + a { + color: var(--notification-text-color); + font-size: 13px; + opacity: 0.8; + text-decoration: underline; + cursor: pointer; + transition: opacity 0.15s ease; + + &:hover { + opacity: 1; + } + } +} + +.context { + pre { + overflow-x: auto; + border: 1px dotted + color-mix(in srgb, var(--notification-text-color), transparent 70%); + padding: 0.75em; + font-size: 12px; + line-height: 1.4; + color: var(--notification-text-color); + white-space: pre-wrap; + word-break: break-word; + } +} diff --git a/src/scss/notification-popup.scss b/src/scss/notification-popup.scss new file mode 100644 index 00000000..c02e9530 --- /dev/null +++ b/src/scss/notification-popup.scss @@ -0,0 +1,228 @@ +@use './z-index.scss' as *; +@use './notification-common.scss'; + +:host { + position: absolute; + inset: 0; + display: flex; + justify-content: center; + align-items: flex-end; + padding: 24px; + pointer-events: none; + z-index: $z-index-notification; +} + +.backdrop { + position: absolute; + inset: 0; + pointer-events: auto; +} + +.notification { + position: relative; + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 12px; + + width: min(92%, 400px); + max-height: calc(100% - 48px); + padding: 20px 24px; + padding-right: 48px; + overflow-y: auto; + overflow-x: hidden; + scrollbar-width: thin; + scrollbar-color: var(--advanced-camera-card-notification-popup-scrollbar-color) + transparent; + user-select: text; + -webkit-user-select: text; + + // Glassmorphism background + background: var(--advanced-camera-card-notification-popup-background); + backdrop-filter: var(--advanced-camera-card-notification-popup-backdrop-filter); + -webkit-backdrop-filter: var( + --advanced-camera-card-notification-popup-backdrop-filter + ); + + // Borders & Edge highlights + border: var(--advanced-camera-card-notification-popup-border); + border-top: var(--advanced-camera-card-notification-popup-border-top); + border-radius: var(--advanced-camera-card-border-radius-final); + + // Shadows + box-shadow: var( + --advanced-camera-card-box-shadow-override, + var(--advanced-camera-card-notification-popup-box-shadow) + ); + + --notification-text-color: var(--advanced-camera-card-notification-popup-color); + --notification-control-color: var( + --advanced-camera-card-notification-popup-close-color + ); + + pointer-events: auto; + + // Entry animation (auto-plays on render) + animation: slideUp 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards; +} + +// Exit animation +.notification.exiting { + animation: slideDown 0.25s ease-out forwards; +} + +@keyframes slideUp { + 0% { + opacity: 0; + transform: translateY(20px) scale(0.95); + } + 60% { + opacity: 1; + transform: translateY(-6px) scale(1.02); + } + 80% { + transform: translateY(3px) scale(0.98); + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes slideDown { + 0% { + opacity: 1; + transform: translateY(0) scale(1); + } + 20% { + transform: translateY(-4px) scale(1.02); + } + 100% { + opacity: 0; + transform: translateY(20px) scale(0.95); + } +} + +.controls { + position: absolute; + top: 8px; + right: 48px; + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + border-radius: var(--advanced-camera-card-button-border-radius); + + background: color-mix( + in srgb, + var(--advanced-camera-card-notification-popup-close-color), + transparent 92% + ); + + // When there are no interactive controls (spinner-only case) drop the pill + // chrome so the spinner looks like as a status indicator, not a clickable + // button. + &:not(:has(.control)) { + padding: 0; + background: none; + } +} + +.close { + position: absolute; + top: 8px; + right: 8px; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--advanced-camera-card-button-border-radius); + + background: color-mix( + in srgb, + var(--advanced-camera-card-notification-popup-close-color), + transparent 95% + ); + color: color-mix( + in srgb, + var(--advanced-camera-card-notification-popup-close-color), + transparent 40% + ); + + cursor: pointer; + transition: all 0.15s ease; +} + +.close:hover { + background: color-mix( + in srgb, + var(--advanced-camera-card-notification-popup-close-color), + transparent 90% + ); + color: var(--advanced-camera-card-notification-popup-close-color); +} + +.close advanced-camera-card-icon { + --mdc-icon-size: 18px; +} + +.details { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 12px 16px; + flex: 1; + min-width: 0; +} + +.detail { + display: flex; + align-items: center; + gap: 8px; + color: var(--advanced-camera-card-notification-popup-color); + font-size: 14px; + line-height: 1.4; + + advanced-camera-card-icon { + flex-shrink: 0; + --mdc-icon-size: 18px; + opacity: 0.7; + } + + span { + min-width: 0; + word-break: break-word; + } + + &.heading { + width: 100%; + font-weight: 600; + font-size: 16px; + margin-bottom: 4px; + + advanced-camera-card-icon { + opacity: 1; + } + } + + &.body { + width: 100%; + opacity: 0.85; + margin-top: 4px; + } +} + +.context { + width: 100%; + margin-top: 4px; + + pre { + margin-top: 4px; + } +} + +.url { + margin-top: 8px; + width: 100%; +} diff --git a/src/scss/notification.scss b/src/scss/notification.scss deleted file mode 100644 index d0d12e59..00000000 --- a/src/scss/notification.scss +++ /dev/null @@ -1,310 +0,0 @@ -@use './z-index.scss' as *; - -:host { - position: absolute; - inset: 0; - display: flex; - justify-content: center; - align-items: flex-end; - padding: 24px; - pointer-events: none; - z-index: $z-index-notification; -} - -.backdrop { - position: absolute; - inset: 0; - pointer-events: auto; -} - -.notification { - position: relative; - display: flex; - flex-direction: row; - align-items: flex-start; - gap: 12px; - - width: min(92%, 400px); - max-height: calc(100% - 48px); - padding: 20px 24px; - padding-right: 48px; - overflow-y: auto; - scrollbar-width: thin; - scrollbar-color: var(--advanced-camera-card-notification-scrollbar-color) transparent; - - // Glassmorphism background - background: var(--advanced-camera-card-notification-background); - backdrop-filter: var(--advanced-camera-card-notification-backdrop-filter); - -webkit-backdrop-filter: var(--advanced-camera-card-notification-backdrop-filter); - - // Borders & Edge highlights - border: var(--advanced-camera-card-notification-border); - border-top: var(--advanced-camera-card-notification-border-top); - border-radius: var(--advanced-camera-card-border-radius-final); - - // Shadows - box-shadow: var( - --advanced-camera-card-box-shadow-override, - var(--advanced-camera-card-notification-box-shadow) - ); - - pointer-events: auto; - - // Entry animation (auto-plays on render) - animation: slideUp 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards; -} - -// Exit animation -.notification.exiting { - animation: slideDown 0.25s ease-out forwards; -} - -@keyframes slideUp { - 0% { - opacity: 0; - transform: translateY(20px) scale(0.95); - } - 60% { - opacity: 1; - transform: translateY(-6px) scale(1.02); - } - 80% { - transform: translateY(3px) scale(0.98); - } - 100% { - opacity: 1; - transform: translateY(0) scale(1); - } -} - -@keyframes slideDown { - 0% { - opacity: 1; - transform: translateY(0) scale(1); - } - 20% { - transform: translateY(-4px) scale(1.02); - } - 100% { - opacity: 0; - transform: translateY(20px) scale(0.95); - } -} - -.icon { - flex-shrink: 0; - width: 28px; - height: 28px; - display: flex; - align-items: center; - justify-content: center; - color: var(--advanced-camera-card-notification-icon-color); - filter: drop-shadow( - 0 0 8px - color-mix( - in oklab, - var(--advanced-camera-card-notification-icon-color), - transparent 60% - ) - ); -} - -.icon advanced-camera-card-icon { - --mdc-icon-size: 20px; -} - -.text { - flex: 1; - font-size: 15px; - font-weight: 400; - line-height: 1.5; - color: var(--advanced-camera-card-notification-text-color); - word-break: break-word; -} - -.controls { - position: absolute; - top: 8px; - right: 48px; - display: flex; - align-items: center; - gap: 4px; - padding: 4px 8px; - border-radius: var(--advanced-camera-card-button-border-radius); - - background: color-mix( - in srgb, - var(--advanced-camera-card-notification-close-color), - transparent 92% - ); -} - -.control { - width: 24px; - height: 24px; - display: flex; - align-items: center; - justify-content: center; - border-radius: var(--advanced-camera-card-button-border-radius); - - color: color-mix( - in srgb, - var(--advanced-camera-card-notification-close-color), - transparent 40% - ); - - cursor: pointer; - transition: all 0.15s ease; - - advanced-camera-card-icon { - --mdc-icon-size: 16px; - } - - &:hover { - filter: brightness(1.2); - } - - // Base control hover color transition (from dimmed to full). - &:not([class^='severity-']):hover { - color: var(--advanced-camera-card-notification-close-color); - } - - &.severity-low { - color: var(--advanced-camera-card-foreground-severity-low-color); - } - &.severity-medium { - color: var(--advanced-camera-card-foreground-severity-medium-color); - } - &.severity-high { - color: var(--advanced-camera-card-foreground-severity-high-color); - } -} - -.close { - position: absolute; - top: 8px; - right: 8px; - width: 32px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; - border-radius: var(--advanced-camera-card-button-border-radius); - - background: color-mix( - in srgb, - var(--advanced-camera-card-notification-close-color), - transparent 95% - ); - color: color-mix( - in srgb, - var(--advanced-camera-card-notification-close-color), - transparent 40% - ); - - cursor: pointer; - transition: all 0.15s ease; -} - -.close:hover { - background: color-mix( - in srgb, - var(--advanced-camera-card-notification-close-color), - transparent 90% - ); - color: var(--advanced-camera-card-notification-close-color); -} - -.close advanced-camera-card-icon { - --mdc-icon-size: 18px; -} - -.details { - display: flex; - flex-direction: row; - flex-wrap: wrap; - gap: 8px 16px; - flex: 1; - min-width: 0; -} - -.detail { - display: flex; - align-items: center; - gap: 8px; - color: var(--advanced-camera-card-notification-text-color); - font-size: 14px; - line-height: 1.4; - - advanced-camera-card-icon { - flex-shrink: 0; - --mdc-icon-size: 18px; - opacity: 0.7; - } - - span { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - min-width: 0; - } - - &.heading { - width: 100%; - font-weight: 600; - font-size: 16px; - margin-bottom: 4px; - - advanced-camera-card-icon { - opacity: 1; - } - } - - &.severity-low advanced-camera-card-icon { - color: var(--advanced-camera-card-foreground-severity-low-color); - opacity: 1; - } - &.severity-medium advanced-camera-card-icon { - color: var(--advanced-camera-card-foreground-severity-medium-color); - opacity: 1; - } - &.severity-high advanced-camera-card-icon { - color: var(--advanced-camera-card-foreground-severity-high-color); - opacity: 1; - } -} - -.description { - width: 100%; - margin-top: 8px; - padding-top: 8px; - border-top: 1px solid - color-mix( - in srgb, - var(--advanced-camera-card-notification-text-color), - transparent 80% - ); - color: var(--advanced-camera-card-notification-text-color); - font-size: 14px; - line-height: 1.5; - white-space: pre-wrap; - word-break: break-word; -} - -.url { - margin-top: 8px; - width: 100%; - - a { - color: var(--advanced-camera-card-notification-text-color); - font-size: 13px; - opacity: 0.8; - text-decoration: underline; - cursor: pointer; - transition: opacity 0.15s ease; - - &:hover { - opacity: 1; - } - } -} diff --git a/src/scss/progress-indicator.scss b/src/scss/progress-indicator.scss new file mode 100644 index 00000000..93b3be8a --- /dev/null +++ b/src/scss/progress-indicator.scss @@ -0,0 +1,15 @@ +:host { + display: flex; + flex-direction: column; + justify-content: center; + height: 100%; + width: 100%; +} + +.indicator { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + height: 100%; +} diff --git a/src/scss/themes/base.scss b/src/scss/themes/base.scss index fcfab7cc..ff25213e 100644 --- a/src/scss/themes/base.scss +++ b/src/scss/themes/base.scss @@ -228,19 +228,6 @@ --advanced-camera-card-control-foreground ); - /********* - * Message - *********/ - --advanced-camera-card-message-color: var(--advanced-camera-card-text-color); - --advanced-camera-card-message-background: var(--advanced-camera-card-background); - - --advanced-camera-card-message-overlay-background: var( - --advanced-camera-card-control-background-transparent - ); - --advanced-camera-card-message-overlay-color: var( - --advanced-camera-card-control-foreground - ); - /********* * Trigger *********/ @@ -287,23 +274,30 @@ ); /************** - * Notification + * Notification (block) **************/ - --advanced-camera-card-notification-background: linear-gradient( + --advanced-camera-card-notification-block-background: var( + --advanced-camera-card-background + ); + --advanced-camera-card-notification-block-color: var( + --advanced-camera-card-text-color + ); + + /************** + * Notification (popup) + **************/ + --advanced-camera-card-notification-popup-background: linear-gradient( 135deg, rgba(35, 35, 35, 0.75) 0%, rgba(15, 15, 15, 0.65) 100% ); - --advanced-camera-card-notification-backdrop-filter: blur(20px) saturate(180%); - --advanced-camera-card-notification-border: 1px solid rgba(255, 255, 255, 0.1); - --advanced-camera-card-notification-border-top: 1px solid rgba(255, 255, 255, 0.15); - --advanced-camera-card-notification-box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.5), + --advanced-camera-card-notification-popup-backdrop-filter: blur(20px) saturate(180%); + --advanced-camera-card-notification-popup-border: 1px solid rgba(255, 255, 255, 0.1); + --advanced-camera-card-notification-popup-border-top: 1px solid + rgba(255, 255, 255, 0.15); + --advanced-camera-card-notification-popup-box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.5), 0 2px 8px 0 rgba(0, 0, 0, 0.3), inset 0 0 0 1px rgba(255, 255, 255, 0.05); - - --advanced-camera-card-notification-text-color: #ffffff; - --advanced-camera-card-notification-icon-color: var( - --advanced-camera-card-foreground-primary - ); - --advanced-camera-card-notification-scrollbar-color: rgba(255, 255, 255, 0.2); - --advanced-camera-card-notification-close-color: #ffffff; + --advanced-camera-card-notification-popup-color: #ffffff; + --advanced-camera-card-notification-popup-scrollbar-color: rgba(255, 255, 255, 0.2); + --advanced-camera-card-notification-popup-close-color: #ffffff; } diff --git a/src/scss/themes/light.scss b/src/scss/themes/light.scss index 6dc6d16f..92dd12e7 100644 --- a/src/scss/themes/light.scss +++ b/src/scss/themes/light.scss @@ -522,20 +522,20 @@ // Menu border colors need to re-adjust based on redefined variable. --wa-color-surface-border: var(--ha-color-border-neutral-quiet); - /************** - * Notification - **************/ - --advanced-camera-card-notification-background: linear-gradient( + /********************** + * Notification (popup) + **********************/ + --advanced-camera-card-notification-popup-background: linear-gradient( 135deg, rgba(255, 255, 255, 0.85) 0%, rgba(235, 235, 235, 0.75) 100% ); - --advanced-camera-card-notification-text-color: var(--primary-text-color); - --advanced-camera-card-notification-border: 1px solid rgba(0, 0, 0, 0.3); - --advanced-camera-card-notification-border-top: 1px solid rgba(0, 0, 0, 0.2); - --advanced-camera-card-notification-box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3), + --advanced-camera-card-notification-popup-color: var(--primary-text-color); + --advanced-camera-card-notification-popup-border: 1px solid rgba(0, 0, 0, 0.3); + --advanced-camera-card-notification-popup-border-top: 1px solid rgba(0, 0, 0, 0.2); + --advanced-camera-card-notification-popup-box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3), 0 2px 8px 0 rgba(0, 0, 0, 0.2), inset 0 0 0 1px rgba(255, 255, 255, 0.8); - --advanced-camera-card-notification-scrollbar-color: rgba(0, 0, 0, 0.5); - --advanced-camera-card-notification-close-color: #000000; + --advanced-camera-card-notification-popup-scrollbar-color: rgba(0, 0, 0, 0.5); + --advanced-camera-card-notification-popup-close-color: #000000; --app-header-text-color: white; } diff --git a/src/types.ts b/src/types.ts index c70d388d..ae13bddc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,5 @@ import { z } from 'zod'; import type { EffectOptions } from './card-controller/effects/types'; -import type { Link } from './config/schema/common/link'; import type { LovelaceCard, LovelaceCardConfig, LovelaceCardEditor } from './ha/types'; // UI-facing media types for galleries and views. @@ -49,17 +48,6 @@ export interface MediaLoadedInfo { placeholder?: boolean; } -export type MessageType = 'info' | 'error' | 'connection' | 'diagnostics'; - -export interface Message { - message: string; - type?: MessageType; - icon?: string; - context?: unknown; - dotdotdot?: boolean; - link?: Link; -} - export type WebkitHTMLVideoElement = HTMLVideoElement & { webkitDisplayingFullscreen: boolean; webkitSupportsFullscreen: boolean; diff --git a/src/utils/diagnostics.ts b/src/utils/diagnostics.ts index 530f6c7f..c350dd3c 100644 --- a/src/utils/diagnostics.ts +++ b/src/utils/diagnostics.ts @@ -1,5 +1,5 @@ import pkg from '../../package.json'; -import { ProblemPresence } from '../card-controller/problems/types'; +import { IssueKey, IssuePresence } from '../card-controller/issues/types'; import { RawAdvancedCameraCardConfig } from '../config/types'; import { getIntegrationManifest } from '../ha/integration'; import { IntegrationManifest } from '../ha/integration/types'; @@ -47,7 +47,7 @@ interface Diagnostics { ha_version?: string; config?: RawAdvancedCameraCardConfig; - problems?: ProblemPresence; + issues?: IssueKey[]; custom_integrations: { frigate: IntegrationDiagnostics & { @@ -82,7 +82,7 @@ export const getDiagnostics = async ( hass?: HomeAssistant, deviceRegistryManager?: DeviceRegistryManager, rawConfig?: RawAdvancedCameraCardConfig, - problems?: ProblemPresence, + issues?: IssuePresence, ): Promise => { // Get the Frigate devices in order to extract the Frigate integration and // server version numbers. @@ -124,7 +124,7 @@ export const getDiagnostics = async ( }, hass_web_proxy: await getIntegrationDiagnostics(HASS_WEB_PROXY_DOMAIN, hass), }, - ...(problems && { problems }), + issues: issues ? [...issues.keys()] : [], ...(rawConfig && { config: rawConfig }), }; }; diff --git a/src/utils/error-context.ts b/src/utils/error-context.ts new file mode 100644 index 00000000..5cb1ccf5 --- /dev/null +++ b/src/utils/error-context.ts @@ -0,0 +1,12 @@ +import { AdvancedCameraCardError } from '../types.js'; + +// Narrows an unknown error to its structured object `context` — non-null and +// of object type — or null if the error is not an AdvancedCameraCardError or +// has no usable context. Consolidates the instanceof + typeof + null-guard +// dance that notification builders and error handlers would otherwise repeat. +export const getContextFromError = (error: unknown): object | null => + error instanceof AdvancedCameraCardError && + typeof error.context === 'object' && + error.context !== null + ? error.context + : null; diff --git a/src/utils/initializer/initializer.ts b/src/utils/initializer/initializer.ts index 34112187..95a0b0bf 100644 --- a/src/utils/initializer/initializer.ts +++ b/src/utils/initializer/initializer.ts @@ -1,6 +1,6 @@ import { allPromises } from '../basic'; -type InitializationCallback = () => Promise; +type InitializationCallback = () => Promise; /** * Manages initialization state & calling initializers. There is no guarantee @@ -12,30 +12,24 @@ export class Initializer { public async initializeMultipleIfNecessary( aspects: Record, - ): Promise { - const results = await allPromises( + ): Promise { + await allPromises( Object.entries(aspects), async ([aspect, options]) => await this.initializeIfNecessary(aspect, options), ); - return results.every(Boolean); } public async initializeIfNecessary( aspect: string, initializer?: InitializationCallback, - ): Promise { + ): Promise { if (this._initialized.has(aspect)) { - return true; + return; } - if (!initializer) { - this._initialized.add(aspect); - return true; + if (initializer) { + await initializer(); } - if (await initializer()) { - this._initialized.add(aspect); - return true; - } - return false; + this._initialized.add(aspect); } public uninitialize(aspect: string): void { diff --git a/src/utils/ptz.ts b/src/utils/ptz.ts index 92402d3d..82029d5b 100644 --- a/src/utils/ptz.ts +++ b/src/utils/ptz.ts @@ -1,9 +1,8 @@ import { CameraManager } from '../camera-manager/manager'; import { PTZAction } from '../config/schema/actions/custom/ptz'; -import { IMAGE_VIEW_ZOOM_TARGET_SENTINEL } from '../const'; import { PTZCapabilities } from '../types'; import { View } from '../view/view'; -import { getStreamCameraID } from './substream'; +import { getViewTargetID } from '../view/target-id'; export type PTZType = 'digital' | 'ptz'; interface PTZTarget { @@ -18,39 +17,29 @@ export const getPTZTarget = ( cameraManager?: CameraManager; }, ): PTZTarget | null => { + const targetID = getViewTargetID(view); + if (!targetID) { + return null; + } + if (view.isViewerView()) { - const targetID = view.queryResults?.getSelectedResult()?.getID() ?? null; - return options?.type === 'ptz' || !targetID - ? null - : { - targetID: targetID, - type: 'digital', - }; - } else if (view.is('live')) { - const substreamAwareCameraID = getStreamCameraID(view); - if (!substreamAwareCameraID) { - return null; - } + return options?.type === 'ptz' ? null : { targetID, type: 'digital' }; + } + if (view.is('image')) { + return { targetID, type: 'digital' }; + } + if (view.is('live')) { let type: PTZType = 'digital'; if (options?.type !== 'digital' && options?.cameraManager) { - if (hasCameraTruePTZ(options.cameraManager, substreamAwareCameraID)) { + if (hasCameraTruePTZ(options.cameraManager, targetID)) { type = 'ptz'; } if (type !== 'ptz' && options?.type === 'ptz') { return null; } } - - return { - targetID: substreamAwareCameraID, - type: type, - }; - } else if (view.is('image')) { - return { - targetID: IMAGE_VIEW_ZOOM_TARGET_SENTINEL, - type: 'digital', - }; + return { targetID, type }; } return null; }; diff --git a/src/view/target-id.ts b/src/view/target-id.ts new file mode 100644 index 00000000..9c244877 --- /dev/null +++ b/src/view/target-id.ts @@ -0,0 +1,27 @@ +import { getStreamCameraID } from '../utils/substream'; +import { View } from './view'; + +// Synthetic target ID used for the image view. The image view has no natural +// per-display identifier (unlike live, which has a camera ID, or the viewer, +// which has a media ID), so a sentinel string is used as its universal key in +// the target-ID namespace. Must not collide with real camera IDs or media IDs. +export const IMAGE_VIEW_TARGET_ID_SENTINEL = '__IMAGE_VIEW__'; + +// Returns a universal target identifier for the current view — the single key +// used by PTZ/zoom state and media retry epochs to identify "what is currently +// being displayed." Distinct from a raw camera ID because it accounts for +// substreams (live) and the image view sentinel. Media IDs, camera IDs, and +// the image sentinel inhabit distinct namespaces so there are no collisions +// across view types. +export const getViewTargetID = (view: View): string | null => { + if (view.isViewerView()) { + return view.queryResults?.getSelectedResult()?.getID() ?? null; + } + if (view.is('live')) { + return getStreamCameraID(view); + } + if (view.is('image')) { + return IMAGE_VIEW_TARGET_ID_SENTINEL; + } + return null; +}; diff --git a/src/view/view.ts b/src/view/view.ts index 890483b3..cf200973 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -8,6 +8,12 @@ import { UnifiedQuery } from './unified-query'; declare module 'view' { interface ViewContext { + // Per-target media retry epoch. Keyed by the universal target ID returned + // by `getViewTargetID` (camera ID / media ID / image sentinel). When bumped + // for a target, consumers rendering that target tear down and recreate + // their media element to retry loading. + mediaEpoch?: Record; + loading?: { query?: unknown; }; @@ -34,6 +40,38 @@ export const mergeViewContext = ( return merge({}, a, b); }; +const VIEWER_VIEW_NAMES: readonly AdvancedCameraCardView[] = [ + 'folder', + 'media', + 'clip', + 'snapshot', + 'recording', + 'review', +]; + +const GALLERY_VIEW_NAMES: readonly AdvancedCameraCardView[] = [ + 'clips', + 'folders', + 'gallery', + 'snapshots', + 'recordings', + 'reviews', +]; + +const FOLDER_VIEW_NAMES: readonly AdvancedCameraCardView[] = ['folder', 'folders']; + +const isViewerViewName = (view?: AdvancedCameraCardView): boolean => + !!view && VIEWER_VIEW_NAMES.includes(view); + +const isGalleryViewName = (view?: AdvancedCameraCardView): boolean => + !!view && GALLERY_VIEW_NAMES.includes(view); + +const isAnyFolderViewName = (view?: AdvancedCameraCardView): boolean => + !!view && FOLDER_VIEW_NAMES.includes(view); + +export const isAnyMediaViewName = (view?: AdvancedCameraCardView): boolean => + isViewerViewName(view) || view === 'live' || view === 'image'; + export class View { public view: AdvancedCameraCardView; public camera: string | null; @@ -126,14 +164,7 @@ export class View { * Determine if a view is a gallery. */ public isGalleryView(): boolean { - return [ - 'clips', - 'folders', - 'gallery', - 'snapshots', - 'recordings', - 'reviews', - ].includes(this.view); + return isGalleryViewName(this.view); } /** @@ -141,20 +172,18 @@ export class View { * live view, image view -- anything that can create a MediaLoadedInfo event). */ public isAnyMediaView(): boolean { - return this.isViewerView() || this.is('live') || this.is('image'); + return isAnyMediaViewName(this.view); } public isAnyFolderView(): boolean { - return ['folder', 'folders'].includes(this.view); + return isAnyFolderViewName(this.view); } /** * Determine if a view is for the media viewer. */ public isViewerView(): boolean { - return ['folder', 'media', 'clip', 'snapshot', 'recording', 'review'].includes( - this.view, - ); + return isViewerViewName(this.view); } public supportsMultipleDisplayModes(): boolean { diff --git a/tests/camera-manager/manager.test.ts b/tests/camera-manager/manager.test.ts index 13d60729..14dbb060 100644 --- a/tests/camera-manager/manager.test.ts +++ b/tests/camera-manager/manager.test.ts @@ -5,6 +5,11 @@ 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, @@ -314,7 +319,7 @@ describe('CameraManager', () => { vi.clearAllMocks(); }); - it('successfully', async () => { + it('should succeed', async () => { const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const manager = createCameraManager(api); @@ -324,14 +329,14 @@ describe('CameraManager', () => { expect(manager.isInitialized()).toBeTruthy(); }); - it('without hass', async () => { + it('should handle missing hass', async () => { const manager = createCameraManager(createCardAPI()); await manager.initializeCamerasFromConfig(); expect(manager.getStore().getCameraCount()).toBe(0); }); - it('without a config', async () => { + it('should handle missing config', async () => { const api = createCardAPI(); vi.mocked(api.getConfigManager().getConfig).mockReturnValue(null); @@ -341,7 +346,7 @@ describe('CameraManager', () => { expect(manager.getStore().getCameraCount()).toBe(0); }); - it('without cameras in config', async () => { + it('should handle missing cameras in config', async () => { const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); vi.mocked(api.getConfigManager().getConfig).mockReturnValue( @@ -356,7 +361,7 @@ describe('CameraManager', () => { expect(manager.getStore().getCameraCount()).toBe(0); }); - it('without an id', async () => { + it('should reject missing id', async () => { const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); @@ -368,17 +373,12 @@ describe('CameraManager', () => { }), }, ]); - expect(await manager.initializeCamerasFromConfig()).toBeFalsy(); - expect(api.getMessageManager().setErrorIfHigherPriority).toBeCalledWith( - new Error( - 'Could not determine camera id for the following camera, ' + - "may need to set 'id' parameter manually", - ), - 'Camera initialization failed', + await expect(manager.initializeCamerasFromConfig()).rejects.toThrow( + CameraNoIDError, ); }); - it('with a duplicate id', async () => { + it('should reject duplicate id', async () => { const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); @@ -394,17 +394,12 @@ describe('CameraManager', () => { config: cameraConfig, }, ]); - expect(await manager.initializeCamerasFromConfig()).toBeFalsy(); - expect(api.getMessageManager().setErrorIfHigherPriority).toBeCalledWith( - new Error( - 'Duplicate camera id for the following camera, ' + - "use the 'id' parameter to uniquely identify cameras", - ), - 'Camera initialization failed', + await expect(manager.initializeCamerasFromConfig()).rejects.toThrow( + CameraDuplicateIDError, ); }); - it('with no engine', async () => { + it('should reject missing engine', async () => { const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); @@ -416,10 +411,8 @@ describe('CameraManager', () => { engineType: null, }, ]); - expect(await manager.initializeCamerasFromConfig()).toBeFalsy(); - expect(api.getMessageManager().setErrorIfHigherPriority).toBeCalledWith( - new Error('Could not determine suitable engine for camera'), - 'Camera initialization failed', + await expect(manager.initializeCamerasFromConfig()).rejects.toThrow( + CameraNoEngineError, ); }); @@ -434,7 +427,7 @@ describe('CameraManager', () => { [{}], factory, ); - expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); + await manager.initializeCamerasFromConfig(); const eventCallback = factory.createEngine.mock.calls[0][1].eventCallback; const cameraEvent: CameraEvent = { @@ -447,7 +440,7 @@ describe('CameraManager', () => { }); describe('should fetch entity list when required', () => { - it('with entity based trigger', async () => { + it('should fetch with entity based trigger', async () => { const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); @@ -462,17 +455,17 @@ describe('CameraManager', () => { }, ]); - expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); + await manager.initializeCamerasFromConfig(); expect(api.getEntityRegistryManager().fetchEntityList).toBeCalled(); }); - it('without entity based trigger', async () => { + it('should skip without entity based trigger', async () => { const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const manager = createCameraManager(api); - expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); + await manager.initializeCamerasFromConfig(); expect(api.getEntityRegistryManager().fetchEntityList).not.toBeCalled(); }); }); @@ -511,7 +504,7 @@ describe('CameraManager', () => { const engine = mock(); const manager = createCameraManager(api, engine); - expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); + await manager.initializeCamerasFromConfig(); const queries = [{ type: queryType, cameraIDs: new Set(['id']) }]; engine[engineMethodName].mockReturnValue(queries); @@ -519,7 +512,7 @@ describe('CameraManager', () => { }, ); - it('without camera', async () => { + it('should handle missing camera', async () => { const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); @@ -528,13 +521,13 @@ describe('CameraManager', () => { expect(manager.generateDefaultEventQueries('not_a_camera')).toBeNull(); }); - it('without queries', async () => { + it('should handle missing queries', async () => { const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const engine = mock(); const manager = createCameraManager(api, engine); - expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); + await manager.initializeCamerasFromConfig(); engine.generateDefaultEventQuery.mockReturnValue(null); expect(manager.generateDefaultEventQueries('id')).toBeNull(); @@ -559,7 +552,7 @@ describe('CameraManager', () => { const engine = mock(); const manager = createCameraManager(api, engine); - expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); + await manager.initializeCamerasFromConfig(); engine.getDefaultQueryParameters.mockReturnValue({ what: new Set(['person']) }); expect(manager.getDefaultQueryParameters('id', QueryType.Event)).toEqual({ @@ -583,7 +576,7 @@ describe('CameraManager', () => { }), }, ]); - expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); + await manager.initializeCamerasFromConfig(); expect(manager.getStore().getCamera('id')?.getConfig().triggers.events).toEqual([ 'snapshots', ]); @@ -596,13 +589,13 @@ describe('CameraManager', () => { cameraIDs: new Set('id'), }; - it('with nothing', async () => { + it('should handle empty metadata', async () => { const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const engine = mock(); const manager = createCameraManager(api, engine); - expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); + await manager.initializeCamerasFromConfig(); const queryResults = { type: QueryResultsType.MediaMetadata as const, @@ -622,7 +615,7 @@ describe('CameraManager', () => { const engine = mock(); const manager = createCameraManager(api, engine); - expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); + await manager.initializeCamerasFromConfig(); const metadata: MediaMetadata = { [metadataType]: new Set(['data']), @@ -640,12 +633,12 @@ describe('CameraManager', () => { }); describe('should get events', () => { - it('without hass', async () => { + it('should handle missing hass', async () => { const manager = createCameraManager(createCardAPI()); expect(await manager.getEvents(baseEventQuery)).toEqual(new Map()); }); - it('without cameras', async () => { + it('should handle missing cameras', async () => { const api = createCardAPI(); const hass = createHASS(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); @@ -654,14 +647,14 @@ describe('CameraManager', () => { expect(await manager.getEvents(baseEventQuery)).toEqual(new Map()); }); - it('successfully', async () => { + it('should succeed', async () => { const api = createCardAPI(); const hass = createHASS(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); const engine = mock(); const manager = createCameraManager(api, engine); - expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); + await manager.initializeCamerasFromConfig(); const engineOptions = {}; const results = new Map([[baseEventQuery, baseEventQueryResults]]); @@ -677,7 +670,7 @@ describe('CameraManager', () => { }); describe('should review media', () => { - it('without camera', async () => { + it('should handle missing camera', async () => { const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const manager = createCameraManager(api); @@ -686,7 +679,7 @@ describe('CameraManager', () => { await manager.reviewMedia(media, true); }); - it('successfully', async () => { + it('should succeed', async () => { const api = createCardAPI(); const hass = createHASS(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); @@ -695,7 +688,7 @@ describe('CameraManager', () => { vi.mocked(engine.getEngineType).mockReturnValue(Engine.Generic); const manager = createCameraManager(api, engine); - expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); + await manager.initializeCamerasFromConfig(); const media = new TestViewMedia({ cameraID: 'id' }); await manager.reviewMedia(media, true); @@ -705,14 +698,14 @@ describe('CameraManager', () => { }); describe('should get recordings', () => { - it('successfully', async () => { + it('should succeed', async () => { const api = createCardAPI(); const hass = createHASS(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); const engine = mock(); const manager = createCameraManager(api, engine); - expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); + await manager.initializeCamerasFromConfig(); const engineOptions = {}; const results = new Map([[baseRecordingQuery, baseRecordingQueryResults]]); @@ -737,14 +730,14 @@ describe('CameraManager', () => { segments: [], }; - it('successfully', async () => { + it('should succeed', async () => { const api = createCardAPI(); const hass = createHASS(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); const engine = mock(); const manager = createCameraManager(api, engine); - expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); + await manager.initializeCamerasFromConfig(); const engineOptions = {}; const results = new Map([[query, queryResults]]); @@ -754,7 +747,7 @@ describe('CameraManager', () => { }); describe('should execute media queries', () => { - it('events', async () => { + it('should handle events', async () => { const api = createCardAPI(); const hass = createHASS(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); @@ -763,7 +756,7 @@ describe('CameraManager', () => { vi.mocked(engine.getEngineType).mockReturnValue(Engine.Generic); const manager = createCameraManager(api, engine); - expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); + await manager.initializeCamerasFromConfig(); const results = new Map([[baseEventQuery, baseEventQueryResults]]); engine.getEvents.mockResolvedValue(results); @@ -773,7 +766,7 @@ describe('CameraManager', () => { expect(await manager.executeMediaQueries([baseEventQuery])).toEqual(media); }); - it('no converted media', async () => { + it('should handle no converted media', async () => { const api = createCardAPI(); const hass = createHASS(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); @@ -782,7 +775,7 @@ describe('CameraManager', () => { vi.mocked(engine.getEngineType).mockReturnValue(Engine.Generic); const manager = createCameraManager(api, engine); - expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); + await manager.initializeCamerasFromConfig(); const results = new Map([[baseEventQuery, baseEventQueryResults]]); engine.getEvents.mockResolvedValue(results); @@ -791,7 +784,7 @@ describe('CameraManager', () => { expect(await manager.executeMediaQueries([baseEventQuery])).toEqual([]); }); - it('without matching camera engine during conversion', async () => { + it('should handle missing camera engine during conversion', async () => { const api = createCardAPI(); const hass = createHASS(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); @@ -800,7 +793,7 @@ describe('CameraManager', () => { vi.mocked(engine.getEngineType).mockReturnValue(Engine.Generic); const manager = createCameraManager(api, engine); - expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); + await manager.initializeCamerasFromConfig(); const results = new Map([ [baseEventQuery, { ...baseEventQueryResults, engine: Engine.MotionEye }], @@ -810,7 +803,7 @@ describe('CameraManager', () => { expect(await manager.executeMediaQueries([baseEventQuery])).toEqual([]); }); - it('recordings', async () => { + it('should handle recordings', async () => { const api = createCardAPI(); const hass = createHASS(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); @@ -819,7 +812,7 @@ describe('CameraManager', () => { vi.mocked(engine.getEngineType).mockReturnValue(Engine.Generic); const manager = createCameraManager(api, engine); - expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); + await manager.initializeCamerasFromConfig(); const results = new Map([[baseRecordingQuery, baseRecordingQueryResults]]); engine.getRecordings.mockResolvedValue(results); @@ -829,7 +822,7 @@ describe('CameraManager', () => { expect(await manager.executeMediaQueries([baseRecordingQuery])).toEqual(media); }); - it('without hass', async () => { + it('should handle missing hass', async () => { const engine = mock(); vi.mocked(engine.getEngineType).mockReturnValue(Engine.Generic); @@ -841,7 +834,7 @@ describe('CameraManager', () => { }); describe('should merge compatible queries', () => { - it('merges queries with identical properties', async () => { + it('should merge queries with identical properties', async () => { const api = createCardAPI(); const engine = mock(); vi.mocked(engine.getEngineType).mockReturnValue(Engine.Generic); @@ -851,7 +844,7 @@ describe('CameraManager', () => { { config: createCameraConfig({ ...baseCameraConfig, id: 'cam1' }) }, { config: createCameraConfig({ ...baseCameraConfig, id: 'cam2' }) }, ]); - expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); + await manager.initializeCamerasFromConfig(); const query1: EventQuery = { source: QuerySource.Camera, @@ -884,7 +877,7 @@ describe('CameraManager', () => { ); }); - it('does not merge queries with different properties', async () => { + it('should not merge queries with different properties', async () => { const api = createCardAPI(); const engine = mock(); vi.mocked(engine.getEngineType).mockReturnValue(Engine.Generic); @@ -894,7 +887,7 @@ describe('CameraManager', () => { { config: createCameraConfig({ ...baseCameraConfig, id: 'cam1' }) }, { config: createCameraConfig({ ...baseCameraConfig, id: 'cam2' }) }, ]); - expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); + await manager.initializeCamerasFromConfig(); const query1: EventQuery = { source: QuerySource.Camera, @@ -918,14 +911,14 @@ describe('CameraManager', () => { expect(engine.getEvents).toHaveBeenCalledTimes(2); }); - it('handles single query without merging', async () => { + it('should handle single query without merging', async () => { const api = createCardAPI(); const engine = mock(); vi.mocked(engine.getEngineType).mockReturnValue(Engine.Generic); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const manager = createCameraManager(api, engine); - expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); + await manager.initializeCamerasFromConfig(); engine.getEvents.mockResolvedValue( new Map([[baseEventQuery, baseEventQueryResults]]), @@ -961,7 +954,7 @@ describe('CameraManager', () => { new ViewFolder(createFolder(), []), ]; - it('without hass', async () => { + it('should handle missing hass', async () => { const engine = mock(); vi.mocked(engine.getEngineType).mockReturnValue(Engine.Generic); @@ -1076,7 +1069,7 @@ describe('CameraManager', () => { vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const manager = createCameraManager(api, engine); - expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); + await manager.initializeCamerasFromConfig(); engine.getEvents.mockResolvedValue(inputQueries); engine.generateMediaFromEvents.mockReturnValue(outputMediaResults); @@ -1103,28 +1096,28 @@ describe('CameraManager', () => { }); describe('should get media download path', () => { - it('without camera', async () => { + it('should handle missing camera', async () => { const manager = createCameraManager(createCardAPI()); expect(await manager.getMediaDownloadPath(new TestViewMedia())).toBeNull(); }); - it('without hass', async () => { + it('should handle missing hass', async () => { const api = createCardAPI(); const manager = createCameraManager(api); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); - expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); + await manager.initializeCamerasFromConfig(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(null); expect(await manager.getMediaDownloadPath(new TestViewMedia())).toBeNull(); }); - it('successfully', async () => { + it('should succeed', async () => { const api = createCardAPI(); const engine = mock(); const manager = createCameraManager(api, engine); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); - expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); + await manager.initializeCamerasFromConfig(); const result: Endpoint = { endpoint: 'http://localhost/path/to/media', @@ -1137,18 +1130,18 @@ describe('CameraManager', () => { }); describe('should get media capabilities', () => { - it('without camera', async () => { + it('should handle missing camera', async () => { const manager = createCameraManager(createCardAPI()); expect(manager.getMediaCapabilities(new TestViewMedia())).toBeNull(); }); - it('successfully', async () => { + it('should succeed', async () => { const api = createCardAPI(); const engine = mock(); const manager = createCameraManager(api, engine); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); - expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); + await manager.initializeCamerasFromConfig(); const result: ViewItemCapabilities = { canFavorite: false, @@ -1162,7 +1155,7 @@ describe('CameraManager', () => { }); describe('should favorite media', () => { - it('without camera', async () => { + it('should handle missing camera', async () => { const engine = mock(); const manager = createCameraManager(createCardAPI(), engine); manager.favoriteMedia(new TestViewMedia(), true); @@ -1170,7 +1163,7 @@ describe('CameraManager', () => { expect(engine.favoriteMedia).not.toBeCalled(); }); - it('successfully', async () => { + it('should succeed', async () => { const api = createCardAPI(); const engine = mock(); const manager = createCameraManager(api, engine); @@ -1178,7 +1171,7 @@ describe('CameraManager', () => { const hass = createHASS(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); - expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); + await manager.initializeCamerasFromConfig(); const media = new TestViewMedia({ cameraID: 'id' }); manager.favoriteMedia(media, true); @@ -1187,12 +1180,12 @@ describe('CameraManager', () => { }); describe('should get camera endpoints', () => { - it('without camera', () => { + it('should handle missing camera', () => { const manager = createCameraManager(createCardAPI()); expect(manager.getCameraEndpoints('BAD')).toBeNull(); }); - it('successfully', async () => { + it('should succeed', async () => { const api = createCardAPI(); const engine = mock(); const manager = createCameraManager(api, engine); @@ -1200,19 +1193,19 @@ describe('CameraManager', () => { const hass = createHASS(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); - expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); + await manager.initializeCamerasFromConfig(); expect(manager.getCameraEndpoints('id', { view: 'live' })).toBeDefined(); }); }); describe('should get camera metadata', () => { - it('without camera', () => { + it('should handle missing camera', () => { const manager = createCameraManager(createCardAPI()); expect(manager.getCameraMetadata('BAD')).toBeNull(); }); - it('successfully', async () => { + it('should succeed', async () => { const api = createCardAPI(); const engine = mock(); const manager = createCameraManager(api, engine); @@ -1220,7 +1213,7 @@ describe('CameraManager', () => { const hass = createHASS(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); - expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); + await manager.initializeCamerasFromConfig(); const result: CameraManagerCameraMetadata = { title: 'My Camera', @@ -1235,12 +1228,12 @@ describe('CameraManager', () => { }); describe('should get camera capabilities', () => { - it('without camera', () => { + it('should handle missing camera', () => { const manager = createCameraManager(createCardAPI()); expect(manager.getCameraCapabilities('BAD')).toBeNull(); }); - it('successfully', async () => { + it('should succeed', async () => { const api = createCardAPI(); const engine = mock(); const manager = createCameraManager(api, engine); @@ -1248,13 +1241,13 @@ describe('CameraManager', () => { const hass = createHASS(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); - expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); + await manager.initializeCamerasFromConfig(); expect(manager.getCameraCapabilities('id')).toEqual(createCapabilities()); }); }); describe('should get aggregate camera capabilities', () => { - it('without camera', () => { + it('should handle missing camera', () => { const manager = createCameraManager(createCardAPI()); const capabilities = manager.getAggregateCameraCapabilities(); @@ -1268,7 +1261,7 @@ describe('CameraManager', () => { expect(capabilities.has('snapshots')).toBeFalsy(); }); - it('successfully', async () => { + it('should succeed', async () => { const api = createCardAPI(); const manager = createCameraManager(api, mock(), [ { @@ -1303,7 +1296,7 @@ describe('CameraManager', () => { ]); const hass = createHASS(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); - expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); + await manager.initializeCamerasFromConfig(); const capabilities = manager.getAggregateCameraCapabilities(); @@ -1319,7 +1312,7 @@ describe('CameraManager', () => { }); describe('should execute PTZ action', () => { - it('without camera', () => { + it('should handle missing camera', () => { const engine = mock(); const manager = createCameraManager(createCardAPI(), engine); @@ -1328,7 +1321,7 @@ describe('CameraManager', () => { // No visible action. }); - it('successfully with null hass', async () => { + it('should succeed with null hass', async () => { const api = createCardAPI(); const engine = mock(); const hass = createHASS(); @@ -1348,7 +1341,7 @@ describe('CameraManager', () => { }), }, ]); - expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); + await manager.initializeCamerasFromConfig(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(null); manager.executePTZAction('another', 'left'); @@ -1358,7 +1351,7 @@ describe('CameraManager', () => { }); }); - it('successfully', async () => { + it('should succeed', async () => { const api = createCardAPI(); const engine = mock(); const hass = createHASS(); @@ -1378,7 +1371,7 @@ describe('CameraManager', () => { }), }, ]); - expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); + await manager.initializeCamerasFromConfig(); manager.executePTZAction('another', 'left'); @@ -1482,7 +1475,7 @@ describe('CameraManager', () => { }), }, ]); - expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); + await manager.initializeCamerasFromConfig(); manager.executePTZAction( 'rotated-camera', @@ -1551,7 +1544,7 @@ describe('CameraManager', () => { vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const manager = createCameraManager(api, engine); - expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); + await manager.initializeCamerasFromConfig(); engine.getQueryResultMaxAge.mockReturnValue(60); expect(manager.areMediaQueriesResultsFresh(resultsTimestamp, queries)).toBe( @@ -1586,7 +1579,7 @@ describe('CameraManager', () => { vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const manager = createCameraManager(api, engine); - expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); + await manager.initializeCamerasFromConfig(); expect( await manager.getMediaSeekTime( @@ -1598,13 +1591,13 @@ describe('CameraManager', () => { ); }); - it('successfully', async () => { + it('should succeed', async () => { const api = createCardAPI(); const engine = mock(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const manager = createCameraManager(api, engine); - expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); + await manager.initializeCamerasFromConfig(); engine.getMediaSeekTime.mockResolvedValue(42); const media = new TestViewMedia({ @@ -1622,13 +1615,13 @@ describe('CameraManager', () => { ); }); - it('handles null return value', async () => { + it('should handle null return value', async () => { const api = createCardAPI(); const engine = mock(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const manager = createCameraManager(api, engine); - expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); + await manager.initializeCamerasFromConfig(); engine.getMediaSeekTime.mockResolvedValue(null); const media = new TestViewMedia({ @@ -1646,7 +1639,7 @@ describe('CameraManager', () => { vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const manager = createCameraManager(api, engine); - expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); + await manager.initializeCamerasFromConfig(); expect(manager.getStore().getCameraCount()).toBe(1); diff --git a/tests/card-controller/actions/actions-manager.test.ts b/tests/card-controller/actions/actions-manager.test.ts index 2e25fbce..7dc74389 100644 --- a/tests/card-controller/actions/actions-manager.test.ts +++ b/tests/card-controller/actions/actions-manager.test.ts @@ -68,12 +68,14 @@ describe('ActionsManager', () => { vi.restoreAllMocks(); }); - it('should get no merged actions with a message', () => { + it('should get no merged actions with an issue', () => { const api = createCardAPI(); vi.mocked(api.getViewManager().getView).mockReturnValue( createView({ view: 'live' }), ); - vi.mocked(api.getMessageManager().hasMessage).mockReturnValue(true); + vi.mocked( + api.getIssueManager().getStateManager().hasFullCardIssue, + ).mockReturnValue(true); const manager = new ActionsManager(api); diff --git a/tests/card-controller/actions/factory.test.ts b/tests/card-controller/actions/factory.test.ts index 07243bf2..2a48c7fe 100644 --- a/tests/card-controller/actions/factory.test.ts +++ b/tests/card-controller/actions/factory.test.ts @@ -49,7 +49,7 @@ import { ActionConfig } from '../../../src/config/schema/actions/types'; // @vitest-environment jsdom describe('ActionFactory', () => { - it('mismatched card-id', () => { + it('should return null for mismatched card-id', () => { const factory = new ActionFactory(); expect( factory.createAction( @@ -130,7 +130,7 @@ describe('ActionFactory', () => { [ { advanced_camera_card_action: 'notification' as const, - notification: { text: 'test' }, + notification: { body: { text: 'test' } }, }, NotificationAction, ], diff --git a/tests/card-controller/automations-manager.test.ts b/tests/card-controller/automations-manager.test.ts index dc09fff6..aa795035 100644 --- a/tests/card-controller/automations-manager.test.ts +++ b/tests/card-controller/automations-manager.test.ts @@ -56,13 +56,15 @@ describe('AutomationsManager', () => { expect(api.getActionsManager().executeActions).not.toBeCalled(); }); - it('should do nothing with an error message present', () => { + it('should do nothing when an issue is present', () => { const api = createCardAPI(); vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true); vi.mocked(api.getInitializationManager().isInitializedMandatory).mockReturnValue( true, ); - vi.mocked(api.getMessageManager().hasErrorMessage).mockReturnValue(true); + vi.mocked( + api.getIssueManager().getStateManager().hasFullCardIssue, + ).mockReturnValue(true); const stateManager = new ConditionStateManager(); vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); @@ -159,13 +161,13 @@ describe('AutomationsManager', () => { stateManager.setState({ fullscreen: fullscreen }); - expect(api.getMessageManager().setMessageIfHigherPriority).toBeCalledWith( - expect.objectContaining({ - type: 'error', - message: - 'Too many nested automation calls, please check your configuration for loops', - }), - ); + expect(api.getNotificationManager().setNotification).toBeCalledWith({ + heading: { + text: 'Too many nested automation calls, please check your configuration for loops', + icon: 'mdi:alert', + severity: 'high', + }, + }); expect(api.getActionsManager().executeActions).toBeCalledTimes(10); }); diff --git a/tests/card-controller/config/config-manager.test.ts b/tests/card-controller/config/config-manager.test.ts index 4964a97c..7b425f8a 100644 --- a/tests/card-controller/config/config-manager.test.ts +++ b/tests/card-controller/config/config-manager.test.ts @@ -75,12 +75,12 @@ describe('ConfigManager', () => { }); describe('should handle error when', () => { - it('no input', () => { + it('should handle no input', () => { const manager = new ConfigManager(createCardAPI()); expect(() => manager.setConfig()).toThrowError(/Invalid configuration/); }); - it('invalid configuration', () => { + it('should handle invalid configuration', () => { const schemaForMock: z.ZodType = advancedCameraCardConfigSchema; const spy = vi .spyOn(schemaForMock, 'safeParse') @@ -94,14 +94,14 @@ describe('ConfigManager', () => { spy.mockRestore(); }); - it('invalid configuration with hint', () => { + it('should handle invalid configuration with hint', () => { const manager = new ConfigManager(createCardAPI()); expect(() => manager.setConfig({})).toThrowError( 'Invalid configuration: [\n "type"\n]', ); }); - it('upgradeable', () => { + it('should handle upgradeable config', () => { const manager = new ConfigManager(createCardAPI()); expect(() => manager.setConfig({ @@ -150,9 +150,9 @@ describe('ConfigManager', () => { displayMode: undefined, camera: undefined, }); + expect(api.getIssueManager().reset).toBeCalledWith('config_error'); expect(api.getMediaLoadedInfoManager().clear).toBeCalled(); expect(api.getViewManager().reset).toBeCalled(); - expect(api.getMessageManager().reset).toBeCalled(); expect(api.getAutomationsManager().addAutomations).toBeCalled(); expect(api.getStyleManager().updateFromConfig).toBeCalled(); expect(api.getCardElementManager().update).toBeCalled(); @@ -327,13 +327,14 @@ describe('ConfigManager', () => { stateManager.setState({ fullscreen: true }); expect(manager.getConfig()).not.toBeNull(); - expect(api.getMessageManager().setErrorIfHigherPriority).toBeCalledWith( - expect.objectContaining({ message: 'Invalid override configuration' }), + expect(api.getIssueManager().trigger).toBeCalledWith( + 'config_error', + expect.objectContaining({ error: expect.any(Error) }), ); }); describe('should uninitialize on override', () => { - it('cameras', () => { + it('should uninitialize cameras', () => { const api = createCardAPI(); const stateManager = new ConditionStateManager(); vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); @@ -365,7 +366,7 @@ describe('ConfigManager', () => { ); }); - it('cameras_global', () => { + it('should uninitialize cameras_global', () => { const api = createCardAPI(); const stateManager = new ConditionStateManager(); vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); @@ -397,7 +398,7 @@ describe('ConfigManager', () => { ); }); - it('live.microphone.always_connected', () => { + it('should uninitialize live.microphone.always_connected', () => { const api = createCardAPI(); const stateManager = new ConditionStateManager(); vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); diff --git a/tests/card-controller/config/load-folders.test.ts b/tests/card-controller/config/load-folders.test.ts index 3f9fa642..540751fc 100644 --- a/tests/card-controller/config/load-folders.test.ts +++ b/tests/card-controller/config/load-folders.test.ts @@ -39,6 +39,8 @@ describe('setFoldersFromConfig', () => { setFoldersFromConfig(api); - expect(api.getMessageManager().setErrorIfHigherPriority).toBeCalledWith(error); + expect(api.getIssueManager().trigger).toBeCalledWith('config_error', { + error, + }); }); }); diff --git a/tests/card-controller/config/overrides-manager.test.ts b/tests/card-controller/config/overrides-manager.test.ts index 66e40ec6..d12bd47f 100644 --- a/tests/card-controller/config/overrides-manager.test.ts +++ b/tests/card-controller/config/overrides-manager.test.ts @@ -1,6 +1,8 @@ -import { describe, expect, it, vi } from 'vitest'; +import { assert, describe, expect, it, vi } from 'vitest'; import { OverridesManager } from '../../../src/card-controller/config/overrides-manager'; +import { AdvancedCameraCardConfig } from '../../../src/config/schema/types'; import { ConditionStateManager } from '../../../src/conditions/state-manager'; +import { AdvancedCameraCardError } from '../../../src/types'; import { createConfig } from '../../test-utils'; describe('OverridesManager', () => { @@ -316,30 +318,93 @@ describe('OverridesManager', () => { }); }); - it('should throw on invalid schema', () => { - const config = createConfig({ - overrides: [ - { - conditions: [ - { - condition: 'fullscreen' as const, - fullscreen: true, - }, - ], - }, - ], + describe('should throw on invalid schema', () => { + const runInvalidOverride = ( + mutate: (config: AdvancedCameraCardConfig) => void, + ): AdvancedCameraCardError => { + const config = createConfig({ + overrides: [ + { + conditions: [ + { + condition: 'fullscreen' as const, + fullscreen: true, + }, + ], + }, + ], + }); + mutate(config); + + const stateManager = new ConditionStateManager(); + stateManager.setState({ fullscreen: true }); + + const manager = new OverridesManager(vi.fn()); + manager.set(stateManager, config.overrides); + + let thrown: unknown = null; + try { + manager.getConfig(config); + } catch (e) { + thrown = e; + } + assert(thrown instanceof AdvancedCameraCardError); + return thrown; + }; + + it('with `invalid_type` surfacing the attempted value', () => { + const error = runInvalidOverride((config) => { + assert(config.overrides); + // @ts-expect-error — intentionally invalid runtime value to trigger + // Zod's `invalid_type` issue code. + config.overrides[0].merge = 6; + }); + + expect(error.message).toMatch(/Invalid override configuration/); + expect(error.context).toMatchObject({ + failures: expect.arrayContaining([ + expect.objectContaining({ + path: expect.any(String), + expected: expect.anything(), + }), + ]), + }); }); - config.overrides![0].merge = 6 as unknown as Record; - const stateManager = new ConditionStateManager(); - stateManager.setState({ fullscreen: true }); + it('with `invalid_value` surfacing the allowed enum values', () => { + const error = runInvalidOverride((config) => { + assert(config.overrides); + config.overrides[0].set = { 'view.default': 'not_a_real_view' }; + }); - const manager = new OverridesManager(vi.fn()); - manager.set(stateManager, config.overrides); + expect(error.context).toMatchObject({ + failures: expect.arrayContaining([ + expect.objectContaining({ + path: 'view.default', + received: 'not_a_real_view', + expected: expect.arrayContaining(['live']), + }), + ]), + }); + }); - expect(() => manager.getConfig(config)).toThrowError( - /Invalid override configuration/, - ); + it('with a fallback to the issue message for unhandled codes', () => { + // Hits the ternary's fallback branch: `too_small` is neither + // invalid_value nor invalid_type, so we surface `issue.message`. + const error = runInvalidOverride((config) => { + assert(config.overrides); + config.overrides[0].set = { 'status_bar.height': -1 }; + }); + + expect(error.context).toMatchObject({ + failures: expect.arrayContaining([ + expect.objectContaining({ + path: 'status_bar.height', + expected: expect.any(String), + }), + ]), + }); + }); }); // See: https://github.com/dermotduffy/advanced-camera-card/issues/1954 diff --git a/tests/card-controller/controller.test.ts b/tests/card-controller/controller.test.ts index 97bc9939..0b86de30 100644 --- a/tests/card-controller/controller.test.ts +++ b/tests/card-controller/controller.test.ts @@ -16,14 +16,13 @@ import { FullscreenManager } from '../../src/card-controller/fullscreen/fullscre import { HASSManager } from '../../src/card-controller/hass/hass-manager'; import { InitializationManager } from '../../src/card-controller/initialization-manager'; import { InteractionManager } from '../../src/card-controller/interaction-manager'; +import { IssueManager } from '../../src/card-controller/issues/issue-manager'; import { KeyboardStateManager } from '../../src/card-controller/keyboard-state-manager'; import { MediaLoadedInfoManager } from '../../src/card-controller/media-info-manager'; import { MediaPlayerManager } from '../../src/card-controller/media-player-manager'; -import { MessageManager } from '../../src/card-controller/message-manager'; import { MicrophoneManager } from '../../src/card-controller/microphone-manager'; import { NotificationManager } from '../../src/card-controller/notification-manager'; import { PIPManager } from '../../src/card-controller/pip-manager'; -import { ProblemManager } from '../../src/card-controller/problems/manager'; import { QueryStringManager } from '../../src/card-controller/query-string-manager'; import { StatusBarItemManager } from '../../src/card-controller/status-bar-item-manager'; import { StyleManager } from '../../src/card-controller/style-manager'; @@ -53,11 +52,11 @@ vi.mock('../../src/card-controller/interaction-manager'); vi.mock('../../src/card-controller/keyboard-state-manager'); vi.mock('../../src/card-controller/media-info-manager'); vi.mock('../../src/card-controller/media-player-manager'); -vi.mock('../../src/card-controller/message-manager'); vi.mock('../../src/card-controller/microphone-manager'); vi.mock('../../src/card-controller/notification-manager'); vi.mock('../../src/card-controller/pip-manager'); -vi.mock('../../src/card-controller/problems/manager'); +vi.mock('../../src/card-controller/issues/state-manager'); +vi.mock('../../src/card-controller/issues/issue-manager'); vi.mock('../../src/card-controller/query-string-manager'); vi.mock('../../src/card-controller/status-bar-item-manager'); vi.mock('../../src/card-controller/style-manager'); @@ -102,164 +101,158 @@ describe('CardController', () => { }); describe('accessors', () => { - it('getActionsManager', () => { + it('should return getActionsManager', () => { expect(createController().getActionsManager()).toBe( vi.mocked(ActionsManager).mock.instances[0], ); }); - it('getAutomationsManager', () => { + it('should return getAutomationsManager', () => { expect(createController().getAutomationsManager()).toBe( vi.mocked(AutomationsManager).mock.instances[0], ); }); - it('getDefaultManager', () => { + it('should return getDefaultManager', () => { expect(createController().getDefaultManager()).toBe( vi.mocked(DefaultManager).mock.instances[0], ); }); - it('getCameraManager', () => { + it('should return getCameraManager', () => { expect(createController().getCameraManager()).toBe( vi.mocked(CameraManager).mock.instances[0], ); }); - it('getCameraURLManager', () => { + it('should return getCameraURLManager', () => { expect(createController().getCameraURLManager()).toBe( vi.mocked(CameraURLManager).mock.instances[0], ); }); - it('getCardElementManager', () => { + it('should return getCardElementManager', () => { expect(createController().getCardElementManager()).toBe( vi.mocked(CardElementManager).mock.instances[0], ); }); - it('ConditionStateManager', () => { + it('should return ConditionStateManager', () => { expect(createController().getConditionStateManager()).toBe( vi.mocked(ConditionStateManager).mock.instances[0], ); }); - it('getConfigElement', async () => { + it('should return getConfigElement', async () => { expect( (await CardController.getConfigElement()) instanceof AdvancedCameraCardEditor, ); }); - it('getConfigManager', () => { + it('should return getConfigManager', () => { expect(createController().getConfigManager()).toBe( vi.mocked(ConfigManager).mock.instances[0], ); }); - it('getDeviceRegistryManager', () => { + it('should return getDeviceRegistryManager', () => { expect(createController().getDeviceRegistryManager()).toBe( vi.mocked(DeviceRegistryManager).mock.instances[0], ); }); - it('getEntityRegistryManager', () => { + it('should return getEntityRegistryManager', () => { expect(createController().getEntityRegistryManager()).toBe( vi.mocked(EntityRegistryManagerLive).mock.instances[0], ); }); - it('getExpandManager', () => { + it('should return getExpandManager', () => { expect(createController().getExpandManager()).toBe( vi.mocked(ExpandManager).mock.instances[0], ); }); - it('getFoldersManager', () => { + it('should return getFoldersManager', () => { expect(createController().getFoldersManager()).toBe( vi.mocked(FoldersManager).mock.instances[0], ); }); - it('getFullscreenManager', () => { + it('should return getFullscreenManager', () => { expect(createController().getFullscreenManager()).toBe( vi.mocked(FullscreenManager).mock.instances[0], ); }); - it('getHASSManager', () => { + it('should return getHASSManager', () => { expect(createController().getHASSManager()).toBe( vi.mocked(HASSManager).mock.instances[0], ); }); - it('getInitializationManager', () => { + it('should return getInitializationManager', () => { expect(createController().getInitializationManager()).toBe( vi.mocked(InitializationManager).mock.instances[0], ); }); - it('getInteractionManager', () => { + it('should return getInteractionManager', () => { expect(createController().getInteractionManager()).toBe( vi.mocked(InteractionManager).mock.instances[0], ); }); - it('getKeyboardStateManager', () => { + it('should return getKeyboardStateManager', () => { expect(createController().getKeyboardStateManager()).toBe( vi.mocked(KeyboardStateManager).mock.instances[0], ); }); - it('getMediaLoadedInfoManager', () => { + it('should return getMediaLoadedInfoManager', () => { expect(createController().getMediaLoadedInfoManager()).toBe( vi.mocked(MediaLoadedInfoManager).mock.instances[0], ); }); - it('getMediaPlayerManager', () => { + it('should return getMediaPlayerManager', () => { expect(createController().getMediaPlayerManager()).toBe( vi.mocked(MediaPlayerManager).mock.instances[0], ); }); - it('getMessageManager', () => { - expect(createController().getMessageManager()).toBe( - vi.mocked(MessageManager).mock.instances[0], - ); - }); - - it('getNotificationManager', () => { + it('should return getNotificationManager', () => { expect(createController().getNotificationManager()).toBe( vi.mocked(NotificationManager).mock.instances[0], ); }); - it('getPIPManager', () => { + it('should return getPIPManager', () => { expect(createController().getPIPManager()).toBe( vi.mocked(PIPManager).mock.instances[0], ); }); - it('getProblemManager', () => { - expect(createController().getProblemManager()).toBe( - vi.mocked(ProblemManager).mock.instances[0], + it('should return getIssueManager', () => { + expect(createController().getIssueManager()).toBe( + vi.mocked(IssueManager).mock.instances[0], ); }); - it('getMicrophoneManager', () => { + it('should return getMicrophoneManager', () => { expect(createController().getMicrophoneManager()).toBe( vi.mocked(MicrophoneManager).mock.instances[0], ); }); - it('getResolvedMediaCache', () => { + it('should return getResolvedMediaCache', () => { expect(createController().getResolvedMediaCache()).toBe( vi.mocked(ResolvedMediaCache).mock.instances[0], ); }); describe('getStubConfig', () => { - it('with camera entities', () => { + it('should handle with camera entities', () => { expect( CardController.getStubConfig(['camera.office', 'binary_sensor.motion']), ).toEqual({ @@ -267,44 +260,44 @@ describe('CardController', () => { }); }); - it('without camera entities', () => { + it('should handle without camera entities', () => { expect(CardController.getStubConfig(['binary_sensor.motion'])).toEqual({ cameras: [{ camera_entity: 'camera.demo' }], }); }); }); - it('getQueryStringManager', () => { + it('should return getQueryStringManager', () => { expect(createController().getQueryStringManager()).toBe( vi.mocked(QueryStringManager).mock.instances[0], ); }); - it('getStatusBarItemManager', () => { + it('should return getStatusBarItemManager', () => { expect(createController().getStatusBarItemManager()).toBe( vi.mocked(StatusBarItemManager).mock.instances[0], ); }); - it('getStyleManager', () => { + it('should return getStyleManager', () => { expect(createController().getStyleManager()).toBe( vi.mocked(StyleManager).mock.instances[0], ); }); - it('getTriggersManager', () => { + it('should return getTriggersManager', () => { expect(createController().getTriggersManager()).toBe( vi.mocked(TriggersManager).mock.instances[0], ); }); - it('getViewItemManager', () => { + it('should return getViewItemManager', () => { expect(createController().getViewItemManager()).toBe( vi.mocked(ViewItemManager).mock.instances[0], ); }); - it('getViewManager', () => { + it('should return getViewManager', () => { expect(createController().getViewManager()).toBe( vi.mocked(ViewManager).mock.instances[0], ); @@ -312,7 +305,7 @@ describe('CardController', () => { }); describe('creaters ', () => { - it('createCameraManager', () => { + it('should create createCameraManager', () => { const controller = createController(); const original = controller.getCameraManager(); @@ -321,7 +314,7 @@ describe('CardController', () => { expect(controller.getCameraManager()).not.toBe(original); }); - it('createMicrophoneManager', () => { + it('should create createMicrophoneManager', () => { const controller = createController(); const original = controller.getMicrophoneManager(); @@ -332,14 +325,14 @@ describe('CardController', () => { }); describe('handlers', () => { - it('hostConnected', () => { + it('should handle hostConnected', () => { createController().hostConnected(); expect( vi.mocked(CardElementManager).mock.instances[0].elementConnected, ).toBeCalled(); }); - it('hostDisconnected', () => { + it('should handle hostDisconnected', () => { createController().hostDisconnected(); expect( vi.mocked(CardElementManager).mock.instances[0].elementDisconnected, diff --git a/tests/card-controller/hass/hass-manager.test.ts b/tests/card-controller/hass/hass-manager.test.ts index c3e4198f..e4b8ec5e 100644 --- a/tests/card-controller/hass/hass-manager.test.ts +++ b/tests/card-controller/hass/hass-manager.test.ts @@ -1,3 +1,4 @@ +import { STATE_RUNNING, STATE_STARTING } from 'home-assistant-js-websocket'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { HASSManager } from '../../../src/card-controller/hass/hass-manager'; import { StateWatcher } from '../../../src/card-controller/hass/state-watcher'; @@ -61,77 +62,26 @@ describe('HASSManager', () => { }); describe('should handle connection state change when', () => { - it('initially disconnected', () => { + it('should reinitialize cameras and view on lost → ready transition', () => { const api = createCardAPI(); const manager = new HASSManager(api); - const disconnectedHASS = createHASS(); - disconnectedHASS.connected = false; - - manager.setHASS(disconnectedHASS); - - expect(api.getMessageManager().setMessageIfHigherPriority).toBeCalledWith( - expect.objectContaining({ - message: 'Reconnecting', - icon: 'mdi:lan-disconnect', - type: 'connection', - dotdotdot: true, - }), - ); - }); - - it('disconnected', () => { - const api = createCardAPI(); - const manager = new HASSManager(api); - - manager.setHASS(createHASS()); - - const disconnectedHASS = createHASS(); - disconnectedHASS.connected = false; - manager.setHASS(disconnectedHASS); - - expect(api.getMessageManager().setMessageIfHigherPriority).toBeCalledWith( - expect.objectContaining({ - message: 'Reconnecting', - icon: 'mdi:lan-disconnect', - type: 'connection', - dotdotdot: true, - }), - ); - }); - - it('reconnected', () => { - const api = createCardAPI(); - const manager = new HASSManager(api); - - const disconnectedHASS = createHASS(); - disconnectedHASS.connected = false; - manager.setHASS(disconnectedHASS); - - const reconnectedHASS = createHASS(); - manager.setHASS(reconnectedHASS); - - expect(api.getMessageManager().resetType).toBeCalled(); - }); - - it('reconnected reinitializes cameras and view', () => { - const api = createCardAPI(); - const manager = new HASSManager(api); - - // First establish a connected state. - const connectedHASS = createHASS(); - connectedHASS.connected = true; - manager.setHASS(connectedHASS); + // First establish a fully-ready state. + const readyHASS = createHASS(); + readyHASS.connected = true; + readyHASS.config.state = STATE_RUNNING; + manager.setHASS(readyHASS); // Simulate disconnection. const disconnectedHASS = createHASS(); disconnectedHASS.connected = false; manager.setHASS(disconnectedHASS); - // Simulate reconnection. - const reconnectedHASS = createHASS(); - reconnectedHASS.connected = true; - manager.setHASS(reconnectedHASS); + // Simulate full recovery (connected AND running). + const recoveredHASS = createHASS(); + recoveredHASS.connected = true; + recoveredHASS.config.state = STATE_RUNNING; + manager.setHASS(recoveredHASS); // Cameras and view should be uninitialized so they get re-subscribed // to event sources (e.g. Frigate WebSocket events) on the next @@ -144,7 +94,87 @@ describe('HASSManager', () => { ); }); - it('hass is null', () => { + it('should reinitialize on starting → ready transition (integrations finished loading)', () => { + const api = createCardAPI(); + const manager = new HASSManager(api); + + // WebSocket reconnected but HA still booting. + const startingHASS = createHASS(); + startingHASS.connected = true; + startingHASS.config.state = STATE_STARTING; + manager.setHASS(startingHASS); + + // No reinit yet — HA isn't fully ready. + expect(api.getInitializationManager().uninitialize).not.toBeCalled(); + expect(api.getCameraManager().destroy).not.toBeCalled(); + + // HA finishes booting. + const readyHASS = createHASS(); + readyHASS.connected = true; + readyHASS.config.state = STATE_RUNNING; + manager.setHASS(readyHASS); + + expect(api.getInitializationManager().uninitialize).toBeCalledWith('cameras'); + expect(api.getCameraManager().destroy).toBeCalled(); + expect(api.getInitializationManager().uninitialize).toBeCalledWith('view'); + expect(api.getInitializationManager().uninitialize).toBeCalledWith( + 'initial-trigger', + ); + }); + + it('should not reinitialize on lost → starting transition', () => { + const api = createCardAPI(); + const manager = new HASSManager(api); + + const disconnectedHASS = createHASS(); + disconnectedHASS.connected = false; + manager.setHASS(disconnectedHASS); + + const startingHASS = createHASS(); + startingHASS.connected = true; + startingHASS.config.state = STATE_STARTING; + manager.setHASS(startingHASS); + + // WS came back but integrations still loading — wait for RUNNING. + expect(api.getInitializationManager().uninitialize).not.toBeCalled(); + expect(api.getCameraManager().destroy).not.toBeCalled(); + }); + + it('should not reinitialize on first hass set (no previous hass)', () => { + const api = createCardAPI(); + const manager = new HASSManager(api); + + const readyHASS = createHASS(); + readyHASS.connected = true; + readyHASS.config.state = STATE_RUNNING; + manager.setHASS(readyHASS); + + // First-ever hass set — there's no "previous not-ready state" to + // transition from, so the normal first-load init flow applies and we + // must not blow away cameras. + expect(api.getInitializationManager().uninitialize).not.toBeCalled(); + expect(api.getCameraManager().destroy).not.toBeCalled(); + }); + + it('should not reinitialize on ready → ready (no transition)', () => { + const api = createCardAPI(); + const manager = new HASSManager(api); + + const readyHASS = createHASS(); + readyHASS.connected = true; + readyHASS.config.state = STATE_RUNNING; + manager.setHASS(readyHASS); + + const anotherReadyHASS = createHASS(); + anotherReadyHASS.connected = true; + anotherReadyHASS.config.state = STATE_RUNNING; + manager.setHASS(anotherReadyHASS); + + expect(api.getInitializationManager().uninitialize).not.toBeCalled(); + expect(api.getCameraManager().destroy).not.toBeCalled(); + }); + + it('should not crash when hass is null', () => { const api = createCardAPI(); const manager = new HASSManager(api); const connectedHASS = createHASS(); @@ -152,23 +182,12 @@ describe('HASSManager', () => { manager.setHASS(connectedHASS); manager.setHASS(null); - - expect(api.getMessageManager().setMessageIfHigherPriority).toBeCalledWith( - expect.objectContaining({ - message: 'Reconnecting', - icon: 'mdi:lan-disconnect', - type: 'connection', - dotdotdot: true, - }), - ); - manager.setHASS(connectedHASS); - expect(api.getMessageManager().resetType).toBeCalled(); }); }); describe('should not set default view when', () => { - it('selected camera is unknown', () => { + it('should not set default view when selected camera is unknown', () => { const api = createCardAPI(); vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); vi.mocked(api.getCameraManager().getStore).mockReturnValue( @@ -199,7 +218,7 @@ describe('HASSManager', () => { expect(api.getViewManager().setViewDefault).not.toBeCalled(); }); - it('when there is card interaction', () => { + it('should not set default view when there is card interaction', () => { const api = createCardAPI(); vi.mocked(api.getConfigManager().getConfig).mockReturnValue( createConfig({ diff --git a/tests/card-controller/initialization-manager.test.ts b/tests/card-controller/initialization-manager.test.ts index fb36eb8e..c9a15d0a 100644 --- a/tests/card-controller/initialization-manager.test.ts +++ b/tests/card-controller/initialization-manager.test.ts @@ -1,3 +1,4 @@ +import { STATE_STARTING } from 'home-assistant-js-websocket'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { @@ -20,14 +21,14 @@ describe('InitializationManager', () => { }); describe('should correctly determine when mandatory initialization is required', () => { - it('without config', () => { + it('should handle without config', () => { const api = createCardAPI(); const manager = new InitializationManager(api); expect(manager.isInitializedMandatory()).toBeFalsy(); }); - it('without aspects', () => { + it('should handle without aspects', () => { const api = createCardAPI(); const manager = new InitializationManager(api); @@ -36,7 +37,7 @@ describe('InitializationManager', () => { expect(manager.isInitializedMandatory()).toBeFalsy(); }); - it('with microphone if configured', () => { + it('should handle with microphone if configured', () => { const api = createCardAPI(); const manager = new InitializationManager(api); @@ -50,24 +51,40 @@ describe('InitializationManager', () => { }); describe('should initialize mandatory', () => { - it('without hass', async () => { + it('should handle without hass', async () => { const manager = new InitializationManager(createCardAPI()); await manager.initializeMandatory(); expect(manager.wasEverInitialized()).toBeFalsy(); }); - it('without config', async () => { + it('should handle without config', async () => { const api = createCardAPI(); const manager = new InitializationManager(api); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); - vi.mocked(loadLanguages).mockResolvedValue(true); - vi.mocked(sideLoadHomeAssistantElements).mockResolvedValue(true); await manager.initializeMandatory(); expect(manager.wasEverInitialized()).toBeFalsy(); }); - it('successfully', async () => { + it('should be a no-op when hass.config.state is not RUNNING', async () => { + const api = createCardAPI(); + const hass = createHASS(); + hass.config.state = STATE_STARTING; + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); + + const initializer = mock(); + const manager = new InitializationManager(api, initializer); + + await manager.initializeMandatory(); + + expect(initializer.initializeMultipleIfNecessary).not.toBeCalled(); + expect(initializer.initializeIfNecessary).not.toBeCalled(); + expect(api.getIssueManager().trigger).not.toBeCalled(); + expect(manager.wasEverInitialized()).toBeFalsy(); + }); + + it('should succeed', async () => { const stateListener = vi.fn(); const stateMananger = new ConditionStateManager(); stateMananger.addListener(stateListener); @@ -77,17 +94,12 @@ describe('InitializationManager', () => { vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const config = createConfig(); vi.mocked(api.getConfigManager().getConfig).mockReturnValue(config); - vi.mocked(api.getMessageManager().hasMessage).mockReturnValue(false); + vi.mocked( + api.getIssueManager().getStateManager().hasFullCardIssue, + ).mockReturnValue(false); vi.mocked(api.getQueryStringManager().hasViewRelatedActionsToRun).mockReturnValue( false, ); - vi.mocked(loadLanguages).mockResolvedValue(true); - vi.mocked(sideLoadHomeAssistantElements).mockResolvedValue(true); - vi.mocked(api.getCameraManager().initializeCamerasFromConfig).mockResolvedValue( - true, - ); - vi.mocked(api.getViewManager().initialize).mockResolvedValue(true); - const manager = new InitializationManager(api); expect(manager.isInitialized(InitializationAspect.LANGUAGES)).toBeFalsy(); @@ -126,18 +138,13 @@ describe('InitializationManager', () => { expect(manager.isInitialized(InitializationAspect.INITIAL_TRIGGER)).toBeTruthy(); }); - it('successfully with microphone if configured', async () => { + it('should succeed with microphone if configured', async () => { const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); vi.mocked( api.getMicrophoneManager().shouldConnectOnInitialization, ).mockReturnValue(true); vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); - vi.mocked(loadLanguages).mockResolvedValue(true); - vi.mocked(sideLoadHomeAssistantElements).mockResolvedValue(true); - vi.mocked(api.getCameraManager().initializeCamerasFromConfig).mockResolvedValue( - true, - ); const manager = new InitializationManager(api); @@ -146,19 +153,16 @@ describe('InitializationManager', () => { expect(api.getMicrophoneManager().connect).toBeCalled(); }); - it('with message set during initialization', async () => { + it('should handle message set during initialization', async () => { const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); - vi.mocked(api.getMessageManager().hasMessage).mockReturnValue(true); + vi.mocked( + api.getIssueManager().getStateManager().hasFullCardIssue, + ).mockReturnValue(true); vi.mocked(api.getQueryStringManager().hasViewRelatedActionsToRun).mockReturnValue( false, ); - vi.mocked(loadLanguages).mockResolvedValue(true); - vi.mocked(sideLoadHomeAssistantElements).mockResolvedValue(true); - vi.mocked(api.getCameraManager().initializeCamerasFromConfig).mockResolvedValue( - true, - ); const manager = new InitializationManager(api); @@ -167,111 +171,118 @@ describe('InitializationManager', () => { expect(api.getViewManager().initialize).not.toBeCalled(); }); - it('with languages and side load elements in progress', async () => { + it('should handle languages and side load elements in progress', async () => { const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const initializer = mock(); const manager = new InitializationManager(api, initializer); - initializer.initializeMultipleIfNecessary.mockResolvedValue(false); + initializer.initializeMultipleIfNecessary.mockRejectedValue( + new Error('initialization failed'), + ); await manager.initializeMandatory(); expect(manager.wasEverInitialized()).toBeFalsy(); }); - it('with cameras in progress', async () => { + it('should handle cameras initialization failure', async () => { const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); const initializer = mock(); const manager = new InitializationManager(api, initializer); + + // First call (languages/side-load) succeeds, second (cameras) fails. initializer.initializeMultipleIfNecessary - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(false); + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('cameras failed')); await manager.initializeMandatory(); expect(manager.wasEverInitialized()).toBeFalsy(); - }); - - it('with triggers in progress', async () => { - const api = createCardAPI(); - vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); - - const initializer = mock(); - const manager = new InitializationManager(api, initializer); - initializer.initializeMultipleIfNecessary - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(true); - initializer.initializeIfNecessary - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(false); - - await manager.initializeMandatory(); - - expect(manager.wasEverInitialized()).toBeFalsy(); - }); - }); - - it('should report background initialization status', () => { - const initializer = mock(); - const manager = new InitializationManager(createCardAPI(), initializer); - - initializer.isInitialized.mockReturnValue(false); - expect(manager.isInitializedBackground()).toBe(false); - - initializer.isInitialized.mockReturnValue(true); - expect(manager.isInitializedBackground()).toBe(true); - - expect(initializer.isInitialized).toBeCalledWith(InitializationAspect.PROBLEMS); - }); - - describe('should initialize background', () => { - it('without hass', async () => { - const api = createCardAPI(); - const initializer = mock(); - const manager = new InitializationManager(api, initializer); - - await manager.initializeBackground(); - - expect(initializer.initializeIfNecessary).not.toBeCalled(); - }); - - it('successfully', async () => { - const api = createCardAPI(); - const hass = createHASS(); - vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); - - const initializer = mock(); - initializer.initializeIfNecessary.mockResolvedValue(true); - const manager = new InitializationManager(api, initializer); - - await manager.initializeBackground(); - - expect(initializer.initializeIfNecessary).toBeCalledWith( - InitializationAspect.PROBLEMS, - expect.any(Function), + expect(api.getIssueManager().trigger).toBeCalledWith( + 'initialization', + expect.objectContaining({ error: expect.any(Error) }), ); }); - it('should call detectStatic on problem manager', async () => { + it('should handle initial trigger initialization failure', async () => { const api = createCardAPI(); - const hass = createHASS(); - vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); const initializer = mock(); - initializer.initializeIfNecessary.mockImplementation(async (_aspect, callback) => { - return callback ? await callback() : true; - }); - const manager = new InitializationManager(api, initializer); - await manager.initializeBackground(); + // First initializeIfNecessary call (view) succeeds, second + // (initial_trigger) fails. + initializer.initializeIfNecessary + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('triggers failed')); - expect(api.getProblemManager().detectStatic).toBeCalledWith(hass); + await manager.initializeMandatory(); + + expect(manager.wasEverInitialized()).toBeFalsy(); + expect(api.getIssueManager().trigger).toBeCalledWith( + 'initialization', + expect.objectContaining({ error: expect.any(Error) }), + ); }); + + it('should handle VIEW initialization failure', async () => { + const api = createCardAPI(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); + + const initializer = mock(); + const manager = new InitializationManager(api, initializer); + initializer.initializeIfNecessary.mockRejectedValueOnce( + new Error('view initialization failed'), + ); + + await manager.initializeMandatory(); + + expect(manager.wasEverInitialized()).toBeFalsy(); + expect(api.getIssueManager().trigger).toBeCalledWith( + 'initialization', + expect.objectContaining({ error: expect.any(Error) }), + ); + }); + + it('should handle non-Error thrown during initialization', async () => { + const api = createCardAPI(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); + + const initializer = mock(); + const manager = new InitializationManager(api, initializer); + // Throw a non-Error to exercise the else-branch in _tryInitialize + initializer.initializeMultipleIfNecessary.mockRejectedValueOnce('string error'); + + await manager.initializeMandatory(); + + expect(manager.wasEverInitialized()).toBeFalsy(); + expect(api.getIssueManager().trigger).toBeCalledWith( + 'initialization', + expect.objectContaining({ error: 'string error' }), + ); + }); + }); + + it('should uninitialize mandatory aspects', () => { + const initializer = mock(); + const manager = new InitializationManager(createCardAPI(), initializer); + + manager.uninitializeMandatory(); + + expect(initializer.uninitialize).toBeCalledWith(InitializationAspect.CAMERAS); + expect(initializer.uninitialize).toBeCalledWith( + InitializationAspect.MICROPHONE_CONNECT, + ); + expect(initializer.uninitialize).toBeCalledWith(InitializationAspect.VIEW); + expect(initializer.uninitialize).toBeCalledWith( + InitializationAspect.INITIAL_TRIGGER, + ); }); it('should uninitialize', () => { diff --git a/tests/card-controller/interaction-manager.test.ts b/tests/card-controller/interaction-manager.test.ts index f31f38e6..dd803cce 100644 --- a/tests/card-controller/interaction-manager.test.ts +++ b/tests/card-controller/interaction-manager.test.ts @@ -4,7 +4,7 @@ import { InteractionManager } from '../../src/card-controller/interaction-manage import { createCardAPI, createConfig, createLitElement } from '../test-utils'; vi.mock('lodash-es', () => ({ - throttle: vi.fn((fn) => fn), + throttle: vi.fn((fn) => Object.assign(fn, { cancel: vi.fn() })), })); // @vitest-environment jsdom @@ -87,4 +87,33 @@ describe('InteractionManager', () => { expect(manager.hasInteraction()).toBeFalsy(); expect(element.getAttribute('interaction')).toBeNull(); }); + + it('should uninitialize', () => { + const api = createCardAPI(); + const element = createLitElement(); + vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + view: { + interaction_seconds: 10, + }, + }), + ); + const manager = new InteractionManager(api); + + vi.useFakeTimers(); + vi.setSystemTime(start); + + manager.reportInteraction(); + expect(manager.hasInteraction()).toBeTruthy(); + + manager.uninitialize(); + + // Timer should have been stopped: advancing time should not change + // interaction state. + vi.setSystemTime(add(start, { seconds: 10 })); + vi.runOnlyPendingTimers(); + + expect(manager.hasInteraction()).toBeTruthy(); + }); }); diff --git a/tests/card-controller/issues/factory.test.ts b/tests/card-controller/issues/factory.test.ts new file mode 100644 index 00000000..e29f5de5 --- /dev/null +++ b/tests/card-controller/issues/factory.test.ts @@ -0,0 +1,91 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createIssueManager } from '../../../src/card-controller/issues/factory'; +import { IssueManager } from '../../../src/card-controller/issues/issue-manager'; +import { ConditionStateManager } from '../../../src/conditions/state-manager'; +import { createCardAPI } from '../../test-utils'; + +describe('createIssueManager', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should return a IssueManager instance', () => { + const manager = createIssueManager(createCardAPI()); + expect(manager).toBeInstanceOf(IssueManager); + }); + + it('should register all expected issues', () => { + const manager = createIssueManager(createCardAPI()).getStateManager(); + + expect(manager.getIssueDescriptions()).toHaveLength(0); + + const expectedKeys = [ + 'config_error', + 'config_upgrade', + 'connection', + 'initialization', + 'legacy_resource', + 'media_query', + 'media_load', + 'view_incompatible', + ] as const; + + for (const key of expectedKeys) { + expect(() => manager.getNotification(key)).not.toThrow(); + } + }); + + it('should register issues in an order that determines priority', () => { + // Lock the registration order. The relative order of these issues + // governs full-card display priority (getFullCardIssue returns the + // first active full-card issue) and retry-loop priority. Alphabetizing + // the list in factory.ts would silently change both. Triggering in a + // scrambled order proves getIssueDescriptions reflects registration + // order, not trigger order. + const api = createCardAPI(); + const stateManager = new ConditionStateManager(); + vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); + const manager = createIssueManager(api); + + manager.trigger('media_query', { error: new Error('x') }); + manager.trigger('initialization', { error: new Error('x') }); + manager.trigger('config_error', { error: new Error('x') }); + manager.trigger('view_incompatible', { error: new Error('x') }); + + const keys = manager + .getStateManager() + .getIssueDescriptions() + .map((d) => d.key); + + expect(keys).toEqual([ + 'config_error', + 'view_incompatible', + 'initialization', + 'media_query', + ]); + }); + + it('should wire changeCallback so timer-based issues activate via evaluate', () => { + const api = createCardAPI(); + const stateManager = new ConditionStateManager(); + vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); + + const manager = createIssueManager(api); + + // Setting view starts the media_load timer (via the condition state + // listener → evaluate → detectDynamic). + stateManager.setState({ targetID: 'camera-1', view: 'live' }); + expect(manager.getStateManager().getIssuePresence().has('media_load')).toBe(false); + + // After the timeout, the changeCallback fires evaluate which + // updates the card element. + vi.advanceTimersByTime(10000); + + expect(manager.getStateManager().getIssuePresence().has('media_load')).toBe(true); + }); +}); diff --git a/tests/card-controller/issues/issue-manager.test.ts b/tests/card-controller/issues/issue-manager.test.ts new file mode 100644 index 00000000..6ee83e19 --- /dev/null +++ b/tests/card-controller/issues/issue-manager.test.ts @@ -0,0 +1,884 @@ +// @vitest-environment jsdom +import { afterEach, assert, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { CardController } from '../../../src/card-controller/controller'; +import { + IssueManager, + RETRY_EXPONENTIAL_BASE_SECONDS, + RETRY_EXPONENTIAL_MAX_SECONDS, +} from '../../../src/card-controller/issues/issue-manager'; +import { + Issue, + IssueDescription, + IssueKey, +} from '../../../src/card-controller/issues/types'; +import { ConditionStateManager } from '../../../src/conditions/state-manager'; +import { InteractionMode } from '../../../src/config/schema/view'; +import { + createCardAPI, + createConfig, + createHASS, + flushPromises, +} from '../../test-utils'; + +const DEFAULT_RETRY_SECONDS = 1; + +const createIssue = (key: IssueKey, overrides?: Partial): Issue => + mock({ + key, + hasIssue: vi.fn().mockReturnValue(false), + getIssue: vi.fn().mockReturnValue(null), + needsRetry: vi.fn().mockReturnValue(false), + ...overrides, + }); + +const createIssueDescription = ( + overrides?: Partial, +): IssueDescription => ({ + icon: 'mdi:alert', + severity: 'high', + notification: { body: { text: 'test' } }, + ...overrides, +}); + +const createRetriableSetup = (options?: { + retrySeconds?: 'auto' | number; + interactionMode?: InteractionMode; + hasInteraction?: boolean; +}): { + api: CardController; + manager: IssueManager; + issue: Issue; +} => { + const api = createCardAPI(); + vi.mocked(api.getConditionStateManager().getState).mockReturnValue({}); + + if (options?.hasInteraction !== undefined) { + vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue( + options.hasInteraction, + ); + } + + const config = createConfig(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue({ + ...config, + view: { + ...config.view, + issues: { + interaction_mode: options?.interactionMode ?? 'inactive', + retry_seconds: options?.retrySeconds ?? DEFAULT_RETRY_SECONDS, + }, + }, + }); + + const manager = new IssueManager(api); + + const issue = createIssue('media_load', { + hasIssue: vi.fn().mockReturnValueOnce(false).mockReturnValue(true), + needsRetry: vi.fn().mockReturnValue(true), + retry: vi.fn().mockReturnValue(false), + }); + manager.addIssue(issue); + + return { api, manager, issue }; +}; + +describe('IssueManager', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('should register a listener on the condition state manager on construction', () => { + const api = createCardAPI(); + const stateManager = new ConditionStateManager(); + vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); + + const manager = new IssueManager(api); + const issue = createIssue('config_error', { + detectDynamic: vi.fn(), + }); + manager.addIssue(issue); + + stateManager.setState({ view: 'live' }); + + expect(issue.detectDynamic).toBeCalled(); + }); + + describe('addIssue / getStateManager', () => { + it('should make added issues accessible via getManager', () => { + const manager = new IssueManager(createCardAPI()); + + const issue = createIssue('config_error'); + manager.addIssue(issue); + + expect(manager.getStateManager().getIssuePresence().has('config_error')).toBe( + false, + ); + }); + }); + + describe('static detection via condition-state listener', () => { + it('should run static detection when mandatory init completes', async () => { + const api = createCardAPI(); + const conditionStateManager = new ConditionStateManager(); + vi.mocked(api.getConditionStateManager).mockReturnValue(conditionStateManager); + + const manager = new IssueManager(api); + const detectStatic = vi.fn().mockResolvedValue(undefined); + const issue = createIssue('legacy_resource', { detectStatic }); + manager.addIssue(issue); + + const hass = createHASS(); + conditionStateManager.setState({ hass }); + conditionStateManager.setState({ initialized: true }); + await flushPromises(); + + expect(detectStatic).toBeCalledWith(hass); + }); + + it('should not run static detection when hass is unset', () => { + const api = createCardAPI(); + const conditionStateManager = new ConditionStateManager(); + vi.mocked(api.getConditionStateManager).mockReturnValue(conditionStateManager); + + const manager = new IssueManager(api); + const detectStatic = vi.fn().mockResolvedValue(undefined); + const issue = createIssue('legacy_resource', { detectStatic }); + manager.addIssue(issue); + + conditionStateManager.setState({ initialized: true }); + + expect(detectStatic).not.toBeCalled(); + }); + + it('should not run static detection on unrelated state changes', () => { + const api = createCardAPI(); + const conditionStateManager = new ConditionStateManager(); + vi.mocked(api.getConditionStateManager).mockReturnValue(conditionStateManager); + + const manager = new IssueManager(api); + const detectStatic = vi.fn().mockResolvedValue(undefined); + const issue = createIssue('legacy_resource', { detectStatic }); + manager.addIssue(issue); + + const hass = createHASS(); + conditionStateManager.setState({ hass }); + conditionStateManager.setState({ view: 'live' }); + + expect(detectStatic).not.toBeCalled(); + }); + }); + + describe('trigger', () => { + it('should trigger the issue and call evaluate', () => { + const api = createCardAPI(); + vi.mocked(api.getConditionStateManager().getState).mockReturnValue({}); + + const manager = new IssueManager(api); + + const issue = createIssue('config_error', { + trigger: vi.fn(), + }); + manager.addIssue(issue); + + manager.trigger('config_error', { error: new Error('cfg') }); + + expect(issue.trigger).toBeCalledWith({ error: expect.any(Error) }); + }); + + it('should update presence even when state was mutated before detectDynamic', () => { + const api = createCardAPI(); + vi.mocked(api.getConditionStateManager().getState).mockReturnValue({}); + vi.mocked(api.getConditionStateManager().setState).mockReturnValue(true); + + const manager = new IssueManager(api); + + // hasIssue returns true from the start — simulates trigger() having + // already mutated state before detectDynamic snapshots. The + // before/after check inside detectDynamic sees true→true (no + // transition), but the presence comparison against ConditionState + // must still detect the change. + const description = createIssueDescription(); + const issue = createIssue('config_error', { + hasIssue: vi.fn().mockReturnValue(true), + getIssue: vi.fn().mockReturnValue(description), + trigger: vi.fn(), + }); + manager.addIssue(issue); + + manager.trigger('config_error', { error: new Error('cfg') }); + + expect(api.getConditionStateManager().setState).toBeCalledWith({ + issues: new Map([['config_error', description]]), + }); + expect(api.getCardElementManager().update).toBeCalled(); + }); + + it('should never auto-popup on trigger — non-full-card issues surface via the status-bar icon; user clicks to open', () => { + const api = createCardAPI(); + vi.mocked(api.getConditionStateManager().getState).mockReturnValue({}); + + const manager = new IssueManager(api); + + const issue = createIssue('view_incompatible', { + hasIssue: vi.fn().mockReturnValue(true), + isFullCardIssue: vi.fn().mockReturnValue(false), + getIssue: vi.fn().mockReturnValue(createIssueDescription()), + getNotification: vi.fn().mockReturnValue({ body: { text: 'noop' } }), + trigger: vi.fn(), + }); + manager.addIssue(issue); + + manager.trigger('view_incompatible', { error: new Error('mismatch') }); + + expect(api.getNotificationManager().setNotification).not.toBeCalled(); + }); + }); + + describe('retry', () => { + it('should call retry on the manager and reset the timer', () => { + const { manager, issue } = createRetriableSetup(); + + // Start the timer via evaluate, then immediately retry. + manager.evaluate(); + manager.retry('media_load'); + + expect(issue.retry).toBeCalled(); + + // Timer should have been reset — advancing less than retrySeconds + // should not fire it again. + assert(issue.retry); + vi.mocked(issue.retry).mockClear(); + vi.advanceTimersByTime(500); + expect(issue.retry).not.toBeCalled(); + }); + + it('should force retry even when needsRetry is false', () => { + const api = createCardAPI(); + vi.mocked(api.getConditionStateManager().getState).mockReturnValue({}); + const manager = new IssueManager(api); + const issue = createIssue('media_load', { + retry: vi.fn().mockReturnValue(false), + }); + manager.addIssue(issue); + + manager.retry('media_load', true); + + expect(issue.retry).toBeCalled(); + }); + }); + + describe('evaluate', () => { + it('should update condition state and card when presence differs from state', () => { + const api = createCardAPI(); + vi.mocked(api.getConditionStateManager().getState).mockReturnValue({}); + vi.mocked(api.getConditionStateManager().setState).mockReturnValue(true); + + const manager = new IssueManager(api); + const description = createIssueDescription(); + const issue = createIssue('config_error', { + hasIssue: vi.fn().mockReturnValue(true), + getIssue: vi.fn().mockReturnValue(description), + }); + manager.addIssue(issue); + + manager.evaluate(); + + expect(api.getConditionStateManager().setState).toBeCalledWith({ + issues: new Map([['config_error', description]]), + }); + expect(api.getCardElementManager().update).toBeCalled(); + }); + + it('should sync presence to condition state without update when unchanged', () => { + const api = createCardAPI(); + vi.mocked(api.getConditionStateManager().getState).mockReturnValue({}); + + const manager = new IssueManager(api); + const issue = createIssue('config_error'); + manager.addIssue(issue); + + manager.evaluate(); + + expect(api.getConditionStateManager().setState).toBeCalledWith({ + issues: new Map(), + }); + expect(api.getCardElementManager().update).not.toBeCalled(); + }); + + it('should call update when an active issue swaps sub-states without changing the key set', () => { + // Simulates ConnectionIssue going from 'lost' to 'starting': the + // presence key set ({connection}) is identical, but the description + // value differs. Because IssuePresence is a Map, + // the condition state diff sees the value-level change and fires + // listeners — the IssueManager's own listener calls update(). + const api = createCardAPI(); + + // Real ConditionStateManager so its isEqual-based diff actually runs. + const stateManager = new ConditionStateManager(); + vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); + + const manager = new IssueManager(api); + const getIssue = vi + .fn() + .mockReturnValue( + createIssueDescription({ notification: { body: { text: 'lost' } } }), + ); + const issue = createIssue('connection', { + hasIssue: vi.fn().mockReturnValue(true), + getIssue, + }); + manager.addIssue(issue); + + manager.evaluate(); + vi.mocked(api.getCardElementManager().update).mockClear(); + + // Same key set ({connection}), different description value. + getIssue.mockReturnValue( + createIssueDescription({ notification: { body: { text: 'starting' } } }), + ); + manager.evaluate(); + + expect(api.getCardElementManager().update).toBeCalled(); + }); + + it('should not call update when content is identical across evaluations', () => { + const api = createCardAPI(); + const stateManager = new ConditionStateManager(); + vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); + + const manager = new IssueManager(api); + const issue = createIssue('connection', { + hasIssue: vi.fn().mockReturnValue(true), + getIssue: vi.fn().mockReturnValue(createIssueDescription()), + }); + manager.addIssue(issue); + + manager.evaluate(); + vi.mocked(api.getCardElementManager().update).mockClear(); + + // Re-evaluate without any change. + manager.evaluate(); + + expect(api.getCardElementManager().update).not.toBeCalled(); + }); + + it('should trigger evaluate from listener on condition state manager', () => { + const api = createCardAPI(); + const stateManager = new ConditionStateManager(); + vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); + + const manager = new IssueManager(api); + const issue = createIssue('config_error', { + hasIssue: vi.fn().mockReturnValue(true), + detectDynamic: vi.fn(), + }); + manager.addIssue(issue); + + stateManager.setState({ view: 'live' }); + + expect(issue.detectDynamic).toBeCalled(); + }); + + it('should not re-enter evaluate when setState triggers listener', () => { + const api = createCardAPI(); + const stateManager = new ConditionStateManager(); + vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); + + const manager = new IssueManager(api); + const issue = createIssue('config_error', { + hasIssue: vi.fn().mockReturnValue(true), + detectDynamic: vi.fn(), + }); + manager.addIssue(issue); + + // Calling evaluate() will call setState() on the real + // ConditionStateManager, which fires listeners synchronously. The + // reentrancy guard must prevent detectDynamic from running twice. + manager.evaluate(); + + expect(issue.detectDynamic).toBeCalledTimes(1); + }); + }); + + describe('showNotification', () => { + it('should call setNotification when a notification is available', () => { + const api = createCardAPI(); + vi.mocked(api.getConditionStateManager().getState).mockReturnValue({}); + const manager = new IssueManager(api); + + const notification = { body: { text: 'test notification' } }; + const issue = createIssue('media_query', { + getNotification: vi.fn().mockReturnValue(notification), + }); + manager.addIssue(issue); + + manager.showNotification('media_query'); + + expect(api.getNotificationManager().setNotification).toBeCalledWith(notification); + }); + + it('should not call setNotification when no notification exists for key', () => { + const manager = new IssueManager(createCardAPI()); + + manager.showNotification('initialization'); + + expect(createCardAPI().getNotificationManager().setNotification).not.toBeCalled(); + }); + }); + + describe('scheduled retries', () => { + it('should not schedule a retry when no issue wants retry', () => { + const api = createCardAPI(); + vi.mocked(api.getConditionStateManager().getState).mockReturnValue({}); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); + + const manager = new IssueManager(api); + const issue = createIssue('config_error'); + manager.addIssue(issue); + + manager.evaluate(); + vi.runAllTimers(); + + expect(api.getViewManager().setViewWithMergedContext).not.toBeCalled(); + }); + + it('should not schedule a retry when config is null', () => { + const api = createCardAPI(); + vi.mocked(api.getConditionStateManager().getState).mockReturnValue({}); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(null); + + const manager = new IssueManager(api); + + const issue = createIssue('media_load', { + hasIssue: vi.fn().mockReturnValueOnce(false).mockReturnValue(true), + needsRetry: vi.fn().mockReturnValue(true), + retry: vi.fn().mockReturnValue(false), + }); + manager.addIssue(issue); + + manager.evaluate(); + vi.runAllTimers(); + + expect(issue.retry).not.toBeCalled(); + }); + + it('should not schedule a retry when retry_seconds is 0', () => { + const { manager, issue } = createRetriableSetup({ retrySeconds: 0 }); + + manager.evaluate(); + vi.runAllTimers(); + + expect(issue.retry).not.toBeCalled(); + }); + + it('should schedule a retry when an issue wants retry and retry_seconds > 0', () => { + const { manager, issue } = createRetriableSetup({ retrySeconds: 5 }); + + manager.evaluate(); + vi.advanceTimersByTime(5000); + + expect(issue.retry).toBeCalled(); + }); + + it('should call retry on the issue when the timer fires', () => { + const { manager, issue } = createRetriableSetup(); + + manager.evaluate(); + vi.advanceTimersByTime(DEFAULT_RETRY_SECONDS * 1000); + + expect(issue.retry).toBeCalled(); + }); + + it('should not schedule a second timer if one is already running', () => { + const { manager, issue } = createRetriableSetup({ retrySeconds: 10 }); + + manager.evaluate(); + manager.evaluate(); + + vi.advanceTimersByTime(10000); + + expect(issue.retry).toBeCalledTimes(1); + }); + + it('should stop repeated timer when needsRetry becomes false', () => { + const { manager, issue } = createRetriableSetup(); + + manager.evaluate(); + vi.advanceTimersByTime(DEFAULT_RETRY_SECONDS * 1000); + expect(issue.retry).toBeCalledTimes(1); + + assert(issue.needsRetry); + vi.mocked(issue.needsRetry).mockReturnValue(false); + vi.advanceTimersByTime(DEFAULT_RETRY_SECONDS * 1000); + + expect(issue.retry).toBeCalledTimes(1); + + vi.advanceTimersByTime(5000); + expect(issue.retry).toBeCalledTimes(1); + }); + + it('should skip scheduled retry when user is interacting and mode is inactive', () => { + const { manager, issue } = createRetriableSetup({ + hasInteraction: true, + }); + + manager.evaluate(); + vi.advanceTimersByTime(DEFAULT_RETRY_SECONDS * 1000); + + expect(issue.retry).not.toBeCalled(); + }); + + it('should allow scheduled retry when user is not interacting and mode is inactive', () => { + const { manager, issue } = createRetriableSetup({ + hasInteraction: false, + }); + + manager.evaluate(); + vi.advanceTimersByTime(DEFAULT_RETRY_SECONDS * 1000); + + expect(issue.retry).toBeCalled(); + }); + + it('should allow scheduled retry when mode is all regardless of interaction', () => { + const { manager, issue } = createRetriableSetup({ + interactionMode: 'all', + hasInteraction: true, + }); + + manager.evaluate(); + vi.advanceTimersByTime(DEFAULT_RETRY_SECONDS * 1000); + + expect(issue.retry).toBeCalled(); + }); + + it('should retry on next interval after interaction ends', () => { + const { api, manager, issue } = createRetriableSetup({ + hasInteraction: true, + }); + + manager.evaluate(); + vi.advanceTimersByTime(DEFAULT_RETRY_SECONDS * 1000); + expect(issue.retry).not.toBeCalled(); + + vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(false); + vi.advanceTimersByTime(DEFAULT_RETRY_SECONDS * 1000); + expect(issue.retry).toBeCalled(); + }); + }); + + describe('auto retry (exponential backoff)', () => { + it('should schedule the first retry within the 15s–30s jitter range', () => { + // Math.random returns 0 → jitter = 0.5 → delay = base * 0.5 = 15s. + vi.spyOn(Math, 'random').mockReturnValue(0); + const { manager, issue } = createRetriableSetup({ retrySeconds: 'auto' }); + manager.evaluate(); + + vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 0.5 * 1000 - 1); + expect(issue.retry).not.toBeCalled(); + + vi.advanceTimersByTime(1); + expect(issue.retry).toBeCalledTimes(1); + }); + + it('should schedule the first retry at the upper bound when jitter is max', () => { + // Math.random returns 1 → jitter = 1.0 → delay = base * 1.0 = 30s. + vi.spyOn(Math, 'random').mockReturnValue(1); + const { manager, issue } = createRetriableSetup({ retrySeconds: 'auto' }); + manager.evaluate(); + + vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 1.0 * 1000 - 1); + expect(issue.retry).not.toBeCalled(); + + vi.advanceTimersByTime(1); + expect(issue.retry).toBeCalledTimes(1); + }); + + it('should double the base delay on each successive attempt', () => { + // Math.random returns 0.5 → jitter = 0.75 → delays: 22.5, 45, 90 seconds. + vi.spyOn(Math, 'random').mockReturnValue(0.5); + const { manager, issue } = createRetriableSetup({ retrySeconds: 'auto' }); + manager.evaluate(); + + vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 0.75 * 1000); + expect(issue.retry).toBeCalledTimes(1); + + vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 2 * 0.75 * 1000); + expect(issue.retry).toBeCalledTimes(2); + + vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 4 * 0.75 * 1000); + expect(issue.retry).toBeCalledTimes(3); + }); + + it('should cap the backoff at the max delay', () => { + // Drive 5 pre-cap attempts (30, 60, 120, 240, 480 seconds), then assert + // the 6th attempt clamps to MAX instead of the would-be 960. + vi.spyOn(Math, 'random').mockReturnValue(1); + const { manager, issue } = createRetriableSetup({ retrySeconds: 'auto' }); + manager.evaluate(); + + vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 1 * 1000); + vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 2 * 1000); + vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 4 * 1000); + vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 8 * 1000); + vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 16 * 1000); + expect(issue.retry).toBeCalledTimes(5); + + vi.advanceTimersByTime(RETRY_EXPONENTIAL_MAX_SECONDS * 1000); + expect(issue.retry).toBeCalledTimes(6); + }); + + it('should reset the attempt counter when the issue clears', () => { + vi.spyOn(Math, 'random').mockReturnValue(0.5); + const { manager, issue } = createRetriableSetup({ retrySeconds: 'auto' }); + manager.evaluate(); + + // Run two retries — second delay should be 2x the first. + vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 0.75 * 1000); + vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 2 * 0.75 * 1000); + expect(issue.retry).toBeCalledTimes(2); + + // Clear the issue: needsRetry returns false. The next timer fire sees + // it cleared and resets the attempt counter. + assert(issue.needsRetry); + vi.mocked(issue.needsRetry).mockReturnValue(false); + vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 4 * 0.75 * 1000); + expect(issue.retry).toBeCalledTimes(2); + + // Re-arm: needsRetry returns true again, evaluate to re-schedule. + vi.mocked(issue.needsRetry).mockReturnValue(true); + manager.evaluate(); + + // Next delay should be back at the base (attempt 0), not continuing + // from where we left off. + vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 0.75 * 1000); + expect(issue.retry).toBeCalledTimes(3); + }); + + it('should not grow the delay while retries are gated by user interaction', () => { + // Auto mode + interaction gating: when the timer fires while the user + // is interacting, the retry is skipped (not counted as an attempt) and + // the timer re-arms at the *same* delay, not the next exponential step. + vi.spyOn(Math, 'random').mockReturnValue(0.5); + const { api, manager, issue } = createRetriableSetup({ + retrySeconds: 'auto', + hasInteraction: true, + }); + manager.evaluate(); + + // Three gated firings — each at the base delay (22.5s with 0.75 jitter). + // If the counter were incrementing on gated fires, the second would be + // at 45s and we'd never reach it after only 22.5s. + vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 0.75 * 1000); + vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 0.75 * 1000); + vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 0.75 * 1000); + expect(issue.retry).not.toBeCalled(); + + // Clear the interaction. The next firing — still at the base delay — + // is now allowed and the retry runs. + vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(false); + vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 0.75 * 1000); + expect(issue.retry).toBeCalledTimes(1); + }); + + it('should reset the attempt counter when retries are disabled and re-enabled', () => { + // Drive auto-mode retries to push _retryAttempt > 0, then disable + // retries (retry_seconds=0) and re-enable. The next retry must fire at + // the base delay, not at the inflated delay the prior counter implies. + vi.spyOn(Math, 'random').mockReturnValue(0.5); + const { api, manager, issue } = createRetriableSetup({ retrySeconds: 'auto' }); + manager.evaluate(); + + vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 0.75 * 1000); + vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 2 * 0.75 * 1000); + expect(issue.retry).toBeCalledTimes(2); + + // Disable retries via config. + const config = createConfig(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue({ + ...config, + view: { + ...config.view, + issues: { interaction_mode: 'inactive', retry_seconds: 0 }, + }, + }); + + // Let the pending timer fire. The retry runs (#3), then evaluate sees + // retry_seconds=0 and resets _retryAttempt. + vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 4 * 0.75 * 1000); + expect(issue.retry).toBeCalledTimes(3); + + // Re-enable. + vi.mocked(api.getConfigManager().getConfig).mockReturnValue({ + ...config, + view: { + ...config.view, + issues: { interaction_mode: 'inactive', retry_seconds: 'auto' }, + }, + }); + manager.evaluate(); + + // Without the reset, _retryAttempt would be 3 here, making the next + // delay BASE * 8 * 0.75 = 180s. With the reset, it's BASE * 0.75 = 22.5s, + // so advancing only the base interval triggers the next retry. + vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 0.75 * 1000); + expect(issue.retry).toBeCalledTimes(4); + }); + }); + + describe('reset', () => { + it('should reset a specific issue and re-evaluate', () => { + const api = createCardAPI(); + vi.mocked(api.getConditionStateManager().getState).mockReturnValue({}); + + const manager = new IssueManager(api); + + const issue = createIssue('config_error', { + hasIssue: vi.fn().mockReturnValue(true), + getIssue: vi.fn().mockReturnValue(createIssueDescription()), + reset: vi.fn(), + }); + manager.addIssue(issue); + + manager.reset('config_error'); + + expect(issue.reset).toBeCalled(); + }); + + it('should skip reset when targeted key has no active issue', () => { + const api = createCardAPI(); + vi.mocked(api.getConditionStateManager().getState).mockReturnValue({}); + + const manager = new IssueManager(api); + + const issue = createIssue('config_error', { + hasIssue: vi.fn().mockReturnValue(false), + detectDynamic: vi.fn(), + reset: vi.fn(), + }); + manager.addIssue(issue); + + manager.reset('config_error'); + + expect(issue.reset).not.toBeCalled(); + expect(issue.detectDynamic).not.toBeCalled(); + }); + }); + + describe('suspend / resume', () => { + it('should stop the retry timer on suspend', () => { + const { manager, issue } = createRetriableSetup({ retrySeconds: 5 }); + + manager.evaluate(); + manager.suspend(); + + vi.advanceTimersByTime(5000); + + expect(issue.retry).not.toBeCalled(); + }); + + it('should gate evaluate while suspended', () => { + const api = createCardAPI(); + vi.mocked(api.getConditionStateManager().getState).mockReturnValue({}); + + const manager = new IssueManager(api); + const issue = createIssue('config_error', { + hasIssue: vi.fn().mockReturnValue(true), + detectDynamic: vi.fn(), + }); + manager.addIssue(issue); + + manager.suspend(); + manager.evaluate(); + + expect(issue.detectDynamic).not.toBeCalled(); + }); + + it('should preserve issue state across suspend', () => { + const api = createCardAPI(); + vi.mocked(api.getConditionStateManager().getState).mockReturnValue({}); + + const manager = new IssueManager(api); + const issue = createIssue('config_error', { + hasIssue: vi.fn().mockReturnValue(true), + getIssue: vi.fn().mockReturnValue(createIssueDescription()), + }); + manager.addIssue(issue); + + manager.suspend(); + + expect(manager.getStateManager().getIssuePresence().has('config_error')).toBe( + true, + ); + }); + + it('should resume evaluation on resume', () => { + const api = createCardAPI(); + vi.mocked(api.getConditionStateManager().getState).mockReturnValue({}); + vi.mocked(api.getConditionStateManager().setState).mockReturnValue(true); + + const manager = new IssueManager(api); + const issue = createIssue('config_error', { + hasIssue: vi.fn().mockReturnValue(true), + detectDynamic: vi.fn(), + }); + manager.addIssue(issue); + + manager.suspend(); + manager.resume(); + + expect(issue.detectDynamic).toBeCalled(); + expect(api.getCardElementManager().update).toBeCalled(); + }); + + it('should invoke Issue.suspend on timer-backed issues when suspended', () => { + const api = createCardAPI(); + vi.mocked(api.getConditionStateManager().getState).mockReturnValue({}); + + const manager = new IssueManager(api); + const issue = createIssue('media_load', { suspend: vi.fn() }); + manager.addIssue(issue); + + manager.suspend(); + + expect(issue.suspend).toBeCalled(); + }); + + it('should tolerate issues without a suspend hook', () => { + const api = createCardAPI(); + vi.mocked(api.getConditionStateManager().getState).mockReturnValue({}); + + const manager = new IssueManager(api); + // Plain Issue implementation — no optional methods installed. + const issue: Issue = { + key: 'config_error', + hasIssue: () => false, + getIssue: () => null, + }; + manager.addIssue(issue); + + // Must not throw. + manager.suspend(); + }); + }); + + describe('destroy', () => { + it('should stop the retry timer and destroy the manager', () => { + const { manager, issue } = createRetriableSetup({ retrySeconds: 5 }); + assert(issue.reset); + + manager.evaluate(); + manager.destroy(); + + vi.advanceTimersByTime(5000); + + expect(issue.retry).not.toBeCalled(); + expect(issue.reset).toBeCalled(); + }); + }); +}); diff --git a/tests/card-controller/issues/issues/config-error.test.ts b/tests/card-controller/issues/issues/config-error.test.ts new file mode 100644 index 00000000..703298e0 --- /dev/null +++ b/tests/card-controller/issues/issues/config-error.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { ConfigErrorIssue } from '../../../../src/card-controller/issues/issues/config-error'; + +describe('ConfigErrorIssue', () => { + it('should have correct key', () => { + const issue = new ConfigErrorIssue(); + expect(issue.key).toBe('config_error'); + }); + + it('should report no issue when untriggered', () => { + const issue = new ConfigErrorIssue(); + expect(issue.hasIssue()).toBe(false); + expect(issue.getIssue()).toBeNull(); + }); + + it('should report an issue after trigger with an error', () => { + const issue = new ConfigErrorIssue(); + issue.trigger({ error: new Error('bad config') }); + + expect(issue.hasIssue()).toBe(true); + }); + + it('should report an issue after trigger with a string error', () => { + const issue = new ConfigErrorIssue(); + issue.trigger({ error: 'string error' }); + + expect(issue.hasIssue()).toBe(true); + }); + + it('should treat a triggered null/undefined error as no issue', () => { + const issue = new ConfigErrorIssue(); + issue.trigger({ error: undefined }); + + expect(issue.hasIssue()).toBe(false); + expect(issue.getIssue()).toBeNull(); + }); + + it('isFullCardIssue should return true', () => { + const issue = new ConfigErrorIssue(); + expect(issue.isFullCardIssue()).toBe(true); + }); + + it('getIssue should return a IssueDescription with expected shape', () => { + const issue = new ConfigErrorIssue(); + issue.trigger({ error: new Error('config is invalid') }); + + const result = issue.getIssue(); + expect(result).toEqual( + expect.objectContaining({ + icon: 'mdi:alert', + severity: 'high', + notification: expect.objectContaining({ + body: expect.objectContaining({ + text: 'config is invalid', + }), + }), + }), + ); + }); + + it('should clear the issue after reset', () => { + const issue = new ConfigErrorIssue(); + issue.trigger({ error: new Error('oops') }); + expect(issue.hasIssue()).toBe(true); + + issue.reset(); + + expect(issue.hasIssue()).toBe(false); + expect(issue.getIssue()).toBeNull(); + }); +}); diff --git a/tests/card-controller/issues/issues/config-upgrade.test.ts b/tests/card-controller/issues/issues/config-upgrade.test.ts new file mode 100644 index 00000000..35cdb2d8 --- /dev/null +++ b/tests/card-controller/issues/issues/config-upgrade.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ConfigUpgradeIssue } from '../../../../src/card-controller/issues/issues/config-upgrade'; +import { isConfigUpgradeable } from '../../../../src/config/management'; +import { RawAdvancedCameraCardConfig } from '../../../../src/config/types'; +import { createCardAPI } from '../../../test-utils'; + +vi.mock('../../../../src/config/management.js'); + +const createAPI = (rawConfig?: RawAdvancedCameraCardConfig) => { + const api = createCardAPI(); + vi.mocked(api.getConfigManager().getRawConfig).mockReturnValue(rawConfig ?? null); + return api; +}; + +describe('ConfigUpgradeIssue', () => { + it('should have correct key', () => { + const issue = new ConfigUpgradeIssue(createAPI()); + expect(issue.key).toBe('config_upgrade'); + }); + + it('should detect upgradeable config', async () => { + vi.mocked(isConfigUpgradeable).mockReturnValue(true); + const rawConfig = { type: 'custom:frigate-card' }; + const issue = new ConfigUpgradeIssue(createAPI(rawConfig)); + + await issue.detectStatic(); + + expect(issue.hasIssue()).toBe(true); + expect(isConfigUpgradeable).toBeCalledWith(rawConfig); + }); + + it('should detect non-upgradeable config', async () => { + vi.mocked(isConfigUpgradeable).mockReturnValue(false); + const rawConfig = { type: 'custom:advanced-camera-card' }; + const issue = new ConfigUpgradeIssue(createAPI(rawConfig)); + + await issue.detectStatic(); + + expect(issue.hasIssue()).toBe(false); + }); + + it('should handle null raw config', async () => { + const issue = new ConfigUpgradeIssue(createAPI()); + + await issue.detectStatic(); + + expect(issue.hasIssue()).toBe(false); + expect(issue.getIssue()).toBeNull(); + }); + + it('should return result when upgradeable', async () => { + vi.mocked(isConfigUpgradeable).mockReturnValue(true); + const issue = new ConfigUpgradeIssue(createAPI({ type: 'custom:frigate-card' })); + + await issue.detectStatic(); + + const result = issue.getIssue(); + expect(result).toEqual( + expect.objectContaining({ + icon: 'mdi:update', + severity: 'medium', + notification: expect.objectContaining({ + heading: expect.objectContaining({ + icon: 'mdi:update', + severity: 'medium', + }), + }), + }), + ); + }); +}); diff --git a/tests/card-controller/issues/issues/connection.test.ts b/tests/card-controller/issues/issues/connection.test.ts new file mode 100644 index 00000000..3f5b8190 --- /dev/null +++ b/tests/card-controller/issues/issues/connection.test.ts @@ -0,0 +1,127 @@ +import { STATE_RUNNING, STATE_STARTING } from 'home-assistant-js-websocket'; +import { describe, expect, it } from 'vitest'; +import { ConnectionIssue } from '../../../../src/card-controller/issues/issues/connection'; +import { createHASS } from '../../../test-utils'; + +describe('ConnectionIssue', () => { + it('should have correct key', () => { + const issue = new ConnectionIssue(); + expect(issue.key).toBe('connection'); + }); + + it('should report no issue when hass has never been set', () => { + const issue = new ConnectionIssue(); + + issue.detectDynamic({}); + + expect(issue.hasIssue()).toBe(false); + expect(issue.getIssue()).toBeNull(); + }); + + it('should report a lost issue when hass is disconnected', () => { + const issue = new ConnectionIssue(); + const hass = createHASS(); + hass.connected = false; + + issue.detectDynamic({ hass }); + + expect(issue.hasIssue()).toBe(true); + expect(issue.getIssue()).toEqual( + expect.objectContaining({ + icon: 'mdi:lan-disconnect', + severity: 'high', + notification: expect.objectContaining({ + in_progress: true, + heading: expect.objectContaining({ + text: 'Connection lost', + icon: 'mdi:lan-disconnect', + severity: 'high', + }), + body: expect.objectContaining({ + text: 'Connection to Home Assistant lost', + }), + }), + }), + ); + }); + + it('should report a starting issue when hass is connected but not running', () => { + const issue = new ConnectionIssue(); + const hass = createHASS(); + hass.connected = true; + hass.config.state = STATE_STARTING; + + issue.detectDynamic({ hass }); + + expect(issue.hasIssue()).toBe(true); + expect(issue.getIssue()).toEqual( + expect.objectContaining({ + icon: 'mdi:home-assistant', + severity: 'medium', + notification: expect.objectContaining({ + in_progress: true, + heading: expect.objectContaining({ + text: 'Home Assistant is starting', + icon: 'mdi:home-assistant', + severity: 'medium', + }), + body: expect.objectContaining({ + text: 'Waiting for Home Assistant startup to complete', + }), + }), + }), + ); + }); + + it('should not report an issue when hass is connected and running', () => { + const issue = new ConnectionIssue(); + const hass = createHASS(); + hass.connected = true; + hass.config.state = STATE_RUNNING; + + issue.detectDynamic({ hass }); + + expect(issue.hasIssue()).toBe(false); + expect(issue.getIssue()).toBeNull(); + }); + + it('should clear when hass transitions lost → starting → ready', () => { + const issue = new ConnectionIssue(); + const hass = createHASS(); + + hass.connected = false; + issue.detectDynamic({ hass }); + expect(issue.hasIssue()).toBe(true); + expect(issue.getIssue()?.notification.heading?.text).toBe('Connection lost'); + + hass.connected = true; + hass.config.state = STATE_STARTING; + issue.detectDynamic({ hass }); + expect(issue.hasIssue()).toBe(true); + expect(issue.getIssue()?.notification.heading?.text).toBe( + 'Home Assistant is starting', + ); + + hass.config.state = STATE_RUNNING; + issue.detectDynamic({ hass }); + expect(issue.hasIssue()).toBe(false); + }); + + it('should return true for isFullCardIssue', () => { + const issue = new ConnectionIssue(); + expect(issue.isFullCardIssue()).toBe(true); + }); + + it('should clear the issue after reset', () => { + const issue = new ConnectionIssue(); + const hass = createHASS(); + hass.connected = false; + issue.detectDynamic({ hass }); + expect(issue.hasIssue()).toBe(true); + + issue.reset(); + + expect(issue.hasIssue()).toBe(false); + expect(issue.getIssue()).toBeNull(); + }); +}); diff --git a/tests/card-controller/issues/issues/initialization.test.ts b/tests/card-controller/issues/issues/initialization.test.ts new file mode 100644 index 00000000..1917205a --- /dev/null +++ b/tests/card-controller/issues/issues/initialization.test.ts @@ -0,0 +1,153 @@ +import { assert, describe, expect, it, vi } from 'vitest'; +import { CardController } from '../../../../src/card-controller/controller'; +import { InitializationIssue } from '../../../../src/card-controller/issues/issues/initialization'; +import { InternalCallbackActionConfig } from '../../../../src/config/schema/actions/custom/internal'; +import { createCardAPI } from '../../../test-utils'; + +describe('InitializationIssue', () => { + const createAPI = (isInitializedMandatory = false): CardController => { + const api = createCardAPI(); + vi.mocked(api.getInitializationManager().isInitializedMandatory).mockReturnValue( + isInitializedMandatory, + ); + return api; + }; + + it('should have correct key', () => { + const issue = new InitializationIssue(createAPI()); + expect(issue.key).toBe('initialization'); + }); + + it('should report no issue when untriggered', () => { + const issue = new InitializationIssue(createAPI()); + expect(issue.hasIssue()).toBe(false); + expect(issue.getIssue()).toBeNull(); + }); + + it('should report an issue after trigger', () => { + const issue = new InitializationIssue(createAPI()); + issue.trigger({ error: new Error('init failed') }); + + expect(issue.hasIssue()).toBe(true); + }); + + it('should treat a triggered null/undefined error as no issue', () => { + const issue = new InitializationIssue(createAPI()); + issue.trigger({ error: undefined }); + + expect(issue.hasIssue()).toBe(false); + expect(issue.getIssue()).toBeNull(); + expect(issue.needsRetry()).toBe(false); + }); + + it('should return true for isFullCardIssue', () => { + const issue = new InitializationIssue(createAPI()); + expect(issue.isFullCardIssue()).toBe(true); + }); + + it('should return notification from error via getIssue', () => { + const issue = new InitializationIssue(createAPI()); + issue.trigger({ error: new Error('init failed') }); + + const result = issue.getIssue(); + expect(result).toEqual( + expect.objectContaining({ + icon: 'mdi:alert', + severity: 'high', + notification: expect.objectContaining({ + heading: expect.objectContaining({ text: 'Initialization failed' }), + body: expect.objectContaining({ text: 'init failed' }), + controls: expect.arrayContaining([ + expect.objectContaining({ icon: 'mdi:refresh', dismiss: true }), + ]), + }), + }), + ); + expect(result?.notification.in_progress).toBeUndefined(); + }); + + it('should call manager.retry with the issue key from getIssue retry control callback', async () => { + const api = createCardAPI(); + const issue = new InitializationIssue(api); + issue.trigger({ error: new Error('init failed') }); + + const control = issue.getIssue()?.notification.controls?.[0]; + assert(control); + const tapAction = control.actions?.tap_action as InternalCallbackActionConfig; + await tapAction.callback(api); + + expect(api.getIssueManager().retry).toBeCalledWith('initialization', true); + }); + + describe('detectDynamic', () => { + it('should clear the issue when initialization is now mandatory', () => { + const issue = new InitializationIssue(createAPI(true)); + issue.trigger({ error: new Error('init failed') }); + expect(issue.hasIssue()).toBe(true); + + issue.detectDynamic(); + + expect(issue.hasIssue()).toBe(false); + expect(issue.getIssue()).toBeNull(); + }); + + it('should keep the issue when initialization is still not mandatory', () => { + const issue = new InitializationIssue(createAPI(false)); + issue.trigger({ error: new Error('init failed') }); + + issue.detectDynamic(); + + expect(issue.hasIssue()).toBe(true); + }); + + it('should do nothing when not failed', () => { + const api = createAPI(false); + const issue = new InitializationIssue(api); + + issue.detectDynamic(); + + expect(issue.hasIssue()).toBe(false); + expect(api.getInitializationManager().isInitializedMandatory).not.toBeCalled(); + }); + }); + + describe('needsRetry', () => { + it('should return true when failed', () => { + const issue = new InitializationIssue(createAPI()); + issue.trigger({ error: new Error('init failed') }); + expect(issue.needsRetry()).toBe(true); + }); + + it('should return false when not failed', () => { + const issue = new InitializationIssue(createAPI()); + expect(issue.needsRetry()).toBe(false); + }); + }); + + describe('retry', () => { + it('should uninitialize mandatory initialization and destroy camera manager', () => { + const api = createAPI(); + const issue = new InitializationIssue(api); + issue.trigger({ error: new Error('init failed') }); + + const result = issue.retry(); + + expect(result).toBe(false); + expect(issue.hasIssue()).toBe(false); + expect(issue.needsRetry()).toBe(false); + expect(api.getInitializationManager().uninitializeMandatory).toBeCalled(); + expect(api.getCameraManager().destroy).toBeCalled(); + }); + }); + + it('should clear the issue after reset', () => { + const issue = new InitializationIssue(createAPI()); + issue.trigger({ error: new Error('oops') }); + expect(issue.hasIssue()).toBe(true); + + issue.reset(); + + expect(issue.hasIssue()).toBe(false); + expect(issue.getIssue()).toBeNull(); + }); +}); diff --git a/tests/card-controller/problems/problems/legacy-resource.test.ts b/tests/card-controller/issues/issues/legacy-resource.test.ts similarity index 71% rename from tests/card-controller/problems/problems/legacy-resource.test.ts rename to tests/card-controller/issues/issues/legacy-resource.test.ts index 0321f040..a22c273e 100644 --- a/tests/card-controller/problems/problems/legacy-resource.test.ts +++ b/tests/card-controller/issues/issues/legacy-resource.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { LegacyResourceProblem } from '../../../../src/card-controller/problems/problems/legacy-resource'; +import { LegacyResourceIssue } from '../../../../src/card-controller/issues/issues/legacy-resource'; import { HomeAssistant } from '../../../../src/ha/types'; import { createCardAPI, createHASS, createUser } from '../../../test-utils'; @@ -11,28 +11,28 @@ const setupHASSResources = ( vi.mocked(hass.callWS).mockResolvedValue(resources); }; -describe('LegacyResourceProblem', () => { +describe('LegacyResourceIssue', () => { beforeEach(() => { vi.resetAllMocks(); }); it('should have correct key', () => { - const problem = new LegacyResourceProblem(vi.fn()); - expect(problem.key).toBe('legacy_resource'); + const issue = new LegacyResourceIssue(); + expect(issue.key).toBe('legacy_resource'); }); describe('detectStatic', () => { it('should skip non-admin users', async () => { - const problem = new LegacyResourceProblem(vi.fn()); + const issue = new LegacyResourceIssue(); const hass = createHASS(undefined, createUser({ is_admin: false })); - await problem.detectStatic(hass); + await issue.detectStatic(hass); - expect(problem.hasResult()).toBe(false); + expect(issue.hasIssue()).toBe(false); }); it('should detect legacy resource regardless of directory', async () => { - const problem = new LegacyResourceProblem(vi.fn()); + const issue = new LegacyResourceIssue(); const hass = createHASS(undefined, createUser({ is_admin: true })); setupHASSResources(hass, [ { @@ -42,13 +42,13 @@ describe('LegacyResourceProblem', () => { }, ]); - await problem.detectStatic(hass); + await issue.detectStatic(hass); - expect(problem.hasResult()).toBe(true); + expect(issue.hasIssue()).toBe(true); }); it('should not detect when only advanced-camera-card exists', async () => { - const problem = new LegacyResourceProblem(vi.fn()); + const issue = new LegacyResourceIssue(); const hass = createHASS(undefined, createUser({ is_admin: true })); setupHASSResources(hass, [ { @@ -58,45 +58,45 @@ describe('LegacyResourceProblem', () => { }, ]); - await problem.detectStatic(hass); + await issue.detectStatic(hass); - expect(problem.hasResult()).toBe(false); + expect(issue.hasIssue()).toBe(false); }); it('should handle invalid resource data', async () => { - const problem = new LegacyResourceProblem(vi.fn()); + const issue = new LegacyResourceIssue(); const hass = createHASS(undefined, createUser({ is_admin: true })); vi.mocked(hass.callWS).mockResolvedValue('not-an-array'); - await problem.detectStatic(hass); + await issue.detectStatic(hass); - expect(problem.hasResult()).toBe(false); + expect(issue.hasIssue()).toBe(false); }); it('should handle websocket failure', async () => { - const problem = new LegacyResourceProblem(vi.fn()); + const issue = new LegacyResourceIssue(); const hass = createHASS(undefined, createUser({ is_admin: true })); vi.mocked(hass.callWS).mockRejectedValue(new Error('connection lost')); - await problem.detectStatic(hass); + await issue.detectStatic(hass); - expect(problem.hasResult()).toBe(false); + expect(issue.hasIssue()).toBe(false); }); it('should handle missing user', async () => { - const problem = new LegacyResourceProblem(vi.fn()); + const issue = new LegacyResourceIssue(); const hass = createHASS(); Object.defineProperty(hass, 'user', { value: undefined }); - await problem.detectStatic(hass); + await issue.detectStatic(hass); - expect(problem.hasResult()).toBe(false); + expect(issue.hasIssue()).toBe(false); }); }); - describe('getResult', () => { + describe('getIssue', () => { it('should return controls and link when both resources exist', async () => { - const problem = new LegacyResourceProblem(vi.fn()); + const issue = new LegacyResourceIssue(); const hass = createHASS(undefined, createUser({ is_admin: true })); setupHASSResources(hass, [ { @@ -111,16 +111,16 @@ describe('LegacyResourceProblem', () => { }, ]); - await problem.detectStatic(hass); + await issue.detectStatic(hass); - const result = problem.getResult(); + const result = issue.getIssue(); expect(result).not.toBeNull(); expect(result?.notification.controls).toHaveLength(1); expect(result?.notification.link).toBeDefined(); }); it('should return link without controls when only legacy exists', async () => { - const problem = new LegacyResourceProblem(vi.fn()); + const issue = new LegacyResourceIssue(); const hass = createHASS(undefined, createUser({ is_admin: true })); setupHASSResources(hass, [ { @@ -130,24 +130,24 @@ describe('LegacyResourceProblem', () => { }, ]); - await problem.detectStatic(hass); + await issue.detectStatic(hass); - const result = problem.getResult(); + const result = issue.getIssue(); expect(result).not.toBeNull(); expect(result?.notification.link).toBeDefined(); expect(result?.notification.controls).toBeUndefined(); }); it('should return null when no result', () => { - const problem = new LegacyResourceProblem(vi.fn()); - expect(problem.getResult()).toBeNull(); + const issue = new LegacyResourceIssue(); + expect(issue.getIssue()).toBeNull(); }); }); describe('fix', () => { it('should remove legacy resources when correct resource exists', async () => { - const triggerUpdate = vi.fn(); - const problem = new LegacyResourceProblem(triggerUpdate); + const onChange = vi.fn(); + const issue = new LegacyResourceIssue(onChange); const hass = createHASS(undefined, createUser({ is_admin: true })); vi.mocked(hass.hassUrl).mockReturnValue('http://homeassistant.local:8123'); @@ -163,7 +163,7 @@ describe('LegacyResourceProblem', () => { url: '/hacsfiles/advanced-camera-card/advanced-camera-card.js', }, ]); - await problem.detectStatic(hass); + await issue.detectStatic(hass); vi.mocked(hass.callWS) .mockResolvedValueOnce(undefined) @@ -175,7 +175,7 @@ describe('LegacyResourceProblem', () => { }, ]); - const result = await problem.fix(hass); + const result = await issue.fix(hass); expect(result).toBe(true); expect(hass.callWS).toBeCalledWith( @@ -184,12 +184,12 @@ describe('LegacyResourceProblem', () => { resource_id: '1', }), ); - expect(problem.hasResult()).toBe(false); - expect(triggerUpdate).toBeCalled(); + expect(issue.hasIssue()).toBe(false); + expect(onChange).toBeCalled(); }); it('should not fix when only legacy resource exists', async () => { - const problem = new LegacyResourceProblem(vi.fn()); + const issue = new LegacyResourceIssue(); const hass = createHASS(undefined, createUser({ is_admin: true })); setupHASSResources(hass, [ { @@ -199,23 +199,23 @@ describe('LegacyResourceProblem', () => { }, ]); - await problem.detectStatic(hass); + await issue.detectStatic(hass); - const result = await problem.fix(hass); + const result = await issue.fix(hass); expect(result).toBe(false); }); it('should not fix for non-admin', async () => { - const problem = new LegacyResourceProblem(vi.fn()); + const issue = new LegacyResourceIssue(); const hass = createHASS(undefined, createUser({ is_admin: false })); - const result = await problem.fix(hass); + const result = await issue.fix(hass); expect(result).toBe(false); }); it('should return false on websocket failure during fix', async () => { - const triggerUpdate = vi.fn(); - const problem = new LegacyResourceProblem(triggerUpdate); + const onChange = vi.fn(); + const issue = new LegacyResourceIssue(onChange); const hass = createHASS(undefined, createUser({ is_admin: true })); vi.mocked(hass.hassUrl).mockReturnValue('http://homeassistant.local:8123'); vi.mocked(hass.callWS).mockResolvedValueOnce([ @@ -231,18 +231,52 @@ describe('LegacyResourceProblem', () => { }, ]); - await problem.detectStatic(hass); + await issue.detectStatic(hass); vi.mocked(hass.callWS).mockRejectedValue(new Error('connection lost')); - const result = await problem.fix(hass); + const result = await issue.fix(hass); expect(result).toBe(false); - expect(triggerUpdate).not.toBeCalled(); + expect(onChange).not.toBeCalled(); + }); + + it('should return false when the verification fetch silently fails after a successful delete', async () => { + const onChange = vi.fn(); + const issue = new LegacyResourceIssue(onChange); + const hass = createHASS(undefined, createUser({ is_admin: true })); + vi.mocked(hass.hassUrl).mockReturnValue('http://homeassistant.local:8123'); + + // Initial detection: finds one legacy resource + one correct resource. + vi.mocked(hass.callWS).mockResolvedValueOnce([ + { + id: '1', + type: 'module', + url: '/hacsfiles/frigate-hass-card/frigate-hass-card.js', + }, + { + id: '2', + type: 'module', + url: '/hacsfiles/advanced-camera-card/advanced-camera-card.js', + }, + ]); + await issue.detectStatic(hass); + + // Delete succeeds; verification fetch fails. detectStatic silently + // swallows the WS error, so the fix path must not interpret the + // absence of a positive signal as success. + vi.mocked(hass.callWS) + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('connection lost')); + + const result = await issue.fix(hass); + + expect(result).toBe(false); + expect(onChange).not.toBeCalled(); }); it('should return false when re-detection still finds legacy resource', async () => { - const triggerUpdate = vi.fn(); - const problem = new LegacyResourceProblem(triggerUpdate); + const onChange = vi.fn(); + const issue = new LegacyResourceIssue(onChange); const hass = createHASS(undefined, createUser({ is_admin: true })); vi.mocked(hass.hassUrl).mockReturnValue('http://homeassistant.local:8123'); @@ -258,7 +292,7 @@ describe('LegacyResourceProblem', () => { url: '/hacsfiles/advanced-camera-card/advanced-camera-card.js', }, ]); - await problem.detectStatic(hass); + await issue.detectStatic(hass); // Delete succeeds, but re-detection still finds the legacy resource. vi.mocked(hass.callWS) @@ -276,14 +310,14 @@ describe('LegacyResourceProblem', () => { }, ]); - const result = await problem.fix(hass); + const result = await issue.fix(hass); expect(result).toBe(false); - expect(triggerUpdate).not.toBeCalled(); + expect(onChange).not.toBeCalled(); }); it('should fix multiple legacy resources', async () => { - const problem = new LegacyResourceProblem(vi.fn()); + const issue = new LegacyResourceIssue(); const hass = createHASS(undefined, createUser({ is_admin: true })); vi.mocked(hass.hassUrl).mockReturnValue('http://homeassistant.local:8123'); vi.mocked(hass.callWS).mockResolvedValueOnce([ @@ -300,7 +334,7 @@ describe('LegacyResourceProblem', () => { }, ]); - await problem.detectStatic(hass); + await issue.detectStatic(hass); vi.mocked(hass.callWS) .mockResolvedValueOnce(undefined) @@ -313,13 +347,13 @@ describe('LegacyResourceProblem', () => { }, ]); - expect(await problem.fix(hass)).toBe(true); + expect(await issue.fix(hass)).toBe(true); }); }); describe('getResourcePath fallback', () => { it('should handle invalid URLs by stripping query string', async () => { - const problem = new LegacyResourceProblem(vi.fn()); + const issue = new LegacyResourceIssue(); const hass = createHASS(undefined, createUser({ is_admin: true })); vi.mocked(hass.hassUrl).mockReturnValue('not-a-valid-url'); vi.mocked(hass.callWS).mockResolvedValue([ @@ -330,13 +364,13 @@ describe('LegacyResourceProblem', () => { }, ]); - await problem.detectStatic(hass); + await issue.detectStatic(hass); - expect(problem.hasResult()).toBe(true); + expect(issue.hasIssue()).toBe(true); }); it('should handle invalid URLs without query string', async () => { - const problem = new LegacyResourceProblem(vi.fn()); + const issue = new LegacyResourceIssue(); const hass = createHASS(undefined, createUser({ is_admin: true })); vi.mocked(hass.hassUrl).mockReturnValue('not-a-valid-url'); vi.mocked(hass.callWS).mockResolvedValue([ @@ -347,17 +381,17 @@ describe('LegacyResourceProblem', () => { }, ]); - await problem.detectStatic(hass); + await issue.detectStatic(hass); - expect(problem.hasResult()).toBe(true); + expect(issue.hasIssue()).toBe(true); }); }); describe('callback action', () => { const getCallback = ( - problem: LegacyResourceProblem, + issue: LegacyResourceIssue, ): ((api: unknown) => Promise) | null => { - const result = problem.getResult(); + const result = issue.getIssue(); const action = result?.notification.controls?.[0]?.actions?.tap_action; if (action && 'callback' in action) { return (action as { callback: (api: unknown) => Promise }).callback; @@ -366,7 +400,7 @@ describe('LegacyResourceProblem', () => { }; it('should call fix via the notification control action', async () => { - const problem = new LegacyResourceProblem(vi.fn()); + const issue = new LegacyResourceIssue(); const hass = createHASS(undefined, createUser({ is_admin: true })); vi.mocked(hass.hassUrl).mockReturnValue('http://homeassistant.local:8123'); vi.mocked(hass.callWS).mockResolvedValueOnce([ @@ -382,9 +416,9 @@ describe('LegacyResourceProblem', () => { }, ]); - await problem.detectStatic(hass); + await issue.detectStatic(hass); - const callback = getCallback(problem); + const callback = getCallback(issue); expect(callback).toBeDefined(); const api = createCardAPI(); @@ -410,7 +444,7 @@ describe('LegacyResourceProblem', () => { }); it('should handle missing hass in callback', async () => { - const problem = new LegacyResourceProblem(vi.fn()); + const issue = new LegacyResourceIssue(); const hass = createHASS(undefined, createUser({ is_admin: true })); vi.mocked(hass.hassUrl).mockReturnValue('http://homeassistant.local:8123'); vi.mocked(hass.callWS).mockResolvedValueOnce([ @@ -426,9 +460,9 @@ describe('LegacyResourceProblem', () => { }, ]); - await problem.detectStatic(hass); + await issue.detectStatic(hass); - const callback = getCallback(problem); + const callback = getCallback(issue); expect(callback).toBeDefined(); const api = createCardAPI(); diff --git a/tests/card-controller/issues/issues/media-load.test.ts b/tests/card-controller/issues/issues/media-load.test.ts new file mode 100644 index 00000000..73766b4b --- /dev/null +++ b/tests/card-controller/issues/issues/media-load.test.ts @@ -0,0 +1,623 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { MediaLoadIssue } from '../../../../src/card-controller/issues/issues/media-load'; +import { InternalCallbackActionConfig } from '../../../../src/config/schema/actions/custom/internal'; +import { View } from '../../../../src/view/view'; +import { createCardAPI, createMediaLoadedInfo } from '../../../test-utils'; +import { IMAGE_VIEW_TARGET_ID_SENTINEL } from '../../../../src/view/target-id'; + +const createAPI = () => createCardAPI(); + +// @vitest-environment jsdom +describe('MediaLoadIssue', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should have correct key', () => { + const issue = new MediaLoadIssue(createAPI()); + expect(issue.key).toBe('media_load'); + }); + + describe('detectDynamic', () => { + it.each([ + ['live' as const], + ['clip' as const], + ['folder' as const], + ['media' as const], + ['snapshot' as const], + ['recording' as const], + ['review' as const], + ])('should start timer when view is %s and not loaded', (view) => { + const onChange = vi.fn(); + const issue = new MediaLoadIssue(createAPI(), onChange); + + issue.detectDynamic({ targetID: 'target-1', view }); + + expect(issue.hasIssue()).toBe(false); + + vi.advanceTimersByTime(10000); + + expect(issue.hasIssue()).toBe(true); + expect(onChange).toBeCalled(); + }); + + it('should not start timer when targetID is null (no provider rendering)', () => { + const issue = new MediaLoadIssue(createAPI()); + + // Media view but no targetID, e.g. viewer showing "No media to display" + // instead of mounting a provider. + issue.detectDynamic({ view: 'media' }); + + vi.advanceTimersByTime(10000); + + expect(issue.hasIssue()).toBe(false); + }); + + it('should deactivate when targetID becomes null', () => { + const issue = new MediaLoadIssue(createAPI()); + + issue.detectDynamic({ targetID: 'camera-1', view: 'live' }); + vi.advanceTimersByTime(10000); + expect(issue.hasIssue()).toBe(true); + + // Target cleared (e.g. switched to a view with no media provider). + issue.detectDynamic({ view: 'live' }); + expect(issue.hasIssue()).toBe(false); + }); + + it('should not start timer when view is not a media view', () => { + const issue = new MediaLoadIssue(createAPI()); + + issue.detectDynamic({ view: 'timeline' }); + + vi.advanceTimersByTime(10000); + + expect(issue.hasIssue()).toBe(false); + }); + + it('should not start timer when view is undefined', () => { + const issue = new MediaLoadIssue(createAPI()); + + issue.detectDynamic({}); + + vi.advanceTimersByTime(10000); + + expect(issue.hasIssue()).toBe(false); + }); + + it('should not start timer when media is loaded', () => { + const issue = new MediaLoadIssue(createAPI()); + + issue.detectDynamic({ view: 'live', mediaLoadedInfo: createMediaLoadedInfo() }); + + vi.advanceTimersByTime(10000); + + expect(issue.hasIssue()).toBe(false); + }); + + it('should clear timeout when media loads', () => { + const issue = new MediaLoadIssue(createAPI()); + + issue.detectDynamic({ targetID: 'camera-1', view: 'live' }); + vi.advanceTimersByTime(5000); + + issue.detectDynamic({ view: 'live', mediaLoadedInfo: createMediaLoadedInfo() }); + + vi.advanceTimersByTime(5000); + + expect(issue.hasIssue()).toBe(false); + }); + + it('should clear timeout when view changes to a non-media view', () => { + const issue = new MediaLoadIssue(createAPI()); + + issue.detectDynamic({ targetID: 'camera-1', view: 'live' }); + vi.advanceTimersByTime(5000); + + issue.detectDynamic({ view: 'timeline' }); + + vi.advanceTimersByTime(5000); + + expect(issue.hasIssue()).toBe(false); + }); + + it('should remain active across media views for the same target', () => { + const issue = new MediaLoadIssue(createAPI()); + + issue.detectDynamic({ targetID: 'camera-1', view: 'live' }); + vi.advanceTimersByTime(10000); + expect(issue.hasIssue()).toBe(true); + + // Same target, different media view — issue stays active. + issue.detectDynamic({ targetID: 'camera-1', view: 'clip' }); + expect(issue.hasIssue()).toBe(true); + }); + + it('should deactivate when target changes to non-errored target', () => { + const issue = new MediaLoadIssue(createAPI()); + + issue.detectDynamic({ targetID: 'camera-1', view: 'live' }); + vi.advanceTimersByTime(10000); + expect(issue.hasIssue()).toBe(true); + + // Switch to camera-2 which has no error — should deactivate and start + // a fresh timer for the new target. + issue.detectDynamic({ targetID: 'camera-2', view: 'live' }); + expect(issue.hasIssue()).toBe(false); + + // camera-2 gets its own timeout window. + vi.advanceTimersByTime(10000); + expect(issue.hasIssue()).toBe(true); + }); + + it('should stay active when target changes to errored target', () => { + const issue = new MediaLoadIssue(createAPI()); + + issue.trigger({ targetID: 'camera-1' }); + issue.trigger({ targetID: 'camera-2' }); + + issue.detectDynamic({ targetID: 'camera-1', view: 'live' }); + expect(issue.hasIssue()).toBe(true); + + // Switch to camera-2 which also has an error — should stay active. + issue.detectDynamic({ targetID: 'camera-2', view: 'live' }); + expect(issue.hasIssue()).toBe(true); + }); + + it('should clear timed-out state when media loads', () => { + const issue = new MediaLoadIssue(createAPI()); + + issue.detectDynamic({ targetID: 'camera-1', view: 'live' }); + vi.advanceTimersByTime(10000); + expect(issue.hasIssue()).toBe(true); + + issue.detectDynamic({ view: 'live', mediaLoadedInfo: createMediaLoadedInfo() }); + expect(issue.hasIssue()).toBe(false); + }); + + it('should restart timer when target changes', () => { + const onChange = vi.fn(); + const issue = new MediaLoadIssue(createAPI(), onChange); + + issue.detectDynamic({ + targetID: 'camera-1', + view: 'live', + }); + vi.advanceTimersByTime(5000); + + // Switch to camera-2: timer restarts from 0 for the new target. + issue.detectDynamic({ + targetID: 'camera-2', + view: 'live', + }); + + // 5 more seconds is not enough for the new 10s timer. + vi.advanceTimersByTime(5000); + expect(issue.hasIssue()).toBe(false); + + // Full 10s from camera-2's timer start. + vi.advanceTimersByTime(5000); + expect(issue.hasIssue()).toBe(true); + expect(onChange).toBeCalledTimes(1); + }); + + it('should not restart timer for same target while running', () => { + const onChange = vi.fn(); + const issue = new MediaLoadIssue(createAPI(), onChange); + + issue.detectDynamic({ + targetID: 'camera-1', + view: 'live', + }); + vi.advanceTimersByTime(5000); + + // Same target again: timer should continue, not restart. + issue.detectDynamic({ + targetID: 'camera-1', + view: 'live', + }); + + // 5 more seconds completes the original 10s timer. + vi.advanceTimersByTime(5000); + expect(issue.hasIssue()).toBe(true); + expect(onChange).toBeCalledTimes(1); + }); + + it('should not restart timer when targetID is undefined and matches', () => { + const onChange = vi.fn(); + const issue = new MediaLoadIssue(createAPI(), onChange); + + issue.detectDynamic({ targetID: 'camera-1', view: 'live' }); + vi.advanceTimersByTime(5000); + + // Same undefined target: timer should continue. + issue.detectDynamic({ targetID: 'camera-1', view: 'live' }); + + vi.advanceTimersByTime(5000); + expect(issue.hasIssue()).toBe(true); + expect(onChange).toBeCalledTimes(1); + }); + + it('should not restart timer if already timed out', () => { + const onChange = vi.fn(); + const issue = new MediaLoadIssue(createAPI(), onChange); + + issue.detectDynamic({ targetID: 'camera-1', view: 'live' }); + vi.advanceTimersByTime(10000); + expect(onChange).toBeCalledTimes(1); + + // Calling detectDynamic again should not restart timer. + issue.detectDynamic({ targetID: 'camera-1', view: 'live' }); + vi.advanceTimersByTime(10000); + expect(onChange).toBeCalledTimes(1); + }); + }); + + describe('trigger', () => { + it('should activate immediately when target has error and view is a media view', () => { + const issue = new MediaLoadIssue(createAPI()); + + issue.trigger({ targetID: 'camera-1' }); + issue.detectDynamic({ + targetID: 'camera-1', + view: 'live', + }); + + expect(issue.hasIssue()).toBe(true); + }); + + it('should not activate with only a trigger', () => { + const issue = new MediaLoadIssue(createAPI()); + + issue.trigger({ targetID: 'camera-1' }); + + expect(issue.hasIssue()).toBe(false); + }); + + it('should clear target error when media loads', () => { + const issue = new MediaLoadIssue(createAPI()); + + issue.trigger({ targetID: 'camera-1' }); + issue.detectDynamic({ + targetID: 'camera-1', + view: 'live', + }); + + expect(issue.hasIssue()).toBe(true); + + // Media loaded clears the error for this target. + issue.detectDynamic({ + targetID: 'camera-1', + view: 'live', + mediaLoadedInfo: createMediaLoadedInfo(), + }); + + // Target error was cleared by the successful load, so this unloaded state + // falls back to the timer (issue would not activate until after the + // timer is reached). + issue.detectDynamic({ + targetID: 'camera-1', + view: 'live', + }); + + expect(issue.hasIssue()).toBe(false); + }); + + it('should not activate for a different target', () => { + const issue = new MediaLoadIssue(createAPI()); + + issue.trigger({ targetID: 'camera-1' }); + issue.detectDynamic({ + targetID: 'camera-2', + view: 'live', + }); + + // camera-2 has no error, so it falls back to timeout behavior. + expect(issue.hasIssue()).toBe(false); + }); + }); + + describe('getNotification', () => { + it('should return notification regardless of active state', () => { + const issue = new MediaLoadIssue(createAPI()); + + const notification = issue.getNotification(); + expect(notification).toEqual( + expect.objectContaining({ + heading: expect.objectContaining({ + text: expect.any(String), + }), + link: expect.objectContaining({ + url: expect.any(String), + }), + }), + ); + }); + + it('should include metadata for errored targets', () => { + const issue = new MediaLoadIssue(createAPI()); + issue.trigger({ targetID: 'camera.office' }); + + const notification = issue.getNotification(); + expect(notification.metadata).toEqual([ + expect.objectContaining({ text: 'camera.office', icon: 'mdi:cctv' }), + ]); + }); + + it('should include the pending timer target in metadata', () => { + const issue = new MediaLoadIssue(createAPI()); + + // Start a load timer (no explicit error yet, just slow-loading). + issue.detectDynamic({ targetID: 'camera.garden', view: 'live' }); + + const notification = issue.getNotification(); + expect(notification.metadata).toEqual([ + expect.objectContaining({ text: 'camera.garden', icon: 'mdi:cctv' }), + ]); + }); + + it('should use camera title when available', () => { + const api = createAPI(); + vi.mocked(api.getCameraManager().getCameraMetadata).mockReturnValue({ + title: 'Office', + icon: { icon: 'mdi:cctv' }, + }); + const issue = new MediaLoadIssue(api); + issue.trigger({ targetID: 'camera.office' }); + + const notification = issue.getNotification(); + expect(notification.metadata).toEqual([ + expect.objectContaining({ text: 'Office' }), + ]); + }); + + it('should use localized label and image icon for the image-view sentinel', () => { + const issue = new MediaLoadIssue(createAPI()); + issue.trigger({ targetID: IMAGE_VIEW_TARGET_ID_SENTINEL }); + + const notification = issue.getNotification(); + expect(notification.metadata).toEqual([ + expect.objectContaining({ text: 'Image', icon: 'mdi:image' }), + ]); + }); + + it('should include a retry control with wired callback', async () => { + const api = createCardAPI(); + const issue = new MediaLoadIssue(api); + + const control = issue.getNotification().controls?.[0]; + expect(control).toMatchObject({ icon: 'mdi:refresh', dismiss: true }); + + const tapAction = control?.actions?.tap_action as InternalCallbackActionConfig; + await tapAction.callback(api); + + expect(api.getIssueManager().retry).toBeCalledWith('media_load', true); + }); + }); + + describe('getIssue', () => { + it('should return result when timed out', () => { + const issue = new MediaLoadIssue(createAPI()); + + issue.detectDynamic({ targetID: 'camera-1', view: 'live' }); + vi.advanceTimersByTime(10000); + + const result = issue.getIssue(); + expect(result).toEqual( + expect.objectContaining({ + icon: 'mdi:cctv-off', + severity: 'high', + notification: expect.objectContaining({ + link: expect.objectContaining({ + url: expect.any(String), + }), + }), + }), + ); + }); + + it('should return null when not timed out', () => { + const issue = new MediaLoadIssue(createAPI()); + + expect(issue.getIssue()).toBeNull(); + }); + }); + + describe('needsRetry', () => { + it('should return true when issue is active', () => { + const issue = new MediaLoadIssue(createAPI()); + + issue.detectDynamic({ targetID: 'camera-1', view: 'live' }); + vi.advanceTimersByTime(10000); + + expect(issue.needsRetry()).toBe(true); + }); + + it('should return false when issue is not active', () => { + const issue = new MediaLoadIssue(createAPI()); + + expect(issue.needsRetry()).toBe(false); + }); + }); + + describe('retry', () => { + it('should keep issue active after retry so error stays visible', () => { + const onChange = vi.fn(); + const issue = new MediaLoadIssue(createAPI(), onChange); + + issue.detectDynamic({ targetID: 'camera-1', view: 'live' }); + vi.advanceTimersByTime(10000); + expect(issue.hasIssue()).toBe(true); + + issue.retry(); + + // Issue remains active — no new 10s grace period. The error stays + // visible while the provider re-attempts loading underneath. + expect(issue.hasIssue()).toBe(true); + }); + + it('should return false when no targets have errors', () => { + const api = createAPI(); + const issue = new MediaLoadIssue(api); + + expect(issue.retry()).toBe(false); + }); + + it('should bump mediaEpoch for targets with errors and call setViewWithMergedContext', () => { + const api = createAPI(); + vi.mocked(api.getViewManager().getView).mockReturnValue(mock()); + const issue = new MediaLoadIssue(api); + + issue.trigger({ targetID: 'camera-1' }); + issue.trigger({ targetID: 'media-1' }); + + const result = issue.retry(); + + expect(result).toEqual(false); + expect(api.getViewManager().setViewWithMergedContext).toBeCalledWith({ + mediaEpoch: { 'camera-1': 1, 'media-1': 1 }, + }); + }); + + it('should bump mediaEpoch for the image-view sentinel', () => { + const api = createAPI(); + vi.mocked(api.getViewManager().getView).mockReturnValue(mock()); + const issue = new MediaLoadIssue(api); + + issue.trigger({ targetID: IMAGE_VIEW_TARGET_ID_SENTINEL }); + + const result = issue.retry(); + + expect(result).toEqual(false); + expect(api.getViewManager().setViewWithMergedContext).toBeCalledWith({ + mediaEpoch: { [IMAGE_VIEW_TARGET_ID_SENTINEL]: 1 }, + }); + }); + + it('should increment existing epoch values from current view context', () => { + const api = createAPI(); + vi.mocked(api.getViewManager().getView).mockReturnValue( + mock({ context: { mediaEpoch: { 'camera-1': 5, 'camera-2': 3 } } }), + ); + const issue = new MediaLoadIssue(api); + + issue.trigger({ targetID: 'camera-1' }); + + const result = issue.retry(); + + expect(result).toEqual(false); + expect(api.getViewManager().setViewWithMergedContext).toBeCalledWith({ + mediaEpoch: { 'camera-1': 6, 'camera-2': 3 }, + }); + }); + + it('should include pending timer target in retry', () => { + const api = createAPI(); + vi.mocked(api.getViewManager().getView).mockReturnValue(mock()); + const issue = new MediaLoadIssue(api); + + // Start the timer for camera-1 (not yet timed out). + issue.detectDynamic({ targetID: 'camera-1', view: 'live' }); + + issue.retry(); + + expect(api.getViewManager().setViewWithMergedContext).toBeCalledWith({ + mediaEpoch: { 'camera-1': 1 }, + }); + }); + + it('should keep errored targets and issue state after retry', () => { + const api = createAPI(); + vi.mocked(api.getViewManager().getView).mockReturnValue(mock()); + const issue = new MediaLoadIssue(api); + + issue.trigger({ targetID: 'camera-1' }); + issue.detectDynamic({ targetID: 'camera-1', view: 'live' }); + expect(issue.hasIssue()).toBe(true); + + issue.retry(); + + // After retry, the issue stays active and the errored target is + // preserved — no new 10s grace period. If media:loaded fires, the + // existing _handleMediaLoaded path will clear everything. + expect(issue.hasIssue()).toBe(true); + issue.detectDynamic({ targetID: 'camera-1', view: 'live' }); + expect(issue.hasIssue()).toBe(true); + }); + }); + + describe('reset', () => { + it('should stop timer', () => { + const onChange = vi.fn(); + const issue = new MediaLoadIssue(createAPI(), onChange); + + issue.detectDynamic({ targetID: 'camera-1', view: 'live' }); + issue.reset(); + + vi.advanceTimersByTime(10000); + + expect(issue.hasIssue()).toBe(false); + expect(onChange).not.toBeCalled(); + }); + }); + + describe('suspend', () => { + it('should stop the pending-load timer so it cannot mature offscreen', () => { + const onChange = vi.fn(); + const issue = new MediaLoadIssue(createAPI(), onChange); + + // Enter loading state. Timer arms but has not yet fired. + issue.detectDynamic({ targetID: 'camera-1', view: 'live' }); + vi.advanceTimersByTime(5000); + expect(issue.hasIssue()).toBe(false); + + // Card detaches: timer must stop. + issue.suspend(); + + // Full 10s later (plus margin) the timer has NOT matured — the user + // was offscreen and that time does not count against them. + vi.advanceTimersByTime(20000); + expect(issue.hasIssue()).toBe(false); + expect(onChange).not.toBeCalled(); + }); + + it('should preserve an already-active issue across suspend', () => { + const issue = new MediaLoadIssue(createAPI()); + + // Issue activates (timeout fires). + issue.detectDynamic({ targetID: 'camera-1', view: 'live' }); + vi.advanceTimersByTime(10000); + expect(issue.hasIssue()).toBe(true); + + // Card detaches — issue must remain visible on reattach. + issue.suspend(); + + expect(issue.hasIssue()).toBe(true); + }); + + it('should rearm a fresh timer window on resume via detectDynamic', () => { + const onChange = vi.fn(); + const issue = new MediaLoadIssue(createAPI(), onChange); + + issue.detectDynamic({ targetID: 'camera-1', view: 'live' }); + vi.advanceTimersByTime(5000); + issue.suspend(); + + // Reattach: the manager's resume() triggers evaluate() → detectDynamic. + // The target is still loading, so the timer arms with a fresh 10s + // window — not whatever was left when we suspended. + issue.detectDynamic({ targetID: 'camera-1', view: 'live' }); + + vi.advanceTimersByTime(9999); + expect(issue.hasIssue()).toBe(false); + vi.advanceTimersByTime(1); + expect(issue.hasIssue()).toBe(true); + expect(onChange).toBeCalled(); + }); + }); +}); diff --git a/tests/card-controller/issues/issues/media-query.test.ts b/tests/card-controller/issues/issues/media-query.test.ts new file mode 100644 index 00000000..c466dc42 --- /dev/null +++ b/tests/card-controller/issues/issues/media-query.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from 'vitest'; +import { CardController } from '../../../../src/card-controller/controller'; +import { MediaQueryIssue } from '../../../../src/card-controller/issues/issues/media-query'; +import { InternalCallbackActionConfig } from '../../../../src/config/schema/actions/custom/internal'; +import { createCardAPI } from '../../../test-utils'; + +const createIssue = (): { + issue: MediaQueryIssue; + api: CardController; +} => { + const api = createCardAPI(); + const issue = new MediaQueryIssue(api); + return { issue, api }; +}; + +describe('MediaQueryIssue', () => { + it('should have correct key', () => { + const { issue } = createIssue(); + expect(issue.key).toBe('media_query'); + }); + + it('should report no issue when untriggered', () => { + const { issue } = createIssue(); + expect(issue.hasIssue()).toBe(false); + expect(issue.getIssue()).toBeNull(); + }); + + it('should report an issue after trigger with an error', () => { + const { issue } = createIssue(); + issue.trigger({ error: new Error('query failed') }); + + expect(issue.hasIssue()).toBe(true); + }); + + it('should treat a triggered null/undefined error as no issue', () => { + const { issue } = createIssue(); + issue.trigger({ error: undefined }); + + expect(issue.hasIssue()).toBe(false); + expect(issue.getIssue()).toBeNull(); + expect(issue.needsRetry()).toBe(false); + }); + + it('should return expected shape from getIssue when triggered with an error', () => { + const { issue } = createIssue(); + issue.trigger({ error: new Error('media query failed') }); + + const result = issue.getIssue(); + expect(result).toEqual( + expect.objectContaining({ + icon: 'mdi:alert', + severity: 'high', + notification: expect.objectContaining({ + body: expect.objectContaining({ + text: 'media query failed', + }), + }), + }), + ); + }); + + describe('getNotification', () => { + it('should return null when no error is set', () => { + const { issue } = createIssue(); + + expect(issue.getNotification()).toBeNull(); + }); + + it('should return notification with retry control when triggered with an error', () => { + const { issue } = createIssue(); + issue.trigger({ error: new Error('query failed') }); + + const notification = issue.getNotification(); + expect(notification?.controls).toHaveLength(1); + expect(notification?.controls?.[0]).toMatchObject({ + icon: 'mdi:refresh', + dismiss: true, + }); + }); + + it('should call manager.retry with the issue key from retry control callback', async () => { + const { issue, api } = createIssue(); + issue.trigger({ error: new Error('query failed') }); + + const control = issue.getNotification()?.controls?.[0]; + const tapAction = control?.actions?.tap_action as InternalCallbackActionConfig; + await tapAction.callback(api); + + expect(api.getIssueManager().retry).toBeCalledWith('media_query', true); + }); + }); + + describe('needsRetry', () => { + it('should return true after trigger with an error', () => { + const { issue } = createIssue(); + issue.trigger({ error: new Error('query failed') }); + + expect(issue.needsRetry()).toBe(true); + }); + + it('should return false when not triggered', () => { + const { issue } = createIssue(); + + expect(issue.needsRetry()).toBe(false); + }); + }); + + describe('retry', () => { + it('should return requery action and clear error and needsRetry', () => { + const { issue, api } = createIssue(); + issue.trigger({ error: new Error('query failed') }); + + const result = issue.retry(); + + expect(result).toEqual(true); + expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalled(); + expect(issue.needsRetry()).toBe(false); + expect(issue.hasIssue()).toBe(false); + }); + + it('should return null when needsRetry is false', () => { + const { issue } = createIssue(); + + const result = issue.retry(); + + expect(result).toBe(false); + }); + }); + + it('should clear the issue after reset', () => { + const { issue } = createIssue(); + issue.trigger({ error: new Error('oops') }); + expect(issue.hasIssue()).toBe(true); + + issue.reset(); + + expect(issue.hasIssue()).toBe(false); + expect(issue.getIssue()).toBeNull(); + }); + + it('should clear needsRetry after reset', () => { + const { issue } = createIssue(); + issue.trigger({ error: new Error('oops') }); + expect(issue.needsRetry()).toBe(true); + + issue.reset(); + + expect(issue.needsRetry()).toBe(false); + }); +}); diff --git a/tests/card-controller/issues/issues/view-incompatible.test.ts b/tests/card-controller/issues/issues/view-incompatible.test.ts new file mode 100644 index 00000000..1ab0a18f --- /dev/null +++ b/tests/card-controller/issues/issues/view-incompatible.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { CardController } from '../../../../src/card-controller/controller'; +import { ViewIncompatibleIssue } from '../../../../src/card-controller/issues/issues/view-incompatible'; +import { AdvancedCameraCardError } from '../../../../src/types'; +import { View } from '../../../../src/view/view'; +import { createCardAPI } from '../../../test-utils'; + +describe('ViewIncompatibleIssue', () => { + const createAPI = (hasView = false): CardController => { + const api = createCardAPI(); + vi.mocked(api.getViewManager().getView).mockReturnValue( + hasView ? mock() : null, + ); + return api; + }; + + it('should have correct key', () => { + const issue = new ViewIncompatibleIssue(createAPI()); + expect(issue.key).toBe('view_incompatible'); + }); + + it('should report no issue when untriggered', () => { + const issue = new ViewIncompatibleIssue(createAPI()); + expect(issue.hasIssue()).toBe(false); + expect(issue.getIssue()).toBeNull(); + }); + + it('should report an issue after trigger', () => { + const issue = new ViewIncompatibleIssue(createAPI()); + issue.trigger({ error: new Error('boom') }); + + expect(issue.hasIssue()).toBe(true); + }); + + it('should treat a triggered null/undefined error as no issue', () => { + const issue = new ViewIncompatibleIssue(createAPI()); + issue.trigger({ error: undefined }); + + expect(issue.hasIssue()).toBe(false); + expect(issue.getIssue()).toBeNull(); + }); + + describe('isFullCardIssue', () => { + it('should return true when no view is set', () => { + const issue = new ViewIncompatibleIssue(createAPI(false)); + expect(issue.isFullCardIssue()).toBe(true); + }); + + it('should return false when a view is set', () => { + const issue = new ViewIncompatibleIssue(createAPI(true)); + expect(issue.isFullCardIssue()).toBe(false); + }); + }); + + describe('getIssue', () => { + it('should return a notification with heading, body, and no retry control', () => { + const issue = new ViewIncompatibleIssue(createAPI()); + issue.trigger({ error: new Error('boom') }); + + expect(issue.getIssue()).toEqual({ + icon: 'mdi:video-off', + severity: 'high', + notification: { + heading: { + text: 'View not supported', + icon: 'mdi:video-off', + severity: 'high', + }, + body: { text: 'The selected camera or media does not support this view' }, + }, + }); + }); + + it('should include error context on AdvancedCameraCardError', () => { + const issue = new ViewIncompatibleIssue(createAPI()); + issue.trigger({ + error: new AdvancedCameraCardError('err', { + view: 'snapshot', + camera: 'cam.office', + }), + }); + + const result = issue.getIssue(); + expect(result?.notification.context).toEqual([ + expect.stringContaining('view: snapshot'), + ]); + }); + + it('should omit context on plain errors', () => { + const issue = new ViewIncompatibleIssue(createAPI()); + issue.trigger({ error: new Error('plain') }); + + const result = issue.getIssue(); + expect(result?.notification.context).toBeUndefined(); + }); + }); + + it('should clear the issue after reset', () => { + const issue = new ViewIncompatibleIssue(createAPI()); + issue.trigger({ error: new Error('boom') }); + expect(issue.hasIssue()).toBe(true); + + issue.reset(); + + expect(issue.hasIssue()).toBe(false); + expect(issue.getIssue()).toBeNull(); + }); +}); diff --git a/tests/card-controller/issues/retry-control.test.ts b/tests/card-controller/issues/retry-control.test.ts new file mode 100644 index 00000000..12432ef4 --- /dev/null +++ b/tests/card-controller/issues/retry-control.test.ts @@ -0,0 +1,25 @@ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest'; +import { createRetryControl } from '../../../src/card-controller/issues/retry-control'; +import { InternalCallbackActionConfig } from '../../../src/config/schema/actions/custom/internal'; +import { createCardAPI } from '../../test-utils'; + +describe('createRetryControl', () => { + it('should return a control with expected icon, tooltip, and dismiss', () => { + const control = createRetryControl('media_load'); + + expect(control.icon).toBe('mdi:refresh'); + expect(control.tooltip).toBe('Retry'); + expect(control.dismiss).toBe(true); + }); + + it('should call manager.retry with the issue key when the callback executes', async () => { + const api = createCardAPI(); + const control = createRetryControl('media_query'); + + const tapAction = control.actions?.tap_action as InternalCallbackActionConfig; + await tapAction.callback(api); + + expect(api.getIssueManager().retry).toBeCalledWith('media_query', true); + }); +}); diff --git a/tests/card-controller/issues/state-manager.test.ts b/tests/card-controller/issues/state-manager.test.ts new file mode 100644 index 00000000..29111624 --- /dev/null +++ b/tests/card-controller/issues/state-manager.test.ts @@ -0,0 +1,555 @@ +import { assert, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { IssueStateManager } from '../../../src/card-controller/issues/state-manager'; +import { Issue, IssueDescription } from '../../../src/card-controller/issues/types'; +import { createHASS } from '../../test-utils'; + +const createIssueDescription = ( + overrides?: Partial, +): IssueDescription => ({ + icon: 'mdi:test', + severity: 'high', + notification: { + heading: { + text: 'Test heading', + icon: 'mdi:test', + severity: 'high', + }, + body: { text: 'Test text' }, + }, + ...overrides, +}); + +describe('IssueStateManager', () => { + let mockConfigUpgrade: Issue; + let mockLegacyResource: Issue; + let mockMediaLoad: Issue; + + const createManager = (issues?: Issue[]): IssueStateManager => { + const manager = new IssueStateManager(); + for (const issue of issues ?? [ + mockConfigUpgrade, + mockLegacyResource, + mockMediaLoad, + ]) { + manager.addIssue(issue); + } + return manager; + }; + + beforeEach(() => { + vi.resetAllMocks(); + + mockConfigUpgrade = mock({ key: 'config_upgrade' }); + mockLegacyResource = mock({ key: 'legacy_resource' }); + mockMediaLoad = mock({ key: 'media_load' }); + }); + + it('should register all provided issues on construction', () => { + const manager = createManager(); + const presence = manager.getIssuePresence(); + + expect(presence.has('config_upgrade')).toBe(false); + expect(presence.has('legacy_resource')).toBe(false); + expect(presence.has('media_load')).toBe(false); + }); + + describe('detectStatic', () => { + it('should call detectStatic on all issues', async () => { + const manager = createManager(); + const hass = createHASS(); + + await manager.detectStatic(hass); + + assert(mockConfigUpgrade.detectStatic); + assert(mockLegacyResource.detectStatic); + assert(mockMediaLoad.detectStatic); + expect(mockConfigUpgrade.detectStatic).toBeCalledWith(hass); + expect(mockLegacyResource.detectStatic).toBeCalledWith(hass); + expect(mockMediaLoad.detectStatic).toBeCalledWith(hass); + }); + }); + + describe('trigger', () => { + it('should call trigger on the matching issue', () => { + const manager = createManager(); + + manager.trigger('media_load', { targetID: 'cam1' }); + + assert(mockMediaLoad.trigger); + expect(mockMediaLoad.trigger).toBeCalledWith({ targetID: 'cam1' }); + }); + + it('should do nothing for unknown key', () => { + const manager = createManager(); + + manager.trigger('unknown' as never, {} as never); + + assert(mockMediaLoad.trigger); + expect(mockMediaLoad.trigger).not.toBeCalled(); + }); + }); + + describe('detectDynamic', () => { + it('should call detectDynamic on issues with the given state', () => { + const manager = createManager(); + + manager.detectDynamic({ view: 'live' }); + + assert(mockMediaLoad.detectDynamic); + expect(mockMediaLoad.detectDynamic).toBeCalledWith({ view: 'live' }); + }); + }); + + describe('getFullCardIssue', () => { + it('should return first full-card issue', () => { + const result = createIssueDescription(); + vi.mocked(mockMediaLoad.hasIssue).mockReturnValue(true); + assert(mockMediaLoad.isFullCardIssue); + vi.mocked(mockMediaLoad.isFullCardIssue).mockReturnValue(true); + vi.mocked(mockMediaLoad.getIssue).mockReturnValue(result); + + const manager = createManager(); + + expect(manager.getFullCardIssue()).toBe(result); + }); + + it('should return null when only popup issues exist', () => { + vi.mocked(mockMediaLoad.hasIssue).mockReturnValue(true); + assert(mockMediaLoad.isFullCardIssue); + vi.mocked(mockMediaLoad.isFullCardIssue).mockReturnValue(false); + + const manager = createManager(); + + expect(manager.getFullCardIssue()).toBeNull(); + }); + + it('should skip inactive full-card issues', () => { + vi.mocked(mockMediaLoad.hasIssue).mockReturnValue(false); + + const manager = createManager(); + + expect(manager.getFullCardIssue()).toBeNull(); + }); + }); + + describe('hasFullCardIssue', () => { + it('should return true when full-card issue exists', () => { + vi.mocked(mockMediaLoad.hasIssue).mockReturnValue(true); + assert(mockMediaLoad.isFullCardIssue); + vi.mocked(mockMediaLoad.isFullCardIssue).mockReturnValue(true); + vi.mocked(mockMediaLoad.getIssue).mockReturnValue(createIssueDescription()); + + expect(createManager().hasFullCardIssue()).toBe(true); + }); + + it('should return false when no full-card issues', () => { + expect(createManager().hasFullCardIssue()).toBe(false); + }); + }); + + describe('getIssueDescriptions', () => { + it('should return results for active issues', () => { + const result = createIssueDescription(); + vi.mocked(mockConfigUpgrade.getIssue).mockReturnValue(result); + + const manager = createManager(); + + expect(manager.getIssueDescriptions()).toEqual([ + { key: 'config_upgrade', issue: result }, + ]); + }); + + it('should return empty array when no issues active', () => { + expect(createManager().getIssueDescriptions()).toEqual([]); + }); + }); + + describe('getIssuePresence', () => { + it('should return a map keyed by issue key with the current description as value', () => { + const description = createIssueDescription(); + vi.mocked(mockConfigUpgrade.getIssue).mockReturnValue(description); + vi.mocked(mockLegacyResource.getIssue).mockReturnValue(null); + + const manager = createManager(); + + const presence = manager.getIssuePresence(); + expect(presence.has('config_upgrade')).toBe(true); + expect(presence.get('config_upgrade')).toBe(description); + expect(presence.has('legacy_resource')).toBe(false); + }); + }); + + describe('getNotification', () => { + it('should return notification for an issue', () => { + const notification = { body: { text: 'test' } }; + mockMediaLoad.getNotification = vi.fn().mockReturnValue(notification); + + const manager = createManager(); + + expect(manager.getNotification('media_load')).toBe(notification); + }); + + it('should return null for unknown key', () => { + expect(createManager().getNotification('unknown' as never)).toBeNull(); + }); + }); + + describe('retry', () => { + it('should call retry on issues that want retry with non-exclusive result', () => { + assert(mockMediaLoad.needsRetry); + assert(mockMediaLoad.retry); + vi.mocked(mockMediaLoad.needsRetry).mockReturnValue(true); + vi.mocked(mockMediaLoad.retry).mockReturnValue(false); + + const manager = createManager(); + manager.retry(); + + expect(mockMediaLoad.retry).toBeCalled(); + }); + + it('should call retry on issues that want retry with exclusive result', () => { + assert(mockMediaLoad.needsRetry); + assert(mockMediaLoad.retry); + vi.mocked(mockMediaLoad.needsRetry).mockReturnValue(true); + vi.mocked(mockMediaLoad.retry).mockReturnValue(true); + + createManager().retry(); + + expect(mockMediaLoad.retry).toBeCalled(); + }); + + it('should not call retry on issues that do not want retry', () => { + assert(mockMediaLoad.needsRetry); + vi.mocked(mockMediaLoad.needsRetry).mockReturnValue(false); + + const manager = createManager(); + manager.retry(); + + assert(mockMediaLoad.retry); + expect(mockMediaLoad.retry).not.toBeCalled(); + }); + + it('should stop after exclusive result and not call retry on subsequent issues', () => { + // configUpgrade returns exclusive (true) → loop should stop. + // mediaLoad is registered after, so its retry should not be called. + assert(mockConfigUpgrade.needsRetry); + assert(mockConfigUpgrade.retry); + vi.mocked(mockConfigUpgrade.needsRetry).mockReturnValue(true); + vi.mocked(mockConfigUpgrade.retry).mockReturnValue(true); + assert(mockMediaLoad.needsRetry); + vi.mocked(mockMediaLoad.needsRetry).mockReturnValue(true); + + const manager = createManager(); + manager.retry(); + + expect(mockConfigUpgrade.retry).toBeCalled(); + assert(mockMediaLoad.retry); + expect(mockMediaLoad.retry).not.toBeCalled(); + }); + + it('should continue after non-exclusive result and call retry on subsequent issues', () => { + // configUpgrade returns non-exclusive (false) → loop should continue. + assert(mockConfigUpgrade.needsRetry); + assert(mockConfigUpgrade.retry); + vi.mocked(mockConfigUpgrade.needsRetry).mockReturnValue(true); + vi.mocked(mockConfigUpgrade.retry).mockReturnValue(false); + assert(mockMediaLoad.needsRetry); + assert(mockMediaLoad.retry); + vi.mocked(mockMediaLoad.needsRetry).mockReturnValue(true); + vi.mocked(mockMediaLoad.retry).mockReturnValue(false); + + const manager = createManager(); + manager.retry(); + + expect(mockConfigUpgrade.retry).toBeCalled(); + expect(mockMediaLoad.retry).toBeCalled(); + }); + }); + + describe('retry with key', () => { + it('should call retry on the matching issue when needsRetry is true', () => { + assert(mockMediaLoad.needsRetry); + assert(mockMediaLoad.retry); + vi.mocked(mockMediaLoad.needsRetry).mockReturnValue(true); + vi.mocked(mockMediaLoad.retry).mockReturnValue(false); + + createManager().retry('media_load'); + + expect(mockMediaLoad.retry).toBeCalled(); + }); + + it('should not call retry on the matching issue when needsRetry is false', () => { + assert(mockMediaLoad.needsRetry); + vi.mocked(mockMediaLoad.needsRetry).mockReturnValue(false); + + createManager().retry('media_load'); + + assert(mockMediaLoad.retry); + expect(mockMediaLoad.retry).not.toBeCalled(); + }); + + it('should call retry when force is true even if needsRetry is false', () => { + assert(mockMediaLoad.needsRetry); + assert(mockMediaLoad.retry); + vi.mocked(mockMediaLoad.needsRetry).mockReturnValue(false); + vi.mocked(mockMediaLoad.retry).mockReturnValue(false); + + createManager().retry('media_load', true); + + expect(mockMediaLoad.retry).toBeCalled(); + }); + + it('should do nothing for unknown key', () => { + createManager().retry('unknown' as never); + + assert(mockMediaLoad.retry); + expect(mockMediaLoad.retry).not.toBeCalled(); + }); + }); + + describe('needsRetry', () => { + it('should return true when issues want retry', () => { + assert(mockMediaLoad.needsRetry); + vi.mocked(mockMediaLoad.needsRetry).mockReturnValue(true); + + expect(createManager().needsRetry()).toBe(true); + }); + + it('should return false when no issues want retry', () => { + expect(createManager().needsRetry()).toBe(false); + }); + }); + + describe('logging', () => { + it('should log on static detection when issue is active', async () => { + const spy = vi.spyOn(console, 'warn').mockReturnValue(); + const result = createIssueDescription({ + notification: { body: { text: 'Legacy issue' } }, + }); + vi.mocked(mockLegacyResource.hasIssue).mockReturnValue(true); + vi.mocked(mockLegacyResource.getIssue).mockReturnValue(result); + + const manager = createManager(); + await manager.detectStatic(createHASS()); + + expect(spy).toBeCalledWith( + 'Advanced Camera Card [issue=legacy_resource]: Legacy issue', + ); + spy.mockRestore(); + }); + + it('should log on dynamic evaluation when issue becomes active', () => { + const spy = vi.spyOn(console, 'warn').mockReturnValue(); + const result = createIssueDescription({ + notification: { body: { text: 'Stream issue' } }, + }); + vi.mocked(mockMediaLoad.hasIssue).mockReturnValueOnce(false).mockReturnValue(true); + vi.mocked(mockMediaLoad.getIssue).mockReturnValue(result); + + const manager = createManager(); + manager.detectDynamic({ view: 'live' }); + + expect(spy).toBeCalledWith( + 'Advanced Camera Card [issue=media_load]: Stream issue', + ); + spy.mockRestore(); + }); + + it('should log on trigger when the issue becomes active', () => { + const spy = vi.spyOn(console, 'warn').mockReturnValue(); + const result = createIssueDescription({ + notification: { body: { text: 'Triggered' } }, + }); + vi.mocked(mockMediaLoad.getIssue).mockReturnValue(result); + + const manager = createManager(); + manager.trigger('media_load', { targetID: 'cam1' }); + + expect(spy).toBeCalledWith('Advanced Camera Card [issue=media_load]: Triggered'); + spy.mockRestore(); + }); + + it('should not log on trigger when the issue stays inactive', () => { + const spy = vi.spyOn(console, 'warn').mockReturnValue(); + vi.mocked(mockMediaLoad.getIssue).mockReturnValue(null); + + const manager = createManager(); + manager.trigger('media_load', { targetID: 'cam1' }); + + expect(spy).not.toBeCalled(); + spy.mockRestore(); + }); + + it('should not log on trigger for an unknown key', () => { + const spy = vi.spyOn(console, 'warn').mockReturnValue(); + + const manager = createManager(); + manager.trigger('unknown' as never, {} as never); + + expect(spy).not.toBeCalled(); + spy.mockRestore(); + }); + + it('should only log once per issue key', async () => { + const spy = vi.spyOn(console, 'warn').mockReturnValue(); + const result = createIssueDescription({ + notification: { body: { text: 'Repeated' } }, + }); + vi.mocked(mockLegacyResource.hasIssue).mockReturnValue(true); + vi.mocked(mockLegacyResource.getIssue).mockReturnValue(result); + + const manager = createManager(); + await manager.detectStatic(createHASS()); + await manager.detectStatic(createHASS()); + + expect(spy).toBeCalledTimes(1); + spy.mockRestore(); + }); + + it('should not log when issue has no result', async () => { + const spy = vi.spyOn(console, 'warn').mockReturnValue(); + vi.mocked(mockLegacyResource.hasIssue).mockReturnValue(false); + + const manager = createManager(); + await manager.detectStatic(createHASS()); + + expect(spy).not.toBeCalled(); + spy.mockRestore(); + }); + + it('should not log when issue result has no summarizable text', async () => { + const spy = vi.spyOn(console, 'warn').mockReturnValue(); + // Notification has neither body.text nor heading.text + const result = createIssueDescription({ + notification: {}, + }); + vi.mocked(mockLegacyResource.hasIssue).mockReturnValue(true); + vi.mocked(mockLegacyResource.getIssue).mockReturnValue(result); + + const manager = createManager(); + await manager.detectStatic(createHASS()); + + expect(spy).not.toBeCalled(); + spy.mockRestore(); + }); + + it('should log again after the issue clears and re-activates', async () => { + const spy = vi.spyOn(console, 'warn').mockReturnValue(); + const first = createIssueDescription({ + notification: { body: { text: 'First' } }, + }); + const second = createIssueDescription({ + notification: { body: { text: 'Second' } }, + }); + vi.mocked(mockLegacyResource.getIssue) + .mockReturnValueOnce(first) + .mockReturnValueOnce(null) + .mockReturnValueOnce(second); + + const manager = createManager(); + // Activate → log First. + await manager.detectStatic(createHASS()); + // Clear → drop dedupe entry. + await manager.detectStatic(createHASS()); + // Re-activate with a different payload → log Second. + await manager.detectStatic(createHASS()); + + expect(spy).toBeCalledTimes(2); + expect(spy).toHaveBeenNthCalledWith( + 1, + 'Advanced Camera Card [issue=legacy_resource]: First', + ); + expect(spy).toHaveBeenNthCalledWith( + 2, + 'Advanced Camera Card [issue=legacy_resource]: Second', + ); + spy.mockRestore(); + }); + + it('should log again after reset and re-activation', () => { + const spy = vi.spyOn(console, 'warn').mockReturnValue(); + const description = createIssueDescription({ + notification: { body: { text: 'Repeat' } }, + }); + // Active → cleared-by-reset → active again on next eval. + vi.mocked(mockMediaLoad.getIssue) + .mockReturnValueOnce(description) + .mockReturnValueOnce(null) + .mockReturnValueOnce(description); + + const manager = createManager(); + manager.detectDynamic({ view: 'live' }); + manager.reset('media_load'); + // After reset, the issue reports cleared on next detect, releasing the + // dedupe. + manager.detectDynamic({ view: 'live' }); + // Then it re-activates (e.g. new trigger arrives). + manager.detectDynamic({ view: 'live' }); + + expect(spy).toBeCalledTimes(2); + spy.mockRestore(); + }); + }); + + describe('reset', () => { + it('should reset a specific issue by key', () => { + const manager = createManager(); + manager.reset('media_load'); + + assert(mockMediaLoad.reset); + expect(mockMediaLoad.reset).toBeCalled(); + assert(mockConfigUpgrade.reset); + expect(mockConfigUpgrade.reset).not.toBeCalled(); + }); + + it('should reset all issues when no key is given', () => { + const manager = createManager(); + manager.reset(); + + assert(mockConfigUpgrade.reset); + assert(mockLegacyResource.reset); + assert(mockMediaLoad.reset); + expect(mockConfigUpgrade.reset).toBeCalled(); + expect(mockLegacyResource.reset).toBeCalled(); + expect(mockMediaLoad.reset).toBeCalled(); + }); + + it('should do nothing for unknown key', () => { + const manager = createManager(); + manager.reset('unknown' as never); + + assert(mockMediaLoad.reset); + expect(mockMediaLoad.reset).not.toBeCalled(); + }); + }); + + describe('suspend', () => { + it('should call suspend on all issues that implement it', () => { + const manager = createManager(); + manager.suspend(); + + assert(mockConfigUpgrade.suspend); + assert(mockLegacyResource.suspend); + assert(mockMediaLoad.suspend); + expect(mockConfigUpgrade.suspend).toBeCalled(); + expect(mockLegacyResource.suspend).toBeCalled(); + expect(mockMediaLoad.suspend).toBeCalled(); + }); + }); + + describe('destroy', () => { + it('should destroy all issues and clear', () => { + const manager = createManager(); + manager.destroy(); + + assert(mockConfigUpgrade.reset); + assert(mockLegacyResource.reset); + assert(mockMediaLoad.reset); + expect(mockConfigUpgrade.reset).toBeCalled(); + expect(mockLegacyResource.reset).toBeCalled(); + expect(mockMediaLoad.reset).toBeCalled(); + expect(manager.getIssuePresence().size).toBe(0); + }); + }); +}); diff --git a/tests/card-controller/keyboard-state-manager.test.ts b/tests/card-controller/keyboard-state-manager.test.ts index 392effca..315a81c9 100644 --- a/tests/card-controller/keyboard-state-manager.test.ts +++ b/tests/card-controller/keyboard-state-manager.test.ts @@ -83,4 +83,30 @@ describe('KeyboardStateManager', () => { expect(api.getConditionStateManager().setState).not.toBeCalled(); }); + + it('should clear held keys on uninitialize', () => { + const api = createCardAPI(); + const element = createLitElement(); + vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); + const manager = new KeyboardStateManager(api); + manager.initialize(); + + element.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' })); + vi.mocked(api.getConditionStateManager().setState).mockClear(); + + manager.uninitialize(); + + expect(api.getConditionStateManager().setState).toBeCalledWith({ keys: {} }); + }); + + it('should not set state on uninitialize when no keys held', () => { + const api = createCardAPI(); + const element = createLitElement(); + vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); + const manager = new KeyboardStateManager(api); + manager.initialize(); + manager.uninitialize(); + + expect(api.getConditionStateManager().setState).not.toBeCalled(); + }); }); diff --git a/tests/card-controller/media-player-manager.test.ts b/tests/card-controller/media-player-manager.test.ts index 77652a39..fb750b5e 100644 --- a/tests/card-controller/media-player-manager.test.ts +++ b/tests/card-controller/media-player-manager.test.ts @@ -69,7 +69,7 @@ describe('MediaPlayerManager', () => { }); describe('should initialize', () => { - it('correctly', async () => { + it('should initialize correctly', async () => { const entityRegistryManager = new EntityRegistryManagerMock([ createRegistryEntity({ entity_id: 'media_player.ok1', @@ -108,7 +108,7 @@ describe('MediaPlayerManager', () => { expect(manager.hasMediaPlayers()).toBeTruthy(); }); - it('without hass', async () => { + it('should handle without hass', async () => { const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(null); const manager = new MediaPlayerManager(api); @@ -119,7 +119,7 @@ describe('MediaPlayerManager', () => { expect(manager.hasMediaPlayers()).toBeFalsy(); }); - it('even if entity registry call fails', async () => { + it('should handle entity registry call failure', async () => { const spy = vi.spyOn(global.console, 'warn').mockImplementation(() => true); const entityRegistryManager = mock(); @@ -273,7 +273,7 @@ describe('MediaPlayerManager', () => { describe('should play', () => { describe('live', () => { - it('without camera config', async () => { + it('should handle without camera config', async () => { const api = createCardAPI(); vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); @@ -285,7 +285,7 @@ describe('MediaPlayerManager', () => { }); describe('using standard method', () => { - it('successfully', async () => { + it('should succeed', async () => { const api = createCardAPI(); vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); vi.mocked(api.getCameraManager().getStore).mockReturnValue( @@ -330,7 +330,7 @@ describe('MediaPlayerManager', () => { ); }); - it('without camera_entity', async () => { + it('should handle without camera_entity', async () => { const api = createCardAPI(); vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); @@ -350,7 +350,7 @@ describe('MediaPlayerManager', () => { expect(api.getHASSManager().getHASS()?.callService).not.toBeCalled(); }); - it('without title and thumbnail', async () => { + it('should handle without title and thumbnail', async () => { const api = createCardAPI(); vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); vi.mocked(api.getCameraManager().getStore).mockReturnValue( @@ -382,7 +382,7 @@ describe('MediaPlayerManager', () => { }); describe('using dashboard method', () => { - it('successfully', async () => { + it('should succeed', async () => { const api = createCardAPI(); vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); vi.mocked(api.getCameraManager().getStore).mockReturnValue( @@ -418,7 +418,7 @@ describe('MediaPlayerManager', () => { ); }); - it('without hass', async () => { + it('should handle without hass', async () => { const api = createCardAPI(); vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); vi.mocked(api.getCameraManager().getStore).mockReturnValue( @@ -446,38 +446,36 @@ describe('MediaPlayerManager', () => { // No actual test can be performed here as nothing observable happens. // This test serves only as code-coverage long-tail. }); - }); - it('without required configuration', async () => { - const api = createCardAPI(); - vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); - vi.mocked(api.getCameraManager().getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera.foo', - config: createCameraConfig({ - camera_entity: 'camera.foo', - cast: { - method: 'dashboard', - }, - }), - }, - ]), - ); - vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); + it('should handle without dashboard_path or view_path', async () => { + const api = createCardAPI(); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); - const manager = new MediaPlayerManager(api); + // Bypass schema validation — the code has a runtime guard (TypeScript + // narrowing) for the case where dashboard config is present but + // dashboard_path / view_path are missing. + const configWithNoDashboardPaths = createCameraConfig({ + camera_entity: 'camera.foo', + }); + (configWithNoDashboardPaths as Record).cast = { + method: 'dashboard', + dashboard: { dashboard_path: undefined, view_path: undefined }, + }; - await manager.playLive('media_player.foo', 'camera.foo'); + vi.mocked(api.getCameraManager().getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera.foo', + config: configWithNoDashboardPaths, + }, + ]), + ); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); + const manager = new MediaPlayerManager(api); - expect( - vi.mocked(api.getMessageManager().setMessageIfHigherPriority), - ).toBeCalledWith({ - type: 'error', - icon: 'mdi:cast', - message: - "Both 'dashboard_path' and 'view_path' parameters are required " + - "for the 'dashboard' cast method", + await manager.playLive('media_player.foo', 'camera.foo'); + + expect(api.getHASSManager().getHASS()?.callService).not.toBeCalled(); }); }); }); @@ -516,7 +514,7 @@ describe('MediaPlayerManager', () => { }); }); - it('without hass', async () => { + it('should handle without hass', async () => { const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(null); const manager = new MediaPlayerManager(api); diff --git a/tests/card-controller/message-manager.test.ts b/tests/card-controller/message-manager.test.ts deleted file mode 100644 index 69f4c9ea..00000000 --- a/tests/card-controller/message-manager.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { afterAll, describe, expect, it, vi } from 'vitest'; -import { MessageManager } from '../../src/card-controller/message-manager'; -import { AdvancedCameraCardError, Message } from '../../src/types'; -import { createCardAPI } from '../test-utils'; - -const createMessage = (options?: Partial): Message => { - return { - message: options?.message ?? 'message', - ...(!!options?.type && { type: options.type }), - ...(!!options?.icon && { icon: options.icon }), - ...(!!options?.context && { context: options.context }), - ...(!!options?.dotdotdot && { dotdotdot: options.dotdotdot }), - }; -}; - -describe('MessageManager', () => { - afterAll(() => { - vi.restoreAllMocks(); - }); - - it('should construct', () => { - const manager = new MessageManager(createCardAPI()); - expect(manager.hasMessage()).toBeFalsy(); - expect(manager.getMessage()).toBeNull(); - expect(manager.hasErrorMessage()).toBeFalsy(); - }); - - it('should set info message', () => { - const api = createCardAPI(); - const manager = new MessageManager(api); - const message = createMessage(); - manager.setMessageIfHigherPriority(message); - expect(manager.hasMessage()).toBeTruthy(); - expect(manager.getMessage()).toBe(message); - expect(manager.hasErrorMessage()).toBeFalsy(); - - expect(api.getMediaLoadedInfoManager().clear).toBeCalled(); - expect(api.getCardElementManager().scrollReset).toBeCalled(); - expect(api.getCardElementManager().update).toBeCalled(); - }); - - it('should set error message', () => { - const api = createCardAPI(); - const manager = new MessageManager(api); - const message = createMessage({ type: 'error' }); - manager.setMessageIfHigherPriority(message); - expect(manager.hasMessage()).toBeTruthy(); - expect(manager.getMessage()).toBe(message); - expect(manager.hasErrorMessage()).toBeTruthy(); - - expect(api.getMediaLoadedInfoManager().clear).toBeCalled(); - expect(api.getCardElementManager().scrollReset).toBeCalled(); - expect(api.getCardElementManager().update).toBeCalled(); - }); - - it('should reset message', () => { - const api = createCardAPI(); - const manager = new MessageManager(api); - - manager.reset(); - expect(manager.hasMessage()).toBeFalsy(); - - const message = createMessage({ type: 'error' }); - manager.setMessageIfHigherPriority(message); - expect(manager.hasMessage()).toBeTruthy(); - - vi.mocked(api.getCardElementManager().update).mockClear(); - manager.reset(); - - expect(manager.hasMessage()).toBeFalsy(); - expect(api.getCardElementManager().update).toBeCalled(); - }); - - it('should reset message that matches type', () => { - const api = createCardAPI(); - const manager = new MessageManager(api); - - const message = createMessage({ type: 'connection' }); - manager.setMessageIfHigherPriority(message); - expect(manager.getMessage()).toBe(message); - - manager.resetType('error'); - expect(manager.getMessage()).toBe(message); - - manager.resetType('connection'); - expect(manager.getMessage()).toBeNull(); - expect(manager.hasMessage()).toBeFalsy(); - }); - - it('should respect priority', () => { - const api = createCardAPI(); - const manager = new MessageManager(api); - - manager.reset(); - expect(manager.hasMessage()).toBeFalsy(); - - const errorMessage = createMessage({ type: 'error' }); - manager.setMessageIfHigherPriority(errorMessage); - - const explicitInfoMessage = createMessage({ type: 'info' }); - manager.setMessageIfHigherPriority(explicitInfoMessage); - - const implicitInfoMessage = createMessage(); - manager.setMessageIfHigherPriority(implicitInfoMessage); - - expect(manager.getMessage()).toBe(errorMessage); - - const connectionMessage = createMessage({ type: 'connection' }); - manager.setMessageIfHigherPriority(connectionMessage); - - expect(manager.getMessage()).toBe(connectionMessage); - }); - - it('should set AdvancedCameraCardError object', () => { - const consoleSpy = vi.spyOn(global.console, 'warn').mockReturnValue(undefined); - - const api = createCardAPI(); - const manager = new MessageManager(api); - const context = { foo: 'bar' }; - - manager.setErrorIfHigherPriority( - new AdvancedCameraCardError('advanced camera card message', context), - ); - expect(manager.hasMessage()).toBeTruthy(); - expect(manager.getMessage()).toEqual({ - message: 'advanced camera card message', - type: 'error', - context: context, - }); - - expect(consoleSpy).toBeCalled(); - }); - - it('should set Error object', () => { - const consoleSpy = vi.spyOn(global.console, 'warn').mockReturnValue(undefined); - - const api = createCardAPI(); - const manager = new MessageManager(api); - - manager.setErrorIfHigherPriority(new Error('generic error message')); - expect(manager.hasMessage()).toBeTruthy(); - expect(manager.getMessage()).toEqual({ - message: 'generic error message', - type: 'error', - }); - - expect(consoleSpy).toBeCalled(); - }); - - it('should set error with prefix', () => { - const consoleSpy = vi.spyOn(global.console, 'warn').mockReturnValue(undefined); - - const api = createCardAPI(); - const manager = new MessageManager(api); - - manager.setErrorIfHigherPriority(new Error('generic error message'), 'PREFIX'); - expect(manager.hasMessage()).toBeTruthy(); - expect(manager.getMessage()).toEqual({ - message: 'PREFIX: generic error message', - type: 'error', - }); - - expect(consoleSpy).toBeCalled(); - }); - - it('should not set unknown error type', () => { - const consoleSpy = vi.spyOn(global.console, 'warn').mockReturnValue(undefined); - - const api = createCardAPI(); - const manager = new MessageManager(api); - - manager.setErrorIfHigherPriority('not_an_error_object'); - expect(manager.hasMessage()).toBeFalsy(); - expect(consoleSpy).not.toBeCalled(); - }); -}); diff --git a/tests/card-controller/microphone-manager.test.ts b/tests/card-controller/microphone-manager.test.ts index 1945b0a0..0407cd72 100644 --- a/tests/card-controller/microphone-manager.test.ts +++ b/tests/card-controller/microphone-manager.test.ts @@ -1,6 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; -import { MicrophoneManager } from '../../src/card-controller/microphone-manager'; +import { + MicrophoneManager, + MicrophoneNotSupportedError, +} from '../../src/card-controller/microphone-manager'; import { MicrophoneState } from '../../src/card-controller/types'; import { createCardAPI, createConfig } from '../test-utils'; @@ -82,20 +85,17 @@ describe('MicrophoneManager', () => { const stream = createMockStream(); vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream); - await manager.connect(); + await expect(manager.connect()).rejects.toThrow(MicrophoneNotSupportedError); expect(manager.isConnected()).toBeFalsy(); }); it('should be forbidden when permission denied', async () => { - // Don't actually log messages to the console during the test. - vi.spyOn(global.console, 'warn').mockReturnValue(undefined); - const api = createCardAPI(); const manager = new MicrophoneManager(api); vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockRejectedValue(new Error()); - expect(await manager.connect()).toBeFalsy(); + await expect(manager.connect()).rejects.toThrow(Error); expect(manager.isConnected()).toBeFalsy(); expect(manager.isForbidden()).toBeTruthy(); @@ -127,7 +127,7 @@ describe('MicrophoneManager', () => { const manager = new MicrophoneManager(api); vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockRejectedValue(new Error()); - await manager.connect(); + await expect(manager.connect()).rejects.toThrow(Error); expect(manager.isMuted()).toBeTruthy(); expect(api.getCardElementManager().update).toBeCalledTimes(1); @@ -245,7 +245,7 @@ describe('MicrophoneManager', () => { }); describe('should require initialization', async () => { - it('when configured and supported', async () => { + it('should require when configured and supported', async () => { const api = createCardAPI(); const manager = new MicrophoneManager(api); vi.mocked(api.getConfigManager().getConfig).mockReturnValue( @@ -263,7 +263,7 @@ describe('MicrophoneManager', () => { expect(manager.shouldConnectOnInitialization()).toBeTruthy(); }); - it('when configured but not supported', async () => { + it('should not require when configured but not supported', async () => { vi.stubGlobal('navigator', medialessNavigatorMock); const api = createCardAPI(); @@ -278,19 +278,19 @@ describe('MicrophoneManager', () => { }), ); - await manager.connect(); + await expect(manager.connect()).rejects.toThrow(MicrophoneNotSupportedError); expect(manager.shouldConnectOnInitialization()).toBeFalsy(); }); - it('when neither configured nor supported', async () => { + it('should not require when neither configured nor supported', async () => { vi.stubGlobal('navigator', medialessNavigatorMock); const api = createCardAPI(); const manager = new MicrophoneManager(api); vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); - await manager.connect(); + await expect(manager.connect()).rejects.toThrow(MicrophoneNotSupportedError); expect(manager.shouldConnectOnInitialization()).toBeFalsy(); }); diff --git a/tests/card-controller/notification-manager.test.ts b/tests/card-controller/notification-manager.test.ts index 6de3eae2..24681ee2 100644 --- a/tests/card-controller/notification-manager.test.ts +++ b/tests/card-controller/notification-manager.test.ts @@ -22,7 +22,7 @@ describe('NotificationManager', () => { it('should set and get notification', () => { const manager = new NotificationManager(api); - const notification = { text: 'foo' }; + const notification = { body: { text: 'foo' } }; manager.setNotification(notification); expect(manager.getNotification()).toBe(notification); @@ -32,7 +32,7 @@ describe('NotificationManager', () => { it('should reset notification', () => { const manager = new NotificationManager(api); - manager.setNotification({ text: 'foo' }); + manager.setNotification({ body: { text: 'foo' } }); vi.clearAllMocks(); manager.reset(); diff --git a/tests/card-controller/problems/manager.test.ts b/tests/card-controller/problems/manager.test.ts deleted file mode 100644 index aa04e526..00000000 --- a/tests/card-controller/problems/manager.test.ts +++ /dev/null @@ -1,365 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { mock } from 'vitest-mock-extended'; -import { ProblemManager } from '../../../src/card-controller/problems/manager'; -import { ConfigUpgradeProblem } from '../../../src/card-controller/problems/problems/config-upgrade'; -import { LegacyResourceProblem } from '../../../src/card-controller/problems/problems/legacy-resource'; -import { StreamNotLoadingProblem } from '../../../src/card-controller/problems/problems/stream-not-loading'; -import { Problem, ProblemResult } from '../../../src/card-controller/problems/types'; -import { ConditionStateManager } from '../../../src/conditions/state-manager'; -import { createCardAPI, createHASS } from '../../test-utils'; - -vi.mock('../../../src/card-controller/problems/problems/config-upgrade'); -vi.mock('../../../src/card-controller/problems/problems/legacy-resource'); -vi.mock('../../../src/card-controller/problems/problems/stream-not-loading'); - -const createProblemResult = (overrides?: Partial): ProblemResult => ({ - icon: 'mdi:test', - severity: 'high', - notification: { - heading: { - text: 'Test heading', - icon: 'mdi:test', - severity: 'high', - }, - text: 'Test text', - }, - ...overrides, -}); - -// @vitest-environment jsdom -describe('ProblemManager', () => { - let mockConfigUpgrade: Problem; - let mockLegacyResource: Problem; - let mockStreamNotLoading: Problem; - - beforeEach(() => { - vi.resetAllMocks(); - - mockConfigUpgrade = mock({ key: 'config_upgrade' }); - mockLegacyResource = mock({ key: 'legacy_resource' }); - mockStreamNotLoading = mock({ key: 'stream_not_loading' }); - - vi.mocked(ConfigUpgradeProblem).mockImplementation( - () => mockConfigUpgrade as unknown as ConfigUpgradeProblem, - ); - vi.mocked(LegacyResourceProblem).mockImplementation( - () => mockLegacyResource as unknown as LegacyResourceProblem, - ); - vi.mocked(StreamNotLoadingProblem).mockImplementation( - () => mockStreamNotLoading as unknown as StreamNotLoadingProblem, - ); - }); - - it('should pass config getter to ConfigUpgradeProblem', () => { - const api = createCardAPI(); - new ProblemManager(api); - - const callback = vi.mocked(ConfigUpgradeProblem).mock.calls[0][0]; - callback(); - - expect(api.getConfigManager().getRawConfig).toBeCalled(); - }); - - it('should pass update callback to LegacyResourceProblem', () => { - const api = createCardAPI(); - new ProblemManager(api); - - const callback = vi.mocked(LegacyResourceProblem).mock.calls[0][0]; - callback(); - - expect(api.getCardElementManager().update).toBeCalled(); - }); - - it('should pass update callback to StreamNotLoadingProblem', () => { - const api = createCardAPI(); - new ProblemManager(api); - - const callback = vi.mocked(StreamNotLoadingProblem).mock.calls[0][0]; - callback(); - - expect(api.getCardElementManager().update).toBeCalled(); - }); - - it('should register all built-in problems on construction', () => { - const api = createCardAPI(); - const manager = new ProblemManager(api); - const presence = manager.getProblemPresence(); - - expect('config_upgrade' in presence).toBe(true); - expect('legacy_resource' in presence).toBe(true); - expect('stream_not_loading' in presence).toBe(true); - }); - - describe('detectStatic', () => { - it('should call detectStatic on all problems', async () => { - const api = createCardAPI(); - const manager = new ProblemManager(api); - const hass = createHASS(); - - await manager.detectStatic(hass); - - expect(mockConfigUpgrade.detectStatic).toBeCalledWith(hass); - expect(mockLegacyResource.detectStatic).toBeCalledWith(hass); - expect(mockStreamNotLoading.detectStatic).toBeCalledWith(hass); - expect(api.getCardElementManager().update).toBeCalled(); - }); - }); - - describe('trigger', () => { - it('should trigger a problem and update when state changes', () => { - const api = createCardAPI(); - vi.mocked(api.getConditionStateManager().getState).mockReturnValue({}); - const manager = new ProblemManager(api); - vi.mocked(mockStreamNotLoading.hasResult) - .mockReturnValueOnce(false) - .mockReturnValue(true); - - manager.trigger('stream_not_loading'); - - expect(mockStreamNotLoading.trigger).toBeCalled(); - expect(api.getCardElementManager().update).toBeCalled(); - }); - - it('should do nothing for unknown key', () => { - const api = createCardAPI(); - const manager = new ProblemManager(api); - - manager.trigger(('stream_not_loading' + '_unknown') as never); - - expect(mockStreamNotLoading.trigger).not.toBeCalled(); - }); - - it('should not update when trigger does not change state', () => { - const api = createCardAPI(); - vi.mocked(api.getConditionStateManager().getState).mockReturnValue({}); - const manager = new ProblemManager(api); - vi.mocked(mockStreamNotLoading.hasResult).mockReturnValue(false); - - manager.trigger('stream_not_loading'); - - expect(mockStreamNotLoading.trigger).toBeCalled(); - expect(api.getCardElementManager().update).not.toBeCalled(); - }); - }); - - describe('forceNotify', () => { - it('should show notification from getNotification', () => { - const api = createCardAPI(); - const manager = new ProblemManager(api); - const notification = { text: 'from getNotification' }; - mockStreamNotLoading.getNotification = vi.fn().mockReturnValue(notification); - - manager.forceNotify('stream_not_loading'); - - expect(api.getNotificationManager().setNotification).toBeCalledWith(notification); - }); - - it('should not show notification when getNotification returns null', () => { - const api = createCardAPI(); - const manager = new ProblemManager(api); - - manager.forceNotify('config_upgrade'); - - expect(api.getNotificationManager().setNotification).not.toBeCalled(); - }); - }); - - describe('getProblemResults', () => { - it('should return results for active problems', () => { - const api = createCardAPI(); - const result = createProblemResult(); - vi.mocked(mockConfigUpgrade.getResult).mockReturnValue(result); - - const manager = new ProblemManager(api); - - expect(manager.getProblemResults()).toEqual([ - { key: 'config_upgrade', problem: result }, - ]); - }); - - it('should return empty array when no problems active', () => { - const api = createCardAPI(); - const manager = new ProblemManager(api); - - expect(manager.getProblemResults()).toEqual([]); - }); - }); - - describe('getProblemPresence', () => { - it('should return presence map', () => { - const api = createCardAPI(); - vi.mocked(mockConfigUpgrade.hasResult).mockReturnValue(true); - vi.mocked(mockLegacyResource.hasResult).mockReturnValue(false); - - const manager = new ProblemManager(api); - - expect(manager.getProblemPresence()).toMatchObject({ - ['config_upgrade']: true, - ['legacy_resource']: false, - }); - }); - }); - - describe('state change handling', () => { - it('should detect dynamic problems on view change', () => { - const api = createCardAPI(); - const stateManager = new ConditionStateManager(); - vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); - - const manager = new ProblemManager(api); - vi.mocked(mockStreamNotLoading.hasResult) - .mockReturnValueOnce(false) - .mockReturnValue(true); - - manager.initialize(); - - stateManager.setState({ view: 'live' }); - - expect(mockStreamNotLoading.detectDynamic).toBeCalledWith({ - view: 'live', - mediaLoaded: false, - }); - expect(api.getCardElementManager().update).toBeCalled(); - }); - - it('should detect dynamic problems on mediaLoadedInfo change', () => { - const api = createCardAPI(); - const stateManager = new ConditionStateManager(); - vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); - - const manager = new ProblemManager(api); - - manager.initialize(); - - stateManager.setState({ - mediaLoadedInfo: { width: 1920, height: 1080 }, - }); - - expect(mockStreamNotLoading.detectDynamic).toBeCalledWith( - expect.objectContaining({ mediaLoaded: true }), - ); - }); - - it('should not update when dynamic detection does not change state', () => { - const api = createCardAPI(); - const stateManager = new ConditionStateManager(); - vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); - - const manager = new ProblemManager(api); - vi.mocked(mockStreamNotLoading.hasResult).mockReturnValue(false); - - manager.initialize(); - - stateManager.setState({ view: 'live' }); - - expect(mockStreamNotLoading.detectDynamic).toBeCalled(); - expect(api.getCardElementManager().update).not.toBeCalled(); - }); - }); - - describe('uninitialize', () => { - it('should remove state listener', () => { - const api = createCardAPI(); - const stateManager = new ConditionStateManager(); - vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); - - const manager = new ProblemManager(api); - - manager.initialize(); - manager.uninitialize(); - - stateManager.setState({ view: 'live' }); - - expect(mockStreamNotLoading.detectDynamic).not.toBeCalled(); - }); - }); - - describe('logging', () => { - it('should log on static detection when problem is active', async () => { - const spy = vi.spyOn(console, 'warn').mockReturnValue(); - const api = createCardAPI(); - const result = createProblemResult({ notification: { text: 'Legacy problem' } }); - vi.mocked(mockLegacyResource.hasResult).mockReturnValue(true); - vi.mocked(mockLegacyResource.getResult).mockReturnValue(result); - - const manager = new ProblemManager(api); - await manager.detectStatic(createHASS()); - - expect(spy).toBeCalledWith('Advanced Camera Card: Legacy problem'); - spy.mockRestore(); - }); - - it('should log on dynamic detection when problem becomes active', () => { - const spy = vi.spyOn(console, 'warn').mockReturnValue(); - const api = createCardAPI(); - const stateManager = new ConditionStateManager(); - vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); - - const result = createProblemResult({ - notification: { text: 'Stream problem' }, - }); - vi.mocked(mockStreamNotLoading.hasResult) - .mockReturnValueOnce(false) - .mockReturnValue(true); - vi.mocked(mockStreamNotLoading.getResult).mockReturnValue(result); - - const manager = new ProblemManager(api); - manager.initialize(); - - stateManager.setState({ view: 'live' }); - - expect(spy).toBeCalledWith('Advanced Camera Card: Stream problem'); - spy.mockRestore(); - }); - - it('should only log once per problem key', async () => { - const spy = vi.spyOn(console, 'warn').mockReturnValue(); - const api = createCardAPI(); - const result = createProblemResult({ notification: { text: 'Repeated' } }); - vi.mocked(mockLegacyResource.hasResult).mockReturnValue(true); - vi.mocked(mockLegacyResource.getResult).mockReturnValue(result); - - const manager = new ProblemManager(api); - await manager.detectStatic(createHASS()); - await manager.detectStatic(createHASS()); - - expect(spy).toBeCalledTimes(1); - spy.mockRestore(); - }); - - it('should not log when problem has no result', async () => { - const spy = vi.spyOn(console, 'warn').mockReturnValue(); - const api = createCardAPI(); - vi.mocked(mockLegacyResource.hasResult).mockReturnValue(false); - - const manager = new ProblemManager(api); - await manager.detectStatic(createHASS()); - - expect(spy).not.toBeCalled(); - spy.mockRestore(); - }); - }); - - describe('destroy', () => { - it('should destroy all problems and clear', () => { - const api = createCardAPI(); - const stateManager = new ConditionStateManager(); - vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); - - const manager = new ProblemManager(api); - - manager.initialize(); - manager.destroy(); - - expect(mockConfigUpgrade.destroy).toBeCalled(); - expect(mockLegacyResource.destroy).toBeCalled(); - expect(mockStreamNotLoading.destroy).toBeCalled(); - expect(manager.getProblemPresence()).toEqual({}); - - // State changes after destroy should not trigger detection. - stateManager.setState({ view: 'live' }); - - expect(mockStreamNotLoading.detectDynamic).not.toBeCalled(); - }); - }); -}); diff --git a/tests/card-controller/problems/problems/config-upgrade.test.ts b/tests/card-controller/problems/problems/config-upgrade.test.ts deleted file mode 100644 index 7d7b9300..00000000 --- a/tests/card-controller/problems/problems/config-upgrade.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { ConfigUpgradeProblem } from '../../../../src/card-controller/problems/problems/config-upgrade'; -import { isConfigUpgradeable } from '../../../../src/config/management'; - -vi.mock('../../../../src/config/management.js'); - -describe('ConfigUpgradeProblem', () => { - it('should have correct key', () => { - const problem = new ConfigUpgradeProblem(() => null); - expect(problem.key).toBe('config_upgrade'); - }); - - it('should detect upgradeable config', async () => { - vi.mocked(isConfigUpgradeable).mockReturnValue(true); - const rawConfig = { type: 'custom:frigate-card' }; - const problem = new ConfigUpgradeProblem(() => rawConfig); - - await problem.detectStatic(); - - expect(problem.hasResult()).toBe(true); - expect(isConfigUpgradeable).toBeCalledWith(rawConfig); - }); - - it('should detect non-upgradeable config', async () => { - vi.mocked(isConfigUpgradeable).mockReturnValue(false); - const rawConfig = { type: 'custom:advanced-camera-card' }; - const problem = new ConfigUpgradeProblem(() => rawConfig); - - await problem.detectStatic(); - - expect(problem.hasResult()).toBe(false); - }); - - it('should handle null raw config', async () => { - const problem = new ConfigUpgradeProblem(() => null); - - await problem.detectStatic(); - - expect(problem.hasResult()).toBe(false); - expect(problem.getResult()).toBeNull(); - }); - - it('should return result when upgradeable', async () => { - vi.mocked(isConfigUpgradeable).mockReturnValue(true); - const problem = new ConfigUpgradeProblem(() => ({ type: 'custom:frigate-card' })); - - await problem.detectStatic(); - - const result = problem.getResult(); - expect(result).toEqual( - expect.objectContaining({ - icon: 'mdi:update', - severity: 'medium', - notification: expect.objectContaining({ - heading: expect.objectContaining({ - icon: 'mdi:update', - severity: 'medium', - }), - }), - }), - ); - }); -}); diff --git a/tests/card-controller/problems/problems/stream-not-loading.test.ts b/tests/card-controller/problems/problems/stream-not-loading.test.ts deleted file mode 100644 index ca415113..00000000 --- a/tests/card-controller/problems/problems/stream-not-loading.test.ts +++ /dev/null @@ -1,311 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { StreamNotLoadingProblem } from '../../../../src/card-controller/problems/problems/stream-not-loading'; - -// @vitest-environment jsdom -describe('StreamNotLoadingProblem', () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it('should have correct key', () => { - const problem = new StreamNotLoadingProblem(vi.fn()); - expect(problem.key).toBe('stream_not_loading'); - }); - - describe('detectDynamic', () => { - it('should start timer when live and not loaded', () => { - const triggerUpdate = vi.fn(); - const problem = new StreamNotLoadingProblem(triggerUpdate); - - problem.detectDynamic({ view: 'live', mediaLoaded: false }); - - expect(problem.hasResult()).toBe(false); - - vi.advanceTimersByTime(10000); - - expect(problem.hasResult()).toBe(true); - expect(triggerUpdate).toBeCalled(); - }); - - it('should not start timer when not live', () => { - const problem = new StreamNotLoadingProblem(vi.fn()); - - problem.detectDynamic({ view: 'media', mediaLoaded: false }); - - vi.advanceTimersByTime(10000); - - expect(problem.hasResult()).toBe(false); - }); - - it('should not start timer when media is loaded', () => { - const problem = new StreamNotLoadingProblem(vi.fn()); - - problem.detectDynamic({ view: 'live', mediaLoaded: true }); - - vi.advanceTimersByTime(10000); - - expect(problem.hasResult()).toBe(false); - }); - - it('should clear timeout when media loads', () => { - const problem = new StreamNotLoadingProblem(vi.fn()); - - problem.detectDynamic({ view: 'live', mediaLoaded: false }); - vi.advanceTimersByTime(5000); - - problem.detectDynamic({ view: 'live', mediaLoaded: true }); - - vi.advanceTimersByTime(5000); - - expect(problem.hasResult()).toBe(false); - }); - - it('should clear timeout when view changes away from live', () => { - const problem = new StreamNotLoadingProblem(vi.fn()); - - problem.detectDynamic({ view: 'live', mediaLoaded: false }); - vi.advanceTimersByTime(5000); - - problem.detectDynamic({ view: 'media', mediaLoaded: false }); - - vi.advanceTimersByTime(5000); - - expect(problem.hasResult()).toBe(false); - }); - - it('should clear timed-out state when media loads', () => { - const problem = new StreamNotLoadingProblem(vi.fn()); - - problem.detectDynamic({ view: 'live', mediaLoaded: false }); - vi.advanceTimersByTime(10000); - expect(problem.hasResult()).toBe(true); - - problem.detectDynamic({ view: 'live', mediaLoaded: true }); - expect(problem.hasResult()).toBe(false); - }); - - it('should restart timer when camera changes', () => { - const triggerUpdate = vi.fn(); - const problem = new StreamNotLoadingProblem(triggerUpdate); - - problem.detectDynamic({ - cameraID: 'camera-1', - view: 'live', - mediaLoaded: false, - }); - vi.advanceTimersByTime(5000); - - // Switch to camera-2: timer restarts from 0 for the new camera. - problem.detectDynamic({ - cameraID: 'camera-2', - view: 'live', - mediaLoaded: false, - }); - - // 5 more seconds is not enough for the new 10s timer. - vi.advanceTimersByTime(5000); - expect(problem.hasResult()).toBe(false); - - // Full 10s from camera-2's timer start. - vi.advanceTimersByTime(5000); - expect(problem.hasResult()).toBe(true); - expect(triggerUpdate).toBeCalledTimes(1); - }); - - it('should not restart timer for same camera while running', () => { - const triggerUpdate = vi.fn(); - const problem = new StreamNotLoadingProblem(triggerUpdate); - - problem.detectDynamic({ - cameraID: 'camera-1', - view: 'live', - mediaLoaded: false, - }); - vi.advanceTimersByTime(5000); - - // Same camera again: timer should continue, not restart. - problem.detectDynamic({ - cameraID: 'camera-1', - view: 'live', - mediaLoaded: false, - }); - - // 5 more seconds completes the original 10s timer. - vi.advanceTimersByTime(5000); - expect(problem.hasResult()).toBe(true); - expect(triggerUpdate).toBeCalledTimes(1); - }); - - it('should not restart timer when cameraID is undefined and matches', () => { - const triggerUpdate = vi.fn(); - const problem = new StreamNotLoadingProblem(triggerUpdate); - - problem.detectDynamic({ view: 'live', mediaLoaded: false }); - vi.advanceTimersByTime(5000); - - // Same undefined cameraID: timer should continue. - problem.detectDynamic({ view: 'live', mediaLoaded: false }); - - vi.advanceTimersByTime(5000); - expect(problem.hasResult()).toBe(true); - expect(triggerUpdate).toBeCalledTimes(1); - }); - - it('should not restart timer if already timed out', () => { - const triggerUpdate = vi.fn(); - const problem = new StreamNotLoadingProblem(triggerUpdate); - - problem.detectDynamic({ view: 'live', mediaLoaded: false }); - vi.advanceTimersByTime(10000); - expect(triggerUpdate).toBeCalledTimes(1); - - // Calling detectDynamic again should not restart timer. - problem.detectDynamic({ view: 'live', mediaLoaded: false }); - vi.advanceTimersByTime(10000); - expect(triggerUpdate).toBeCalledTimes(1); - }); - }); - - describe('trigger', () => { - it('should activate immediately when camera has error and view is live', () => { - const problem = new StreamNotLoadingProblem(vi.fn()); - - problem.trigger({ cameraID: 'camera-1' }); - problem.detectDynamic({ - cameraID: 'camera-1', - view: 'live', - mediaLoaded: false, - }); - - expect(problem.hasResult()).toBe(true); - }); - - it('should not activate with only a trigger', () => { - const problem = new StreamNotLoadingProblem(vi.fn()); - - problem.trigger({ cameraID: 'camera-1' }); - - expect(problem.hasResult()).toBe(false); - }); - - it('should ignore trigger without cameraID', () => { - const problem = new StreamNotLoadingProblem(vi.fn()); - - problem.trigger(); - problem.detectDynamic({ view: 'live', mediaLoaded: false }); - - // No camera error recorded, so falls back to timeout behavior. - expect(problem.hasResult()).toBe(false); - }); - - it('should clear camera error when stream loads', () => { - const problem = new StreamNotLoadingProblem(vi.fn()); - - problem.trigger({ cameraID: 'camera-1' }); - problem.detectDynamic({ - cameraID: 'camera-1', - view: 'live', - mediaLoaded: false, - }); - - expect(problem.hasResult()).toBe(true); - - // Stream loaded clears the error for this camera. - problem.detectDynamic({ - cameraID: 'camera-1', - view: 'live', - mediaLoaded: true, - }); - - // Camera error was cleared by the successful load, so this unloaded state - // falls back to the timer (problem would not activate until after the - // timer is reached). - problem.detectDynamic({ - cameraID: 'camera-1', - view: 'live', - mediaLoaded: false, - }); - - expect(problem.hasResult()).toBe(false); - }); - - it('should not activate for a different camera', () => { - const problem = new StreamNotLoadingProblem(vi.fn()); - - problem.trigger({ cameraID: 'camera-1' }); - problem.detectDynamic({ - cameraID: 'camera-2', - view: 'live', - mediaLoaded: false, - }); - - // camera-2 has no error, so it falls back to timeout behavior. - expect(problem.hasResult()).toBe(false); - }); - }); - - describe('getNotification', () => { - it('should return notification regardless of active state', () => { - const problem = new StreamNotLoadingProblem(vi.fn()); - - const notification = problem.getNotification(); - expect(notification).toEqual( - expect.objectContaining({ - heading: expect.objectContaining({ - text: expect.any(String), - }), - link: expect.objectContaining({ - url: expect.any(String), - }), - }), - ); - }); - }); - - describe('getResult', () => { - it('should return result when timed out', () => { - const problem = new StreamNotLoadingProblem(vi.fn()); - - problem.detectDynamic({ view: 'live', mediaLoaded: false }); - vi.advanceTimersByTime(10000); - - const result = problem.getResult(); - expect(result).toEqual( - expect.objectContaining({ - icon: 'mdi:cctv-off', - severity: 'high', - notification: expect.objectContaining({ - link: expect.objectContaining({ - url: expect.any(String), - }), - }), - }), - ); - }); - - it('should return null when not timed out', () => { - const problem = new StreamNotLoadingProblem(vi.fn()); - - expect(problem.getResult()).toBeNull(); - }); - }); - - describe('destroy', () => { - it('should stop timer', () => { - const triggerUpdate = vi.fn(); - const problem = new StreamNotLoadingProblem(triggerUpdate); - - problem.detectDynamic({ view: 'live', mediaLoaded: false }); - problem.destroy(); - - vi.advanceTimersByTime(10000); - - expect(problem.hasResult()).toBe(false); - expect(triggerUpdate).not.toBeCalled(); - }); - }); -}); diff --git a/tests/card-controller/query-string-manager.test.ts b/tests/card-controller/query-string-manager.test.ts index 3e9ff60b..e1a2cd16 100644 --- a/tests/card-controller/query-string-manager.test.ts +++ b/tests/card-controller/query-string-manager.test.ts @@ -29,7 +29,9 @@ describe('QueryStringManager', () => { it('should reject malformed query string', async () => { setQueryString('BOGUS_KEY=BOGUS_VALUE'); const api = createCardAPI(); - vi.mocked(api.getMessageManager().hasMessage).mockReturnValue(true); + vi.mocked(api.getIssueManager().getStateManager().hasFullCardIssue).mockReturnValue( + true, + ); const manager = new QueryStringManager(api); expect(manager.hasViewRelatedActionsToRun()).toBeFalsy(); @@ -230,7 +232,7 @@ describe('QueryStringManager', () => { }); describe('should handle conflicting but valid actions', () => { - it('view and default with camera and substream specified', async () => { + it('should handle view and default with camera and substream specified', async () => { setQueryString( '?advanced-camera-card-action.id.clips=' + '&advanced-camera-card-action.id.live_substream_select=camera.kitchen_hd' + @@ -253,7 +255,7 @@ describe('QueryStringManager', () => { expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled(); }); - it('multiple cameras specified', async () => { + it('should handle multiple cameras specified', async () => { setQueryString( '?advanced-camera-card-action.id.camera_select=camera.kitchen' + '&advanced-camera-card-action.id.camera_select=camera.office', diff --git a/tests/card-controller/status-bar-item-manager.test.ts b/tests/card-controller/status-bar-item-manager.test.ts index 9574ac15..83554bd2 100644 --- a/tests/card-controller/status-bar-item-manager.test.ts +++ b/tests/card-controller/status-bar-item-manager.test.ts @@ -48,7 +48,7 @@ describe('StatusBarItemManager', () => { describe('should have standard status bar items', () => { describe('should have title', () => { describe('live', () => { - it('with metadata', () => { + it('should show with metadata', () => { const manager = new StatusBarItemManager(createCardAPI()); const store = createStore([ { @@ -74,7 +74,7 @@ describe('StatusBarItemManager', () => { }); }); - it('without metadata', () => { + it('should handle without metadata', () => { const manager = new StatusBarItemManager(createCardAPI()); const cameraManager = createCameraManager(); expect( @@ -87,7 +87,7 @@ describe('StatusBarItemManager', () => { }); describe('media', () => { - it('with a title', () => { + it('should show with a title', () => { const manager = new StatusBarItemManager(createCardAPI()); const cameraManager = createCameraManager(); @@ -114,7 +114,7 @@ describe('StatusBarItemManager', () => { }); }); - it('without a title', () => { + it('should handle without a title', () => { const manager = new StatusBarItemManager(createCardAPI()); const cameraManager = createCameraManager(); @@ -187,7 +187,7 @@ describe('StatusBarItemManager', () => { }); describe('should have technology', () => { - it('webrtc', () => { + it('should show webrtc icon', () => { const manager = new StatusBarItemManager(createCardAPI()); expect( @@ -200,7 +200,7 @@ describe('StatusBarItemManager', () => { }); }); - it('non-webrtc', () => { + it('should show non-webrtc string', () => { const manager = new StatusBarItemManager(createCardAPI()); expect( @@ -241,15 +241,15 @@ describe('StatusBarItemManager', () => { }); }); - describe('problems', () => { - it('should show problem items', () => { + describe('issues', () => { + it('should show issue items', () => { const manager = new StatusBarItemManager(createCardAPI()); const items = manager.calculateItems({ - problems: [ + issues: [ { key: 'config_upgrade', - problem: { + issue: { icon: 'mdi:update', severity: 'medium', notification: { @@ -258,7 +258,7 @@ describe('StatusBarItemManager', () => { icon: 'mdi:update', severity: 'medium', }, - text: 'Upgrade text', + body: { text: 'Upgrade text' }, }, }, }, @@ -281,11 +281,11 @@ describe('StatusBarItemManager', () => { ); }); - it('should not show problem items when empty', () => { + it('should not show issue items when empty', () => { const manager = new StatusBarItemManager(createCardAPI()); const items = manager.calculateItems({ - problems: [], + issues: [], }); expect(items).not.toContainEqual( @@ -295,7 +295,7 @@ describe('StatusBarItemManager', () => { ); }); - it('should not show problem items by default', () => { + it('should not show issue items by default', () => { const manager = new StatusBarItemManager(createCardAPI()); const items = manager.calculateItems(); @@ -307,7 +307,7 @@ describe('StatusBarItemManager', () => { ); }); - it('should filter out disabled problems', () => { + it('should filter out all issues when disabled', () => { const manager = new StatusBarItemManager(createCardAPI()); const items = manager.calculateItems({ @@ -318,19 +318,17 @@ describe('StatusBarItemManager', () => { height: 40, items: { engine: { enabled: true, priority: 50 }, + issues: { enabled: false, priority: 50 }, resolution: { enabled: true, priority: 50 }, severity: { enabled: true, priority: 50 }, technology: { enabled: true, priority: 50 }, title: { enabled: true, priority: 50 }, - problem_config_upgrade: { enabled: false, priority: 50 }, - problem_legacy_resource: { enabled: true, priority: 50 }, - problem_stream_not_loading: { enabled: true, priority: 50 }, }, }, - problems: [ + issues: [ { key: 'config_upgrade', - problem: { + issue: { icon: 'mdi:update', severity: 'medium', notification: { @@ -339,7 +337,7 @@ describe('StatusBarItemManager', () => { icon: 'mdi:update', severity: 'medium', }, - text: 'Upgrade text', + body: { text: 'Upgrade text' }, }, }, }, @@ -353,7 +351,7 @@ describe('StatusBarItemManager', () => { ); }); - it('should apply config overrides to problem items', () => { + it('should apply config overrides to issue items', () => { const manager = new StatusBarItemManager(createCardAPI()); const items = manager.calculateItems({ @@ -364,19 +362,17 @@ describe('StatusBarItemManager', () => { height: 40, items: { engine: { enabled: true, priority: 50 }, + issues: { enabled: true, priority: 90 }, resolution: { enabled: true, priority: 50 }, severity: { enabled: true, priority: 50 }, technology: { enabled: true, priority: 50 }, title: { enabled: true, priority: 50 }, - problem_config_upgrade: { enabled: true, priority: 90 }, - problem_legacy_resource: { enabled: true, priority: 50 }, - problem_stream_not_loading: { enabled: true, priority: 50 }, }, }, - problems: [ + issues: [ { key: 'config_upgrade', - problem: { + issue: { icon: 'mdi:update', severity: 'medium', notification: { @@ -385,7 +381,7 @@ describe('StatusBarItemManager', () => { icon: 'mdi:update', severity: 'medium', }, - text: 'Upgrade text', + body: { text: 'Upgrade text' }, }, }, }, diff --git a/tests/card-controller/triggers-manager.test.ts b/tests/card-controller/triggers-manager.test.ts index 198e9745..9fb6ade7 100644 --- a/tests/card-controller/triggers-manager.test.ts +++ b/tests/card-controller/triggers-manager.test.ts @@ -19,7 +19,7 @@ import { vi.mock('lodash-es', async () => ({ ...(await vi.importActual('lodash-es')), - throttle: vi.fn((fn) => fn), + throttle: vi.fn((fn) => Object.assign(fn, { cancel: vi.fn() })), })); const baseTriggersConfig: TriggersOptions = { @@ -542,6 +542,56 @@ describe('TriggersManager', () => { expect(manager.isTriggered()).toBe(false); }); + it('should stop timers on reset while untrigger delay is pending', async () => { + const api = createTriggerAPI({ + config: { + untrigger_delay_seconds: 10, + }, + }); + const manager = new TriggersManager(api); + + // Trigger then end to start the untrigger delay timer. + await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'e1', type: 'new' }); + await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'e1', type: 'end' }); + expect(manager.isTriggered()).toBe(true); + + // Reset clears all states and timers. + manager.reset(); + expect(manager.isTriggered()).toBe(false); + + // Advancing past the delay should not cause errors or state changes. + vi.setSystemTime(add(start, { seconds: 15 })); + vi.runOnlyPendingTimers(); + await flushPromises(); + + expect(manager.isTriggered()).toBe(false); + }); + + it('should stop force untrigger timer on reset', async () => { + const api = createTriggerAPI({ + config: { + untrigger_delay_seconds: 0, + untrigger_force_seconds: 10, + }, + }); + const manager = new TriggersManager(api); + + // Trigger to start the force untrigger timer. + await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'e1', type: 'new' }); + expect(manager.isTriggered()).toBe(true); + + // Reset clears all states and timers. + manager.reset(); + expect(manager.isTriggered()).toBe(false); + + // Advancing past the force timer should not cause errors. + vi.setSystemTime(add(start, { seconds: 15 })); + vi.runOnlyPendingTimers(); + await flushPromises(); + + expect(manager.isTriggered()).toBe(false); + }); + it('should untrigger immediately when untrigger_delay_seconds is 0', async () => { const api = createTriggerAPI({ config: { diff --git a/tests/card-controller/view/factory.test.ts b/tests/card-controller/view/factory.test.ts index 4afbb1cf..9d6ba272 100644 --- a/tests/card-controller/view/factory.test.ts +++ b/tests/card-controller/view/factory.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { FoldersManager } from '../../../src/card-controller/folders/manager'; import { ViewFactory } from '../../../src/card-controller/view/factory'; -import { ViewModifier } from '../../../src/card-controller/view/types'; +import { ViewIncompatible, ViewModifier } from '../../../src/card-controller/view/types'; import { AdvancedCameraCardView } from '../../../src/config/schema/common/const'; import { ViewDisplayMode } from '../../../src/config/schema/common/display'; import { View } from '../../../src/view/view'; @@ -47,7 +47,7 @@ describe('getViewDefault', () => { ); const factory = new ViewFactory(api); - expect(() => factory.getViewDefault()).toThrowError(/No cameras support this view/); + expect(() => factory.getViewDefault()).toThrowError(ViewIncompatible); }); it('should use folders view as default when folders exist without cameras', () => { @@ -241,7 +241,7 @@ describe('getViewByParameters', () => { view: 'snapshots', }, }), - ).toThrowError(/No cameras support this view/); + ).toThrowError(ViewIncompatible); }); describe('should handle no camera for view with failsafe', () => { @@ -321,7 +321,7 @@ describe('getViewByParameters', () => { view: 'snapshots', }, }), - ).toThrowError(/The selected camera or media does not support this view/); + ).toThrowError(ViewIncompatible); }); it('should choose live view with failsafe', () => { @@ -369,7 +369,7 @@ describe('getViewByParameters', () => { view: 'snapshots', }, }), - ).toThrowError(/The selected camera or media does not support this view/); + ).toThrowError(ViewIncompatible); }); }); diff --git a/tests/card-controller/view/item-manager.test.ts b/tests/card-controller/view/item-manager.test.ts index 5c75a433..8af3b8b8 100644 --- a/tests/card-controller/view/item-manager.test.ts +++ b/tests/card-controller/view/item-manager.test.ts @@ -94,9 +94,15 @@ describe('ViewItemManager', () => { vi.mocked(homeAssistantGetSignedURLIfNecessary).mockRejectedValue(signError); expect(await manager.download(item)).toBe(false); - expect(api.getMessageManager().setErrorIfHigherPriority).toHaveBeenCalledWith( + expect(api.getNotificationManager().setNotification).toBeCalledWith( expect.objectContaining({ - message: 'Could not sign media URL for download', + heading: expect.objectContaining({ + text: 'Download failed', + icon: 'mdi:download-off', + }), + body: expect.objectContaining({ + text: 'Could not sign media URL for download', + }), }), ); expect(consoleSpy).toHaveBeenCalledWith('sign-error'); @@ -169,9 +175,15 @@ describe('ViewItemManager', () => { const item = new TestViewMedia({ cameraID: null, folder: null }); expect(await manager.download(item)).toBe(false); - expect(api.getMessageManager().setErrorIfHigherPriority).toHaveBeenCalledWith( + expect(api.getNotificationManager().setNotification).toBeCalledWith( expect.objectContaining({ - message: 'No media to download', + heading: expect.objectContaining({ + text: 'Download failed', + icon: 'mdi:download-off', + }), + body: expect.objectContaining({ + text: 'No media to download', + }), }), ); }); diff --git a/tests/card-controller/view/view-manager.test.ts b/tests/card-controller/view/view-manager.test.ts index af74b76b..4cdbba0f 100644 --- a/tests/card-controller/view/view-manager.test.ts +++ b/tests/card-controller/view/view-manager.test.ts @@ -26,7 +26,7 @@ const createInitializedCardAPI = (initialized?: boolean): CardController => { }; describe('should act correctly when view is set', () => { - it('basic view', () => { + it('should set basic view', () => { const view = createView({ view: 'live', camera: 'camera', @@ -45,17 +45,17 @@ describe('should act correctly when view is set', () => { expect(manager.hasView()).toBeTruthy(); expect(api.getMediaLoadedInfoManager().clear).toBeCalled(); expect(api.getCardElementManager().scrollReset).toBeCalled(); - expect(api.getMessageManager().reset).toBeCalled(); expect(api.getStyleManager().setExpandedMode).toBeCalled(); expect(api.getConditionStateManager()?.setState).toBeCalledWith({ view: 'live', camera: 'camera', displayMode: 'grid', + targetID: 'camera', }); expect(api.getCardElementManager().update).toBeCalled(); }); - it('view with minor changes without media clearing or scroll', () => { + it('should set view with minor changes without media clearing or scroll', () => { const view_1 = createView({ view: 'live', camera: 'camera', @@ -89,7 +89,7 @@ describe('should act correctly when view is set', () => { }); }); -it('setViewWithMergedContext', () => { +it('should set view with merged context', () => { const api = createInitializedCardAPI(); const factory = mock(); @@ -113,14 +113,14 @@ it('setViewWithMergedContext', () => { expect(manager.getView()?.context).toEqual(context); }); -it('getEpoch', () => { +it('should return epoch', () => { const factory = mock(); const manager = new ViewManager(createCardAPI(), { viewFactory: factory }); expect(manager.getEpoch()).toBeTruthy(); expect(manager.getEpoch().manager).toBe(manager); }); -it('reset', () => { +it('should reset view', () => { const factory = mock(); const manager = new ViewManager(createInitializedCardAPI(), { viewFactory: factory }); @@ -166,7 +166,7 @@ describe('should not set view without cameras being initialized', () => { }); }); -it('setViewDefault', () => { +it('should set view default', () => { const factory = mock(); factory.getViewDefault.mockReturnValue(createView()); @@ -177,7 +177,7 @@ it('setViewDefault', () => { expect(manager.getView()?.camera).toBe('camera'); }); -it('setViewByParameters', () => { +it('should set view by parameters', () => { const factory = mock(); factory.getViewByParameters.mockReturnValue(createView()); @@ -188,7 +188,7 @@ it('setViewByParameters', () => { expect(manager.getView()?.camera).toBe('camera'); }); -it('setViewDefaultWithNewQuery', async () => { +it('should set view default with new query', async () => { const viewFactory = mock(); viewFactory.getViewDefault.mockReturnValue(createView()); @@ -205,7 +205,7 @@ it('setViewDefaultWithNewQuery', async () => { expect(manager.getView()?.camera).toBe('camera'); }); -it('setViewByParametersWithNewQuery', async () => { +it('should set view by parameters with new query', async () => { const viewFactory = mock(); viewFactory.getViewByParameters.mockReturnValue(createView()); @@ -222,7 +222,7 @@ it('setViewByParametersWithNewQuery', async () => { expect(manager.getView()?.camera).toBe('camera'); }); -it('setViewByParametersWithExistingQuery', async () => { +it('should set view by parameters with existing query', async () => { const viewFactory = mock(); viewFactory.getViewByParameters.mockReturnValue(createView()); @@ -241,34 +241,122 @@ it('setViewByParametersWithExistingQuery', async () => { }); describe('should handle exceptions', () => { - it('should handle exceptions in sync calls', () => { - const error = new Error(); + it('should retry with failSafe when no existing view in sync calls', () => { const viewFactory = mock(); - viewFactory.getViewDefault.mockImplementation(() => { - throw error; - }); + const failSafeView = createView(); + const error = new Error('message'); + viewFactory.getViewDefault + .mockImplementationOnce(() => { + throw error; + }) + .mockReturnValueOnce(failSafeView); const api = createInitializedCardAPI(); const manager = new ViewManager(api, { viewFactory: viewFactory }); manager.setViewDefault(); - expect(manager.hasView()).toBeFalsy(); - expect(api.getMessageManager().setErrorIfHigherPriority).toBeCalledWith(error); + expect(manager.hasView()).toBeTruthy(); + expect(manager.getView()).toBe(failSafeView); + expect(viewFactory.getViewDefault).toBeCalledWith( + expect.objectContaining({ baseView: null, failSafe: true }), + ); + expect(api.getIssueManager().trigger).toBeCalledWith('view_incompatible', { + error, + }); + expect(api.getNotificationManager().setNotification).not.toBeCalled(); }); - it('should handle viewFactory exceptions in async calls', async () => { - const error = new Error(); + it('should not retry with failSafe when existing view in sync calls', () => { const viewFactory = mock(); - viewFactory.getViewDefault.mockImplementation(() => { - throw error; + const existingView = createView(); + const error = new Error('message'); + viewFactory.getViewDefault + .mockReturnValueOnce(existingView) + .mockImplementationOnce(() => { + throw error; + }); + + const api = createInitializedCardAPI(); + const manager = new ViewManager(api, { viewFactory: viewFactory }); + manager.setViewDefault(); + manager.setViewDefault(); + + expect(manager.getView()).toBe(existingView); + expect(viewFactory.getViewDefault).toBeCalledTimes(2); + expect(api.getIssueManager().trigger).toBeCalledWith('view_incompatible', { + error, }); + }); + + it('should retry with failSafe when no existing view in async calls', async () => { + const viewFactory = mock(); + const failSafeView = createView(); + const error = new Error('message'); + viewFactory.getViewDefault + .mockImplementationOnce(() => { + throw error; + }) + .mockReturnValueOnce(failSafeView); const api = createInitializedCardAPI(); const manager = new ViewManager(api, { viewFactory: viewFactory }); await manager.setViewDefaultWithNewQuery(); + expect(manager.hasView()).toBeTruthy(); + expect(viewFactory.getViewDefault).toBeCalledWith( + expect.objectContaining({ baseView: null, failSafe: true }), + ); + expect(api.getIssueManager().trigger).toBeCalledWith('view_incompatible', { + error, + }); + expect(api.getNotificationManager().setNotification).not.toBeCalled(); + }); + + it('should not retry with failSafe when existing view in async calls', async () => { + const viewFactory = mock(); + const existingView = createView(); + const error = new Error('message'); + viewFactory.getViewDefault + .mockReturnValueOnce(existingView) + .mockImplementationOnce(() => { + throw error; + }); + + const api = createInitializedCardAPI(); + const manager = new ViewManager(api, { viewFactory: viewFactory }); + manager.setViewDefault(); + await manager.setViewDefaultWithNewQuery(); + + expect(manager.getView()).not.toBeNull(); + expect(viewFactory.getViewDefault).toBeCalledTimes(2); + expect(api.getIssueManager().trigger).toBeCalledWith('view_incompatible', { + error, + }); + }); + + it('should reset view_incompatible on successful view set', () => { + const viewFactory = mock(); + viewFactory.getViewDefault.mockReturnValue(createView()); + + const api = createInitializedCardAPI(); + const manager = new ViewManager(api, { viewFactory }); + manager.setViewDefault(); + + expect(api.getIssueManager().reset).toBeCalledWith('view_incompatible'); + }); + + it('should return null when failSafe view factory also throws', () => { + const viewFactory = mock(); + viewFactory.getViewDefault.mockImplementation(() => { + throw new Error('message'); + }); + + const api = createInitializedCardAPI(); + const manager = new ViewManager(api, { viewFactory }); + manager.setViewDefault(); + expect(manager.hasView()).toBeFalsy(); - expect(api.getMessageManager().setErrorIfHigherPriority).toBeCalledWith(error); + expect(viewFactory.getViewDefault).toBeCalledTimes(2); }); it('should handle viewQueryExecutor exceptions in async calls', async () => { @@ -290,7 +378,79 @@ describe('should handle exceptions', () => { expect(manager.hasView()).toBeTruthy(); // But an error will also be generated. - expect(api.getMessageManager().setErrorIfHigherPriority).toBeCalledWith(error); + expect(api.getIssueManager().trigger).toBeCalledWith( + 'media_query', + expect.objectContaining({ error }), + ); + + // The loading flag must be cleared on error — otherwise gallery/viewer + // components render "Awaiting media" indefinitely on top of the error + // notification. + expect(manager.getView()?.context?.loading?.query).toBeUndefined(); + }); + + it('should reset media_query when navigating via the sync path', () => { + const viewFactory = mock(); + viewFactory.getViewByParameters.mockReturnValue(createView({ view: 'live' })); + + const api = createInitializedCardAPI(); + const manager = new ViewManager(api, { viewFactory }); + manager.setViewByParameters(); + + expect(api.getIssueManager().reset).toBeCalledWith('media_query'); + }); + + it('should tolerate the view being reset during a failing async query', async () => { + const error = new Error(); + const viewFactory = mock(); + viewFactory.getViewDefault.mockReturnValue(createView()); + const viewQueryExecutor = mock(); + + const api = createInitializedCardAPI(); + const manager = new ViewManager(api, { + viewFactory: viewFactory, + viewQueryExecutor: viewQueryExecutor, + }); + + // Concurrent reset during the await — clears `_view` before the + // rejection is processed. The error path must not crash on the null + // view when attempting to clear the loading flag. + viewQueryExecutor.getNewQueryModifiers.mockImplementation(async () => { + manager.reset(); + throw error; + }); + + await manager.setViewDefaultWithNewQuery(); + + expect(manager.getView()).toBeNull(); + expect(api.getIssueManager().trigger).toBeCalledWith( + 'media_query', + expect.objectContaining({ error }), + ); + }); + + it('should reset media_query at the start of a new async query', async () => { + const viewFactory = mock(); + viewFactory.getViewDefault.mockReturnValue(createView()); + const viewQueryExecutor = mock(); + viewQueryExecutor.getNewQueryModifiers.mockResolvedValue(null); + + const api = createInitializedCardAPI(); + const manager = new ViewManager(api, { + viewFactory: viewFactory, + viewQueryExecutor: viewQueryExecutor, + }); + + await manager.setViewDefaultWithNewQuery(); + + // Reset is called twice: once at dispatch (supersedes any prior error) + // and once after success (clears on confirmed success). + expect(api.getIssueManager().reset).toBeCalledWith('media_query'); + expect( + vi + .mocked(api.getIssueManager().reset) + .mock.calls.filter(([key]) => key === 'media_query').length, + ).toBe(2); }); }); @@ -431,7 +591,7 @@ describe('hasMajorMediaChange', () => { }); describe('should initialize', () => { - it('without querystring', async () => { + it('should initialize without querystring', async () => { const view = createView({ view: 'live', camera: 'camera', @@ -445,12 +605,12 @@ describe('should initialize', () => { viewFactory: viewFactory, }); - expect(await manager.initialize()).toBeTruthy(); + await manager.initialize(); expect(manager.getView()).toBe(view); }); - it('with querystring', async () => { + it('should initialize with querystring', async () => { const api = createCardAPI(); const factory = mock(); const manager = new ViewManager(api, { viewFactory: factory }); @@ -458,7 +618,7 @@ describe('should initialize', () => { true, ); - expect(await manager.initialize()).toBeTruthy(); + await manager.initialize(); expect(manager.hasView()).toBeFalsy(); }); @@ -480,7 +640,8 @@ describe('should apply async view modifications', () => { }), ]); - const manager = new ViewManager(createInitializedCardAPI(), { + const api = createInitializedCardAPI(); + const manager = new ViewManager(api, { viewFactory: viewFactory, viewQueryExecutor: viewQueryExecutor, }); @@ -490,6 +651,7 @@ describe('should apply async view modifications', () => { expect(manager.getView()?.query).toBe(query); expect(manager.getView()?.queryResults).toBe(queryResults); expect(manager.getView()?.context?.loading?.query).toBeUndefined(); + expect(api.getIssueManager().reset).toBeCalledWith('media_query'); }); it('should not apply modifications if there is a major media change', async () => { diff --git a/tests/components-lib/media/details-controller.test.ts b/tests/components-lib/media/notification-controller.test.ts similarity index 83% rename from tests/components-lib/media/details-controller.test.ts rename to tests/components-lib/media/notification-controller.test.ts index b36b7c59..04726ebd 100644 --- a/tests/components-lib/media/details-controller.test.ts +++ b/tests/components-lib/media/notification-controller.test.ts @@ -7,9 +7,9 @@ import { CardController } from '../../../src/card-controller/controller'; import { ViewItemManager } from '../../../src/card-controller/view/item-manager'; import { ViewManagerEpoch } from '../../../src/card-controller/view/types'; import { - MediaDetailsController, + MediaNotificationController, NotificationControlsContext, -} from '../../../src/components-lib/media/details-controller'; +} from '../../../src/components-lib/media/notification-controller'; import { NotificationControl } from '../../../src/config/schema/actions/types'; import { formatDateAndTime } from '../../../src/utils/basic'; import { downloadMedia, navigateToTimeline } from '../../../src/utils/media-actions'; @@ -34,7 +34,7 @@ async function executeControlAction( await action?.execute(api); } -describe('MediaDetailsController', () => { +describe('MediaNotificationController', () => { describe('should set heading', () => { it('should set heading on event with what, tags and score', () => { const item = new TestViewMedia({ @@ -43,7 +43,7 @@ describe('MediaDetailsController', () => { score: 0.5, }); - const controller = new MediaDetailsController(); + const controller = new MediaNotificationController(); controller.calculate(null, item); expect(controller.getHeading()?.text).toBe('Person, Car: Tag1, Tag2 50.00%'); }); @@ -53,7 +53,7 @@ describe('MediaDetailsController', () => { tags: ['tag1', 'tag2'], }); - const controller = new MediaDetailsController(); + const controller = new MediaNotificationController(); controller.calculate(null, item); expect(controller.getHeading()?.text).toBe('Tag1, Tag2'); }); @@ -63,7 +63,7 @@ describe('MediaDetailsController', () => { what: ['person', 'car'], }); - const controller = new MediaDetailsController(); + const controller = new MediaNotificationController(); controller.calculate(null, item); expect(controller.getHeading()?.text).toBe('Person, Car'); }); @@ -76,7 +76,7 @@ describe('MediaDetailsController', () => { score: null, }); - const controller = new MediaDetailsController(); + const controller = new MediaNotificationController(); controller.calculate(null, item); expect(controller.getHeading()).toBeNull(); }); @@ -92,7 +92,7 @@ describe('MediaDetailsController', () => { mediaType: ViewMediaType.Recording, }); - const controller = new MediaDetailsController(); + const controller = new MediaNotificationController(); controller.calculate(cameraManager, item); expect(controller.getHeading()?.text).toBe('Camera Title'); }); @@ -102,7 +102,7 @@ describe('MediaDetailsController', () => { mediaType: ViewMediaType.Recording, }); - const controller = new MediaDetailsController(); + const controller = new MediaNotificationController(); controller.calculate(null, item); expect(controller.getHeading()).toBeNull(); }); @@ -110,7 +110,7 @@ describe('MediaDetailsController', () => { it('should set no heading on folder', () => { const item = new ViewFolder(createFolder(), []); - const controller = new MediaDetailsController(); + const controller = new MediaNotificationController(); controller.calculate(null, item); expect(controller.getHeading()).toBeNull(); }); @@ -124,9 +124,9 @@ describe('MediaDetailsController', () => { where: ['where1', 'where2'], }); - const controller = new MediaDetailsController(); + const controller = new MediaNotificationController(); controller.calculate(null, item); - expect(controller.getDetails()).toContainEqual({ + expect(controller.getMetadata()).toContainEqual({ text: 'Test Event', icon: 'mdi:rename', tooltip: 'Title', @@ -138,9 +138,9 @@ describe('MediaDetailsController', () => { title: 'Test Event', }); - const controller = new MediaDetailsController(); + const controller = new MediaNotificationController(); controller.calculate(null, item); - expect(controller.getDetails()).toEqual([ + expect(controller.getMetadata()).toEqual([ { text: 'Test Event', }, @@ -153,9 +153,9 @@ describe('MediaDetailsController', () => { startTime: new Date('2025-05-22T21:12:00Z'), }); - const controller = new MediaDetailsController(); + const controller = new MediaNotificationController(); controller.calculate(null, item); - expect(controller.getDetails()).not.toContainEqual( + expect(controller.getMetadata()).not.toContainEqual( expect.objectContaining({ text: 'Test Event', }), @@ -169,11 +169,11 @@ describe('MediaDetailsController', () => { startTime, }); - const controller = new MediaDetailsController(); + const controller = new MediaNotificationController(); controller.calculate(null, item); // Use formatDateAndTime to generate expected value (formats in local time with seconds) - expect(controller.getDetails()).toContainEqual({ + expect(controller.getMetadata()).toContainEqual({ text: formatDateAndTime(startTime, true), tooltip: 'Start', icon: 'mdi:calendar-clock-outline', @@ -187,9 +187,9 @@ describe('MediaDetailsController', () => { endTime: new Date('2025-05-18T17:04:00Z'), }); - const controller = new MediaDetailsController(); + const controller = new MediaNotificationController(); controller.calculate(null, item); - expect(controller.getDetails()).toContainEqual({ + expect(controller.getMetadata()).toContainEqual({ text: '1m 0s', tooltip: 'Duration', icon: 'mdi:clock-outline', @@ -203,10 +203,10 @@ describe('MediaDetailsController', () => { inProgress: true, }); - const controller = new MediaDetailsController(); + const controller = new MediaNotificationController(); controller.calculate(null, item); - expect(controller.getDetails()).toContainEqual({ - text: 'In Progress', + expect(controller.getMetadata()).toContainEqual({ + text: 'In progress...', tooltip: 'Duration', icon: 'mdi:clock-outline', }); @@ -219,10 +219,10 @@ describe('MediaDetailsController', () => { inProgress: true, }); - const controller = new MediaDetailsController(); + const controller = new MediaNotificationController(); controller.calculate(null, item); - expect(controller.getDetails()).toContainEqual({ - text: '1m 0s In Progress', + expect(controller.getMetadata()).toContainEqual({ + text: '1m 0s In progress...', tooltip: 'Duration', icon: 'mdi:clock-outline', }); @@ -240,9 +240,9 @@ describe('MediaDetailsController', () => { cameraID: 'camera_1', }); - const controller = new MediaDetailsController(); + const controller = new MediaNotificationController(); controller.calculate(cameraManager, item); - expect(controller.getDetails()).toContainEqual({ + expect(controller.getMetadata()).toContainEqual({ text: 'Camera Title', tooltip: 'Camera', icon: 'mdi:cctv', @@ -255,9 +255,9 @@ describe('MediaDetailsController', () => { where: ['where1', 'where2'], }); - const controller = new MediaDetailsController(); + const controller = new MediaNotificationController(); controller.calculate(null, item); - expect(controller.getDetails()).toContainEqual({ + expect(controller.getMetadata()).toContainEqual({ text: 'Where1, Where2', tooltip: 'Where', icon: 'mdi:map-marker-outline', @@ -270,9 +270,9 @@ describe('MediaDetailsController', () => { tags: ['tag1', 'tag2'], }); - const controller = new MediaDetailsController(); + const controller = new MediaNotificationController(); controller.calculate(null, item); - expect(controller.getDetails()).toContainEqual({ + expect(controller.getMetadata()).toContainEqual({ text: 'Tag1, Tag2', tooltip: 'Tag', icon: 'mdi:tag', @@ -283,11 +283,11 @@ describe('MediaDetailsController', () => { const item = new TestViewMedia(); const seekTime = new Date('2025-05-20T07:14:57Z'); - const controller = new MediaDetailsController(); + const controller = new MediaNotificationController(); controller.calculate(null, item, seekTime); // Use format() to generate expected value (formats in local time) - expect(controller.getDetails()).toContainEqual({ + expect(controller.getMetadata()).toContainEqual({ text: format(seekTime, 'HH:mm:ss'), tooltip: 'Seek', icon: 'mdi:clock-fast', @@ -300,7 +300,7 @@ describe('MediaDetailsController', () => { severity: 'high', }); - const controller = new MediaDetailsController(); + const controller = new MediaNotificationController(); controller.calculate(null, item); const heading = controller.getHeading(); expect(heading?.text).toBe('Review Title'); @@ -316,7 +316,7 @@ describe('MediaDetailsController', () => { severity: null, }); - const controller = new MediaDetailsController(); + const controller = new MediaNotificationController(); controller.calculate(null, item); const heading = controller.getHeading(); expect(heading?.text).toBe('Review Title'); @@ -329,16 +329,16 @@ describe('MediaDetailsController', () => { title: null, }); - const controller = new MediaDetailsController(); + const controller = new MediaNotificationController(); controller.calculate(null, item); expect(controller.getHeading()).toBeNull(); }); it('should calculate with null item', () => { - const controller = new MediaDetailsController(); + const controller = new MediaNotificationController(); controller.calculate(null, undefined); expect(controller.getHeading()).toBeNull(); - expect(controller.getDetails()).toEqual([]); + expect(controller.getMetadata()).toEqual([]); }); }); @@ -354,25 +354,25 @@ describe('MediaDetailsController', () => { description: 'Test Description', }); - const controller = new MediaDetailsController(); + const controller = new MediaNotificationController(); controller.calculate(null, item); const notification = controller.getNotification(); expect(notification.heading?.text).toBe('Person'); - expect(notification.details).toContainEqual({ + expect(notification.metadata).toContainEqual({ text: 'Test Title', }); - expect(notification.text).toBe('Test Description'); + expect(notification.body).toEqual({ text: 'Test Description' }); }); it('should get notification without media', () => { const item = new ViewFolder(createFolder(), []); - const controller = new MediaDetailsController(); + const controller = new MediaNotificationController(); controller.calculate(null, item); const notification = controller.getNotification(); - expect(notification.text).toBeUndefined(); + expect(notification.body).toBeUndefined(); }); it('should get notification with null description', () => { @@ -380,11 +380,11 @@ describe('MediaDetailsController', () => { description: null, }); - const controller = new MediaDetailsController(); + const controller = new MediaNotificationController(); controller.calculate(null, item); const notification = controller.getNotification(); - expect(notification.text).toBeUndefined(); + expect(notification.body).toBeUndefined(); }); it('should get notification with controls', async () => { @@ -408,7 +408,7 @@ describe('MediaDetailsController', () => { viewManagerEpoch: viewManagerEpoch, }; - const controller = new MediaDetailsController(); + const controller = new MediaNotificationController(); controller.calculate(null, item); const notification = controller.getNotification(context); @@ -469,7 +469,7 @@ describe('MediaDetailsController', () => { }, }; - const controller = new MediaDetailsController(); + const controller = new MediaNotificationController(); controller.calculate(null, item); const notification = controller.getNotification(context); @@ -494,7 +494,7 @@ describe('MediaDetailsController', () => { }, }; - const controller = new MediaDetailsController(); + const controller = new MediaNotificationController(); controller.calculate(null, item); const notification = controller.getNotification(context); @@ -507,7 +507,7 @@ describe('MediaDetailsController', () => { }); const context = {}; - const controller = new MediaDetailsController(); + const controller = new MediaNotificationController(); controller.calculate(null, item); const notification = controller.getNotification(context); @@ -515,11 +515,11 @@ describe('MediaDetailsController', () => { }); it('should get empty controls when item is null', () => { - const controller = new MediaDetailsController(); + const ctrl = new MediaNotificationController(); // Directly call protected method via casting to test the null item branch. // Use cast to unknown first to avoid any-related lint errors. const controls = ( - controller as unknown as { + ctrl as unknown as { _getControls: (context: NotificationControlsContext) => NotificationControl[]; } )._getControls({}); diff --git a/tests/components-lib/message/controller.test.ts b/tests/components-lib/message/controller.test.ts deleted file mode 100644 index ff740dc0..00000000 --- a/tests/components-lib/message/controller.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import yaml from 'js-yaml'; -import { describe, expect, it } from 'vitest'; -import { MessageController } from '../../../src/components-lib/message/controller'; -import { Link } from '../../../src/config/schema/common/link'; -import { TROUBLESHOOTING_URL } from '../../../src/const'; -import { localize } from '../../../src/localize/localize'; -import { Message, MessageType } from '../../../src/types'; - -describe('MessageController', () => { - describe('should return the correct message string', () => { - it('should return simple message string', () => { - const controller = new MessageController(); - const message: Message = { - message: 'Message', - type: 'info', - }; - expect(controller.getMessageString(message)).toBe('Message'); - }); - - it('should embed simple string context', () => { - const controller = new MessageController(); - const message: Message = { - message: 'Message', - context: 'Context', - type: 'info', - }; - expect(controller.getMessageString(message)).toBe('Message: Context'); - }); - }); - - describe('should return the correct icon', () => { - describe('when icon is specified', () => { - it.each([['info' as const], ['error' as const], ['connection' as const]])( - '%s', - (type: MessageType) => { - const controller = new MessageController(); - const message: Message = { - message: 'Message', - icon: 'mdi:car', - type, - }; - expect(controller.getIcon(message)).toBe('mdi:car'); - }, - ); - }); - - it('when type is an error', () => { - const controller = new MessageController(); - const message: Message = { - message: 'Message', - type: 'error', - }; - expect(controller.getIcon(message)).toBe('mdi:alert-circle'); - }); - - it('by default', () => { - const controller = new MessageController(); - const message: Message = { - message: 'Message', - }; - expect(controller.getIcon(message)).toBe('mdi:information-outline'); - }); - }); - - describe('should show troubleshooting link', () => { - it('should show for errors', () => { - const controller = new MessageController(); - const message: Message = { message: 'Error message', type: 'error' }; - expect(controller.getLink(message)).toEqual({ - url: TROUBLESHOOTING_URL, - title: localize('error.troubleshooting'), - }); - }); - - describe('should not show for other types', () => { - it.each([['info' as const], ['connection' as const]])( - '%s', - (type: MessageType) => { - const controller = new MessageController(); - const message: Message = { - message: 'Message', - icon: 'mdi:car', - type, - }; - expect(controller.getLink(message)).toBeNull(); - }, - ); - }); - - describe('should show correct URL', () => { - it('by default', () => { - const controller = new MessageController(); - const message: Message = { message: 'Error message', type: 'error' }; - expect(controller.getLink(message)?.url).toBe(TROUBLESHOOTING_URL); - }); - - it('when specified', () => { - const controller = new MessageController(); - const url: Link = { - url: 'link', - title: 'title', - }; - const message: Message = { - message: 'Error message', - type: 'error', - link: url, - }; - expect(controller.getLink(message)).toBe(url); - }); - }); - }); - - describe('should get context strings', () => { - it('for no context', () => { - const controller = new MessageController(); - const message: Message = { - message: 'Message', - type: 'info', - }; - expect(controller.getContextStrings(message)).toEqual([]); - }); - - it('for simple string', () => { - const controller = new MessageController(); - const message: Message = { - message: 'Message', - context: 'Context', - type: 'info', - }; - expect(controller.getContextStrings(message)).toEqual(['Context']); - }); - - it('for object', () => { - const controller = new MessageController(); - const obj = { one: 1, two: 2 }; - const message: Message = { - message: 'Message', - context: obj, - type: 'info', - }; - expect(controller.getContextStrings(message)).toEqual([yaml.dump(obj)]); - }); - - it('for array', () => { - const controller = new MessageController(); - const array = ['one', 'two']; - const message: Message = { - message: 'Message', - context: array, - type: 'info', - }; - expect(controller.getContextStrings(message)).toEqual( - array.map((item) => yaml.dump(item)), - ); - }); - }); -}); diff --git a/tests/components-lib/message/dispatch.test.ts b/tests/components-lib/message/dispatch.test.ts deleted file mode 100644 index c6959f19..00000000 --- a/tests/components-lib/message/dispatch.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { expect, it, vi } from 'vitest'; -import { dispatchAdvancedCameraCardErrorEvent } from '../../../src/components-lib/message/dispatch'; -import { AdvancedCameraCardError } from '../../../src/types'; - -// @vitest-environment jsdom -it('should ignore non-error', () => { - const element = document.createElement('div'); - const handler = vi.fn(); - element.addEventListener('advanced-camera-card:message', handler); - - dispatchAdvancedCameraCardErrorEvent(element, 'NOT_ADVANCED_CAMERA_CARD_EVENT'); - - expect(handler).not.toBeCalled(); -}); - -it('should dispatch error', () => { - const element = document.createElement('div'); - const handler = vi.fn(); - element.addEventListener('advanced-camera-card:message', handler); - - dispatchAdvancedCameraCardErrorEvent(element, new Error('ERROR')); - - expect(handler).toBeCalledWith( - expect.objectContaining({ - detail: expect.objectContaining({ - message: 'ERROR', - }), - }), - ); -}); - -it('should dispatch error with context', () => { - const element = document.createElement('div'); - const handler = vi.fn(); - element.addEventListener('advanced-camera-card:message', handler); - - dispatchAdvancedCameraCardErrorEvent( - element, - new AdvancedCameraCardError('ERROR', 'CONTEXT'), - ); - - expect(handler).toBeCalledWith( - expect.objectContaining({ - detail: expect.objectContaining({ - message: 'ERROR', - context: 'CONTEXT', - }), - }), - ); -}); diff --git a/tests/components-lib/notification/action.test.ts b/tests/components-lib/notification/action.test.ts new file mode 100644 index 00000000..a4f854c5 --- /dev/null +++ b/tests/components-lib/notification/action.test.ts @@ -0,0 +1,93 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { dispatchActionExecutionRequest } from '../../../src/card-controller/actions/utils/execution-request'; +import { handleControlAction } from '../../../src/components-lib/notification/action'; +import { NotificationControl } from '../../../src/config/schema/actions/types'; +import { + getActionConfigGivenAction, + stopEventFromActivatingCardWideActions, +} from '../../../src/utils/action'; + +vi.mock('../../../src/card-controller/actions/utils/execution-request.js'); +vi.mock('../../../src/utils/action.js'); + +describe('handleControlAction', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + const createControl = ( + overrides?: Partial, + ): NotificationControl => ({ + dismiss: true, + ...overrides, + }); + + it('should stop the event from activating card-wide actions', () => { + const ev = new CustomEvent('action', { detail: { action: 'tap' } }); + const host = document.createElement('div'); + handleControlAction(ev, createControl(), host); + + expect(stopEventFromActivatingCardWideActions).toBeCalledWith(ev); + }); + + it('should dispatch action when getActionConfigGivenAction returns an action', () => { + const action = { action: 'navigate' as const, navigation_path: '/foo' }; + vi.mocked(getActionConfigGivenAction).mockReturnValue(action); + + const ev = new CustomEvent('action', { detail: { action: 'tap' } }); + const control = createControl({ actions: { tap_action: action } }); + const host = document.createElement('div'); + + handleControlAction(ev, control, host); + + expect(dispatchActionExecutionRequest).toBeCalledWith(host, { + actions: [action], + }); + }); + + it('should not dispatch when getActionConfigGivenAction returns null', () => { + vi.mocked(getActionConfigGivenAction).mockReturnValue(null); + + const ev = new CustomEvent('action', { detail: { action: 'tap' } }); + const host = document.createElement('div'); + + handleControlAction(ev, createControl(), host); + + expect(dispatchActionExecutionRequest).not.toBeCalled(); + }); + + it('should call onDismiss when dismiss is not false', () => { + vi.mocked(getActionConfigGivenAction).mockReturnValue(null); + + const ev = new CustomEvent('action', { detail: { action: 'tap' } }); + const host = document.createElement('div'); + const onDismiss = vi.fn(); + + handleControlAction(ev, createControl({ dismiss: true }), host, onDismiss); + + expect(onDismiss).toBeCalled(); + }); + + it('should not call onDismiss when dismiss is false', () => { + vi.mocked(getActionConfigGivenAction).mockReturnValue(null); + + const ev = new CustomEvent('action', { detail: { action: 'tap' } }); + const host = document.createElement('div'); + const onDismiss = vi.fn(); + + handleControlAction(ev, createControl({ dismiss: false }), host, onDismiss); + + expect(onDismiss).not.toBeCalled(); + }); + + it('should not call onDismiss when no onDismiss is provided', () => { + vi.mocked(getActionConfigGivenAction).mockReturnValue(null); + + const ev = new CustomEvent('action', { detail: { action: 'tap' } }); + const host = document.createElement('div'); + + // Should not throw when onDismiss is undefined + expect(() => handleControlAction(ev, createControl(), host)).not.toThrow(); + }); +}); diff --git a/tests/components-lib/notification/data-to-context.test.ts b/tests/components-lib/notification/data-to-context.test.ts new file mode 100644 index 00000000..39ba6dcb --- /dev/null +++ b/tests/components-lib/notification/data-to-context.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { dataToContext } from '../../../src/components-lib/notification/data-to-context'; + +describe('dataToContext', () => { + it('should return an array of string items unchanged when input is an array of strings', () => { + expect(dataToContext(['line one', 'line two'])).toEqual(['line one', 'line two']); + }); + + it('should YAML-dump object items when input is an array containing objects', () => { + const result = dataToContext([{ key: 'value' }]); + expect(result).toHaveLength(1); + expect(result[0]).toContain('key: value'); + }); + + it('should handle a mixed array of strings and objects', () => { + const result = dataToContext(['plain string', { foo: 'bar' }]); + expect(result).toHaveLength(2); + expect(result[0]).toBe('plain string'); + expect(result[1]).toContain('foo: bar'); + }); + + it('should return a single YAML-dumped string for a plain object', () => { + const result = dataToContext({ error: 'something went wrong', code: 42 }); + expect(result).toHaveLength(1); + expect(result[0]).toContain('error: something went wrong'); + expect(result[0]).toContain('code: 42'); + }); + + it('should return an empty array for an empty array input', () => { + expect(dataToContext([])).toEqual([]); + }); +}); diff --git a/tests/components-lib/notification/factory.test.ts b/tests/components-lib/notification/factory.test.ts new file mode 100644 index 00000000..fca973dc --- /dev/null +++ b/tests/components-lib/notification/factory.test.ts @@ -0,0 +1,165 @@ +import { assert, describe, expect, it } from 'vitest'; +import { + createNotificationFromError, + createNotificationFromText, +} from '../../../src/components-lib/notification/factory'; +import { AdvancedCameraCardError } from '../../../src/types'; + +describe('createNotificationFromText', () => { + it('should create a notification with body text', () => { + const notification = createNotificationFromText('Something failed'); + expect(notification.body?.text).toBe('Something failed'); + }); + + it('should add the default error icon to body when no heading and no icon provided', () => { + const notification = createNotificationFromText('oops'); + expect(notification.body?.icon).toBe('mdi:alert'); + }); + + it('should add a custom icon to body when specified', () => { + const notification = createNotificationFromText('oops', { icon: 'mdi:wifi-off' }); + expect(notification.body?.icon).toBe('mdi:wifi-off'); + }); + + it('should include the heading when provided', () => { + const notification = createNotificationFromText('oops', { + heading: { text: 'Error heading' }, + }); + expect(notification.heading?.text).toBe('Error heading'); + }); + + it('should omit icon from body when heading is provided and no icon is specified', () => { + const notification = createNotificationFromText('oops', { + heading: { text: 'Error heading' }, + }); + expect(notification.body?.icon).toBeUndefined(); + }); + + it('should add icon to body when heading is provided but icon is also specified', () => { + const notification = createNotificationFromText('oops', { + heading: { text: 'Error heading' }, + icon: 'mdi:alert-circle', + }); + expect(notification.body?.icon).toBe('mdi:alert-circle'); + }); + + it('should include metadata when provided', () => { + const notification = createNotificationFromText('oops', { + metadata: [{ text: 'meta line' }], + }); + expect(notification.metadata).toEqual([{ text: 'meta line' }]); + }); + + it('should omit metadata when not provided', () => { + const notification = createNotificationFromText('oops'); + expect(notification.metadata).toBeUndefined(); + }); + + it('should include link when provided', () => { + const notification = createNotificationFromText('oops', { + link: { url: 'https://example.com', title: 'Docs' }, + }); + expect(notification.link?.url).toBe('https://example.com'); + }); + + it('should include context when provided', () => { + const notification = createNotificationFromText('oops', { + context: { detail: 'extra info' }, + }); + expect(notification.context).toBeDefined(); + expect(notification.context?.join(' ')).toContain('detail: extra info'); + }); + + it('should omit context when not provided', () => { + const notification = createNotificationFromText('oops'); + expect(notification.context).toBeUndefined(); + }); + + it('should include in_progress when true', () => { + const notification = createNotificationFromText('oops', { in_progress: true }); + expect(notification.in_progress).toBe(true); + }); + + it('should include in_progress when false', () => { + const notification = createNotificationFromText('oops', { in_progress: false }); + expect(notification.in_progress).toBe(false); + }); + + it('should omit in_progress when not provided', () => { + const notification = createNotificationFromText('oops'); + expect(notification.in_progress).toBeUndefined(); + }); +}); + +describe('createNotificationFromError', () => { + it('should create a notification from an Error object', () => { + const notification = createNotificationFromError(new Error('something failed')); + assert(notification); + expect(notification.body?.text).toBe('something failed'); + }); + + it('should create a notification from a string error', () => { + const notification = createNotificationFromError('plain string error'); + assert(notification); + expect(notification.body?.text).toBe('plain string error'); + }); + + it('should create a notification from a non-error object', () => { + const notification = createNotificationFromError({ code: 404 }); + assert(notification); + expect(notification.body?.text).toBe('{"code":404}'); + }); + + it('should merge the default error icon into heading when heading is provided', () => { + const notification = createNotificationFromError(new Error('failed'), { + heading: { text: 'Init Error' }, + }); + assert(notification); + expect(notification.heading?.text).toBe('Init Error'); + expect(notification.heading?.icon).toBe('mdi:alert'); + expect(notification.heading?.severity).toBe('high'); + }); + + it('should preserve custom heading properties alongside defaults', () => { + const notification = createNotificationFromError(new Error('failed'), { + heading: { text: 'My Heading', icon: 'mdi:custom', severity: 'medium' }, + }); + assert(notification); + // Spread order: { icon: DEFAULT, severity: 'high', ...options.heading } + // so custom values win + expect(notification.heading?.icon).toBe('mdi:custom'); + expect(notification.heading?.severity).toBe('medium'); + }); + + it('should use context from AdvancedCameraCardError when no explicit context is provided', () => { + const error = new AdvancedCameraCardError('boom', { reason: 'network' }); + const notification = createNotificationFromError(error); + assert(notification); + expect(notification.context).toBeDefined(); + expect(notification.context?.join(' ')).toContain('reason: network'); + }); + + it('should use explicit context option over AdvancedCameraCardError context', () => { + const error = new AdvancedCameraCardError('boom', { reason: 'network' }); + const notification = createNotificationFromError(error, { + context: { override: 'explicit' }, + }); + assert(notification); + expect(notification.context?.join(' ')).toContain('override: explicit'); + expect(notification.context?.join(' ')).not.toContain('reason: network'); + }); + + it('should not include context when AdvancedCameraCardError has a non-object context', () => { + const error = new AdvancedCameraCardError('boom', 'not an object'); + const notification = createNotificationFromError(error); + assert(notification); + expect(notification.context).toBeUndefined(); + }); + + it('should not include context when AdvancedCameraCardError context is null', () => { + const error = new AdvancedCameraCardError('boom', null); + const notification = createNotificationFromError(error); + assert(notification); + expect(notification.context).toBeUndefined(); + }); +}); diff --git a/tests/components-lib/notification/summarize.test.ts b/tests/components-lib/notification/summarize.test.ts new file mode 100644 index 00000000..a0dd9411 --- /dev/null +++ b/tests/components-lib/notification/summarize.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { summarizeNotification } from '../../../src/components-lib/notification/summarize'; +import { Notification } from '../../../src/config/schema/actions/types'; + +describe('summarizeNotification', () => { + it('should return the body text when present', () => { + const notification: Notification = { body: { text: 'Body text' } }; + expect(summarizeNotification(notification)).toBe('Body text'); + }); + + it('should return the heading text when body is absent', () => { + const notification: Notification = { + heading: { text: 'Heading text' }, + }; + expect(summarizeNotification(notification)).toBe('Heading text'); + }); + + it('should return the body text even when heading is also present', () => { + const notification: Notification = { + heading: { text: 'Heading text' }, + body: { text: 'Body text' }, + }; + expect(summarizeNotification(notification)).toBe('Body text'); + }); + + it('should return null when neither body nor heading has text', () => { + const notification: Notification = {}; + expect(summarizeNotification(notification)).toBeNull(); + }); + + it('should append metadata text in brackets', () => { + const notification: Notification = { + body: { text: 'Media not loading' }, + metadata: [{ text: 'camera.office' }, { text: 'camera.garden' }], + }; + expect(summarizeNotification(notification)).toBe( + 'Media not loading [camera.office, camera.garden]', + ); + }); + + it('should not append brackets when metadata is empty', () => { + const notification: Notification = { + body: { text: 'Body text' }, + metadata: [], + }; + expect(summarizeNotification(notification)).toBe('Body text'); + }); +}); diff --git a/tests/components-lib/status-bar-controller.test.ts b/tests/components-lib/status-bar-controller.test.ts index ae599cd3..c4a7bb9d 100644 --- a/tests/components-lib/status-bar-controller.test.ts +++ b/tests/components-lib/status-bar-controller.test.ts @@ -267,6 +267,105 @@ describe('StatusBarController', () => { expect(host.getAttribute('hide')).toBe(null); }); + it('should not start popup timer when permanent items are present', () => { + const host = createLitElement(); + setOrRemoveAttribute(host, true, 'hide'); + + const controller = new StatusBarController(host); + controller.setConfig( + createConfig({ + style: 'popup', + }), + ); + + const permanentItem = { + type: 'custom:advanced-camera-card-status-bar-icon' as const, + icon: 'mdi:alert', + sufficient: true, + permanent: true, + }; + + controller.setItems([permanentItem]); + expect(host.getAttribute('hide')).toBe(null); + + // Timer should not hide the bar. + vi.advanceTimersByTime(10000); + expect(host.getAttribute('hide')).toBe(null); + }); + + it('should start popup timer when permanent items are removed', () => { + const host = createLitElement(); + + const controller = new StatusBarController(host); + controller.setConfig( + createConfig({ + style: 'popup', + }), + ); + + const permanentItem = { + type: 'custom:advanced-camera-card-status-bar-icon' as const, + icon: 'mdi:alert', + sufficient: true, + permanent: true, + }; + const nonPermanentItem = { + type: 'custom:advanced-camera-card-status-bar-string' as const, + string: 'Title', + sufficient: true, + }; + + // Start with permanent item — bar stays visible. + controller.setItems([permanentItem, nonPermanentItem]); + vi.advanceTimersByTime(10000); + expect(host.getAttribute('hide')).toBe(null); + + // Remove permanent item — popup timer starts. + controller.setItems([nonPermanentItem]); + expect(host.getAttribute('hide')).toBe(null); + + vi.advanceTimersByTime(3000); + expect(host.getAttribute('hide')).not.toBe(null); + }); + + it('should start popup timer when permanent item is removed without changing sufficient items', () => { + const host = createLitElement(); + + const controller = new StatusBarController(host); + controller.setConfig( + createConfig({ + style: 'popup', + }), + ); + + const sufficientItem = { + type: 'custom:advanced-camera-card-status-bar-string' as const, + string: 'Title', + sufficient: true, + }; + // A permanent item that is NOT sufficient — removing it does not + // change the sufficient-values set, so the popup timer takes the + // dedicated permanent-removal branch. + const permanentInsufficientItem = { + type: 'custom:advanced-camera-card-status-bar-icon' as const, + icon: 'mdi:alert', + sufficient: false, + permanent: true, + }; + + controller.setItems([sufficientItem, permanentInsufficientItem]); + vi.advanceTimersByTime(10000); + expect(host.getAttribute('hide')).toBe(null); + + // Remove the permanent (insufficient) item — sufficient values are + // unchanged, but the popup timer must still start. + controller.setItems([sufficientItem]); + expect(host.getAttribute('hide')).toBe(null); + + vi.advanceTimersByTime(3000); + expect(host.getAttribute('hide')).not.toBe(null); + }); + it('should hide popup after expiry', () => { const host = createLitElement(); diff --git a/tests/components/notification/no-media.test.ts b/tests/components/notification/no-media.test.ts new file mode 100644 index 00000000..8f964a99 --- /dev/null +++ b/tests/components/notification/no-media.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from 'vitest'; +import { renderNoMedia } from '../../../src/components/notification/no-media'; +import { createCameraManager, createStore } from '../../test-utils'; + +// @vitest-environment jsdom +describe('renderNoMedia', () => { + it('should render "No media" heading when not loading', () => { + const result = renderNoMedia({ + cameraID: null, + cameraManager: null, + }); + expect(result).toBeTruthy(); + }); + + it('should render "Awaiting media" heading when loading', () => { + const result = renderNoMedia({ + cameraID: null, + cameraManager: null, + loading: true, + }); + expect(result).toBeTruthy(); + }); + + it('should include camera title as metadata when camera resolves', () => { + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getCameraMetadata).mockReturnValue({ + title: 'Office', + icon: { icon: 'mdi:cctv' }, + }); + + const result = renderNoMedia({ + cameraID: 'camera.office', + cameraManager, + }); + expect(result).toBeTruthy(); + expect(cameraManager.getCameraMetadata).toBeCalledWith('camera.office'); + }); + + it('should fall back to raw camera ID when metadata has no title', () => { + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getCameraMetadata).mockReturnValue(null); + + const result = renderNoMedia({ + cameraID: 'camera.office', + cameraManager, + }); + expect(result).toBeTruthy(); + }); + + it('should fall back to default camera ID when cameraID is null', () => { + const store = createStore([{ cameraID: 'camera.default' }]); + const cameraManager = createCameraManager(store); + vi.mocked(cameraManager.getCameraMetadata).mockReturnValue(null); + + const result = renderNoMedia({ + cameraID: null, + cameraManager, + }); + expect(result).toBeTruthy(); + expect(cameraManager.getCameraMetadata).toBeCalledWith('camera.default'); + }); + + it('should not include metadata when no camera is resolvable', () => { + // Empty store — no default camera. + const cameraManager = createCameraManager(createStore()); + + const result = renderNoMedia({ + cameraID: null, + cameraManager, + }); + expect(result).toBeTruthy(); + expect(cameraManager.getCameraMetadata).not.toBeCalled(); + }); +}); diff --git a/tests/conditions/state-manager.test.ts b/tests/conditions/state-manager.test.ts index b1c6b837..25050429 100644 --- a/tests/conditions/state-manager.test.ts +++ b/tests/conditions/state-manager.test.ts @@ -36,43 +36,49 @@ describe('ConditionStateManager', () => { fullscreen: true, }; - manager.setState(state); + expect(manager.setState(state)).toBe(true); expect(listener).toBeCalledTimes(1); - manager.setState(state); + expect(manager.setState(state)).toBe(false); expect(listener).toBeCalledTimes(1); - manager.setState({ ...state }); + expect(manager.setState({ ...state })).toBe(false); expect(listener).toBeCalledTimes(1); - manager.setState({ - hass: createHASS({ - 'binary_sensor.foo': createStateEntity(), + expect( + manager.setState({ + hass: createHASS({ + 'binary_sensor.foo': createStateEntity(), + }), }), - }); + ).toBe(true); expect(listener).toBeCalledTimes(2); - manager.setState({ fullscreen: true }); + expect(manager.setState({ fullscreen: true })).toBe(false); expect(listener).toBeCalledTimes(2); - manager.setState({ - hass: createHASS({ - 'binary_sensor.foo': createStateEntity(), + expect( + manager.setState({ + hass: createHASS({ + 'binary_sensor.foo': createStateEntity(), + }), }), - }); + ).toBe(true); expect(listener).toBeCalledTimes(3); - manager.setState({ fullscreen: false }); + expect(manager.setState({ fullscreen: false })).toBe(true); expect(listener).toBeCalledTimes(4); - manager.setState({ fullscreen: false }); + expect(manager.setState({ fullscreen: false })).toBe(false); expect(listener).toBeCalledTimes(4); - manager.setState({ - hass: createHASS({ - 'binary_sensor.foo': createStateEntity({ state: 'off' }), + expect( + manager.setState({ + hass: createHASS({ + 'binary_sensor.foo': createStateEntity({ state: 'off' }), + }), }), - }); + ).toBe(true); expect(listener).toBeCalledTimes(5); }); }); diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index 903cd1c0..fd567f78 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -429,34 +429,32 @@ describe('config defaults', () => { items: { engine: { enabled: true, + permanent: false, + priority: 50, + }, + issues: { + enabled: true, + permanent: true, priority: 50, }, resolution: { enabled: true, + permanent: false, priority: 50, }, severity: { enabled: true, + permanent: false, priority: 50, }, technology: { enabled: true, + permanent: false, priority: 50, }, title: { enabled: true, - priority: 50, - }, - problem_config_upgrade: { - enabled: true, - priority: 50, - }, - problem_legacy_resource: { - enabled: true, - priority: 50, - }, - problem_stream_not_loading: { - enabled: true, + permanent: false, priority: 50, }, }, @@ -537,6 +535,10 @@ describe('config defaults', () => { untrigger_delay_seconds: 0, untrigger_force_seconds: 0, }, + issues: { + interaction_mode: 'all', + retry_seconds: 'auto', + }, }, }); }); @@ -828,6 +830,7 @@ describe('config defaults', () => { enabled: true, exclusive: false, expand: false, + permanent: false, string: 'Intruder alert!', priority: 50, sufficient: false, @@ -838,6 +841,7 @@ describe('config defaults', () => { exclusive: false, expand: false, icon: 'mdi:cow', + permanent: false, priority: 50, sufficient: false, }, @@ -847,6 +851,7 @@ describe('config defaults', () => { exclusive: false, expand: false, image: 'https://my.site.com/status.png', + permanent: false, priority: 50, sufficient: false, }, @@ -1093,8 +1098,8 @@ describe('config defaults', () => { icon: 'mdi:alert', severity: 'high', }, - text: 'Something happened.', - details: [{ text: 'Detail 1', icon: 'mdi:info' }], + body: { text: 'Something happened.' }, + metadata: [{ text: 'Detail 1', icon: 'mdi:info' }], controls: [ { icon: 'mdi:check', @@ -1191,6 +1196,7 @@ describe('config defaults', () => { enabled: true, exclusive: false, expand: false, + permanent: false, string: 'Intruder alert!', priority: 50, sufficient: false, @@ -1201,6 +1207,7 @@ describe('config defaults', () => { exclusive: false, expand: false, icon: 'mdi:cow', + permanent: false, priority: 50, sufficient: false, }, @@ -1210,6 +1217,7 @@ describe('config defaults', () => { exclusive: false, expand: false, image: 'https://my.site.com/status.png', + permanent: false, priority: 50, sufficient: false, }, @@ -1225,6 +1233,7 @@ describe('config defaults', () => { enabled: true, exclusive: false, expand: false, + permanent: false, string: 'Intruder alert!', priority: 50, sufficient: false, @@ -1235,6 +1244,7 @@ describe('config defaults', () => { exclusive: false, expand: false, icon: 'mdi:cow', + permanent: false, priority: 50, sufficient: false, }, @@ -1244,6 +1254,7 @@ describe('config defaults', () => { exclusive: false, expand: false, image: 'https://my.site.com/status.png', + permanent: false, priority: 50, sufficient: false, }, @@ -1413,7 +1424,7 @@ describe('should convert webrtc card PTZ to Advanced Camera Card PTZ', () => { }); }); - it('presets via presets sub-object', () => { + it('should parse presets via presets sub-object', () => { expect( cameraConfigSchema.parse({ ptz: { @@ -1457,7 +1468,7 @@ describe('should convert webrtc card PTZ to Advanced Camera Card PTZ', () => { ); }); - it('actions_left takes priority over data_left', () => { + it('should prioritize actions_left over data_left', () => { const result = cameraConfigSchema.parse({ ptz: { service: 'foo', @@ -1482,7 +1493,7 @@ describe('should convert webrtc card PTZ to Advanced Camera Card PTZ', () => { ); }); - it('data_home creates a home preset', () => { + it('should create a home preset from data_home', () => { expect( cameraConfigSchema.parse({ ptz: { @@ -1511,7 +1522,7 @@ describe('should convert webrtc card PTZ to Advanced Camera Card PTZ', () => { ); }); - it('data_home does not overwrite existing home preset', () => { + it('should not overwrite existing home preset from data_home', () => { expect( cameraConfigSchema.parse({ ptz: { @@ -1546,7 +1557,7 @@ describe('should convert webrtc card PTZ to Advanced Camera Card PTZ', () => { }); describe('should lazy evaluate schemas', () => { - it('conditional picture element', () => { + it('should parse conditional picture element', () => { expect( conditionalSchema.parse({ type: 'conditional', @@ -1594,7 +1605,7 @@ describe('should lazy evaluate schemas', () => { }); }); - it('status bar actions', () => { + it('should parse status bar actions', () => { const input = { action: 'fire-dom-event', advanced_camera_card_action: 'status_bar', @@ -1604,6 +1615,7 @@ describe('should lazy evaluate schemas', () => { enabled: true, exclusive: false, expand: false, + permanent: false, priority: 50, sufficient: false, type: 'custom:advanced-camera-card-status-bar-string', @@ -1688,7 +1700,7 @@ it('media viewer should not support microphone based conditions', () => { }); describe('automations should require at least one action', () => { - it('no action', () => { + it('should handle no action', () => { expect(() => createConfig({ cameras: [{}], @@ -1697,7 +1709,7 @@ describe('automations should require at least one action', () => { ).toThrowError(/Automations must include at least one action/); }); - it('empty actions', () => { + it('should handle empty actions', () => { expect(() => createConfig({ cameras: [{}], @@ -1706,7 +1718,7 @@ describe('automations should require at least one action', () => { ).toThrowError(/Automations must include at least one action/); }); - it('at least one action', () => { + it('should handle at least one action', () => { expect(() => createConfig({ cameras: [{}], diff --git a/tests/ha/has-hass-connection-changed.test.ts b/tests/ha/has-hass-connection-changed.test.ts deleted file mode 100644 index 7c22da8e..00000000 --- a/tests/ha/has-hass-connection-changed.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { hasHAConnectionStateChanged } from '../../src/ha/has-hass-connection-changed'; -import { createHASS } from '../test-utils'; - -describe('hasHAConnectionStateChanged', () => { - it('returns false if both oldHass and newHass are undefined', () => { - expect(hasHAConnectionStateChanged()).toBe(false); - }); - - it('returns false if both oldHass and newHass are null', () => { - expect(hasHAConnectionStateChanged(null, null)).toBe(false); - }); - - it('returns false if both oldHass and newHass are the same object', () => { - const hass = createHASS(); - expect(hasHAConnectionStateChanged(hass, hass)).toBe(false); - }); - - it('returns false if both oldHass.connected and newHass.connected are the same', () => { - const oldHass = createHASS(); - const newHass = createHASS(); - oldHass.connected = true; - newHass.connected = true; - expect(hasHAConnectionStateChanged(oldHass, newHass)).toBe(false); - }); - - it('returns true if oldHass.connected and newHass.connected are different', () => { - const oldHass = createHASS(); - const newHass = createHASS(); - oldHass.connected = true; - newHass.connected = false; - expect(hasHAConnectionStateChanged(oldHass, newHass)).toBe(true); - }); -}); diff --git a/tests/ha/side-load-ha-elements.test.ts b/tests/ha/side-load-ha-elements.test.ts index 7527a850..d603e7fc 100644 --- a/tests/ha/side-load-ha-elements.test.ts +++ b/tests/ha/side-load-ha-elements.test.ts @@ -14,12 +14,12 @@ describe('sideLoadHomeAssistantElements', () => { vi.unstubAllGlobals(); }); - it('returns true if all elements already registered', async () => { + it('should return if all elements already registered', async () => { vi.mocked(customElements.get).mockResolvedValue(LitElement); - expect(await sideLoadHomeAssistantElements()).toBe(true); + await sideLoadHomeAssistantElements(); }); - it('returns false when the picture glance card cannot be found', async () => { + it('should throw when the picture glance card cannot be found', async () => { vi.mocked(customElements.get).mockReturnValue(undefined); const createCardElement = vi.fn(); @@ -29,10 +29,10 @@ describe('sideLoadHomeAssistantElements', () => { }), }); - expect(await sideLoadHomeAssistantElements()).toBe(false); + await expect(sideLoadHomeAssistantElements()).rejects.toThrow(); }); - it('returns true when elements are loaded', async () => { + it('should return when elements are loaded', async () => { vi.mocked(customElements.get).mockImplementation((name: string) => { if (name === 'hui-picture-glance-card') { const result = LitElement; @@ -50,7 +50,7 @@ describe('sideLoadHomeAssistantElements', () => { }), }); - expect(await sideLoadHomeAssistantElements()).toBe(true); + await sideLoadHomeAssistantElements(); expect(customElements.whenDefined).toHaveBeenCalledWith('hui-picture-glance-card'); expect(createCardElement).toHaveBeenCalledWith({ diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 88ca8397..6facedf6 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -1,4 +1,4 @@ -import { HassEntities, HassEntity } from 'home-assistant-js-websocket'; +import { HassEntities, HassEntity, STATE_RUNNING } from 'home-assistant-js-websocket'; import { LitElement } from 'lit'; import screenfull from 'screenfull'; import { expect, vi } from 'vitest'; @@ -40,14 +40,14 @@ import { HASSManager } from '../src/card-controller/hass/hass-manager'; import { StateWatcherSubscriptionInterface } from '../src/card-controller/hass/state-watcher'; import { InitializationManager } from '../src/card-controller/initialization-manager'; import { InteractionManager } from '../src/card-controller/interaction-manager'; +import { IssueManager } from '../src/card-controller/issues/issue-manager'; +import { IssueStateManager } from '../src/card-controller/issues/state-manager'; import { KeyboardStateManager } from '../src/card-controller/keyboard-state-manager'; import { MediaLoadedInfoManager } from '../src/card-controller/media-info-manager'; import { MediaPlayerManager } from '../src/card-controller/media-player-manager'; -import { MessageManager } from '../src/card-controller/message-manager'; import { MicrophoneManager } from '../src/card-controller/microphone-manager'; import { NotificationManager } from '../src/card-controller/notification-manager'; import { PIPManager } from '../src/card-controller/pip-manager'; -import { ProblemManager } from '../src/card-controller/problems/manager'; import { QueryStringManager } from '../src/card-controller/query-string-manager'; import { StatusBarItemManager } from '../src/card-controller/status-bar-item-manager'; import { StyleManager } from '../src/card-controller/style-manager'; @@ -131,6 +131,10 @@ export const createHASS = (states?: HassEntities, user?: CurrentUser): HomeAssis hass.user = user; } hass.config.components = []; + + // Default to a fully-started HA so existing tests that don't care about + // startup state still represent a "ready" instance. + hass.config.state = STATE_RUNNING; hass.connection.subscribeMessage = vi.fn(); // ha-nunjucks calls sendMessagePromise to fetch label registry; return empty array to prevent crash. @@ -678,11 +682,14 @@ export const createCardAPI = (): CardController => { api.getKeyboardStateManager.mockReturnValue(mock()); api.getMediaLoadedInfoManager.mockReturnValue(mock()); api.getMediaPlayerManager.mockReturnValue(mock()); - api.getMessageManager.mockReturnValue(mock()); api.getMicrophoneManager.mockReturnValue(mock()); api.getNotificationManager.mockReturnValue(mock()); api.getPIPManager.mockReturnValue(mock()); - api.getProblemManager.mockReturnValue(mock()); + + const issueManager = mock(); + issueManager.getStateManager.mockReturnValue(mock()); + api.getIssueManager.mockReturnValue(issueManager); + api.getQueryStringManager.mockReturnValue(mock()); api.getStatusBarItemManager.mockReturnValue(mock()); api.getStyleManager.mockReturnValue(mock()); diff --git a/tests/utils/action.test.ts b/tests/utils/action.test.ts index ff07153d..7f9c157e 100644 --- a/tests/utils/action.test.ts +++ b/tests/utils/action.test.ts @@ -360,7 +360,7 @@ describe('createSetReviewAction', () => { describe('createNotificationAction', () => { it('should create notification action', () => { - const notification = { text: 'test' }; + const notification = { body: { text: 'test' } }; expect(createNotificationAction(notification)).toEqual({ action: 'fire-dom-event', advanced_camera_card_action: 'notification', @@ -369,7 +369,7 @@ describe('createNotificationAction', () => { }); it('should create notification action with cardID', () => { - const notification = { text: 'test' }; + const notification = { body: { text: 'test' } }; expect(createNotificationAction(notification, { cardID: 'card_id' })).toEqual({ action: 'fire-dom-event', advanced_camera_card_action: 'notification', diff --git a/tests/utils/diagnostics.test.ts b/tests/utils/diagnostics.test.ts index 656fef6a..0e6580da 100644 --- a/tests/utils/diagnostics.test.ts +++ b/tests/utils/diagnostics.test.ts @@ -104,6 +104,7 @@ describe('getDiagnostics', () => { lang: 'en', ha_version: '2023.9.0', timezone: expect.anything(), + issues: [], }); }); @@ -148,26 +149,33 @@ describe('getDiagnostics', () => { date: now, lang: 'en', timezone: expect.anything(), + issues: [], }); }); - it('should include problems in diagnostics', async () => { + it('should include issues in diagnostics', async () => { const deviceRegistryManager = mock(); deviceRegistryManager.getMatchingDevices.mockResolvedValue([]); - const problems = { - config_upgrade: true, - legacy_resource: false, - }; + const issues = new Map([ + [ + 'config_upgrade' as const, + { + icon: 'mdi:update', + severity: 'medium' as const, + notification: { body: { text: 'test' } }, + }, + ], + ]); const result = await getDiagnostics( hass, deviceRegistryManager, { cameras: [{ camera_entity: 'camera.office' }] }, - problems, + issues, ); - expect(result.problems).toEqual(problems); + expect(result.issues).toEqual(['config_upgrade']); }); it('should fetch diagnostics without device model', async () => { @@ -194,6 +202,7 @@ describe('getDiagnostics', () => { date: now, lang: 'en', timezone: expect.anything(), + issues: [], }); }); }); diff --git a/tests/utils/error-context.test.ts b/tests/utils/error-context.test.ts new file mode 100644 index 00000000..f0249c3d --- /dev/null +++ b/tests/utils/error-context.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { AdvancedCameraCardError } from '../../src/types'; +import { getContextFromError } from '../../src/utils/error-context'; + +describe('getContextFromError', () => { + it('should return the context for an AdvancedCameraCardError with an object context', () => { + const error = new AdvancedCameraCardError('boom', { foo: 'bar' }); + expect(getContextFromError(error)).toEqual({ foo: 'bar' }); + }); + + it('should return null for an AdvancedCameraCardError without context', () => { + const error = new AdvancedCameraCardError('boom'); + expect(getContextFromError(error)).toBeNull(); + }); + + it('should return null for an AdvancedCameraCardError with null context', () => { + const error = new AdvancedCameraCardError('boom', null); + expect(getContextFromError(error)).toBeNull(); + }); + + it('should return null for an AdvancedCameraCardError with non-object context', () => { + const error = new AdvancedCameraCardError('boom', 'string context'); + expect(getContextFromError(error)).toBeNull(); + }); + + it('should return null for a plain Error', () => { + expect(getContextFromError(new Error('plain'))).toBeNull(); + }); + + it('should return null for non-error values', () => { + expect(getContextFromError('string')).toBeNull(); + expect(getContextFromError(42)).toBeNull(); + expect(getContextFromError(null)).toBeNull(); + expect(getContextFromError(undefined)).toBeNull(); + expect(getContextFromError({})).toBeNull(); + }); +}); diff --git a/tests/utils/initializer.test.ts b/tests/utils/initializer.test.ts index 7e0a34f0..183a9dc7 100644 --- a/tests/utils/initializer.test.ts +++ b/tests/utils/initializer.test.ts @@ -6,9 +6,8 @@ describe('Initializer', () => { const initializer = new Initializer(); expect(initializer.isInitialized('foo')).toBeFalsy(); - expect( - await initializer.initializeIfNecessary('foo', async () => true), - ).toBeTruthy(); + + await initializer.initializeIfNecessary('foo', async () => {}); expect(initializer.isInitialized('foo')).toBeTruthy(); }); @@ -16,9 +15,7 @@ describe('Initializer', () => { const initializer = new Initializer(); expect(initializer.isInitialized('foo')).toBeFalsy(); - expect( - await initializer.initializeIfNecessary('foo', async () => true), - ).toBeTruthy(); + await initializer.initializeIfNecessary('foo'); expect(initializer.isInitialized('foo')).toBeTruthy(); }); @@ -26,12 +23,8 @@ describe('Initializer', () => { const initializer = new Initializer(); expect(initializer.isInitialized('foo')).toBeFalsy(); - expect( - await initializer.initializeIfNecessary('foo', async () => true), - ).toBeTruthy(); - expect( - await initializer.initializeIfNecessary('foo', async () => true), - ).toBeTruthy(); + await initializer.initializeIfNecessary('foo', async () => {}); + await initializer.initializeIfNecessary('foo', async () => {}); expect(initializer.isInitialized('foo')).toBeTruthy(); }); @@ -39,9 +32,11 @@ describe('Initializer', () => { const initializer = new Initializer(); expect(initializer.isInitialized('foo')).toBeFalsy(); - expect( - await initializer.initializeIfNecessary('foo', async () => false), - ).toBeFalsy(); + await expect( + initializer.initializeIfNecessary('foo', async () => { + throw new Error('test'); + }), + ).rejects.toThrow('test'); expect(initializer.isInitialized('foo')).toBeFalsy(); }); @@ -49,20 +44,20 @@ describe('Initializer', () => { const initializer = new Initializer(); expect(initializer.isInitializedMultiple(['foo', 'bar'])).toBeFalsy(); - expect( - await initializer.initializeMultipleIfNecessary({ - foo: async () => true, - bar: async () => false, + await expect( + initializer.initializeMultipleIfNecessary({ + foo: async () => {}, + bar: async () => { + throw new Error('test'); + }, }), - ).toBeFalsy(); + ).rejects.toThrow('test'); expect(initializer.isInitializedMultiple(['foo', 'bar'])).toBeFalsy(); - expect( - await initializer.initializeMultipleIfNecessary({ - bar: async () => true, - }), - ).toBeTruthy(); + await initializer.initializeMultipleIfNecessary({ + bar: async () => {}, + }); expect(initializer.isInitializedMultiple(['foo', 'bar'])).toBeTruthy(); }); diff --git a/tests/utils/ptz.test.ts b/tests/utils/ptz.test.ts index 3f3e3cf6..94c61792 100644 --- a/tests/utils/ptz.test.ts +++ b/tests/utils/ptz.test.ts @@ -1,7 +1,6 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { Capabilities } from '../../src/camera-manager/capabilities'; import { AdvancedCameraCardView } from '../../src/config/schema/common/const'; -import { IMAGE_VIEW_ZOOM_TARGET_SENTINEL } from '../../src/const'; import { PTZMovementType } from '../../src/types'; import { getPTZTarget, @@ -9,6 +8,8 @@ import { ptzActionToCapabilityKey, } from '../../src/utils/ptz'; import { QueryResults } from '../../src/view/query-results'; +import { IMAGE_VIEW_TARGET_ID_SENTINEL } from '../../src/view/target-id'; +import * as targetId from '../../src/view/target-id'; import { TestViewMedia, createCameraManager, @@ -18,7 +19,7 @@ import { describe('getPTZTarget', () => { describe('in a viewer view', () => { - it('with media', () => { + it('should return target with media', () => { const media = [new TestViewMedia({ id: 'media-id' })]; const view = createView({ view: 'media', @@ -30,14 +31,14 @@ describe('getPTZTarget', () => { }); }); - it('without media', () => { + it('should return null without media', () => { const view = createView({ view: 'media', }); expect(getPTZTarget(view, { cameraManager: createCameraManager() })).toBeNull(); }); - it('with true PTZ restriction', () => { + it('should return null with true PTZ restriction', () => { const media = [new TestViewMedia({ id: 'media-id' })]; const view = createView({ view: 'media', @@ -49,15 +50,15 @@ describe('getPTZTarget', () => { }); }); - it('in image view', () => { + it('should return target in image view', () => { expect(getPTZTarget(createView({ view: 'image' }))).toEqual({ - targetID: IMAGE_VIEW_ZOOM_TARGET_SENTINEL, + targetID: IMAGE_VIEW_TARGET_ID_SENTINEL, type: 'digital', }); }); describe('in live view', () => { - it('without restriction with true PTZ capability', () => { + it('should return PTZ target without restriction with true PTZ capability', () => { const view = createView({ view: 'live', camera: 'camera-1', @@ -75,7 +76,7 @@ describe('getPTZTarget', () => { }); }); - it('without restriction without true PTZ capability', () => { + it('should return digital target without restriction without true PTZ capability', () => { const view = createView({ view: 'live', camera: 'camera-1', @@ -87,7 +88,7 @@ describe('getPTZTarget', () => { }); }); - it('with truePTZ restriction without true PTZ capability', () => { + it('should return null with truePTZ restriction without true PTZ capability', () => { const view = createView({ view: 'live', camera: 'camera-1', @@ -98,7 +99,7 @@ describe('getPTZTarget', () => { ).toBeNull(); }); - it('with digitalPTZ restriction with true PTZ capability', () => { + it('should return digital target with digitalPTZ restriction with true PTZ capability', () => { const view = createView({ view: 'live', camera: 'camera-1', @@ -121,7 +122,7 @@ describe('getPTZTarget', () => { }); }); - it('without cameraID', () => { + it('should return null without cameraID', () => { const view = createView({ view: 'live', camera: null, @@ -141,10 +142,22 @@ describe('getPTZTarget', () => { }, ); }); + + it('should return null for a view with a targetID that is not viewer, image, or live', () => { + // Force getViewTargetID to return a non-null value so that the early-return + // guard passes, then present a view that is none of viewer/image/live to + // exercise the final return null branch at the end of getPTZTarget. + vi.spyOn(targetId, 'getViewTargetID').mockReturnValue('some-target'); + const view = createView({ view: 'timeline' }); + + expect(getPTZTarget(view)).toBeNull(); + + vi.restoreAllMocks(); + }); }); describe('hasCameraTruePTZ', () => { - it('with true PTZ', () => { + it('should return true with true PTZ', () => { const store = createStore([ { cameraID: 'camera-1', @@ -155,12 +168,12 @@ describe('hasCameraTruePTZ', () => { expect(hasCameraTruePTZ(createCameraManager(store), 'camera-1')).toBeTruthy(); }); - it('without true PTZ', () => { + it('should return false without true PTZ', () => { expect(hasCameraTruePTZ(createCameraManager(createStore()), 'camera-1')).toBeFalsy(); }); }); -it('ptzActionToCapabilityKey', () => { +it('should map ptzActionToCapabilityKey correctly', () => { expect(ptzActionToCapabilityKey('left')).toBe('left'); expect(ptzActionToCapabilityKey('right')).toBe('right'); expect(ptzActionToCapabilityKey('up')).toBe('up');