trilium/apps/client/src/services/shortcuts.spec.ts

356 lines
14 KiB
TypeScript

import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import shortcuts, { keyMatches, matchesShortcut, isIMEComposing } from "./shortcuts.js";
// Mock utils module
vi.mock("./utils.js", () => ({
default: {
isDesktop: () => true
}
}));
// Mock jQuery globally since it's used in the shortcuts module
const mockElement = {
addEventListener: vi.fn(),
removeEventListener: vi.fn()
};
const mockJQuery = vi.fn(() => [mockElement]);
(mockJQuery as any).length = 1;
mockJQuery[0] = mockElement;
(global as any).$ = mockJQuery as any;
global.document = mockElement as any;
describe("shortcuts", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
// Clean up any active bindings after each test
shortcuts.removeGlobalShortcut("test-namespace");
});
describe("normalizeShortcut", () => {
it("should normalize shortcut to lowercase and remove whitespace", () => {
expect(shortcuts.normalizeShortcut("Ctrl + A")).toBe("ctrl+a");
expect(shortcuts.normalizeShortcut(" SHIFT + F1 ")).toBe("shift+f1");
expect(shortcuts.normalizeShortcut("Alt+Space")).toBe("alt+space");
});
it("should handle empty or null shortcuts", () => {
expect(shortcuts.normalizeShortcut("")).toBe("");
expect(shortcuts.normalizeShortcut(null as any)).toBe(null);
expect(shortcuts.normalizeShortcut(undefined as any)).toBe(undefined);
});
it("should handle shortcuts with multiple spaces", () => {
expect(shortcuts.normalizeShortcut("Ctrl + Shift + A")).toBe("ctrl+shift+a");
});
it("should warn about malformed shortcuts", () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
shortcuts.normalizeShortcut("ctrl+");
shortcuts.normalizeShortcut("+a");
shortcuts.normalizeShortcut("ctrl++a");
expect(consoleSpy).toHaveBeenCalledTimes(3);
consoleSpy.mockRestore();
});
});
describe("keyMatches", () => {
const createKeyboardEvent = (key: string, code?: string) => ({
key,
code: code || `Key${key.toUpperCase()}`
} as KeyboardEvent);
it("should match regular letter keys using key code", () => {
const event = createKeyboardEvent("a", "KeyA");
expect(keyMatches(event, "a")).toBe(true);
expect(keyMatches(event, "A")).toBe(true);
});
it("should match number keys using digit codes", () => {
const event = createKeyboardEvent("1", "Digit1");
expect(keyMatches(event, "1")).toBe(true);
});
it("should match special keys using key mapping", () => {
expect(keyMatches({ key: "Enter" } as KeyboardEvent, "return")).toBe(true);
expect(keyMatches({ key: "Enter" } as KeyboardEvent, "enter")).toBe(true);
expect(keyMatches({ key: "Delete" } as KeyboardEvent, "del")).toBe(true);
expect(keyMatches({ key: "Escape" } as KeyboardEvent, "esc")).toBe(true);
expect(keyMatches({ key: " " } as KeyboardEvent, "space")).toBe(true);
expect(keyMatches({ key: "ArrowUp" } as KeyboardEvent, "up")).toBe(true);
});
it("should match function keys", () => {
expect(keyMatches({ key: "F1" } as KeyboardEvent, "f1")).toBe(true);
expect(keyMatches({ key: "F12" } as KeyboardEvent, "f12")).toBe(true);
});
it("should handle undefined or null keys", () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
expect(keyMatches({} as KeyboardEvent, null as any)).toBe(false);
expect(keyMatches({} as KeyboardEvent, undefined as any)).toBe(false);
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
});
describe("matchesShortcut", () => {
const createKeyboardEvent = (options: {
key: string;
code?: string;
ctrlKey?: boolean;
altKey?: boolean;
shiftKey?: boolean;
metaKey?: boolean;
}) => ({
key: options.key,
code: options.code || `Key${options.key.toUpperCase()}`,
ctrlKey: options.ctrlKey || false,
altKey: options.altKey || false,
shiftKey: options.shiftKey || false,
metaKey: options.metaKey || false
} as KeyboardEvent);
it("should match shortcuts with modifiers", () => {
const event = createKeyboardEvent({ key: "a", code: "KeyA", ctrlKey: true });
expect(matchesShortcut(event, "ctrl+a")).toBe(true);
const shiftEvent = createKeyboardEvent({ key: "a", code: "KeyA", shiftKey: true });
expect(matchesShortcut(shiftEvent, "shift+a")).toBe(true);
});
it("should match complex modifier combinations", () => {
const event = createKeyboardEvent({
key: "a",
code: "KeyA",
ctrlKey: true,
shiftKey: true
});
expect(matchesShortcut(event, "ctrl+shift+a")).toBe(true);
});
it("should not match when modifiers don't match", () => {
const event = createKeyboardEvent({ key: "a", code: "KeyA", ctrlKey: true });
expect(matchesShortcut(event, "alt+a")).toBe(false);
expect(matchesShortcut(event, "a")).toBe(false);
});
it("should not match when no modifiers are used", () => {
const event = createKeyboardEvent({ key: "a", code: "KeyA" });
expect(matchesShortcut(event, "a")).toBe(false);
});
it("should handle alternative modifier names", () => {
const ctrlEvent = createKeyboardEvent({ key: "a", code: "KeyA", ctrlKey: true });
expect(matchesShortcut(ctrlEvent, "control+a")).toBe(true);
const metaEvent = createKeyboardEvent({ key: "a", code: "KeyA", metaKey: true });
expect(matchesShortcut(metaEvent, "cmd+a")).toBe(true);
expect(matchesShortcut(metaEvent, "command+a")).toBe(true);
});
it("should handle empty or invalid shortcuts", () => {
const event = createKeyboardEvent({ key: "a", code: "KeyA" });
expect(matchesShortcut(event, "")).toBe(false);
expect(matchesShortcut(event, null as any)).toBe(false);
});
it("should handle invalid events", () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
expect(matchesShortcut(null as any, "a")).toBe(false);
expect(matchesShortcut({} as KeyboardEvent, "a")).toBe(false);
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
it("should warn about invalid shortcut formats", () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const event = createKeyboardEvent({ key: "a", code: "KeyA" });
matchesShortcut(event, "ctrl+");
matchesShortcut(event, "+");
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
});
describe("bindGlobalShortcut", () => {
it("should bind a global shortcut", () => {
const handler = vi.fn();
shortcuts.bindGlobalShortcut("ctrl+a", handler, "test-namespace");
expect(mockElement.addEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
});
it("should not bind shortcuts when handler is null", () => {
shortcuts.bindGlobalShortcut("ctrl+a", null, "test-namespace");
expect(mockElement.addEventListener).not.toHaveBeenCalled();
});
it("should remove previous bindings when namespace is reused", () => {
const handler1 = vi.fn();
const handler2 = vi.fn();
shortcuts.bindGlobalShortcut("ctrl+a", handler1, "test-namespace");
expect(mockElement.addEventListener).toHaveBeenCalledTimes(1);
shortcuts.bindGlobalShortcut("ctrl+b", handler2, "test-namespace");
expect(mockElement.removeEventListener).toHaveBeenCalledTimes(1);
expect(mockElement.addEventListener).toHaveBeenCalledTimes(2);
});
});
describe("bindElShortcut", () => {
it("should bind shortcut to specific element", () => {
const mockEl = { addEventListener: vi.fn(), removeEventListener: vi.fn() };
const mockJQueryEl = [mockEl] as any;
mockJQueryEl.length = 1;
const handler = vi.fn();
shortcuts.bindElShortcut(mockJQueryEl, "ctrl+a", handler, "test-namespace");
expect(mockEl.addEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
});
it("should fall back to document when element is empty", () => {
const emptyJQuery = [] as any;
emptyJQuery.length = 0;
const handler = vi.fn();
shortcuts.bindElShortcut(emptyJQuery, "ctrl+a", handler, "test-namespace");
expect(mockElement.addEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
});
});
describe("removeGlobalShortcut", () => {
it("should remove shortcuts for a specific namespace", () => {
const handler = vi.fn();
shortcuts.bindGlobalShortcut("ctrl+a", handler, "test-namespace");
shortcuts.removeGlobalShortcut("test-namespace");
expect(mockElement.removeEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
});
});
describe("event handling", () => {
it.skip("should call handler when shortcut matches", () => {
const handler = vi.fn();
shortcuts.bindGlobalShortcut("ctrl+a", handler, "test-namespace");
// Get the listener that was registered
expect(mockElement.addEventListener.mock.calls).toHaveLength(1);
const [, listener] = mockElement.addEventListener.mock.calls[0];
// First verify that matchesShortcut works directly
const testEvent = {
type: "keydown",
key: "a",
code: "KeyA",
ctrlKey: true,
altKey: false,
shiftKey: false,
metaKey: false,
preventDefault: vi.fn(),
stopPropagation: vi.fn()
} as any;
// Test matchesShortcut directly first
expect(matchesShortcut(testEvent, "ctrl+a")).toBe(true);
// Now test the actual listener
listener(testEvent);
expect(handler).toHaveBeenCalled();
expect(testEvent.preventDefault).toHaveBeenCalled();
expect(testEvent.stopPropagation).toHaveBeenCalled();
});
it("should not call handler for non-keyboard events", () => {
const handler = vi.fn();
shortcuts.bindGlobalShortcut("ctrl+a", handler, "test-namespace");
const [, listener] = mockElement.addEventListener.mock.calls[0];
// Simulate a non-keyboard event
const event = {
type: "click"
} as any;
listener(event);
expect(handler).not.toHaveBeenCalled();
});
it("should not call handler when shortcut doesn't match", () => {
const handler = vi.fn();
shortcuts.bindGlobalShortcut("ctrl+a", handler, "test-namespace");
const [, listener] = mockElement.addEventListener.mock.calls[0];
// Simulate a non-matching keydown event
const event = {
type: "keydown",
key: "b",
code: "KeyB",
ctrlKey: true,
altKey: false,
shiftKey: false,
metaKey: false,
preventDefault: vi.fn(),
stopPropagation: vi.fn()
} as any;
listener(event);
expect(handler).not.toHaveBeenCalled();
expect(event.preventDefault).not.toHaveBeenCalled();
});
});
describe('isIMEComposing', () => {
it('should return true when event.isComposing is true', () => {
const event = { isComposing: true, keyCode: 65 } as KeyboardEvent;
expect(isIMEComposing(event)).toBe(true);
});
it('should return true when keyCode is 229', () => {
const event = { isComposing: false, keyCode: 229 } as KeyboardEvent;
expect(isIMEComposing(event)).toBe(true);
});
it('should return true when both isComposing is true and keyCode is 229', () => {
const event = { isComposing: true, keyCode: 229 } as KeyboardEvent;
expect(isIMEComposing(event)).toBe(true);
});
it('should return false for normal keys', () => {
const event = { isComposing: false, keyCode: 65 } as KeyboardEvent;
expect(isIMEComposing(event)).toBe(false);
});
it('should return false when isComposing is undefined and keyCode is not 229', () => {
const event = { keyCode: 13 } as KeyboardEvent;
expect(isIMEComposing(event)).toBe(false);
});
it('should handle null/undefined events gracefully', () => {
expect(isIMEComposing(null as any)).toBe(false);
expect(isIMEComposing(undefined as any)).toBe(false);
});
});
});