UNPKG

rx-hotkeys

Version:

Advanced Keyboard Shortcut Management library using rxjs

818 lines 66.4 kB
import { describe, it, before, beforeEach, afterEach, mock } from "node:test"; import assert from "node:assert"; import { Hotkeys, ShortcutTypes } from "./hotkeys.js"; import { Keys } from "./keys.js"; import { fromEvent, BehaviorSubject, Observable, EMPTY, firstValueFrom } from "rxjs"; import { createMockFn, dispatchKeyEvent } from "./testutils.js"; import { JSDOM } from "jsdom"; // --- JSDOM and RxJS setup for Node.js tests --- let dom; let window; let document; let originalPerformanceNow; let testArea; // For target tests before(() => { dom = new JSDOM(`<!DOCTYPE html><html><body><div id="test-area"></div></body></html>`, { url: "http://localhost", }); window = dom.window; document = window.document; testArea = document.getElementById("test-area"); // @ts-ignore global.document = document; // @ts-ignore global.window = window; // @ts-ignore global.HTMLElement = window.HTMLElement; // @ts-ignore global.KeyboardEvent = window.KeyboardEvent; // @ts-ignore global.BehaviorSubject = BehaviorSubject; // @ts-ignore global.fromEvent = fromEvent; // @ts-ignore global.Observable = Observable; // @ts-ignore global.EMPTY = EMPTY; // @ts-ignore if (typeof global.performance === "undefined") { // @ts-ignore global.performance = {}; } // @ts-ignore originalPerformanceNow = global.performance.now; // @ts-ignore if (typeof global.performance.now !== "function") { // @ts-ignore global.performance.now = (() => { const start = Date.now(); return () => Date.now() - start; })(); } }); describe("Hotkeys Library (Node.js Test Runner)", () => { let keyManager; let mockCallback; let consoleWarnMock; let consoleErrorMock; let performanceNowMock; // To mock global.performance.now specifically for sequence tests beforeEach(() => { keyManager = new Hotkeys(null, false); mockCallback = createMockFn(); consoleWarnMock = mock.method(console, "warn"); consoleErrorMock = mock.method(console, "error"); }); afterEach(() => { if (keyManager) { keyManager.destroy(); } mockCallback.mockClear(); mock.reset(); if (consoleWarnMock && consoleWarnMock.mock) consoleWarnMock.mock.restore(); if (consoleErrorMock && consoleErrorMock.mock) consoleErrorMock.mock.restore(); // @ts-ignore if (global.performance && global.performance.now !== originalPerformanceNow) { // @ts-ignore global.performance.now = originalPerformanceNow; } }); // ... (Initialization and Basic Context tests remain the same) ... describe("Initialization and Context Stack Management", () => { it("should initialize with a base context (null by default)", () => { assert(keyManager instanceof Hotkeys); assert.strictEqual(keyManager.getActiveContext(), null); }); it("should initialize with a given initial context", () => { const manager = new Hotkeys("editor", false); assert.strictEqual(manager.getActiveContext(), "editor"); manager.destroy(); }); it("should allow entering a new context, making it active", () => { assert.strictEqual(keyManager.getActiveContext(), null); keyManager.enterContext("modal"); assert.strictEqual(keyManager.getActiveContext(), "modal"); }); it("should allow leaving a context, restoring the previous one and returning the one that was left", () => { keyManager.enterContext("modal"); keyManager.enterContext("dropdown"); assert.strictEqual(keyManager.getActiveContext(), "dropdown"); const leftDropdown = keyManager.leaveContext(); assert.strictEqual(leftDropdown, "dropdown", `Should return "dropdown"`); assert.strictEqual(keyManager.getActiveContext(), "modal"); const leftModal = keyManager.leaveContext(); assert.strictEqual(leftModal, "modal", `Should return "modal"`); assert.strictEqual(keyManager.getActiveContext(), null); }); it("should not allow leaving the base context and should return undefined", () => { assert.strictEqual(keyManager.getActiveContext(), null); const leftResult1 = keyManager.leaveContext(); // Attempt to leave assert.strictEqual(leftResult1, undefined, "Should return undefined when leaving the base"); assert.strictEqual(keyManager.getActiveContext(), null); keyManager.enterContext("modal"); keyManager.leaveContext(); const leftResult2 = keyManager.leaveContext(); // Second attempt assert.strictEqual(leftResult2, undefined, "Should return undefined on second attempt"); assert.strictEqual(keyManager.getActiveContext(), null); }); it("should log context changes in debug mode", () => { const consoleLogMock = mock.method(console, "log"); const manager = new Hotkeys("base", true); // Debug on consoleLogMock.mock.resetCalls(); // Clear init logs manager.enterContext("modal"); assert.ok(consoleLogMock.mock.calls.some(call => call.arguments[0].includes(`Entering context: "modal"`))); assert.ok(consoleLogMock.mock.calls.some(call => call.arguments[0].includes(`Active context changed to: modal`))); consoleLogMock.mock.resetCalls(); manager.leaveContext(); assert.ok(consoleLogMock.mock.calls.some(call => call.arguments[0].includes(`Leaving context: "modal"`))); assert.ok(consoleLogMock.mock.calls.some(call => call.arguments[0].includes(`Active context changed to: base`))); consoleLogMock.mock.restore(); }); it("should toggle debug mode and log its state", () => { const consoleLogMock = mock.method(console, "log"); keyManager.setDebugMode(true); assert.ok(consoleLogMock.mock.calls.some(call => call.arguments[0].includes("Debug mode enabled"))); consoleLogMock.mock.resetCalls(); keyManager.setDebugMode(false); assert.ok(consoleLogMock.mock.calls.some(call => call.arguments[0].includes("Debug mode disabled"))); consoleLogMock.mock.restore(); }); describe("activeContext$ observable", () => { it("should emit the initial context to a new subscriber", async () => { const manager = new Hotkeys("initial"); const activeContext = await firstValueFrom(manager.onContextChange$); assert.strictEqual(activeContext, "initial"); manager.destroy(); }); it("should emit when the active context changes via enter/leave", () => { const spy = createMockFn(); const sub = keyManager.onContextChange$.subscribe(spy); assert.strictEqual(spy.calledCount, 1, "Did not emit initial context"); assert.strictEqual(spy.lastArgs[0], null); keyManager.enterContext("newContext"); assert.strictEqual(spy.calledCount, 2, "Did not emit on enterContext"); assert.strictEqual(spy.lastArgs[0], "newContext"); keyManager.enterContext("anotherContext"); assert.strictEqual(spy.calledCount, 3, "Did not emit on second enterContext"); assert.strictEqual(spy.lastArgs[0], "anotherContext"); keyManager.leaveContext(); assert.strictEqual(spy.calledCount, 4, "Did not emit on leaveContext"); assert.strictEqual(spy.lastArgs[0], "newContext"); keyManager.leaveContext(); assert.strictEqual(spy.calledCount, 5, "Did not emit on final leaveContext"); assert.strictEqual(spy.lastArgs[0], null); sub.unsubscribe(); }); it("should not emit if the active context does not change (e.g. attempting to leave base context)", () => { const spy = createMockFn(); const sub = keyManager.onContextChange$.subscribe(spy); // Emits initial `null` spy.mockClear(); // Clear initial emission keyManager.leaveContext(); assert.strictEqual(spy.calledCount, 0, "Should not emit when leaving base context"); sub.unsubscribe(); }); }); describe("Deprecated context methods", () => { it("getContext should warn and return active context", () => { keyManager.enterContext("test"); const ctx = keyManager.getContext(); assert.strictEqual(ctx, "test"); assert.ok(consoleWarnMock.mock.calls.some(c => c.arguments[0].includes(`"getContext" is deprecated`))); }); }); }); describe("Context Management (Stack and Override)", () => { describe("Context Stack (enter/leave)", () => { it("should enter and leave contexts on the stack correctly", () => { assert.strictEqual(keyManager.getActiveContext(), null); keyManager.enterContext("modal"); assert.strictEqual(keyManager.getActiveContext(), "modal"); keyManager.enterContext("dropdown"); assert.strictEqual(keyManager.getActiveContext(), "dropdown"); const left1 = keyManager.leaveContext(); assert.strictEqual(left1, "dropdown"); assert.strictEqual(keyManager.getActiveContext(), "modal"); const left2 = keyManager.leaveContext(); assert.strictEqual(left2, "modal"); assert.strictEqual(keyManager.getActiveContext(), null); const left3 = keyManager.leaveContext(); assert.strictEqual(left3, undefined); }); }); describe("Context Override (setContext/restore)", () => { it("setContext should set an override context and return a restore function", () => { keyManager.enterContext("parent"); assert.strictEqual(keyManager.getActiveContext(), "parent"); const restore = keyManager.setContext("override"); assert.strictEqual(keyManager.getActiveContext(), "override"); restore(); assert.strictEqual(keyManager.getActiveContext(), "parent"); }); it("the override context should take precedence over the stack context", () => { keyManager.enterContext("parent"); keyManager.setContext("override"); // Even if we change the stack, the override should remain active keyManager.enterContext("child"); assert.strictEqual(keyManager.getActiveContext(), "override"); keyManager.leaveContext(); // leaves "child" assert.strictEqual(keyManager.getActiveContext(), "override"); }); it("restoring the override should reveal the correct, current stack context", () => { keyManager.enterContext("parent"); const restore = keyManager.setContext("override"); keyManager.enterContext("child"); assert.strictEqual(keyManager.getActiveContext(), "override"); restore(); assert.strictEqual(keyManager.getActiveContext(), "child", `Should reveal "child" which is top of the stack`); }); it("should distinguish between restoring an override and setting an override to null", () => { keyManager.enterContext("parent"); assert.strictEqual(keyManager.getActiveContext(), "parent"); // 1. Set an override to "null". The active context should be null. const restoreNull = keyManager.setContext(null); assert.strictEqual(keyManager.getActiveContext(), null, "Active context should be null due to override."); // Leaving the stack context should have no effect on the active context, since the override is active. keyManager.leaveContext(); // Tries to leave "parent" assert.strictEqual(keyManager.getActiveContext(), null, "Active context should still be null after stack change."); // Restore from the null override. Active context should revert to the top of the stack. restoreNull(); assert.strictEqual(keyManager.getActiveContext(), null, `Active context should now be from the stack, which is "null".`); // 2. Test restore from a non-null override keyManager.enterContext("parent"); // Stack is [null, parent] const restoreEditor = keyManager.setContext("editor"); assert.strictEqual(keyManager.getActiveContext(), "editor"); restoreEditor(); assert.strictEqual(keyManager.getActiveContext(), "parent"); }); }); describe("Active Context Logic and Shortcuts", () => { it("should trigger shortcuts based on the override context", () => { const overrideCb = createMockFn(); keyManager.addCombination({ id: "o", keys: "o", context: "override" }).subscribe(overrideCb); const parentCb = createMockFn(); keyManager.addCombination({ id: "p", keys: "p", context: "parent" }).subscribe(parentCb); keyManager.enterContext("parent"); keyManager.setContext("override"); dispatchKeyEvent(document, "p"); assert.strictEqual(parentCb.calledCount, 0); dispatchKeyEvent(document, "o"); assert.strictEqual(overrideCb.calledCount, 1); }); it("should trigger stack context shortcuts after override is restored", () => { const overrideCb = createMockFn(); keyManager.addCombination({ id: "o", keys: "o", context: "override" }).subscribe(overrideCb); const parentCb = createMockFn(); keyManager.addCombination({ id: "p", keys: "p", context: "parent" }).subscribe(parentCb); keyManager.enterContext("parent"); const restore = keyManager.setContext("override"); dispatchKeyEvent(document, "p"); assert.strictEqual(parentCb.calledCount, 0); restore(); // Restore context back to "parent" dispatchKeyEvent(document, "p"); assert.strictEqual(parentCb.calledCount, 1); dispatchKeyEvent(document, "o"); assert.strictEqual(overrideCb.calledCount, 0); }); }); }); describe("Contextual Shortcut Triggering", () => { it("should only trigger shortcut in its active context", () => { keyManager.addCombination({ id: "test", keys: "a", context: "editor" }).subscribe(mockCallback); // 1. Wrong context keyManager.enterContext("modal"); dispatchKeyEvent(document, "a"); assert.strictEqual(mockCallback.calledCount, 0, `Should not trigger in "modal" context`); // 2. No context (should not trigger) keyManager.leaveContext(); // back to "null" dispatchKeyEvent(document, "a"); assert.strictEqual(mockCallback.calledCount, 0, `Should not trigger in "null" context`); // 3. Correct context keyManager.enterContext("editor"); dispatchKeyEvent(document, "a"); assert.strictEqual(mockCallback.calledCount, 1, `Should trigger in "editor" context`); }); it("global shortcut should trigger if no specific context is active", () => { keyManager.addCombination({ id: "global", keys: "g" }).subscribe(mockCallback); dispatchKeyEvent(document, "g"); assert.strictEqual(mockCallback.calledCount, 1, `Global shortcut should trigger in "null" context`); }); it("global shortcut should trigger if a different specific context is active", () => { keyManager.addCombination({ id: "global", keys: "g" }).subscribe(mockCallback); keyManager.enterContext("editor"); dispatchKeyEvent(document, "g"); assert.strictEqual(mockCallback.calledCount, 1, "Global shortcut should trigger in any active context"); }); }); describe("addCombination", () => { it(`should return an Observable that emits on a simple key combination (e.g., "A")`, () => { const config = { id: "simpleA", keys: { key: Keys.A } }; const combo$ = keyManager.addCombination(config); assert(combo$ instanceof Observable, "Did not return an Observable"); combo$.subscribe(mockCallback); dispatchKeyEvent(document, "a"); assert.strictEqual(mockCallback.calledCount, 1, `Callback for "a" not called`); mockCallback.mockClear(); dispatchKeyEvent(document, "A"); assert.strictEqual(mockCallback.calledCount, 1, `Callback for "A" not called`); }); it("should return an empty observable and warn if keys.key is null or undefined", () => { const config = { id: "nullKey", keys: { key: null } }; const combo$ = keyManager.addCombination(config); combo$.subscribe(mockCallback); assert.strictEqual(consoleWarnMock.mock.calls.length, 1); assert.ok(consoleWarnMock.mock.calls[0].arguments[0].includes(`Invalid "key" property in shortcut "nullKey"`)); dispatchKeyEvent(document, "a"); assert.strictEqual(mockCallback.calledCount, 0); }); it("should pass the KeyboardEvent to the subscriber", () => { const config = { id: "eventPass", keys: { key: Keys.E } }; keyManager.addCombination(config).subscribe(mockCallback); const event = dispatchKeyEvent(document, "e"); assert.strictEqual(mockCallback.calledCount, 1); assert.deepStrictEqual(mockCallback.lastArgs, [event]); }); it("should emit for a combination with Ctrl key", () => { const config = { id: "ctrlS", keys: { key: Keys.S, ctrlKey: true } }; keyManager.addCombination(config).subscribe(mockCallback); dispatchKeyEvent(document, "s", "keydown", { ctrlKey: true }); assert.strictEqual(mockCallback.calledCount, 1); }); it("should NOT emit if specified modifier key (ctrlKey: false) is false and event has it true", () => { const config = { id: "noCtrlA", keys: { key: Keys.A, ctrlKey: false } }; keyManager.addCombination(config).subscribe(mockCallback); dispatchKeyEvent(document, "a", "keydown", { ctrlKey: true }); assert.strictEqual(mockCallback.calledCount, 0); dispatchKeyEvent(document, "a", "keydown", { ctrlKey: false }); assert.strictEqual(mockCallback.calledCount, 1); }); it("should emit for special keys like Escape (object form)", () => { const config = { id: "escapeKeyObj", keys: { key: Keys.Escape } }; keyManager.addCombination(config).subscribe(mockCallback); dispatchKeyEvent(document, "Escape"); // Event key matches Keys.Escape assert.strictEqual(mockCallback.calledCount, 1); }); it("should handle preventDefault correctly (object form)", () => { const config = { id: "preventAObj", keys: { key: Keys.A }, preventDefault: true }; keyManager.addCombination(config).subscribe(mockCallback); const event = dispatchKeyEvent(document, "a"); assert.strictEqual(mockCallback.calledCount, 1); assert.strictEqual(event.defaultPrevented, true); }); it("should overwrite an existing combination, warn, and terminate the old stream", () => { const firstCallback = createMockFn(); const firstComplete = createMockFn(); const secondCallback = createMockFn(); const first$ = keyManager.addCombination({ id: "combo1", keys: { key: Keys.K } }); first$.subscribe({ next: firstCallback, complete: firstComplete }); consoleWarnMock.mock.resetCalls(); const second$ = keyManager.addCombination({ id: "combo1", keys: { key: Keys.K, ctrlKey: true } }); second$.subscribe(secondCallback); assert.strictEqual(consoleWarnMock.mock.calls.length, 1); assert.ok(consoleWarnMock.mock.calls[0].arguments[0].includes(`Shortcut with ID "combo1" already exists`)); assert.strictEqual(firstComplete.calledCount, 1, "First observable should have completed"); dispatchKeyEvent(document, "k"); assert.strictEqual(firstCallback.calledCount, 0); dispatchKeyEvent(document, "k", "keydown", { ctrlKey: true }); assert.strictEqual(secondCallback.calledCount, 1); }); it("should not affect other shortcuts if one subscription throws an error", () => { const workingCallback = createMockFn(); const erroringCallback = () => { throw new Error("Test callback error"); }; const error$ = keyManager.addCombination({ id: "errorCombo", keys: { key: Keys.E } }); // Suppress unhandled exception message in test runner output error$.subscribe(erroringCallback, () => { }); const working$ = keyManager.addCombination({ id: "workingCombo", keys: { key: Keys.W } }); working$.subscribe(workingCallback); // Dispatch event that causes an error in the subscription //dispatchKeyEvent(document, "e"); // Dispatch another event to ensure the other shortcut still works dispatchKeyEvent(document, "w"); assert.strictEqual(workingCallback.calledCount, 1, "Working callback should have been called"); }); it("should emit if any of multiple key combinations are pressed", () => { const config = { id: "multiCombo", keys: [ { key: Keys.A, ctrlKey: true }, // Ctrl+A Keys.Escape, // Escape { key: Keys.B, shiftKey: true, altKey: true } // Shift+Alt+B ], }; keyManager.addCombination(config).subscribe(mockCallback); dispatchKeyEvent(document, "A", "keydown", { ctrlKey: true }); assert.strictEqual(mockCallback.calledCount, 1, "Ctrl+A did not trigger"); dispatchKeyEvent(document, "Escape"); assert.strictEqual(mockCallback.calledCount, 2, "Escape did not trigger"); dispatchKeyEvent(document, "B", "keydown", { shiftKey: true, altKey: true }); assert.strictEqual(mockCallback.calledCount, 3, "Shift+Alt+B did not trigger"); dispatchKeyEvent(document, "C"); // Should not trigger assert.strictEqual(mockCallback.calledCount, 3, "Unrelated key C triggered"); }); it("should support a mixed array of triggers including combination strings", () => { const config = { id: "mixedTriggerCombo", keys: [ "ctrl+s", // 1. Combination String Keys.F1, // 2. StandardKey { key: Keys.X, altKey: true } // 3. KeyCombinationTrigger Object ] }; keyManager.addCombination(config).subscribe(mockCallback); // Test trigger 1: Combination String dispatchKeyEvent(document, "s", "keydown", { ctrlKey: true }); assert.strictEqual(mockCallback.calledCount, 1, `Trigger "ctrl+s" (string) failed`); // Test trigger 2: StandardKey dispatchKeyEvent(document, "F1"); assert.strictEqual(mockCallback.calledCount, 2, `Trigger "F1" (StandardKey) failed`); // Test trigger 3: KeyCombinationTrigger Object dispatchKeyEvent(document, "x", "keydown", { altKey: true }); assert.strictEqual(mockCallback.calledCount, 3, `Trigger "{ key: X, altKey: true }" (Object) failed`); // Test a non-matching key dispatchKeyEvent(document, "y"); assert.strictEqual(mockCallback.calledCount, 3, "An unrelated key triggered the callback unexpectedly"); }); it(`should return an empty observable and warn if "keys" array is empty`, () => { const config = { id: "emptyKeysArray", keys: [] }; const combo$ = keyManager.addCombination(config); combo$.subscribe(mockCallback); assert.ok(consoleErrorMock.mock.calls.some(call => call.arguments[0].includes(`"keys" definition for combination shortcut "emptyKeysArray" is empty`))); dispatchKeyEvent(document, "a"); assert.strictEqual(mockCallback.calledCount, 0); }); it(`should return an empty observable and warn if a key in "keys" array is invalid (shorthand)`, () => { const config = { id: "invalidInArrayShorthand", keys: [Keys.A, ""] }; const combo$ = keyManager.addCombination(config); combo$.subscribe(mockCallback); assert.ok(consoleWarnMock.mock.calls.some(call => call.arguments[0].includes(`Could not parse key: "" in shortcut "invalidInArrayShorthand"`))); }); it(`should return an empty observable and warn if a key in "keys" array is invalid (object)`, () => { const config = { id: "invalidInArrayObject", keys: [Keys.A, { key: "" }] }; const combo$ = keyManager.addCombination(config); combo$.subscribe(mockCallback); assert.ok(consoleWarnMock.mock.calls.some(call => call.arguments[0].includes(`Invalid "key" property in shortcut "invalidInArrayObject"`))); }); it("should correctly log multiple key triggers in debug mode", () => { const consoleLogMock = mock.method(console, "log"); keyManager.setDebugMode(true); keyManager.addCombination({ id: "multiLog", keys: [Keys.F1, { key: Keys.F2, ctrlKey: true }] }); assert.ok(consoleLogMock.mock.calls.some(call => call.arguments[0].includes(`Triggers: [ { key: "F1" (no mods) }, { key: "F2", ctrl: true } ]`)), "Multi-trigger log format incorrect"); consoleLogMock.mock.restore(); }); describe("addCombination - Shorthand Syntax", () => { it("should emit for a simple key using shorthand (e.g., Keys.X)", () => { const config = { id: "shorthandX", keys: Keys.X }; keyManager.addCombination(config).subscribe(mockCallback); dispatchKeyEvent(document, Keys.X.toLowerCase()); assert.strictEqual(mockCallback.calledCount, 1, `Callback for "x" (shorthand) not called`); mockCallback.mockClear(); dispatchKeyEvent(document, Keys.X); assert.strictEqual(mockCallback.calledCount, 1, `Callback for "X" (shorthand) not called`); }); it("should NOT emit for shorthand if modifier is pressed", () => { const config = { id: "shorthandY", keys: Keys.Y }; keyManager.addCombination(config).subscribe(mockCallback); dispatchKeyEvent(document, Keys.Y, "keydown", { ctrlKey: true }); assert.strictEqual(mockCallback.calledCount, 0, `Callback for "y" (shorthand) should not be called with Ctrl`); mockCallback.mockClear(); dispatchKeyEvent(document, Keys.Y, "keydown", { altKey: true }); assert.strictEqual(mockCallback.calledCount, 0, `Callback for "y" (shorthand) should not be called with Alt`); mockCallback.mockClear(); dispatchKeyEvent(document, Keys.Y, "keydown", { shiftKey: true }); assert.strictEqual(mockCallback.calledCount, 0, `Callback for "y" (shorthand) should not be called with Shift`); mockCallback.mockClear(); dispatchKeyEvent(document, Keys.Y, "keydown", { metaKey: true }); assert.strictEqual(mockCallback.calledCount, 0, `Callback for "y" (shorthand) should not be called with Meta`); }); it("should emit for shorthand if ONLY the key is pressed (no modifiers)", () => { const config = { id: "shorthandZ", keys: Keys.Z }; keyManager.addCombination(config).subscribe(mockCallback); dispatchKeyEvent(document, Keys.Z, "keydown", { ctrlKey: false, altKey: false, shiftKey: false, metaKey: false }); assert.strictEqual(mockCallback.calledCount, 1); }); it("should handle preventDefault correctly for shorthand", () => { const config = { id: "shorthandPrevent", keys: Keys.P, preventDefault: true }; keyManager.addCombination(config).subscribe(mockCallback); const event = dispatchKeyEvent(document, Keys.P.toLowerCase()); assert.strictEqual(mockCallback.calledCount, 1); assert.strictEqual(event.defaultPrevented, true); }); it("should respect context for shorthand", () => { const config = { id: "shorthandContext", keys: Keys.C, context: "editor" }; keyManager.addCombination(config).subscribe(mockCallback); keyManager.setContext("other"); dispatchKeyEvent(document, Keys.C.toLowerCase()); assert.strictEqual(mockCallback.calledCount, 0); keyManager.setContext("editor"); dispatchKeyEvent(document, Keys.C.toLowerCase()); assert.strictEqual(mockCallback.calledCount, 1); }); it("should return an empty observable and warn if shorthand key is an empty string", () => { const config = { id: "emptyShorthand", keys: "" }; const combo$ = keyManager.addCombination(config); combo$.subscribe(mockCallback); assert.strictEqual(consoleWarnMock.mock.calls.length, 1); assert.ok(consoleWarnMock.mock.calls[0].arguments[0].includes(`Could not parse key: "" in shortcut "emptyShorthand"`)); }); it("should return an empty observable and warn if shorthand key is an empty array", () => { const config = { id: "emptyArrayShorthand", keys: [] }; const combo$ = keyManager.addCombination(config); combo$.subscribe(mockCallback); assert.strictEqual(consoleErrorMock.mock.calls.length, 1); assert.ok(consoleErrorMock.mock.calls[0].arguments[0].includes(`"keys" definition for combination shortcut "emptyArrayShorthand" is empty or invalid. Shortcut not added.`)); }); it("should return an empty observable and warn if shorthand key is an empty string in array", () => { const config = { id: "emptyStringShorthand", keys: [""] }; const combo$ = keyManager.addCombination(config); combo$.subscribe(mockCallback); assert.strictEqual(consoleWarnMock.mock.calls.length, 1); assert.ok(consoleWarnMock.mock.calls[0].arguments[0].includes(`Could not parse key: "" in shortcut "emptyStringShorthand"`)); }); it("should return an empty observable and warn if shorthand key is invalid", () => { const config = { id: "invalidKeyPropertyShorthand", keys: { key: "" } }; const combo$ = keyManager.addCombination(config); combo$.subscribe(mockCallback); assert.strictEqual(consoleWarnMock.mock.calls.length, 1); assert.ok(consoleWarnMock.mock.calls[0].arguments[0].includes(`Invalid "key" property in shortcut "invalidKeyPropertyShorthand". Key must be a non-empty string value from Keys.`)); }); it("should correctly log shorthand key details in debug mode", () => { const consoleLogMock = mock.method(console, "log"); keyManager.setDebugMode(true); const config = { id: "debugShorthand", keys: Keys.D }; keyManager.addCombination(config); const logMessage = consoleLogMock.mock.calls.find(call => call.arguments[0].includes(`combination shortcut "debugShorthand" added.`)); assert.ok(logMessage, "Debug log for adding shortcut not found"); assert.ok(logMessage.arguments[0].includes(`{ key: "D" (no mods) }`), `Log message content mismatch: ${logMessage.arguments[0]}`); consoleLogMock.mock.restore(); keyManager.setDebugMode(false); }); it("should emit for Keys.Space using shorthand", () => { const config = { id: "spaceShorthand", keys: Keys.Space }; keyManager.addCombination(config).subscribe(mockCallback); dispatchKeyEvent(document, " "); // Event key for space is " " assert.strictEqual(mockCallback.calledCount, 1, "Callback for Keys.Space (shorthand) not called"); }); it("should emit for Keys.Space using object form", () => { const config = { id: "spaceObject", keys: { key: Keys.Space } }; keyManager.addCombination(config).subscribe(mockCallback); dispatchKeyEvent(document, " "); assert.strictEqual(mockCallback.calledCount, 1, "Callback for Keys.Space (object form) not called"); }); }); }); describe("String-based Definitions", () => { it(`should parse and trigger a simple string "ctrl+s"`, () => { keyManager.addCombination({ id: "strSave", keys: "ctrl+s" }).subscribe(mockCallback); dispatchKeyEvent(document, "s", "keydown", { ctrlKey: true }); assert.strictEqual(mockCallback.calledCount, 1); dispatchKeyEvent(document, "s"); assert.strictEqual(mockCallback.calledCount, 1); }); it(`should parse and trigger "shift+alt+k"`, () => { keyManager.addCombination({ id: "strComplex", keys: "shift+alt+k" }).subscribe(mockCallback); dispatchKeyEvent(document, "k", "keydown", { shiftKey: true, altKey: true }); assert.strictEqual(mockCallback.calledCount, 1); }); it(`should parse aliases like "cmd+p"`, () => { keyManager.addCombination({ id: "strAlias", keys: "cmd+p" }).subscribe(mockCallback); dispatchKeyEvent(document, "p", "keydown", { metaKey: true }); assert.strictEqual(mockCallback.calledCount, 1); }); it(`should parse special key strings like "escape"`, () => { keyManager.addCombination({ id: "strSpecial", keys: "escape" }).subscribe(mockCallback); dispatchKeyEvent(document, "Escape"); assert.strictEqual(mockCallback.calledCount, 1); }); it(`should parse sequence string "g -> i"`, () => { keyManager.addSequence({ id: "strSeq", sequence: "g -> i" }).subscribe(mockCallback); dispatchKeyEvent(document, "g"); dispatchKeyEvent(document, "i"); assert.strictEqual(mockCallback.calledCount, 1); }); it(`should parse sequence string with special keys "up -> down -> enter"`, () => { keyManager.addSequence({ id: "strSeqSpecial", sequence: "up -> down -> enter" }).subscribe(mockCallback); dispatchKeyEvent(document, "ArrowUp"); dispatchKeyEvent(document, "ArrowDown"); dispatchKeyEvent(document, "Enter"); assert.strictEqual(mockCallback.calledCount, 1); }); it("should warn and return empty on invalid string", () => { const combo$ = keyManager.addCombination({ id: "invalid", keys: "ctrl+badkey" }); combo$.subscribe(mockCallback); assert.ok(consoleWarnMock.mock.calls.some(c => c.arguments[0].includes(`Could not parse key: "badkey"`))); assert.strictEqual(mockCallback.calledCount, 0); }); }); describe("Normalization and Edge Case Tests", () => { it(`should handle the "+" key as a shorthand, not a combination`, () => { keyManager.addCombination({ id: "plusKey", keys: Keys.KeypadAdd }).subscribe(mockCallback); dispatchKeyEvent(document, "+"); assert.strictEqual(mockCallback.calledCount, 1, `Callback for "+" key not called`); }); it(`should handle "escape" (lowercase) and normalize it to match the "Escape" event key`, () => { keyManager.addCombination({ id: "lowerEscape", keys: "escape" }).subscribe(mockCallback); dispatchKeyEvent(document, "Escape"); // Event key from browser is capitalized assert.strictEqual(mockCallback.calledCount, 1, `Lowercase "escape" did not match event`); }); it(`should handle "ESC" (uppercase) and normalize it`, () => { keyManager.addCombination({ id: "upperEsc", keys: "ESC" }).subscribe(mockCallback); dispatchKeyEvent(document, "Escape"); assert.strictEqual(mockCallback.calledCount, 1, `Uppercase "ESC" did not match event`); }); it(`should handle a single-word modifier alias like "cmd" as a shorthand for the "Meta" key`, () => { keyManager.addCombination({ id: "cmdKey", keys: "cmd" }).subscribe(mockCallback); dispatchKeyEvent(document, "Meta"); // Event key for command key is "Meta" assert.strictEqual(mockCallback.calledCount, 1, `Alias "cmd" did not match "Meta" key event`); }); it(`should handle combination string with extra spaces like " ctrl + s "`, () => { keyManager.addCombination({ id: "paddedCombo", keys: " ctrl + s " }).subscribe(mockCallback); dispatchKeyEvent(document, "s", "keydown", { ctrlKey: true }); assert.strictEqual(mockCallback.calledCount, 1, "Padded combination string failed to parse"); }); }); describe("New Feature: Element-Scoped Listeners", () => { let el1, el2; beforeEach(() => { el1 = document.createElement("div"); el2 = document.createElement("div"); document.body.append(el1, el2); }); afterEach(() => { el1.remove(); el2.remove(); }); it("should only trigger shortcut on the specified target element", () => { keyManager.addCombination({ id: "scoped", keys: "a", target: el1 }).subscribe(mockCallback); dispatchKeyEvent(el1, "a"); assert.strictEqual(mockCallback.calledCount, 1, "Should trigger on target element"); dispatchKeyEvent(el2, "a"); assert.strictEqual(mockCallback.calledCount, 1, "Should NOT trigger on another element"); dispatchKeyEvent(document, "a"); assert.strictEqual(mockCallback.calledCount, 1, "Should NOT trigger on document"); }); it("should trigger global shortcut if no target is specified", () => { keyManager.addCombination({ id: "global", keys: "b" }).subscribe(mockCallback); // Event bubbles up from el1 to document dispatchKeyEvent(el1, "b"); assert.strictEqual(mockCallback.calledCount, 1, "Should trigger on event from child element"); dispatchKeyEvent(document, "b"); assert.strictEqual(mockCallback.calledCount, 2, "Should trigger on document directly"); }); it("should work for sequences on a specific target", () => { keyManager.addSequence({ id: "scopedSeq", sequence: "a -> b", target: el1 }).subscribe(mockCallback); dispatchKeyEvent(el1, "a"); dispatchKeyEvent(el1, "b"); assert.strictEqual(mockCallback.calledCount, 1, "Sequence should trigger on target"); dispatchKeyEvent(document, "a"); dispatchKeyEvent(document, "b"); assert.strictEqual(mockCallback.calledCount, 1, "Sequence should not trigger on document"); }); }); describe("New Feature: keyup Event Support", () => { it("should trigger combination on keyup when specified", () => { keyManager.addCombination({ id: "keyupCombo", keys: "a", event: "keyup" }).subscribe(mockCallback); dispatchKeyEvent(document, "a", "keydown"); assert.strictEqual(mockCallback.calledCount, 0, "Should not trigger on keydown"); dispatchKeyEvent(document, "a", "keyup"); assert.strictEqual(mockCallback.calledCount, 1, "Should trigger on keyup"); }); it("should trigger sequence on keyup when specified", () => { keyManager.addSequence({ id: "keyupSeq", sequence: "a -> b", event: "keyup" }).subscribe(mockCallback); dispatchKeyEvent(document, "a", "keydown"); dispatchKeyEvent(document, "b", "keydown"); assert.strictEqual(mockCallback.calledCount, 0, "Sequence should not trigger on keydown events"); dispatchKeyEvent(document, "a", "keyup"); dispatchKeyEvent(document, "b", "keyup"); assert.strictEqual(mockCallback.calledCount, 1, "Sequence should trigger on keyup events"); }); it("should default to keydown if event type is not specified", () => { keyManager.addCombination({ id: "keydownDefault", keys: "c" }).subscribe(mockCallback); dispatchKeyEvent(document, "c", "keyup"); assert.strictEqual(mockCallback.calledCount, 0); dispatchKeyEvent(document, "c", "keydown"); assert.strictEqual(mockCallback.calledCount, 1); }); }); describe("Context Priority (Specific > Global)", () => { let globalCallback; let specificCallback; beforeEach(() => { globalCallback = createMockFn(); specificCallback = createMockFn(); // Global shortcut: Ctrl+G keyManager.addCombination({ id: "globalCtrlG", keys: { key: Keys.G, ctrlKey: true }, context: null // Explicitly global }).subscribe(globalCallback); // Specific context shortcut: Ctrl+G in "editor" context keyManager.addCombination({ id: "editorCtrlG", keys: { key: Keys.G, ctrlKey: true }, context: "editor" }).subscribe(specificCallback); }); it("should only trigger specific context callback when specific context is active", () => { keyManager.enterContext("editor"); dispatchKeyEvent(document, Keys.G, "keydown", { ctrlKey: true }); assert.strictEqual(specificCallback.calledCount, 1, "Specific callback should have been called"); assert.strictEqual(globalCallback.calledCount, 0, "Global callback should NOT have been called"); }); it("should only trigger global callback when no specific context is active", () => { // Initial context is null dispatchKeyEvent(document, Keys.G, "keydown", { ctrlKey: true }); assert.strictEqual(specificCallback.calledCount, 0, "Specific callback should NOT have been called"); assert.strictEqual(globalCallback.calledCount, 1, "Global callback should have been called"); }); it("should trigger global callback when a non-matching specific context is active", () => { globalCallback.mockClear(); keyManager.enterContext("anotherContext"); // Different specific context dispatchKeyEvent(document, Keys.G, "keydown", { ctrlKey: true }); assert.strictEqual(specificCallback.calledCount, 0, `Specific callback should NOT have been called for "anotherContext"`); assert.strictEqual(globalCallback.calledCount, 1, `Global callback should have been called when in "anotherContext"`); }); }); describe("Sequence Context Priority (Specific > Global)", () => { let globalSeqCallback; let specificSeqCallback; const testSequence = [Keys.G, Keys.I]; beforeEach(() => { globalSeqCallback = createMockFn(); specificSeqCallback = createMockFn(); keyManager.addSequence({ id: "globalGI", sequence: testSequence, context: null // Global }).subscribe(globalSeqCallback); keyManager.addSequence({ id: "editorGI", sequence: testSequence, context: "editor" // Specific }).subscribe(specificSeqCallback); }); it("should only trigger specific context sequence callback when specific context is active", () => { keyManager.setContext("editor"); testSequence.forEach(key => dispatchKeyEvent(document, key)); assert.strictEqual(specificSeqCallback.calledCount, 1, "Specific sequence callback should have been called"); assert.strictEqual(globalSeqCallback.calledCount, 0, "Global sequence callback should NOT have been called"); }); it("should only trigger global sequence callback when no specific context is active (or context doesn't match)", () => { keyManager.setContext(null); // No specific context testSequence.forEach(key => dispatchKeyEvent(document, key)); assert.strictEqual(specificSeqCallback.calledCount, 0, "Specific sequence callback should NOT have been called"); assert.strictEqual(globalSeqCallback.calledCount, 1, "Global sequence callback should have been called"); globalSeqCallback.mockClear(); specificSeqCallback.mockClear(); // Clear for next part of test keyManager.setContext("anotherContext"); // Different specific context testSequence.forEach(key => dispatchKeyEvent(document, key)); assert.strictEqual(specificSeqCallback.calledCount, 0, `Specific sequence callback should NOT have been called for "anotherContext"`); assert.strictEqual(globalSeqCallback.calledCount, 1, `Global sequence callback should have been called when in "anotherContext"`); }); }); describe("Global Shortcut Context Behavior (`strict` flag)", () => { let strictGlobalCallback; let defaultGlobalCallback; let specificContextCallback; beforeEach(() => { strictGlobalCallback = createMockFn(); defaultGlobalCallback = createMockFn(); specificContextCallback = createMockFn(); // 1. A specific shortcut for the "editor" context keyManager.addCombination({ id: "editorSave", keys: { key: Keys.S, ctrlKey: true }, context: "editor" }).subscribe(specificContextCallback); // 2. A "strictly global" shortcut, which only runs when context is null keyManager.addCombination({ id: "strictGlobalOpen", keys: { key: Keys.O, ctrlKey: true }, strict: true, }).subscribe(strictGlobalCallback); // 3. A default global shortcut, which runs in any context unless overridden keyManager.addCombination({ id: "defaultGlobalSave", keys: { key: Keys.S, ctrlKey: true }, }).subscribe(defaultGlobalCallback); }); it("should trigger both strict and default global shortcuts when context is null", () => { keyManager.setContext(null); dispatchKeyEvent(document, Keys.O, "keydown", { ctrlKey: true }); assert.strictEqual(strictGlobalCallback.calledCount, 1, "Strictly global (Ctrl+O) should fire"); dispatchKeyEvent(document, Keys.S, "keydown", { ctrlKey: true }); assert.strictEqual(defaultGlobalCallback.calledCount, 1, "Default global (Ctrl+S) should fire"); assert.strictEqual(specificContextCallback.calledCount, 0, "Specific context callback should not fire"); }); it("should suppress strict global but allow default global (which is then suppressed by priority)", () => { keyManager.setContext("editor"); dispatchKeyEvent(document, Keys.O, "keydown", { ctrlKey: true }); assert.strictEqual(strictGlobalCallback.calledCount, 0, `Strictly global (Ctrl+O) should NOT fire in "editor" context`); dispatchKeyEvent(document, Keys.S, "keydown", { ctrlKey: true }); assert.strictEqual(defaultGlobalCallback.calledCount, 0, "Default global (Ctrl+S) should be suppressed by the specific one"); assert.strictEqual(specificContextCallback.calledCount, 1, `Specific "editor" callback (Ctrl+S) should fire and take priority`); }); it("should suppress strict global but trigger default global in a non-conflicting context", () => { keyManager.setContext("someOtherContext"); dispatchKeyEvent(document, Keys.O, "keydown", { ctrlKey: true }); assert.strictEqual(strictGlobalCallback.calledCount, 0, `Strictly global (Ctrl+O) should NOT fire in "someOtherContext"`); dispatchKeyEvent(document, Keys.S, "keydown", { ctrlKey: true