Files
advanced-camera-card/tests/card-controller/card-element-manager.test.ts
T
Dermot Duffy 17146c8ca6 fix: Clear the initialized condition state when the card is not usable (#2646)
The `initialized` state (used in conditions/triggers) was written once
and never cleared, so it meant "has this card ever been initialized"
while everything reading it took it as "is this card usable now". Home
Assistant takes a card off the page and puts it back whenever its
dashboard tab is left and returned to, so the card initialized again
while the state claimed it was initialized throughout: `trigger:
initialized` fired once per card rather than once per startup, and
automations were dropped in between.

The card lifecycle is now an explicit state machine (`SessionManager`),
the only writer of `initialized`, which separates a card that is
starting up from one initializing part of itself again while it runs. A
new `ever` parameter (conditions/triggers) selects the old latched
behaviour.

`remote_control` uses that parameter to keep its two camera priorities
correct under repeated starts. With `camera_priority: entity` the card
now re-reads the entity every time it starts, so a camera selected while
the card was away is picked up on return. With `camera_priority: card`
the card writes the entity on its first start only, unchanged, since
repeating that write would overwrite a camera the user had selected.

Closes: #2642


BREAKING CHANGE: `condition: initialized` is now `false` whenever the
card is not usable, and `trigger: initialized` fires each time the card
starts up rather than only the first time. Set `ever: true` on either to
keep the previous behaviour.
2026-08-02 12:52:58 -07:00

474 lines
14 KiB
TypeScript

import { afterEach, assert, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { CardElementManager } from '../../src/card-controller/card-element-manager';
import type { StateWatcher } from '../../src/card-controller/hass/state-watcher';
import { InitializationAspect } from '../../src/card-controller/initialization/initialization-manager';
import { QueryResults } from '../../src/view/query-results';
import { View } from '../../src/view/view';
import { createConfig } from '../config/test-utils';
import {
callStateWatcherCallback,
createCardAPI,
createCardHTMLElement,
createStateEntity,
} from '../test-utils';
import { createView, TestViewMedia } from '../view/test-utils';
// @vitest-environment jsdom
describe('CardElementManager', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('should get element', () => {
const element = createCardHTMLElement();
const manager = new CardElementManager(
createCardAPI(),
element,
() => undefined,
() => undefined,
);
expect(manager.getElement()).toBe(element);
});
it('should report whether the element is connected', () => {
const element = createCardHTMLElement();
const manager = new CardElementManager(
createCardAPI(),
element,
() => undefined,
() => undefined,
);
expect(manager.isConnected()).toBe(false);
document.body.append(element);
expect(manager.isConnected()).toBe(true);
element.remove();
expect(manager.isConnected()).toBe(false);
});
it('should reset scroll', () => {
const callback = vi.fn();
const manager = new CardElementManager(
createCardAPI(),
createCardHTMLElement(),
callback,
() => undefined,
);
manager.scrollReset();
expect(callback).toHaveBeenCalled();
});
it('should toggle menu', () => {
const callback = vi.fn();
const manager = new CardElementManager(
createCardAPI(),
createCardHTMLElement(),
() => undefined,
callback,
);
manager.toggleMenu();
expect(callback).toHaveBeenCalled();
});
it('should update', () => {
const element = createCardHTMLElement();
const manager = new CardElementManager(
createCardAPI(),
element,
() => undefined,
() => undefined,
);
manager.update();
expect(element.requestUpdate).toHaveBeenCalled();
});
it('should get hasUpdated', () => {
const element = createCardHTMLElement();
element.hasUpdated = true;
const manager = new CardElementManager(
createCardAPI(),
element,
() => undefined,
() => undefined,
);
expect(manager.hasUpdated()).toBeTruthy();
});
it('should connect', () => {
const windowAddEventListener = vi.spyOn(global.window, 'addEventListener');
const addEventListener = vi.fn();
const element = createCardHTMLElement();
element.addEventListener = addEventListener;
const api = createCardAPI();
const manager = new CardElementManager(
api,
element,
() => undefined,
() => undefined,
);
manager.elementConnected();
expect(element.getAttribute('panel')).toBeNull();
expect(element.getAttribute('casted')).toBeNull();
expect(element.getAttribute('tabindex')).toBe('0');
expect(api.getFullscreenManager().connect).toHaveBeenCalled();
expect(addEventListener).toHaveBeenCalledWith(
'mousemove',
api.getInteractionManager().reportInteraction,
);
expect(addEventListener).toHaveBeenCalledWith(
'll-custom',
api.getActionsManager().handleCustomActionEvent,
);
expect(addEventListener).toHaveBeenCalledWith(
'action',
api.getActionsManager().handleInteractionEvent,
);
expect(addEventListener).toHaveBeenCalledWith(
'action',
api.getInteractionManager().reportInteraction,
);
expect(addEventListener).toHaveBeenCalledWith(
'touchstart',
api.getInteractionManager().reportInteraction,
);
expect(addEventListener).toHaveBeenCalledWith(
'touchmove',
api.getInteractionManager().reportInteraction,
);
expect(windowAddEventListener).toHaveBeenCalledWith(
'location-changed',
expect.anything(),
);
expect(windowAddEventListener).toHaveBeenCalledWith('popstate', expect.anything());
expect(windowAddEventListener).toHaveBeenCalledWith(
'advanced-camera-card:editor:diagnostics',
expect.anything(),
);
expect(api.getInteractionManager().initialize).toHaveBeenCalled();
expect(api.getFullscreenManager().initialize).toHaveBeenCalled();
expect(api.getExpandManager().initialize).toHaveBeenCalled();
expect(api.getMediaLoadedInfoManager().initialize).toHaveBeenCalled();
expect(api.getMicrophoneManager().initialize).toHaveBeenCalled();
expect(api.getCallManager().initialize).toHaveBeenCalled();
});
it('should disconnect', () => {
const windowRemoveEventListener = vi.spyOn(global.window, 'removeEventListener');
const element = createCardHTMLElement();
element.setAttribute('panel', '');
element.setAttribute('casted', '');
element.setAttribute('tabindex', '0');
const removeEventListener = vi.fn();
element.removeEventListener = removeEventListener;
const api = createCardAPI();
const manager = new CardElementManager(
api,
element,
() => undefined,
() => undefined,
);
manager.elementDisconnected();
expect(element.getAttribute('panel')).toBeNull();
expect(element.getAttribute('casted')).toBeNull();
expect(element.getAttribute('tabindex')).toBeNull();
expect(api.getMediaLoadedInfoManager().clear).toHaveBeenCalled();
expect(api.getFullscreenManager().disconnect).toHaveBeenCalled();
expect(removeEventListener).toHaveBeenCalledWith(
'mousemove',
api.getInteractionManager().reportInteraction,
);
expect(removeEventListener).toHaveBeenCalledWith(
'll-custom',
api.getActionsManager().handleCustomActionEvent,
);
expect(removeEventListener).toHaveBeenCalledWith(
'action',
api.getActionsManager().handleInteractionEvent,
);
expect(removeEventListener).toHaveBeenCalledWith(
'action',
api.getInteractionManager().reportInteraction,
);
expect(removeEventListener).toHaveBeenCalledWith(
'touchstart',
api.getInteractionManager().reportInteraction,
);
expect(removeEventListener).toHaveBeenCalledWith(
'touchmove',
api.getInteractionManager().reportInteraction,
);
expect(windowRemoveEventListener).toHaveBeenCalledWith(
'location-changed',
expect.anything(),
);
expect(windowRemoveEventListener).toHaveBeenCalledWith(
'popstate',
expect.anything(),
);
expect(windowRemoveEventListener).toHaveBeenCalledWith(
'advanced-camera-card:editor:diagnostics',
expect.anything(),
);
expect(api.getMediaLoadedInfoManager().clear).toHaveBeenCalled();
expect(api.getFullscreenManager().disconnect).toHaveBeenCalled();
expect(api.getKeyboardStateManager().uninitialize).toHaveBeenCalled();
expect(api.getActionsManager().uninitialize).toHaveBeenCalled();
expect(api.getCallManager().uninitialize).toHaveBeenCalled();
expect(api.getInitializationManager().invalidateAspect).toHaveBeenCalledWith(
InitializationAspect.CAMERAS,
);
expect(api.getInitializationManager().invalidateAspect).toHaveBeenCalledWith(
InitializationAspect.INITIAL_TRIGGER,
);
expect(api.getInitializationManager().getSessionManager().end).toHaveBeenCalled();
});
describe('should update card when', () => {
it('render entity changes', () => {
const api = createCardAPI();
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
view: {
render_entities: ['sensor.force_update'],
},
}),
);
const stateWatcher = mock<StateWatcher>();
vi.mocked(api.getHASSManager().getStateWatcher).mockReturnValue(stateWatcher);
const element = createCardHTMLElement();
const manager = new CardElementManager(
api,
element,
() => undefined,
() => undefined,
);
manager.elementConnected();
const diff = {
entityID: 'sensor.force_update',
newState: createStateEntity({ state: 'off' }),
};
callStateWatcherCallback(stateWatcher, diff);
expect(element.requestUpdate).toHaveBeenCalled();
});
it('media player entity changes', () => {
const api = createCardAPI();
vi.mocked(api.getMediaPlayerManager().getMediaPlayers).mockReturnValue([
'media_player.foo',
]);
const stateWatcher = mock<StateWatcher>();
vi.mocked(api.getHASSManager().getStateWatcher).mockReturnValue(stateWatcher);
const element = createCardHTMLElement();
const manager = new CardElementManager(
api,
element,
() => undefined,
() => undefined,
);
manager.elementConnected();
const diff = {
entityID: 'sensor.force_update',
newState: createStateEntity({ state: 'off' }),
};
callStateWatcherCallback(stateWatcher, diff);
expect(element.requestUpdate).toHaveBeenCalled();
});
it('selected media review status changes', () => {
const api = createCardAPI();
const selectedMedia = new TestViewMedia({ id: 'media-1' });
const queryResults = new QueryResults({
results: [selectedMedia],
selectedIndex: 0,
});
const view = createView({ queryResults });
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const element = createCardHTMLElement();
const manager = new CardElementManager(
api,
element,
() => undefined,
() => undefined,
);
manager.elementConnected();
// Clear any previous calls from elementConnected.
vi.mocked(element.requestUpdate).mockClear();
// Dispatch the media reviewed event with the selected media item.
element.dispatchEvent(
new CustomEvent('advanced-camera-card:media:reviewed', {
detail: selectedMedia,
}),
);
expect(element.requestUpdate).toHaveBeenCalled();
});
it('non-selected media review status changes does not update', () => {
const api = createCardAPI();
const selectedMedia = new TestViewMedia({ id: 'media-1' });
const otherMedia = new TestViewMedia({ id: 'media-2' });
const queryResults = new QueryResults({
results: [selectedMedia, otherMedia],
selectedIndex: 0,
});
const view = createView({ queryResults });
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const element = createCardHTMLElement();
const manager = new CardElementManager(
api,
element,
() => undefined,
() => undefined,
);
manager.elementConnected();
// Clear any previous calls from elementConnected.
vi.mocked(element.requestUpdate).mockClear();
// Dispatch the media reviewed event with a DIFFERENT media item.
element.dispatchEvent(
new CustomEvent('advanced-camera-card:media:reviewed', {
detail: otherMedia,
}),
);
// Should NOT update because the reviewed item is not the selected item.
expect(element.requestUpdate).not.toHaveBeenCalled();
});
});
describe('should handle diagnostics', () => {
afterEach(() => {
vi.clearAllMocks();
});
const createDialogWithCard = (element: HTMLElement) => {
const dialog = document.createElement('hui-dialog-edit-card');
dialog.attachShadow({ mode: 'open' });
assert(dialog.shadowRoot);
dialog.shadowRoot.append(element);
return dialog;
};
const fireFromDialog = (dialog: HTMLElement) => {
const editorDiv = document.createElement('div');
assert(dialog.shadowRoot);
dialog.shadowRoot.append(editorDiv);
editorDiv.dispatchEvent(
new CustomEvent('advanced-camera-card:editor:diagnostics', {
bubbles: true,
composed: true,
}),
);
};
it('sets view to diagnostics if card is in editor', () => {
const api = createCardAPI();
const element = createCardHTMLElement();
const manager = new CardElementManager(
api,
element,
() => undefined,
() => undefined,
);
const dialog = createDialogWithCard(element);
document.body.append(dialog);
manager.elementConnected();
fireFromDialog(dialog);
expect(api.getViewManager().setViewByParameters).toHaveBeenCalledWith({
params: { view: 'diagnostics' },
});
});
it('resets to default view if already in diagnostics view', () => {
const api = createCardAPI();
const element = createCardHTMLElement();
const manager = new CardElementManager(
api,
element,
() => undefined,
() => undefined,
);
vi.mocked(api.getViewManager().getView).mockReturnValue(
new View({ view: 'diagnostics' }),
);
const dialog = createDialogWithCard(element);
document.body.append(dialog);
manager.elementConnected();
fireFromDialog(dialog);
expect(api.getViewManager().setViewDefault).toHaveBeenCalled();
});
it('does not set view to diagnostics if card is not in editor', () => {
const api = createCardAPI();
const element = createCardHTMLElement();
const manager = new CardElementManager(
api,
element,
() => undefined,
() => undefined,
);
manager.elementConnected();
// Event fired from a different dialog that does not contain the card
const otherDialog = document.createElement('hui-dialog-edit-card');
otherDialog.attachShadow({ mode: 'open' });
document.body.append(otherDialog);
fireFromDialog(otherDialog);
expect(api.getViewManager().setViewByParameters).not.toHaveBeenCalled();
});
});
});