diff --git a/tests/browser/ha-element-stubs.ts b/tests/browser/ha-element-stubs.ts index 3079dd33..d1320f61 100644 --- a/tests/browser/ha-element-stubs.ts +++ b/tests/browser/ha-element-stubs.ts @@ -1,4 +1,5 @@ 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'; @@ -79,8 +80,11 @@ class HACardStub extends HAElementStub { * 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 * menu out. Without it a button is only as big as whatever it contains. + * + * Home Assistant draws a real ``; + } } /** @@ -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 * 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 { public hass?: unknown; - public setConfig(): void { - // The card only needs the call to succeed; nothing renders from it here. + public setConfig(config: ConditionalElementConfig): void { + 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); + } } } diff --git a/tests/browser/mounted-card.ts b/tests/browser/mounted-card.ts index 56fed086..96b75f9b 100644 --- a/tests/browser/mounted-card.ts +++ b/tests/browser/mounted-card.ts @@ -5,7 +5,7 @@ import type { AdvancedCameraCard } from '../../src/card'; import type { RawAdvancedCameraCardConfig } from '../../src/config/types'; import type { FakeEntityOptions, FakeHASS } from './fake-hass'; 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 // a card usually gets. The card derives height from the media it is showing. @@ -35,6 +35,19 @@ interface ConsoleEntry { 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; type ConsoleLevel = (typeof CONSOLE_LEVELS)[number]; @@ -154,12 +167,15 @@ class ConsoleLedger { private _entries: ConsoleEntry[] = []; private _originals = new Map void>(); + private _waiting: ConsoleWaiter[] = []; + constructor() { for (const level of CONSOLE_LEVELS) { const original = console[level]; this._originals.set(level, original); console[level] = (...args: unknown[]): void => { this._entries.push({ level, args }); + this._resolveWaiters(); original(...args); }; } @@ -175,12 +191,55 @@ class ConsoleLedger { 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 { + const waiter = { + count: options?.count ?? 1, + level: options?.level ?? 'info', + message, + }; + + if (this._isSatisfied(waiter)) { + return; + } + + return await new Promise((resolve) => { + this._waiting.push({ ...waiter, resolve }); + }); + } + + private _isSatisfied(waiter: Omit): 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 { for (const [level, original] of this._originals) { console[level] = original; } this._originals.clear(); this._entries = []; + this._waiting = []; } } @@ -195,6 +254,11 @@ export interface MountOptions { width?: 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. expectedConsoleErrors?: RegExp[]; @@ -246,6 +310,11 @@ export class MountedCard { if (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); // 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 { 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 * 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 { const control = await this._findControl(name); diff --git a/tests/browser/test-utils.ts b/tests/browser/test-utils.ts index d62b80a8..21d021b1 100644 --- a/tests/browser/test-utils.ts +++ b/tests/browser/test-utils.ts @@ -1,5 +1,8 @@ +import { userEvent } from 'vitest/browser'; + import type { RawAdvancedCameraCardConfig } from '../../src/config/types'; import type { MediaLoadedInfoEventDetail } from '../../src/types'; +import { createLogAction } from '../../src/utils/action'; import { FakeHASS, type FakeEntityOptions } from './fake-hass'; export const STILL_CAMERA_ENTITY = 'camera.office'; @@ -111,6 +114,20 @@ export const createStillImageCardConfig = ( ...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 // 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. @@ -183,3 +200,54 @@ export const isLiveMediaShowing = (root: ParentNode): boolean => deepQueryAll(root, 'advanced-camera-card-live-provider').some( (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 => + await userEvent.keyboard(asKeyboardInput(key)); + +export const holdKey = async (key: string): Promise => + await userEvent.keyboard(`{${key}>}`); + +export const releaseKey = async (key: string): Promise => + await userEvent.keyboard(`{/${key}}`); + +export const pressTab = async (): Promise => 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 => + 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; +}; diff --git a/tests/card-controller/card-element-manager.browser.test.ts b/tests/card-controller/card-element-manager.browser.test.ts new file mode 100644 index 00000000..73b27dd4 --- /dev/null +++ b/tests/card-controller/card-element-manager.browser.test.ts @@ -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 => { + 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 => { + 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); + }); +}); diff --git a/tests/card-controller/initialization/session-manager.browser.test.ts b/tests/card-controller/initialization/session-manager.browser.test.ts index 3f6c2fbc..38562786 100644 --- a/tests/card-controller/initialization/session-manager.browser.test.ts +++ b/tests/card-controller/initialization/session-manager.browser.test.ts @@ -3,31 +3,21 @@ import { describe, expect, it, vi } from 'vitest'; import type { RawAdvancedCameraCardConfig } from '../../../src/config/types'; import { MountedCard } from '../../browser/mounted-card'; import { + CARD_INITIALIZED_MESSAGE, + createInitializedAutomation, createStillCameraHASS, createStillImageCameraConfig, createStillImageCardConfig, isMediaLoadedInfoEventDetail, } from '../../browser/test-utils'; -const STARTED_MESSAGE = 'card-started'; const OTHER_CAMERA_ENTITY = 'camera.other'; const createConfig = ( overrides?: Partial, ): RawAdvancedCameraCardConfig => createStillImageCardConfig({ - automations: [ - { - triggers: [{ trigger: 'initialized' }], - actions: [ - { - action: 'fire-dom-event', - advanced_camera_card_action: 'log', - message: STARTED_MESSAGE, - }, - ], - }, - ], + automations: [createInitializedAutomation()], ...overrides, }); @@ -39,11 +29,6 @@ const mount = async ( 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 // named no camera is left out, since these are only read to ask which camera // the card ended up on. @@ -59,40 +44,40 @@ describe('SessionManager', () => { it('should fire an initialized trigger each time the card starts', async () => { 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, // and must not fire it. card.detach(); await card.updateComplete; - expect(getStartedMessages(card)).toHaveLength(1); + expect(card.console.countMessages(CARD_INITIALIZED_MESSAGE)).toBe(1); 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 () => { 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 // fire it. card.setConnected(false); await card.updateComplete; - expect(getStartedMessages(card)).toHaveLength(1); + expect(card.console.countMessages(CARD_INITIALIZED_MESSAGE)).toBe(1); 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 () => { const card = await mount(); 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 // cameras are destroyed and initialized again. @@ -107,7 +92,7 @@ describe('SessionManager', () => { 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 () => { @@ -127,7 +112,7 @@ describe('SessionManager', () => { card.detach(); 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'); }); @@ -142,7 +127,7 @@ describe('SessionManager', () => { card.detach(); 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 // again when it is re-attached. diff --git a/tests/card-controller/issues/issues/initialization.browser.test.ts b/tests/card-controller/issues/issues/initialization.browser.test.ts index b858eb6b..c207dffe 100644 --- a/tests/card-controller/issues/issues/initialization.browser.test.ts +++ b/tests/card-controller/issues/issues/initialization.browser.test.ts @@ -2,22 +2,20 @@ import { describe, expect, it, vi } from 'vitest'; import { MountedCard } from '../../../browser/mounted-card'; import { + CARD_INITIALIZED_MESSAGE, + createInitializedAutomation, createStillCameraHASS, createStillImageCameraConfig, createStillImageCardConfig, getBlockNotificationText, } from '../../../browser/test-utils'; -const STARTED_MESSAGE = 'card-started'; const INIT_FAILED_ISSUE_HEADING = 'Initialization failed'; // 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. 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[] => card.console .getMessages('warn') @@ -40,18 +38,7 @@ const mountBrokenCard = async (): Promise => // Automatic retries switched off, so any recovery below can only be the // retry control being used. view: { issues: { retry_seconds: 0 } }, - automations: [ - { - triggers: [{ trigger: 'initialized' }], - actions: [ - { - action: 'fire-dom-event', - advanced_camera_card_action: 'log', - message: STARTED_MESSAGE, - }, - ], - }, - ], + automations: [createInitializedAutomation()], }), createStillCameraHASS(), ); @@ -63,7 +50,7 @@ describe('InitializationIssue', () => { await waitForInitializationFailures(card); // 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 // 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'); 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'); // Starting is not the same as the issue leaving the screen, since a @@ -95,6 +82,6 @@ describe('InitializationIssue', () => { ); await waitForInitializationFailures(card); - expect(getStartedMessages(card)).toHaveLength(0); + expect(card.console.countMessages(CARD_INITIALIZED_MESSAGE)).toBe(0); }); }); diff --git a/tests/card-controller/keyboard-state-manager.browser.test.ts b/tests/card-controller/keyboard-state-manager.browser.test.ts new file mode 100644 index 00000000..869ed11d --- /dev/null +++ b/tests/card-controller/keyboard-state-manager.browser.test.ts @@ -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 => { + 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 => + 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); + }); +});