Files
makelore/tests/unit/coding-feature-ui.test.tsx

493 lines
19 KiB
TypeScript

import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { ProviderAccount, ProviderVendorInfo } from '@/lib/providers';
import type { ConversationSnapshot } from '@/types/coding-conversation';
const interactionApi = vi.hoisted(() => ({
respond: vi.fn(),
abort: vi.fn(),
compact: vi.fn(),
model: vi.fn(),
thinking: vi.fn(),
diagnostics: vi.fn(),
}));
const inspectorApi = vi.hoisted(() => ({
changes: vi.fn(),
commands: vi.fn(),
content: vi.fn(),
files: vi.fn(),
find: vi.fn(),
skills: vi.fn(),
}));
vi.mock('@/lib/coding-conversations', async (importOriginal) => ({
...await importOriginal<typeof import('@/lib/coding-conversations')>(),
respondCodingConversationInteraction: interactionApi.respond,
abortCodingConversation: interactionApi.abort,
compactCodingConversation: interactionApi.compact,
setCodingConversationModel: interactionApi.model,
setCodingConversationThinking: interactionApi.thinking,
getCodingRuntimeDiagnostics: interactionApi.diagnostics,
}));
vi.mock('@/lib/coding-product-tools', () => ({
getCodingConversationChanges: inspectorApi.changes,
getCodingConversationCommands: inspectorApi.commands,
getCodingFileContent: inspectorApi.content,
getCodingFileStatus: inspectorApi.files,
findCodingFiles: inspectorApi.find,
getCodingSkills: inspectorApi.skills,
}));
describe('PI-130 feature-complete Coding UI', () => {
it('builds vendor-neutral Conversation model choices without runtime keys', async () => {
const { buildCodingModelOptions, parseCodingModelKey } = await import(
'@/lib/coding-model-options'
);
const account = {
id: 'account-1',
vendorId: 'custom',
label: 'Work account',
authMode: 'api_key',
model: 'model-a',
fallbackModels: ['model-b', 'model-a'],
enabled: true,
isDefault: true,
createdAt: '2026-08-24T00:00:00.000Z',
updatedAt: '2026-08-24T00:00:00.000Z',
} satisfies ProviderAccount;
const vendor = { id: 'custom', name: 'Custom vendor' } as ProviderVendorInfo;
const options = buildCodingModelOptions([account], [vendor]);
expect(options.map((option) => option.modelId)).toEqual(['model-a', 'model-b']);
expect(options[0].label).toBe('Work account · Custom vendor / model-a');
expect(parseCodingModelKey(options[0].key)).toEqual({ accountId: 'account-1', modelId: 'model-a' });
expect(JSON.stringify(options)).not.toMatch(/opencode|providerID/i);
});
it('exposes steer/follow-up modes and queue waiting state while a turn runs', async () => {
const onModeChange = vi.fn();
const onSubmit = vi.fn();
const { CodingComposer } = await import('@/pages/Chat/CodingComposer');
render(
<CodingComposer
value="Please add tests"
editable
canSend
preparing={false}
recovering={false}
runStatus="running"
mode="follow-up"
queue={{ items: [{ id: 'queue-1', clientRequestId: 'request-1', mode: 'follow-up', text: 'Queued', attachmentIds: [] }] }}
error={null}
recoverableError={false}
acceptedCount={0}
attachments={[]}
submitting={false}
placeholder="Prompt"
onChange={vi.fn()}
onModeChange={onModeChange}
onSubmit={onSubmit}
onRecover={vi.fn()}
onAddFiles={vi.fn()}
onRemoveAttachment={vi.fn()}
/>,
);
expect(screen.getByText('1 条消息正在等待;本轮结算后会继续处理。')).toBeInTheDocument();
expect(screen.getByText('第 1 位 · 下一轮')).toBeInTheDocument();
fireEvent.change(screen.getByRole('combobox', { name: '消息发送方式' }), { target: { value: 'steer' } });
expect(onModeChange).toHaveBeenCalledWith('steer');
fireEvent.click(screen.getByRole('button', { name: '发送' }));
expect(onSubmit).toHaveBeenCalledOnce();
});
it('unlocks editing and offers recovery after the local Agent exits', async () => {
const onRecover = vi.fn();
const { CodingComposer } = await import('@/pages/Chat/CodingComposer');
render(
<CodingComposer
value="Draft remains editable"
editable
canSend={false}
preparing={false}
recovering={false}
runStatus="error"
mode="prompt"
queue={{ items: [] }}
error="本地 Agent 已中断,原请求未自动重发。"
recoverableError
acceptedCount={0}
attachments={[]}
submitting={false}
placeholder="Prompt"
onChange={vi.fn()}
onModeChange={vi.fn()}
onSubmit={vi.fn()}
onRecover={onRecover}
onAddFiles={vi.fn()}
onRemoveAttachment={vi.fn()}
/>,
);
expect(screen.getByRole('textbox')).toBeEnabled();
expect(screen.getByRole('alert')).toHaveTextContent('本地 Agent 已中断,原请求未自动重发。');
expect(screen.queryByText('当前对话正在处理。')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '重试' }));
expect(onRecover).toHaveBeenCalledOnce();
});
it('answers product interactions and explains stale responses', async () => {
interactionApi.respond.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('409 stale'));
const { CodingInteractionPanel } = await import('@/pages/Chat/CodingInteractionPanel');
const onSettled = vi.fn();
const { rerender } = render(
<CodingInteractionPanel
conversationId="conversation-1"
interactions={[{
id: 'interaction-select',
conversationId: 'conversation-1',
runId: 'run-1',
kind: 'select',
title: '选择方案',
options: [{ id: 'option-a', label: '方案 A' }],
status: 'pending',
}]}
onSettled={onSettled}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '方案 A' }));
await waitFor(() => expect(interactionApi.respond).toHaveBeenCalledWith('conversation-1', {
interactionId: 'interaction-select',
optionId: 'option-a',
}));
expect(onSettled).toHaveBeenCalledOnce();
rerender(
<CodingInteractionPanel
conversationId="conversation-1"
interactions={[{
id: 'interaction-input',
conversationId: 'conversation-1',
runId: 'run-1',
kind: 'input',
title: '输入名称',
status: 'pending',
}]}
/>,
);
fireEvent.change(screen.getByRole('textbox', { name: '输入名称的回答' }), { target: { value: 'Makelore' } });
fireEvent.click(screen.getByRole('button', { name: '提交回答' }));
expect(await screen.findByText(/请求可能已失效/)).toBeInTheDocument();
});
it('keeps model, thinking, metadata, and fork controls on the selected idle Conversation', async () => {
const { useProviderStore } = await import('@/stores/providers');
useProviderStore.setState({
accounts: [{
id: 'account-1',
vendorId: 'custom',
label: 'Work account',
authMode: 'api_key',
model: 'model-a',
fallbackModels: ['model-b'],
enabled: true,
isDefault: true,
createdAt: '2026-08-24T00:00:00.000Z',
updatedAt: '2026-08-24T00:00:00.000Z',
}],
vendors: [{ id: 'custom', name: 'Custom vendor' } as ProviderVendorInfo],
});
interactionApi.abort.mockResolvedValue(undefined);
interactionApi.model.mockResolvedValue({ model: null, modelResolution: 'required' });
interactionApi.thinking.mockResolvedValue({ model: null, modelResolution: 'required' });
const callbacks = {
rename: vi.fn(async () => undefined),
archive: vi.fn(async () => undefined),
unread: vi.fn(async () => undefined),
fork: vi.fn(async () => undefined),
refresh: vi.fn(async () => undefined),
recover: vi.fn(async () => undefined),
inspector: vi.fn(),
};
const { CodingConversationHeader } = await import('@/pages/Chat/CodingConversationHeader');
render(
<CodingConversationHeader
conversation={{
id: 'conversation-1',
agentId: 'agent-1',
title: 'Feature UI',
archivedAt: null,
unread: false,
createdAt: '2026-08-24T00:00:00.000Z',
updatedAt: '2026-08-24T00:00:00.000Z',
model: { accountId: 'account-1', modelId: 'model-a', thinkingLevel: 'medium' },
modelResolution: 'resolved',
}}
snapshot={{
schemaVersion: 1,
conversation: {
id: 'conversation-1',
projectId: 'project-1',
agentId: 'agent-1',
title: 'Feature UI',
model: {
model: { accountId: 'account-1', modelId: 'model-a', thinkingLevel: 'high' },
modelResolution: 'resolved',
availableThinkingLevels: ['high'],
},
},
nodes: [],
run: { status: 'idle' },
queue: { items: [] },
context: { usedTokens: 200, contextWindow: 1000, compaction: 'idle' },
pendingInteractions: [],
worker: { status: 'ready', generation: 1 },
cursor: { workerGeneration: 1, seq: 1 },
}}
connectionState="live"
onRename={callbacks.rename}
onArchive={callbacks.archive}
onToggleUnread={callbacks.unread}
onFork={callbacks.fork}
onRefresh={callbacks.refresh}
onRecover={callbacks.recover}
onOpenInspector={callbacks.inspector}
/>,
);
fireEvent.change(screen.getByRole('combobox', { name: '当前对话模型' }), {
target: { value: JSON.stringify(['account-1', 'model-b']) },
});
await waitFor(() => expect(interactionApi.model).toHaveBeenCalledWith('conversation-1', {
accountId: 'account-1',
modelId: 'model-b',
thinkingLevel: 'high',
}));
await waitFor(() => expect(callbacks.refresh).toHaveBeenCalledOnce());
const thinkingSelect = screen.getByRole('combobox', { name: '当前对话思考级别' });
expect(within(thinkingSelect).getAllByRole('option')).toHaveLength(1);
expect(within(thinkingSelect).getByRole('option', { name: '高思考' })).toBeInTheDocument();
expect(within(thinkingSelect).queryByRole('option', { name: '关闭思考' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '创建分支' }));
await waitFor(() => expect(callbacks.fork).toHaveBeenCalledOnce());
expect(screen.queryByText(/分享|回滚|待办/)).not.toBeInTheDocument();
});
it('clears a compact confirmation uncertainty when the authoritative run settles', async () => {
interactionApi.compact.mockRejectedValueOnce(Object.assign(
new Error('请求确认延迟,可能仍在执行。请等待结果,或中止/恢复后再重试。'),
{ details: { backendCode: 'CODING_REQUEST_UNCERTAIN' } },
));
const conversation = {
id: 'conversation-compact-uncertain',
agentId: 'agent-1',
title: 'Compact uncertainty',
archivedAt: null,
unread: false,
createdAt: '2026-08-25T00:00:00.000Z',
updatedAt: '2026-08-25T00:00:00.000Z',
model: { accountId: 'account-1', modelId: 'model-a', thinkingLevel: 'medium' as const },
modelResolution: 'resolved' as const,
};
const snapshot = (run: ConversationSnapshot['run'], seq = 1): ConversationSnapshot => ({
schemaVersion: 1,
conversation: {
id: conversation.id,
projectId: 'project-1',
agentId: conversation.agentId,
title: conversation.title,
model: { model: conversation.model, modelResolution: 'resolved' },
},
nodes: [],
run,
queue: { items: [] },
context: { usedTokens: 200, contextWindow: 1000, compaction: 'idle' },
pendingInteractions: [],
worker: { status: 'ready', generation: 1 },
cursor: { workerGeneration: 1, seq },
});
const callbacks = {
onRename: vi.fn(async () => undefined),
onArchive: vi.fn(async () => undefined),
onToggleUnread: vi.fn(async () => undefined),
onFork: vi.fn(async () => undefined),
onRefresh: vi.fn(async () => undefined),
onRecover: vi.fn(async () => undefined),
onOpenInspector: vi.fn(),
};
const { CodingConversationHeader } = await import('@/pages/Chat/CodingConversationHeader');
const { rerender } = render(
<CodingConversationHeader
conversation={conversation}
snapshot={snapshot({ status: 'idle' })}
connectionState="live"
{...callbacks}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '整理上下文' }));
expect(await screen.findByText(/请求确认延迟,可能仍在执行/)).toBeInTheDocument();
rerender(
<CodingConversationHeader
conversation={conversation}
snapshot={snapshot({
status: 'compacting',
runId: 'run-compact-uncertain',
error: {
code: 'CODING_REQUEST_UNCERTAIN',
message: '请求确认延迟,可能仍在执行。',
recoverable: true,
},
}, 2)}
connectionState="live"
{...callbacks}
/>,
);
expect(screen.getByText(/请求确认延迟,可能仍在执行/)).toBeInTheDocument();
rerender(
<CodingConversationHeader
conversation={conversation}
snapshot={snapshot({
status: 'idle',
runId: 'run-compact-uncertain',
settledAt: 12_000,
terminalReason: 'completed',
}, 3)}
connectionState="live"
{...callbacks}
/>,
);
await waitFor(() => expect(screen.queryByText(/请求确认延迟,可能仍在执行/))
.not.toBeInTheDocument());
});
it('blocks overlapping mutations but keeps abort and recover available while confirmation is uncertain', async () => {
interactionApi.abort.mockResolvedValue(undefined);
const recover = vi.fn(async () => undefined);
const fork = vi.fn(async () => undefined);
const { CodingConversationHeader } = await import('@/pages/Chat/CodingConversationHeader');
render(
<CodingConversationHeader
conversation={{
id: 'conversation-uncertain',
agentId: 'agent-1',
title: 'Slow provider',
archivedAt: null,
unread: false,
createdAt: '2026-08-25T00:00:00.000Z',
updatedAt: '2026-08-25T00:00:00.000Z',
model: { accountId: 'account-1', modelId: 'model-a', thinkingLevel: 'medium' },
modelResolution: 'resolved',
}}
snapshot={{
schemaVersion: 1,
conversation: {
id: 'conversation-uncertain',
projectId: 'project-1',
agentId: 'agent-1',
title: 'Slow provider',
model: {
model: { accountId: 'account-1', modelId: 'model-a', thinkingLevel: 'medium' },
modelResolution: 'resolved',
availableThinkingLevels: ['off', 'medium'],
},
},
nodes: [],
run: {
status: 'running',
runId: 'run-uncertain',
mode: 'prompt',
error: {
code: 'CODING_REQUEST_UNCERTAIN',
message: '请求确认延迟,可能仍在执行。',
recoverable: true,
},
},
queue: { items: [] },
context: { usedTokens: 200, contextWindow: 1000, compaction: 'idle' },
pendingInteractions: [],
worker: { status: 'ready', generation: 1 },
cursor: { workerGeneration: 1, seq: 2 },
}}
connectionState="live"
onRename={vi.fn(async () => undefined)}
onArchive={vi.fn(async () => undefined)}
onToggleUnread={vi.fn(async () => undefined)}
onFork={fork}
onRefresh={vi.fn(async () => undefined)}
onRecover={recover}
onOpenInspector={vi.fn()}
/>,
);
expect(screen.getByRole('combobox', { name: '当前对话模型' })).toBeDisabled();
expect(screen.getByRole('combobox', { name: '当前对话思考级别' })).toBeDisabled();
expect(screen.getByRole('button', { name: '整理上下文' })).toBeDisabled();
expect(screen.getByRole('button', { name: '创建分支' })).toBeDisabled();
expect(screen.queryByText('本地编程运行时暂时不可用')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '中止' }));
await waitFor(() => expect(interactionApi.abort).toHaveBeenCalledWith('conversation-uncertain'));
await waitFor(() => expect(screen.getByRole('button', { name: '恢复' })).toBeEnabled());
fireEvent.click(screen.getByRole('button', { name: '恢复' }));
await waitFor(() => expect(recover).toHaveBeenCalledOnce());
expect(fork).not.toHaveBeenCalled();
});
it('does not let an old tools load overwrite the newly selected Conversation inspector', async () => {
let resolveOld!: (value: { commands: Array<{ name: string; title: string; description: string; source: 'makelore' }> }) => void;
const oldCommands = new Promise<{ commands: Array<{ name: string; title: string; description: string; source: 'makelore' }> }>((resolve) => {
resolveOld = resolve;
});
inspectorApi.changes.mockResolvedValue({ changes: null });
inspectorApi.files.mockResolvedValue({ files: [] });
inspectorApi.skills.mockResolvedValue({ skills: [] });
inspectorApi.commands.mockImplementation((conversationId: string) => (
conversationId === 'conversation-old'
? oldCommands
: Promise.resolve({ commands: [{ name: 'new-command', title: 'New command', description: 'New', source: 'makelore' as const }] })
));
interactionApi.diagnostics.mockResolvedValue({ revision: { provider: 1, resources: 1 }, workers: [] });
const { CodingWorkspaceInspector } = await import('@/pages/Chat/CodingWorkspaceInspector');
const view = render(
<CodingWorkspaceInspector
key="conversation-old"
open
onOpenChange={vi.fn()}
conversationId="conversation-old"
agentId="agent-old"
snapshot={null}
onUseCommand={vi.fn()}
/>,
);
await waitFor(() => expect(inspectorApi.commands).toHaveBeenCalledWith('conversation-old'));
view.rerender(
<CodingWorkspaceInspector
key="conversation-new"
open
onOpenChange={vi.fn()}
conversationId="conversation-new"
agentId="agent-new"
snapshot={null}
onUseCommand={vi.fn()}
/>,
);
await waitFor(() => expect(inspectorApi.commands).toHaveBeenCalledWith('conversation-new'));
await waitFor(() => expect(screen.getByRole('button', { name: '刷新' })).toBeEnabled());
const commandsTab = screen.getByRole('tab', { name: '命令' });
fireEvent.mouseDown(commandsTab, { button: 0, ctrlKey: false });
await waitFor(() => expect(commandsTab).toHaveAttribute('aria-selected', 'true'));
expect(await screen.findByRole('button', { name: /\/new-command/ })).toBeInTheDocument();
resolveOld({ commands: [{ name: 'old-command', title: 'Old command', description: 'Old', source: 'makelore' }] });
await oldCommands;
await waitFor(() => expect(screen.queryByRole('button', { name: /\/old-command/ })).not.toBeInTheDocument());
expect(screen.getByRole('button', { name: /\/new-command/ })).toBeInTheDocument();
});
});