summaryrefslogtreecommitdiff
path: root/packages/cli/src/ui/hooks/useSlashCompletion.test.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cli/src/ui/hooks/useSlashCompletion.test.ts')
-rw-r--r--packages/cli/src/ui/hooks/useSlashCompletion.test.ts434
1 files changed, 434 insertions, 0 deletions
diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts
new file mode 100644
index 00000000..ba26f2d2
--- /dev/null
+++ b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts
@@ -0,0 +1,434 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/** @vitest-environment jsdom */
+
+import { describe, it, expect, vi } from 'vitest';
+import { renderHook, waitFor } from '@testing-library/react';
+import { useSlashCompletion } from './useSlashCompletion.js';
+import { CommandContext, SlashCommand } from '../commands/types.js';
+import { useState } from 'react';
+import { Suggestion } from '../components/SuggestionsDisplay.js';
+
+// Test harness to capture the state from the hook's callbacks.
+function useTestHarnessForSlashCompletion(
+ enabled: boolean,
+ query: string | null,
+ slashCommands: readonly SlashCommand[],
+ commandContext: CommandContext,
+) {
+ const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
+ const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false);
+ const [isPerfectMatch, setIsPerfectMatch] = useState(false);
+
+ const { completionStart, completionEnd } = useSlashCompletion({
+ enabled,
+ query,
+ slashCommands,
+ commandContext,
+ setSuggestions,
+ setIsLoadingSuggestions,
+ setIsPerfectMatch,
+ });
+
+ return {
+ suggestions,
+ isLoadingSuggestions,
+ isPerfectMatch,
+ completionStart,
+ completionEnd,
+ };
+}
+
+describe('useSlashCompletion', () => {
+ // A minimal mock is sufficient for these tests.
+ const mockCommandContext = {} as CommandContext;
+
+ describe('Top-Level Commands', () => {
+ it('should suggest all top-level commands for the root slash', async () => {
+ const slashCommands = [
+ { name: 'help', altNames: ['?'], description: 'Show help' },
+ {
+ name: 'stats',
+ altNames: ['usage'],
+ description: 'check session stats. Usage: /stats [model|tools]',
+ },
+ { name: 'clear', description: 'Clear the screen' },
+ {
+ name: 'memory',
+ description: 'Manage memory',
+ subCommands: [{ name: 'show', description: 'Show memory' }],
+ },
+ { name: 'chat', description: 'Manage chat history' },
+ ] as unknown as SlashCommand[];
+ const { result } = renderHook(() =>
+ useTestHarnessForSlashCompletion(
+ true,
+ '/',
+ slashCommands,
+ mockCommandContext,
+ ),
+ );
+
+ expect(result.current.suggestions.length).toBe(slashCommands.length);
+ expect(result.current.suggestions.map((s) => s.label)).toEqual(
+ expect.arrayContaining(['help', 'clear', 'memory', 'chat', 'stats']),
+ );
+ });
+
+ it('should filter commands based on partial input', async () => {
+ const slashCommands = [
+ { name: 'memory', description: 'Manage memory' },
+ ] as unknown as SlashCommand[];
+ const { result } = renderHook(() =>
+ useTestHarnessForSlashCompletion(
+ true,
+ '/mem',
+ slashCommands,
+ mockCommandContext,
+ ),
+ );
+
+ expect(result.current.suggestions).toEqual([
+ { label: 'memory', value: 'memory', description: 'Manage memory' },
+ ]);
+ });
+
+ it('should suggest commands based on partial altNames', async () => {
+ const slashCommands = [
+ {
+ name: 'stats',
+ altNames: ['usage'],
+ description: 'check session stats. Usage: /stats [model|tools]',
+ },
+ ] as unknown as SlashCommand[];
+ const { result } = renderHook(() =>
+ useTestHarnessForSlashCompletion(
+ true,
+ '/usag',
+ slashCommands,
+ mockCommandContext,
+ ),
+ );
+
+ expect(result.current.suggestions).toEqual([
+ {
+ label: 'stats',
+ value: 'stats',
+ description: 'check session stats. Usage: /stats [model|tools]',
+ },
+ ]);
+ });
+
+ it('should NOT provide suggestions for a perfectly typed command that is a leaf node', async () => {
+ const slashCommands = [
+ { name: 'clear', description: 'Clear the screen', action: vi.fn() },
+ ] as unknown as SlashCommand[];
+ const { result } = renderHook(() =>
+ useTestHarnessForSlashCompletion(
+ true,
+ '/clear',
+ slashCommands,
+ mockCommandContext,
+ ),
+ );
+
+ expect(result.current.suggestions).toHaveLength(0);
+ });
+
+ it.each([['/?'], ['/usage']])(
+ 'should not suggest commands when altNames is fully typed',
+ async (query) => {
+ const mockSlashCommands = [
+ {
+ name: 'help',
+ altNames: ['?'],
+ description: 'Show help',
+ action: vi.fn(),
+ },
+ {
+ name: 'stats',
+ altNames: ['usage'],
+ description: 'check session stats. Usage: /stats [model|tools]',
+ action: vi.fn(),
+ },
+ ] as unknown as SlashCommand[];
+
+ const { result } = renderHook(() =>
+ useTestHarnessForSlashCompletion(
+ true,
+ query,
+ mockSlashCommands,
+ mockCommandContext,
+ ),
+ );
+
+ expect(result.current.suggestions).toHaveLength(0);
+ },
+ );
+
+ it('should not provide suggestions for a fully typed command that has no sub-commands or argument completion', async () => {
+ const slashCommands = [
+ { name: 'clear', description: 'Clear the screen' },
+ ] as unknown as SlashCommand[];
+ const { result } = renderHook(() =>
+ useTestHarnessForSlashCompletion(
+ true,
+ '/clear ',
+ slashCommands,
+ mockCommandContext,
+ ),
+ );
+
+ expect(result.current.suggestions).toHaveLength(0);
+ });
+
+ it('should not provide suggestions for an unknown command', async () => {
+ const slashCommands = [
+ { name: 'help', description: 'Show help' },
+ ] as unknown as SlashCommand[];
+ const { result } = renderHook(() =>
+ useTestHarnessForSlashCompletion(
+ true,
+ '/unknown-command',
+ slashCommands,
+ mockCommandContext,
+ ),
+ );
+
+ expect(result.current.suggestions).toHaveLength(0);
+ });
+ });
+
+ describe('Sub-Commands', () => {
+ it('should suggest sub-commands for a parent command', async () => {
+ const slashCommands = [
+ {
+ name: 'memory',
+ description: 'Manage memory',
+ subCommands: [
+ { name: 'show', description: 'Show memory' },
+ { name: 'add', description: 'Add to memory' },
+ ],
+ },
+ ] as unknown as SlashCommand[];
+
+ const { result } = renderHook(() =>
+ useTestHarnessForSlashCompletion(
+ true,
+ '/memory',
+ slashCommands,
+ mockCommandContext,
+ ),
+ );
+
+ expect(result.current.suggestions).toHaveLength(2);
+ expect(result.current.suggestions).toEqual(
+ expect.arrayContaining([
+ { label: 'show', value: 'show', description: 'Show memory' },
+ { label: 'add', value: 'add', description: 'Add to memory' },
+ ]),
+ );
+ });
+
+ it('should suggest all sub-commands when the query ends with the parent command and a space', async () => {
+ const slashCommands = [
+ {
+ name: 'memory',
+ description: 'Manage memory',
+ subCommands: [
+ { name: 'show', description: 'Show memory' },
+ { name: 'add', description: 'Add to memory' },
+ ],
+ },
+ ] as unknown as SlashCommand[];
+ const { result } = renderHook(() =>
+ useTestHarnessForSlashCompletion(
+ true,
+ '/memory ',
+ slashCommands,
+ mockCommandContext,
+ ),
+ );
+
+ expect(result.current.suggestions).toHaveLength(2);
+ expect(result.current.suggestions).toEqual(
+ expect.arrayContaining([
+ { label: 'show', value: 'show', description: 'Show memory' },
+ { label: 'add', value: 'add', description: 'Add to memory' },
+ ]),
+ );
+ });
+
+ it('should filter sub-commands by prefix', async () => {
+ const slashCommands = [
+ {
+ name: 'memory',
+ description: 'Manage memory',
+ subCommands: [
+ { name: 'show', description: 'Show memory' },
+ { name: 'add', description: 'Add to memory' },
+ ],
+ },
+ ] as unknown as SlashCommand[];
+ const { result } = renderHook(() =>
+ useTestHarnessForSlashCompletion(
+ true,
+ '/memory a',
+ slashCommands,
+ mockCommandContext,
+ ),
+ );
+
+ expect(result.current.suggestions).toEqual([
+ { label: 'add', value: 'add', description: 'Add to memory' },
+ ]);
+ });
+
+ it('should provide no suggestions for an invalid sub-command', async () => {
+ const slashCommands = [
+ {
+ name: 'memory',
+ description: 'Manage memory',
+ subCommands: [
+ { name: 'show', description: 'Show memory' },
+ { name: 'add', description: 'Add to memory' },
+ ],
+ },
+ ] as unknown as SlashCommand[];
+ const { result } = renderHook(() =>
+ useTestHarnessForSlashCompletion(
+ true,
+ '/memory dothisnow',
+ slashCommands,
+ mockCommandContext,
+ ),
+ );
+
+ expect(result.current.suggestions).toHaveLength(0);
+ });
+ });
+
+ describe('Argument Completion', () => {
+ it('should call the command.completion function for argument suggestions', async () => {
+ const availableTags = [
+ 'my-chat-tag-1',
+ 'my-chat-tag-2',
+ 'another-channel',
+ ];
+ const mockCompletionFn = vi
+ .fn()
+ .mockImplementation(
+ async (_context: CommandContext, partialArg: string) =>
+ availableTags.filter((tag) => tag.startsWith(partialArg)),
+ );
+
+ const slashCommands = [
+ {
+ name: 'chat',
+ description: 'Manage chat history',
+ subCommands: [
+ {
+ name: 'resume',
+ description: 'Resume a saved chat',
+ completion: mockCompletionFn,
+ },
+ ],
+ },
+ ] as unknown as SlashCommand[];
+
+ const { result } = renderHook(() =>
+ useTestHarnessForSlashCompletion(
+ true,
+ '/chat resume my-ch',
+ slashCommands,
+ mockCommandContext,
+ ),
+ );
+
+ await waitFor(() => {
+ expect(mockCompletionFn).toHaveBeenCalledWith(
+ mockCommandContext,
+ 'my-ch',
+ );
+ });
+
+ await waitFor(() => {
+ expect(result.current.suggestions).toEqual([
+ { label: 'my-chat-tag-1', value: 'my-chat-tag-1' },
+ { label: 'my-chat-tag-2', value: 'my-chat-tag-2' },
+ ]);
+ });
+ });
+
+ it('should call command.completion with an empty string when args start with a space', async () => {
+ const mockCompletionFn = vi
+ .fn()
+ .mockResolvedValue(['my-chat-tag-1', 'my-chat-tag-2', 'my-channel']);
+
+ const slashCommands = [
+ {
+ name: 'chat',
+ description: 'Manage chat history',
+ subCommands: [
+ {
+ name: 'resume',
+ description: 'Resume a saved chat',
+ completion: mockCompletionFn,
+ },
+ ],
+ },
+ ] as unknown as SlashCommand[];
+
+ const { result } = renderHook(() =>
+ useTestHarnessForSlashCompletion(
+ true,
+ '/chat resume ',
+ slashCommands,
+ mockCommandContext,
+ ),
+ );
+
+ await waitFor(() => {
+ expect(mockCompletionFn).toHaveBeenCalledWith(mockCommandContext, '');
+ });
+
+ await waitFor(() => {
+ expect(result.current.suggestions).toHaveLength(3);
+ });
+ });
+
+ it('should handle completion function that returns null', async () => {
+ const completionFn = vi.fn().mockResolvedValue(null);
+ const slashCommands = [
+ {
+ name: 'chat',
+ description: 'Manage chat history',
+ subCommands: [
+ {
+ name: 'resume',
+ description: 'Resume a saved chat',
+ completion: completionFn,
+ },
+ ],
+ },
+ ] as unknown as SlashCommand[];
+
+ const { result } = renderHook(() =>
+ useTestHarnessForSlashCompletion(
+ true,
+ '/chat resume ',
+ slashCommands,
+ mockCommandContext,
+ ),
+ );
+
+ await waitFor(() => {
+ expect(result.current.suggestions).toHaveLength(0);
+ });
+ });
+ });
+});