feat: update Makelore modules and conversations
This commit is contained in:
@@ -3,14 +3,40 @@ import { MemoryRouter } from 'react-router-dom';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ProjectConfiguration } from '@/pages/ProjectConfiguration';
|
||||
import { useOpencodeStore } from '@/stores/opencode';
|
||||
import { useProjectConversationStore } from '@/stores/project-conversations';
|
||||
import { useProjectConfigStore } from '@/stores/project-config';
|
||||
import { useProviderStore } from '@/stores/providers';
|
||||
import { createProjectConfig } from '../../shared/project-config';
|
||||
import { createProjectConversationState, removeProjectSessionMetadata, upsertProjectSessionMetadata } from '../../shared/project-conversations';
|
||||
|
||||
const hostApiFetchMock = vi.fn();
|
||||
vi.mock('@/lib/host-api', () => ({ hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args) }));
|
||||
|
||||
describe('ProjectConfiguration', () => {
|
||||
const project = { id: 'prj_config', path: 'D:/repo/config', name: 'config', createdAt: '', updatedAt: '', lastOpenedAt: '' };
|
||||
const account = {
|
||||
id: 'openai-account',
|
||||
vendorId: 'openai',
|
||||
label: 'OpenAI',
|
||||
authMode: 'api_key' as const,
|
||||
model: 'gpt-4o-mini',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
createdAt: '2026-07-11T00:00:00.000Z',
|
||||
updatedAt: '2026-07-11T00:00:00.000Z',
|
||||
};
|
||||
const status = {
|
||||
id: account.id,
|
||||
name: account.label,
|
||||
type: account.vendorId,
|
||||
model: account.model,
|
||||
enabled: true,
|
||||
hasKey: true,
|
||||
keyMasked: 'sk-***',
|
||||
createdAt: account.createdAt,
|
||||
updatedAt: account.updatedAt,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
@@ -26,114 +52,103 @@ describe('ProjectConfiguration', () => {
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
const config = createProjectConfig('standard-development');
|
||||
const config = createProjectConfig();
|
||||
useOpencodeStore.setState({ activeProject: project, projects: [project] });
|
||||
useProjectConfigStore.setState({ configsByProjectId: { [project.id]: config }, knowledgeByProjectId: { [project.id]: [] }, errorsByProjectId: {}, loadingProjectId: null });
|
||||
useProviderStore.setState({
|
||||
accounts: [account],
|
||||
statuses: [status],
|
||||
defaultAccountId: account.id,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
|
||||
if (path === `/api/opencode/projects/config?projectId=${project.id}`) return { status: 'valid', config, knowledgeFiles: [] };
|
||||
if (path === '/api/opencode/skills') return { skills: [
|
||||
{ name: 'youth-plain-language', description: 'Youth language.', location: 'D:/repo/.opencode/skills/youth-plain-language/SKILL.md' },
|
||||
{ name: 'pm-project-plan', description: 'Plan projects.', location: 'D:/repo/.opencode/skills/pm-project-plan/SKILL.md' },
|
||||
] };
|
||||
if (path === '/api/opencode/skills') return { skills: [] };
|
||||
if (path === '/api/provider-accounts') return [account];
|
||||
if (path === '/api/provider-accounts/key-info') return [{ accountId: account.id, hasKey: true, keyMasked: 'sk-***' }];
|
||||
if (path === '/api/provider-vendors') return [];
|
||||
if (path === '/api/provider-accounts/default') return { accountId: account.id };
|
||||
if (path === '/api/opencode/projects/config' && init?.method === 'PUT') return { success: true, config: { ...(JSON.parse(init.body as string).config), initialized: true }, knowledgeFiles: [] };
|
||||
if (path === '/api/opencode/projects/remove' && init?.method === 'POST') return { success: true, removedProjectId: project.id, projects: [], activeProject: null };
|
||||
throw new Error(`Unexpected path ${path}`);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders compact project resources in the requested order and emphasizes fixed-size Agent cards', async () => {
|
||||
it('shows an empty contact list for a new project', async () => {
|
||||
render(<MemoryRouter><ProjectConfiguration /></MemoryRouter>);
|
||||
const heading = await screen.findByText('在这里配置你的项目基础!');
|
||||
expect(heading.tagName).toBe('H1');
|
||||
expect(heading.parentElement).not.toHaveClass('border-4');
|
||||
expect(screen.queryByText('管理资源,配置伙伴,然后开始创作')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(project.path)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('已初始化')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('待初始化')).not.toBeInTheDocument();
|
||||
const agentCards = screen.getAllByTestId(/^project-agent-/);
|
||||
expect(agentCards).toHaveLength(4);
|
||||
expect(screen.getByTestId('agent-cards-scroll-region')).toHaveClass('p-2', '-m-2');
|
||||
expect(agentCards[0]).toHaveClass('h-[260px]', 'w-[210px]');
|
||||
expect(agentCards[0]?.querySelector('img')).toHaveClass('mx-auto');
|
||||
const resourceCards = screen.getAllByTestId(/^resource-card-/);
|
||||
expect(resourceCards.map((card) => card.textContent)).toEqual([
|
||||
expect.stringContaining('大脑(大语言模型)'),
|
||||
expect.stringContaining('工具箱(技能)'),
|
||||
expect.stringContaining('笔记本(知识库)'),
|
||||
]);
|
||||
expect(screen.queryByTestId('resource-card-publish')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('superpowers-card')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId('resource-card-skills'));
|
||||
expect(screen.getByText('这里展示已安装技能;绑定请进入伙伴配置。')).toBeInTheDocument();
|
||||
const superpowersToggle = screen.getByRole('switch', { name: '启用 Superpowers' });
|
||||
expect(superpowersToggle).toHaveAttribute('aria-checked', 'true');
|
||||
expect(screen.getByText('已启用')).toBeInTheDocument();
|
||||
fireEvent.click(superpowersToggle);
|
||||
expect(superpowersToggle).toHaveAttribute('aria-checked', 'false');
|
||||
expect(screen.getByText('已关闭')).toBeInTheDocument();
|
||||
await waitFor(() => expect(hostApiFetchMock.mock.calls.filter(([path]) => path === '/api/opencode/skills')).toHaveLength(2));
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭' }));
|
||||
|
||||
fireEvent.click(screen.getByTestId('resource-card-models'));
|
||||
expect(screen.getByRole('heading', { name: '大脑(大语言模型)', level: 2 })).toBeInTheDocument();
|
||||
expect(screen.getByText('项目默认模型')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭' }));
|
||||
await waitFor(() => expect(screen.queryByRole('heading', { name: '大脑(大语言模型)', level: 2 })).not.toBeInTheDocument());
|
||||
expect(await screen.findByText('配置你的项目空间')).toBeInTheDocument();
|
||||
expect(screen.getByText(/新项目还没有联系人/)).toBeInTheDocument();
|
||||
expect(screen.getByText('还没有联系人,点击右上角创建第一个。')).toBeInTheDocument();
|
||||
expect(screen.queryAllByTestId(/^project-agent-/)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('renders the promotion Agent last for a game project', async () => {
|
||||
const gameConfig = createProjectConfig('game-development');
|
||||
useProjectConfigStore.setState({ configsByProjectId: { [project.id]: gameConfig }, knowledgeByProjectId: { [project.id]: [] }, errorsByProjectId: {}, loadingProjectId: null });
|
||||
hostApiFetchMock.mockImplementation(async (path: string) => {
|
||||
if (path === `/api/opencode/projects/config?projectId=${project.id}`) return { status: 'valid', config: gameConfig, knowledgeFiles: [] };
|
||||
if (path === '/api/opencode/skills') return { skills: [{ name: 'youth-plain-language', description: 'Youth language.', location: 'D:/repo/.opencode/skills/youth-plain-language/SKILL.md' }] };
|
||||
it('creates a contact with the required basics and leaves advanced settings empty', async () => {
|
||||
render(<MemoryRouter><ProjectConfiguration /></MemoryRouter>);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '新增伙伴' }));
|
||||
|
||||
expect(screen.getByRole('dialog', { name: '创建项目联系人' })).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByLabelText(/联系人名称/), { target: { value: '小明' } });
|
||||
fireEvent.change(screen.getByLabelText(/职责简述/), { target: { value: '负责把想法拆成步骤。' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '更换头像' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '选择头像:像素伙伴 3' }));
|
||||
fireEvent.change(screen.getByLabelText('Agent 提示词'), { target: { value: '先拆解目标,再给出执行步骤。' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建伙伴' }));
|
||||
await waitFor(() => expect(screen.queryByRole('dialog', { name: '创建项目联系人' })).not.toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认并完成初始化' }));
|
||||
|
||||
await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith('/api/opencode/projects/config', expect.objectContaining({ method: 'PUT' })));
|
||||
const saveCall = hostApiFetchMock.mock.calls.find(([path, init]) => path === '/api/opencode/projects/config' && (init as RequestInit | undefined)?.method === 'PUT');
|
||||
const savedConfig = JSON.parse((saveCall?.[1] as RequestInit).body as string).config;
|
||||
expect(savedConfig.initialized).toBe(true);
|
||||
expect(savedConfig.agents).toEqual([expect.objectContaining({
|
||||
name: '小明',
|
||||
avatarId: 'avatar-03',
|
||||
model: 'openai/gpt-4o-mini',
|
||||
prompt: '先拆解目标,再给出执行步骤。',
|
||||
skillIds: [],
|
||||
responsibility: expect.objectContaining({ mission: '负责把想法拆成步骤。' }),
|
||||
})]);
|
||||
});
|
||||
|
||||
it('requires confirmation before archiving a contact in settings', async () => {
|
||||
const config = createProjectConfig();
|
||||
const contact = {
|
||||
id: 'agent-settings',
|
||||
avatarId: 'avatar-01',
|
||||
roleName: '项目伙伴',
|
||||
name: '小红',
|
||||
builtIn: false,
|
||||
enabled: true,
|
||||
model: 'openai/gpt-4o-mini',
|
||||
skillIds: [],
|
||||
responsibility: { mission: '负责设计体验。', owns: [], boundaries: [], collaborators: [], principles: [] },
|
||||
prompt: '',
|
||||
archivedAt: null,
|
||||
pinned: false,
|
||||
};
|
||||
const configWithContact = { ...config, initialized: true, agents: [contact] };
|
||||
useProjectConfigStore.setState({ configsByProjectId: { [project.id]: configWithContact }, knowledgeByProjectId: { [project.id]: [] }, errorsByProjectId: {}, loadingProjectId: null });
|
||||
hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
|
||||
if (path === `/api/opencode/projects/config?projectId=${project.id}`) return { status: 'valid', config: configWithContact, knowledgeFiles: [] };
|
||||
if (path === '/api/opencode/skills') return { skills: [] };
|
||||
if (path === '/api/provider-accounts') return [account];
|
||||
if (path === '/api/provider-accounts/key-info') return [{ accountId: account.id, hasKey: true, keyMasked: 'sk-***' }];
|
||||
if (path === '/api/provider-vendors') return [];
|
||||
if (path === '/api/provider-accounts/default') return { accountId: account.id };
|
||||
if (path === '/api/opencode/projects/conversations') return { state: { schemaVersion: 1, sessions: [], updatedAt: new Date().toISOString() } };
|
||||
if (path === '/api/opencode/projects/config' && init?.method === 'PUT') return { success: true, config: JSON.parse(init.body as string).config, knowledgeFiles: [] };
|
||||
throw new Error(`Unexpected path ${path}`);
|
||||
});
|
||||
|
||||
render(<MemoryRouter><ProjectConfiguration /></MemoryRouter>);
|
||||
const cards = await screen.findAllByTestId(/^project-agent-/);
|
||||
expect(cards).toHaveLength(5);
|
||||
expect(cards.at(-1)).toHaveTextContent('运营宣传角色');
|
||||
});
|
||||
|
||||
it('edits a partner name beside a readonly role and one prompt module', async () => {
|
||||
render(<MemoryRouter><ProjectConfiguration /></MemoryRouter>);
|
||||
fireEvent.click((await screen.findAllByTestId(/^project-agent-/))[0]!);
|
||||
expect(screen.getByRole('heading', { name: '基础设置' })).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('启用此 Agent')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Agent 提示词' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: '绑定技能' })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('搜索技能')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('绑定技能:青少年通俗表达')).toBeDisabled();
|
||||
expect(screen.getByText('所有伙伴必需')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('职能名称')).toHaveValue('产品规划');
|
||||
expect(screen.getByLabelText('职能名称')).toHaveAttribute('readonly');
|
||||
expect((screen.getByLabelText('Agent 提示词') as HTMLTextAreaElement).value).toContain('## 核心职责');
|
||||
expect(screen.getByLabelText('模型覆盖(可选)').tagName).toBe('SELECT');
|
||||
expect(screen.getAllByRole('button', { name: /^选择头像:/ })).toHaveLength(16);
|
||||
fireEvent.click(screen.getByRole('button', { name: '选择头像:像素伙伴 16' }));
|
||||
expect(screen.getByRole('button', { name: '选择头像:像素伙伴 16' })).toHaveAttribute('aria-pressed', 'true');
|
||||
fireEvent.click(screen.getByRole('button', { name: '完成配置' }));
|
||||
await waitFor(() => expect(screen.queryByLabelText('搜索技能')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('completes initialization after all partner names are filled', async () => {
|
||||
render(<MemoryRouter><ProjectConfiguration /></MemoryRouter>);
|
||||
fireEvent.click(screen.getByTestId('resource-card-skills'));
|
||||
fireEvent.click(await screen.findByRole('switch', { name: '启用 Superpowers' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭' }));
|
||||
const cards = await screen.findAllByTestId(/^project-agent-/);
|
||||
for (const [index, card] of cards.entries()) {
|
||||
fireEvent.click(card);
|
||||
fireEvent.change(screen.getByLabelText('伙伴名字'), { target: { value: `伙伴${index + 1}` } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭' }));
|
||||
}
|
||||
fireEvent.click(await screen.findByRole('button', { name: '确认并完成初始化' }));
|
||||
await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith('/api/opencode/projects/config', expect.objectContaining({ method: 'PUT', body: expect.stringContaining('"initialized":true') })));
|
||||
const saveCall = hostApiFetchMock.mock.calls.find(([path, init]) => path === '/api/opencode/projects/config' && (init as RequestInit | undefined)?.method === 'PUT');
|
||||
expect(saveCall?.[1]).toEqual(expect.objectContaining({ body: expect.stringContaining('"superpowersEnabled":false') }));
|
||||
fireEvent.click(await screen.findByTestId('project-agent-agent-settings'));
|
||||
fireEvent.click(screen.getByRole('button', { name: '归档联系人' }));
|
||||
expect(screen.getByRole('dialog', { name: '归档联系人“小红”?' })).toBeInTheDocument();
|
||||
expect(hostApiFetchMock.mock.calls.some(([path]) => path === '/api/opencode/projects/config')).toBe(false);
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消' }));
|
||||
expect(screen.queryByRole('dialog', { name: '归档联系人“小红”?' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('requires explicit confirmation before removing a project', async () => {
|
||||
@@ -152,4 +167,65 @@ describe('ProjectConfiguration', () => {
|
||||
expect(useOpencodeStore.getState().projects).toEqual([]);
|
||||
expect(useOpencodeStore.getState().activeProject).toBeNull();
|
||||
});
|
||||
|
||||
it('permanently deletes archived custom contacts and their sessions', async () => {
|
||||
const contact = {
|
||||
id: 'agent-archived-delete',
|
||||
avatarId: 'avatar-01',
|
||||
roleName: '项目伙伴',
|
||||
name: '待删除伙伴',
|
||||
builtIn: false,
|
||||
enabled: true,
|
||||
model: 'openai/gpt-4o-mini',
|
||||
skillIds: [],
|
||||
responsibility: { mission: '负责清理测试数据。', owns: [], boundaries: [], collaborators: [], principles: [] },
|
||||
prompt: '',
|
||||
archivedAt: '2026-07-29T00:00:00.000Z',
|
||||
pinned: false,
|
||||
};
|
||||
const config = { ...createProjectConfig(), initialized: true, agents: [contact] };
|
||||
let conversationState = upsertProjectSessionMetadata(
|
||||
createProjectConversationState(),
|
||||
'ses-archived-delete',
|
||||
contact.id,
|
||||
'2026-07-29T00:00:00.000Z',
|
||||
);
|
||||
conversationState = {
|
||||
...conversationState,
|
||||
sessions: conversationState.sessions.map((session) => ({ ...session, archivedAt: '2026-07-29T00:00:00.000Z' })),
|
||||
};
|
||||
useOpencodeStore.setState({ activeProject: project, projects: [project], sessions: [{ id: 'ses-archived-delete', title: '旧会话' }] });
|
||||
useProjectConversationStore.setState({ statesByProjectId: { [project.id]: conversationState } });
|
||||
useProjectConfigStore.setState({ configsByProjectId: { [project.id]: config }, knowledgeByProjectId: { [project.id]: [] }, errorsByProjectId: {}, loadingProjectId: null });
|
||||
hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
|
||||
if (path === `/api/opencode/projects/config?projectId=${project.id}`) return { status: 'valid', config, knowledgeFiles: [] };
|
||||
if (path === '/api/opencode/skills') return { skills: [] };
|
||||
if (path === '/api/provider-accounts') return [account];
|
||||
if (path === '/api/provider-accounts/key-info') return [{ accountId: account.id, hasKey: true, keyMasked: 'sk-***' }];
|
||||
if (path === '/api/provider-vendors') return [];
|
||||
if (path === '/api/provider-accounts/default') return { accountId: account.id };
|
||||
if (path === `/api/opencode/projects/conversations?projectId=${project.id}`) return { state: conversationState };
|
||||
if (path === '/api/opencode/sessions/ses-archived-delete' && init?.method === 'DELETE') return { success: true };
|
||||
if (path === '/api/opencode/projects/conversations' && init?.method === 'POST') {
|
||||
conversationState = removeProjectSessionMetadata(conversationState, 'ses-archived-delete');
|
||||
return { success: true, state: conversationState };
|
||||
}
|
||||
if (path === '/api/opencode/projects/config' && init?.method === 'PUT') return { success: true, config: JSON.parse(init.body as string).config, knowledgeFiles: [] };
|
||||
throw new Error(`Unexpected path ${path}`);
|
||||
});
|
||||
|
||||
render(<MemoryRouter><ProjectConfiguration /></MemoryRouter>);
|
||||
const deleteButton = await screen.findByRole('button', { name: '永久删除联系人 待删除伙伴' });
|
||||
fireEvent.click(deleteButton);
|
||||
expect(await screen.findByRole('dialog', { name: '永久删除联系人“待删除伙伴”?' })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '永久删除' }));
|
||||
|
||||
await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith('/api/opencode/sessions/ses-archived-delete', { method: 'DELETE' }));
|
||||
await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith('/api/opencode/projects/conversations', expect.objectContaining({ method: 'POST' })));
|
||||
await waitFor(() => {
|
||||
const saveCall = hostApiFetchMock.mock.calls.find(([path, init]) => path === '/api/opencode/projects/config' && (init as RequestInit | undefined)?.method === 'PUT');
|
||||
expect(saveCall).toBeTruthy();
|
||||
expect(JSON.parse((saveCall?.[1] as RequestInit).body as string).config.agents).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user