/** * @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([]); 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); }); }); }); });