test: Add browser based tests for keyboard focus handling (#2647)
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import { css, html, LitElement, type TemplateResult } from 'lit';
|
import { css, html, LitElement, type TemplateResult } from 'lit';
|
||||||
|
import { property } from 'lit/decorators.js';
|
||||||
|
|
||||||
import { SIDE_LOADED_ELEMENTS } from '../../src/ha/side-load-ha-elements';
|
import { SIDE_LOADED_ELEMENTS } from '../../src/ha/side-load-ha-elements';
|
||||||
|
|
||||||
@@ -79,8 +80,11 @@ class HACardStub extends HAElementStub {
|
|||||||
* A round tap target sized by `--ha-icon-button-size`, which is how Home
|
* A round tap target sized by `--ha-icon-button-size`, which is how Home
|
||||||
* Assistant's own icon button sizes itself and what the card sets to lay its
|
* Assistant's own icon button sizes itself and what the card sets to lay its
|
||||||
* menu out. Without it a button is only as big as whatever it contains.
|
* menu out. Without it a button is only as big as whatever it contains.
|
||||||
|
*
|
||||||
|
* Home Assistant draws a real `<button>` within, so a menu button takes focus
|
||||||
|
* when it is pressed, can be tabbed to, and ignores a press while disabled.
|
||||||
*/
|
*/
|
||||||
class HAIconButtonStub extends HAElementStub {
|
class HAIconButtonStub extends LitElement {
|
||||||
static styles = css`
|
static styles = css`
|
||||||
:host {
|
:host {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -92,7 +96,26 @@ class HAIconButtonStub extends HAElementStub {
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
button {
|
||||||
|
align-items: center;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
@property({ type: Boolean })
|
||||||
|
public disabled = false;
|
||||||
|
|
||||||
|
protected render(): TemplateResult {
|
||||||
|
return html`<button ?disabled=${this.disabled}><slot></slot></button>`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -110,15 +133,58 @@ class HAIconStub extends LitElement {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PictureElementConfig {
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConditionalElementConfig {
|
||||||
|
elements?: PictureElementConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every Home Assistant element takes its own shape of configuration through the
|
||||||
|
// same call, and nothing here reads what is passed.
|
||||||
|
interface ConfigurableElement extends HTMLElement {
|
||||||
|
setConfig(config: unknown): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isConfigurable = (element: HTMLElement): element is ConfigurableElement =>
|
||||||
|
'setConfig' in element;
|
||||||
|
|
||||||
|
const CUSTOM_ELEMENT_PREFIX = 'custom:';
|
||||||
|
const CARD_ELEMENT_PREFIX = `${CUSTOM_ELEMENT_PREFIX}advanced-camera-card-`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Home Assistant's conditional picture element, which the card builds one of on
|
* Home Assistant's conditional picture element, which the card builds one of on
|
||||||
* every mount to host whatever picture elements are configured.
|
* every mount to host whatever picture elements are configured.
|
||||||
|
*
|
||||||
|
* Home Assistant creates one element per entry. The card's own menu and status
|
||||||
|
* bar items are elements that ask to be added when they are connected, so
|
||||||
|
* without that a configured menu button never reaches the menu.
|
||||||
|
*
|
||||||
|
* Only the card's own elements are created here. Other elements (e.g. Home
|
||||||
|
* Assistant's `icon` or `image`) are skipped, so a test that needs one must add
|
||||||
|
* it to this stub first.
|
||||||
*/
|
*/
|
||||||
class HuiConditionalElementStub extends HTMLElement {
|
class HuiConditionalElementStub extends HTMLElement {
|
||||||
public hass?: unknown;
|
public hass?: unknown;
|
||||||
|
|
||||||
public setConfig(): void {
|
public setConfig(config: ConditionalElementConfig): void {
|
||||||
// The card only needs the call to succeed; nothing renders from it here.
|
this.replaceChildren();
|
||||||
|
|
||||||
|
for (const element of config.elements ?? []) {
|
||||||
|
if (!element.type.startsWith(CARD_ELEMENT_PREFIX)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const child = document.createElement(
|
||||||
|
// Example: custom:advanced-camera-card-menu-icon -> advanced-camera-card-menu-icon
|
||||||
|
element.type.slice(CUSTOM_ELEMENT_PREFIX.length),
|
||||||
|
);
|
||||||
|
if (isConfigurable(child)) {
|
||||||
|
child.setConfig(element);
|
||||||
|
}
|
||||||
|
this.append(child);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { AdvancedCameraCard } from '../../src/card';
|
|||||||
import type { RawAdvancedCameraCardConfig } from '../../src/config/types';
|
import type { RawAdvancedCameraCardConfig } from '../../src/config/types';
|
||||||
import type { FakeEntityOptions, FakeHASS } from './fake-hass';
|
import type { FakeEntityOptions, FakeHASS } from './fake-hass';
|
||||||
import { defineHAElementStubs } from './ha-element-stubs';
|
import { defineHAElementStubs } from './ha-element-stubs';
|
||||||
import { deepQuery, deepQueryAll } from './test-utils';
|
import { clickElement, deepQuery, deepQueryAll } from './test-utils';
|
||||||
|
|
||||||
// Home Assistant's masonry columns are `max-width: 500px`, so this is the width
|
// Home Assistant's masonry columns are `max-width: 500px`, so this is the width
|
||||||
// a card usually gets. The card derives height from the media it is showing.
|
// a card usually gets. The card derives height from the media it is showing.
|
||||||
@@ -35,6 +35,19 @@ interface ConsoleEntry {
|
|||||||
args: unknown[];
|
args: unknown[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ConsoleWaiter {
|
||||||
|
// The number of occurrences required to satisfy this waiter.
|
||||||
|
count: number;
|
||||||
|
level: ConsoleLevel;
|
||||||
|
message: RegExp;
|
||||||
|
resolve: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConsoleWaiterOptions {
|
||||||
|
count?: number;
|
||||||
|
level?: ConsoleLevel;
|
||||||
|
}
|
||||||
|
|
||||||
const CONSOLE_LEVELS = ['debug', 'error', 'info', 'log', 'warn'] as const;
|
const CONSOLE_LEVELS = ['debug', 'error', 'info', 'log', 'warn'] as const;
|
||||||
type ConsoleLevel = (typeof CONSOLE_LEVELS)[number];
|
type ConsoleLevel = (typeof CONSOLE_LEVELS)[number];
|
||||||
|
|
||||||
@@ -154,12 +167,15 @@ class ConsoleLedger {
|
|||||||
private _entries: ConsoleEntry[] = [];
|
private _entries: ConsoleEntry[] = [];
|
||||||
private _originals = new Map<ConsoleLevel, (...args: unknown[]) => void>();
|
private _originals = new Map<ConsoleLevel, (...args: unknown[]) => void>();
|
||||||
|
|
||||||
|
private _waiting: ConsoleWaiter[] = [];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
for (const level of CONSOLE_LEVELS) {
|
for (const level of CONSOLE_LEVELS) {
|
||||||
const original = console[level];
|
const original = console[level];
|
||||||
this._originals.set(level, original);
|
this._originals.set(level, original);
|
||||||
console[level] = (...args: unknown[]): void => {
|
console[level] = (...args: unknown[]): void => {
|
||||||
this._entries.push({ level, args });
|
this._entries.push({ level, args });
|
||||||
|
this._resolveWaiters();
|
||||||
original(...args);
|
original(...args);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -175,12 +191,55 @@ class ConsoleLedger {
|
|||||||
return this.getEntries(level).map((entry) => entry.args.map(String).join(' '));
|
return this.getEntries(level).map((entry) => entry.args.map(String).join(' '));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public countMessages(message: RegExp, level: ConsoleLevel = 'info'): number {
|
||||||
|
return this.getMessages(level).filter((written) => message.test(written)).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait until a message has been written. The card acts on what a test does to
|
||||||
|
* it without waiting to be asked, so a test that presses a key and reads the
|
||||||
|
* log on the next line usually finds nothing there yet.
|
||||||
|
*/
|
||||||
|
public async waitForMessage(
|
||||||
|
message: RegExp,
|
||||||
|
options?: ConsoleWaiterOptions,
|
||||||
|
): Promise<void> {
|
||||||
|
const waiter = {
|
||||||
|
count: options?.count ?? 1,
|
||||||
|
level: options?.level ?? 'info',
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this._isSatisfied(waiter)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await new Promise<void>((resolve) => {
|
||||||
|
this._waiting.push({ ...waiter, resolve });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private _isSatisfied(waiter: Omit<ConsoleWaiter, 'resolve'>): boolean {
|
||||||
|
return this.countMessages(waiter.message, waiter.level) >= waiter.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _resolveWaiters(): void {
|
||||||
|
this._waiting = this._waiting.filter((waiter) => {
|
||||||
|
if (!this._isSatisfied(waiter)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
waiter.resolve();
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public destroy(): void {
|
public destroy(): void {
|
||||||
for (const [level, original] of this._originals) {
|
for (const [level, original] of this._originals) {
|
||||||
console[level] = original;
|
console[level] = original;
|
||||||
}
|
}
|
||||||
this._originals.clear();
|
this._originals.clear();
|
||||||
this._entries = [];
|
this._entries = [];
|
||||||
|
this._waiting = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,6 +254,11 @@ export interface MountOptions {
|
|||||||
width?: string;
|
width?: string;
|
||||||
height?: string;
|
height?: string;
|
||||||
|
|
||||||
|
// Where that container is placed, as CSS lengths from the page's top left
|
||||||
|
// corner. The page grows to reach it, so a card put beyond the window can
|
||||||
|
// only be brought into view by scrolling.
|
||||||
|
position?: { top?: string; left?: string };
|
||||||
|
|
||||||
// Console errors required to be logged.
|
// Console errors required to be logged.
|
||||||
expectedConsoleErrors?: RegExp[];
|
expectedConsoleErrors?: RegExp[];
|
||||||
|
|
||||||
@@ -246,6 +310,11 @@ export class MountedCard {
|
|||||||
if (options?.height) {
|
if (options?.height) {
|
||||||
this._container.style.height = options.height;
|
this._container.style.height = options.height;
|
||||||
}
|
}
|
||||||
|
if (options?.position) {
|
||||||
|
this._container.style.position = 'absolute';
|
||||||
|
this._container.style.top = options.position.top ?? '0';
|
||||||
|
this._container.style.left = options.position.left ?? '0';
|
||||||
|
}
|
||||||
document.body.append(this._container);
|
document.body.append(this._container);
|
||||||
|
|
||||||
// Before the card exists, so that nothing it does during its first render is
|
// Before the card exists, so that nothing it does during its first render is
|
||||||
@@ -378,12 +447,16 @@ export class MountedCard {
|
|||||||
public async clickControl(name: string): Promise<void> {
|
public async clickControl(name: string): Promise<void> {
|
||||||
const control = await this._findControl(name);
|
const control = await this._findControl(name);
|
||||||
|
|
||||||
control.click();
|
await clickElement(control);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Press and keep holding a control until the card takes it as a hold, which
|
* Press and keep holding a control until the card takes it as a hold, which
|
||||||
* is a second action several controls carry alongside their tap.
|
* is a second action several controls carry alongside their tap.
|
||||||
|
*
|
||||||
|
* Assembled from events rather than driven with a real pointer, because
|
||||||
|
* `userEvent` offers whole gestures only (click, hover, drag) and none of
|
||||||
|
* them stops part way through a press.
|
||||||
*/
|
*/
|
||||||
public async holdControl(name: string): Promise<void> {
|
public async holdControl(name: string): Promise<void> {
|
||||||
const control = await this._findControl(name);
|
const control = await this._findControl(name);
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import { userEvent } from 'vitest/browser';
|
||||||
|
|
||||||
import type { RawAdvancedCameraCardConfig } from '../../src/config/types';
|
import type { RawAdvancedCameraCardConfig } from '../../src/config/types';
|
||||||
import type { MediaLoadedInfoEventDetail } from '../../src/types';
|
import type { MediaLoadedInfoEventDetail } from '../../src/types';
|
||||||
|
import { createLogAction } from '../../src/utils/action';
|
||||||
import { FakeHASS, type FakeEntityOptions } from './fake-hass';
|
import { FakeHASS, type FakeEntityOptions } from './fake-hass';
|
||||||
|
|
||||||
export const STILL_CAMERA_ENTITY = 'camera.office';
|
export const STILL_CAMERA_ENTITY = 'camera.office';
|
||||||
@@ -111,6 +114,20 @@ export const createStillImageCardConfig = (
|
|||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// What an initialized card writes, as the pattern the console is searched for.
|
||||||
|
export const CARD_INITIALIZED_MESSAGE = /card initialized/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An automation that reports every time the card finishes initializing. A card
|
||||||
|
* announces nothing else when it is ready to be acted on, and it initializes
|
||||||
|
* again each time it returns to the page or Home Assistant comes back, so a
|
||||||
|
* test that acts on a card too early sees nothing happen.
|
||||||
|
*/
|
||||||
|
export const createInitializedAutomation = (): RawAdvancedCameraCardConfig => ({
|
||||||
|
triggers: [{ trigger: 'initialized' }],
|
||||||
|
actions: [createLogAction(CARD_INITIALIZED_MESSAGE.source)],
|
||||||
|
});
|
||||||
|
|
||||||
// `querySelectorAll` does not look inside a shadow root, so a full search has
|
// `querySelectorAll` does not look inside a shadow root, so a full search has
|
||||||
// to step through them a level at a time. The node's own root counts because a
|
// to step through them a level at a time. The node's own root counts because a
|
||||||
// Lit element renders into that, not into its children.
|
// Lit element renders into that, not into its children.
|
||||||
@@ -183,3 +200,54 @@ export const isLiveMediaShowing = (root: ParentNode): boolean =>
|
|||||||
deepQueryAll(root, 'advanced-camera-card-live-provider').some(
|
deepQueryAll(root, 'advanced-camera-card-live-provider').some(
|
||||||
(provider) => !!deepQuery(provider, MEDIA_SELECTOR),
|
(provider) => !!deepQuery(provider, MEDIA_SELECTOR),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// `userEvent.keyboard` is given one string naming every key to press, in which
|
||||||
|
// a name of more than one character is wrapped in braces (`{Escape}`) and a
|
||||||
|
// single character stands for itself.
|
||||||
|
// See: https://vitest.dev/guide/browser/interactivity-api.html#userevent-keyboard
|
||||||
|
const asKeyboardInput = (key: string): string => (key.length === 1 ? key : `{${key}}`);
|
||||||
|
|
||||||
|
export const pressKey = async (key: string): Promise<void> =>
|
||||||
|
await userEvent.keyboard(asKeyboardInput(key));
|
||||||
|
|
||||||
|
export const holdKey = async (key: string): Promise<void> =>
|
||||||
|
await userEvent.keyboard(`{${key}>}`);
|
||||||
|
|
||||||
|
export const releaseKey = async (key: string): Promise<void> =>
|
||||||
|
await userEvent.keyboard(`{/${key}}`);
|
||||||
|
|
||||||
|
export const pressTab = async (): Promise<void> => await userEvent.tab();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click an element with a real pointer, which is the only kind that carries the
|
||||||
|
* browser's own behaviour: the press moves focus, and an element that stops the
|
||||||
|
* press doing so leaves it where it was.
|
||||||
|
*/
|
||||||
|
export const clickElement = async (element: Element): Promise<void> =>
|
||||||
|
await userEvent.click(element);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a `pointerdown` to an element without moving a real pointer, so the page
|
||||||
|
* stays scrolled where the test left it: `clickElement` scrolls its target into
|
||||||
|
* view before pressing it.
|
||||||
|
*
|
||||||
|
* The browser does nothing of its own with a press it did not itself deliver,
|
||||||
|
* so what follows is only what the card's own listener does.
|
||||||
|
*/
|
||||||
|
export const dispatchPointerDown = (element: Element): void => {
|
||||||
|
element.dispatchEvent(
|
||||||
|
new PointerEvent('pointerdown', { bubbles: true, composed: true }),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The element that actually has focus. `document.activeElement` names the
|
||||||
|
* outermost shadow host in the way, since focus is reported per tree.
|
||||||
|
*/
|
||||||
|
export const getFocusedElement = (): Element | null => {
|
||||||
|
let focused = document.activeElement;
|
||||||
|
while (focused?.shadowRoot?.activeElement) {
|
||||||
|
focused = focused.shadowRoot.activeElement;
|
||||||
|
}
|
||||||
|
return focused;
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { describe, expect, it, onTestFinished } from 'vitest';
|
||||||
|
|
||||||
|
import { createLogAction } from '../../src/utils/action';
|
||||||
|
import { MountedCard } from '../browser/mounted-card';
|
||||||
|
import {
|
||||||
|
CARD_INITIALIZED_MESSAGE,
|
||||||
|
createInitializedAutomation,
|
||||||
|
createStillCameraHASS,
|
||||||
|
createStillImageCardConfig,
|
||||||
|
deepQueryAll,
|
||||||
|
getFocusedElement,
|
||||||
|
pressKey,
|
||||||
|
pressTab,
|
||||||
|
} 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 MountedCard.create(
|
||||||
|
createStillImageCardConfig({
|
||||||
|
automations: [
|
||||||
|
createInitializedAutomation(),
|
||||||
|
{
|
||||||
|
triggers: [{ trigger: 'key', key: 'z' }],
|
||||||
|
actions: [createLogAction(KEY_MESSAGE.source)],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
createStillCameraHASS(),
|
||||||
|
);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Tabbing starts from the top of the page, so the first press is the one
|
||||||
|
// that reaches the card.
|
||||||
|
await pressTab();
|
||||||
|
|
||||||
|
for (
|
||||||
|
let press = 0;
|
||||||
|
press < bound && card.card.contains(document.activeElement);
|
||||||
|
press++
|
||||||
|
) {
|
||||||
|
await pressTab();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('CardElementManager', () => {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,31 +3,21 @@ import { describe, expect, it, vi } from 'vitest';
|
|||||||
import type { RawAdvancedCameraCardConfig } from '../../../src/config/types';
|
import type { RawAdvancedCameraCardConfig } from '../../../src/config/types';
|
||||||
import { MountedCard } from '../../browser/mounted-card';
|
import { MountedCard } from '../../browser/mounted-card';
|
||||||
import {
|
import {
|
||||||
|
CARD_INITIALIZED_MESSAGE,
|
||||||
|
createInitializedAutomation,
|
||||||
createStillCameraHASS,
|
createStillCameraHASS,
|
||||||
createStillImageCameraConfig,
|
createStillImageCameraConfig,
|
||||||
createStillImageCardConfig,
|
createStillImageCardConfig,
|
||||||
isMediaLoadedInfoEventDetail,
|
isMediaLoadedInfoEventDetail,
|
||||||
} from '../../browser/test-utils';
|
} from '../../browser/test-utils';
|
||||||
|
|
||||||
const STARTED_MESSAGE = 'card-started';
|
|
||||||
const OTHER_CAMERA_ENTITY = 'camera.other';
|
const OTHER_CAMERA_ENTITY = 'camera.other';
|
||||||
|
|
||||||
const createConfig = (
|
const createConfig = (
|
||||||
overrides?: Partial<RawAdvancedCameraCardConfig>,
|
overrides?: Partial<RawAdvancedCameraCardConfig>,
|
||||||
): RawAdvancedCameraCardConfig =>
|
): RawAdvancedCameraCardConfig =>
|
||||||
createStillImageCardConfig({
|
createStillImageCardConfig({
|
||||||
automations: [
|
automations: [createInitializedAutomation()],
|
||||||
{
|
|
||||||
triggers: [{ trigger: 'initialized' }],
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
action: 'fire-dom-event',
|
|
||||||
advanced_camera_card_action: 'log',
|
|
||||||
message: STARTED_MESSAGE,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -39,11 +29,6 @@ const mount = async (
|
|||||||
createStillCameraHASS({ cameras: [OTHER_CAMERA_ENTITY] }),
|
createStillCameraHASS({ cameras: [OTHER_CAMERA_ENTITY] }),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Only the messages the automation logged, since the card logs other things at
|
|
||||||
// the same level.
|
|
||||||
const getStartedMessages = (card: MountedCard): string[] =>
|
|
||||||
card.console.getMessages('info').filter((message) => message === STARTED_MESSAGE);
|
|
||||||
|
|
||||||
// The cameras the card has actually loaded media for, in order. Media that
|
// The cameras the card has actually loaded media for, in order. Media that
|
||||||
// named no camera is left out, since these are only read to ask which camera
|
// named no camera is left out, since these are only read to ask which camera
|
||||||
// the card ended up on.
|
// the card ended up on.
|
||||||
@@ -59,40 +44,40 @@ describe('SessionManager', () => {
|
|||||||
it('should fire an initialized trigger each time the card starts', async () => {
|
it('should fire an initialized trigger each time the card starts', async () => {
|
||||||
const card = await mount();
|
const card = await mount();
|
||||||
|
|
||||||
await vi.waitFor(() => expect(getStartedMessages(card)).toHaveLength(1));
|
await card.console.waitForMessage(CARD_INITIALIZED_MESSAGE);
|
||||||
|
|
||||||
// The card leaving the page is a change of the value the trigger watches,
|
// The card leaving the page is a change of the value the trigger watches,
|
||||||
// and must not fire it.
|
// and must not fire it.
|
||||||
card.detach();
|
card.detach();
|
||||||
await card.updateComplete;
|
await card.updateComplete;
|
||||||
|
|
||||||
expect(getStartedMessages(card)).toHaveLength(1);
|
expect(card.console.countMessages(CARD_INITIALIZED_MESSAGE)).toBe(1);
|
||||||
|
|
||||||
card.attach();
|
card.attach();
|
||||||
await vi.waitFor(() => expect(getStartedMessages(card)).toHaveLength(2));
|
await card.console.waitForMessage(CARD_INITIALIZED_MESSAGE, { count: 2 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should start the card again once Home Assistant comes back', async () => {
|
it('should start the card again once Home Assistant comes back', async () => {
|
||||||
const card = await mount();
|
const card = await mount();
|
||||||
|
|
||||||
await vi.waitFor(() => expect(getStartedMessages(card)).toHaveLength(1));
|
await card.console.waitForMessage(CARD_INITIALIZED_MESSAGE);
|
||||||
|
|
||||||
// Losing Home Assistant changes the value the trigger watches, and must not
|
// Losing Home Assistant changes the value the trigger watches, and must not
|
||||||
// fire it.
|
// fire it.
|
||||||
card.setConnected(false);
|
card.setConnected(false);
|
||||||
await card.updateComplete;
|
await card.updateComplete;
|
||||||
|
|
||||||
expect(getStartedMessages(card)).toHaveLength(1);
|
expect(card.console.countMessages(CARD_INITIALIZED_MESSAGE)).toBe(1);
|
||||||
|
|
||||||
card.setConnected(true);
|
card.setConnected(true);
|
||||||
await vi.waitFor(() => expect(getStartedMessages(card)).toHaveLength(2));
|
await card.console.waitForMessage(CARD_INITIALIZED_MESSAGE, { count: 2 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should initialize the new cameras without starting the card again', async () => {
|
it('should initialize the new cameras without starting the card again', async () => {
|
||||||
const card = await mount();
|
const card = await mount();
|
||||||
|
|
||||||
await card.events.waitForFirst('advanced-camera-card:media:loaded');
|
await card.events.waitForFirst('advanced-camera-card:media:loaded');
|
||||||
await vi.waitFor(() => expect(getStartedMessages(card)).toHaveLength(1));
|
await card.console.waitForMessage(CARD_INITIALIZED_MESSAGE);
|
||||||
|
|
||||||
// A camera change is the heaviest configuration change there is: the
|
// A camera change is the heaviest configuration change there is: the
|
||||||
// cameras are destroyed and initialized again.
|
// cameras are destroyed and initialized again.
|
||||||
@@ -107,7 +92,7 @@ describe('SessionManager', () => {
|
|||||||
expect(getLoadedCameraIDs(card)).toContain(OTHER_CAMERA_ENTITY),
|
expect(getLoadedCameraIDs(card)).toContain(OTHER_CAMERA_ENTITY),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(getStartedMessages(card)).toHaveLength(1);
|
expect(card.console.countMessages(CARD_INITIALIZED_MESSAGE)).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should apply an override keyed on the card being started every time it starts', async () => {
|
it('should apply an override keyed on the card being started every time it starts', async () => {
|
||||||
@@ -127,7 +112,7 @@ describe('SessionManager', () => {
|
|||||||
|
|
||||||
card.detach();
|
card.detach();
|
||||||
card.attach();
|
card.attach();
|
||||||
await vi.waitFor(() => expect(getStartedMessages(card)).toHaveLength(2));
|
await card.console.waitForMessage(CARD_INITIALIZED_MESSAGE, { count: 2 });
|
||||||
|
|
||||||
await card.waitForSelector('advanced-camera-card-menu');
|
await card.waitForSelector('advanced-camera-card-menu');
|
||||||
});
|
});
|
||||||
@@ -142,7 +127,7 @@ describe('SessionManager', () => {
|
|||||||
|
|
||||||
card.detach();
|
card.detach();
|
||||||
card.attach();
|
card.attach();
|
||||||
await vi.waitFor(() => expect(getStartedMessages(card)).toHaveLength(2));
|
await card.console.waitForMessage(CARD_INITIALIZED_MESSAGE, { count: 2 });
|
||||||
|
|
||||||
// A card that has been on screen once must not show the loading indicator
|
// A card that has been on screen once must not show the loading indicator
|
||||||
// again when it is re-attached.
|
// again when it is re-attached.
|
||||||
|
|||||||
@@ -2,22 +2,20 @@ import { describe, expect, it, vi } from 'vitest';
|
|||||||
|
|
||||||
import { MountedCard } from '../../../browser/mounted-card';
|
import { MountedCard } from '../../../browser/mounted-card';
|
||||||
import {
|
import {
|
||||||
|
CARD_INITIALIZED_MESSAGE,
|
||||||
|
createInitializedAutomation,
|
||||||
createStillCameraHASS,
|
createStillCameraHASS,
|
||||||
createStillImageCameraConfig,
|
createStillImageCameraConfig,
|
||||||
createStillImageCardConfig,
|
createStillImageCardConfig,
|
||||||
getBlockNotificationText,
|
getBlockNotificationText,
|
||||||
} from '../../../browser/test-utils';
|
} from '../../../browser/test-utils';
|
||||||
|
|
||||||
const STARTED_MESSAGE = 'card-started';
|
|
||||||
const INIT_FAILED_ISSUE_HEADING = 'Initialization failed';
|
const INIT_FAILED_ISSUE_HEADING = 'Initialization failed';
|
||||||
|
|
||||||
// A camera Home Assistant has never heard of, which is what a typo in a
|
// A camera Home Assistant has never heard of, which is what a typo in a
|
||||||
// configuration looks like and the earliest thing a camera can fail on.
|
// configuration looks like and the earliest thing a camera can fail on.
|
||||||
const MISSING_CAMERA_ENTITY = 'camera.missing';
|
const MISSING_CAMERA_ENTITY = 'camera.missing';
|
||||||
|
|
||||||
const getStartedMessages = (card: MountedCard): string[] =>
|
|
||||||
card.console.getMessages('info').filter((message) => message === STARTED_MESSAGE);
|
|
||||||
|
|
||||||
const getReportedInitializationFailures = (card: MountedCard): string[] =>
|
const getReportedInitializationFailures = (card: MountedCard): string[] =>
|
||||||
card.console
|
card.console
|
||||||
.getMessages('warn')
|
.getMessages('warn')
|
||||||
@@ -40,18 +38,7 @@ const mountBrokenCard = async (): Promise<MountedCard> =>
|
|||||||
// Automatic retries switched off, so any recovery below can only be the
|
// Automatic retries switched off, so any recovery below can only be the
|
||||||
// retry control being used.
|
// retry control being used.
|
||||||
view: { issues: { retry_seconds: 0 } },
|
view: { issues: { retry_seconds: 0 } },
|
||||||
automations: [
|
automations: [createInitializedAutomation()],
|
||||||
{
|
|
||||||
triggers: [{ trigger: 'initialized' }],
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
action: 'fire-dom-event',
|
|
||||||
advanced_camera_card_action: 'log',
|
|
||||||
message: STARTED_MESSAGE,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
createStillCameraHASS(),
|
createStillCameraHASS(),
|
||||||
);
|
);
|
||||||
@@ -63,7 +50,7 @@ describe('InitializationIssue', () => {
|
|||||||
await waitForInitializationFailures(card);
|
await waitForInitializationFailures(card);
|
||||||
|
|
||||||
// A card that failed to start has not started, whatever it is showing.
|
// A card that failed to start has not started, whatever it is showing.
|
||||||
expect(getStartedMessages(card)).toHaveLength(0);
|
expect(card.console.countMessages(CARD_INITIALIZED_MESSAGE)).toBe(0);
|
||||||
|
|
||||||
// The camera the user meant now exists. Nothing recovers on its own from
|
// The camera the user meant now exists. Nothing recovers on its own from
|
||||||
// here: automatic retries are off, and a card showing a full-card issue
|
// here: automatic retries are off, and a card showing a full-card issue
|
||||||
@@ -71,7 +58,7 @@ describe('InitializationIssue', () => {
|
|||||||
card.setEntityState(MISSING_CAMERA_ENTITY, 'idle');
|
card.setEntityState(MISSING_CAMERA_ENTITY, 'idle');
|
||||||
await card.clickControl('Retry');
|
await card.clickControl('Retry');
|
||||||
|
|
||||||
await vi.waitFor(() => expect(getStartedMessages(card)).toHaveLength(1));
|
await card.console.waitForMessage(CARD_INITIALIZED_MESSAGE);
|
||||||
await card.events.waitForFirst('advanced-camera-card:media:loaded');
|
await card.events.waitForFirst('advanced-camera-card:media:loaded');
|
||||||
|
|
||||||
// Starting is not the same as the issue leaving the screen, since a
|
// Starting is not the same as the issue leaving the screen, since a
|
||||||
@@ -95,6 +82,6 @@ describe('InitializationIssue', () => {
|
|||||||
);
|
);
|
||||||
await waitForInitializationFailures(card);
|
await waitForInitializationFailures(card);
|
||||||
|
|
||||||
expect(getStartedMessages(card)).toHaveLength(0);
|
expect(card.console.countMessages(CARD_INITIALIZED_MESSAGE)).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
import { assert, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import type { LogActionConfig } from '../../src/config/schema/actions/custom/log';
|
||||||
|
import { createLogAction } from '../../src/utils/action';
|
||||||
|
import { MountedCard, type MountOptions } from '../browser/mounted-card';
|
||||||
|
import {
|
||||||
|
CARD_INITIALIZED_MESSAGE,
|
||||||
|
clickElement,
|
||||||
|
createInitializedAutomation,
|
||||||
|
createStillCameraHASS,
|
||||||
|
createStillImageCardConfig,
|
||||||
|
dispatchPointerDown,
|
||||||
|
getFocusedElement,
|
||||||
|
holdKey,
|
||||||
|
pressKey,
|
||||||
|
releaseKey,
|
||||||
|
} from '../browser/test-utils';
|
||||||
|
|
||||||
|
const ZOOM_ENTITY = 'input_boolean.zoom';
|
||||||
|
|
||||||
|
// What each automation writes when it runs, written as the pattern the console
|
||||||
|
// is later searched for. The console is the only place a `log` action reports
|
||||||
|
// to, and a message that names the automation is what makes one key press
|
||||||
|
// distinguishable from another.
|
||||||
|
const KEY_MESSAGE = /plain key press/;
|
||||||
|
const OTHER_CARD_KEY_MESSAGE = /key press on the other card/;
|
||||||
|
const CTRL_KEY_MESSAGE = /key press with ctrl held/;
|
||||||
|
const KEY_DOWN_MESSAGE = /key on the way down/;
|
||||||
|
const KEY_UP_MESSAGE = /key on the way up/;
|
||||||
|
const MENU_BUTTON_MESSAGE = /menu button pressed/;
|
||||||
|
|
||||||
|
const MENU_BUTTON_CONTROL = 'Log';
|
||||||
|
const EXPAND_CONTROL = 'Expand';
|
||||||
|
|
||||||
|
// The card's own builder, given the text its pattern matches.
|
||||||
|
const logAction = (regexp: RegExp): LogActionConfig => createLogAction(regexp.source);
|
||||||
|
|
||||||
|
interface MountCardOptions extends MountOptions {
|
||||||
|
// What a plain key press logs, so that a test with two cards on the page can
|
||||||
|
// tell which of them answered.
|
||||||
|
keyMessage?: RegExp;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mountCard = async (options?: MountCardOptions): Promise<MountedCard> => {
|
||||||
|
const { keyMessage, ...mountOptions } = options ?? {};
|
||||||
|
|
||||||
|
const card = await MountedCard.create(
|
||||||
|
createStillImageCardConfig({
|
||||||
|
menu: { style: 'outside', buttons: { expand: { enabled: true } } },
|
||||||
|
elements: [
|
||||||
|
{
|
||||||
|
type: 'custom:advanced-camera-card-menu-icon',
|
||||||
|
icon: 'mdi:cow',
|
||||||
|
title: MENU_BUTTON_CONTROL,
|
||||||
|
tap_action: logAction(MENU_BUTTON_MESSAGE),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
automations: [
|
||||||
|
createInitializedAutomation(),
|
||||||
|
{
|
||||||
|
triggers: [{ trigger: 'key', key: 'z' }],
|
||||||
|
actions: [logAction(keyMessage ?? KEY_MESSAGE)],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
triggers: [{ trigger: 'key', key: 'b', ctrl: true }],
|
||||||
|
actions: [logAction(CTRL_KEY_MESSAGE)],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
triggers: [{ trigger: 'key', key: 'w', state: 'down' }],
|
||||||
|
actions: [logAction(KEY_DOWN_MESSAGE)],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
triggers: [{ trigger: 'key', key: 'w', state: 'up' }],
|
||||||
|
actions: [logAction(KEY_UP_MESSAGE)],
|
||||||
|
},
|
||||||
|
// A key whose action redraws the card, so a test can press a key, have
|
||||||
|
// the picture replaced under it, and press again.
|
||||||
|
{
|
||||||
|
triggers: [{ trigger: 'key', key: '1' }],
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
action: 'fire-dom-event',
|
||||||
|
advanced_camera_card_action: 'ptz_digital',
|
||||||
|
absolute: { zoom: 3 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// The same zoom driven by an entity rather than a key, so a test can
|
||||||
|
// zoom the card without pressing or clicking it first. A zoomed
|
||||||
|
// picture takes a press as the start of a pan, which is what such a
|
||||||
|
// test is about.
|
||||||
|
{
|
||||||
|
triggers: [{ trigger: 'state', entity: ZOOM_ENTITY, to: 'on' }],
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
action: 'fire-dom-event',
|
||||||
|
advanced_camera_card_action: 'ptz_digital',
|
||||||
|
absolute: { zoom: 3 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
createStillCameraHASS({ entities: { [ZOOM_ENTITY]: 'off' } }),
|
||||||
|
mountOptions,
|
||||||
|
);
|
||||||
|
|
||||||
|
await card.events.waitForFirst('advanced-camera-card:media:loaded');
|
||||||
|
|
||||||
|
return card;
|
||||||
|
};
|
||||||
|
|
||||||
|
// What the live view draws the camera into, which is the part of the card a
|
||||||
|
// user looks at and the largest part of it that is not a control.
|
||||||
|
const LIVE_MEDIA_SELECTOR = 'advanced-camera-card-live-provider';
|
||||||
|
|
||||||
|
const clickMedia = async (card: MountedCard): Promise<void> =>
|
||||||
|
await clickElement(await card.waitForSelector(LIVE_MEDIA_SELECTOR));
|
||||||
|
|
||||||
|
describe('KeyboardStateManager', () => {
|
||||||
|
it('should not act on a key until the card has been used', async () => {
|
||||||
|
const card = await mountCard();
|
||||||
|
|
||||||
|
// A key press belongs to whatever the user is looking at. A card that
|
||||||
|
// answered one aimed at something else on the dashboard would fire
|
||||||
|
// shortcuts nobody asked for.
|
||||||
|
await pressKey('z');
|
||||||
|
|
||||||
|
await clickMedia(card);
|
||||||
|
await pressKey('z');
|
||||||
|
await card.console.waitForMessage(KEY_MESSAGE);
|
||||||
|
|
||||||
|
// Once, from the press after the card was used. The first press is what
|
||||||
|
// this test is about, and only counting says it was ignored rather than
|
||||||
|
// merely slow.
|
||||||
|
expect(card.console.countMessages(KEY_MESSAGE)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep receiving keys after an action has redrawn the card', async () => {
|
||||||
|
const card = await mountCard();
|
||||||
|
|
||||||
|
await clickMedia(card);
|
||||||
|
|
||||||
|
// The zoom replaces what is on screen, and the card is drawn again around
|
||||||
|
// it. Anything holding focus below the card would be thrown away here.
|
||||||
|
await pressKey('1');
|
||||||
|
await card.events.waitForFirst('advanced-camera-card:zoom:zoomed');
|
||||||
|
|
||||||
|
await pressKey('z');
|
||||||
|
await card.console.waitForMessage(KEY_MESSAGE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep receiving keys after a menu button has been pressed', async () => {
|
||||||
|
const card = await mountCard();
|
||||||
|
|
||||||
|
await card.clickControl(MENU_BUTTON_CONTROL);
|
||||||
|
|
||||||
|
// The button did what it was asked, so nothing about claiming focus
|
||||||
|
// swallowed the press on its way in.
|
||||||
|
await card.console.waitForMessage(MENU_BUTTON_MESSAGE);
|
||||||
|
|
||||||
|
await pressKey('z');
|
||||||
|
await card.console.waitForMessage(KEY_MESSAGE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should claim focus from a press that is consumed by the card', async () => {
|
||||||
|
const card = await mountCard();
|
||||||
|
|
||||||
|
card.setEntityState(ZOOM_ENTITY, 'on');
|
||||||
|
await card.events.waitForFirst('advanced-camera-card:zoom:zoomed');
|
||||||
|
|
||||||
|
// A press on a zoomed picture is taken as the start of a pan: it is kept
|
||||||
|
// from reaching anything else, and the browser is told not to do what it
|
||||||
|
// would normally do with it, which includes moving focus.
|
||||||
|
await clickMedia(card);
|
||||||
|
|
||||||
|
expect(getFocusedElement()).toBe(card.card);
|
||||||
|
|
||||||
|
await pressKey('z');
|
||||||
|
await card.console.waitForMessage(KEY_MESSAGE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not scroll the page when it takes focus', async () => {
|
||||||
|
// Well below the window, so the card is out of sight until the page is
|
||||||
|
// scrolled to it.
|
||||||
|
const card = await mountCard({ position: { top: '2000px' } });
|
||||||
|
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
|
||||||
|
// Pressed without a pointer: a real one is moved to what it presses, and
|
||||||
|
// where the page ends up is the thing being asserted on here.
|
||||||
|
dispatchPointerDown(await card.waitForSelector(LIVE_MEDIA_SELECTOR));
|
||||||
|
|
||||||
|
// The card is far below what is on screen. A user pressing a card they can
|
||||||
|
// only see part of would find the dashboard jumping under them.
|
||||||
|
expect(window.scrollY).toBe(0);
|
||||||
|
|
||||||
|
expect(getFocusedElement()).toBe(card.card);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should leave focus where it is when it is already inside the card', async () => {
|
||||||
|
const card = await mountCard();
|
||||||
|
|
||||||
|
await card.clickControl(MENU_BUTTON_CONTROL);
|
||||||
|
await card.console.waitForMessage(MENU_BUTTON_MESSAGE);
|
||||||
|
|
||||||
|
// Pressing a control puts focus on it rather than on the card around it,
|
||||||
|
// which is the situation this test is about.
|
||||||
|
const control = getFocusedElement();
|
||||||
|
assert(control);
|
||||||
|
expect(control).not.toBe(card.card);
|
||||||
|
|
||||||
|
let lostFocus = false;
|
||||||
|
control.addEventListener('focusout', () => (lostFocus = true));
|
||||||
|
|
||||||
|
await card.clickControl(MENU_BUTTON_CONTROL);
|
||||||
|
await card.console.waitForMessage(MENU_BUTTON_MESSAGE, { count: 2 });
|
||||||
|
|
||||||
|
// Taking focus off a control the user is working in is how a field being
|
||||||
|
// typed into loses what is in it, or a picker closes mid-choice.
|
||||||
|
expect(lostFocus).toBe(false);
|
||||||
|
expect(getFocusedElement()).toBe(control);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should hold on to a held key while a control in the card is pressed', async () => {
|
||||||
|
const card = await mountCard();
|
||||||
|
|
||||||
|
await clickMedia(card);
|
||||||
|
|
||||||
|
await holdKey('w');
|
||||||
|
await card.console.waitForMessage(KEY_DOWN_MESSAGE);
|
||||||
|
|
||||||
|
// Focus moves from the card to the button within it, which is not the user
|
||||||
|
// letting go of the key.
|
||||||
|
await card.clickControl(MENU_BUTTON_CONTROL);
|
||||||
|
await card.console.waitForMessage(MENU_BUTTON_MESSAGE);
|
||||||
|
|
||||||
|
// A key whose press was forgotten has no release either, which is what
|
||||||
|
// leaves a camera panning with nothing to stop it.
|
||||||
|
await releaseKey('w');
|
||||||
|
await card.console.waitForMessage(KEY_UP_MESSAGE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should act on a key only when its modifiers match', async () => {
|
||||||
|
const card = await mountCard();
|
||||||
|
|
||||||
|
await clickMedia(card);
|
||||||
|
|
||||||
|
await pressKey('b');
|
||||||
|
|
||||||
|
// The trigger calls the modifier `ctrl`; the keyboard calls the key
|
||||||
|
// `Control`.
|
||||||
|
await holdKey('Control');
|
||||||
|
await pressKey('b');
|
||||||
|
await releaseKey('Control');
|
||||||
|
await card.console.waitForMessage(CTRL_KEY_MESSAGE);
|
||||||
|
|
||||||
|
// The press without the modifier is a different shortcut, and one this card
|
||||||
|
// has nothing configured for.
|
||||||
|
expect(card.console.countMessages(CTRL_KEY_MESSAGE)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep receiving keys while expanded', async () => {
|
||||||
|
const card = await mountCard();
|
||||||
|
|
||||||
|
await card.clickControl(EXPAND_CONTROL);
|
||||||
|
|
||||||
|
// The whole card is drawn again inside a dialog, which is as large a
|
||||||
|
// change as anything that happens to it.
|
||||||
|
await card.waitForSelector('web-dialog');
|
||||||
|
|
||||||
|
await clickMedia(card);
|
||||||
|
await pressKey('z');
|
||||||
|
await card.console.waitForMessage(KEY_MESSAGE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should stop receiving keys once it is taken out of the document', async () => {
|
||||||
|
const card = await mountCard();
|
||||||
|
|
||||||
|
await clickMedia(card);
|
||||||
|
await pressKey('z');
|
||||||
|
await card.console.waitForMessage(KEY_MESSAGE);
|
||||||
|
|
||||||
|
card.detach();
|
||||||
|
|
||||||
|
// A card on a dashboard tab that is not being looked at is out of the
|
||||||
|
// document, and answering keys from there would run shortcuts on a card
|
||||||
|
// nobody can see.
|
||||||
|
await pressKey('z');
|
||||||
|
expect(card.console.countMessages(KEY_MESSAGE)).toBe(1);
|
||||||
|
|
||||||
|
card.attach();
|
||||||
|
|
||||||
|
// The card builds itself again from nothing on its return, and answers
|
||||||
|
// nothing until it has.
|
||||||
|
await card.console.waitForMessage(CARD_INITIALIZED_MESSAGE, { count: 2 });
|
||||||
|
|
||||||
|
await clickMedia(card);
|
||||||
|
await pressKey('z');
|
||||||
|
await card.console.waitForMessage(KEY_MESSAGE, { count: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not act on a key aimed at another card', async () => {
|
||||||
|
const card = await mountCard();
|
||||||
|
const otherCard = await mountCard({ keyMessage: OTHER_CARD_KEY_MESSAGE });
|
||||||
|
|
||||||
|
// Either ledger reports both cards, since there is only one console. Which
|
||||||
|
// card answered is in the message rather than in where it was read.
|
||||||
|
await clickMedia(card);
|
||||||
|
await pressKey('z');
|
||||||
|
await card.console.waitForMessage(KEY_MESSAGE);
|
||||||
|
|
||||||
|
await clickMedia(otherCard);
|
||||||
|
await pressKey('z');
|
||||||
|
await card.console.waitForMessage(OTHER_CARD_KEY_MESSAGE);
|
||||||
|
|
||||||
|
// One press each. A dashboard of cards that all answer every key press
|
||||||
|
// would run a shortcut once per card on screen.
|
||||||
|
expect(card.console.countMessages(KEY_MESSAGE)).toBe(1);
|
||||||
|
expect(card.console.countMessages(OTHER_CARD_KEY_MESSAGE)).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user