Files
advanced-camera-card/tests/card-controller/card-element-manager.browser.test.ts
T

207 lines
6.4 KiB
TypeScript

import { describe, expect, it, onTestFinished } from 'vitest';
import type { RawAdvancedCameraCardConfig } from '../../src/config/types';
import { createLogAction } from '../../src/utils/action';
import {
deepQuery,
deepQueryAll,
getFocusedElement,
pressKey,
pressTab,
tabUntil,
} from '../browser/dom';
import { MountedCardFactory, type MountedCard } from '../browser/mounted-card';
import {
CARD_INITIALIZED_MESSAGE,
createGenericCameraHASS,
createInitializedAutomation,
createStillImageCameraConfig,
createStillImageCardConfig,
getBlockNotificationText,
isLiveMediaShowing,
} from '../browser/test-utils';
// What the automation writes when it runs, written as the pattern the console
// is later searched for.
const KEY_MESSAGE = /key pressed/;
const mountCard = async (): Promise<MountedCard> => {
const card = await MountedCardFactory.createFromSource(
createStillImageCardConfig({
automations: [
createInitializedAutomation(),
{
triggers: [{ trigger: 'key', key: 'z' }],
actions: [createLogAction(KEY_MESSAGE.source)],
},
],
}),
createGenericCameraHASS(),
);
await card.events.waitForFirst('advanced-camera-card:media:loaded');
return card;
};
/**
* Tab until focus is past the card, however many places within it can take
* focus. The bound is a runaway guard rather than a count, so that a card
* which never lets focus go fails instead of tabbing forever; it is taken from
* the card's own size, since nothing in it can be a tab stop twice.
*/
const tabPastCard = async (card: MountedCard): Promise<void> => {
const bound = deepQueryAll(card.card, '*').length;
await tabUntil(() => !card.card.contains(document.activeElement), bound);
};
/**
* Something to tab on to after the card, standing in for whatever else is on
* the dashboard below it.
*/
const addTrailingControl = (): HTMLElement => {
const control = document.createElement('button');
document.body.append(control);
onTestFinished(() => control.remove());
return control;
};
// The Home Assistant dialog the card is previewed in while its configuration is
// edited, which is what the card looks for before answering the editor's
// diagnostics button.
const EDIT_DIALOG_TAG_NAME = 'hui-dialog-edit-card';
const INIT_FAILED_ISSUE_HEADING = 'Initialization failed';
const DIAGNOSTICS_SELECTOR = 'advanced-camera-card-diagnostics';
const mountCardInEditDialog = async (
config: RawAdvancedCameraCardConfig,
): Promise<MountedCard> =>
await MountedCardFactory.createFromSource(config, createGenericCameraHASS(), {
containerTagName: EDIT_DIALOG_TAG_NAME,
});
/**
* Press the editor's diagnostics button. The editor is elsewhere in the dialog
* rather than within the card, so the event is fired from a sibling of it.
*/
const toggleDiagnostics = (card: MountedCard): void => {
const editor = document.createElement('div');
card.card.parentElement?.append(editor);
editor.dispatchEvent(
new CustomEvent('advanced-camera-card:editor:diagnostics', {
bubbles: true,
composed: true,
}),
);
editor.remove();
};
const isDiagnosticsShowing = (card: MountedCard): boolean =>
!!deepQuery(card.card, DIAGNOSTICS_SELECTOR);
describe('CardElementManager', () => {
describe('should toggle diagnostics from the editor', () => {
it('should show diagnostics over a card that could not be started', async () => {
// A camera Home Assistant has never heard of, so the card cannot start
// and shows an issue in place of its views.
const card = await mountCardInEditDialog(
createStillImageCardConfig({
cameras: [createStillImageCameraConfig('camera.missing')],
view: { issues: { retry_seconds: 0 } },
}),
);
await card.waitForRender(
() =>
getBlockNotificationText(card.card).includes(INIT_FAILED_ISSUE_HEADING) ||
null,
'the initialization issue',
);
toggleDiagnostics(card);
// Diagnostics is what the user is asked for when the card is broken, so
// it must be reachable in the state the issue describes.
await card.waitForSelector(DIAGNOSTICS_SELECTOR);
expect(getBlockNotificationText(card.card)).not.toContain(
INIT_FAILED_ISSUE_HEADING,
);
toggleDiagnostics(card);
// Toggling diagnostics again just puts the issue back in front of the
// user.
await card.waitForRender(
() =>
getBlockNotificationText(card.card).includes(INIT_FAILED_ISSUE_HEADING) ||
null,
'the initialization issue',
);
expect(isDiagnosticsShowing(card)).toBe(false);
});
it('should return a started card to its default view', async () => {
const card = await mountCardInEditDialog(createStillImageCardConfig());
await card.events.waitForFirst('advanced-camera-card:media:loaded');
toggleDiagnostics(card);
await card.waitForSelector(DIAGNOSTICS_SELECTOR);
toggleDiagnostics(card);
await card.waitForRender(
() => isLiveMediaShowing(card.card) || null,
'the live view',
);
expect(isDiagnosticsShowing(card)).toBe(false);
});
});
it('should be reachable by tabbing', async () => {
const card = await mountCard();
// No pointer is used here at all. A card that can only be reached by
// clicking on it is out of reach of a keyboard-only user.
await pressTab();
expect(getFocusedElement()).toBe(card.card);
await pressKey('z');
await card.console.waitForMessage(KEY_MESSAGE);
});
it('should be possible to tab beyond the card', async () => {
const card = await mountCard();
const trailingControl = addTrailingControl();
await tabPastCard(card);
// Tabbing continues past the card and out the other side. A card that kept
// focus would strand a user part way down the dashboard.
expect(getFocusedElement()).toBe(trailingControl);
});
it('should be reachable by tabbing after being put back in the document', async () => {
const card = await mountCard();
// Home Assistant takes a card out of the document when the dashboard tab it
// is on is left, and puts the same element back on return.
card.detach();
card.attach();
await card.console.waitForMessage(CARD_INITIALIZED_MESSAGE, { count: 2 });
await pressTab();
expect(getFocusedElement()).toBe(card.card);
await pressKey('z');
await card.console.waitForMessage(KEY_MESSAGE);
});
});