379 lines
13 KiB
TypeScript
379 lines
13 KiB
TypeScript
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> = {},
|
|
): 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<ChatCommandContext>;
|
|
} = {},
|
|
) {
|
|
const context = {
|
|
...enabledContext,
|
|
...overrides.context,
|
|
};
|
|
const actions = {
|
|
rename: vi.fn<ChatCommandActions['rename']>().mockResolvedValue(undefined),
|
|
timeline: vi.fn<ChatCommandActions['timeline']>(),
|
|
fork: vi.fn<ChatCommandActions['fork']>().mockResolvedValue(undefined),
|
|
compact: vi.fn<ChatCommandActions['compact']>().mockResolvedValue(undefined),
|
|
share: vi.fn<ChatCommandActions['share']>().mockImplementation(async () => {
|
|
context.shared = true;
|
|
}),
|
|
unshare: vi.fn<ChatCommandActions['unshare']>().mockImplementation(async () => {
|
|
context.shared = false;
|
|
}),
|
|
undo: vi.fn<ChatCommandActions['undo']>().mockResolvedValue(undefined),
|
|
redo: vi.fn<ChatCommandActions['redo']>().mockResolvedValue(undefined),
|
|
toggleTimestamps:
|
|
vi.fn<ChatCommandActions['toggleTimestamps']>(),
|
|
toggleThinking: vi.fn<ChatCommandActions['toggleThinking']>(),
|
|
copy: vi.fn<ChatCommandActions['copy']>().mockResolvedValue(undefined),
|
|
export: vi.fn<ChatCommandActions['export']>().mockResolvedValue(undefined),
|
|
project: vi.fn<ChatCommandActions['project']>().mockResolvedValue({
|
|
status: 'executed',
|
|
}),
|
|
};
|
|
let currentDraft = initialDraft;
|
|
let draftRevision = 0;
|
|
let setDraft:
|
|
| ReturnType<typeof useState<string>>[1]
|
|
| undefined;
|
|
let setScopeKey:
|
|
| ReturnType<typeof useState<string>>[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('/fork');
|
|
expect(fixture.result.current.dialog).toEqual({
|
|
kind: 'fork',
|
|
originatingDraft: '/fork',
|
|
});
|
|
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('/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<void>();
|
|
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<void>();
|
|
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<void>();
|
|
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('runs a fork selection as a single flight', async () => {
|
|
const deferred = Promise.withResolvers<void>();
|
|
const fixture = renderCommandHook('/fork');
|
|
fixture.actions.fork.mockReturnValue(deferred.promise);
|
|
await act(async () => fixture.result.current.tryExecuteDraft());
|
|
|
|
let first!: Promise<void>;
|
|
let second!: Promise<void>;
|
|
act(() => {
|
|
first = fixture.result.current.selectTimelineMessage('msg-user');
|
|
second = fixture.result.current.selectTimelineMessage('msg-user');
|
|
});
|
|
expect(fixture.actions.fork).toHaveBeenCalledOnce();
|
|
|
|
deferred.resolve();
|
|
await act(async () => {
|
|
await Promise.all([first, second]);
|
|
});
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|