Files
makelore/tests/unit/use-chat-commands.test.tsx
inman 80e8386fa6
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled
feat: update Makelore modules and conversations
2026-07-31 10:08:41 +08:00

355 lines
12 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']>(),
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']>(),
toggleProcess: vi.fn<ChatCommandActions['toggleProcess']>(),
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('/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('/process');
expect(fixture.actions.toggleProcess).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('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');
});
});