212 lines
7.4 KiB
TypeScript
212 lines
7.4 KiB
TypeScript
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
|
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { ProjectConfiguration } from '@/pages/ProjectConfiguration';
|
|
import { codingWorkspaceStore } from '@/stores/coding-workspace';
|
|
import { useProjectConfigStore } from '@/stores/project-config';
|
|
import { useProviderStore } from '@/stores/providers';
|
|
import type { ProviderAccount } from '@/lib/providers';
|
|
import type { CodingProjectAgent, CodingProjectConfig, CodingProjectSummary } from '@/types/coding-project';
|
|
import { createCodingProjectConfigV2 } from '@electron/coding-projects/project-config';
|
|
|
|
const getCodingSkillsMock = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock('@/lib/coding-product-tools', () => ({
|
|
getCodingSkills: (...args: unknown[]) => getCodingSkillsMock(...args),
|
|
}));
|
|
|
|
const NOW = '2026-09-06T00:00:00.000Z';
|
|
const project: CodingProjectSummary = {
|
|
id: 'project-1',
|
|
path: 'D:/projects/project-1',
|
|
name: 'Onboarding project',
|
|
createdAt: NOW,
|
|
updatedAt: NOW,
|
|
lastOpenedAt: NOW,
|
|
};
|
|
|
|
const configuredAccount: ProviderAccount = {
|
|
id: 'account-1',
|
|
vendorId: 'custom',
|
|
label: 'Test account',
|
|
authMode: 'api_key',
|
|
model: 'model-1',
|
|
fallbackModels: [],
|
|
enabled: true,
|
|
isDefault: true,
|
|
createdAt: NOW,
|
|
updatedAt: NOW,
|
|
};
|
|
|
|
const readyAgent: CodingProjectAgent = {
|
|
id: 'agent-1',
|
|
avatarId: 'avatar-01',
|
|
roleName: '项目智能体',
|
|
name: 'Builder',
|
|
builtIn: false,
|
|
enabled: true,
|
|
model: { accountId: configuredAccount.id, modelId: configuredAccount.model!, thinkingLevel: 'off' },
|
|
modelResolution: 'resolved',
|
|
skillIds: [],
|
|
responsibility: {
|
|
mission: '完成项目工作',
|
|
owns: [],
|
|
boundaries: [],
|
|
collaborators: [],
|
|
principles: [],
|
|
},
|
|
prompt: '',
|
|
archivedAt: null,
|
|
pinned: false,
|
|
createdAt: NOW,
|
|
updatedAt: NOW,
|
|
};
|
|
|
|
function LocationProbe() {
|
|
const location = useLocation();
|
|
return <output data-testid="location-path">{location.pathname}</output>;
|
|
}
|
|
|
|
function renderConfiguration(config: CodingProjectConfig) {
|
|
useProjectConfigStore.setState({
|
|
configsByProjectId: { [project.id]: config },
|
|
knowledgeByProjectId: { [project.id]: [] },
|
|
});
|
|
|
|
return render(
|
|
<MemoryRouter initialEntries={['/project-config']}>
|
|
<Routes>
|
|
<Route path="/project-config" element={<ProjectConfiguration />} />
|
|
<Route path="/models" element={<LocationProbe />} />
|
|
<Route path="/chat" element={<LocationProbe />} />
|
|
<Route path="/module-select" element={<LocationProbe />} />
|
|
</Routes>
|
|
</MemoryRouter>,
|
|
);
|
|
}
|
|
|
|
function setProviderModels() {
|
|
useProviderStore.setState({
|
|
accounts: [configuredAccount],
|
|
vendors: [],
|
|
});
|
|
}
|
|
|
|
describe('project configuration onboarding', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
getCodingSkillsMock.mockResolvedValue({ skills: [] });
|
|
|
|
const load = vi.fn().mockResolvedValue(undefined);
|
|
const reloadWorkspace = vi.fn().mockResolvedValue(undefined);
|
|
const refreshProviderSnapshot = vi.fn().mockResolvedValue(undefined);
|
|
|
|
codingWorkspaceStore.setState({
|
|
activeProject: project,
|
|
projects: [project],
|
|
conversations: [],
|
|
load: reloadWorkspace,
|
|
});
|
|
useProjectConfigStore.setState({ load });
|
|
useProviderStore.setState({
|
|
accounts: [],
|
|
vendors: [],
|
|
refreshProviderSnapshot,
|
|
});
|
|
});
|
|
|
|
it('strongly guides a project without models to model settings', async () => {
|
|
renderConfiguration(createCodingProjectConfigV2(NOW));
|
|
|
|
expect(await screen.findByTestId('project-agent-setup-callout')).toHaveTextContent('必需');
|
|
expect(screen.getByTestId('project-agent-setup-primary')).toHaveTextContent('先配置模型');
|
|
expect(screen.getByTestId('project-configuration-back-button')).toHaveAccessibleName('稍后设置');
|
|
|
|
fireEvent.click(screen.getByTestId('project-agent-setup-primary'));
|
|
|
|
expect(await screen.findByTestId('location-path')).toHaveTextContent('/models');
|
|
});
|
|
|
|
it('saves the first Agent as initialized and enters chat after creation', async () => {
|
|
setProviderModels();
|
|
const savedConfig = vi.fn((_projectId: string, nextConfig: CodingProjectConfig) => Promise.resolve(nextConfig));
|
|
const reloadWorkspace = vi.fn().mockResolvedValue(undefined);
|
|
useProjectConfigStore.setState({ save: savedConfig });
|
|
codingWorkspaceStore.setState({ load: reloadWorkspace });
|
|
|
|
renderConfiguration(createCodingProjectConfigV2(NOW));
|
|
fireEvent.click(await screen.findByTestId('project-agent-setup-primary'));
|
|
|
|
const dialog = await screen.findByRole('dialog', { name: '创建项目智能体' });
|
|
fireEvent.change(within(dialog).getByLabelText(/智能体名称/), { target: { value: 'Builder' } });
|
|
fireEvent.change(within(dialog).getByLabelText(/职责说明/), { target: { value: '完成项目工作' } });
|
|
fireEvent.click(within(dialog).getByRole('button', { name: '创建并进入对话' }));
|
|
|
|
await waitFor(() => expect(savedConfig).toHaveBeenCalledTimes(1));
|
|
expect(savedConfig).toHaveBeenCalledWith(project.id, expect.objectContaining({
|
|
initialized: true,
|
|
agents: [expect.objectContaining({
|
|
name: 'Builder',
|
|
modelResolution: 'resolved',
|
|
})],
|
|
}));
|
|
expect(reloadWorkspace).toHaveBeenCalledTimes(1);
|
|
expect(await screen.findByTestId('location-path')).toHaveTextContent('/chat');
|
|
});
|
|
|
|
it('labels the back action and returns a complete project to chat', async () => {
|
|
setProviderModels();
|
|
|
|
renderConfiguration({
|
|
...createCodingProjectConfigV2(NOW),
|
|
initialized: true,
|
|
agents: [readyAgent],
|
|
});
|
|
|
|
const backButton = await screen.findByTestId('project-configuration-back-button');
|
|
expect(backButton).toHaveAccessibleName('返回对话');
|
|
|
|
fireEvent.click(backButton);
|
|
|
|
expect(await screen.findByTestId('location-path')).toHaveTextContent('/chat');
|
|
});
|
|
|
|
it('offers a new Agent when the project contains only disabled Agents', async () => {
|
|
setProviderModels();
|
|
|
|
renderConfiguration({
|
|
...createCodingProjectConfigV2(NOW),
|
|
initialized: false,
|
|
agents: [{ ...readyAgent, enabled: false }],
|
|
});
|
|
|
|
const setupButton = await screen.findByTestId('project-agent-setup-primary');
|
|
expect(setupButton).toHaveTextContent('创建项目智能体');
|
|
fireEvent.click(setupButton);
|
|
|
|
expect(await screen.findByRole('dialog', { name: '创建项目智能体' })).toBeVisible();
|
|
});
|
|
|
|
it('confirms before the labelled back action discards Agent edits', async () => {
|
|
setProviderModels();
|
|
|
|
renderConfiguration({
|
|
...createCodingProjectConfigV2(NOW),
|
|
initialized: true,
|
|
agents: [readyAgent],
|
|
});
|
|
|
|
fireEvent.click(await screen.findByTestId(`project-agent-${readyAgent.id}`));
|
|
const editor = await screen.findByRole('dialog', { name: `${readyAgent.name} · 编辑智能体` });
|
|
fireEvent.change(within(editor).getByLabelText(/智能体名称/), { target: { value: 'Builder 2' } });
|
|
fireEvent.click(within(editor).getByRole('button', { name: '保存智能体' }));
|
|
await waitFor(() => expect(editor).not.toBeInTheDocument());
|
|
|
|
fireEvent.click(screen.getByTestId('project-configuration-back-button'));
|
|
const confirm = await screen.findByRole('dialog', { name: '放弃未保存的项目配置?' });
|
|
fireEvent.click(within(confirm).getByRole('button', { name: '返回对话' }));
|
|
|
|
expect(await screen.findByTestId('location-path')).toHaveTextContent('/chat');
|
|
});
|
|
});
|