Makelore 2.0 initial clean snapshot
This commit is contained in:
378
tests/unit/use-chat-commands.test.tsx
Normal file
378
tests/unit/use-chat-commands.test.tsx
Normal file
@@ -0,0 +1,378 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user