fix: Stop PTZ movement and browser key handling from fighting (#2674)
- Closes: #2623
This commit is contained in:
@@ -474,5 +474,93 @@ describe('should handle ptz digital action', () => {
|
||||
|
||||
expect(api.getViewManager().setViewWithModifiers).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should stop without anything in progress', async () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getViewManager().getView).mockReturnValue(createView());
|
||||
|
||||
const stopAction = new PTZDigitalAction(
|
||||
{},
|
||||
{
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'ptz_digital',
|
||||
ptz_phase: 'stop',
|
||||
},
|
||||
);
|
||||
await stopAction.execute(api);
|
||||
|
||||
vi.runOnlyPendingTimers();
|
||||
|
||||
expect(api.getViewManager().setViewWithModifiers).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should stop movement started by two concurrent starts', async () => {
|
||||
const api = createCardAPI();
|
||||
const context = {};
|
||||
vi.mocked(api.getViewManager().getView).mockReturnValue(createView());
|
||||
|
||||
const createStartAction = (ptzAction: PTZAction): PTZDigitalAction =>
|
||||
new PTZDigitalAction(context, {
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'ptz_digital',
|
||||
ptz_action: ptzAction,
|
||||
ptz_phase: 'start',
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
createStartAction('left').execute(api),
|
||||
createStartAction('up').execute(api),
|
||||
]);
|
||||
|
||||
const stopAction = new PTZDigitalAction(context, {
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'ptz_digital',
|
||||
ptz_phase: 'stop',
|
||||
});
|
||||
await stopAction.execute(api);
|
||||
|
||||
// One step per start, and none after the stop.
|
||||
expect(api.getViewManager().setViewWithModifiers).toHaveBeenCalledTimes(2);
|
||||
|
||||
vi.runOnlyPendingTimers();
|
||||
|
||||
expect(api.getViewManager().setViewWithModifiers).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should continue movement when a start is concurrent with a stop', async () => {
|
||||
const api = createCardAPI();
|
||||
const context = {};
|
||||
vi.mocked(api.getViewManager().getView).mockReturnValue(createView());
|
||||
|
||||
const leftAction = new PTZDigitalAction(context, {
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'ptz_digital',
|
||||
ptz_action: 'left',
|
||||
ptz_phase: 'start',
|
||||
});
|
||||
await leftAction.execute(api);
|
||||
|
||||
const stopAction = new PTZDigitalAction(context, {
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'ptz_digital',
|
||||
ptz_phase: 'stop',
|
||||
});
|
||||
const upAction = new PTZDigitalAction(context, {
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'ptz_digital',
|
||||
ptz_action: 'up',
|
||||
ptz_phase: 'start',
|
||||
});
|
||||
|
||||
// The left key is released as the up key is pressed.
|
||||
await Promise.all([stopAction.execute(api), upAction.execute(api)]);
|
||||
|
||||
// One step for the left start, one for the up start.
|
||||
expect(api.getViewManager().setViewWithModifiers).toHaveBeenCalledTimes(2);
|
||||
|
||||
vi.runOnlyPendingTimers();
|
||||
|
||||
expect(api.getViewManager().setViewWithModifiers).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -611,5 +611,108 @@ describe('should handle ptz action', () => {
|
||||
// There should be no additional calls.
|
||||
expect(api.getCameraManager().executePTZAction).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should stop movement started by two concurrent starts', async () => {
|
||||
const api = createCardAPI();
|
||||
const store = createStore([
|
||||
{
|
||||
cameraID: 'camera.office',
|
||||
capabilities: new Capabilities({
|
||||
ptz: {
|
||||
left: [PTZMovementType.Relative],
|
||||
up: [PTZMovementType.Relative],
|
||||
},
|
||||
}),
|
||||
},
|
||||
]);
|
||||
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store));
|
||||
vi.mocked(api.getViewManager().getView).mockReturnValue(
|
||||
createView({ camera: 'camera.office' }),
|
||||
);
|
||||
|
||||
const context = {};
|
||||
const createStartAction = (ptzAction: 'left' | 'up'): PTZAction =>
|
||||
new PTZAction(context, {
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'ptz',
|
||||
ptz_action: ptzAction,
|
||||
ptz_phase: 'start',
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
createStartAction('left').execute(api),
|
||||
createStartAction('up').execute(api),
|
||||
]);
|
||||
|
||||
const stopAction = new PTZAction(context, {
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'ptz',
|
||||
ptz_action: 'left',
|
||||
ptz_phase: 'stop',
|
||||
});
|
||||
await stopAction.execute(api);
|
||||
|
||||
// One move per start, and none after the stop.
|
||||
expect(api.getCameraManager().executePTZAction).toHaveBeenCalledTimes(2);
|
||||
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(api.getCameraManager().executePTZAction).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should continue movement when a start is concurrent with a stop', async () => {
|
||||
const api = createCardAPI();
|
||||
const store = createStore([
|
||||
{
|
||||
cameraID: 'camera.office',
|
||||
capabilities: new Capabilities({
|
||||
ptz: {
|
||||
left: [PTZMovementType.Relative],
|
||||
up: [PTZMovementType.Relative],
|
||||
},
|
||||
}),
|
||||
},
|
||||
]);
|
||||
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store));
|
||||
vi.mocked(api.getViewManager().getView).mockReturnValue(
|
||||
createView({ camera: 'camera.office' }),
|
||||
);
|
||||
|
||||
const context = {};
|
||||
await new PTZAction(context, {
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'ptz',
|
||||
ptz_action: 'left',
|
||||
ptz_phase: 'start',
|
||||
}).execute(api);
|
||||
|
||||
const stopAction = new PTZAction(context, {
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'ptz',
|
||||
ptz_action: 'left',
|
||||
ptz_phase: 'stop',
|
||||
});
|
||||
const upAction = new PTZAction(context, {
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'ptz',
|
||||
ptz_action: 'up',
|
||||
ptz_phase: 'start',
|
||||
});
|
||||
|
||||
// The left key is released as the up key is pressed.
|
||||
await Promise.all([stopAction.execute(api), upAction.execute(api)]);
|
||||
|
||||
// One move for the left start, one for the up start.
|
||||
expect(api.getCameraManager().executePTZAction).toHaveBeenCalledTimes(2);
|
||||
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(api.getCameraManager().executePTZAction).toHaveBeenCalledTimes(3);
|
||||
expect(api.getCameraManager().executePTZAction).toHaveBeenLastCalledWith(
|
||||
'camera.office',
|
||||
'up',
|
||||
{ preset: undefined },
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ActionsExecutionRequest } from '../../src/card-controller/actions/
|
||||
import { AutomationsManager } from '../../src/card-controller/automations-manager.js';
|
||||
import type { EventWatcherSubscriptionInterface } from '../../src/card-controller/hass/event-watcher.js';
|
||||
import { ConditionStateManager } from '../../src/condition-trigger/conditions/state-manager.js';
|
||||
import type { Trigger } from '../../src/config/schema/condition-trigger/triggers/types.js';
|
||||
import {
|
||||
createCardAPI,
|
||||
createHASS,
|
||||
@@ -375,4 +376,27 @@ describe('AutomationsManager', () => {
|
||||
stateManager.setState({ expand: true });
|
||||
expect(api.getActionsManager().executeActions).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
describe('should report the triggers it subscribes to', () => {
|
||||
it('with no automations', () => {
|
||||
expect(new AutomationsManager(createCardAPI()).getTriggers()).toEqual([]);
|
||||
});
|
||||
|
||||
it('with automations', () => {
|
||||
const keyTrigger: Trigger = { trigger: 'key', key: 'ArrowLeft' };
|
||||
const expandTrigger: Trigger = { trigger: 'expand', expand: true };
|
||||
|
||||
const automationsManager = new AutomationsManager(createCardAPI());
|
||||
automationsManager.addAutomations([
|
||||
{ triggers: [keyTrigger, expandTrigger], actions },
|
||||
{ triggers: triggers, actions },
|
||||
]);
|
||||
|
||||
expect(automationsManager.getTriggers()).toEqual([
|
||||
keyTrigger,
|
||||
expandTrigger,
|
||||
...triggers,
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -538,7 +538,7 @@ describe('ConfigManager', () => {
|
||||
view: {
|
||||
keyboard_shortcuts: {
|
||||
enabled: true,
|
||||
ptz_home: { key: 'h' },
|
||||
ptz_home: { key: 'q' },
|
||||
},
|
||||
},
|
||||
overrides: [
|
||||
@@ -552,39 +552,28 @@ describe('ConfigManager', () => {
|
||||
manager.setConfig(config);
|
||||
await flushPromises();
|
||||
|
||||
// Verify keyboard shortcuts automations were added initially with ptz_home
|
||||
expect(addAutomationsSpy).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
triggers: expect.arrayContaining([
|
||||
expect.objectContaining({ trigger: 'key', key: 'h' }),
|
||||
]),
|
||||
actions: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
advanced_camera_card_action: 'ptz_multi',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const hasKeyTrigger = (key: string): boolean =>
|
||||
addAutomationsSpy.mock.calls.some((call) =>
|
||||
call[0].some((automation: Automation) =>
|
||||
automation.triggers.some(
|
||||
(trig: Trigger) => trig.trigger === 'key' && trig.key === key,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// The configured binding, rather than the default one.
|
||||
expect(hasKeyTrigger('q')).toBe(true);
|
||||
|
||||
addAutomationsSpy.mockClear();
|
||||
|
||||
// Trigger the override - keyboard_shortcuts should be deleted
|
||||
// Trigger the override, which deletes the shortcut configuration.
|
||||
stateManager.setState({ fullscreen: true });
|
||||
await flushPromises();
|
||||
|
||||
// Verify newly added automations don't contain keyboard shortcuts (key: 'h')
|
||||
// This confirms the override removed them (directly verified through add calls)
|
||||
const addCalls = addAutomationsSpy.mock.calls;
|
||||
const hasKeyboardShortcut = addCalls.some((call) =>
|
||||
call[0].some((automation: Automation) =>
|
||||
automation.triggers.some(
|
||||
(trig: Trigger) => trig.trigger === 'key' && trig.key === 'h',
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(hasKeyboardShortcut).toBe(false);
|
||||
// Deleting the configuration restores the defaults rather than removing
|
||||
// the shortcuts, so the loader re-runs and binds the default key.
|
||||
expect(hasKeyTrigger('q')).toBe(false);
|
||||
expect(hasKeyTrigger('h')).toBe(true);
|
||||
});
|
||||
|
||||
it('should re-run folders loader when overrides change', async () => {
|
||||
|
||||
@@ -114,6 +114,55 @@ describe('setKeyboardShortcutsFromConfig', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('should give the start and stop of a shortcut the same modifiers', () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
|
||||
createConfig({
|
||||
view: {
|
||||
keyboard_shortcuts: {
|
||||
enabled: true,
|
||||
ptz_home: null,
|
||||
ptz_right: null,
|
||||
ptz_up: null,
|
||||
ptz_down: null,
|
||||
ptz_zoom_in: null,
|
||||
ptz_zoom_out: null,
|
||||
ptz_left: { key: 'z', ctrl: false, alt: false, meta: false },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
setKeyboardShortcutsFromConfig(api);
|
||||
|
||||
const automations = vi.mocked(api.getAutomationsManager().addAutomations).mock
|
||||
.calls[0][0];
|
||||
expect(automations.map((automation) => automation.triggers)).toEqual([
|
||||
[
|
||||
{
|
||||
trigger: 'key',
|
||||
key: 'z',
|
||||
state: 'down',
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
meta: false,
|
||||
shift: undefined,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
trigger: 'key',
|
||||
key: 'z',
|
||||
state: 'up',
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
meta: false,
|
||||
shift: undefined,
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('ptz_home', () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
|
||||
@@ -134,11 +183,11 @@ describe('setKeyboardShortcutsFromConfig', () => {
|
||||
],
|
||||
triggers: [
|
||||
{
|
||||
alt: undefined,
|
||||
alt: false,
|
||||
trigger: 'key',
|
||||
ctrl: undefined,
|
||||
ctrl: false,
|
||||
key: 'h',
|
||||
meta: undefined,
|
||||
meta: false,
|
||||
shift: undefined,
|
||||
state: 'down',
|
||||
},
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { assert, describe, expect, it } from 'vitest';
|
||||
import { assert, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { STEP_PAN } from '../../src/card-controller/actions/actions/ptz-digital';
|
||||
import type { ZoomSettingsObserved } from '../../src/components-lib/zoom/types';
|
||||
import type { LogActionConfig } from '../../src/config/schema/actions/custom/log';
|
||||
import { createLogAction } from '../../src/utils/action';
|
||||
import { isRecord } from '../../src/utils/basic';
|
||||
import {
|
||||
clickElement,
|
||||
dispatchPointerDown,
|
||||
@@ -117,6 +120,9 @@ const mountCard = async (options?: MountCardOptions): Promise<MountedCard> => {
|
||||
return card;
|
||||
};
|
||||
|
||||
const isZoomSettingsObserved = (detail: unknown): detail is ZoomSettingsObserved =>
|
||||
isRecord(detail) && isRecord(detail.pan) && typeof detail.pan.x === 'number';
|
||||
|
||||
// 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';
|
||||
@@ -125,6 +131,10 @@ const clickMedia = async (card: MountedCard): Promise<void> =>
|
||||
await clickElement(await card.waitForSelector(LIVE_MEDIA_SELECTOR));
|
||||
|
||||
describe('KeyboardStateManager', () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should not act on a key until the card has been used', async () => {
|
||||
const card = await mountCard();
|
||||
|
||||
@@ -307,6 +317,51 @@ describe('KeyboardStateManager', () => {
|
||||
await card.console.waitForMessage(KEY_MESSAGE, { count: 2 });
|
||||
});
|
||||
|
||||
// See: https://github.com/dermotduffy/advanced-camera-card/issues/2623
|
||||
it('should keep panning while an arrow key is held after previous press', async () => {
|
||||
const card = await mountCard();
|
||||
|
||||
card.setEntityState(ZOOM_ENTITY, 'on');
|
||||
await card.events.waitForFirst('advanced-camera-card:zoom:zoomed');
|
||||
await clickMedia(card);
|
||||
|
||||
// Press a key to ensure holds after initial press are functional.
|
||||
await pressKey('ArrowUp');
|
||||
|
||||
// How far across the camera the picture sits, as a percentage, from the
|
||||
// last change the card reported. It starts halfway across.
|
||||
const panX = (): number | null => {
|
||||
const detail = card.events
|
||||
.getEntries('advanced-camera-card:zoom:change')
|
||||
.at(-1)?.detail;
|
||||
return isZoomSettingsObserved(detail) ? detail.pan.x : null;
|
||||
};
|
||||
|
||||
//`shouldAdvanceTime` lets the clock run at its own pace until "controlled",
|
||||
// necessary for the panning while a key is being "held" to work.
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
|
||||
await holdKey('ArrowLeft');
|
||||
await card.waitForRender(() => {
|
||||
const x = panX();
|
||||
|
||||
// Stop well short of the left edge, to allow detection of continuous pan
|
||||
// that never stopped.
|
||||
return x !== null && x < 40 ? x : null;
|
||||
}, 'the picture to pan left');
|
||||
await releaseKey('ArrowLeft');
|
||||
|
||||
const atRelease = panX();
|
||||
assert(atRelease !== null);
|
||||
|
||||
await card.advanceSeconds(5);
|
||||
|
||||
const afterWaiting = panX();
|
||||
assert(afterWaiting !== null);
|
||||
|
||||
expect(Math.abs(atRelease - afterWaiting)).toBeLessThanOrEqual(STEP_PAN);
|
||||
});
|
||||
|
||||
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 });
|
||||
|
||||
@@ -1,20 +1,54 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
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 = createCardAPI();
|
||||
const element = createLitElement();
|
||||
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
|
||||
const manager = new KeyboardStateManager(api);
|
||||
manager.initialize();
|
||||
const { api, element } = createManager();
|
||||
|
||||
element.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' }));
|
||||
|
||||
@@ -30,12 +64,109 @@ describe('KeyboardStateManager', () => {
|
||||
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 = createCardAPI();
|
||||
const element = createLitElement();
|
||||
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
|
||||
const manager = new KeyboardStateManager(api);
|
||||
manager.initialize();
|
||||
const { api, element } = createManager();
|
||||
|
||||
element.dispatchEvent(new KeyboardEvent('keyup', { key: 'a' }));
|
||||
|
||||
@@ -53,12 +184,8 @@ describe('KeyboardStateManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should set state on focus loss', () => {
|
||||
const api = createCardAPI();
|
||||
const element = createLitElement();
|
||||
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
|
||||
const manager = new KeyboardStateManager(api);
|
||||
manager.initialize();
|
||||
it('should release held keys on focus loss', () => {
|
||||
const { api, element } = createManager();
|
||||
|
||||
element.dispatchEvent(new FocusEvent('blur'));
|
||||
expect(api.getConditionStateManager().setState).not.toHaveBeenCalled();
|
||||
@@ -68,16 +195,42 @@ describe('KeyboardStateManager', () => {
|
||||
|
||||
expect(api.getConditionStateManager().setState).toHaveBeenCalledTimes(2);
|
||||
expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith({
|
||||
keys: {},
|
||||
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 = createCardAPI();
|
||||
const element = createLitElement();
|
||||
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
|
||||
const manager = new KeyboardStateManager(api);
|
||||
manager.initialize();
|
||||
const { api, element } = createManager();
|
||||
|
||||
element.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' }));
|
||||
element.dispatchEvent(new FocusEvent('blur', { relatedTarget: element }));
|
||||
@@ -91,12 +244,8 @@ describe('KeyboardStateManager', () => {
|
||||
});
|
||||
|
||||
it('should take focus on pointerdown', () => {
|
||||
const api = createCardAPI();
|
||||
const element = createLitElement();
|
||||
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
|
||||
const { element } = createManager();
|
||||
const focus = vi.spyOn(element, 'focus');
|
||||
const manager = new KeyboardStateManager(api);
|
||||
manager.initialize();
|
||||
|
||||
element.dispatchEvent(new Event('pointerdown'));
|
||||
|
||||
@@ -104,9 +253,7 @@ describe('KeyboardStateManager', () => {
|
||||
});
|
||||
|
||||
it('should not take focus on pointerdown when focus is already within the card', () => {
|
||||
const api = createCardAPI();
|
||||
const element = createLitElement();
|
||||
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
|
||||
const { element } = createManager();
|
||||
document.body.append(element);
|
||||
|
||||
const child = document.createElement('div');
|
||||
@@ -115,8 +262,6 @@ describe('KeyboardStateManager', () => {
|
||||
child.focus();
|
||||
|
||||
const focus = vi.spyOn(element, 'focus');
|
||||
const manager = new KeyboardStateManager(api);
|
||||
manager.initialize();
|
||||
|
||||
element.dispatchEvent(new Event('pointerdown'));
|
||||
|
||||
@@ -126,12 +271,8 @@ describe('KeyboardStateManager', () => {
|
||||
});
|
||||
|
||||
it('should not take focus on pointerdown after uninitialization', () => {
|
||||
const api = createCardAPI();
|
||||
const element = createLitElement();
|
||||
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
|
||||
const { element, manager } = createManager();
|
||||
const focus = vi.spyOn(element, 'focus');
|
||||
const manager = new KeyboardStateManager(api);
|
||||
manager.initialize();
|
||||
manager.uninitialize();
|
||||
|
||||
element.dispatchEvent(new Event('pointerdown'));
|
||||
@@ -140,11 +281,7 @@ describe('KeyboardStateManager', () => {
|
||||
});
|
||||
|
||||
it('should not act after uninitialization', () => {
|
||||
const api = createCardAPI();
|
||||
const element = createLitElement();
|
||||
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
|
||||
const manager = new KeyboardStateManager(api);
|
||||
manager.initialize();
|
||||
const { api, element, manager } = createManager();
|
||||
manager.uninitialize();
|
||||
|
||||
element.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' }));
|
||||
@@ -152,27 +289,23 @@ describe('KeyboardStateManager', () => {
|
||||
expect(api.getConditionStateManager().setState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should clear held keys on uninitialize', () => {
|
||||
const api = createCardAPI();
|
||||
const element = createLitElement();
|
||||
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
|
||||
const manager = new KeyboardStateManager(api);
|
||||
manager.initialize();
|
||||
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: {} });
|
||||
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 = createCardAPI();
|
||||
const element = createLitElement();
|
||||
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
|
||||
const manager = new KeyboardStateManager(api);
|
||||
manager.initialize();
|
||||
const { api, manager } = createManager();
|
||||
manager.uninitialize();
|
||||
|
||||
expect(api.getConditionStateManager().setState).not.toHaveBeenCalled();
|
||||
|
||||
Reference in New Issue
Block a user