246 lines
9.5 KiB
TypeScript
246 lines
9.5 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
BUILTIN_CHAT_COMMANDS,
|
|
buildCommandPaletteItems,
|
|
filterChatCommands,
|
|
getSlashPaletteQuery,
|
|
mergeChatCommandCatalog,
|
|
parseSlashCommand,
|
|
resolveCommandName,
|
|
type ChatCommandContext,
|
|
} from '@/pages/Chat/chat-command-registry';
|
|
|
|
function context(overrides: Partial<ChatCommandContext> = {}): ChatCommandContext {
|
|
return {
|
|
hasSession: true,
|
|
hasMessages: true,
|
|
hasUndoableUserMessage: true,
|
|
runtimeReady: true,
|
|
busy: false,
|
|
hasModel: true,
|
|
shared: false,
|
|
reverted: false,
|
|
shareEnabled: true,
|
|
clipboardAvailable: true,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('chat command registry', () => {
|
|
it('defines the approved built-ins and aliases', () => {
|
|
expect(BUILTIN_CHAT_COMMANDS.map((command) => command.name)).toEqual([
|
|
'share', 'rename', 'timeline', 'compact', 'unshare',
|
|
'undo', 'redo', 'timestamps', 'thinking', 'process', 'copy', 'export',
|
|
]);
|
|
const catalog = mergeChatCommandCatalog([]);
|
|
expect(resolveCommandName('summarize', catalog)?.name).toBe('compact');
|
|
expect(resolveCommandName('toggle-timestamps', catalog)?.name).toBe('timestamps');
|
|
expect(resolveCommandName('toggle-thinking', catalog)?.name).toBe('thinking');
|
|
});
|
|
|
|
it('namespaces case-insensitive reserved collisions and preserves raw names', () => {
|
|
const catalog = mergeChatCommandCatalog([
|
|
{ name: 'review', description: 'Review changes', hints: ['$ARGUMENTS'] },
|
|
{ name: 'Compact', description: 'Case collision', hints: [] },
|
|
{ name: 'SUMMARIZE', description: 'Alias collision', hints: [] },
|
|
{ name: 'project:Compact', description: 'Project namespace collision', hints: [] },
|
|
]);
|
|
|
|
expect(catalog.find((item) => item.rawName === 'review')?.inputName).toBe('review');
|
|
expect(catalog.find((item) => item.rawName === 'Compact')?.inputName).toBe('project:Compact');
|
|
expect(catalog.find((item) => item.rawName === 'SUMMARIZE')?.inputName).toBe('project:SUMMARIZE');
|
|
expect(catalog.find((item) => item.rawName === 'project:Compact')?.inputName)
|
|
.toBe('project:project:Compact');
|
|
expect(resolveCommandName('PROJECT:COMPACT', catalog)?.rawName).toBe('Compact');
|
|
});
|
|
|
|
it('resolves duplicate project names to stable unique display tokens', () => {
|
|
const first = mergeChatCommandCatalog([
|
|
{ name: 'review', hints: [] },
|
|
{ name: 'Review', hints: [] },
|
|
]).filter((item) => item.source === 'project').map((item) => [item.rawName, item.inputName]);
|
|
const second = mergeChatCommandCatalog([
|
|
{ name: 'Review', hints: [] },
|
|
{ name: 'review', hints: [] },
|
|
]).filter((item) => item.source === 'project').map((item) => [item.rawName, item.inputName]);
|
|
|
|
expect(second).toEqual(first);
|
|
expect(new Set(first.map((entry) => String(entry[1]).toLowerCase())).size).toBe(2);
|
|
});
|
|
|
|
it('sorts exact-name project duplicates by metadata before allocating tokens', () => {
|
|
const commands = [
|
|
{
|
|
name: 'review',
|
|
description: 'Review from MCP',
|
|
source: 'mcp' as const,
|
|
hints: ['[mcp]'],
|
|
},
|
|
{
|
|
name: 'review',
|
|
description: 'Review from command',
|
|
source: 'command' as const,
|
|
hints: ['[command]'],
|
|
},
|
|
];
|
|
const first = buildCommandPaletteItems(
|
|
mergeChatCommandCatalog(commands),
|
|
context(),
|
|
);
|
|
const second = buildCommandPaletteItems(
|
|
mergeChatCommandCatalog([...commands].reverse()),
|
|
context(),
|
|
);
|
|
|
|
expect(second).toEqual(first);
|
|
expect(first.filter((item) => item.source === 'project').map((item) => item.inputName))
|
|
.toEqual(['review', 'project:review']);
|
|
});
|
|
|
|
it('preserves raw argument spacing and only opens the palette in the first token', () => {
|
|
expect(parseSlashCommand(' /rename New title ')).toEqual({
|
|
token: 'rename',
|
|
arguments: 'New title ',
|
|
});
|
|
expect(parseSlashCommand('/rename \t New\t title ')).toEqual({
|
|
token: 'rename',
|
|
arguments: '\t New\t title ',
|
|
});
|
|
expect(parseSlashCommand('/rename\n')).toBeNull();
|
|
expect(getSlashPaletteQuery(' /comp')).toBe('comp');
|
|
expect(getSlashPaletteQuery(' /compact now')).toBeNull();
|
|
expect(parseSlashCommand('not a command')).toBeNull();
|
|
});
|
|
|
|
it('searches names aliases title and description with built-ins first on ties', () => {
|
|
const catalog = mergeChatCommandCatalog([
|
|
{ name: 'project-time', description: '时间 display helper', hints: [] },
|
|
]);
|
|
expect(filterChatCommands(catalog, 'sum')[0]?.name).toBe('compact');
|
|
expect(filterChatCommands(catalog, '时间')[0]?.name).toBe('timeline');
|
|
});
|
|
|
|
it('keeps loaded-data commands available while the runtime is stopped', () => {
|
|
const items = buildCommandPaletteItems(
|
|
mergeChatCommandCatalog([{ name: 'review', hints: [] }]),
|
|
context({ runtimeReady: false }),
|
|
);
|
|
const availability = Object.fromEntries(
|
|
items.map((item) => [item.inputName, item.computedAvailability]),
|
|
);
|
|
expect(availability.timeline).toEqual({ enabled: true });
|
|
expect(availability.timestamps).toEqual({ enabled: true });
|
|
expect(availability.thinking).toEqual({ enabled: true });
|
|
expect(availability.process).toEqual({ enabled: true });
|
|
expect(availability.copy).toEqual({ enabled: true });
|
|
expect(availability.export).toEqual({ enabled: true });
|
|
expect(availability.rename).toEqual({ enabled: false, reason: 'OpenCode 运行时未启动' });
|
|
expect(availability.review).toEqual({ enabled: false, reason: 'OpenCode 运行时未启动' });
|
|
});
|
|
|
|
it('uses hasUndoableUserMessage independently from loaded messages', () => {
|
|
const items = buildCommandPaletteItems(
|
|
mergeChatCommandCatalog([]),
|
|
context({ hasMessages: true, hasUndoableUserMessage: false }),
|
|
);
|
|
const undo = items.find((item) => item.name === 'undo');
|
|
const timeline = items.find((item) => item.name === 'timeline');
|
|
const copy = items.find((item) => item.name === 'copy');
|
|
expect(undo?.computedAvailability).toEqual({
|
|
enabled: false,
|
|
reason: '没有可撤销的用户消息',
|
|
});
|
|
expect(timeline?.computedAvailability.enabled).toBe(true);
|
|
expect(copy?.computedAvailability.enabled).toBe(true);
|
|
});
|
|
|
|
it('implements busy share revert model clipboard and session constraints', () => {
|
|
const catalog = mergeChatCommandCatalog([{ name: 'review', hints: [] }]);
|
|
const availabilityFor = (name: string, overrides: Partial<ChatCommandContext>) =>
|
|
buildCommandPaletteItems(catalog, context(overrides))
|
|
.find((item) => item.inputName === name)?.computedAvailability;
|
|
|
|
for (const name of ['share', 'rename', 'timeline', 'undo', 'timestamps', 'thinking', 'process', 'copy', 'export']) {
|
|
expect(availabilityFor(name, { busy: true })?.enabled).toBe(true);
|
|
}
|
|
for (const name of ['compact', 'redo', 'review']) {
|
|
expect(availabilityFor(name, {
|
|
busy: true,
|
|
...(name === 'redo' ? { reverted: true } : {}),
|
|
})).toEqual({
|
|
enabled: false,
|
|
reason: '当前任务运行中',
|
|
});
|
|
}
|
|
expect(availabilityFor('compact', { hasModel: false })).toEqual({
|
|
enabled: false,
|
|
reason: '尚未选择模型',
|
|
});
|
|
expect(availabilityFor('share', { shareEnabled: false })).toEqual({
|
|
enabled: false,
|
|
reason: '服务端已禁用分享',
|
|
});
|
|
expect(availabilityFor('unshare', { shared: false })).toEqual({
|
|
enabled: false,
|
|
reason: '会话尚未分享',
|
|
});
|
|
expect(availabilityFor('redo', { reverted: false })).toEqual({
|
|
enabled: false,
|
|
reason: '没有可重做的消息',
|
|
});
|
|
expect(availabilityFor('copy', { clipboardAvailable: false })).toEqual({
|
|
enabled: false,
|
|
reason: '剪贴板不可用',
|
|
});
|
|
expect(availabilityFor('timeline', { hasMessages: false })).toEqual({
|
|
enabled: false,
|
|
reason: '没有已加载消息',
|
|
});
|
|
expect(availabilityFor('rename', { hasSession: false })).toEqual({
|
|
enabled: false,
|
|
reason: '请先选择会话',
|
|
});
|
|
});
|
|
|
|
it('uses deterministic availability reason precedence when several constraints fail', () => {
|
|
const catalog = mergeChatCommandCatalog([{ name: 'review', hints: [] }]);
|
|
const availabilityFor = (name: string, overrides: Partial<ChatCommandContext>) =>
|
|
buildCommandPaletteItems(catalog, context(overrides))
|
|
.find((item) => item.inputName === name)?.computedAvailability;
|
|
|
|
expect(availabilityFor('redo', {
|
|
hasSession: false,
|
|
runtimeReady: false,
|
|
reverted: false,
|
|
busy: true,
|
|
})).toEqual({ enabled: false, reason: '请先选择会话' });
|
|
expect(availabilityFor('redo', {
|
|
runtimeReady: false,
|
|
reverted: false,
|
|
busy: true,
|
|
})).toEqual({ enabled: false, reason: '没有可重做的消息' });
|
|
expect(availabilityFor('compact', {
|
|
runtimeReady: false,
|
|
hasModel: false,
|
|
busy: true,
|
|
})).toEqual({ enabled: false, reason: 'OpenCode 运行时未启动' });
|
|
expect(availabilityFor('compact', {
|
|
hasModel: false,
|
|
busy: true,
|
|
})).toEqual({ enabled: false, reason: '尚未选择模型' });
|
|
expect(availabilityFor('compact', {
|
|
hasModel: true,
|
|
busy: true,
|
|
})).toEqual({ enabled: false, reason: '当前任务运行中' });
|
|
expect(availabilityFor('review', {
|
|
hasSession: false,
|
|
runtimeReady: false,
|
|
busy: true,
|
|
})).toEqual({ enabled: false, reason: '请先选择会话' });
|
|
expect(availabilityFor('review', {
|
|
runtimeReady: false,
|
|
busy: true,
|
|
})).toEqual({ enabled: false, reason: 'OpenCode 运行时未启动' });
|
|
});
|
|
});
|