import { act, renderHook } from '@testing-library/react'; import { useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; import { mergeChatCommandCatalog, type ChatCommandContext, } from '@/pages/Chat/chat-command-registry'; import { useChatCommands, type ChatCommandActions, type ChatCommandKeyEvent, } from '@/pages/Chat/use-chat-commands'; function keyEvent( key: string, overrides: Partial = {}, ): ChatCommandKeyEvent { return { key, shiftKey: false, isComposing: false, preventDefault: vi.fn(), ...overrides, }; } const enabledContext: ChatCommandContext = { hasSession: true, hasMessages: true, hasUndoableUserMessage: true, runtimeReady: true, busy: false, hasModel: true, shared: false, reverted: true, shareEnabled: true, clipboardAvailable: true, }; function renderCommandHook( initialDraft: string, overrides: { context?: Partial; } = {}, ) { const context = { ...enabledContext, ...overrides.context, }; const actions = { rename: vi.fn().mockResolvedValue(undefined), timeline: vi.fn(), compact: vi.fn().mockResolvedValue(undefined), share: vi.fn().mockImplementation(async () => { context.shared = true; }), unshare: vi.fn().mockImplementation(async () => { context.shared = false; }), undo: vi.fn().mockResolvedValue(undefined), redo: vi.fn().mockResolvedValue(undefined), toggleTimestamps: vi.fn(), toggleThinking: vi.fn(), toggleProcess: vi.fn(), copy: vi.fn().mockResolvedValue(undefined), export: vi.fn().mockResolvedValue(undefined), project: vi.fn().mockResolvedValue({ status: 'executed', }), }; let currentDraft = initialDraft; let draftRevision = 0; let setDraft: | ReturnType>[1] | undefined; let setScopeKey: | ReturnType>[1] | undefined; const catalog = mergeChatCommandCatalog([{ name: 'Review', hints: ['$ARGUMENTS'], }]); const { result } = renderHook(() => { const [draft, updateDraftState] = useState(initialDraft); const [scopeKey, updateScopeKey] = useState('ses-test'); const updateDraft = ( next: string | ((current: string) => string), ) => { updateDraftState((current) => { const value = typeof next === 'function' ? next(current) : next; if (value !== current) draftRevision += 1; return value; }); }; currentDraft = draft; setDraft = updateDraft; setScopeKey = updateScopeKey; return useChatCommands({ draft, setDraft: updateDraft, scopeKey, draftRevision, catalog, context, actions, normalizeError: (error) => ( error instanceof Error ? error.message : String(error) ), }); }); const updateDraft = (value: string) => { if (!setDraft) throw new Error('Hook draft setter is unavailable'); setDraft(value); }; return { result, actions, getDraft: () => currentDraft, setDraft: updateDraft, setScopeKey: (scopeKey: string) => { if (!setScopeKey) throw new Error('Hook scope setter is unavailable'); setScopeKey(scopeKey); }, run: async (command: string) => { await act(async () => { updateDraft(command); }); await act(async () => { await result.current.tryExecuteDraft(); }); }, }; } describe('useChatCommands', () => { it('fills a palette selection and executes it only on the next Enter', async () => { const fixture = renderCommandHook('/comp'); act(() => fixture.result.current.handleKeyDown(keyEvent('Enter'))); expect(fixture.getDraft()).toBe('/compact'); expect(fixture.actions.compact).not.toHaveBeenCalled(); expect(fixture.result.current.notice).toBe('再次按 Enter 执行'); await act(async () => fixture.result.current.handleKeyDown(keyEvent('Enter'))); expect(fixture.actions.compact).toHaveBeenCalledOnce(); expect(fixture.getDraft()).toBe(''); }); it('executes a directly typed exact command and preserves raw project argument spacing', async () => { const fixture = renderCommandHook('/Review staged changes '); await act(async () => fixture.result.current.handleKeyDown(keyEvent('Enter'))); expect(fixture.actions.project).toHaveBeenCalledWith({ rawName: 'Review', arguments: ' staged changes ', }); }); it('never intercepts Shift+Enter or IME composition', () => { const fixture = renderCommandHook('/compact'); expect(fixture.result.current.handleKeyDown(keyEvent('Enter', { shiftKey: true, }))).toBe(false); expect(fixture.result.current.handleKeyDown(keyEvent('Enter', { isComposing: true, }))).toBe(false); expect(fixture.actions.compact).not.toHaveBeenCalled(); }); it('supports arrows Tab and Escape without executing the highlighted item', () => { const fixture = renderCommandHook('/'); expect(fixture.result.current.activeIndex).toBe(0); act(() => fixture.result.current.handleKeyDown(keyEvent('ArrowDown'))); expect(fixture.result.current.activeIndex).toBe(1); act(() => fixture.result.current.handleKeyDown(keyEvent('ArrowUp'))); expect(fixture.result.current.activeIndex).toBe(0); act(() => fixture.result.current.handleKeyDown(keyEvent('Tab'))); expect(fixture.getDraft()).toMatch(/^\/\S+/); expect( Object.values(fixture.actions) .every((mock) => mock.mock.calls.length === 0), ).toBe(true); act(() => fixture.result.current.handleKeyDown(keyEvent('Escape'))); expect(fixture.result.current.paletteOpen).toBe(false); }); it('blocks an unknown slash token instead of sending it to the model', async () => { const fixture = renderCommandHook('/does-not-exist payload'); let handled = false; await act(async () => { handled = await fixture.result.current.tryExecuteDraft(); }); expect(handled).toBe(true); expect(fixture.result.current.error).toBe( '未知命令 /does-not-exist', ); expect( Object.values(fixture.actions) .every((mock) => mock.mock.calls.length === 0), ).toBe(true); expect(fixture.getDraft()).toBe('/does-not-exist payload'); }); it('keeps the originating draft when an action rejects or is cancelled', async () => { const fixture = renderCommandHook('/compact'); fixture.actions.compact.mockRejectedValueOnce( new Error('summarize failed'), ); await act(async () => fixture.result.current.tryExecuteDraft()); expect(fixture.getDraft()).toBe('/compact'); expect(fixture.result.current.error).toBe('summarize failed'); act(() => fixture.setDraft('/undo')); await act(async () => fixture.result.current.tryExecuteDraft()); expect(fixture.result.current.confirmation?.kind).toBe('undo'); act(() => fixture.result.current.cancelDialog()); expect(fixture.getDraft()).toBe('/undo'); }); it('dispatches every approved builtin to its exact workflow', async () => { const fixture = renderCommandHook('/rename New title'); await fixture.run('/rename New title'); expect(fixture.actions.rename).toHaveBeenCalledWith('New title'); await fixture.run('/rename'); expect(fixture.result.current.dialog).toEqual({ kind: 'rename', originatingDraft: '/rename', }); fixture.result.current.cancelDialog(); await fixture.run('/timeline'); expect(fixture.result.current.dialog).toEqual({ kind: 'timeline', originatingDraft: '/timeline', }); fixture.result.current.cancelDialog(); await fixture.run('/compact'); expect(fixture.actions.compact).toHaveBeenCalledOnce(); await fixture.run('/share'); expect(fixture.result.current.confirmation?.kind).toBe('share'); await act(async () => fixture.result.current.confirmDialog()); expect(fixture.actions.share).toHaveBeenCalledOnce(); await fixture.run('/unshare'); expect(fixture.result.current.confirmation?.kind).toBe('unshare'); await act(async () => fixture.result.current.confirmDialog()); expect(fixture.actions.unshare).toHaveBeenCalledOnce(); await fixture.run('/undo'); expect(fixture.result.current.confirmation?.kind).toBe('undo'); await act(async () => fixture.result.current.confirmDialog()); expect(fixture.actions.undo).toHaveBeenCalledOnce(); await fixture.run('/redo'); expect(fixture.result.current.confirmation?.kind).toBe('redo'); await act(async () => fixture.result.current.confirmDialog()); expect(fixture.actions.redo).toHaveBeenCalledOnce(); await fixture.run('/timestamps'); expect(fixture.actions.toggleTimestamps).toHaveBeenCalledOnce(); await fixture.run('/thinking'); expect(fixture.actions.toggleThinking).toHaveBeenCalledOnce(); await fixture.run('/process'); expect(fixture.actions.toggleProcess).toHaveBeenCalledOnce(); await fixture.run('/copy'); expect(fixture.actions.copy).toHaveBeenCalledOnce(); await fixture.run('/export'); expect(fixture.actions.export).toHaveBeenCalledOnce(); }); it('does not invoke disabled runtime actions and exposes the computed reason', async () => { const fixture = renderCommandHook('/Review changes', { context: { runtimeReady: false }, }); await fixture.run('/Review changes'); expect(fixture.actions.project).not.toHaveBeenCalled(); expect(fixture.result.current.error).toBe( 'OpenCode 运行时未启动', ); }); it('retains a newer edit when the originating command completes', async () => { const deferred = Promise.withResolvers(); const fixture = renderCommandHook('/compact'); fixture.actions.compact.mockReturnValueOnce(deferred.promise); const pending = fixture.result.current.tryExecuteDraft(); act(() => fixture.setDraft('/compact plus my newer note')); deferred.resolve(); await act(async () => pending); expect(fixture.getDraft()).toBe('/compact plus my newer note'); }); it('retains an ABA-edited draft when an older command completes', async () => { const deferred = Promise.withResolvers(); const fixture = renderCommandHook('/compact'); fixture.actions.compact.mockReturnValueOnce(deferred.promise); const pending = fixture.result.current.tryExecuteDraft(); act(() => fixture.setDraft('temporary edit')); act(() => fixture.setDraft('/compact')); deferred.resolve(); await act(async () => pending); expect(fixture.getDraft()).toBe('/compact'); }); it('does not clear an identical draft after its command scope changes', async () => { const deferred = Promise.withResolvers(); const fixture = renderCommandHook('/compact'); fixture.actions.compact.mockReturnValueOnce(deferred.promise); const pending = fixture.result.current.tryExecuteDraft(); act(() => fixture.setScopeKey('ses-second')); deferred.resolve(); await act(async () => pending); expect(fixture.getDraft()).toBe('/compact'); }); it('closes a stale confirmation without dispatching it in a new scope', async () => { const fixture = renderCommandHook('/undo'); await act(async () => fixture.result.current.tryExecuteDraft()); expect(fixture.result.current.confirmation?.kind).toBe('undo'); act(() => fixture.setScopeKey('ses-second')); expect(fixture.result.current.confirmation).toBeNull(); await act(async () => fixture.result.current.confirmDialog()); expect(fixture.actions.undo).not.toHaveBeenCalled(); }); it('does not clear an identical current draft when a project command is cancelled', async () => { const fixture = renderCommandHook('/Review same text'); fixture.actions.project.mockResolvedValueOnce({ status: 'cancelled' }); await act(async () => fixture.result.current.tryExecuteDraft()); expect(fixture.actions.project).toHaveBeenCalledWith({ rawName: 'Review', arguments: 'same text', }); expect(fixture.getDraft()).toBe('/Review same text'); }); });