summaryrefslogtreecommitdiff
path: root/packages/cli/src/ui/keyMatchers.test.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cli/src/ui/keyMatchers.test.ts')
-rw-r--r--packages/cli/src/ui/keyMatchers.test.ts338
1 files changed, 338 insertions, 0 deletions
diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts
new file mode 100644
index 00000000..16951e79
--- /dev/null
+++ b/packages/cli/src/ui/keyMatchers.test.ts
@@ -0,0 +1,338 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect } from 'vitest';
+import { keyMatchers, Command, createKeyMatchers } from './keyMatchers.js';
+import { KeyBindingConfig, defaultKeyBindings } from '../config/keyBindings.js';
+import type { Key } from './hooks/useKeypress.js';
+
+describe('keyMatchers', () => {
+ const createKey = (name: string, mods: Partial<Key> = {}): Key => ({
+ name,
+ ctrl: false,
+ meta: false,
+ shift: false,
+ paste: false,
+ sequence: name,
+ ...mods,
+ });
+
+ // Original hard-coded logic (for comparison)
+ const originalMatchers = {
+ [Command.HOME]: (key: Key) => key.ctrl && key.name === 'a',
+ [Command.END]: (key: Key) => key.ctrl && key.name === 'e',
+ [Command.KILL_LINE_RIGHT]: (key: Key) => key.ctrl && key.name === 'k',
+ [Command.KILL_LINE_LEFT]: (key: Key) => key.ctrl && key.name === 'u',
+ [Command.CLEAR_INPUT]: (key: Key) => key.ctrl && key.name === 'c',
+ [Command.CLEAR_SCREEN]: (key: Key) => key.ctrl && key.name === 'l',
+ [Command.HISTORY_UP]: (key: Key) => key.ctrl && key.name === 'p',
+ [Command.HISTORY_DOWN]: (key: Key) => key.ctrl && key.name === 'n',
+ [Command.NAVIGATION_UP]: (key: Key) => key.name === 'up',
+ [Command.NAVIGATION_DOWN]: (key: Key) => key.name === 'down',
+ [Command.ACCEPT_SUGGESTION]: (key: Key) =>
+ key.name === 'tab' || (key.name === 'return' && !key.ctrl),
+ [Command.ESCAPE]: (key: Key) => key.name === 'escape',
+ [Command.SUBMIT]: (key: Key) =>
+ key.name === 'return' && !key.ctrl && !key.meta && !key.paste,
+ [Command.NEWLINE]: (key: Key) =>
+ key.name === 'return' && (key.ctrl || key.meta || key.paste),
+ [Command.OPEN_EXTERNAL_EDITOR]: (key: Key) =>
+ key.ctrl && (key.name === 'x' || key.sequence === '\x18'),
+ [Command.PASTE_CLIPBOARD_IMAGE]: (key: Key) => key.ctrl && key.name === 'v',
+ [Command.SHOW_ERROR_DETAILS]: (key: Key) => key.ctrl && key.name === 'o',
+ [Command.TOGGLE_TOOL_DESCRIPTIONS]: (key: Key) =>
+ key.ctrl && key.name === 't',
+ [Command.TOGGLE_IDE_CONTEXT_DETAIL]: (key: Key) =>
+ key.ctrl && key.name === 'e',
+ [Command.QUIT]: (key: Key) =>
+ key.ctrl && (key.name === 'c' || key.name === 'C'),
+ [Command.EXIT]: (key: Key) =>
+ key.ctrl && (key.name === 'd' || key.name === 'D'),
+ [Command.SHOW_MORE_LINES]: (key: Key) => key.ctrl && key.name === 's',
+ [Command.REVERSE_SEARCH]: (key: Key) => key.ctrl && key.name === 'r',
+ [Command.SUBMIT_REVERSE_SEARCH]: (key: Key) =>
+ key.name === 'return' && !key.ctrl,
+ [Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: (key: Key) =>
+ key.name === 'tab',
+ };
+
+ // Test data for each command with positive and negative test cases
+ const testCases = [
+ // Basic bindings
+ {
+ command: Command.ESCAPE,
+ positive: [createKey('escape'), createKey('escape', { ctrl: true })],
+ negative: [createKey('e'), createKey('esc')],
+ },
+
+ // Cursor movement
+ {
+ command: Command.HOME,
+ positive: [createKey('a', { ctrl: true })],
+ negative: [
+ createKey('a'),
+ createKey('a', { shift: true }),
+ createKey('b', { ctrl: true }),
+ ],
+ },
+ {
+ command: Command.END,
+ positive: [createKey('e', { ctrl: true })],
+ negative: [
+ createKey('e'),
+ createKey('e', { shift: true }),
+ createKey('a', { ctrl: true }),
+ ],
+ },
+
+ // Text deletion
+ {
+ command: Command.KILL_LINE_RIGHT,
+ positive: [createKey('k', { ctrl: true })],
+ negative: [createKey('k'), createKey('l', { ctrl: true })],
+ },
+ {
+ command: Command.KILL_LINE_LEFT,
+ positive: [createKey('u', { ctrl: true })],
+ negative: [createKey('u'), createKey('k', { ctrl: true })],
+ },
+ {
+ command: Command.CLEAR_INPUT,
+ positive: [createKey('c', { ctrl: true })],
+ negative: [createKey('c'), createKey('k', { ctrl: true })],
+ },
+
+ // Screen control
+ {
+ command: Command.CLEAR_SCREEN,
+ positive: [createKey('l', { ctrl: true })],
+ negative: [createKey('l'), createKey('k', { ctrl: true })],
+ },
+
+ // History navigation
+ {
+ command: Command.HISTORY_UP,
+ positive: [createKey('p', { ctrl: true })],
+ negative: [createKey('p'), createKey('up')],
+ },
+ {
+ command: Command.HISTORY_DOWN,
+ positive: [createKey('n', { ctrl: true })],
+ negative: [createKey('n'), createKey('down')],
+ },
+ {
+ command: Command.NAVIGATION_UP,
+ positive: [createKey('up'), createKey('up', { ctrl: true })],
+ negative: [createKey('p'), createKey('u')],
+ },
+ {
+ command: Command.NAVIGATION_DOWN,
+ positive: [createKey('down'), createKey('down', { ctrl: true })],
+ negative: [createKey('n'), createKey('d')],
+ },
+
+ // Auto-completion
+ {
+ command: Command.ACCEPT_SUGGESTION,
+ positive: [createKey('tab'), createKey('return')],
+ negative: [createKey('return', { ctrl: true }), createKey('space')],
+ },
+
+ // Text input
+ {
+ command: Command.SUBMIT,
+ positive: [createKey('return')],
+ negative: [
+ createKey('return', { ctrl: true }),
+ createKey('return', { meta: true }),
+ createKey('return', { paste: true }),
+ ],
+ },
+ {
+ command: Command.NEWLINE,
+ positive: [
+ createKey('return', { ctrl: true }),
+ createKey('return', { meta: true }),
+ createKey('return', { paste: true }),
+ ],
+ negative: [createKey('return'), createKey('n')],
+ },
+
+ // External tools
+ {
+ command: Command.OPEN_EXTERNAL_EDITOR,
+ positive: [
+ createKey('x', { ctrl: true }),
+ { ...createKey('\x18'), sequence: '\x18', ctrl: true },
+ ],
+ negative: [createKey('x'), createKey('c', { ctrl: true })],
+ },
+ {
+ command: Command.PASTE_CLIPBOARD_IMAGE,
+ positive: [createKey('v', { ctrl: true })],
+ negative: [createKey('v'), createKey('c', { ctrl: true })],
+ },
+
+ // App level bindings
+ {
+ command: Command.SHOW_ERROR_DETAILS,
+ positive: [createKey('o', { ctrl: true })],
+ negative: [createKey('o'), createKey('e', { ctrl: true })],
+ },
+ {
+ command: Command.TOGGLE_TOOL_DESCRIPTIONS,
+ positive: [createKey('t', { ctrl: true })],
+ negative: [createKey('t'), createKey('s', { ctrl: true })],
+ },
+ {
+ command: Command.TOGGLE_IDE_CONTEXT_DETAIL,
+ positive: [createKey('e', { ctrl: true })],
+ negative: [createKey('e'), createKey('t', { ctrl: true })],
+ },
+ {
+ command: Command.QUIT,
+ positive: [
+ createKey('c', { ctrl: true }),
+ createKey('C', { ctrl: true }),
+ ],
+ negative: [createKey('c'), createKey('d', { ctrl: true })],
+ },
+ {
+ command: Command.EXIT,
+ positive: [
+ createKey('d', { ctrl: true }),
+ createKey('D', { ctrl: true }),
+ ],
+ negative: [createKey('d'), createKey('c', { ctrl: true })],
+ },
+ {
+ command: Command.SHOW_MORE_LINES,
+ positive: [createKey('s', { ctrl: true })],
+ negative: [createKey('s'), createKey('l', { ctrl: true })],
+ },
+
+ // Shell commands
+ {
+ command: Command.REVERSE_SEARCH,
+ positive: [createKey('r', { ctrl: true })],
+ negative: [createKey('r'), createKey('s', { ctrl: true })],
+ },
+ {
+ command: Command.SUBMIT_REVERSE_SEARCH,
+ positive: [createKey('return')],
+ negative: [createKey('return', { ctrl: true }), createKey('tab')],
+ },
+ {
+ command: Command.ACCEPT_SUGGESTION_REVERSE_SEARCH,
+ positive: [createKey('tab'), createKey('tab', { ctrl: true })],
+ negative: [createKey('return'), createKey('space')],
+ },
+ ];
+
+ describe('Data-driven key binding matches original logic', () => {
+ testCases.forEach(({ command, positive, negative }) => {
+ it(`should match ${command} correctly`, () => {
+ positive.forEach((key) => {
+ expect(
+ keyMatchers[command](key),
+ `Expected ${command} to match ${JSON.stringify(key)}`,
+ ).toBe(true);
+ expect(
+ originalMatchers[command](key),
+ `Original matcher should also match ${JSON.stringify(key)}`,
+ ).toBe(true);
+ });
+
+ negative.forEach((key) => {
+ expect(
+ keyMatchers[command](key),
+ `Expected ${command} to NOT match ${JSON.stringify(key)}`,
+ ).toBe(false);
+ expect(
+ originalMatchers[command](key),
+ `Original matcher should also NOT match ${JSON.stringify(key)}`,
+ ).toBe(false);
+ });
+ });
+ });
+
+ it('should properly handle ACCEPT_SUGGESTION_REVERSE_SEARCH cases', () => {
+ expect(
+ keyMatchers[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH](
+ createKey('return', { ctrl: true }),
+ ),
+ ).toBe(false); // ctrl must be false
+ expect(
+ keyMatchers[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH](createKey('tab')),
+ ).toBe(true);
+ expect(
+ keyMatchers[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH](
+ createKey('tab', { ctrl: true }),
+ ),
+ ).toBe(true); // modifiers ignored
+ });
+ });
+
+ describe('Custom key bindings', () => {
+ it('should work with custom configuration', () => {
+ const customConfig: KeyBindingConfig = {
+ ...defaultKeyBindings,
+ [Command.HOME]: [{ key: 'h', ctrl: true }, { key: '0' }],
+ };
+
+ const customMatchers = createKeyMatchers(customConfig);
+
+ expect(customMatchers[Command.HOME](createKey('h', { ctrl: true }))).toBe(
+ true,
+ );
+ expect(customMatchers[Command.HOME](createKey('0'))).toBe(true);
+ expect(customMatchers[Command.HOME](createKey('a', { ctrl: true }))).toBe(
+ false,
+ );
+ });
+
+ it('should support multiple key bindings for same command', () => {
+ const config: KeyBindingConfig = {
+ ...defaultKeyBindings,
+ [Command.QUIT]: [
+ { key: 'q', ctrl: true },
+ { key: 'q', command: true },
+ ],
+ };
+
+ const matchers = createKeyMatchers(config);
+ expect(matchers[Command.QUIT](createKey('q', { ctrl: true }))).toBe(true);
+ expect(matchers[Command.QUIT](createKey('q', { meta: true }))).toBe(true);
+ });
+ });
+
+ describe('Edge Cases', () => {
+ it('should handle empty binding arrays', () => {
+ const config: KeyBindingConfig = {
+ ...defaultKeyBindings,
+ [Command.HOME]: [],
+ };
+
+ const matchers = createKeyMatchers(config);
+ expect(matchers[Command.HOME](createKey('a', { ctrl: true }))).toBe(
+ false,
+ );
+ });
+
+ it('should handle case sensitivity', () => {
+ const config: KeyBindingConfig = {
+ ...defaultKeyBindings,
+ [Command.QUIT]: [{ key: 'Q', ctrl: true }],
+ };
+
+ const matchers = createKeyMatchers(config);
+ expect(matchers[Command.QUIT](createKey('Q', { ctrl: true }))).toBe(true);
+ expect(matchers[Command.QUIT](createKey('q', { ctrl: true }))).toBe(
+ false,
+ );
+ });
+ });
+});