feat: organize project conversations and add coding teacher side chat
This commit is contained in:
@@ -1,60 +0,0 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { AgentCreationDialog } from '@/components/coding/AgentCreationDialog';
|
||||
import type { CodingProjectAgent } from '@/types/coding-project';
|
||||
|
||||
const agent: CodingProjectAgent = {
|
||||
id: 'builder',
|
||||
avatarId: 'avatar-01',
|
||||
roleName: '实现者',
|
||||
name: 'Builder',
|
||||
builtIn: false,
|
||||
enabled: true,
|
||||
model: { accountId: 'account-a', modelId: 'model-a', thinkingLevel: 'medium' },
|
||||
modelResolution: 'resolved',
|
||||
skillIds: ['data-service'],
|
||||
responsibility: {
|
||||
mission: 'Implement changes', owns: [], boundaries: [], collaborators: [], principles: [],
|
||||
},
|
||||
prompt: '',
|
||||
archivedAt: null,
|
||||
pinned: false,
|
||||
createdAt: '2026-08-27T00:00:00Z',
|
||||
updatedAt: '2026-08-27T00:00:00Z',
|
||||
};
|
||||
|
||||
describe('AgentCreationDialog plugin Skill availability', () => {
|
||||
it('shows a retained disabled assignment as unavailable and persists it unchanged', async () => {
|
||||
const onUpdate = vi.fn(async () => undefined);
|
||||
render(<AgentCreationDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
agent={agent}
|
||||
modelOptions={[{
|
||||
key: JSON.stringify(['account-a', 'model-a']),
|
||||
accountId: 'account-a',
|
||||
modelId: 'model-a',
|
||||
label: 'Account A / Model A',
|
||||
availableThinkingLevels: null,
|
||||
}]}
|
||||
skills={[{
|
||||
id: 'data-service',
|
||||
name: 'Data Service',
|
||||
description: 'Project data tools',
|
||||
available: false,
|
||||
effective: false,
|
||||
}]}
|
||||
onUpdate={onUpdate}
|
||||
/>);
|
||||
|
||||
const checkbox = screen.getByRole('checkbox', { name: '绑定插件能力:Data Service' });
|
||||
expect(checkbox).toBeChecked();
|
||||
expect(checkbox).toBeDisabled();
|
||||
expect(screen.getByText('插件未启用;现有分配会保留,但当前不可用。')).toBeVisible();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存智能体' }));
|
||||
await waitFor(() => expect(onUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ skillIds: ['data-service'] }),
|
||||
));
|
||||
});
|
||||
});
|
||||
@@ -43,7 +43,8 @@ function withForkSource(
|
||||
};
|
||||
}
|
||||
|
||||
const runtimeSnapshot = deferred<ConversationSnapshot>();
|
||||
const navigate = vi.hoisted(() => vi.fn());
|
||||
vi.mock('react-router-dom', async (importOriginal) => ({ ...await importOriginal<typeof import('react-router-dom')>(), useNavigate: () => navigate }));
|
||||
|
||||
class FakeEventSource {
|
||||
onopen: ((event: Event) => void) | null = null;
|
||||
@@ -52,7 +53,6 @@ class FakeEventSource {
|
||||
addEventListener = vi.fn();
|
||||
}
|
||||
|
||||
const source = new FakeEventSource();
|
||||
const project: CodingProjectSummary = {
|
||||
id: 'project-1',
|
||||
name: 'Local project',
|
||||
@@ -216,18 +216,20 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
|
||||
conversationApi.snapshot.mockResolvedValue({ ...createLocalConversationSnapshot(project.id, conversation),
|
||||
run: { status: 'running', runId: 'run-1' } });
|
||||
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
|
||||
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
|
||||
const { codingConversationStore } = await import('@/stores/coding-conversations');
|
||||
render(<CodingChatPanel />);
|
||||
const menu = await screen.findByRole('button', { name: '对话操作:新对话' });
|
||||
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId).toBe(conversation.id));
|
||||
fireEvent.keyDown(menu, { key: 'Enter' });
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: '归档(任务继续运行)' }));
|
||||
await screen.findByText('没有最近会话,可新建对话或查看归档');
|
||||
await screen.findByTestId('coding-conversation-pending');
|
||||
expect(projectApi.create).not.toHaveBeenCalled();
|
||||
expect(conversationApi.abort).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '已归档(1)' }));
|
||||
fireEvent.click(within(screen.getByTestId('coding-conversation-sidebar')).getByRole('button', { name: '新对话', exact: true }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '查看归档对话' }));
|
||||
fireEvent.click(within(screen.getByTestId('project-conversations')).getByRole('button', { name: '新对话', exact: true }));
|
||||
await screen.findByText('此对话已归档,任务仍在运行。');
|
||||
expect(screen.queryByRole('button', { name: '发送', exact: true })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '恢复对话' }));
|
||||
@@ -245,7 +247,9 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
|
||||
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
|
||||
conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, conversation));
|
||||
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
|
||||
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
|
||||
render(<CodingChatPanel />);
|
||||
fireEvent.keyDown(await screen.findByRole('button', { name: '对话操作:旧标题' }), { key: 'Enter' });
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: '重命名' }));
|
||||
@@ -267,13 +271,15 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
config: { ...configForAgents([]), initialized: false },
|
||||
});
|
||||
projectApi.conversations.mockResolvedValue([]);
|
||||
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
|
||||
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
|
||||
|
||||
render(<CodingChatPanel onOpenProjectSettings={openProjectSettings} />);
|
||||
|
||||
expect(await screen.findByTestId('coding-chat-empty-agent')).toHaveTextContent('项目已准备好');
|
||||
expect(projectApi.create).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建项目智能体' }));
|
||||
fireEvent.click(within(screen.getByTestId('coding-chat-empty-agent')).getByRole('button', { name: '项目设置' }));
|
||||
expect(openProjectSettings).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
@@ -282,7 +288,9 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
const onCreateProject = vi.fn();
|
||||
const onOpenProject = vi.fn();
|
||||
const onOpenProjectSettings = vi.fn();
|
||||
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
|
||||
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
|
||||
|
||||
render(
|
||||
<CodingChatPanel
|
||||
@@ -311,44 +319,22 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
expect(onOpenProjectSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('makes the first-Conversation textarea editable while runtime metadata is held', async () => {
|
||||
it('keeps an empty project editable without creating a conversation until first send', async () => {
|
||||
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
|
||||
projectApi.config.mockResolvedValue({ project, config });
|
||||
projectApi.conversations.mockResolvedValue([]);
|
||||
projectApi.create.mockResolvedValue(conversation);
|
||||
conversationApi.snapshot.mockReturnValue(runtimeSnapshot.promise);
|
||||
conversationApi.events.mockResolvedValue(source as unknown as EventSource);
|
||||
conversationApi.submit.mockRejectedValue(new Error('Provider is intentionally not used'));
|
||||
conversationApi.recover.mockResolvedValue(undefined);
|
||||
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
|
||||
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { createLocalConversationSnapshot } = await import(
|
||||
'@/pages/Chat/coding-chat-snapshot'
|
||||
);
|
||||
const view = render(<CodingChatPanel />);
|
||||
|
||||
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
|
||||
conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, conversation));
|
||||
render(<CodingChatPanel />);
|
||||
const textbox = await screen.findByRole('textbox');
|
||||
expect(screen.getByRole('heading', { name: '你想让麦洛和你一起构建什么?' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('img', { name: '麦洛 M 标识' })).toBeInTheDocument();
|
||||
expect(textbox).toBeEnabled();
|
||||
expect(projectApi.create).toHaveBeenCalledOnce();
|
||||
expect(conversationApi.snapshot).toHaveBeenCalledWith(conversation.id);
|
||||
|
||||
fireEvent.change(textbox, { target: { value: 'First prompt' } });
|
||||
const sendButton = screen.getByRole('button', { name: '发送' });
|
||||
await waitFor(() => expect(sendButton).toBeEnabled());
|
||||
expect(textbox).toHaveValue('First prompt');
|
||||
expect(conversationApi.submit).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
runtimeSnapshot.resolve({
|
||||
...createLocalConversationSnapshot(project.id, conversation),
|
||||
worker: { status: 'ready', generation: 1 },
|
||||
cursor: { workerGeneration: 1, seq: 0 },
|
||||
});
|
||||
await runtimeSnapshot.promise;
|
||||
});
|
||||
view.unmount();
|
||||
expect(source.close).toHaveBeenCalled();
|
||||
expect(projectApi.create).not.toHaveBeenCalled();
|
||||
expect(conversationApi.snapshot).not.toHaveBeenCalled();
|
||||
fireEvent.change(textbox, {target:{value:'First prompt'}});
|
||||
fireEvent.click(screen.getByRole('button',{name:'发送'}));
|
||||
await waitFor(()=>expect(projectApi.create).toHaveBeenCalledOnce());
|
||||
});
|
||||
|
||||
it('rehydrates a preselected stale Conversation when the programming view mounts', async () => {
|
||||
@@ -357,7 +343,9 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
projectApi.conversations.mockResolvedValue([conversation]);
|
||||
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
|
||||
conversationApi.recover.mockResolvedValue(undefined);
|
||||
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
|
||||
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
|
||||
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
|
||||
const { codingConversationStore } = await import('@/stores/coding-conversations');
|
||||
const { codingWorkspaceStore } = await import('@/stores/coding-workspace');
|
||||
@@ -427,7 +415,9 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
.mockResolvedValueOnce(firstSource as unknown as EventSource)
|
||||
.mockResolvedValueOnce(resumedSource as unknown as EventSource);
|
||||
conversationApi.recover.mockResolvedValue(undefined);
|
||||
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
|
||||
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
|
||||
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
|
||||
const { codingConversationStore } = await import('@/stores/coding-conversations');
|
||||
const localSnapshot = createLocalConversationSnapshot(project.id, conversation);
|
||||
@@ -461,7 +451,7 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
await waitFor(() => expect(
|
||||
codingConversationStore.getState().entriesByConversationId[conversation.id]?.reducer.snapshot?.run.status,
|
||||
).toBe('running'));
|
||||
expect(screen.getByText('处理中')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('对话正在运行')).toBeInTheDocument();
|
||||
const snapshotCallsBeforeSleep = conversationApi.snapshot.mock.calls.length;
|
||||
conversationApi.snapshot.mockResolvedValue(terminalSnapshot);
|
||||
|
||||
@@ -495,50 +485,15 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('does not let a slow first-Conversation completion steal selection after an Agent switch', async () => {
|
||||
const slowCreate = deferred<CodingConversationMetadata>();
|
||||
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
|
||||
projectApi.config.mockResolvedValue({ project, config: configForAgents([agent, reviewer]) });
|
||||
projectApi.conversations.mockResolvedValue([reviewerConversation]);
|
||||
projectApi.create.mockReturnValue(slowCreate.promise);
|
||||
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
|
||||
conversationApi.submit.mockRejectedValue(new Error('Provider is intentionally not used'));
|
||||
conversationApi.recover.mockResolvedValue(undefined);
|
||||
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
|
||||
const { codingConversationStore } = await import('@/stores/coding-conversations');
|
||||
conversationApi.snapshot.mockImplementation(async (conversationId: string) => (
|
||||
createLocalConversationSnapshot(
|
||||
project.id,
|
||||
conversationId === reviewerConversation.id ? reviewerConversation : conversation,
|
||||
)
|
||||
));
|
||||
render(<CodingChatPanel />);
|
||||
|
||||
await screen.findByRole('textbox');
|
||||
await waitFor(() => expect(projectApi.create).toHaveBeenCalledWith({
|
||||
projectId: project.id,
|
||||
agentId: agent.id,
|
||||
title: '新对话',
|
||||
}));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Reviewer/ }));
|
||||
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId)
|
||||
.toBe(reviewerConversation.id));
|
||||
|
||||
await act(async () => {
|
||||
slowCreate.resolve(conversation);
|
||||
await slowCreate.promise;
|
||||
});
|
||||
expect(codingConversationStore.getState().selectedConversationId).toBe(reviewerConversation.id);
|
||||
});
|
||||
|
||||
it('renders Conversations as children of only the selected Agent', async () => {
|
||||
it('lists all project conversations without Agent navigation', async () => {
|
||||
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
|
||||
projectApi.config.mockResolvedValue({ project, config: configForAgents([agent, reviewer]) });
|
||||
projectApi.conversations.mockResolvedValue([conversation, reviewerConversation]);
|
||||
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
|
||||
conversationApi.recover.mockResolvedValue(undefined);
|
||||
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
|
||||
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
|
||||
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
|
||||
conversationApi.snapshot.mockImplementation(async (conversationId: string) => (
|
||||
createLocalConversationSnapshot(
|
||||
@@ -548,22 +503,12 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
));
|
||||
render(<CodingChatPanel />);
|
||||
|
||||
const builderButton = await screen.findByRole('button', { name: agent.name });
|
||||
const reviewerButton = screen.getByRole('button', { name: reviewer.name });
|
||||
expect(builderButton).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(reviewerButton).toHaveAttribute('aria-expanded', 'false');
|
||||
const builderConversations = screen.getByRole('group', { name: `${agent.name} 的对话` });
|
||||
expect(within(builderConversations).getByRole('button', { name: conversation.title })).toBeVisible();
|
||||
expect(screen.queryByText(reviewerConversation.title)).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(reviewerButton);
|
||||
|
||||
await waitFor(() => expect(reviewerButton).toHaveAttribute('aria-expanded', 'true'));
|
||||
expect(builderButton).toHaveAttribute('aria-expanded', 'false');
|
||||
const reviewerConversations = screen.getByRole('group', { name: `${reviewer.name} 的对话` });
|
||||
expect(within(reviewerConversations)
|
||||
.getByRole('button', { name: reviewerConversation.title })).toBeVisible();
|
||||
expect(screen.queryByText(conversation.title)).not.toBeInTheDocument();
|
||||
const list = await screen.findByRole('group', {name:'项目会话'});
|
||||
expect(within(list).getByRole('button',{name:conversation.title})).toBeVisible();
|
||||
expect(within(list).getByRole('button',{name:reviewerConversation.title})).toBeVisible();
|
||||
expect(screen.queryByRole('button',{name:agent.name})).not.toBeInTheDocument();
|
||||
fireEvent.click(within(list).getByRole('button',{name:reviewerConversation.title}));
|
||||
await waitFor(()=>expect(within(list).getByRole('button',{name:reviewerConversation.title})).toHaveAttribute('aria-current','page'));
|
||||
});
|
||||
|
||||
it('does not let a pending message fork steal selection after a Conversation switch', async () => {
|
||||
@@ -574,7 +519,9 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
|
||||
conversationApi.recover.mockResolvedValue(undefined);
|
||||
conversationApi.fork.mockReturnValue(forkFlight.promise);
|
||||
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
|
||||
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
|
||||
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
|
||||
const { codingConversationStore } = await import('@/stores/coding-conversations');
|
||||
conversationApi.snapshot.mockImplementation(async (conversationId: string) => (
|
||||
@@ -590,7 +537,7 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '从这里创建新对话分支' }));
|
||||
await waitFor(() => expect(conversationApi.fork)
|
||||
.toHaveBeenCalledWith(conversation.id, `entry-${conversation.id}`));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Reviewer/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: reviewerConversation.title }));
|
||||
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId)
|
||||
.toBe(reviewerConversation.id));
|
||||
expect(screen.getByRole('button', { name: '从这里创建新对话分支' })).toBeEnabled();
|
||||
@@ -622,7 +569,9 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
|
||||
conversationApi.recover.mockResolvedValue(undefined);
|
||||
conversationApi.fork.mockReturnValue(forkFlight.promise);
|
||||
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
|
||||
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
|
||||
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
|
||||
const { codingWorkspaceStore } = await import('@/stores/coding-workspace');
|
||||
const { codingConversationStore } = await import('@/stores/coding-conversations');
|
||||
@@ -664,7 +613,9 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
projectApi.conversations.mockResolvedValue([conversation, reviewerConversation]);
|
||||
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
|
||||
conversationApi.recover.mockResolvedValue(undefined);
|
||||
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
|
||||
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
|
||||
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
|
||||
const { codingWorkspaceStore } = await import('@/stores/coding-workspace');
|
||||
const { codingConversationStore } = await import('@/stores/coding-conversations');
|
||||
@@ -686,7 +637,7 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
});
|
||||
expect(await screen.findByText('metadata rejected')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Reviewer/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: reviewerConversation.title }));
|
||||
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId)
|
||||
.toBe(reviewerConversation.id));
|
||||
expect(screen.queryByText('metadata rejected')).not.toBeInTheDocument();
|
||||
@@ -698,7 +649,9 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
projectApi.conversations.mockResolvedValue([conversation, reviewerConversation]);
|
||||
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
|
||||
conversationApi.recover.mockResolvedValue(undefined);
|
||||
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
|
||||
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
|
||||
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
|
||||
const { codingConversationStore } = await import('@/stores/coding-conversations');
|
||||
conversationApi.snapshot.mockImplementation(async (conversationId: string) => (
|
||||
@@ -720,7 +673,7 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
expect(screen.getAllByRole('button', { name: '项目设置' })).toHaveLength(1);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '项目设置' }));
|
||||
expect(onOpenProjectSettings).toHaveBeenCalledOnce();
|
||||
expect(navigate).toHaveBeenCalledWith('/project-config');
|
||||
});
|
||||
|
||||
it('remounts pending interaction state when switching Conversations', async () => {
|
||||
@@ -731,7 +684,9 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
|
||||
conversationApi.recover.mockResolvedValue(undefined);
|
||||
conversationApi.respond.mockReturnValue(respondFlight.promise);
|
||||
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
|
||||
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
|
||||
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
|
||||
const { codingConversationStore } = await import('@/stores/coding-conversations');
|
||||
conversationApi.snapshot.mockImplementation(async (conversationId: string) => {
|
||||
@@ -757,7 +712,7 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
interactionId: `interaction-${conversation.id}`,
|
||||
confirmed: true,
|
||||
}));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Reviewer/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: reviewerConversation.title }));
|
||||
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId)
|
||||
.toBe(reviewerConversation.id));
|
||||
expect(await screen.findByText(`Confirm ${reviewerConversation.id}`)).toBeInTheDocument();
|
||||
@@ -778,7 +733,9 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
|
||||
conversationApi.submit.mockReturnValueOnce(pendingSubmit.promise);
|
||||
conversationApi.recover.mockResolvedValue(undefined);
|
||||
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
|
||||
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
|
||||
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
|
||||
const { codingConversationStore } = await import('@/stores/coding-conversations');
|
||||
conversationApi.snapshot.mockImplementation(async (conversationId: string) => (
|
||||
@@ -802,7 +759,7 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
await waitFor(() => expect(conversationApi.submit).toHaveBeenCalledOnce());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Reviewer/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: reviewerConversation.title }));
|
||||
await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue(''));
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'B prompt' } });
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '发送' })).toBeEnabled());
|
||||
@@ -835,7 +792,9 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
runId: 'run-retry',
|
||||
mode: input.mode,
|
||||
}));
|
||||
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
|
||||
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
|
||||
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
|
||||
const { codingConversationStore } = await import('@/stores/coding-conversations');
|
||||
conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, conversation));
|
||||
@@ -871,7 +830,9 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
undefined,
|
||||
{ backendCode: 'CODING_REQUEST_UNCERTAIN' },
|
||||
));
|
||||
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
|
||||
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
|
||||
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
|
||||
const { codingConversationStore } = await import('@/stores/coding-conversations');
|
||||
conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, conversation));
|
||||
@@ -949,7 +910,9 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
|
||||
conversationApi.recover.mockResolvedValue(undefined);
|
||||
conversationApi.abort.mockResolvedValue(undefined);
|
||||
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
|
||||
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
|
||||
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
|
||||
const runningSnapshot: ConversationSnapshot = {
|
||||
...createLocalConversationSnapshot(project.id, conversation),
|
||||
@@ -1018,7 +981,9 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
byteLength: file.size,
|
||||
}));
|
||||
});
|
||||
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
|
||||
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
|
||||
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
|
||||
const { codingConversationStore } = await import('@/stores/coding-conversations');
|
||||
conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, conversation));
|
||||
@@ -1083,7 +1048,9 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
? Promise.reject(new Error('图片附件无效。'))
|
||||
: upload.promise
|
||||
));
|
||||
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
|
||||
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
|
||||
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
|
||||
const { codingConversationStore } = await import('@/stores/coding-conversations');
|
||||
conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, conversation));
|
||||
@@ -1131,7 +1098,9 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
mime: file.type,
|
||||
byteLength: file.size,
|
||||
}));
|
||||
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
|
||||
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
|
||||
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
|
||||
const { codingConversationStore } = await import('@/stores/coding-conversations');
|
||||
conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, conversation));
|
||||
@@ -1191,7 +1160,9 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
byteLength: file.size,
|
||||
};
|
||||
});
|
||||
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
|
||||
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
|
||||
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
|
||||
const { codingConversationStore } = await import('@/stores/coding-conversations');
|
||||
conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, conversation));
|
||||
@@ -1240,7 +1211,9 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
runId: 'run-1',
|
||||
mode: input.mode,
|
||||
}));
|
||||
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
|
||||
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
|
||||
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
|
||||
const { codingConversationStore } = await import('@/stores/coding-conversations');
|
||||
const runningSnapshot: ConversationSnapshot = {
|
||||
|
||||
@@ -252,26 +252,12 @@ describe('PI-130 feature-complete Coding UI', () => {
|
||||
it('uses the custom title bar for the full-height Conversation columns', async () => {
|
||||
const originalPlatform = window.electron.platform;
|
||||
const { useSettingsStore } = await import('@/stores/settings');
|
||||
const { CodingConversationSidebar } = await import('@/pages/Chat/CodingConversationSidebar');
|
||||
const { CodingConversationHeader } = await import('@/pages/Chat/CodingConversationHeader');
|
||||
window.electron.platform = 'darwin';
|
||||
useSettingsStore.setState({ sidebarCollapsed: true });
|
||||
|
||||
render(
|
||||
<>
|
||||
<CodingConversationSidebar
|
||||
projectName="Layout project"
|
||||
agents={[]}
|
||||
conversations={[]}
|
||||
conversationSummaries={{}}
|
||||
selectedAgentId={null}
|
||||
selectedConversationId={null}
|
||||
creatingAgentIds={{}}
|
||||
onSelectAgent={vi.fn()}
|
||||
onSelectConversation={vi.fn()}
|
||||
onCreateConversation={vi.fn()}
|
||||
onOpenProjectSettings={vi.fn()}
|
||||
/>
|
||||
<CodingConversationHeader
|
||||
conversation={null}
|
||||
snapshot={null}
|
||||
@@ -282,13 +268,9 @@ describe('PI-130 feature-complete Coding UI', () => {
|
||||
</>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('coding-conversation-sidebar-titlebar')).toHaveClass('fixed', 'top-0', 'h-10');
|
||||
expect(screen.getByTestId('coding-conversation-sidebar-titlebar')).toHaveStyle({ left: '0px' });
|
||||
expect(screen.getByTestId('coding-conversation-header-titlebar')).toHaveClass('fixed', 'top-0', 'h-10');
|
||||
expect(screen.getByTestId('coding-conversation-header-titlebar')).toHaveStyle({ left: '256px' });
|
||||
expect(screen.queryByText('Pi 本地对话')).not.toBeInTheDocument();
|
||||
expect(within(screen.getByTestId('coding-conversation-sidebar'))
|
||||
.queryByTestId('coding-conversation-sidebar-header')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('coding-conversation-header-titlebar')).toHaveStyle({ left: '132px' });
|
||||
expect(screen.queryByTestId('coding-conversation-sidebar')).not.toBeInTheDocument();
|
||||
|
||||
window.electron.platform = originalPlatform;
|
||||
useSettingsStore.setState({ sidebarCollapsed: false });
|
||||
|
||||
@@ -77,7 +77,7 @@ describe('coding project durable identity', () => {
|
||||
const reopened = await projects.createProject(input);
|
||||
expect(reopened.config.projectId).toBe(PROJECT_ID);
|
||||
expect(reopened.config.projectType).toBe('custom');
|
||||
expect(reopened.config.agents).toEqual([agent]);
|
||||
expect(reopened.config.agents).toEqual([...created.config.agents, agent]);
|
||||
expect(reopened.knowledgeFiles).toEqual(['notes.md']);
|
||||
expect(await projects.getActiveProject()).toEqual(reopened.project);
|
||||
expect(await projects.listProjects()).toEqual([reopened.project]);
|
||||
|
||||
@@ -139,7 +139,7 @@ describe('coding project schema v2', () => {
|
||||
const conversationDurationMs = performance.now() - conversationStartedAt;
|
||||
|
||||
expect(project.id).toBe('project-stable-id');
|
||||
expect(config).toMatchObject({ schemaVersion: 2, agents: [] });
|
||||
expect(config).toMatchObject({ schemaVersion: 2, initialized: true, defaultAgentId: 'coding-default', agents: [{ id: 'coding-default', builtIn: true, modelResolution: 'required' }] });
|
||||
expect(agent).toMatchObject({
|
||||
id: 'implementer',
|
||||
name: 'Implementation Agent',
|
||||
|
||||
145
tests/unit/coding-teacher-ui.test.tsx
Normal file
145
tests/unit/coding-teacher-ui.test.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { TeacherChatPanel } from '@/pages/Chat/TeacherChatPanel';
|
||||
import type { TeacherDefinition, TeacherTopic } from '../../shared/coding-teacher';
|
||||
const api = vi.hoisted(() => ({
|
||||
config: vi.fn(),
|
||||
preview: vi.fn(),
|
||||
list: vi.fn(),
|
||||
create: vi.fn(),
|
||||
read: vi.fn(),
|
||||
send: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
save: vi.fn(),
|
||||
events: vi.fn(),
|
||||
}));
|
||||
vi.mock('@/lib/coding-teacher', () => ({
|
||||
teacherApi: api,
|
||||
teacherTopicsPath: (p: string, s: string) => p + '/' + s,
|
||||
}));
|
||||
const definition: TeacherDefinition = {
|
||||
schema_version: 1,
|
||||
teacher_id: 'coding-teacher',
|
||||
name: '老师',
|
||||
description: '',
|
||||
avatar_id: 'avatar-01',
|
||||
welcome_message: '欢迎',
|
||||
suggested_questions: [],
|
||||
system_prompt: '教学',
|
||||
skills: [],
|
||||
model: { model_id: 'qwen', reasoning_choice: { mode: 'default' } },
|
||||
limits: { max_input_tokens: 8000, max_output_tokens: 1500 },
|
||||
};
|
||||
function topic(id: string): TeacherTopic {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
revision: 1,
|
||||
id,
|
||||
accountId: 'user',
|
||||
projectId: 'p',
|
||||
sourceConversationId: 'c',
|
||||
definition,
|
||||
version: 1,
|
||||
createdAt: '2026-09-22',
|
||||
updatedAt: '2026-09-22',
|
||||
requests: [],
|
||||
};
|
||||
}
|
||||
const first = topic('first'),
|
||||
second = topic('second');
|
||||
let streams: Map<string, EventTarget & { close: ReturnType<typeof vi.fn> }>;
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
streams = new Map();
|
||||
api.config.mockResolvedValue({ enabled: true, published_version: 1, revision: 1, definition });
|
||||
api.list.mockResolvedValue({
|
||||
items: [
|
||||
{ id: 'first', title: '第一个' },
|
||||
{ id: 'second', title: '第二个' },
|
||||
],
|
||||
lastSelectedTopicId: 'first',
|
||||
});
|
||||
api.read.mockImplementation(async (_base, id) =>
|
||||
id === 'first' ? structuredClone(first) : structuredClone(second)
|
||||
);
|
||||
api.create.mockResolvedValue(first);
|
||||
api.events.mockImplementation(async (_base, id) => {
|
||||
const stream = Object.assign(new EventTarget(), { close: vi.fn() });
|
||||
streams.set(id, stream);
|
||||
return stream;
|
||||
});
|
||||
});
|
||||
async function ready() {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('combobox', { name: '老师话题' })).toHaveValue('first')
|
||||
);
|
||||
await waitFor(() => expect(streams.has('first')).toBe(true));
|
||||
}
|
||||
describe('teacher side chat', () => {
|
||||
it('retains failed input and retries with the same request id, then appends a reply only on click', async () => {
|
||||
const bringBack = vi.fn();
|
||||
api.send
|
||||
.mockRejectedValueOnce(new Error('网络暂不可用'))
|
||||
.mockImplementationOnce(async (_base, _id, input) => ({
|
||||
...first,
|
||||
revision: 3,
|
||||
requests: [
|
||||
{
|
||||
id: input.requestId,
|
||||
text: input.text,
|
||||
references: [],
|
||||
createdAt: 'now',
|
||||
sourceCursor: { workerGeneration: 1, seq: 1 },
|
||||
sourceCapturedAt: 'now',
|
||||
includedSourceMessageIds: [],
|
||||
omittedMessages: 0,
|
||||
status: 'completed',
|
||||
response: '教学回答',
|
||||
},
|
||||
],
|
||||
}));
|
||||
render(<TeacherChatPanel projectId="p" sourceId="c" onBringBack={bringBack} />);
|
||||
await ready();
|
||||
fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '解释代码' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }));
|
||||
await screen.findByText('网络暂不可用');
|
||||
expect(screen.getByLabelText('向老师提问')).toHaveValue('解释代码');
|
||||
fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }));
|
||||
await screen.findByText('教学回答');
|
||||
expect(api.send.mock.calls[0][2].requestId).toBe(api.send.mock.calls[1][2].requestId);
|
||||
expect(bringBack).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '带回主会话草稿' }));
|
||||
expect(bringBack).toHaveBeenCalledWith('教学回答');
|
||||
});
|
||||
it('ignores an old topic stream after switching and does not cancel when closed', async () => {
|
||||
const closed = vi.fn();
|
||||
const view = render(<TeacherChatPanel projectId="p" sourceId="c" onClose={closed} />);
|
||||
await ready();
|
||||
const old = streams.get('first')!;
|
||||
fireEvent.change(screen.getByRole('combobox', { name: '老师话题' }), {
|
||||
target: { value: 'second' },
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('combobox', { name: '老师话题' })).toHaveValue('second')
|
||||
);
|
||||
act(() =>
|
||||
old.dispatchEvent(
|
||||
new MessageEvent('snapshot', { data: JSON.stringify({ ...first, revision: 999 }) })
|
||||
)
|
||||
);
|
||||
expect(screen.getByRole('combobox', { name: '老师话题' })).toHaveValue('second');
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭老师' }));
|
||||
expect(closed).toHaveBeenCalledOnce();
|
||||
view.unmount();
|
||||
expect(api.cancel).not.toHaveBeenCalled();
|
||||
expect(old.close).toHaveBeenCalled();
|
||||
});
|
||||
it('shows disabled history while blocking a new question', async () => {
|
||||
api.config.mockResolvedValue({ enabled: false, definition });
|
||||
render(<TeacherChatPanel projectId="p" sourceId="c" />);
|
||||
await ready();
|
||||
fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '问题' } });
|
||||
expect(screen.getByRole('button', { name: '提问', exact: true })).toBeDisabled();
|
||||
expect(api.read).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
437
tests/unit/coding-teacher.test.ts
Normal file
437
tests/unit/coding-teacher.test.ts
Normal file
@@ -0,0 +1,437 @@
|
||||
// @vitest-environment node
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { CodingTeacherService } from '../../electron/coding-teacher/service';
|
||||
import { TeacherTopicStore } from '../../electron/coding-teacher/store';
|
||||
import { compileTeacherContext, sourceContext } from '../../electron/coding-teacher/context';
|
||||
import { streamTeacherReply } from '../../electron/coding-teacher/model-runner';
|
||||
import { TeacherError } from '../../electron/coding-teacher/config-client';
|
||||
import {
|
||||
createCodingProjectStore,
|
||||
createMemoryCodingProjectStorage,
|
||||
} from '../../electron/coding-projects/project-store';
|
||||
import { CodingProjectService } from '../../electron/coding-projects/project-service';
|
||||
import {
|
||||
ensureDefaultCodingAgent,
|
||||
createCodingProjectConfigV2,
|
||||
} from '../../electron/coding-projects/project-config';
|
||||
import { InMemoryConversationRuntime } from '../../electron/coding-runtime/in-memory-conversation-runtime';
|
||||
import type { TeacherDefinition, TeacherSourceContext } from '../../shared/coding-teacher';
|
||||
import type { ConversationSnapshot } from '../../shared/coding-conversation-contracts';
|
||||
import { parseNianCodeDeepLinkUrl } from '../../electron/main/app-deep-link';
|
||||
|
||||
const definition: TeacherDefinition = {
|
||||
schema_version: 1,
|
||||
teacher_id: 'coding-teacher',
|
||||
name: '编程老师',
|
||||
description: '',
|
||||
avatar_id: 'avatar-01',
|
||||
welcome_message: '一起学编程',
|
||||
suggested_questions: ['为什么?'],
|
||||
system_prompt: '通过问题引导思考。',
|
||||
skills: [
|
||||
{
|
||||
id: 'explain',
|
||||
name: '讲解',
|
||||
description: '',
|
||||
enabled: true,
|
||||
instructions_markdown: '使用具体的小例子。',
|
||||
},
|
||||
],
|
||||
model: { model_id: 'qwen', reasoning_choice: { mode: 'default' } },
|
||||
limits: { max_input_tokens: 8000, max_output_tokens: 1500 },
|
||||
};
|
||||
const context: TeacherSourceContext = {
|
||||
messages: [{ id: 'source-user', role: 'user', text: '创建计数器' }],
|
||||
cursor: { workerGeneration: 1, seq: 3 },
|
||||
capturedAt: '2026-09-22T00:00:00Z',
|
||||
};
|
||||
const roots: string[] = [];
|
||||
const services: CodingTeacherService[] = [];
|
||||
afterEach(async () => {
|
||||
await Promise.all(services.splice(0).map((service) => service.dispose()));
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
async function fixture() {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'coding-teacher-'));
|
||||
roots.push(root);
|
||||
const projects = new CodingProjectService(
|
||||
createCodingProjectStore(createMemoryCodingProjectStorage())
|
||||
);
|
||||
const created = await projects.createProject({
|
||||
projectPath: path.join(root, 'project'),
|
||||
identity: { kind: 'create' },
|
||||
});
|
||||
const source = await projects
|
||||
.conversationStore(created.project.path)
|
||||
.create({
|
||||
agentId: created.config.defaultAgentId!,
|
||||
title: '源码会话',
|
||||
model: null,
|
||||
modelResolution: 'required',
|
||||
});
|
||||
const scope = { projectId: created.project.id, sourceId: source.id };
|
||||
let enabled = true,
|
||||
version = 1,
|
||||
accountCurrent = true;
|
||||
let finish: () => void = () => undefined;
|
||||
const run = vi.fn(async (_messages, signal: AbortSignal, onText: (text: string) => void) => {
|
||||
onText('计数器保存一个数字。');
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
finish = resolve;
|
||||
signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true });
|
||||
});
|
||||
return { inputTokens: 20, outputTokens: 10 };
|
||||
});
|
||||
const account = {
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
binding: { accountKey: 'test', epoch: 1 },
|
||||
};
|
||||
const service = new CodingTeacherService({
|
||||
projects,
|
||||
runtime: new InMemoryConversationRuntime(),
|
||||
userDataDir: root,
|
||||
account: async () => account,
|
||||
assertAccount: () => {
|
||||
if (!accountCurrent) throw new TeacherError(401, 'teacher_account_changed', '账号变化');
|
||||
},
|
||||
availability: async () => ({ enabled, published_version: version, revision: version }),
|
||||
version: async (_account, v) => ({
|
||||
version: v,
|
||||
payload: { ...definition, name: '老师 v' + v },
|
||||
}),
|
||||
preview: async (_account, revision) => {
|
||||
if (revision !== 2) throw new TeacherError(409, 'teacher_draft_changed', '草稿变化');
|
||||
return { draft_revision: revision, payload: definition };
|
||||
},
|
||||
readSource: async () => structuredClone(context),
|
||||
prepareModel: async () => ({ inputLimit: 8000, run }),
|
||||
});
|
||||
services.push(service);
|
||||
return {
|
||||
root,
|
||||
projects,
|
||||
created,
|
||||
scope,
|
||||
service,
|
||||
run,
|
||||
finish: () => finish(),
|
||||
disable: () => {
|
||||
enabled = false;
|
||||
},
|
||||
nextVersion: () => {
|
||||
version++;
|
||||
},
|
||||
switchAccount: () => {
|
||||
accountCurrent = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
describe('cloud coding teacher', () => {
|
||||
it('persists a fixed version, deduplicates requests, and does not change coding metadata', async () => {
|
||||
const f = await fixture();
|
||||
const before = await readFile(
|
||||
path.join(f.created.project.path, '.makelore/conversations.json'),
|
||||
'utf8'
|
||||
);
|
||||
const topic = await f.service.create(f.scope);
|
||||
f.nextVersion();
|
||||
const next = await f.service.create(f.scope);
|
||||
expect(next.version).toBe(2);
|
||||
expect(topic.version).toBe(1);
|
||||
const input = { requestId: '22222222-2222-4222-8222-222222222222', text: '解释一下' };
|
||||
await f.service.send(f.scope, topic.id, input);
|
||||
await f.service.send(f.scope, topic.id, input);
|
||||
expect(f.run).toHaveBeenCalledOnce();
|
||||
await expect(
|
||||
f.service.send(f.scope, topic.id, { ...input, text: '不同问题' })
|
||||
).rejects.toMatchObject({ code: 'teacher_request_conflict' });
|
||||
f.finish();
|
||||
await vi.waitFor(async () =>
|
||||
expect((await f.service.read(f.scope, topic.id)).requests[0].status).toBe('completed')
|
||||
);
|
||||
expect(
|
||||
await readFile(path.join(f.created.project.path, '.makelore/conversations.json'), 'utf8')
|
||||
).toBe(before);
|
||||
expect((await f.service.list(f.scope)).items).toHaveLength(2);
|
||||
});
|
||||
it('honors disable for old topics while keeping readable history', async () => {
|
||||
const f = await fixture(),
|
||||
topic = await f.service.create(f.scope);
|
||||
f.disable();
|
||||
await expect(
|
||||
f.service.send(f.scope, topic.id, {
|
||||
requestId: '22222222-2222-4222-8222-222222222222',
|
||||
text: '问题',
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'teacher_disabled' });
|
||||
expect((await f.service.read(f.scope, topic.id)).version).toBe(1);
|
||||
expect(f.run).not.toHaveBeenCalled();
|
||||
});
|
||||
it('cancels partial replies and rejects another concurrent question', async () => {
|
||||
const f = await fixture(),
|
||||
topic = await f.service.create(f.scope),
|
||||
id = '22222222-2222-4222-8222-222222222222';
|
||||
await f.service.send(f.scope, topic.id, { requestId: id, text: '问题' });
|
||||
await expect(
|
||||
f.service.send(f.scope, topic.id, {
|
||||
requestId: '33333333-3333-4333-8333-333333333333',
|
||||
text: '另一个问题',
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'teacher_topic_busy' });
|
||||
await f.service.cancel(f.scope, topic.id, id);
|
||||
await vi.waitFor(async () =>
|
||||
expect((await f.service.read(f.scope, topic.id)).requests[0]).toMatchObject({
|
||||
status: 'cancelled',
|
||||
response: '计数器保存一个数字。',
|
||||
})
|
||||
);
|
||||
});
|
||||
it('retains interrupted requests on restart without submitting again', async () => {
|
||||
const f = await fixture(),
|
||||
topic = await f.service.create(f.scope),
|
||||
id = '22222222-2222-4222-8222-222222222222';
|
||||
await f.service.send(f.scope, topic.id, { requestId: id, text: '问题' });
|
||||
const store = new TeacherTopicStore(
|
||||
path.join(
|
||||
f.created.project.path,
|
||||
'.makelore/teacher-conversations',
|
||||
topic.accountId,
|
||||
f.scope.sourceId
|
||||
)
|
||||
);
|
||||
expect((await store.read(topic.id)).requests[0].status).toBe('interrupted');
|
||||
expect(f.run).toHaveBeenCalledOnce();
|
||||
await f.service.cancel(f.scope, topic.id, id);
|
||||
});
|
||||
it('checks exact preview revision and never reads project context for preview', async () => {
|
||||
const f = await fixture(),
|
||||
scope = { projectId: 'preview', sourceId: 'preview' };
|
||||
await expect(f.service.create(scope, 1, 'sample')).rejects.toMatchObject({
|
||||
code: 'teacher_draft_changed',
|
||||
});
|
||||
const topic = await f.service.create(scope, 2, '示例项目的计数器');
|
||||
await f.service.send(scope, topic.id, {
|
||||
requestId: '22222222-2222-4222-8222-222222222222',
|
||||
text: '怎么改进',
|
||||
});
|
||||
expect(JSON.stringify(f.run.mock.calls[0][0])).toContain('示例项目的计数器');
|
||||
expect(JSON.stringify(f.run.mock.calls[0][0])).not.toContain('创建计数器');
|
||||
f.finish();
|
||||
});
|
||||
it('rejects foreign message references before submitting a model request', async () => {
|
||||
const f = await fixture(),
|
||||
topic = await f.service.create(f.scope);
|
||||
await expect(
|
||||
f.service.send(f.scope, topic.id, {
|
||||
requestId: '22222222-2222-4222-8222-222222222222',
|
||||
text: '解释',
|
||||
references: [{ kind: 'message', messageId: 'another-source', text: '别的项目' }],
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'teacher_reference_invalid' });
|
||||
expect(f.run).not.toHaveBeenCalled();
|
||||
});
|
||||
it('cascades source deletion after cancelling its reply, without recreating files', async () => {
|
||||
const f = await fixture(),
|
||||
topic = await f.service.create(f.scope);
|
||||
await f.service.send(f.scope, topic.id, {
|
||||
requestId: '22222222-2222-4222-8222-222222222222',
|
||||
text: '问题',
|
||||
});
|
||||
await f.service.removeSource(f.scope.projectId, f.scope.sourceId);
|
||||
await expect(f.service.read(f.scope, topic.id)).rejects.toMatchObject({
|
||||
code: 'teacher_source_not_found',
|
||||
});
|
||||
await expect(
|
||||
readFile(
|
||||
path.join(
|
||||
f.created.project.path,
|
||||
'.makelore/teacher-conversations',
|
||||
topic.accountId,
|
||||
f.scope.sourceId,
|
||||
topic.id + '.json'
|
||||
)
|
||||
)
|
||||
).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
});
|
||||
it('fails account changes before dispatch', async () => {
|
||||
const f = await fixture(),
|
||||
topic = await f.service.create(f.scope);
|
||||
f.switchAccount();
|
||||
await f.service.send(f.scope, topic.id, {
|
||||
requestId: '22222222-2222-4222-8222-222222222222',
|
||||
text: '问题',
|
||||
});
|
||||
await vi.waitFor(async () =>
|
||||
expect((await f.service.read(f.scope, topic.id)).requests[0].status).toBe('failed')
|
||||
);
|
||||
expect(f.run).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
describe('teacher context and wire contract', () => {
|
||||
it('takes only complete user/assistant text and preserves the read cursor', () => {
|
||||
const snapshot = {
|
||||
nodes: [
|
||||
{
|
||||
kind: 'message',
|
||||
id: 'u',
|
||||
role: 'user',
|
||||
status: 'complete',
|
||||
blocks: [{ kind: 'text', status: 'complete', text: '用户问题' }],
|
||||
},
|
||||
{
|
||||
kind: 'message',
|
||||
id: 'a',
|
||||
role: 'assistant',
|
||||
status: 'complete',
|
||||
blocks: [
|
||||
{ kind: 'thinking', status: 'complete', text: 'private' },
|
||||
{ kind: 'text', status: 'complete', text: '完整回答' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'message',
|
||||
id: 'live',
|
||||
role: 'assistant',
|
||||
status: 'streaming',
|
||||
blocks: [{ kind: 'text', status: 'streaming', text: '未完成' }],
|
||||
},
|
||||
{ kind: 'tool', id: 'tool', args: { password: 'secret' } },
|
||||
],
|
||||
cursor: { workerGeneration: 2, seq: 10 },
|
||||
} as ConversationSnapshot;
|
||||
const selected = sourceContext(snapshot);
|
||||
expect(selected.messages.map((message) => message.text)).toEqual(['用户问题', '完整回答']);
|
||||
expect(selected.cursor).toEqual(snapshot.cursor);
|
||||
});
|
||||
it('trims old source messages but retains instructions, Skill, explicit quote and question', () => {
|
||||
const compiled = compileTeacherContext(
|
||||
definition,
|
||||
{
|
||||
...context,
|
||||
messages: [{ id: 'old', role: 'user', text: 'old'.repeat(4000) }, ...context.messages],
|
||||
},
|
||||
[],
|
||||
'为什么这样?',
|
||||
[{ kind: 'code', text: 'count += 1' }],
|
||||
1200
|
||||
);
|
||||
const text = JSON.stringify(compiled.messages);
|
||||
expect(text).toContain('通过问题引导思考');
|
||||
expect(text).toContain('使用具体的小例子');
|
||||
expect(text).toContain('count += 1');
|
||||
expect(compiled.omittedMessages).toBe(1);
|
||||
expect(() => compileTeacherContext(definition, context, [], 'x'.repeat(9000), [])).toThrow(
|
||||
'超过上下文预算'
|
||||
);
|
||||
});
|
||||
it('sends no tools, ignores reasoning deltas, and requires a terminal stream', async () => {
|
||||
const text: string[] = [];
|
||||
const fake = vi.fn(
|
||||
async () =>
|
||||
new Response(
|
||||
'data: {"choices":[{"delta":{"reasoning_content":"hidden","content":"答案"}}]}\n\ndata: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":3}}\n\ndata: [DONE]\n\n'
|
||||
)
|
||||
);
|
||||
const usage = await streamTeacherReply(
|
||||
{ base_url: 'https://gateway.test/v1', api_key: 'private' },
|
||||
'model',
|
||||
{},
|
||||
1500,
|
||||
[{ role: 'user', content: '问题' }],
|
||||
new AbortController().signal,
|
||||
(delta) => text.push(delta),
|
||||
fake
|
||||
);
|
||||
expect(text).toEqual(['答案']);
|
||||
expect(usage).toEqual({ inputTokens: 10, outputTokens: 3 });
|
||||
const input = fake.mock.calls[0] as unknown as [string, RequestInit];
|
||||
const body = JSON.parse(input[1].body as string);
|
||||
expect(body).not.toHaveProperty('tools');
|
||||
expect(input[0]).toBe('https://gateway.test/v1/chat/completions');
|
||||
await expect(
|
||||
streamTeacherReply(
|
||||
{ base_url: 'https://gateway.test/v1', api_key: 'private' },
|
||||
'model',
|
||||
{},
|
||||
1500,
|
||||
[],
|
||||
new AbortController().signal,
|
||||
() => undefined,
|
||||
async () => new Response('data: {"choices":[{"delta":{"content":"半句"}}]}\n\n')
|
||||
)
|
||||
).rejects.toMatchObject({ code: 'teacher_stream_interrupted' });
|
||||
});
|
||||
it('accepts only the exact credential-free preview link', () => {
|
||||
expect(
|
||||
parseNianCodeDeepLinkUrl('niancode://coding-teacher/preview?draft_revision=2')
|
||||
).toMatchObject({ type: 'teacher-preview', draftRevision: 2 });
|
||||
expect(
|
||||
parseNianCodeDeepLinkUrl('niancode://coding-teacher/preview?draft_revision=2&token=x')
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseNianCodeDeepLinkUrl('niancode://coding-teacher/preview?draft_revision=0')
|
||||
).toBeNull();
|
||||
});
|
||||
it('creates one default and preserves all legacy identities without reviving disabled Agents', () => {
|
||||
const base = createCodingProjectConfigV2();
|
||||
expect(base.agents).toHaveLength(1);
|
||||
expect(ensureDefaultCodingAgent(base)).toBe(base);
|
||||
const legacy = {
|
||||
...base,
|
||||
defaultAgentId: undefined,
|
||||
agents: [
|
||||
{ ...base.agents[0], id: 'old', enabled: false },
|
||||
{ ...base.agents[0], id: 'existing', pinned: true },
|
||||
],
|
||||
};
|
||||
const normalized = ensureDefaultCodingAgent(legacy);
|
||||
expect(normalized.defaultAgentId).toBe('existing');
|
||||
expect(normalized.agents).toEqual(legacy.agents);
|
||||
const disabled = ensureDefaultCodingAgent({ ...legacy, agents: [legacy.agents[0]] });
|
||||
expect(disabled.agents[0].enabled).toBe(false);
|
||||
expect(disabled.agents).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
import {mkdir, writeFile} from 'node:fs/promises';
|
||||
import {readCodingConversationHistory} from '../../electron/coding-projects/conversation-history';
|
||||
import {getPiManagedPaths} from '../../electron/coding-runtime/pi/resource-loader';
|
||||
it('reads only the durable active branch without starting a worker', async()=>{
|
||||
const f=await fixture();
|
||||
const metadata=(await f.projects.conversationStore(f.created.project.path).get(f.scope.sourceId))!;
|
||||
const folder=path.join(getPiManagedPaths(f.root).sessionsDir,f.scope.projectId);await mkdir(folder,{recursive:true});
|
||||
const entries=[
|
||||
{type:'session',id:'session'},
|
||||
{type:'message',id:'root',parentId:null,message:{role:'user',content:'开始学习'}},
|
||||
{type:'message',id:'abandoned',parentId:'root',message:{role:'user',content:'旧分支内容'}},
|
||||
{type:'message',id:'active',parentId:'root',message:{role:'user',content:'当前分支内容'}},
|
||||
];
|
||||
await writeFile(path.join(folder,'session-test.jsonl'),entries.map(entry=>JSON.stringify(entry)).join('\n'));
|
||||
const snapshot=await readCodingConversationHistory(f.root,f.scope.projectId,{...metadata,sessionKey:'session-test'});
|
||||
expect(sourceContext(snapshot).messages.map(message=>message.text)).toEqual(['开始学习','当前分支内容']);
|
||||
expect(snapshot.worker.status).toBe('stopped');
|
||||
});
|
||||
import {createServer} from 'node:http';
|
||||
import {handleCodingTeacherRoutes} from '../../electron/api/routes/coding-teacher';
|
||||
import type {HostApiContext} from '../../electron/api/context';
|
||||
it('serves topic acceptance and SSE snapshots without cancelling on stream close',async()=>{
|
||||
const f=await fixture();
|
||||
const server=createServer((req,res)=>{void handleCodingTeacherRoutes(req,res,new URL(req.url!,'http://localhost'),{codingProducts:{teacher:f.service}} as HostApiContext);});
|
||||
await new Promise<void>(resolve=>server.listen(0,'127.0.0.1',resolve));
|
||||
const address=server.address();if(!address||typeof address==='string')throw new Error('no address');
|
||||
const origin='http://127.0.0.1:'+address.port;
|
||||
const base=origin+'/api/coding/projects/'+f.scope.projectId+'/conversations/'+f.scope.sourceId+'/teacher-topics';
|
||||
try {
|
||||
const created=await fetch(base,{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});expect(created.status).toBe(201);
|
||||
const topic=await created.json();const requestId='22222222-2222-4222-8222-222222222222';
|
||||
const accepted=await fetch(base+'/'+topic.id+'/messages',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({requestId,text:'讲解一下'})});expect(accepted.status).toBe(202);
|
||||
const stream=await fetch(base+'/'+topic.id+'/events');expect(stream.headers.get('content-type')).toContain('text/event-stream');
|
||||
const reader=stream.body!.getReader();const chunk=await reader.read();expect(new TextDecoder().decode(chunk.value)).toContain('event: snapshot');await reader.cancel();
|
||||
expect((await f.service.read(f.scope,topic.id)).requests[0].status).toBe('running');
|
||||
const cancelled=await fetch(base+'/'+topic.id+'/requests/'+requestId+'/cancel',{method:'POST'});expect(cancelled.status).toBe(200);
|
||||
await vi.waitFor(async()=>expect((await f.service.read(f.scope,topic.id)).requests[0].status).toBe('cancelled'));
|
||||
expect((await fetch(origin+'/api/coding/teacher/config',{method:'POST'})).status).toBe(405);
|
||||
} finally {server.closeAllConnections();await new Promise<void>((resolve,reject)=>server.close(error=>error?reject(error):resolve()));}
|
||||
});
|
||||
@@ -306,3 +306,21 @@ describe('coding workspace store', () => {
|
||||
expect(store.getState().conversationErrorsByProjectId[secondProject.id]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the newer project visible when an earlier activation finishes late', async () => {
|
||||
const second = {...project,id:'project-2',name:'Second'};
|
||||
const delayed = deferred<CodingProjectSummary>();
|
||||
let active = project.id;
|
||||
const store = createCodingWorkspaceStore({
|
||||
listProjects: async () => ({projects:[project,second],activeProjectId:active}),
|
||||
getConfig: async (id) => ({project:id===project.id?project:second,config:config([agent('default')])}),
|
||||
listConversations: async id => [conversation(id+'-chat','default')],
|
||||
setActiveProject: async id => {if(id===project.id)return delayed.promise;active=id;return second;},
|
||||
});
|
||||
await store.getState().load();
|
||||
const old = store.getState().setActiveProject(project.id);
|
||||
const latest = store.getState().setActiveProject(second.id);
|
||||
delayed.resolve(project);await Promise.all([old, latest]);
|
||||
expect(store.getState().activeProjectId).toBe(second.id);
|
||||
expect(store.getState().conversations[0].id).toBe('project-2-chat');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user