Files
makelore/tests/unit/chat-command-registry.test.ts
2026-07-29 17:22:35 +08:00

245 lines
9.4 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 twelve approved built-ins and aliases', () => {
expect(BUILTIN_CHAT_COMMANDS.map((command) => command.name)).toEqual([
'share', 'rename', 'timeline', 'fork', 'compact', 'unshare',
'undo', 'redo', 'timestamps', 'thinking', '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.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', 'copy', 'export']) {
expect(availabilityFor(name, { busy: true })?.enabled).toBe(true);
}
for (const name of ['fork', '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 运行时未启动' });
});
});