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 { 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) => 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) => 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 运行时未启动' }); }); });