fix: Stop PTZ movement and browser key handling from fighting (#2674)

- Closes: #2623
This commit is contained in:
Dermot Duffy
2026-08-10 14:49:50 -07:00
committed by GitHub
parent a8aa9ec7c2
commit 3808a1b030
20 changed files with 822 additions and 148 deletions
@@ -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();