chore: Enforce a few house rules via eslint (#2539)

This commit is contained in:
Dermot Duffy
2026-06-30 17:45:13 -07:00
committed by dermotduffy
parent 5572ec728e
commit 921d45e577
100 changed files with 479 additions and 295 deletions
-18
View File
@@ -1,18 +0,0 @@
module.exports = {
parser: '@typescript-eslint/parser', // Specifies the ESLint parser
parserOptions: {
ecmaVersion: 2020, // Allows for the parsing of modern ECMAScript features
sourceType: 'module', // Allows for the use of imports
},
extends: [
'plugin:@typescript-eslint/recommended', // Uses the recommended rules from the @typescript-eslint/eslint-plugin
'prettier', // Uses eslint-config-prettier to disable ESLint rules from @typescript-eslint/eslint-plugin that would conflict with prettier
],
rules: {
// Place to specify ESLint rules. Can be used to overwrite rules specified
// from the extended configs e.g.
// "@typescript-eslint/explicit-function-return-type": "off",
'@typescript-eslint/no-explicit-any': 'error',
'no-multi-assign': 'error',
},
};
+3 -3
View File
@@ -9,7 +9,7 @@ Commands: `yarn install`, `yarn run build`, `yarn run test`, `yarn run coverage`
## Code Style ## Code Style
- Conventional Commits (used by semantic-release). - Conventional Commits (used by semantic-release).
- Comments explain **why**, not what — only when intent isn't obvious from code. - Comments explain **why**, not what -- only when intent isn't obvious from code.
## TypeScript ## TypeScript
@@ -32,7 +32,7 @@ Commands: `yarn install`, `yarn run build`, `yarn run test`, `yarn run coverage`
## Architecture ## Architecture
- Consolidate duplicate logic when adding helpers; don't leave redundant inline expressions. - Consolidate duplicate logic when adding helpers; don't leave redundant inline expressions.
- **Naming consistency:** Renames span all layers — schema, types, CSS, localization keys, templates, docs. All schema fields must appear in the documentation table. - **Naming consistency:** Renames span all layers -- schema, types, CSS, localization keys, templates, docs. All schema fields must appear in the documentation table.
- **Separation of concerns:** `components/` renders only; logic in `components-lib/` controllers (using `ReactiveController` where needed). - **Separation of concerns:** `components/` renders only; logic in `components-lib/` controllers (using `ReactiveController` where needed).
- **Manager pattern:** `CardController` orchestrates managers (`ConfigManager`, `HASSManager`, `ViewManager`, etc.). New cross-cutting concerns → new manager. - **Manager pattern:** `CardController` orchestrates managers (`ConfigManager`, `HASSManager`, `ViewManager`, etc.). New cross-cutting concerns → new manager.
- **Module conventions:** `types.ts` for types/schemas, `*-manager.ts` for coordinators, `*-controller.ts` for logic, `utils/` for helpers. - **Module conventions:** `types.ts` for types/schemas, `*-manager.ts` for coordinators, `*-controller.ts` for logic, `utils/` for helpers.
@@ -40,7 +40,7 @@ Commands: `yarn install`, `yarn run build`, `yarn run test`, `yarn run coverage`
## Preferences ## Preferences
- Think through UX states and edge cases **before** writing code. - Think through UX states and edge cases **before** writing code.
- No re-exports or pass-through files — import from source directly. - No re-exports or pass-through files -- import from source directly.
- Derive TypeScript types from Zod schemas (`z.infer<typeof schema>`); no parallel interfaces. - Derive TypeScript types from Zod schemas (`z.infer<typeof schema>`); no parallel interfaces.
- User-facing schemas: only user-configurable fields. Internal fields go in a derived interface (`interface InternalFoo extends Foo { ... }`). - User-facing schemas: only user-configurable fields. Internal fields go in a derived interface (`interface InternalFoo extends Foo { ... }`).
- Shared schemas go in `config/schema/common/` from the start. - Shared schemas go in `config/schema/common/` from the start.
+1 -1
View File
@@ -39,7 +39,7 @@ Turns the card into a phone-style ringer that answers a [two-way audio
call](../usage/2-way-audio.md) when somebody presses a doorbell. Intended for a call](../usage/2-way-audio.md) when somebody presses a doorbell. Intended for a
wall-mounted tablet sitting on a dashboard with a doorbell camera. wall-mounted tablet sitting on a dashboard with a doorbell camera.
Minimal configuration — just point it at a doorbell-capable camera: Minimal configuration -- just point it at a doorbell-capable camera:
```yaml ```yaml
type: custom:advanced-camera-card type: custom:advanced-camera-card
+8 -8
View File
@@ -29,14 +29,14 @@ status_bar:
### Available Items ### Available Items
| Button name | Description | | Button name | Description |
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `engine` | The icon of the camera engine for the relevant camera. | | `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. | | `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). | | `resolution` | The detected media resolution (if any). |
| `severity` | The media severity indicator (if any) for review severity (e.g. Frigate alerts/detections). | | `severity` | The media severity indicator (if any) for review severity (e.g. Frigate alerts/detections). |
| `technology` | The detected media technology (if any). | | `technology` | The detected media technology (if any). |
| `title` | The media title. | | `title` | The media title. |
### Options for each item ### Options for each item
+5 -5
View File
@@ -91,7 +91,7 @@ automations:
### Fullscreen with display mode and substream switching ### Fullscreen with display mode and substream switching
This example adds a custom menu button that switches to single display mode, This example adds a custom menu button that switches to single display mode,
activates the HD substream, and enters card fullscreen — all in a single tap. activates the HD substream, and enters card fullscreen -- all in a single tap.
An automation restores the grid layout and substream when fullscreen is exited. An automation restores the grid layout and substream when fullscreen is exited.
This is useful in [grid](configuration/live.md) layouts where the card's This is useful in [grid](configuration/live.md) layouts where the card's
standard fullscreen button would show all cameras rather than a single HD standard fullscreen button would show all cameras rather than a single HD
@@ -376,9 +376,9 @@ elements:
The [`doorbell` profile](configuration/profiles.md?id=doorbell) turns a dashboard into a phone-like ringer when somebody presses the doorbell, by setting [`view.triggers.actions.trigger: call`](configuration/view.md?id=triggers) and auto-discovering [HA `event.*` entities](https://www.home-assistant.io/integrations/event/#device-class) with `device_class: doorbell` on the camera's device (Ring, UniFi Protect, Nest, DoorBird, Reolink, etc.). The intended deployment is a wall-mounted tablet sitting on the dashboard. The [`doorbell` profile](configuration/profiles.md?id=doorbell) turns a dashboard into a phone-like ringer when somebody presses the doorbell, by setting [`view.triggers.actions.trigger: call`](configuration/view.md?id=triggers) and auto-discovering [HA `event.*` entities](https://www.home-assistant.io/integrations/event/#device-class) with `device_class: doorbell` on the camera's device (Ring, UniFi Protect, Nest, DoorBird, Reolink, etc.). The intended deployment is a wall-mounted tablet sitting on the dashboard.
A doorbell press is instantaneous, so the card synthesises a ring window from [`view.triggers.event_hold_seconds`](configuration/view.md?id=triggers) (default `30`s) — long enough for a typical phone-style answer window. `untrigger_delay_seconds` then lingers past that, same as for any stateful trigger. A doorbell press is instantaneous, so the card synthesises a ring window from [`view.triggers.event_hold_seconds`](configuration/view.md?id=triggers) (default `30`s) -- long enough for a typical phone-style answer window. `untrigger_delay_seconds` then lingers past that, same as for any stateful trigger.
`triggers.motion`, `triggers.occupancy`, and `triggers.media_events` are off by default — only the explicit doorbell press triggers the call, so casual motion won't make the card ring. `triggers.motion`, `triggers.occupancy`, and `triggers.media_events` are off by default -- only the explicit doorbell press triggers the call, so casual motion won't make the card ring.
```yaml ```yaml
type: custom:advanced-camera-card type: custom:advanced-camera-card
@@ -398,9 +398,9 @@ profiles:
#### With a Zigbee (ZHA / deCONZ) doorbell button #### With a Zigbee (ZHA / deCONZ) doorbell button
Zigbee buttons connected via ZHA or deCONZ typically don't expose a per-device entity — they fire raw HA bus events (`zha_event`, `deconz_event`) shared across every Zigbee device on the integration. The `doorbell` profile's auto-discovery doesn't cover this case; reuse the profile (it still wires up `trigger: call` / `untrigger: call` and the ring window) but opt out of auto-discovery per camera and add [`triggers.events`](configuration/cameras/README.md?id=events) with an `event_data` filter to pick out the right device. Zigbee buttons connected via ZHA or deCONZ typically don't expose a per-device entity -- they fire raw HA bus events (`zha_event`, `deconz_event`) shared across every Zigbee device on the integration. The `doorbell` profile's auto-discovery doesn't cover this case; reuse the profile (it still wires up `trigger: call` / `untrigger: call` and the ring window) but opt out of auto-discovery per camera and add [`triggers.events`](configuration/cameras/README.md?id=events) with an `event_data` filter to pick out the right device.
You can copy the exact `device_ieee` (ZHA) or `id` (deCONZ) and command values straight out of **Developer tools → Events** in Home Assistant — listen to the event type, press the doorbell, and use whatever appears in the `data` payload. You can copy the exact `device_ieee` (ZHA) or `id` (deCONZ) and command values straight out of **Developer tools → Events** in Home Assistant -- listen to the event type, press the doorbell, and use whatever appears in the `data` payload.
```yaml ```yaml
type: custom:advanced-camera-card type: custom:advanced-camera-card
+3 -3
View File
@@ -233,7 +233,7 @@ between clicking the download button and the download starting.
- If you are using a custom `client_id` setting in your `frigate.yml` file (the - If you are using a custom `client_id` setting in your `frigate.yml` file (the
configuration file for the Frigate backend itself), the card will auto-detect configuration file for the Frigate backend itself), the card will auto-detect
it from the camera entity. If auto-detection fails (e.g. no `camera_entity` it from the camera entity. If auto-detection fails (e.g. no `camera_entity`
is configured), set it manually — see [Frigate engine is configured), set it manually -- see [Frigate engine
configuration](configuration/cameras/engine.md?id=frigate). configuration](configuration/cameras/engine.md?id=frigate).
- You must have the `Enable the media browser` option enabled for the Frigate - You must have the `Enable the media browser` option enabled for the Frigate
integration, in order for media fetches to work for the card. Media fetches integration, in order for media fetches to work for the card. Media fetches
@@ -305,7 +305,7 @@ Picture-in-Picture (PIP) uses the browser's [native video PIP
API](https://caniuse.com/picture-in-picture) which floats the raw video element API](https://caniuse.com/picture-in-picture) which floats the raw video element
into a small window. This means: into a small window. This means:
- **No card UI in the PIP window.** Only the video itself is shown — no menu, - **No card UI in the PIP window.** Only the video itself is shown -- no menu,
status bar, timeline, or other card elements. The card remains fully status bar, timeline, or other card elements. The card remains fully
functional on the dashboard behind it. functional on the dashboard behind it.
- **Limited browser support.** Not all browsers support the PIP API. See - **Limited browser support.** Not all browsers support the PIP API. See
@@ -320,7 +320,7 @@ was explored using the experimental [Document Picture-in-Picture
API](https://caniuse.com/mdn-api_documentpictureinpicture), however it proved API](https://caniuse.com/mdn-api_documentpictureinpicture), however it proved
unworkable: Home Assistant state updates cannot reach a card in a separate unworkable: Home Assistant state updates cannot reach a card in a separate
document, and browser-managed styles (`adoptedStyleSheets`) are cleared when document, and browser-managed styles (`adoptedStyleSheets`) are cleared when
elements move between documents — resulting in an unstyled, non-updating card. elements move between documents -- resulting in an unstyled, non-updating card.
### New version not working in Chrome ### New version not working in Chrome
+3 -3
View File
@@ -25,7 +25,7 @@ opt <color:#43a047>**""live.microphone.always_connected""**</color> not enabled
end end
note over User, Mic note over User, Mic
The microphone must connect for the call to proceed — The microphone must connect for the call to proceed --
if access is denied or unsupported, the call does not start if access is denied or unsupported, the call does not start
end note end note
@@ -46,8 +46,8 @@ end
User -> Card : End the call User -> Card : End the call
note over User, Card note over User, Card
Via Hang-up button, <color:#43a047>**""call_end""**</color> action, or — when Via Hang-up button, <color:#43a047>**""call_end""**</color> action, or -- when
<color:#43a047>**""live.controls.call.lock""**</color> is disabled — navigating away <color:#43a047>**""live.controls.call.lock""**</color> is disabled -- navigating away
end note end note
Card --> User : Inbound audio mutes (<color:#43a047>**""live.auto_mute""**</color>) Card --> User : Inbound audio mutes (<color:#43a047>**""live.auto_mute""**</color>)
+81
View File
@@ -13,6 +13,39 @@ const compat = new FlatCompat({
allConfig: js.configs.all, allConfig: js.configs.all,
}); });
// Local rule: ban the em-dash character (U+2014) anywhere, including comments and
// strings. The codebase uses a colon or a double hyphen `--` instead. The regex uses
// the unicode escape so the character never appears literally in this file.
const noEmDash = {
meta: {
type: 'problem',
docs: { description: 'Disallow the em-dash character; use ":" or "--" instead.' },
messages: {
emDash: 'Em-dash character is not allowed; use ":" or "--" instead.',
},
},
create(context) {
const sourceCode = context.sourceCode ?? context.getSourceCode();
return {
Program(node) {
const text = sourceCode.getText();
const re = /\u2014/g;
let match;
while ((match = re.exec(text)) !== null) {
context.report({
node,
loc: {
start: sourceCode.getLocFromIndex(match.index),
end: sourceCode.getLocFromIndex(match.index + 1),
},
messageId: 'emDash',
});
}
},
};
},
};
export default defineConfig([ export default defineConfig([
{ {
extends: compat.extends('plugin:@typescript-eslint/recommended', 'prettier'), extends: compat.extends('plugin:@typescript-eslint/recommended', 'prettier'),
@@ -23,8 +56,56 @@ export default defineConfig([
sourceType: 'module', sourceType: 'module',
}, },
plugins: {
local: { rules: { 'no-em-dash': noEmDash } },
},
rules: { rules: {
curly: 'error', curly: 'error',
'local/no-em-dash': 'error',
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-non-null-assertion': 'error',
'@typescript-eslint/parameter-properties': 'error',
},
},
// Timers must go through the Timer class (src/utils/timer.ts).
{
files: ['src/**/*.ts'],
rules: {
'no-restricted-syntax': [
'error',
{
selector:
'CallExpression > Identifier.callee[name=/^(setTimeout|setInterval)$/]',
message:
'Use the Timer class (src/utils/timer.ts) instead of setTimeout/setInterval.',
},
{
selector: 'MemberExpression[property.name=/^(setTimeout|setInterval)$/]',
message:
'Use the Timer class (src/utils/timer.ts) instead of window.setTimeout/setInterval.',
},
],
},
},
// A fire-and-forget promise must be marked with `void` to show it is deliberate.
// To know which calls return a promise, this rule needs type information, so the
// `project` option below runs the TypeScript type-checker over source while linting.
// That makes linting source slower. Tests are excluded: they leave promises unawaited
// freely.
{
files: ['src/**/*.ts'],
languageOptions: {
parser: tsParser,
parserOptions: {
project: './tsconfig.json',
tsconfigRootDir: __dirname,
},
},
rules: {
'@typescript-eslint/no-floating-promises': 'error',
}, },
}, },
]); ]);
+3 -3
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# #
# `yarn deployment-update` — point the manual-test Home Assistant instance at # `yarn deployment-update` -- point the manual-test Home Assistant instance at
# this worktree. # this worktree.
# #
# Repoints the `$ACC_LIVE_LINK` symlink (the dev static server's document root) # Repoints the `$ACC_LIVE_LINK` symlink (the dev static server's document root)
@@ -9,7 +9,7 @@
set -euo pipefail set -euo pipefail
if [[ -z "${ACC_LIVE_LINK:-}" ]]; then if [[ -z "${ACC_LIVE_LINK:-}" ]]; then
echo "ACC_LIVE_LINK is not set — point it at the symlink to update." >&2 echo "ACC_LIVE_LINK is not set -- point it at the symlink to update." >&2
exit 1 exit 1
fi fi
@@ -18,4 +18,4 @@ mkdir -p "$(dirname "$ACC_LIVE_LINK")"
ln -sfn "$TARGET" "$ACC_LIVE_LINK" ln -sfn "$TARGET" "$ACC_LIVE_LINK"
echo "Advanced Camera Card → $TARGET" echo "Advanced Camera Card → $TARGET"
[[ -d "$TARGET" ]] || echo "(no dist/ here yet — run \`yarn start\` to build it)" [[ -d "$TARGET" ]] || echo "(no dist/ here yet -- run \`yarn start\` to build it)"
@@ -9,13 +9,13 @@ export class EffectAction extends AdvancedCameraCardAction<EffectActionConfig> {
const action = this._getAction(); const action = this._getAction();
switch (action.effect_action) { switch (action.effect_action) {
case 'start': case 'start':
api.getEffectsManager().startEffect(action.effect); void api.getEffectsManager().startEffect(action.effect);
break; break;
case 'stop': case 'stop':
api.getEffectsManager().stopEffect(action.effect); api.getEffectsManager().stopEffect(action.effect);
break; break;
case 'toggle': case 'toggle':
api.getEffectsManager().toggleEffect(action.effect); void api.getEffectsManager().toggleEffect(action.effect);
break; break;
} }
} }
@@ -11,7 +11,7 @@ export class PTZControlsAction extends AdvancedCameraCardAction<PTZControlsActio
// If `enabled` is explicit, use it. If only `type` is being changed, leave // If `enabled` is explicit, use it. If only `type` is being changed, leave
// `enabled` untouched (undefined = no change). Otherwise (neither set), // `enabled` untouched (undefined = no change). Otherwise (neither set),
// toggle the current enabled value — this is the menu-button show/hide use // toggle the current enabled value -- this is the menu-button show/hide use
// case. // case.
const enabled = const enabled =
action.enabled ?? action.enabled ??
@@ -28,9 +28,8 @@ export class PTZMultiAction extends AdvancedCameraCardAction<PTZMultiActionConfi
return; return;
} }
(type === 'ptz' void (
? this._toPTZAction(targetID) type === 'ptz' ? this._toPTZAction(targetID) : this._toPTZDigitalAction(targetID)
: this._toPTZDigitalAction(targetID)
).execute(api); ).execute(api);
} }
+1 -1
View File
@@ -106,6 +106,6 @@ export class AutomationsManager {
--this._nestedAutomationExecutions; --this._nestedAutomationExecutions;
} }
}; };
runActions(automation.actions); void runActions(automation.actions);
} }
} }
+1 -1
View File
@@ -285,7 +285,7 @@ export class CallManager {
restoreView && restoreView &&
(previousView.view !== 'live' || previousView.camera !== call.cameraID) (previousView.view !== 'live' || previousView.camera !== call.cameraID)
) { ) {
viewManager.setViewByParametersWithExistingQuery({ void viewManager.setViewByParametersWithExistingQuery({
baseView: previousView, baseView: previousView,
force: true, force: true,
}); });
+1 -1
View File
@@ -63,7 +63,7 @@ export abstract class GeneratedTone implements Tone {
// a stopped tone can never re-arm its loop. // a stopped tone can never re-arm its loop.
protected _scheduleNext(intervalSeconds: number): void { protected _scheduleNext(intervalSeconds: number): void {
/* istanbul ignore next: defensive guard against a subclass calling /* istanbul ignore next: defensive guard against a subclass calling
_scheduleNext after stop() — JS single-threading makes this unreachable _scheduleNext after stop() -- JS single-threading makes this unreachable
from the existing subclasses -- @preserve */ from the existing subclasses -- @preserve */
if (!this._context) { if (!this._context) {
return; return;
+4 -4
View File
@@ -79,8 +79,8 @@ export class CardElementManager {
// These initializers are called when the config is updated, but on initial // These initializers are called when the config is updated, but on initial
// creation of the card hass is not yet available when the config is first // creation of the card hass is not yet available when the config is first
// loaded. // loaded.
this._api.getDefaultManager().initialize(); void this._api.getDefaultManager().initialize();
this._api.getMediaPlayerManager().initialize(); void this._api.getMediaPlayerManager().initialize();
this._api this._api
.getHASSManager() .getHASSManager()
@@ -184,7 +184,7 @@ export class CardElementManager {
this._api.getFullscreenManager().disconnect(); this._api.getFullscreenManager().disconnect();
this._api.getPIPManager().uninitialize(); this._api.getPIPManager().uninitialize();
this._api.getKeyboardStateManager().uninitialize(); this._api.getKeyboardStateManager().uninitialize();
this._api.getActionsManager().uninitialize(); void this._api.getActionsManager().uninitialize();
this._api.getInteractionManager().uninitialize(); this._api.getInteractionManager().uninitialize();
this._api.getDefaultManager().uninitialize(); this._api.getDefaultManager().uninitialize();
this._api.getHASSManager().getStateWatcher()?.unsubscribe(this.update); this._api.getHASSManager().getStateWatcher()?.unsubscribe(this.update);
@@ -200,7 +200,7 @@ export class CardElementManager {
this._api this._api
.getInitializationManager() .getInitializationManager()
.uninitialize(InitializationAspect.INITIAL_TRIGGER); .uninitialize(InitializationAspect.INITIAL_TRIGGER);
this._api.getCameraManager().destroy(); void this._api.getCameraManager().destroy();
this._element.removeEventListener( this._element.removeEventListener(
'mousemove', 'mousemove',
+3 -3
View File
@@ -154,7 +154,7 @@ export class ConfigManager {
// automations, which destroys associated ConditionsManagers. If a condition // automations, which destroys associated ConditionsManagers. If a condition
// transition (e.g. microphone connect) triggers both a user automation and // transition (e.g. microphone connect) triggers both a user automation and
// an unrelated override, an unconditional reload would delete the // an unrelated override, an unconditional reload would delete the
// automation mid-transition — the freshly created replacement has no prior // automation mid-transition -- the freshly created replacement has no prior
// state, treats the current condition as its baseline, and never fires the // state, treats the current condition as its baseline, and never fires the
// action. // action.
const runIfChanged = <T>( const runIfChanged = <T>(
@@ -189,7 +189,7 @@ export class ConfigManager {
(config) => [config.cameras, config.cameras_global], (config) => [config.cameras, config.cameras_global],
() => { () => {
this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS); this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS);
this._api.getCameraManager().destroy(); void this._api.getCameraManager().destroy();
}, },
true, true,
); );
@@ -203,7 +203,7 @@ export class ConfigManager {
true, true,
); );
/* async */ this._initializeBackgroundAndUpdate(previousConfig); void this._initializeBackgroundAndUpdate(previousConfig);
} }
/** /**
@@ -92,7 +92,7 @@ export class OverridesManager {
const parseResult = advancedCameraCardConfigSchema.safeParse(output); const parseResult = advancedCameraCardConfigSchema.safeParse(output);
if (!parseResult.success) { if (!parseResult.success) {
// Surface one co-located failure object per Zod issue — path, the value // Surface one co-located failure object per Zod issue -- path, the value
// the user actually wrote, and the most informative "expected" field for // the user actually wrote, and the most informative "expected" field for
// this issue code. Avoids dumping the full merged config (which is // this issue code. Avoids dumping the full merged config (which is
// mostly schema defaults the user never wrote) and keeps the reader from // mostly schema defaults the user never wrote) and keeps the reader from
@@ -166,7 +166,7 @@ export class EffectsManager implements EffectsManagerInterface {
private _startPendingEffects(): void { private _startPendingEffects(): void {
for (const [name, options] of this._pendingEffects.entries()) { for (const [name, options] of this._pendingEffects.entries()) {
this._pendingEffects.delete(name); this._pendingEffects.delete(name);
this._startEffect(name, options); void this._startEffect(name, options);
} }
} }
} }
@@ -32,9 +32,12 @@ export class ScreenfullFullScreenProvider
} }
if (fullscreen) { if (fullscreen) {
screenfull.request(this._api.getCardElementManager().getElement()); // A denied request (or an exit when not in fullscreen) leaves the UI
// consistent: the 'change' handler only fires on a real transition.
// Nothing to act on.
screenfull.request(this._api.getCardElementManager().getElement()).catch(() => {});
} else { } else {
screenfull.exit(); screenfull.exit().catch(() => {});
} }
} }
} }
@@ -85,7 +85,10 @@ export class WebkitFullScreenProvider
// clicking the 'X' which then fires this event). That's probably the rare // clicking the 'X' which then fires this event). That's probably the rare
// case though. // case though.
this._playTimer.start(WEBKIT_PLAY_SECONDS, () => { this._playTimer.start(WEBKIT_PLAY_SECONDS, () => {
this._getVideoElement()?.play(); // Best-effort resume after a fullscreen exit.
this._getVideoElement()
?.play()
.catch(() => {});
}); });
}; };
} }
+1 -1
View File
@@ -61,7 +61,7 @@ export class HASSManager implements HASSManagerReadonlyInterface {
); );
this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS); this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS);
this._api.getCameraManager().destroy(); void this._api.getCameraManager().destroy();
this._api.getInitializationManager().uninitialize(InitializationAspect.VIEW); this._api.getInitializationManager().uninitialize(InitializationAspect.VIEW);
this._api this._api
.getInitializationManager() .getInitializationManager()
@@ -80,7 +80,7 @@ export class InitializationManager {
if (!this._shouldInitializeMandatory()) { if (!this._shouldInitializeMandatory()) {
return; return;
} }
/* async */ this.initializeMandatory(); void this.initializeMandatory();
} }
private _shouldInitializeMandatory(): boolean { private _shouldInitializeMandatory(): boolean {
+3 -5
View File
@@ -29,7 +29,7 @@ export class IssueManager {
private _suspended = false; private _suspended = false;
// Reentrancy guard: evaluate() calls setState() on the condition state // Reentrancy guard: evaluate() calls setState() on the condition state
// manager, which fires listeners synchronously — including the one // manager, which fires listeners synchronously -- including the one
// registered in this constructor. Without this guard, detectDynamic() // registered in this constructor. Without this guard, detectDynamic()
// and presence computation would run twice per evaluation. // and presence computation would run twice per evaluation.
private _evaluating = false; private _evaluating = false;
@@ -165,16 +165,14 @@ export class IssueManager {
// normal re-evaluation (on any condition-state change). // normal re-evaluation (on any condition-state change).
// //
// `initialized: true` in the change payload means mandatory initialization // `initialized: true` in the change payload means mandatory initialization
// just finished — see InitializationManager._initializeMandatory. That's // just finished -- see InitializationManager._initializeMandatory. That's
// also the earliest point at which the full HASS object is guaranteed // also the earliest point at which the full HASS object is guaranteed
// ready for websocket calls (e.g. LegacyResourceIssue's lovelace/resources // ready for websocket calls (e.g. LegacyResourceIssue's lovelace/resources
// fetch). Because `initialized` is latched (its comment notes it never // fetch). Because `initialized` is latched (its comment notes it never
// changes again), this block fires exactly once per IssueManager life. // changes again), this block fires exactly once per IssueManager life.
private _onStateChange(change: ConditionStateChange): void { private _onStateChange(change: ConditionStateChange): void {
if (change.change.initialized === true && change.new.hass) { if (change.change.initialized === true && change.new.hass) {
/* async */ this._stateManager void this._stateManager.detectStatic(change.new.hass).then(() => this.evaluate());
.detectStatic(change.new.hass)
.then(() => this.evaluate());
} }
this.evaluate(); this.evaluate();
} }
@@ -19,7 +19,7 @@ export class ConnectionIssue implements Issue {
private _state: ConnectionState = 'ready'; private _state: ConnectionState = 'ready';
public detectDynamic(state: ConditionState): void { public detectDynamic(state: ConditionState): void {
// Before HASS is ever provided, leave state untouched — undefined hass is // Before HASS is ever provided, leave state untouched -- undefined hass is
// not a disconnection, just "not yet initialized". // not a disconnection, just "not yet initialized".
if (state.hass === undefined) { if (state.hass === undefined) {
return; return;
@@ -44,7 +44,7 @@ export class InitializationIssue extends AbstractErrorIssue {
// resources (WebSocket subscriptions, listeners) before the CAMERAS // resources (WebSocket subscriptions, listeners) before the CAMERAS
// init aspect replaces the instance via createCameraManager(). // init aspect replaces the instance via createCameraManager().
this._api.getInitializationManager().uninitializeMandatory(); this._api.getInitializationManager().uninitializeMandatory();
this._api.getCameraManager().destroy(); void this._api.getCameraManager().destroy();
return false; return false;
} }
@@ -37,7 +37,7 @@ export class MediaLoadIssue implements Issue {
} }
// ========================================================================= // =========================================================================
// Explicit trigger — called when a component fires an issue:trigger event. // Explicit trigger -- called when a component fires an issue:trigger event.
// ========================================================================= // =========================================================================
public trigger(context: IssueTriggerContext['media_load']): void { public trigger(context: IssueTriggerContext['media_load']): void {
@@ -45,7 +45,7 @@ export class MediaLoadIssue implements Issue {
} }
// ========================================================================= // =========================================================================
// Detection — called by the manager on every state change. // Detection -- called by the manager on every state change.
// ========================================================================= // =========================================================================
public detectDynamic(state: ConditionState): void { public detectDynamic(state: ConditionState): void {
@@ -62,7 +62,7 @@ export class MediaLoadIssue implements Issue {
} }
// ========================================================================= // =========================================================================
// State queries — called by the manager to read current state. // State queries -- called by the manager to read current state.
// ========================================================================= // =========================================================================
public hasIssue(): boolean { public hasIssue(): boolean {
@@ -113,7 +113,7 @@ export class MediaLoadIssue implements Issue {
} }
// ========================================================================= // =========================================================================
// Retry — called by the manager to schedule a media reload. // Retry -- called by the manager to schedule a media reload.
// ========================================================================= // =========================================================================
public needsRetry(): boolean { public needsRetry(): boolean {
@@ -144,7 +144,7 @@ export class MediaLoadIssue implements Issue {
// re-attempts loading underneath. If the retry succeeds, // re-attempts loading underneath. If the retry succeeds,
// _handleMediaLoaded will clear everything when media:loaded fires. If // _handleMediaLoaded will clear everything when media:loaded fires. If
// it fails silently (e.g. bogus stream name), the error stays visible // it fails silently (e.g. bogus stream name), the error stays visible
// immediately — no new 10s grace period. // immediately -- no new 10s grace period.
this._api.getViewManager().setViewWithMergedContext({ mediaEpoch }); this._api.getViewManager().setViewWithMergedContext({ mediaEpoch });
return false; return false;
} }
@@ -212,7 +212,7 @@ export class MediaLoadIssue implements Issue {
this._timerTargetID = targetID; this._timerTargetID = targetID;
this._timer.start(MEDIA_LOADING_TIMEOUT_SECONDS, () => { this._timer.start(MEDIA_LOADING_TIMEOUT_SECONDS, () => {
// Record the error on timeout so retry() knows which epoch to bump. // Record the error on timeout so retry() knows which epoch to bump.
// targetID is guaranteed non-null here — the null case bails at the // targetID is guaranteed non-null here -- the null case bails at the
// top of _handleMediaNotLoaded. // top of _handleMediaNotLoaded.
this._erroredTargetIDs.add(targetID); this._erroredTargetIDs.add(targetID);
this._activate(); this._activate();
@@ -31,7 +31,7 @@ export class MediaQueryIssue extends AbstractErrorIssue {
return false; return false;
} }
this._error = null; this._error = null;
this._api.getViewManager().setViewByParametersWithNewQuery(); void this._api.getViewManager().setViewByParametersWithNewQuery();
// Exclusive retry. No other issue should attempt to retry until the next // Exclusive retry. No other issue should attempt to retry until the next
// evaluation cycle, when we'll know if this was successful. // evaluation cycle, when we'll know if this was successful.
+10 -4
View File
@@ -3,7 +3,7 @@ import { summarizeNotification } from '../../components-lib/notification/summari
import { ConditionState } from '../../condition-trigger/conditions/types'; import { ConditionState } from '../../condition-trigger/conditions/types';
import { Notification } from '../../config/schema/actions/types'; import { Notification } from '../../config/schema/actions/types';
import { HomeAssistant } from '../../ha/types'; import { HomeAssistant } from '../../ha/types';
import { isTruthy } from '../../utils/basic'; import { errorToConsole, isTruthy } from '../../utils/basic';
import { import {
Issue, Issue,
IssueDescription, IssueDescription,
@@ -27,12 +27,18 @@ export class IssueStateManager implements IssueReadOnlyState {
} }
// ========================================================================= // =========================================================================
// Detection — static (one-shot on init) and dynamic (on every state change). // Detection -- static (one-shot on init) and dynamic (on every state change).
// ========================================================================= // =========================================================================
public async detectStatic(hass: HomeAssistant): Promise<void> { public async detectStatic(hass: HomeAssistant): Promise<void> {
for (const issue of this._issues.values()) { for (const issue of this._issues.values()) {
await issue.detectStatic?.(hass); try {
await issue.detectStatic?.(hass);
} catch (e) {
// Isolate one issue's detection failure so it cannot abort detection
// for the rest; log so the cause is visible.
errorToConsole(e as Error);
}
this._logIfNew(issue); this._logIfNew(issue);
} }
} }
@@ -57,7 +63,7 @@ export class IssueStateManager implements IssueReadOnlyState {
} }
// ========================================================================= // =========================================================================
// Queries — read active issue state. // Queries -- read active issue state.
// ========================================================================= // =========================================================================
public getFullCardIssue(): IssueDescription | null { public getFullCardIssue(): IssueDescription | null {
+11 -11
View File
@@ -28,11 +28,11 @@ export interface KeyedIssueDescription {
} }
// Map of currently active issues keyed by IssueKey, with each entry's value // 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 // being the issue's current rendered description. Stored as a Map (not just a
// a Set of keys) so that sub-state changes within an issue — e.g. // Set of keys) so that sub-state changes within an issue -- e.g.
// ConnectionIssue swapping between 'lost' and 'starting' — are reflected as // ConnectionIssue swapping between 'lost' and 'starting' -- are reflected as
// real value-level diffs to the condition state, triggering re-renders and // real value-level diffs to the condition state, triggering re-renders and any
// any user-defined conditions that depend on issue state. // user-defined conditions that depend on issue state.
export type IssuePresence = Map<IssueKey, IssueDescription>; export type IssuePresence = Map<IssueKey, IssueDescription>;
export interface IssueReadOnlyState { export interface IssueReadOnlyState {
hasFullCardIssue(): boolean; hasFullCardIssue(): boolean;
@@ -80,7 +80,7 @@ export interface Issue {
// loop (exclusive), false to allow subsequent issues to also retry. // loop (exclusive), false to allow subsequent issues to also retry.
retry?(): boolean; retry?(): boolean;
// Optional user-initiated fix. Not called by the issue infrastructure — // Optional user-initiated fix. Not called by the issue infrastructure --
// callers (e.g. notification control actions) invoke this directly. // callers (e.g. notification control actions) invoke this directly.
fix?(hass: HomeAssistant): Promise<boolean>; fix?(hass: HomeAssistant): Promise<boolean>;
@@ -92,11 +92,11 @@ export interface Issue {
// Called when the card is detached. Issues with age-based timers (e.g. // 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 // loading-timeout timers) must stop them here so that time spent offscreen
// doesn't count against the user. Must preserve already-active issue state // doesn't count against the user. Must preserve already-active issue state --
// — a full-card issue visible at detach should still be visible on // a full-card issue visible at detach should still be visible on reattach. No
// reattach. No `resume` hook: IssueManager.resume() triggers a normal // `resume` hook: IssueManager.resume() triggers a normal evaluate(), so any
// evaluate(), so any timer that should restart is re-armed via // timer that should restart is re-armed via detectDynamic against the current
// detectDynamic against the current condition state. // condition state.
suspend?(): void; suspend?(): void;
// Release external resources (e.g. a listener registered on another manager) // Release external resources (e.g. a listener registered on another manager)
+2 -2
View File
@@ -10,8 +10,8 @@ import type { LockPolicy } from './types';
// - Stream-stopping / re-init actions: pause, reload, and casting (which // - Stream-stopping / re-init actions: pause, reload, and casting (which
// rehosts the stream to a media player). // rehosts the stream to a media player).
// //
// `call_start` is intentionally absent — it's the entry into the lock. // `call_start` is intentionally absent -- it's the entry into the lock.
// `call_end` is also absent — it dispatches via `setViewByParameters({ force: // `call_end` is also absent -- it dispatches via `setViewByParameters({ force:
// true })` to bypass the lock, so listing it here would be redundant. // true })` to bypass the lock, so listing it here would be redundant.
const CALL_DISRUPTIVE_ACTIONS: ReadonlySet<string> = new Set([ const CALL_DISRUPTIVE_ACTIONS: ReadonlySet<string> = new Set([
// View / camera / substream changes. // View / camera / substream changes.
+1 -1
View File
@@ -27,7 +27,7 @@ export class MediaLoadedInfoManager {
// never cleared by `clear` / `_clearTarget`, only by `initialize`. // never cleared by `clear` / `_clearTarget`, only by `initialize`.
private _lastKnown: Map<string, MediaLoadedInfo> = new Map(); private _lastKnown: Map<string, MediaLoadedInfo> = new Map();
// The currently "active" target — the one whose info drives condition state // The currently "active" target -- the one whose info drives condition state
// and card-level side effects. Driven by ViewManager on every view change. // and card-level side effects. Driven by ViewManager on every view change.
private _selected: string | null = null; private _selected: string | null = null;
+2 -2
View File
@@ -17,8 +17,8 @@ interface QueryStringViewIntent {
// The substream change to apply alongside the view. Tri-state: // The substream change to apply alongside the view. Tri-state:
// - `undefined`: no substream URL action present, no modifier issued. // - `undefined`: no substream URL action present, no modifier issued.
// - `string`: `substream_on=X` — engage stream X. // - `string`: `substream_on=X` -- engage stream X.
// - `null`: `substream_off` — explicitly clear the override. // - `null`: `substream_off` -- explicitly clear the override.
stream?: string | null; stream?: string | null;
}; };
other?: AdvancedCameraCardCustomActionConfig[]; other?: AdvancedCameraCardCustomActionConfig[];
+2 -2
View File
@@ -126,7 +126,7 @@ export class ViewManager implements ViewManagerInterface {
...options, ...options,
}); });
// A non-throwing factory call clears any prior view_incompatible / // A non-throwing factory call clears any prior view_incompatible /
// media_query state — ensures a previously-dismissed mid-session popup // media_query state -- ensures a previously-dismissed mid-session popup
// does not linger invisibly and re-pop on the next evaluation cycle, // does not linger invisibly and re-pop on the next evaluation cycle,
// and that a stale media_query failure from an abandoned gallery / // and that a stale media_query failure from an abandoned gallery /
// viewer doesn't follow the user into an unrelated view. // viewer doesn't follow the user into an unrelated view.
@@ -361,7 +361,7 @@ export class ViewManager implements ViewManagerInterface {
if (!this._api.getQueryStringManager().hasViewRelatedActionsToRun()) { if (!this._api.getQueryStringManager().hasViewRelatedActionsToRun()) {
// This is not awaited to allow the initialization to complete before the // This is not awaited to allow the initialization to complete before the
// query is answered. // query is answered.
this.setViewDefaultWithNewQuery({ failSafe: true }); void this.setViewDefaultWithNewQuery({ failSafe: true });
} }
}; };
+1 -1
View File
@@ -329,7 +329,7 @@ class AdvancedCameraCard extends LitElement {
protected updated(): void { protected updated(): void {
if (this._controller.getInitializationManager().isInitializedMandatory()) { if (this._controller.getInitializationManager().isInitializedMandatory()) {
this._controller.getQueryStringManager().executeIfNecessary(); void this._controller.getQueryStringManager().executeIfNecessary();
} }
} }
@@ -56,7 +56,7 @@ export class MicrophoneActionsController {
} }
this._callAnswered = answered; this._callAnswered = answered;
if (answered) { if (answered) {
this._unmuteIfConfigured('call'); void this._unmuteIfConfigured('call');
} else { } else {
this._muteIfConfigured('call'); this._muteIfConfigured('call');
} }
@@ -106,7 +106,10 @@ export class MicrophoneActionsController {
this._options?.microphoneManager && this._options?.microphoneManager &&
this._options.autoUnmuteConditions?.includes(condition) this._options.autoUnmuteConditions?.includes(condition)
) { ) {
await this._options.microphoneManager.unmute(); // A denied or missing microphone already shows in the UI: the menu
// microphone button switches to its forbidden icon. A failed auto-unmute
// has nothing more to act on.
await this._options.microphoneManager.unmute().catch(() => {});
} }
} }
@@ -68,7 +68,7 @@ export class MediaActionsController {
public setMicrophoneState(state: MicrophoneState): void { public setMicrophoneState(state: MicrophoneState): void {
const previous = this._microphoneState; const previous = this._microphoneState;
this._microphoneState = state; this._microphoneState = state;
this._microphoneStateChangeHandler(previous, state); void this._microphoneStateChangeHandler(previous, state);
} }
// Audio-out auto-mute/unmute driven by call answer: unmute when the call // Audio-out auto-mute/unmute driven by call answer: unmute when the call
@@ -85,10 +85,10 @@ export class MediaActionsController {
this._callAnswered = answered; this._callAnswered = answered;
if (answered) { if (answered) {
this._pendingCallStartAction = true; this._pendingCallStartAction = true;
this._applyPendingCallStartAction(); void this._applyPendingCallStartAction();
} else { } else {
this._pendingCallStartAction = false; this._pendingCallStartAction = false;
this._muteTargetIfConfigured('call'); void this._muteTargetIfConfigured('call');
} }
} }
@@ -260,7 +260,7 @@ export class MediaFilterController {
const queryCameraIDs = query.getAllCameraIDs(); const queryCameraIDs = query.getAllCameraIDs();
const cameraID = queryCameraIDs.size === 1 ? [...queryCameraIDs][0] : undefined; const cameraID = queryCameraIDs.size === 1 ? [...queryCameraIDs][0] : undefined;
this._viewManager?.setViewByParametersWithExistingQuery({ void this._viewManager?.setViewByParametersWithExistingQuery({
params: { params: {
query, query,
// If single camera, set it as the active camera for menu navigation // If single camera, set it as the active camera for menu navigation
@@ -9,7 +9,7 @@ interface MediaLoadedInfoSinkConfig {
// notification. // notification.
getTargetID: () => string | null; getTargetID: () => string | null;
// Fires when the active info changes — i.e. when the active target's entry // Fires when the active info changes -- i.e. when the active target's entry
// transitions (load arrives, abort, or selection changes the active entry). // transitions (load arrives, abort, or selection changes the active entry).
// Loads for other targets are cached but do not fire the callback. // Loads for other targets are cached but do not fire the callback.
callback?: (info: MediaLoadedInfo | null) => void; callback?: (info: MediaLoadedInfo | null) => void;
@@ -28,7 +28,7 @@ interface MediaLoadedInfoSinkConfig {
* user selects a slide whose media has already loaded, the sink immediately * user selects a slide whose media has already loaded, the sink immediately
* exposes the cached entry. * exposes the cached entry.
* *
* Lifecycle asymmetry — `callback` fires on: * Lifecycle asymmetry -- `callback` fires on:
* - the active target's load arrival, * - the active target's load arrival,
* - the active target's source aborting (with `null`), and * - the active target's source aborting (with `null`), and
* - selection changing to / from a target whose active info differs. * - selection changing to / from a target whose active info differs.
@@ -46,7 +46,7 @@ export class MediaLoadedInfoSinkController implements ReactiveController {
// stale abort can't blow away an entry that's since been overwritten. // stale abort can't blow away an entry that's since been overwritten.
private _byTarget = new Map<string, MediaLoadedInfo>(); private _byTarget = new Map<string, MediaLoadedInfo>();
// The targetID whose info we last surfaced — drives `hostUpdated` change // The targetID whose info we last surfaced -- drives `hostUpdated` change
// detection. `_lastActiveInfo` records what the callback last saw, so we // detection. `_lastActiveInfo` records what the callback last saw, so we
// don't fire it for no-op selection changes (e.g. selection changes but // don't fire it for no-op selection changes (e.g. selection changes but
// both old and new are null/loaded with the same info reference). // both old and new are null/loaded with the same info reference).
@@ -67,7 +67,7 @@ export class MediaLoadedInfoSinkController implements ReactiveController {
} }
public hostUpdated(): void { public hostUpdated(): void {
// Detect selection changes — `getTargetID` is owned by the host and may // Detect selection changes -- `getTargetID` is owned by the host and may
// flip when its props change (carousel slide change, view change, etc.). // flip when its props change (carousel slide change, view change, etc.).
const newID = this._config.getTargetID(); const newID = this._config.getTargetID();
if (newID !== this._lastActiveTargetID) { if (newID !== this._lastActiveTargetID) {
@@ -53,7 +53,7 @@ export class MediaLoadedInfoSourceController implements ReactiveController {
private _abort: AbortController | null = null; private _abort: AbortController | null = null;
// Survives disconnect so we can re-dispatch on reconnect. Only ever holds // Survives disconnect so we can re-dispatch on reconnect. Only ever holds
// info validated by `set` — i.e., always has a targetID. // info validated by `set` -- i.e., always has a targetID.
private _lastSet: TargetedMediaLoadedInfo | null = null; private _lastSet: TargetedMediaLoadedInfo | null = null;
constructor( constructor(
@@ -67,7 +67,7 @@ export class MediaLoadedInfoSourceController implements ReactiveController {
public hostConnected(): void { public hostConnected(): void {
// Two early-returns: // Two early-returns:
// - `!_lastSet`: nothing to replay — either the host has never seen a // - `!_lastSet`: nothing to replay -- either the host has never seen a
// media load or the cache was discarded as stale on a prior reconnect // media load or the cache was discarded as stale on a prior reconnect
// (see below). // (see below).
// - `_abort` non-null: a dispatch is already live, meaning we're already // - `_abort` non-null: a dispatch is already live, meaning we're already
@@ -79,7 +79,7 @@ export class MediaLoadedInfoSourceController implements ReactiveController {
return; return;
} }
// Revalidate against the current targetID — the host's property may have // Revalidate against the current targetID -- the host's property may have
// flipped while we were disconnected. Replaying the cached info under a // flipped while we were disconnected. Replaying the cached info under a
// stale targetID would misregister with the manager. // stale targetID would misregister with the manager.
if (this._lastSet.targetID === this._config.getTargetID()) { if (this._lastSet.targetID === this._config.getTargetID()) {
+2 -2
View File
@@ -55,7 +55,7 @@ export const navigateUp = (options?: FolderNavigationParamaters | null): void =>
}, },
); );
options?.viewManagerEpoch.manager.setViewByParametersWithExistingQuery({ void options?.viewManagerEpoch.manager.setViewByParametersWithExistingQuery({
params: { query }, params: { query },
}); });
}; };
@@ -77,7 +77,7 @@ export const navigateToFolder = (
}, },
); );
options?.viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({ void options?.viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({
params: { query }, params: { query },
}); });
}; };
+1 -1
View File
@@ -48,7 +48,7 @@ export class PTZDragController implements ReactiveController {
// continuous mode. Below that threshold, drag-end dispatches relative. // continuous mode. Below that threshold, drag-end dispatches relative.
private _continuous = false; private _continuous = false;
// When a pinch occurs mid-drag, the drag is "poisoned" — all remaining // When a pinch occurs mid-drag, the drag is "poisoned" -- all remaining
// drag events for that gesture are ignored to prevent stray pan/tilt. // drag events for that gesture are ignored to prevent stray pan/tilt.
private _dragCancelledByPinch = false; private _dragCancelledByPinch = false;
+1 -1
View File
@@ -64,7 +64,7 @@ export class SignedURLController implements ReactiveController {
// When the endpoint requires signing or proxying, the URL must go through // When the endpoint requires signing or proxying, the URL must go through
// the async resolution path. For proxied URLs, under no circumstances // the async resolution path. For proxied URLs, under no circumstances
// should we fall back to returning the unproxied URL — doing so risks // should we fall back to returning the unproxied URL -- doing so risks
// leaking traffic or causing mixed-content errors. // leaking traffic or causing mixed-content errors.
if (options.proxyConfig?.enabled || options.endpoint?.sign) { if (options.proxyConfig?.enabled || options.endpoint?.sign) {
return this._value; return this._value;
+1 -1
View File
@@ -408,7 +408,7 @@ export class TimelineController {
this._setTargetBarAppropriately(targetTime); this._setTargetBarAppropriately(targetTime);
} }
this._throttledSetViewDuringRangeChange(targetTime, properties); void this._throttledSetViewDuringRangeChange(targetTime, properties);
} }
} }
+1 -1
View File
@@ -112,7 +112,7 @@ export class AdvancedCameraCardImageUpdatingPlayer
() => dispatchMediaPlayEvent(this), () => dispatchMediaPlayEvent(this),
() => dispatchMediaPauseEvent(this), () => dispatchMediaPauseEvent(this),
// Clear image load errors on each timer tick so the next render retries // Clear image load errors on each timer tick so the next render retries
// the <img> — but only for modes where the underlying URL genuinely // the <img> -- but only for modes where the underlying URL genuinely
// changes between ticks (camera/entity snapshots). For mode: url, the // changes between ticks (camera/entity snapshots). For mode: url, the
// same static URL will fail the same way every time, so clearing the // same static URL will fail the same way every time, so clearing the
// error just causes a visible flicker (notification → blank <img> → // error just causes a visible flicker (notification → blank <img> →
+5 -5
View File
@@ -233,7 +233,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
private _setViewCameraID(cameraID?: string | null): void { private _setViewCameraID(cameraID?: string | null): void {
if (cameraID) { if (cameraID) {
this.viewManagerEpoch?.manager.setViewByParametersWithNewQuery({ void this.viewManagerEpoch?.manager.setViewByParametersWithNewQuery({
params: { params: {
camera: cameraID, camera: cameraID,
}, },
@@ -319,9 +319,9 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
const controller = this._mediaLoadedInfoSinkController.get()?.mediaPlayerController; const controller = this._mediaLoadedInfoSinkController.get()?.mediaPlayerController;
// Fire-and-forget; the `volumechange` event drives the re-render. // Fire-and-forget; the `volumechange` event drives the re-render.
if (controller?.isMuted()) { if (controller?.isMuted()) {
controller.unmute(); void controller.unmute();
} else { } else {
controller?.mute(); void controller?.mute();
} }
} }
@@ -478,7 +478,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
const selectedCameraIndex = this._getSelectedCameraIndex(); const selectedCameraIndex = this._getSelectedCameraIndex();
if (this.viewFilterCameraID) { if (this.viewFilterCameraID) {
this._mediaActionsController.setTarget( void this._mediaActionsController.setTarget(
selectedCameraIndex, selectedCameraIndex,
// Camera in this carousel is only selected if the camera from the // Camera in this carousel is only selected if the camera from the
// view matches the filtered camera. // view matches the filtered camera.
@@ -486,7 +486,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
); );
} else { } else {
// Carousel is not filtered, so the targeted camera is always selected. // Carousel is not filtered, so the targeted camera is always selected.
this._mediaActionsController.setTarget(selectedCameraIndex, true); void this._mediaActionsController.setTarget(selectedCameraIndex, true);
} }
this._mediaHeightController.setSelected(selectedCameraIndex); this._mediaHeightController.setSelected(selectedCameraIndex);
+1 -1
View File
@@ -103,7 +103,7 @@ export class AdvancedCameraCardLiveGrid extends LitElement {
protected willUpdate(changedProps: PropertyValues): void { protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('viewManagerEpoch') && this._needsGrid()) { if (changedProps.has('viewManagerEpoch') && this._needsGrid()) {
import('../media-grid.js'); void import('../media-grid.js');
} }
} }
+1 -1
View File
@@ -81,7 +81,7 @@ export class AdvancedCameraCardLive extends LitElement {
// from a hidden live view. Treat the live view as having no selected // from a hidden live view. Treat the live view as having no selected
// camera unless it is the active view. // camera unless it is the active view.
const view = this.viewManagerEpoch?.manager.getView(); const view = this.viewManagerEpoch?.manager.getView();
this._microphoneActionsController.setSelectedCamera( void this._microphoneActionsController.setSelectedCamera(
view?.is('live') ? view.camera ?? null : null, view?.is('live') ? view.camera ?? null : null,
); );
} }
@@ -140,7 +140,7 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
// VideoRTC owns the transition: it updates microphoneStream, swaps the // VideoRTC owns the transition: it updates microphoneStream, swaps the
// track on the pre-armed transceiver, and validates against stale async // track on the pre-armed transceiver, and validates against stale async
// completions before any reconnect fallback. Fire-and-forget is fine. // completions before any reconnect fallback. Fire-and-forget is fine.
/* async */ this._player.setMicrophoneStream(this.microphoneStream ?? null); void this._player.setMicrophoneStream(this.microphoneStream ?? null);
} }
} }
@@ -271,7 +271,7 @@ export class VideoRTC extends HTMLElement {
/** /**
* Owns the microphone stream transition end-to-end: updates the property, * Owns the microphone stream transition end-to-end: updates the property,
* extracts the outbound audio track, and swaps it onto the pre-armed audio * extracts the outbound audio track, and swaps it onto the pre-armed audio
* transceiver via `replaceTrack` — no SDP renegotiation, no visible reload. * transceiver via `replaceTrack` -- no SDP renegotiation, no visible reload.
* *
* Falls back to a full reconnect if `replaceTrack` rejects, but only when * Falls back to a full reconnect if `replaceTrack` rejects, but only when
* the rejection still describes the current desired state. The transceiver * the rejection still describes the current desired state. The transceiver
@@ -410,8 +410,8 @@ export class VideoRTC extends HTMLElement {
*/ */
disconnectedCallback() { disconnectedCallback() {
// Synchronous manager-side cleanup by aborting the load's signal. The // Synchronous manager-side cleanup by aborting the load's signal. The
// signal's abort listeners — registered by the card-root listener and // signal's abort listeners -- registered by the card-root listener and
// any sinks in the bubble path — fire even though `parentNode` is // any sinks in the bubble path -- fire even though `parentNode` is
// already null, because abort is plain JS, not DOM-event-bound. // already null, because abort is plain JS, not DOM-event-bound.
this._abortController?.abort(); this._abortController?.abort();
this._abortController = null; this._abortController = null;
@@ -833,7 +833,7 @@ export class VideoRTC extends HTMLElement {
// Always pre-arm a single outbound audio transceiver so the SDP advertises // Always pre-arm a single outbound audio transceiver so the SDP advertises
// the slot from the start. With the slot in place, the mic track can be // the slot from the start. With the slot in place, the mic track can be
// attached/detached later via `setMicrophoneStream` (replaceTrack) without // attached/detached later via `setMicrophoneStream` (replaceTrack) without
// renegotiating — avoiding a visible reload of this cell each time grid // renegotiating -- avoiding a visible reload of this cell each time grid
// selection moves the mic between cameras. // selection moves the mic between cameras.
// //
// Pure SDP allocation: the kind-only `addTransceiver('audio', ...)` form // Pure SDP allocation: the kind-only `addTransceiver('audio', ...)` form
+1 -1
View File
@@ -200,7 +200,7 @@ export class AdvancedCameraCardLiveWebRTCCard extends LitElement implements Medi
public updated(): void { public updated(): void {
// Extract the video component after it has been rendered and generate the // Extract the video component after it has been rendered and generate the
// media load event. // media load event.
this.updateComplete.then(() => { void this.updateComplete.then(() => {
this._videoRTC = this.renderRoot?.querySelector('#webrtc') ?? null; this._videoRTC = this.renderRoot?.querySelector('#webrtc') ?? null;
const video = this._getVideo(); const video = this._getVideo();
if (video) { if (video) {
+1 -1
View File
@@ -60,7 +60,7 @@ export class AdvancedCameraCardLoading extends LitElement {
} }
private _startEffect(effect: EffectName): void { private _startEffect(effect: EffectName): void {
this.effectsManager?.startEffect(effect, { fadeIn: false }); void this.effectsManager?.startEffect(effect, { fadeIn: false });
this._effectName = effect; this._effectName = effect;
} }
+1 -1
View File
@@ -76,7 +76,7 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) {
this.cameraManager, this.cameraManager,
this.foldersManager, this.foldersManager,
); );
this._mediaFilterController.computeMetadataOptions(this.cameraManager); void this._mediaFilterController.computeMetadataOptions(this.cameraManager);
} }
// The first time the viewManager is set, compute the initial default selections. // The first time the viewManager is set, compute the initial default selections.
+1 -1
View File
@@ -87,7 +87,7 @@ export class AdvancedCameraCardSubmenuSelectButton extends LitElement {
} }
if (!this._optionTitles) { if (!this._optionTitles) {
this._refreshOptionTitles(); void this._refreshOptionTitles();
} }
const entityID = this.submenuSelect.entity; const entityID = this.submenuSelect.entity;
+1 -1
View File
@@ -66,7 +66,7 @@ export class AdvancedCameraCardSurround extends LitElement {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
protected willUpdate(_changedProperties: PropertyValues): void { protected willUpdate(_changedProperties: PropertyValues): void {
if (this.timelineConfig?.mode && this.timelineConfig.mode !== 'none') { if (this.timelineConfig?.mode && this.timelineConfig.mode !== 'none') {
import('./timeline-core.js'); void import('./timeline-core.js');
} }
} }
@@ -62,7 +62,7 @@ export class AdvancedCameraCardThumbnailFeatureThumbnail extends LitElement {
this._embedThumbnailTask?.status === TaskStatus.INITIAL && this._embedThumbnailTask?.status === TaskStatus.INITIAL &&
entries.some((entry) => entry.isIntersecting) entries.some((entry) => entry.isIntersecting)
) { ) {
this._embedThumbnailTask?.run(); void this._embedThumbnailTask?.run();
} }
} }
+1 -1
View File
@@ -277,7 +277,7 @@ export class AdvancedCameraCardTimelineCore extends LitElement {
this._controller.setView(this.viewManagerEpoch ?? null), this._controller.setView(this.viewManagerEpoch ?? null),
); );
} else { } else {
this._controller.setView(this.viewManagerEpoch ?? null); void this._controller.setView(this.viewManagerEpoch ?? null);
} }
} }
+6 -6
View File
@@ -93,7 +93,7 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
(this._selected !== null && this._media?.[this._selected]?.getID()) || null, (this._selected !== null && this._media?.[this._selected]?.getID()) || null,
callback: () => { callback: () => {
this._mediaHeightController.recalculate(); this._mediaHeightController.recalculate();
this._seekHandler(); void this._seekHandler();
}, },
}); });
@@ -388,7 +388,7 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
?.context?.mediaViewer?.seek?.getTime() !== ?.context?.mediaViewer?.seek?.getTime() !==
this.viewManagerEpoch?.oldView?.context?.mediaViewer?.seek?.getTime() this.viewManagerEpoch?.oldView?.context?.mediaViewer?.seek?.getTime()
) { ) {
this._seekHandler(); void this._seekHandler();
} }
} }
} }
@@ -397,7 +397,7 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
if (!this._media?.length || this._selected === null) { if (!this._media?.length || this._selected === null) {
this._mediaActionsController.unsetTarget(); this._mediaActionsController.unsetTarget();
} else { } else {
this._mediaActionsController.setTarget( void this._mediaActionsController.setTarget(
this._selected, this._selected,
// Camera in this carousel is only selected if the camera from the view // Camera in this carousel is only selected if the camera from the view
// matches the filtered camera. // matches the filtered camera.
@@ -438,16 +438,16 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
const seekTimeInMedia = selectedMedia.includesTime(seek); const seekTimeInMedia = selectedMedia.includesTime(seek);
this.toggleAttribute('unseekable', !seekTimeInMedia); this.toggleAttribute('unseekable', !seekTimeInMedia);
if (!seekTimeInMedia && !mediaPlayerController.isPaused()) { if (!seekTimeInMedia && !mediaPlayerController.isPaused()) {
mediaPlayerController.pause(); void mediaPlayerController.pause();
} else if (seekTimeInMedia && mediaPlayerController.isPaused()) { } else if (seekTimeInMedia && mediaPlayerController.isPaused()) {
mediaPlayerController.play(); void mediaPlayerController.play();
} }
const seekTime = const seekTime =
(await this.cameraManager?.getMediaSeekTime(selectedMedia, seek)) ?? null; (await this.cameraManager?.getMediaSeekTime(selectedMedia, seek)) ?? null;
if (seekTime !== null) { if (seekTime !== null) {
mediaPlayerController.seek(seekTime); void mediaPlayerController.seek(seekTime);
} }
} }
+1 -1
View File
@@ -72,7 +72,7 @@ export class AdvancedCameraCardViewerGrid extends LitElement {
protected willUpdate(changedProps: PropertyValues): void { protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('viewManagerEpoch') && this._needsGrid()) { if (changedProps.has('viewManagerEpoch') && this._needsGrid()) {
import('../media-grid.js'); void import('../media-grid.js');
} }
} }
+3 -3
View File
@@ -157,11 +157,11 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
changedProps.has('resolvedMediaCache') || changedProps.has('resolvedMediaCache') ||
changedProps.has('hass') changedProps.has('hass')
) { ) {
this._resolveURL(); void this._resolveURL();
} }
if (changedProps.has('viewerConfig') && this.viewerConfig?.zoomable) { if (changedProps.has('viewerConfig') && this.viewerConfig?.zoomable) {
import('../zoomer.js'); void import('../zoomer.js');
} }
} }
@@ -290,7 +290,7 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
.targetID=${mediaID} .targetID=${mediaID}
@click=${() => { @click=${() => {
if (this.viewerConfig?.snapshot_click_plays_clip) { if (this.viewerConfig?.snapshot_click_plays_clip) {
this._switchToRelatedClipView(); void this._switchToRelatedClipView();
} }
}} }}
></advanced-camera-card-image-player>`} ></advanced-camera-card-image-player>`}
+5 -5
View File
@@ -90,16 +90,16 @@ export class AdvancedCameraCardViews extends LitElement {
if (changedProps.has('viewManagerEpoch') || changedProps.has('config')) { if (changedProps.has('viewManagerEpoch') || changedProps.has('config')) {
const view = this.viewManagerEpoch?.manager.getView(); const view = this.viewManagerEpoch?.manager.getView();
if (view?.is('live') || this._shouldLivePreload()) { if (view?.is('live') || this._shouldLivePreload()) {
import('./live/index.js'); void import('./live/index.js');
} }
if (view?.isGalleryView()) { if (view?.isGalleryView()) {
import('./gallery/gallery.js'); void import('./gallery/gallery.js');
} else if (view?.isViewerView()) { } else if (view?.isViewerView()) {
import('./viewer/index.js'); void import('./viewer/index.js');
} else if (view?.is('image')) { } else if (view?.is('image')) {
import('./image.js'); void import('./image.js');
} else if (view?.is('timeline')) { } else if (view?.is('timeline')) {
import('./timeline.js'); void import('./timeline.js');
} }
} }
+1 -1
View File
@@ -6,7 +6,7 @@ import { ConditionState } from '../conditions/types';
// Parses a Home Assistant time-period value (a condition/trigger `for:`) to // Parses a Home Assistant time-period value (a condition/trigger `for:`) to
// seconds, matching HA's `cv.time_period`: // seconds, matching HA's `cv.time_period`:
// - a number, or a bare numeric string, is a count of seconds; // - a number, or a bare numeric string, is a count of seconds;
// - a colon string is `HH:MM` or `HH:MM:SS` — HA reads TWO parts as // - a colon string is `HH:MM` or `HH:MM:SS` -- HA reads TWO parts as
// hours:minutes (not minutes:seconds); // hours:minutes (not minutes:seconds);
// - a `{days, hours, minutes, seconds, milliseconds}` dict (each field a // - a `{days, hours, minutes, seconds, milliseconds}` dict (each field a
// number or a numeric string, e.g. once a template field has been rendered). // number or a numeric string, e.g. once a template field has been rendered).
+1 -1
View File
@@ -1,5 +1,5 @@
import { z } from 'zod'; import { z } from 'zod';
// A value that may be a single string or a list of strings — common across HA // A value that may be a single string or a list of strings -- common across HA
// condition/trigger fields (e.g. `state`, `to`, `entity_id`). // condition/trigger fields (e.g. `state`, `to`, `entity_id`).
export const stringOrArray = z.string().or(z.string().array()); export const stringOrArray = z.string().or(z.string().array());
+9 -5
View File
@@ -278,9 +278,9 @@ import {
CONF_VIEW_TRIGGERS_ACTIONS_INTERACTION_MODE, CONF_VIEW_TRIGGERS_ACTIONS_INTERACTION_MODE,
CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER, CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER,
CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER, CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER,
CONF_VIEW_TRIGGERS_EVENT_HOLD_SECONDS,
CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA, CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA,
CONF_VIEW_TRIGGERS_SHOW_TRIGGER_STATUS, CONF_VIEW_TRIGGERS_SHOW_TRIGGER_STATUS,
CONF_VIEW_TRIGGERS_EVENT_HOLD_SECONDS,
CONF_VIEW_TRIGGERS_UNTRIGGER_DELAY_SECONDS, CONF_VIEW_TRIGGERS_UNTRIGGER_DELAY_SECONDS,
CONF_VIEW_TRIGGERS_UNTRIGGER_FORCE_SECONDS, CONF_VIEW_TRIGGERS_UNTRIGGER_FORCE_SECONDS,
DOCS_URL, DOCS_URL,
@@ -294,7 +294,7 @@ import { HomeAssistant, LovelaceCardEditor } from './ha/types.js';
import { localize } from './localize/localize.js'; import { localize } from './localize/localize.js';
import editorStyle from './scss/editor.scss'; import editorStyle from './scss/editor.scss';
import type { CapabilityKey } from './types.js'; import type { CapabilityKey } from './types.js';
import { arrayMove, prettifyTitle } from './utils/basic.js'; import { arrayMove, errorToConsole, prettifyTitle } from './utils/basic.js';
import { getCameraID } from './utils/camera.js'; import { getCameraID } from './utils/camera.js';
import { fireAdvancedCameraCardEvent } from './utils/fire-advanced-camera-card-event.js'; import { fireAdvancedCameraCardEvent } from './utils/fire-advanced-camera-card-event.js';
import { getFolderID } from './utils/folder.js'; import { getFolderID } from './utils/folder.js';
@@ -1255,9 +1255,13 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
protected willUpdate(): void { protected willUpdate(): void {
if (!this._initialized) { if (!this._initialized) {
sideLoadHomeAssistantElements().then(() => { sideLoadHomeAssistantElements()
this._initialized = true; .then(() => {
}); this._initialized = true;
})
// A failure leaves the editor with degraded HA form elements and is
// retried on the next update; log it so the cause is at least visible.
.catch((e) => errorToConsole(e));
} }
} }
+1 -1
View File
@@ -82,7 +82,7 @@ export const createProxiedEndpointIfNecessary = async (
} }
if (proxyConfig.dynamic) { if (proxyConfig.dynamic) {
// Strip hash fragment — it's client-side only and not relevant for // Strip hash fragment -- it's client-side only and not relevant for
// proxy pattern matching. // proxy pattern matching.
const url = endpoint.endpoint.split(/#/)[0]; const url = endpoint.endpoint.split(/#/)[0];
await addDynamicProxyURL(hass, url, { await addDynamicProxyURL(hass, url, {
+2 -2
View File
@@ -25,7 +25,7 @@ import { onAbort } from '../utils/abort-signal.js';
import './ha-hls-player.js'; import './ha-hls-player.js';
import './ha-web-rtc-player.js'; import './ha-web-rtc-player.js';
customElements.whenDefined('ha-camera-stream').then(() => { void customElements.whenDefined('ha-camera-stream').then(() => {
// ======================================================================================== // ========================================================================================
// From: // From:
// - https://github.com/home-assistant/frontend/blob/dev/src/data/camera.ts // - https://github.com/home-assistant/frontend/blob/dev/src/data/camera.ts
@@ -56,7 +56,7 @@ customElements.whenDefined('ha-camera-stream').then(() => {
// ha-camera-stream renders up to three inner players (MJPEG / HLS / // ha-camera-stream renders up to three inner players (MJPEG / HLS /
// WebRTC), only one visible. Inner leaves all fire `media:loaded` // WebRTC), only one visible. Inner leaves all fire `media:loaded`
// independently — we suppress those at this boundary (`stopPropagation` in // independently -- we suppress those at this boundary (`stopPropagation` in
// `_captureInnerLoad`), cache the latest per type, and republish the // `_captureInnerLoad`), cache the latest per type, and republish the
// visible one's info via our own source controller in `updated()`. // visible one's info via our own source controller in `updated()`.
private _mediaLoadedInfoPerStream: Record<StreamType, MediaLoadedInfo> = {}; private _mediaLoadedInfoPerStream: Record<StreamType, MediaLoadedInfo> = {};
+1 -1
View File
@@ -32,7 +32,7 @@ import {
} from '../utils/media-info.js'; } from '../utils/media-info.js';
import { ConstructableLitElement } from './types.js'; import { ConstructableLitElement } from './types.js';
customElements.whenDefined('ha-hls-player').then(() => { void customElements.whenDefined('ha-hls-player').then(() => {
const HaHlsPlayer = customElements.get('ha-hls-player') as ConstructableLitElement; const HaHlsPlayer = customElements.get('ha-hls-player') as ConstructableLitElement;
@customElement('advanced-camera-card-ha-hls-player') @customElement('advanced-camera-card-ha-hls-player')
+1 -1
View File
@@ -35,7 +35,7 @@ import {
} from '../utils/media-info.js'; } from '../utils/media-info.js';
import { ConstructableLitElement } from './types.js'; import { ConstructableLitElement } from './types.js';
customElements.whenDefined('ha-web-rtc-player').then(() => { void customElements.whenDefined('ha-web-rtc-player').then(() => {
const HaWebRtcPlayer = customElements.get( const HaWebRtcPlayer = customElements.get(
'ha-web-rtc-player', 'ha-web-rtc-player',
) as ConstructableLitElement; ) as ConstructableLitElement;
+1 -1
View File
@@ -60,7 +60,7 @@
} }
} }
// Transmitting: breathe between a softer and a fuller glow — never fully fades, // Transmitting: breathe between a softer and a fuller glow -- never fully fades,
// so the camera always reads as live. // so the camera always reads as live.
@keyframes transmitting-vignette-pulse { @keyframes transmitting-vignette-pulse {
0%, 0%,
+2 -2
View File
@@ -1,7 +1,7 @@
// Shared "pop" enter/exit animation for overlays. // Shared "pop" enter/exit animation for overlays.
// //
// `@include pop-in` auto-plays an entrance on render. `@include pop-out` — // `@include pop-in` auto-plays an entrance on render. `@include pop-out` --
// typically guarded by an `.exiting` class — plays the matching exit; it is // typically guarded by an `.exiting` class -- plays the matching exit; it is
// named `pop-out` so an `animationend` handler can detect exit completion and // named `pop-out` so an `animationend` handler can detect exit completion and
// unmount the element. // unmount the element.
+1 -1
View File
@@ -43,7 +43,7 @@ export interface MediaLoadedInfo {
mediaPlayerController?: MediaPlayerController; mediaPlayerController?: MediaPlayerController;
capabilities?: MediaLoadedCapabilities; capabilities?: MediaLoadedCapabilities;
// Universal key identifying "what this media belongs to" — a camera ID for // Universal key identifying "what this media belongs to" -- a camera ID for
// live, a media ID for the viewer, or a sentinel for the image view. // live, a media ID for the viewer, or a sentinel for the image view.
targetID?: string; targetID?: string;
} }
+7 -7
View File
@@ -35,13 +35,13 @@ export const hasAudio = (
pc?: RTCPeerConnection | null, pc?: RTCPeerConnection | null,
mseCodecs?: string, mseCodecs?: string,
): boolean => { ): boolean => {
// For WebRTC: Check if there's an audio receiver with an active track. // For WebRTC: Check if there's an audio receiver with an active track. We
// We check that the track is not muted because muted means no media data // check that the track is not muted because muted means no media data is
// is flowing (e.g., the source isn't producing audio). It is not related to // flowing (e.g., the source isn't producing audio). It is not related to the
// the audio being muted by the user on the receiving end. // audio being muted by the user on the receiving end. Only trust receivers
// Only trust receivers when the connection is actually established — a stale // when the connection is actually established -- a stale RTCPeerConnection
// RTCPeerConnection (e.g. WebRTC failed, fell back to MSE) will have // (e.g. WebRTC failed, fell back to MSE) will have receivers with muted
// receivers with muted tracks that don't reflect actual media availability. // tracks that don't reflect actual media availability.
// See: https://github.com/dermotduffy/advanced-camera-card/issues/2417 // See: https://github.com/dermotduffy/advanced-camera-card/issues/2417
if (pc && pc.connectionState === 'connected') { if (pc && pc.connectionState === 'connected') {
const receivers = pc.getReceivers(); const receivers = pc.getReceivers();
+2 -2
View File
@@ -1,7 +1,7 @@
import { AdvancedCameraCardError } from '../types.js'; import { AdvancedCameraCardError } from '../types.js';
// Narrows an unknown error to its structured object `context` — non-null and // 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 // of object type -- or null if the error is not an AdvancedCameraCardError or
// has no usable context. Consolidates the instanceof + typeof + null-guard // has no usable context. Consolidates the instanceof + typeof + null-guard
// dance that notification builders and error handlers would otherwise repeat. // dance that notification builders and error handlers would otherwise repeat.
export const getContextFromError = (error: unknown): object | null => export const getContextFromError = (error: unknown): object | null =>
+1 -1
View File
@@ -1,7 +1,7 @@
/** /**
* Walk up `element`'s ancestor chain (through shadow boundaries) looking for * Walk up `element`'s ancestor chain (through shadow boundaries) looking for
* an ancestor with the given tag name. Returns true if one is found and that * an ancestor with the given tag name. Returns true if one is found and that
* same element also appears in the event's composedPath — indicating the event * same element also appears in the event's composedPath -- indicating the event
* originated from within the same subtree as the element. * originated from within the same subtree as the element.
*/ */
export const isAncestorInEventPath = ( export const isAncestorInEventPath = (
+4 -4
View File
@@ -11,10 +11,10 @@ const MEDIA_INFO_HEIGHT_CUTOFF = 50;
const MEDIA_INFO_WIDTH_CUTOFF = MEDIA_INFO_HEIGHT_CUTOFF; const MEDIA_INFO_WIDTH_CUTOFF = MEDIA_INFO_HEIGHT_CUTOFF;
/** /**
* Create a MediaLoadedInfo object. `targetID` is intentionally NOT an option * Create a MediaLoadedInfo object. `targetID` is intentionally NOT an option --
* — it's owned by the source controller (`MediaLoadedInfoSourceController`) * it's owned by the source controller (`MediaLoadedInfoSourceController`) and
* and injected at dispatch time, so leaves don't have to (and can't) plumb * injected at dispatch time, so leaves don't have to (and can't) plumb it
* it through info construction. * through info construction.
* @param source An event or HTMLElement that should be used as a source. * @param source An event or HTMLElement that should be used as a source.
* @returns A new info or null if one could not be created. * @returns A new info or null if one could not be created.
*/ */
+2
View File
@@ -1,4 +1,6 @@
// Usage of this function needs to be justified with a comment. // Usage of this function needs to be justified with a comment.
export const sleep = async (seconds: number) => { export const sleep = async (seconds: number) => {
// This is the low-level delay primitive callers reach for instead of a raw timer.
// eslint-disable-next-line no-restricted-syntax
await new Promise((r) => setTimeout(r, seconds * 1000)); await new Promise((r) => setTimeout(r, seconds * 1000));
}; };
+4
View File
@@ -19,6 +19,8 @@ export class Timer {
public start(seconds: number, func: () => void): void { public start(seconds: number, func: () => void): void {
this.stop(); this.stop();
// This class is the sanctioned wrapper for the browser timer APIs.
// eslint-disable-next-line no-restricted-syntax
this._timer = window.setTimeout(() => { this._timer = window.setTimeout(() => {
this._timer = null; this._timer = null;
func(); func();
@@ -28,6 +30,8 @@ export class Timer {
public startRepeated(seconds: number, func: () => void): void { public startRepeated(seconds: number, func: () => void): void {
this.stop(); this.stop();
// This class is the sanctioned wrapper for the browser timer APIs.
// eslint-disable-next-line no-restricted-syntax
this._timer = window.setInterval(() => { this._timer = window.setInterval(() => {
func(); func();
}, seconds * 1000); }, seconds * 1000);
+1 -1
View File
@@ -76,7 +76,7 @@ function strip(schema: z.ZodType, cache: Map<z.ZodType, z.ZodType>): z.ZodType {
let result: z.ZodType; let result: z.ZodType;
if (schema instanceof z.ZodDefault || schema instanceof z.ZodPrefault) { if (schema instanceof z.ZodDefault || schema instanceof z.ZodPrefault) {
// Unwrap the default — don't cache the wrapper itself. // Unwrap the default -- don't cache the wrapper itself.
result = strip(toClassic(schema.unwrap()), cache); result = strip(toClassic(schema.unwrap()), cache);
} else if (schema instanceof z.ZodObject) { } else if (schema instanceof z.ZodObject) {
const newShape: Record<string, z.core.$ZodType> = {}; const newShape: Record<string, z.core.$ZodType> = {};
+3 -3
View File
@@ -6,12 +6,12 @@ import { View } from './view';
// the target-ID namespace. Must not collide with real camera IDs or media IDs. // the target-ID namespace. Must not collide with real camera IDs or media IDs.
export const IMAGE_VIEW_TARGET_ID_SENTINEL = '__IMAGE_VIEW__'; export const IMAGE_VIEW_TARGET_ID_SENTINEL = '__IMAGE_VIEW__';
// Returns a universal target identifier for the current view — the single key // 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 // used by PTZ/zoom state and media retry epochs to identify "what is currently
// being displayed." For live, this is the *base* camera ID (substream is an // being displayed." For live, this is the *base* camera ID (substream is an
// implementation detail of how to play camera X, not a separate logical // implementation detail of how to play camera X, not a separate logical
// identity — see `getStreamCameraID` for the substream-aware variant used // identity -- see `getStreamCameraID` for the substream-aware variant used only
// only inside the playback chain). // inside the playback chain).
export const getViewTargetID = (view: View): string | null => { export const getViewTargetID = (view: View): string | null => {
if (view.isViewerView()) { if (view.isViewerView()) {
return view.queryResults?.getSelectedResult()?.getID() ?? null; return view.queryResults?.getSelectedResult()?.getID() ?? null;
+1 -1
View File
@@ -46,7 +46,7 @@ describe('ActionHandler', () => {
// A document-level mouseup should cancel the hold timer. // A document-level mouseup should cancel the hold timer.
document.dispatchEvent(new MouseEvent('mouseup')); document.dispatchEvent(new MouseEvent('mouseup'));
// Advance past hold time — hold should NOT have triggered. // Advance past hold time -- hold should NOT have triggered.
vi.advanceTimersByTime(500); vi.advanceTimersByTime(500);
element.dispatchEvent(new MouseEvent('click')); element.dispatchEvent(new MouseEvent('click'));
@@ -501,7 +501,7 @@ describe('should handle ptz action', () => {
// Emulate the stop being called while the action is running, but before // Emulate the stop being called while the action is running, but before
// the *next* timer is scheduled. // the *next* timer is scheduled.
let resolve: () => void; let resolve: () => void = () => {};
const promise: Promise<void> = new Promise((_resolve) => { const promise: Promise<void> = new Promise((_resolve) => {
resolve = _resolve; resolve = _resolve;
}); });
@@ -512,7 +512,7 @@ describe('should handle ptz action', () => {
action.stop(); action.stop();
resolve!(); resolve();
await vi.runOnlyPendingTimersAsync(); await vi.runOnlyPendingTimersAsync();
// There should be no additional calls. // There should be no additional calls.
@@ -10,13 +10,13 @@ interface AudioMocks {
} }
// Uses real jsdom HTMLAudioElement instances and only stubs the parts jsdom // Uses real jsdom HTMLAudioElement instances and only stubs the parts jsdom
// can't fulfil (`play()` / `pause()` — no audio backend). Tests then exercise // can't fulfil (`play()` / `pause()` -- no audio backend). Tests then exercise
// observable behaviour: registered listeners fire via `dispatchEvent`, // observable behaviour: registered listeners fire via `dispatchEvent`, property
// property writes round-trip on the element, etc. // writes round-trip on the element, etc.
// //
// Called once at module load. The `beforeEach`/`afterEach` calls inside this // Called once at module load. The `beforeEach`/`afterEach` calls inside this
// helper register Vitest hooks at the file level — Vitest picks them up just // helper register Vitest hooks at the file level -- Vitest picks them up just
// as if they had been written at the top of the file — so every test in the // as if they had been written at the top of the file -- so every test in the
// file gets fresh mocks installed/torn down automatically. // file gets fresh mocks installed/torn down automatically.
const useAudioElementMocks = (): AudioMocks => { const useAudioElementMocks = (): AudioMocks => {
const handle = { instances: [] as HTMLAudioElement[] } as AudioMocks; const handle = { instances: [] as HTMLAudioElement[] } as AudioMocks;
@@ -5,8 +5,9 @@ import { mock, MockProxy } from 'vitest-mock-extended';
// constructs an AudioContext directly; here we stub the global `AudioContext` // constructs an AudioContext directly; here we stub the global `AudioContext`
// to return a deep-mocked instance whose `createOscillator()` / `createGain()` // to return a deep-mocked instance whose `createOscillator()` / `createGain()`
// factories return a *fresh* mock per call (not a shared one). This lets tests // factories return a *fresh* mock per call (not a shared one). This lets tests
// assert against individual notes — e.g. `audio.oscillators[2].frequency.value` // assert against individual notes -- e.g.
// — instead of having every call write over the same observable state. // `audio.oscillators[2].frequency.value` -- instead of having every call write
// over the same observable state.
interface AudioMocks { interface AudioMocks {
audioContext: MockProxy<AudioContext>; audioContext: MockProxy<AudioContext>;
audioContextCtor: Mock<[], MockProxy<AudioContext>>; audioContextCtor: Mock<[], MockProxy<AudioContext>>;
@@ -15,7 +16,7 @@ interface AudioMocks {
oscillators: MockProxy<OscillatorNode>[]; oscillators: MockProxy<OscillatorNode>[];
gains: MockProxy<GainNode>[]; gains: MockProxy<GainNode>[];
// gainParams[i] is the AudioParam exposed by `gains[i].gain` — kept as a // gainParams[i] is the AudioParam exposed by `gains[i].gain` -- kept as a
// parallel array because `mock<GainNode>()` doesn't auto-populate the // parallel array because `mock<GainNode>()` doesn't auto-populate the
// AudioParam interface as a callable deep mock (we wire it up by hand). // AudioParam interface as a callable deep mock (we wire it up by hand).
gainParams: MockProxy<AudioParam>[]; gainParams: MockProxy<AudioParam>[];
@@ -26,9 +27,9 @@ interface AudioMocks {
// can read its fields after each `beforeEach` runs. // can read its fields after each `beforeEach` runs.
// //
// Called once at module load. The `beforeEach`/`afterEach` calls inside this // Called once at module load. The `beforeEach`/`afterEach` calls inside this
// helper register Vitest hooks at the file level — Vitest picks them up just as // helper register Vitest hooks at the file level -- Vitest picks them up just
// if they had been written at the top of the file — so every test in the file // as if they had been written at the top of the file -- so every test in the
// gets fresh mocks installed/torn down automatically. // file gets fresh mocks installed/torn down automatically.
export const useAudioMocks = (): AudioMocks => { export const useAudioMocks = (): AudioMocks => {
const audio = {} as AudioMocks; const audio = {} as AudioMocks;
@@ -64,7 +65,7 @@ export const useAudioMocks = (): AudioMocks => {
// The source chains `.catch(...)` on the close() Promise. // The source chains `.catch(...)` on the close() Promise.
vi.mocked(audio.audioContext.close).mockResolvedValue(); vi.mocked(audio.audioContext.close).mockResolvedValue();
// The base class reads `_currentTime` from this — left as a deep-mock spy // The base class reads `_currentTime` from this -- left as a deep-mock spy
// by default it'd return a function, so anchor it at 0 for predictable // by default it'd return a function, so anchor it at 0 for predictable
// scheduling assertions. // scheduling assertions.
Object.defineProperty(audio.audioContext, 'currentTime', { Object.defineProperty(audio.audioContext, 'currentTime', {
@@ -355,7 +355,7 @@ describe('OverridesManager', () => {
it('with `invalid_type` surfacing the attempted value', () => { it('with `invalid_type` surfacing the attempted value', () => {
const error = runInvalidOverride((config) => { const error = runInvalidOverride((config) => {
assert(config.overrides); assert(config.overrides);
// @ts-expect-error — intentionally invalid runtime value to trigger // @ts-expect-error -- intentionally invalid runtime value to trigger
// Zod's `invalid_type` issue code. // Zod's `invalid_type` issue code.
config.overrides[0].merge = 6; config.overrides[0].merge = 6;
}); });
@@ -1,7 +1,7 @@
import screenfull from 'screenfull'; import screenfull from 'screenfull';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ScreenfullFullScreenProvider } from '../../../../src/card-controller/fullscreen/screenfull'; import { ScreenfullFullScreenProvider } from '../../../../src/card-controller/fullscreen/screenfull';
import { createCardAPI, setScreenfulEnabled } from '../../../test-utils'; import { createCardAPI, flushPromises, setScreenfulEnabled } from '../../../test-utils';
vi.mock('screenfull', () => ({ vi.mock('screenfull', () => ({
default: { default: {
@@ -21,6 +21,11 @@ const setScreenfulFullscreen = (fullscreen: boolean): void => {
// @vitest-environment jsdom // @vitest-environment jsdom
describe('ScreenfullFullScreenProvider', () => { describe('ScreenfullFullScreenProvider', () => {
beforeEach(() => {
vi.mocked(screenfull.request).mockResolvedValue();
vi.mocked(screenfull.exit).mockResolvedValue();
});
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
@@ -158,5 +163,30 @@ describe('ScreenfullFullScreenProvider', () => {
expect(screenfull.request).not.toBeCalled(); expect(screenfull.request).not.toBeCalled();
expect(screenfull.exit).not.toBeCalled(); expect(screenfull.exit).not.toBeCalled();
}); });
it('should swallow a rejected fullscreen request', async () => {
setScreenfulEnabled(true);
const api = createCardAPI();
const element = document.createElement('div');
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
vi.mocked(screenfull.request).mockRejectedValue(new Error('denied'));
const provider = new ScreenfullFullScreenProvider(api, vi.fn());
provider.setFullscreen(true);
await flushPromises();
expect(screenfull.request).toBeCalledWith(element);
});
it('should swallow a rejected fullscreen exit', async () => {
setScreenfulEnabled(true);
vi.mocked(screenfull.exit).mockRejectedValue(new Error('not in fullscreen'));
const provider = new ScreenfullFullScreenProvider(createCardAPI(), vi.fn());
provider.setFullscreen(false);
await flushPromises();
expect(screenfull.exit).toBeCalled();
});
}); });
}); });
@@ -3,7 +3,11 @@ import { mock } from 'vitest-mock-extended';
import { WebkitFullScreenProvider } from '../../../../src/card-controller/fullscreen/webkit'; import { WebkitFullScreenProvider } from '../../../../src/card-controller/fullscreen/webkit';
import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager'; import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager';
import { MediaPlayerController, WebkitHTMLVideoElement } from '../../../../src/types'; import { MediaPlayerController, WebkitHTMLVideoElement } from '../../../../src/types';
import { createCardAPI, createMediaLoadedInfo } from '../../../test-utils'; import {
createCardAPI,
createMediaLoadedInfo,
flushPromises,
} from '../../../test-utils';
const createWebkitVideoElement = (): HTMLVideoElement & const createWebkitVideoElement = (): HTMLVideoElement &
Partial<WebkitHTMLVideoElement> => { Partial<WebkitHTMLVideoElement> => {
@@ -242,7 +246,7 @@ describe('WebkitFullScreenProvider', () => {
provider.connect(); provider.connect();
const element = createWebkitVideoElement(); const element = createWebkitVideoElement();
element.play = vi.fn(); element.play = vi.fn().mockResolvedValue(undefined);
const mediaPlayerController = createMediaPlayerController(element); const mediaPlayerController = createMediaPlayerController(element);
vi.mocked(api.getMediaLoadedInfoManager().get).mockReturnValue( vi.mocked(api.getMediaLoadedInfoManager().get).mockReturnValue(
@@ -263,4 +267,30 @@ describe('WebkitFullScreenProvider', () => {
expect(element.play).toBeCalled(); expect(element.play).toBeCalled();
}); });
it('should swallow a rejected video replay after fullscreen ends', async () => {
const api = createCardAPI();
const stateManager = new ConditionStateManager();
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
const provider = new WebkitFullScreenProvider(api, vi.fn());
provider.connect();
const element = createWebkitVideoElement();
element.play = vi.fn().mockRejectedValue(new Error('denied'));
const mediaPlayerController = createMediaPlayerController(element);
vi.mocked(api.getMediaLoadedInfoManager().get).mockReturnValue(
createMediaLoadedInfo({ mediaPlayerController }),
);
stateManager.setState({
mediaLoadedInfo: createMediaLoadedInfo({ mediaPlayerController }),
});
element.dispatchEvent(new Event('webkitendfullscreen'));
vi.runOnlyPendingTimers();
await flushPromises();
expect(element.play).toBeCalled();
});
}); });
@@ -145,7 +145,7 @@ describe('HASSManager', () => {
startingHASS.config.state = STATE_STARTING; startingHASS.config.state = STATE_STARTING;
manager.setHASS(startingHASS); manager.setHASS(startingHASS);
// No reinit yet — HA isn't fully ready. // No reinit yet -- HA isn't fully ready.
expect(api.getInitializationManager().uninitialize).not.toBeCalled(); expect(api.getInitializationManager().uninitialize).not.toBeCalled();
expect(api.getCameraManager().destroy).not.toBeCalled(); expect(api.getCameraManager().destroy).not.toBeCalled();
@@ -176,7 +176,7 @@ describe('HASSManager', () => {
startingHASS.config.state = STATE_STARTING; startingHASS.config.state = STATE_STARTING;
manager.setHASS(startingHASS); manager.setHASS(startingHASS);
// WS came back but integrations still loading — wait for RUNNING. // WS came back but integrations still loading -- wait for RUNNING.
expect(api.getInitializationManager().uninitialize).not.toBeCalled(); expect(api.getInitializationManager().uninitialize).not.toBeCalled();
expect(api.getCameraManager().destroy).not.toBeCalled(); expect(api.getCameraManager().destroy).not.toBeCalled();
}); });
@@ -190,9 +190,9 @@ describe('HASSManager', () => {
readyHASS.config.state = STATE_RUNNING; readyHASS.config.state = STATE_RUNNING;
manager.setHASS(readyHASS); manager.setHASS(readyHASS);
// First-ever hass set — there's no "previous not-ready state" to // First-ever hass set -- there's no "previous not-ready state" to
// transition from, so the normal first-load init flow applies and we // transition from, so the normal first-load init flow applies and we must
// must not blow away cameras. // not blow away cameras.
expect(api.getInitializationManager().uninitialize).not.toBeCalled(); expect(api.getInitializationManager().uninitialize).not.toBeCalled();
expect(api.getCameraManager().destroy).not.toBeCalled(); expect(api.getCameraManager().destroy).not.toBeCalled();
}); });
@@ -198,11 +198,11 @@ describe('IssueManager', () => {
const manager = new IssueManager(api); const manager = new IssueManager(api);
// hasIssue returns true from the start — simulates trigger() having // hasIssue returns true from the start -- simulates trigger() having
// already mutated state before detectDynamic snapshots. The // already mutated state before detectDynamic snapshots. The before/after
// before/after check inside detectDynamic sees true→true (no // check inside detectDynamic sees true→true (no transition), but the
// transition), but the presence comparison against ConditionState // presence comparison against ConditionState must still detect the
// must still detect the change. // change.
const description = createIssueDescription(); const description = createIssueDescription();
const issue = createIssue('config_error', { const issue = createIssue('config_error', {
hasIssue: vi.fn().mockReturnValue(true), hasIssue: vi.fn().mockReturnValue(true),
@@ -219,7 +219,7 @@ describe('IssueManager', () => {
expect(api.getCardElementManager().update).toBeCalled(); 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', () => { it('should never auto-popup on trigger -- non-full-card issues surface via the status-bar icon; user clicks to open', () => {
const api = createCardAPI(); const api = createCardAPI();
vi.mocked(api.getConditionStateManager().getState).mockReturnValue({}); vi.mocked(api.getConditionStateManager().getState).mockReturnValue({});
@@ -250,8 +250,8 @@ describe('IssueManager', () => {
expect(issue.retry).toBeCalled(); expect(issue.retry).toBeCalled();
// Timer should have been reset — advancing less than retrySeconds // Timer should have been reset -- advancing less than retrySeconds should
// should not fire it again. // not fire it again.
assert(issue.retry); assert(issue.retry);
vi.mocked(issue.retry).mockClear(); vi.mocked(issue.retry).mockClear();
vi.advanceTimersByTime(500); vi.advanceTimersByTime(500);
@@ -312,11 +312,11 @@ describe('IssueManager', () => {
}); });
it('should call update when an active issue swaps sub-states without changing the key set', () => { it('should call update when an active issue swaps sub-states without changing the key set', () => {
// Simulates ConnectionIssue going from 'lost' to 'starting': the // Simulates ConnectionIssue going from 'lost' to 'starting': the presence
// presence key set ({connection}) is identical, but the description // key set ({connection}) is identical, but the description value differs.
// value differs. Because IssuePresence is a Map<key, description>, // Because IssuePresence is a Map<key, description>, the condition state
// the condition state diff sees the value-level change and fires // diff sees the value-level change and fires listeners -- the
// listeners — the IssueManager's own listener calls update(). // IssueManager's own listener calls update().
const api = createCardAPI(); const api = createCardAPI();
// Real ConditionStateManager so its isEqual-based diff actually runs. // Real ConditionStateManager so its isEqual-based diff actually runs.
@@ -638,7 +638,7 @@ describe('IssueManager', () => {
const { manager, issue } = createRetriableSetup({ retrySeconds: 'auto' }); const { manager, issue } = createRetriableSetup({ retrySeconds: 'auto' });
manager.evaluate(); manager.evaluate();
// Run two retries — second delay should be 2x the first. // 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 * 0.75 * 1000);
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 2 * 0.75 * 1000); vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 2 * 0.75 * 1000);
expect(issue.retry).toBeCalledTimes(2); expect(issue.retry).toBeCalledTimes(2);
@@ -671,16 +671,16 @@ describe('IssueManager', () => {
}); });
manager.evaluate(); manager.evaluate();
// Three gated firings — each at the base delay (22.5s with 0.75 jitter). // 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 // If the counter were incrementing on gated fires, the second would be at
// at 45s and we'd never reach it after only 22.5s. // 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); 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(); expect(issue.retry).not.toBeCalled();
// Clear the interaction. The next firing — still at the base delay — // Clear the interaction. The next firing -- still at the base delay -- is
// is now allowed and the retry runs. // now allowed and the retry runs.
vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(false); vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(false);
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 0.75 * 1000); vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 0.75 * 1000);
expect(issue.retry).toBeCalledTimes(1); expect(issue.retry).toBeCalledTimes(1);
@@ -854,7 +854,7 @@ describe('IssueManager', () => {
vi.mocked(api.getConditionStateManager().getState).mockReturnValue({}); vi.mocked(api.getConditionStateManager().getState).mockReturnValue({});
const manager = new IssueManager(api); const manager = new IssueManager(api);
// Plain Issue implementation — no optional methods installed. // Plain Issue implementation -- no optional methods installed.
const issue: Issue = { const issue: Issue = {
key: 'config_error', key: 'config_error',
hasIssue: () => false, hasIssue: () => false,
@@ -2,9 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended'; import { mock } from 'vitest-mock-extended';
import { MediaLoadIssue } from '../../../../src/card-controller/issues/issues/media-load'; import { MediaLoadIssue } from '../../../../src/card-controller/issues/issues/media-load';
import { InternalCallbackActionConfig } from '../../../../src/config/schema/actions/custom/internal'; import { InternalCallbackActionConfig } from '../../../../src/config/schema/actions/custom/internal';
import { IMAGE_VIEW_TARGET_ID_SENTINEL } from '../../../../src/view/target-id';
import { View } from '../../../../src/view/view'; import { View } from '../../../../src/view/view';
import { createCardAPI, createMediaLoadedInfo } from '../../../test-utils'; import { createCardAPI, createMediaLoadedInfo } from '../../../test-utils';
import { IMAGE_VIEW_TARGET_ID_SENTINEL } from '../../../../src/view/target-id';
const createAPI = () => createCardAPI(); const createAPI = () => createCardAPI();
@@ -133,7 +133,7 @@ describe('MediaLoadIssue', () => {
vi.advanceTimersByTime(10000); vi.advanceTimersByTime(10000);
expect(issue.hasIssue()).toBe(true); expect(issue.hasIssue()).toBe(true);
// Same target, different media view — issue stays active. // Same target, different media view -- issue stays active.
issue.detectDynamic({ targetID: 'camera-1', view: 'clip' }); issue.detectDynamic({ targetID: 'camera-1', view: 'clip' });
expect(issue.hasIssue()).toBe(true); expect(issue.hasIssue()).toBe(true);
}); });
@@ -145,7 +145,7 @@ describe('MediaLoadIssue', () => {
vi.advanceTimersByTime(10000); vi.advanceTimersByTime(10000);
expect(issue.hasIssue()).toBe(true); expect(issue.hasIssue()).toBe(true);
// Switch to camera-2 which has no error — should deactivate and start // Switch to camera-2 which has no error -- should deactivate and start
// a fresh timer for the new target. // a fresh timer for the new target.
issue.detectDynamic({ targetID: 'camera-2', view: 'live' }); issue.detectDynamic({ targetID: 'camera-2', view: 'live' });
expect(issue.hasIssue()).toBe(false); expect(issue.hasIssue()).toBe(false);
@@ -164,7 +164,7 @@ describe('MediaLoadIssue', () => {
issue.detectDynamic({ targetID: 'camera-1', view: 'live' }); issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
expect(issue.hasIssue()).toBe(true); expect(issue.hasIssue()).toBe(true);
// Switch to camera-2 which also has an error — should stay active. // Switch to camera-2 which also has an error -- should stay active.
issue.detectDynamic({ targetID: 'camera-2', view: 'live' }); issue.detectDynamic({ targetID: 'camera-2', view: 'live' });
expect(issue.hasIssue()).toBe(true); expect(issue.hasIssue()).toBe(true);
}); });
@@ -456,7 +456,7 @@ describe('MediaLoadIssue', () => {
issue.retry(); issue.retry();
// Issue remains active — no new 10s grace period. The error stays // Issue remains active -- no new 10s grace period. The error stays
// visible while the provider re-attempts loading underneath. // visible while the provider re-attempts loading underneath.
expect(issue.hasIssue()).toBe(true); expect(issue.hasIssue()).toBe(true);
}); });
@@ -542,9 +542,9 @@ describe('MediaLoadIssue', () => {
issue.retry(); issue.retry();
// After retry, the issue stays active and the errored target is // After retry, the issue stays active and the errored target is preserved
// preserved — no new 10s grace period. If media:loaded fires, the // -- no new 10s grace period. If media:loaded fires, the existing
// existing _handleMediaLoaded path will clear everything. // _handleMediaLoaded path will clear everything.
expect(issue.hasIssue()).toBe(true); expect(issue.hasIssue()).toBe(true);
issue.detectDynamic({ targetID: 'camera-1', view: 'live' }); issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
expect(issue.hasIssue()).toBe(true); expect(issue.hasIssue()).toBe(true);
@@ -579,8 +579,8 @@ describe('MediaLoadIssue', () => {
// Card detaches: timer must stop. // Card detaches: timer must stop.
issue.suspend(); issue.suspend();
// Full 10s later (plus margin) the timer has NOT matured — the user // Full 10s later (plus margin) the timer has NOT matured -- the user was
// was offscreen and that time does not count against them. // offscreen and that time does not count against them.
vi.advanceTimersByTime(20000); vi.advanceTimersByTime(20000);
expect(issue.hasIssue()).toBe(false); expect(issue.hasIssue()).toBe(false);
expect(onChange).not.toBeCalled(); expect(onChange).not.toBeCalled();
@@ -594,7 +594,7 @@ describe('MediaLoadIssue', () => {
vi.advanceTimersByTime(10000); vi.advanceTimersByTime(10000);
expect(issue.hasIssue()).toBe(true); expect(issue.hasIssue()).toBe(true);
// Card detaches — issue must remain visible on reattach. // Card detaches -- issue must remain visible on reattach.
issue.suspend(); issue.suspend();
expect(issue.hasIssue()).toBe(true); expect(issue.hasIssue()).toBe(true);
@@ -609,8 +609,8 @@ describe('MediaLoadIssue', () => {
issue.suspend(); issue.suspend();
// Reattach: the manager's resume() triggers evaluate() → detectDynamic. // Reattach: the manager's resume() triggers evaluate() → detectDynamic.
// The target is still loading, so the timer arms with a fresh 10s // The target is still loading, so the timer arms with a fresh 10s window
// window — not whatever was left when we suspended. // -- not whatever was left when we suspended.
issue.detectDynamic({ targetID: 'camera-1', view: 'live' }); issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
vi.advanceTimersByTime(9999); vi.advanceTimersByTime(9999);
@@ -68,6 +68,28 @@ describe('IssueStateManager', () => {
expect(mockLegacyResource.detectStatic).toBeCalledWith(hass); expect(mockLegacyResource.detectStatic).toBeCalledWith(hass);
expect(mockMediaLoad.detectStatic).toBeCalledWith(hass); expect(mockMediaLoad.detectStatic).toBeCalledWith(hass);
}); });
it('should isolate a failing issue and continue detecting the rest', async () => {
const spy = vi.spyOn(console, 'warn').mockReturnValue();
assert(mockConfigUpgrade.detectStatic);
assert(mockLegacyResource.detectStatic);
assert(mockMediaLoad.detectStatic);
vi.mocked(mockConfigUpgrade.detectStatic).mockRejectedValue(new Error('boom'));
// A second failure, a non-Error rejection, is isolated and logged too.
vi.mocked(mockLegacyResource.detectStatic).mockRejectedValue('bad');
const manager = createManager();
const hass = createHASS();
await expect(manager.detectStatic(hass)).resolves.toBeUndefined();
// Detection continued to the final issue despite the two earlier failures.
expect(mockMediaLoad.detectStatic).toBeCalledWith(hass);
expect(spy).toBeCalledTimes(2);
spy.mockRestore();
});
}); });
describe('trigger', () => { describe('trigger', () => {
@@ -451,9 +451,9 @@ describe('MediaPlayerManager', () => {
const api = createCardAPI(); const api = createCardAPI();
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager());
// Bypass schema validation — the code has a runtime guard (TypeScript // Bypass schema validation -- the code has a runtime guard
// narrowing) for the case where dashboard config is present but // (TypeScript narrowing) for the case where dashboard config is
// dashboard_path / view_path are missing. // present but dashboard_path / view_path are missing.
const configWithNoDashboardPaths = createCameraConfig({ const configWithNoDashboardPaths = createCameraConfig({
camera_entity: 'camera.foo', camera_entity: 'camera.foo',
}); });
@@ -509,7 +509,7 @@ describe('should handle exceptions', () => {
expect.objectContaining({ error }), expect.objectContaining({ error }),
); );
// The loading flag must be cleared on error — otherwise gallery/viewer // The loading flag must be cleared on error -- otherwise gallery/viewer
// components render "Awaiting media" indefinitely on top of the error // components render "Awaiting media" indefinitely on top of the error
// notification. // notification.
expect(manager.getView()?.context?.loading?.query).toBeUndefined(); expect(manager.getView()?.context?.loading?.query).toBeUndefined();
@@ -538,9 +538,9 @@ describe('should handle exceptions', () => {
viewQueryExecutor: viewQueryExecutor, viewQueryExecutor: viewQueryExecutor,
}); });
// Concurrent reset during the await — clears `_view` before the // Concurrent reset during the await -- clears `_view` before the rejection
// rejection is processed. The error path must not crash on the null // is processed. The error path must not crash on the null view when
// view when attempting to clear the loading flag. // attempting to clear the loading flag.
viewQueryExecutor.getNewQueryModifiers.mockImplementation(async () => { viewQueryExecutor.getNewQueryModifiers.mockImplementation(async () => {
manager.reset(); manager.reset();
throw error; throw error;
@@ -56,6 +56,19 @@ describe('MicrophoneActionsController', () => {
expect(microphoneManager.mute).not.toBeCalled(); expect(microphoneManager.mute).not.toBeCalled();
}); });
it('should swallow a rejected auto-unmute so a denied microphone does not surface', async () => {
const microphoneManager = createMicrophoneManager();
vi.mocked(microphoneManager.unmute).mockRejectedValue(new Error('denied'));
const controller = new MicrophoneActionsController();
controller.setOptions({
microphoneManager,
autoUnmuteConditions: ['selected' as const],
});
await expect(controller.setSelectedCamera('camera-1')).resolves.toBeUndefined();
expect(microphoneManager.unmute).toBeCalledTimes(1);
});
it('should mute on unselected when transitioning to a new camera', async () => { it('should mute on unselected when transitioning to a new camera', async () => {
const microphoneManager = createMicrophoneManager(); const microphoneManager = createMicrophoneManager();
const controller = new MicrophoneActionsController(); const controller = new MicrophoneActionsController();
@@ -61,7 +61,7 @@ describe('MediaLoadedInfoSinkController', () => {
// Both cached, but only the selected one is exposed. // Both cached, but only the selected one is exposed.
expect(controller.get()).toBe(infoA); expect(controller.get()).toBe(infoA);
// Selecting the other target switches what `get()` returns — without a // Selecting the other target switches what `get()` returns -- without a
// new event arriving for it. // new event arriving for it.
selected = 'target-B'; selected = 'target-B';
controller.hostUpdated(); controller.hostUpdated();
@@ -102,7 +102,7 @@ describe('MediaLoadedInfoSinkController', () => {
controller.hostConnected(); controller.hostConnected();
vi.clearAllMocks(); vi.clearAllMocks();
// Load for an unselected target — cached but inactive. // Load for an unselected target -- cached but inactive.
host.dispatchEvent( host.dispatchEvent(
createMediaLoadedInfoEvent({ createMediaLoadedInfoEvent({
info: createMediaLoadedInfo({ targetID: 'target-B' }), info: createMediaLoadedInfo({ targetID: 'target-B' }),
@@ -162,7 +162,7 @@ describe('MediaLoadedInfoSinkController', () => {
controller.hostUpdated(); controller.hostUpdated();
vi.clearAllMocks(); vi.clearAllMocks();
// Switch to another target with no cached info — active stays null. // Switch to another target with no cached info -- active stays null.
selected = 'target-B'; selected = 'target-B';
controller.hostUpdated(); controller.hostUpdated();
@@ -126,9 +126,9 @@ describe('MediaLoadedInfoSourceController', () => {
}); });
it('should abort the prior dispatch when targetID changes between calls', () => { it('should abort the prior dispatch when targetID changes between calls', () => {
// Without this, the manager would zombie an entry under the old // Without this, the manager would zombie an entry under the old targetID
// targetID — its `onAbort` cleanup never fires because we never aborted // -- its `onAbort` cleanup never fires because we never aborted the prior
// the prior signal before overwriting `_abort`. // signal before overwriting `_abort`.
let targetID: string | null = 'target-1'; let targetID: string | null = 'target-1';
const host = createLitElement(); const host = createLitElement();
const controller = new MediaLoadedInfoSourceController(host, { const controller = new MediaLoadedInfoSourceController(host, {
@@ -164,7 +164,7 @@ describe('MediaLoadedInfoSourceController', () => {
controller.set(createMediaLoadedInfo()); controller.set(createMediaLoadedInfo());
const firstSignal = (handler.mock.calls[0][0] as CustomEvent).detail.signal; const firstSignal = (handler.mock.calls[0][0] as CustomEvent).detail.signal;
// Disconnect and reconnect — without a fresh `set`. // Disconnect and reconnect -- without a fresh `set`.
controller.hostDisconnected(); controller.hostDisconnected();
controller.hostConnected(); controller.hostConnected();
@@ -201,7 +201,7 @@ describe('MediaLoadedInfoSourceController', () => {
host.addEventListener('advanced-camera-card:media:loaded', handler); host.addEventListener('advanced-camera-card:media:loaded', handler);
controller.set(createMediaLoadedInfo()); controller.set(createMediaLoadedInfo());
// Active registration, no disconnect — connect should be a no-op. // Active registration, no disconnect -- connect should be a no-op.
controller.hostConnected(); controller.hostConnected();
expect(handler).toBeCalledTimes(1); expect(handler).toBeCalledTimes(1);
@@ -228,7 +228,7 @@ describe('MediaLoadedInfoSourceController', () => {
targetID = 'target-2'; targetID = 'target-2';
controller.hostConnected(); controller.hostConnected();
// No re-dispatch — the stale cache was discarded. // No re-dispatch -- the stale cache was discarded.
expect(handler).toBeCalledTimes(1); expect(handler).toBeCalledTimes(1);
// A subsequent set() under the new target dispatches fresh. // A subsequent set() under the new target dispatches fresh.
@@ -771,7 +771,7 @@ describe('SignedURLController', () => {
controller.hostUpdate(); controller.hostUpdate();
expect(createProxiedEndpointIfNecessary).toHaveBeenCalledTimes(1); expect(createProxiedEndpointIfNecessary).toHaveBeenCalledTimes(1);
// Resolve the original request — should still succeed. // Resolve the original request -- should still succeed.
resolveProxy?.({ endpoint: 'http://proxied-url.com', sign: false }); resolveProxy?.({ endpoint: 'http://proxied-url.com', sign: false });
await flushPromises(); await flushPromises();
@@ -809,7 +809,7 @@ describe('SignedURLController', () => {
expect(createProxiedEndpointIfNecessary).toHaveBeenCalledTimes(1); expect(createProxiedEndpointIfNecessary).toHaveBeenCalledTimes(1);
expect(controller.getValue()).toBe('http://signed.com'); expect(controller.getValue()).toBe('http://signed.com');
// Change only the extraneous field — should hit the cache, not re-fetch. // Change only the extraneous field -- should hit the cache, not re-fetch.
extraneous = false; extraneous = false;
await controller.hostUpdate(); await controller.hostUpdate();
expect(createProxiedEndpointIfNecessary).toHaveBeenCalledTimes(1); expect(createProxiedEndpointIfNecessary).toHaveBeenCalledTimes(1);
@@ -314,12 +314,12 @@ describe('StatusBarController', () => {
sufficient: true, sufficient: true,
}; };
// Start with permanent item — bar stays visible. // Start with permanent item -- bar stays visible.
controller.setItems([permanentItem, nonPermanentItem]); controller.setItems([permanentItem, nonPermanentItem]);
vi.advanceTimersByTime(10000); vi.advanceTimersByTime(10000);
expect(host.getAttribute('hide')).toBe(null); expect(host.getAttribute('hide')).toBe(null);
// Remove permanent item — popup timer starts. // Remove permanent item -- popup timer starts.
controller.setItems([nonPermanentItem]); controller.setItems([nonPermanentItem]);
expect(host.getAttribute('hide')).toBe(null); expect(host.getAttribute('hide')).toBe(null);
@@ -342,7 +342,7 @@ describe('StatusBarController', () => {
string: 'Title', string: 'Title',
sufficient: true, sufficient: true,
}; };
// A permanent item that is NOT sufficient — removing it does not // A permanent item that is NOT sufficient -- removing it does not
// change the sufficient-values set, so the popup timer takes the // change the sufficient-values set, so the popup timer takes the
// dedicated permanent-removal branch. // dedicated permanent-removal branch.
const permanentInsufficientItem = { const permanentInsufficientItem = {
@@ -356,7 +356,7 @@ describe('StatusBarController', () => {
vi.advanceTimersByTime(10000); vi.advanceTimersByTime(10000);
expect(host.getAttribute('hide')).toBe(null); expect(host.getAttribute('hide')).toBe(null);
// Remove the permanent (insufficient) item — sufficient values are // Remove the permanent (insufficient) item -- sufficient values are
// unchanged, but the popup timer must still start. // unchanged, but the popup timer must still start.
controller.setItems([sufficientItem]); controller.setItems([sufficientItem]);
expect(host.getAttribute('hide')).toBe(null); expect(host.getAttribute('hide')).toBe(null);
@@ -1,5 +1,5 @@
import { format } from 'date-fns'; import { format } from 'date-fns';
import { describe, expect, it } from 'vitest'; import { assert, describe, expect, it } from 'vitest';
import { mock } from 'vitest-mock-extended'; import { mock } from 'vitest-mock-extended';
import { CameraManager } from '../../../../src/camera-manager/manager'; import { CameraManager } from '../../../../src/camera-manager/manager';
import { ThumbnailFeatureController } from '../../../../src/components-lib/thumbnail/feature/controller'; import { ThumbnailFeatureController } from '../../../../src/components-lib/thumbnail/feature/controller';
@@ -12,6 +12,8 @@ describe('ThumbnailFeatureController', () => {
title: 'Test Event', title: 'Test Event',
cameraID: 'camera_1', cameraID: 'camera_1',
}); });
const itemStartTime = itemWithTime.getStartTime();
assert(itemStartTime);
describe('should set title', () => { describe('should set title', () => {
it('should set title with start time ', () => { it('should set title with start time ', () => {
@@ -20,7 +22,7 @@ describe('ThumbnailFeatureController', () => {
controller.calculate(null, itemWithTime, false); controller.calculate(null, itemWithTime, false);
// Use format() to generate expected time in local timezone // Use format() to generate expected time in local timezone
const expectedTime = format(itemWithTime.getStartTime()!, 'HH:mm'); const expectedTime = format(itemStartTime, 'HH:mm');
expect(controller.getTitle()).toBe(expectedTime); expect(controller.getTitle()).toBe(expectedTime);
}); });
@@ -54,7 +56,7 @@ describe('ThumbnailFeatureController', () => {
controller.calculate(null, itemWithTime, false); controller.calculate(null, itemWithTime, false);
// Use format() to generate expected date string (formats in local time) // Use format() to generate expected date string (formats in local time)
const expectedDate = format(itemWithTime.getStartTime()!, 'MMM do'); const expectedDate = format(itemStartTime, 'MMM do');
expect(controller.getSubtitles()).toContain(expectedDate); expect(controller.getSubtitles()).toContain(expectedDate);
}); });
@@ -61,7 +61,7 @@ describe('renderNoMedia', () => {
}); });
it('should not include metadata when no camera is resolvable', () => { it('should not include metadata when no camera is resolvable', () => {
// Empty store — no default camera. // Empty store -- no default camera.
const cameraManager = createCameraManager(createStore()); const cameraManager = createCameraManager(createStore());
const result = renderNoMedia({ const result = renderNoMedia({
+21 -20
View File
@@ -78,36 +78,37 @@ describe('dispatchViewContextChangeEvent', () => {
const results = new QueryResults({ results: testResults }); const results = new QueryResults({ results: testResults });
const slice = results.getSlice('office'); const slice = results.getSlice('office');
expect(slice).not.toBeNull(); expect(slice).not.toBeNull();
expect(slice!.getResults()).toEqual( assert(slice);
expect(slice.getResults()).toEqual(
testResults.filter((item) => item.getCameraID() === 'office'), testResults.filter((item) => item.getCameraID() === 'office'),
); );
expect(slice!.getResultsCount()).toEqual(100); expect(slice.getResultsCount()).toEqual(100);
expect(slice!.hasResults()).toBeTruthy(); expect(slice.hasResults()).toBeTruthy();
expect(slice!.getResult(0)).not.toBeNull(); expect(slice.getResult(0)).not.toBeNull();
expect(slice!.getResult()).toBeNull(); expect(slice.getResult()).toBeNull();
expect(slice!.getSelectedIndex()).toBe(99); expect(slice.getSelectedIndex()).toBe(99);
expect(slice!.getSelectedResult()?.getID()).toEqual('id-office-99'); expect(slice.getSelectedResult()?.getID()).toEqual('id-office-99');
expect(slice!.hasSelectedResult()).toBeTruthy(); expect(slice.hasSelectedResult()).toBeTruthy();
expect(slice!.resetSelectedResult()); expect(slice.resetSelectedResult());
expect(slice!.getSelectedResult()).toBeNull(); expect(slice.getSelectedResult()).toBeNull();
expect(slice!.selectIndex(10)); expect(slice.selectIndex(10));
expect(slice!.getSelectedIndex()).toBe(10); expect(slice.getSelectedIndex()).toBe(10);
expect(slice!.selectIndex(10000)); expect(slice.selectIndex(10000));
expect(slice!.getSelectedIndex()).toBe(10); expect(slice.getSelectedIndex()).toBe(10);
expect(slice!.selectIndex(-10000)); expect(slice.selectIndex(-10000));
expect(slice!.getSelectedIndex()).toBe(10); expect(slice.getSelectedIndex()).toBe(10);
slice!.selectResultIfFound((item: ViewItem) => item.getID() === 'id-office-42'); slice.selectResultIfFound((item: ViewItem) => item.getID() === 'id-office-42');
expect(slice!.getSelectedResult()?.getID()).toBe('id-office-42'); expect(slice.getSelectedResult()?.getID()).toBe('id-office-42');
slice!.selectBestResult((itemArray: ViewItem[]) => slice.selectBestResult((itemArray: ViewItem[]) =>
itemArray.findIndex((item) => item.getID() === 'id-office-43'), itemArray.findIndex((item) => item.getID() === 'id-office-43'),
); );
expect(slice!.getSelectedResult()?.getID()).toBe('id-office-43'); expect(slice.getSelectedResult()?.getID()).toBe('id-office-43');
}); });
describe('should respect select approach during construction', () => { describe('should respect select approach during construction', () => {