From aa97c19814c8b9cd02b83ad4e3ef2261c054ced3 Mon Sep 17 00:00:00 2001 From: Bruno Boi Date: Thu, 23 Jun 2022 07:06:45 +0000 Subject: [PATCH] [IMP] web: add hotkey service options This commit adds two options to the hotkey service to improve its usability in some cases: - `area` which is a function that returns an HTMLElement that restricts the operating area for an hotkey registration - `validate` which is a function that returns a boolean that tells if the hotkey registration's callback should be called or not Some typings adaptations were made in: - @web/core/commands/command_palette - @web/core/commands/command_service - @web/core/utils/ui Part-of: odoo/odoo#92475 --- .../src/core/commands/command_palette.js | 2 +- .../src/core/commands/command_service.js | 5 +- .../static/src/core/hotkeys/hotkey_hook.js | 11 +- .../static/src/core/hotkeys/hotkey_service.js | 167 +++++++---- addons/web/static/src/core/utils/ui.js | 8 +- .../core/hotkeys/hotkey_service_tests.js | 279 ++++++++++++++++-- 6 files changed, 366 insertions(+), 106 deletions(-) diff --git a/addons/web/static/src/core/commands/command_palette.js b/addons/web/static/src/core/commands/command_palette.js index ba403c4d6c6..ed27aaa2f77 100644 --- a/addons/web/static/src/core/commands/command_palette.js +++ b/addons/web/static/src/core/commands/command_palette.js @@ -45,7 +45,7 @@ const FUZZY_NAMESPACES = ["default"]; /** * @typedef {{ - * configByNamespace?: {[namespace]: NamespaceConfig}; + * configByNamespace?: {[namespace: string]: NamespaceConfig}; * FooterComponent?: Component; * providers: Provider[]; * searchValue?: string; diff --git a/addons/web/static/src/core/commands/command_service.js b/addons/web/static/src/core/commands/command_service.js index 2db5be5bdae..c2c15b272b0 100644 --- a/addons/web/static/src/core/commands/command_service.js +++ b/addons/web/static/src/core/commands/command_service.js @@ -18,11 +18,8 @@ const { Component, xml } = owl; */ /** - * @typedef {{ - * activeElement?: HTMLElement; + * @typedef {import("../hotkeys/hotkey_service").HotkeyOptions & { * category?: string; - * global?: boolean; - * hotkey?: string; * }} CommandOptions */ diff --git a/addons/web/static/src/core/hotkeys/hotkey_hook.js b/addons/web/static/src/core/hotkeys/hotkey_hook.js index 1ab5a2511c4..bd8e19dfbe3 100644 --- a/addons/web/static/src/core/hotkeys/hotkey_hook.js +++ b/addons/web/static/src/core/hotkeys/hotkey_hook.js @@ -9,15 +9,8 @@ const { useEffect } = owl; * when the caller component will mount/unmount. * * @param {string} hotkey - * @param {()=>void} callback - * @param {Object} options additional options - * @param {boolean} [options.allowRepeat=false] - * allow registration to perform multiple times when hotkey is held down - * @param {boolean} [options.bypassEditableProtection=false] - * if true the hotkey service will call this registration - * even if an editable element is focused - * @param {boolean} [options.global=false] - * allow registration to perform no matter the UI active element + * @param {import("./hotkey_service").HotkeyCallback} callback + * @param {import("./hotkey_service").HotkeyOptions} [options] additional options */ export function useHotkey(hotkey, callback, options = {}) { const hotkeyService = useService("hotkey"); diff --git a/addons/web/static/src/core/hotkeys/hotkey_service.js b/addons/web/static/src/core/hotkeys/hotkey_service.js index 3ecf33357f1..c6301aead52 100644 --- a/addons/web/static/src/core/hotkeys/hotkey_service.js +++ b/addons/web/static/src/core/hotkeys/hotkey_service.js @@ -5,6 +5,29 @@ import { registry } from "../registry"; import { browser } from "../browser/browser"; import { getVisibleElements } from "../utils/ui"; +/** + * @typedef {(context: { area: HTMLElement, target: EventTarget }) => void} HotkeyCallback + * + * @typedef {Object} HotkeyOptions + * @property {boolean} [allowRepeat] + * allow registration to perform multiple times when hotkey is held down + * @property {boolean} [bypassEditableProtection] + * if true the hotkey service will call this registration + * even if an editable element is focused + * @property {boolean} [global] + * allow registration to perform no matter the UI active element + * @property {() => HTMLElement} [area] + * adds a restricted operating area for this hotkey + * @property {(target: EventTarget) => boolean} [validate] + * adds a validation before calling the hotkey registration's callback + * + * @typedef {HotkeyOptions & { + * hotkey: string, + * callback: HotkeyCallback, + * activeElement: HTMLElement, + * }} HotkeyRegistration + */ + const ALPHANUM_KEYS = "abcdefghijklmnopqrstuvwxyz0123456789".split(""); const NAV_KEYS = [ "arrowleft", @@ -19,16 +42,56 @@ const NAV_KEYS = [ "enter", "tab", "delete", + "space", ]; const MODIFIERS = ["alt", "control", "shift"]; const AUTHORIZED_KEYS = [...ALPHANUM_KEYS, ...NAV_KEYS, "escape"]; +/** + * Get the actual hotkey being pressed. + * + * @param {KeyboardEvent} ev + * @returns {string} the active hotkey, in lowercase + */ +export function getActiveHotkey(ev) { + const hotkey = []; + + // ------- Modifiers ------- + // Modifiers are pushed in ascending order to the hotkey. + if (isMacOS() ? ev.ctrlKey : ev.altKey) { + hotkey.push("alt"); + } + if (isMacOS() ? ev.metaKey : ev.ctrlKey) { + hotkey.push("control"); + } + if (ev.shiftKey) { + hotkey.push("shift"); + } + + // ------- Key ------- + let key = ev.key.toLowerCase(); + // Identify if the user has tapped on the number keys above the text keys. + if (ev.code && ev.code.indexOf("Digit") === 0) { + key = ev.code.slice(-1); + } + // Prefer physical keys for non-latin keyboard layout. + if (!AUTHORIZED_KEYS.includes(key) && ev.code && ev.code.indexOf("Key") === 0) { + key = ev.code.slice(-1).toLowerCase(); + } + // Make sure we do not duplicate a modifier key + if (!MODIFIERS.includes(key)) { + hotkey.push(key); + } + return hotkey.join("+"); +} + export const hotkeyService = { dependencies: ["ui"], // Be aware that all odoo hotkeys are designed with this modifier in mind, // so changing the overlay modifier may conflict with some shortcuts. overlayModifier: "alt", start(env, { ui }) { + /** @type {Map} */ const registrations = new Map(); let nextToken = 0; let overlaysVisible = false; @@ -111,6 +174,7 @@ export const hotkeyService = { activeElement, hotkey, isRepeated: event.repeat, + target: event.target, shouldProtectEditable, }; const dispatched = dispatch(infos); @@ -140,33 +204,60 @@ export const hotkeyService = { * activeElement: HTMLElement, * hotkey: string, * isRepeated: boolean, + * target: EventTarget, * shouldProtectEditable: boolean, * }} infos * @returns {boolean} true if has been dispatched */ function dispatch(infos) { - const { activeElement, hotkey, isRepeated, shouldProtectEditable } = infos; + const { activeElement, hotkey, isRepeated, target, shouldProtectEditable } = infos; // Prepare registrations and the common filter const reversedRegistrations = Array.from(registrations.values()).reverse(); const domRegistrations = getDomRegistrations(hotkey, activeElement); const allRegistrations = reversedRegistrations.concat(domRegistrations); - // Dispatch actual hotkey to first matching registration - const match = allRegistrations.find( + // Find all candidates + const candidates = allRegistrations.filter( (reg) => reg.hotkey === hotkey && (reg.allowRepeat || !isRepeated) && (reg.bypassEditableProtection || !shouldProtectEditable) && - (reg.global || reg.activeElement === activeElement) + (reg.global || reg.activeElement === activeElement) && + (!reg.validate || reg.validate(target)) && + (!reg.area || + (target instanceof Node && reg.area() && reg.area().contains(target))) ); - if (match) { - match.callback(); + + // First candidate + let winner = candidates.shift(); + if (winner && winner.area) { + // If there is an area, find the closest one + for (const candidate of candidates) { + if (candidate.area() && winner.area().contains(candidate.area())) { + winner = candidate; + } + } + } + + // Dispatch actual hotkey to the matching registration + if (winner) { + winner.callback({ + area: winner.area && winner.area(), + target, + }); return true; } return false; } + /** + * Get a list of registrations from the [data-hotkey] defined in the DOM + * + * @param {string} hotkey + * @param {HTMLElement} activeElement + * @returns {HotkeyRegistration[]} + */ function getDomRegistrations(hotkey, activeElement) { const overlayModParts = hotkeyService.overlayModifier.split("+"); if (!overlayModParts.every((el) => hotkey.includes(el))) { @@ -194,7 +285,7 @@ export const hotkeyService = { /** * Add the hotkey overlays respecting the ui active element. - * @param {Element} activeElement + * @param {HTMLElement} activeElement */ function addHotkeyOverlays(activeElement) { for (const el of getVisibleElements(activeElement, "[data-hotkey]:not(:disabled)")) { @@ -247,57 +338,12 @@ export const hotkeyService = { overlaysVisible = false; } - /** - * Get the actual hotkey being pressed. - * - * @param {KeyboardEvent} ev - * @returns {string} the active hotkey, in lowercase - */ - function getActiveHotkey(ev) { - const hotkey = []; - - // ------- Modifiers ------- - // Modifiers are pushed in ascending order to the hotkey. - if (isMacOS() ? ev.ctrlKey : ev.altKey) { - hotkey.push("alt"); - } - if (isMacOS() ? ev.metaKey : ev.ctrlKey) { - hotkey.push("control"); - } - if (ev.shiftKey) { - hotkey.push("shift"); - } - - // ------- Key ------- - let key = ev.key.toLowerCase(); - // Identify if the user has tapped on the number keys above the text keys. - if (ev.code && ev.code.indexOf("Digit") === 0) { - key = ev.code.slice(-1); - } - // Prefer physical keys for non-latin keyboard layout. - if (!AUTHORIZED_KEYS.includes(key) && ev.code && ev.code.indexOf("Key") === 0) { - key = ev.code.slice(-1).toLowerCase(); - } - // Make sure we do not duplicate a modifier key - if (!MODIFIERS.includes(key)) { - hotkey.push(key); - } - return hotkey.join("+"); - } - /** * Registers a new hotkey. * * @param {string} hotkey - * @param {()=>void} callback - * @param {Object} options additional options - * @param {boolean} [options.allowRepeat=false] - * allow registration to perform multiple times when hotkey is held down - * @param {boolean} [options.bypassEditableProtection=false] - * if true the hotkey service will call this registration - * even if an editable element is focused - * @param {boolean} [options.global=false] - * allow registration to perform no matter the UI active element + * @param {HotkeyCallback} callback + * @param {HotkeyOptions} [options] * @returns {number} registration token */ function registerHotkey(hotkey, callback, options = {}) { @@ -336,6 +382,7 @@ export const hotkeyService = { // Add registration const token = nextToken++; + /** @type {HotkeyRegistration} */ const registration = { hotkey: hotkey.toLowerCase(), callback, @@ -343,8 +390,9 @@ export const hotkeyService = { allowRepeat: options && options.allowRepeat, bypassEditableProtection: options && options.bypassEditableProtection, global: options && options.global, + area: options && options.area, + validate: options && options.validate, }; - registrations.set(token, registration); // Due to the way elements are mounted in the DOM by Owl (bottom-to-top), // we need to wait the next micro task tick to set the context owner of the registration. @@ -352,6 +400,7 @@ export const hotkeyService = { registration.activeElement = ui.activeElement; }); + registrations.set(token, registration); return token; } @@ -367,11 +416,8 @@ export const hotkeyService = { return { /** * @param {string} hotkey - * @param {() => void} callback - * @param {Object} options - * @param {boolean} [options.allowRepeat=false] - * @param {boolean} [options.bypassEditableProtection=false] - * @param {boolean} [options.global=false] + * @param {HotkeyCallback} callback + * @param {HotkeyOptions} [options] * @returns {() => void} */ add(hotkey, callback, options = {}) { @@ -391,3 +437,4 @@ export const hotkeyService = { }; registry.category("services").add("hotkey", hotkeyService); +/** @typedef {ReturnType} HotkeyService */ diff --git a/addons/web/static/src/core/utils/ui.js b/addons/web/static/src/core/utils/ui.js index 79c7381ffe2..09740b0f4da 100644 --- a/addons/web/static/src/core/utils/ui.js +++ b/addons/web/static/src/core/utils/ui.js @@ -1,13 +1,15 @@ /** @odoo-module **/ /** - * @param {Document} activeElement - * @param {DOMString} selector + * @param {Element} activeElement + * @param {String} selector * @returns all selected and visible elements present in the activeElement */ export function getVisibleElements(activeElement, selector) { const visibleElements = []; - for (const el of activeElement.querySelectorAll(selector)) { + /** @type {NodeListOf} */ + const elements = activeElement.querySelectorAll(selector); + for (const el of elements) { const isVisible = el.offsetWidth > 0 && el.offsetHeight > 0; if (isVisible) { visibleElements.push(el); diff --git a/addons/web/static/tests/core/hotkeys/hotkey_service_tests.js b/addons/web/static/tests/core/hotkeys/hotkey_service_tests.js index 78c425e9d31..907320a678b 100644 --- a/addons/web/static/tests/core/hotkeys/hotkey_service_tests.js +++ b/addons/web/static/tests/core/hotkeys/hotkey_service_tests.js @@ -16,7 +16,7 @@ import { triggerHotkey, } from "../../helpers/utils"; -const { Component, useState, xml } = owl; +const { Component, useRef, useState, xml } = owl; const serviceRegistry = registry.category("services"); let env; @@ -242,18 +242,18 @@ QUnit.test("non-MacOS usability", async (assert) => { const hotkey = env.services.hotkey; const key = "q"; + const commentEventAttrs = { key, bubbles: true }; // On non-MacOS, ALT is NOT replaced by CONTROL key let removeHotkey = hotkey.add(`alt+${key}`, () => assert.step(`alt+${key}`)); await nextTick(); - - let keydown = new KeyboardEvent("keydown", { key, altKey: true }); - window.dispatchEvent(keydown); + let keydown = new KeyboardEvent("keydown", { ...commentEventAttrs, altKey: true }); + document.activeElement.dispatchEvent(keydown); await nextTick(); assert.verifySteps([`alt+${key}`]); - keydown = new KeyboardEvent("keydown", { key, ctrlKey: true }); - window.dispatchEvent(keydown); + keydown = new KeyboardEvent("keydown", { ...commentEventAttrs, ctrlKey: true }); + document.activeElement.dispatchEvent(keydown); await nextTick(); assert.verifySteps([]); @@ -263,13 +263,13 @@ QUnit.test("non-MacOS usability", async (assert) => { removeHotkey = hotkey.add(`control+${key}`, () => assert.step(`control+${key}`)); await nextTick(); - keydown = new KeyboardEvent("keydown", { key, ctrlKey: true }); - window.dispatchEvent(keydown); + keydown = new KeyboardEvent("keydown", { ...commentEventAttrs, ctrlKey: true }); + document.activeElement.dispatchEvent(keydown); await nextTick(); assert.verifySteps([`control+${key}`]); - keydown = new KeyboardEvent("keydown", { key, metaKey: true }); - window.dispatchEvent(keydown); + keydown = new KeyboardEvent("keydown", { ...commentEventAttrs, metaKey: true }); + document.activeElement.dispatchEvent(keydown); await nextTick(); assert.verifySteps([]); @@ -407,18 +407,19 @@ QUnit.test("MacOS usability", async (assert) => { const hotkey = env.services.hotkey; const key = "q"; + const commentEventAttrs = { key, bubbles: true }; // On MacOS, ALT is replaced by CONTROL key let removeHotkey = hotkey.add(`alt+${key}`, () => assert.step(`alt+${key}`)); await nextTick(); - let keydown = new KeyboardEvent("keydown", { key, altKey: true }); - window.dispatchEvent(keydown); + let keydown = new KeyboardEvent("keydown", { ...commentEventAttrs, altKey: true }); + document.activeElement.dispatchEvent(keydown); await nextTick(); assert.verifySteps([]); - keydown = new KeyboardEvent("keydown", { key, ctrlKey: true }); - window.dispatchEvent(keydown); + keydown = new KeyboardEvent("keydown", { ...commentEventAttrs, ctrlKey: true }); + document.activeElement.dispatchEvent(keydown); await nextTick(); assert.verifySteps([`alt+${key}`]); @@ -428,13 +429,13 @@ QUnit.test("MacOS usability", async (assert) => { removeHotkey = hotkey.add(`control+${key}`, () => assert.step(`control+${key}`)); await nextTick(); - keydown = new KeyboardEvent("keydown", { key, ctrlKey: true }); - window.dispatchEvent(keydown); + keydown = new KeyboardEvent("keydown", { ...commentEventAttrs, ctrlKey: true }); + document.activeElement.dispatchEvent(keydown); await nextTick(); assert.verifySteps([]); - keydown = new KeyboardEvent("keydown", { key, metaKey: true }); - window.dispatchEvent(keydown); + keydown = new KeyboardEvent("keydown", { ...commentEventAttrs, metaKey: true }); + document.activeElement.dispatchEvent(keydown); await nextTick(); assert.verifySteps([`control+${key}`]); @@ -761,11 +762,12 @@ QUnit.test("protects editable elements", async (assert) => { const input = target.querySelector(".foo"); assert.verifySteps([]); - document.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true })); + triggerHotkey("ArrowLeft"); await nextTick(); assert.verifySteps(["called"]); - input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true })); + input.focus(); + triggerHotkey("ArrowLeft"); await nextTick(); assert.verifySteps( [], @@ -785,11 +787,12 @@ QUnit.test("protects editable elements: can bypassEditableProtection", async (as const input = target.querySelector(".foo"); assert.verifySteps([]); - document.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true })); + triggerHotkey("ArrowLeft"); await nextTick(); assert.verifySteps(["called"]); - input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true })); + input.focus(); + triggerHotkey("ArrowLeft"); await nextTick(); assert.verifySteps( ["called"], @@ -809,15 +812,17 @@ QUnit.test("protects editable elements: an editable can allow hotkeys", async (a const barInput = target.querySelector(".bar"); assert.verifySteps([]); - document.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true })); + triggerHotkey("ArrowLeft"); await nextTick(); assert.verifySteps(["called"]); - fooInput.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true })); + fooInput.focus(); + triggerHotkey("ArrowLeft"); await nextTick(); assert.verifySteps(["called"], "the callback gets called as the foo editable allows it"); - barInput.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true })); + barInput.focus(); + triggerHotkey("ArrowLeft"); await nextTick(); assert.verifySteps( [], @@ -829,17 +834,21 @@ QUnit.test("ignore numpad keys", async (assert) => { assert.expect(3); const key = "1"; + const commonEventAttrs = { + altKey: true, + bubbles: true, + }; env.services.hotkey.add(`alt+${key}`, () => assert.step(key)); await nextTick(); - let keydown = new KeyboardEvent("keydown", { key, code: "Numpad1", altKey: true }); - window.dispatchEvent(keydown); + let keydown = new KeyboardEvent("keydown", { ...commonEventAttrs, key, code: "Numpad1" }); + document.activeElement.dispatchEvent(keydown); await nextTick(); assert.verifySteps([]); - keydown = new KeyboardEvent("keydown", { key: "&", code: "Digit1", altKey: true }); - window.dispatchEvent(keydown); + keydown = new KeyboardEvent("keydown", { ...commonEventAttrs, key: "&", code: "Digit1" }); + document.activeElement.dispatchEvent(keydown); await nextTick(); assert.verifySteps(["1"]); }); @@ -874,3 +883,215 @@ QUnit.test("within iframes", async (assert) => { await nextTick(); assert.verifySteps(["called"]); }); + +QUnit.test("callback: received context", async (assert) => { + class A extends Component { + setup() { + useHotkey("a", (context) => { + assert.deepEqual(context, { + area: undefined, + target: document.activeElement, + }); + }); + } + } + A.template = xml``; + + class B extends Component { + setup() { + useHotkey( + "b", + (context) => { + assert.deepEqual(context, { + area: target, + target: target, + }); + }, + { area: () => target } + ); + } + } + B.template = xml``; + + await mount(A, target, { env }); + await mount(B, target, { env }); + triggerHotkey("A"); + triggerHotkey("B"); + await nextTick(); +}); + +QUnit.test("operating area can be restricted", async (assert) => { + class A extends Component { + setup() { + const areaRef = useRef("area"); + useHotkey( + "space", + ({ area }) => { + assert.step("RGNTDJÛ!"); + assert.strictEqual(area, target.querySelector(".two")); + }, + { + area: () => areaRef.el, + } + ); + } + } + A.template = xml` +
+
+ `; + await mount(A, target, { env }); + + target.querySelector(".one").focus(); + triggerHotkey("Space"); + await nextTick(); + assert.verifySteps([]); + + target.querySelector(".two").focus(); + triggerHotkey("Space"); + await nextTick(); + assert.verifySteps(["RGNTDJÛ!"]); +}); + +QUnit.test("operating area and UI active element", async (assert) => { + class UIOwnershipTakerComponent extends Component { + setup() { + useActiveElement("bouh"); + } + } + UIOwnershipTakerComponent.template = xml`

bouh

`; + class C extends Component { + setup() { + this.state = useState({ foo: false }); + const areaRef = useRef("area"); + useHotkey( + "space", + ({ area }) => { + assert.step("RGNTDJÛ!"); + assert.strictEqual(area, target.querySelector(".two")); + }, + { + area: () => areaRef.el, + } + ); + useHotkey( + "backspace", + ({ area }) => { + assert.step("RGNTDJÛ! (global)"); + assert.strictEqual(area, target.querySelector(".two")); + }, + { + area: () => areaRef.el, + global: true, + } + ); + } + } + C.components = { UIOwnershipTakerComponent }; + C.template = xml` +
+ +
+
+
+ `; + const comp = await mount(C, target, { env }); + assert.strictEqual(env.services.ui.activeElement, document); + + // Show the UIOwnershipTaker + comp.state.foo = true; + await nextTick(); + assert.hasClass(env.services.ui.activeElement, "owner"); + + // Trigger hotkeys from the 'one' + target.querySelector(".one").focus(); + triggerHotkey("Space"); + triggerHotkey("BackSpace"); + await nextTick(); + assert.verifySteps([]); + + // Trigger hotkeys from the 'two' + target.querySelector(".two").focus(); + triggerHotkey("Space"); + triggerHotkey("BackSpace"); + await nextTick(); + assert.verifySteps(["RGNTDJÛ! (global)"]); +}); + +QUnit.test("validating option", async (assert) => { + let isValid = false; + class A extends Component { + setup() { + useHotkey( + "space", + () => { + assert.step("RGNTDJÛ!"); + }, + { + validate: (eventTarget) => { + assert.strictEqual(eventTarget, document.activeElement); + return isValid; + }, + } + ); + } + } + A.template = xml``; + await mount(A, target, { env }); + + triggerHotkey("Space"); + await nextTick(); + assert.verifySteps([]); + + isValid = true; + triggerHotkey("Space"); + await nextTick(); + assert.verifySteps(["RGNTDJÛ!"]); +}); + +QUnit.test("operation area with validating option", async (assert) => { + let isValid; + class A extends Component { + setup() { + const areaRef = useRef("area"); + useHotkey( + "space", + () => { + assert.step("RGNTDJÛ!"); + }, + { area: () => areaRef.el, validate: () => isValid } + ); + } + } + A.template = xml` +
+
+ `; + await mount(A, target, { env }); + + // Trigger hotkeys from the 'one' + target.querySelector(".one").focus(); + + isValid = false; + triggerHotkey("Space"); + await nextTick(); + assert.verifySteps([]); + + isValid = true; + triggerHotkey("Space"); + await nextTick(); + assert.verifySteps([]); + + // Trigger hotkeys from the 'two' + target.querySelector(".two").focus(); + + isValid = false; + triggerHotkey("Space"); + await nextTick(); + assert.verifySteps([]); + + isValid = true; + triggerHotkey("Space"); + await nextTick(); + assert.verifySteps(["RGNTDJÛ!"]); +});