Files
advanced-camera-card/tests/card-controller/keyboard-state-manager.test.ts
T

314 lines
11 KiB
TypeScript

import { describe, expect, it, vi, type MockInstance } from 'vitest';
import type { CardController } from '../../src/card-controller/controller';
import { KeyboardStateManager } from '../../src/card-controller/keyboard-state-manager';
import type { Trigger } from '../../src/config/schema/condition-trigger/triggers/types';
import { createCardAPI, createLitElement } from '../test-utils';
// @vitest-environment jsdom
describe('KeyboardStateManager', () => {
const createManager = (
triggers: Trigger[] = [],
): { api: CardController; element: HTMLElement; manager: KeyboardStateManager } => {
const api = createCardAPI();
const element = createLitElement();
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
vi.mocked(api.getAutomationsManager().getTriggers).mockReturnValue(triggers);
const manager = new KeyboardStateManager(api);
manager.initialize();
return { api, element, manager };
};
// For the tests that assert whether a press was claimed. Counting the calls
// on the returned spy, rather than reading `defaultPrevented`, is what tells
// the manager's prevention apart from that of another listener on the same
// press.
const dispatchKeydownWithPreventionSpy = (
target: HTMLElement,
options?: KeyboardEventInit,
): MockInstance => {
const event = new KeyboardEvent('keydown', {
key: 'ArrowDown',
bubbles: true,
composed: true,
cancelable: true,
...options,
});
const preventDefault = vi.spyOn(event, 'preventDefault');
target.dispatchEvent(event);
return preventDefault;
};
it('should construct', () => {
expect(new KeyboardStateManager(createCardAPI())).toBeTruthy();
});
it('should set state on keydown', () => {
const { api, element } = createManager();
element.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' }));
expect(api.getConditionStateManager().setState).toHaveBeenCalledWith({
keys: {
a: { state: 'down', ctrl: false, alt: false, meta: false, shift: false },
},
});
element.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' }));
// Duplicate keydown should not re-set the state.
expect(api.getConditionStateManager().setState).toHaveBeenCalledTimes(1);
});
describe('should stop the browser acting on a keypress', () => {
it('should stop when a trigger acts on the press of the key', () => {
const { element } = createManager([{ trigger: 'key', key: 'ArrowDown' }]);
expect(dispatchKeydownWithPreventionSpy(element)).toHaveBeenCalled();
});
it('should stop when a trigger acts on the release of the key', () => {
const { element } = createManager([
{ trigger: 'key', key: 'ArrowDown', state: 'up' },
]);
// The browser scrolls as the key goes down, so the press must be claimed
// then even though the card acts on the release.
expect(dispatchKeydownWithPreventionSpy(element)).toHaveBeenCalled();
});
it('should not stop when no trigger matches the key', () => {
const { element } = createManager([{ trigger: 'key', key: 'ArrowUp' }]);
expect(dispatchKeydownWithPreventionSpy(element)).not.toHaveBeenCalled();
});
it('should not stop when the triggers are not about keys at all', () => {
const { element } = createManager([{ trigger: 'fullscreen', fullscreen: true }]);
expect(dispatchKeydownWithPreventionSpy(element)).not.toHaveBeenCalled();
});
describe('should not stop when a modifier the trigger asks for is absent', () => {
it.each([
['ctrl' as const, { ctrlKey: true }],
['alt' as const, { altKey: true }],
['meta' as const, { metaKey: true }],
['shift' as const, { shiftKey: true }],
])('with %s', (modifier: string, held: KeyboardEventInit) => {
const { element } = createManager([
{ trigger: 'key', key: 'ArrowDown', [modifier]: true },
]);
expect(dispatchKeydownWithPreventionSpy(element)).not.toHaveBeenCalled();
expect(dispatchKeydownWithPreventionSpy(element, held)).toHaveBeenCalled();
});
});
it('should not stop when a trigger for the key is turned off', () => {
const { element } = createManager([
{ trigger: 'key', key: 'ArrowDown', enabled: false },
]);
expect(dispatchKeydownWithPreventionSpy(element)).not.toHaveBeenCalled();
});
it('should not stop when a trigger watches every key', () => {
const { api, element } = createManager([{ trigger: 'key' }]);
expect(dispatchKeydownWithPreventionSpy(element)).not.toHaveBeenCalled();
expect(api.getConditionStateManager().setState).toHaveBeenCalled();
});
it('should stop on every repeat of a held key', () => {
const { api, element } = createManager([{ trigger: 'key', key: 'ArrowDown' }]);
expect(dispatchKeydownWithPreventionSpy(element)).toHaveBeenCalled();
expect(dispatchKeydownWithPreventionSpy(element)).toHaveBeenCalled();
expect(api.getConditionStateManager().setState).toHaveBeenCalledTimes(1);
});
});
describe('should ignore a key press that belongs to another element', () => {
it('should ignore when something else has already answered the press', () => {
const { api, element } = createManager([{ trigger: 'key', key: 'ArrowDown' }]);
element.addEventListener('keydown', (ev) => ev.preventDefault(), {
capture: true,
});
// Once from the listener above.
expect(dispatchKeydownWithPreventionSpy(element)).toHaveBeenCalledTimes(1);
expect(api.getConditionStateManager().setState).not.toHaveBeenCalled();
});
it('should ignore when a composition is being typed', () => {
const { api, element } = createManager([{ trigger: 'key', key: 'ArrowDown' }]);
expect(
dispatchKeydownWithPreventionSpy(element, { isComposing: true }),
).not.toHaveBeenCalled();
expect(api.getConditionStateManager().setState).not.toHaveBeenCalled();
});
it('should ignore when the press lands on an element with keys of its own', () => {
const { api, element } = createManager([{ trigger: 'key', key: 'ArrowDown' }]);
const input = document.createElement('input');
element.append(input);
expect(dispatchKeydownWithPreventionSpy(input)).not.toHaveBeenCalled();
expect(api.getConditionStateManager().setState).not.toHaveBeenCalled();
});
});
it('should set state on keyup', () => {
const { api, element } = createManager();
element.dispatchEvent(new KeyboardEvent('keyup', { key: 'a' }));
// Key not held down in the first place should not update the state.
expect(api.getConditionStateManager().setState).not.toHaveBeenCalled();
element.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' }));
element.dispatchEvent(new KeyboardEvent('keyup', { key: 'a' }));
expect(api.getConditionStateManager().setState).toHaveBeenCalledTimes(2);
expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith({
keys: {
a: { state: 'up', ctrl: false, alt: false, meta: false, shift: false },
},
});
});
it('should release held keys on focus loss', () => {
const { api, element } = createManager();
element.dispatchEvent(new FocusEvent('blur'));
expect(api.getConditionStateManager().setState).not.toHaveBeenCalled();
element.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' }));
element.dispatchEvent(new FocusEvent('blur'));
expect(api.getConditionStateManager().setState).toHaveBeenCalledTimes(2);
expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith({
keys: {
a: { state: 'up', ctrl: false, alt: false, meta: false, shift: false },
},
});
});
it('should set state on keydown of a previously released key', () => {
const { api, element } = createManager();
element.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' }));
element.dispatchEvent(new FocusEvent('blur'));
vi.mocked(api.getConditionStateManager().setState).mockClear();
element.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' }));
expect(api.getConditionStateManager().setState).toHaveBeenCalledWith({
keys: {
a: { state: 'down', ctrl: false, alt: false, meta: false, shift: false },
},
});
});
it('should not set state on focus loss when no key is held', () => {
const { api, element } = createManager();
element.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' }));
element.dispatchEvent(new KeyboardEvent('keyup', { key: 'a' }));
vi.mocked(api.getConditionStateManager().setState).mockClear();
element.dispatchEvent(new FocusEvent('blur'));
expect(api.getConditionStateManager().setState).not.toHaveBeenCalled();
});
it('should not clear state when focus moves within the card', () => {
const { api, element } = createManager();
element.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' }));
element.dispatchEvent(new FocusEvent('blur', { relatedTarget: element }));
expect(api.getConditionStateManager().setState).toHaveBeenCalledTimes(1);
expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith({
keys: {
a: { state: 'down', ctrl: false, alt: false, meta: false, shift: false },
},
});
});
it('should take focus on pointerdown', () => {
const { element } = createManager();
const focus = vi.spyOn(element, 'focus');
element.dispatchEvent(new Event('pointerdown'));
expect(focus).toHaveBeenCalledWith({ preventScroll: true });
});
it('should not take focus on pointerdown when focus is already within the card', () => {
const { element } = createManager();
document.body.append(element);
const child = document.createElement('div');
child.setAttribute('tabindex', '0');
element.attachShadow({ mode: 'open' }).appendChild(child);
child.focus();
const focus = vi.spyOn(element, 'focus');
element.dispatchEvent(new Event('pointerdown'));
expect(focus).not.toHaveBeenCalled();
element.remove();
});
it('should not take focus on pointerdown after uninitialization', () => {
const { element, manager } = createManager();
const focus = vi.spyOn(element, 'focus');
manager.uninitialize();
element.dispatchEvent(new Event('pointerdown'));
expect(focus).not.toHaveBeenCalled();
});
it('should not act after uninitialization', () => {
const { api, element, manager } = createManager();
manager.uninitialize();
element.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' }));
expect(api.getConditionStateManager().setState).not.toHaveBeenCalled();
});
it('should release held keys on uninitialize', () => {
const { api, element, manager } = createManager();
element.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' }));
vi.mocked(api.getConditionStateManager().setState).mockClear();
manager.uninitialize();
expect(api.getConditionStateManager().setState).toHaveBeenCalledWith({
keys: {
a: { state: 'up', ctrl: false, alt: false, meta: false, shift: false },
},
});
});
it('should not set state on uninitialize when no keys held', () => {
const { api, manager } = createManager();
manager.uninitialize();
expect(api.getConditionStateManager().setState).not.toHaveBeenCalled();
});
});