import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { CharacterScene, ChatScene } from '@/pages/NiTu'; import { useAuthStore } from '@/stores/auth'; import { useOpencodeStore } from '@/stores/opencode'; import { usePartnerProfilesStore } from '@/stores/partner-profiles'; import { useProjectTemplatesStore } from '@/stores/project-templates'; import { useProviderStore, type ProviderAccount, type ProviderWithKeyInfo } from '@/stores/providers'; import { createProjectTemplateSnapshot } from '../../shared/project-template'; const hostApiFetchMock = vi.fn(); const projectTemplateReadOverrideMock = vi.fn(); vi.mock('@/lib/host-api', () => ({ hostApiFetch: async (...args: unknown[]) => { const override = await projectTemplateReadOverrideMock(...args); if (override !== undefined) return override; const [path] = args; if (typeof path === 'string' && path.startsWith('/api/opencode/projects/template?')) { const projectId = new URL(path, 'https://niancode.test').searchParams.get('projectId') ?? ''; const snapshot = useProjectTemplatesStore.getState().snapshotsByProjectId[projectId]; return snapshot ? { status: 'valid', snapshot } : { status: 'missing' }; } return hostApiFetchMock(...args); }, ensureHostApiToken: vi.fn().mockResolvedValue('test-token'), })); vi.mock('@/lib/api-client', () => ({ invokeIpc: vi.fn(), })); const activeProject = { id: 'prj_1', path: 'D:/repo/packages/ui', name: 'ui', createdAt: '2026-05-12T00:00:00.000Z', updatedAt: '2026-05-12T00:00:00.000Z', lastOpenedAt: '2026-05-12T00:00:00.000Z', }; function resetOpencodeStore() { useOpencodeStore.setState({ status: { state: 'stopped', port: 4096 }, health: null, healthCheckedAt: null, runtimeConfigSummary: null, projects: [], activeProject: null, sessions: [], sessionsByProjectId: {}, selectedSessionId: null, sessionStatuses: {}, sessionMessages: [], sessionMessagesBySessionId: {}, streamingMessage: null, streamingMessagesBySessionId: {}, streamingTools: [], streamingToolsBySessionId: {}, sendingSessionId: null, sendingSessionIds: {}, queuedSessionPrompts: {}, pendingQuestions: [], pendingPermissions: [], sessionTodos: [], sessionTodosBySessionId: {}, sessionTodosLoading: false, sessionTodosError: null, revertedSessionIds: {}, sessionDiffs: [], sessionDiffLoading: false, sessionDiffError: null, fileStatuses: [], fileSearchResults: [], contentSearchResults: [], selectedContextFile: null, fileContextLoading: false, fileContextError: null, runtimeAutoStartAttempted: false, loading: false, error: null, }); } function getSkillCountLabel(container: HTMLElement, count: number) { return within(container).getByText((_, element) => element?.textContent === `${count} 个技能`); } function _mockOpencodeRuntime() { hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => { if (path === '/api/opencode/status') { return { state: 'running', port: 4096, url: 'http://127.0.0.1:4096' }; } if (path === '/api/opencode/projects') { return { projects: [activeProject], activeProject }; } if (path === `/api/opencode/projects/template?projectId=${encodeURIComponent(activeProject.id)}`) { return { status: 'valid', snapshot: createProjectTemplateSnapshot('youth-ai-course', '2026-07-01T00:00:00.000Z'), }; } if (path === '/api/opencode/config-summary') { return { model: 'openai/gpt-5', smallModel: 'openai/gpt-5-mini', providerIds: ['openai'], providerCount: 1, }; } if (path === '/api/opencode/skills') { return { skills: [{ name: 'youth-ai-product-course', description: 'Use when guiding youth AI product course work.', location: 'D:/repo/packages/ui/.opencode/skills/youth-ai-product-course/SKILL.md', content: '# Course rules', }], }; } if (path === '/api/opencode/sessions' && !init?.method) { return { sessions: [] }; } if (path === '/api/opencode/sessions/status') { return { statuses: { ses_pm: { type: 'idle' } } }; } if (path === '/api/opencode/sessions' && init?.method === 'POST') { const body = typeof init.body === 'string' ? JSON.parse(init.body) as { agent?: string; title?: string } : {}; return { success: true, session: { id: 'ses_pm', agent: body.agent ?? 'pm', title: '1. 想法与计划 - 项目经理对话', updatedAt: '2026-05-12T08:30:00.000Z', }, }; } if (path === '/api/opencode/sessions/ses_pm/messages' && init?.method === 'POST') { return { success: true }; } throw new Error(`Unexpected path ${path}`); }); } describe('NiTu character scene partner profiles', () => { beforeEach(() => { vi.clearAllMocks(); hostApiFetchMock.mockReset(); projectTemplateReadOverrideMock.mockReset(); window.localStorage.clear(); useAuthStore.setState({ initialized: true, loading: false, error: null, accessToken: null, refreshToken: null, tokenType: null, expiresAt: null, user: null, }); useProjectTemplatesStore.setState({ snapshotsByProjectId: {}, templateErrorsByProjectId: {}, loadStatusByProjectId: {}, templatesByProjectId: {}, }); usePartnerProfilesStore.setState({ subagents: [], projectRuntimeConfigsByProjectId: {}, profilesById: {} }); useProviderStore.setState({ statuses: [], accounts: [], vendors: [], defaultAccountId: null, loading: false, error: null, }); resetOpencodeStore(); }); it('starts with an empty partner list and an add button', () => { render( , ); expect(screen.getByTestId('partner-empty-state')).toBeInTheDocument(); expect(screen.getByTestId('partner-create-open')).toBeInTheDocument(); expect(screen.queryByTestId('partner-profile-card-pm')).not.toBeInTheDocument(); }); it('keeps add partner drawer text readable on light surfaces', async () => { render( , ); fireEvent.click(screen.getByTestId('partner-create-open')); const drawer = await screen.findByRole('dialog'); const nameInput = within(drawer).getByTestId('partner-create-name'); const roleSelect = within(drawer).getByTestId('partner-create-template-role'); expect(nameInput).toHaveClass('bg-white', 'text-[#26384D]', 'placeholder:text-[#68788B]'); expect(roleSelect).toHaveClass('bg-white', 'text-[#26384D]'); expect(within(drawer).getByTestId('partner-create-system-prompt-note')).toHaveClass( 'bg-[#EEF3F7]', 'text-[#26384D]', ); fireEvent.change(roleSelect, { target: { value: 'custom' } }); expect(within(drawer).getByTestId('partner-create-prompt')).toHaveClass( 'bg-white', 'text-[#26384D]', 'placeholder:text-[#68788B]', ); }); it('shows the selected system template prompt while creating a partner', async () => { render( , ); fireEvent.click(screen.getByTestId('partner-create-open')); const drawer = await screen.findByRole('dialog'); const roleSelect = within(drawer).getByTestId('partner-create-template-role'); expect(within(drawer).getByTestId('partner-create-template-prompt')).toHaveTextContent( '讨论项目目标、核心用户、第一版范围、任务清单和验收标准。', ); fireEvent.change(roleSelect, { target: { value: 'product' } }); expect(within(drawer).getByTestId('partner-create-template-prompt')).toHaveTextContent( '基于项目计划定义页面、按钮、交互、边界状态和 Demo 交付说明', ); fireEvent.change(roleSelect, { target: { value: 'custom' } }); expect(within(drawer).queryByTestId('partner-create-template-prompt')).not.toBeInTheDocument(); expect(within(drawer).getByTestId('partner-create-prompt')).toBeInTheDocument(); }); it('lists all built-in youth and mini-game partner role templates', async () => { render( , ); fireEvent.click(screen.getByTestId('partner-create-open')); const drawer = await screen.findByRole('dialog'); const roleSelect = within(drawer).getByTestId('partner-create-template-role'); const optionValues = within(roleSelect) .getAllByRole('option') .map((option) => option.getAttribute('value')); expect(optionValues).toEqual([ 'pm', 'product', 'designer', 'dev', 'marketing', 'deploy', 'gameplay', 'assets', 'game-dev', 'game-release', 'game-showcase', 'custom', ]); }); it('creates an assets partner whose built-in prompt mentions existing asset search and license notes', async () => { hostApiFetchMock.mockImplementation(async (path: string) => { throw new Error(`Unexpected path ${path}`); }); render( , ); fireEvent.click(screen.getByTestId('partner-create-open')); const createDrawer = await screen.findByRole('dialog'); fireEvent.change(within(createDrawer).getByTestId('partner-create-name'), { target: { value: '素材小队长' }, }); fireEvent.change(within(createDrawer).getByTestId('partner-create-template-role'), { target: { value: 'assets' }, }); fireEvent.click(within(createDrawer).getByRole('button', { name: '创建伙伴' })); const card = await screen.findByTestId(/^partner-subagent-card-/); fireEvent.click(card); const profileDrawer = await screen.findByTestId(/^partner-profile-editor-/); expect(profileDrawer).toHaveTextContent('搜索现有游戏素材'); expect(profileDrawer).toHaveTextContent('来源、授权、项目内路径'); expect(profileDrawer).toHaveTextContent('ASSET_PLAN.md'); }); it('creates a built-in template runtime subagent from Partner Square', async () => { useOpencodeStore.setState({ activeProject, projects: [activeProject], }); hostApiFetchMock.mockResolvedValueOnce({ success: true, subagent: { id: 'nitu-product-test', displayName: '我的产品伙伴', templateRoleId: 'product', filePath: 'D:/repo/.opencode/agent/nitu-product-test.md', createdAt: '2026-06-29T00:00:00.000Z', updatedAt: '2026-06-29T00:00:00.000Z', }, }); render( , ); fireEvent.click(screen.getByTestId('partner-create-open')); const drawer = await screen.findByRole('dialog'); fireEvent.change(within(drawer).getByTestId('partner-create-name'), { target: { value: '我的产品伙伴' }, }); expect(within(drawer).queryByTestId('partner-create-course-role')).not.toBeInTheDocument(); fireEvent.change(within(drawer).getByTestId('partner-create-template-role'), { target: { value: 'product' }, }); fireEvent.click(within(drawer).getByRole('button', { name: '创建伙伴' })); await waitFor(() => { expect(hostApiFetchMock).toHaveBeenCalledWith('/api/opencode/agents', { method: 'POST', body: JSON.stringify({ displayName: '我的产品伙伴', templateRoleId: 'product', }), }); }); expect(await screen.findByTestId('partner-subagent-card-nitu-product-test')).toHaveTextContent('我的产品伙伴'); expect(screen.getByTestId('partner-subagent-card-nitu-product-test')).toHaveTextContent('nitu-product-test'); expect(usePartnerProfilesStore.getState().subagents[0].filePath).toBeUndefined(); expect((usePartnerProfilesStore.getState() as any).projectRuntimeConfigsByProjectId[activeProject.id]['nitu-product-test']).toMatchObject({ filePath: 'D:/repo/.opencode/agent/nitu-product-test.md', }); }); it('creates a local partner before any project is selected', async () => { hostApiFetchMock.mockImplementation(async (path: string) => { throw new Error(`Unexpected path ${path}`); }); render( , ); fireEvent.click(screen.getByTestId('partner-create-open')); const drawer = await screen.findByRole('dialog'); fireEvent.change(within(drawer).getByTestId('partner-create-name'), { target: { value: '小黄毛超级大冒险' }, }); fireEvent.change(within(drawer).getByTestId('partner-create-template-role'), { target: { value: 'pm' }, }); fireEvent.click(within(drawer).getByRole('button', { name: '创建伙伴' })); await waitFor(() => { expect(usePartnerProfilesStore.getState().subagents).toHaveLength(1); }); const created = usePartnerProfilesStore.getState().subagents[0]; expect(created).toMatchObject({ displayName: '小黄毛超级大冒险', templateRoleId: 'pm', }); expect(created.id).toMatch(/^nitu-pm-/); expect(created.filePath).toBeUndefined(); expect(hostApiFetchMock.mock.calls.some(([path]) => path === '/api/opencode/agents')).toBe(false); expect(screen.getAllByText('小黄毛超级大冒险').length).toBeGreaterThan(0); }); it('shows created partners in the new project agent selector', async () => { usePartnerProfilesStore.setState({ subagents: [{ id: 'nitu-pm-test', displayName: '小计划官', templateRoleId: 'pm', filePath: 'D:/repo/.opencode/agent/nitu-pm-test.md', createdAt: '2026-06-29T00:00:00.000Z', updatedAt: '2026-06-29T00:00:00.000Z', }], profilesById: {}, }); render( , ); act(() => { window.dispatchEvent(new Event('nitu:create-project')); }); const sheet = await screen.findByRole('dialog'); const readyPmOption = within(sheet).getAllByText(/^小计划官$/)[0].closest('button'); expect(readyPmOption).not.toBeDisabled(); expect(readyPmOption).toHaveTextContent('可用'); expect(within(sheet).queryByText(/^项目经理$/)).not.toBeInTheDocument(); }); it('uses Makelore as the character scene workspace brand', () => { render( , ); expect(screen.getByText('Makelore')).toBeInTheDocument(); expect(screen.queryByText(/NITU/)).not.toBeInTheDocument(); }); it('does not show the growth path section in the character scene menu', () => { render( , ); expect(screen.queryByText('成长之路')).not.toBeInTheDocument(); expect(screen.queryByText(/不影响主要操作/)).not.toBeInTheDocument(); }); it('shows imported Works Square user models in the LLM model drawer', async () => { useProviderStore.setState({ accounts: [{ id: 'niancode-user-models', vendorId: 'custom', label: 'Makelore Models', authMode: 'api_key', baseUrl: 'https://square.example/v1', model: 'qwen3.6-plus', fallbackModels: ['deepseek-chat', 'gpt-4.1-mini'], enabled: true, isDefault: true, createdAt: '2026-07-02T00:00:00.000Z', updatedAt: '2026-07-02T00:00:00.000Z', } as ProviderAccount], statuses: [{ id: 'niancode-user-models', type: 'custom', name: 'Makelore Models', enabled: true, createdAt: '2026-07-02T00:00:00.000Z', updatedAt: '2026-07-02T00:00:00.000Z', hasKey: true, keyMasked: 'sk-***', } as ProviderWithKeyInfo], defaultAccountId: 'niancode-user-models', }); render( , ); expect(screen.getByTestId('resource-card-models')).toHaveTextContent('已激活3个'); fireEvent.click(screen.getByTestId('resource-card-models')); const drawer = await screen.findByRole('dialog'); const multimodalCard = within(drawer).getByTestId('nitu-model-card-qwen3-6-plus'); const textCard = within(drawer).getByTestId('nitu-model-card-deepseek-chat'); expect(multimodalCard).toHaveTextContent('qwen3.6-plus'); expect(multimodalCard).toHaveTextContent('多模态'); expect(textCard).toHaveTextContent('deepseek-chat'); expect(textCard).toHaveTextContent('文本'); expect(within(drawer).getByTestId('nitu-model-card-gpt-4-1-mini')).toHaveTextContent('gpt-4.1-mini'); expect(multimodalCard).toHaveTextContent('默认账号'); expect(hostApiFetchMock.mock.calls.some(([path]) => path === '/api/auth/me/model-config')).toBe(false); }); it('lets users manually re-fetch remote model config from the LLM drawer', async () => { useAuthStore.setState({ accessToken: 'access-token', refreshToken: 'refresh-token', expiresAt: Date.now() + 60_000, }); const existingAccount = { id: 'niancode-user-models', vendorId: 'custom', label: 'Makelore Models', authMode: 'api_key', baseUrl: 'https://square.example/v1', model: 'gpt-old-mini', fallbackModels: [], enabled: true, isDefault: true, createdAt: '2026-07-02T00:00:00.000Z', updatedAt: '2026-07-02T00:00:00.000Z', } as ProviderAccount; const refreshedAccount = { ...existingAccount, model: 'gpt-4.1-mini', fallbackModels: ['gpt-4o-mini', 'qwen3-coder'], updatedAt: '2026-07-07T00:00:00.000Z', } as ProviderAccount; useProviderStore.setState({ accounts: [existingAccount], statuses: [{ id: 'niancode-user-models', type: 'custom', name: 'Makelore Models', enabled: true, createdAt: '2026-07-02T00:00:00.000Z', updatedAt: '2026-07-02T00:00:00.000Z', hasKey: true, keyMasked: 'sk-***', } as ProviderWithKeyInfo], defaultAccountId: 'niancode-user-models', }); hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => { if (path === '/api/provider-accounts/import-user-model-config' && init?.method === 'POST') { return { success: true, account: refreshedAccount, importedModels: ['gpt-4.1-mini', 'gpt-4o-mini', 'qwen3-coder'], }; } if (path === '/api/provider-accounts') { return [refreshedAccount]; } if (path === '/api/provider-accounts/key-info') { return [{ accountId: 'niancode-user-models', hasKey: true, keyMasked: 'sk-***' }]; } if (path === '/api/provider-vendors') { return []; } if (path === '/api/provider-accounts/default') { return { accountId: 'niancode-user-models' }; } throw new Error(`Unexpected path ${path}`); }); render( , ); fireEvent.click(screen.getByTestId('resource-card-models')); const drawer = await screen.findByRole('dialog'); fireEvent.click(within(drawer).getByRole('button', { name: '重新拉取远端模型配置' })); await waitFor(() => { expect(hostApiFetchMock).toHaveBeenCalledWith('/api/provider-accounts/import-user-model-config', { method: 'POST', body: JSON.stringify({ accessToken: 'access-token' }), }); }); await waitFor(() => { expect(within(drawer).getByTestId('nitu-model-card-gpt-4-1-mini')).toHaveTextContent('gpt-4.1-mini'); }); expect(within(drawer).getByTestId('nitu-model-card-gpt-4o-mini')).toHaveTextContent('gpt-4o-mini'); expect(within(drawer).getByTestId('nitu-model-card-qwen3-coder')).toHaveTextContent('qwen3-coder'); }); it('syncs current user models when entering the LLM drawer before provider settings loaded', async () => { useAuthStore.setState({ accessToken: 'access-token', expiresAt: Date.now() + 60_000, }); const importedAccount = { id: 'niancode-user-models', vendorId: 'custom', label: 'Makelore Models', authMode: 'api_key', baseUrl: 'https://square.example/v1', model: 'gpt-4.1-mini', fallbackModels: ['gpt-4o-mini', 'qwen3-coder'], enabled: true, isDefault: true, createdAt: '2026-07-02T00:00:00.000Z', updatedAt: '2026-07-02T00:00:00.000Z', } as ProviderAccount; let providerAccountsRequests = 0; hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => { if (path === '/api/provider-accounts') { providerAccountsRequests += 1; return providerAccountsRequests === 1 ? [] : [importedAccount]; } if (path === '/api/provider-accounts/key-info') { return providerAccountsRequests <= 1 ? [] : [{ accountId: 'niancode-user-models', hasKey: true, keyMasked: 'sk-***' }]; } if (path === '/api/provider-vendors') { return []; } if (path === '/api/provider-accounts/default') { return { accountId: providerAccountsRequests <= 1 ? null : 'niancode-user-models' }; } if (path === '/api/provider-accounts/import-user-model-config' && init?.method === 'POST') { return { success: true, account: importedAccount, importedModels: ['gpt-4.1-mini', 'gpt-4o-mini', 'qwen3-coder'], }; } throw new Error(`Unexpected path ${path}`); }); render( , ); fireEvent.click(screen.getByTestId('resource-card-models')); await waitFor(() => { expect(hostApiFetchMock).toHaveBeenCalledWith('/api/provider-accounts/import-user-model-config', { method: 'POST', body: JSON.stringify({ accessToken: 'access-token' }), }); }); await waitFor(() => { expect(screen.getByTestId('resource-card-models')).toHaveTextContent('已激活3个'); }); const drawer = await screen.findByRole('dialog'); expect(within(drawer).getByTestId('nitu-model-card-gpt-4-1-mini')).toHaveTextContent('gpt-4.1-mini'); expect(within(drawer).getByTestId('nitu-model-card-gpt-4o-mini')).toHaveTextContent('gpt-4o-mini'); expect(within(drawer).getByTestId('nitu-model-card-qwen3-coder')).toHaveTextContent('qwen3-coder'); expect(hostApiFetchMock.mock.calls.some(([path]) => path === '/api/auth/me/model-config')).toBe(false); }); it('shows a compact name and purpose list in the Skill library drawer', async () => { render( , ); fireEvent.click(screen.getByTestId('resource-card-skills')); const drawer = await screen.findByRole('dialog'); expect(within(drawer).getByTestId('skill-library-card-youth-ai-product-course')).toBeInTheDocument(); expect(getSkillCountLabel(drawer, 12)).toBeInTheDocument(); expect(within(drawer).getByTestId('skill-library-card-youth-plain-language')).toHaveTextContent('青少年通俗表达'); expect(within(drawer).getByTestId('skill-library-card-youth-plain-language')).not.toHaveTextContent('youth-plain-language'); expect(within(drawer).getByTestId('skill-library-card-youth-ai-product-course')).toHaveTextContent('青少年人工智能产品课程'); expect(within(drawer).getByTestId('skill-library-card-youth-ai-product-course')).toHaveTextContent('统一学生沟通方式'); expect(within(drawer).getByTestId('skill-library-card-pm-project-plan')).toHaveTextContent('通用软件项目规划'); expect(within(drawer).queryByTestId('skill-library-card-course-stage-review')).not.toBeInTheDocument(); expect(within(drawer).getByTestId('skill-library-card-nianxxgame-skill')).toHaveTextContent('游戏开发'); const pmSkill = within(drawer).getByTestId('skill-library-card-pm-project-plan'); expect(pmSkill).toHaveTextContent('把软件想法整理成目标'); expect(pmSkill).not.toHaveTextContent('pm-project-plan'); expect(pmSkill).not.toHaveTextContent('触发场景'); expect(pmSkill).not.toHaveTextContent('核心指令'); expect(pmSkill).not.toHaveTextContent('输入上下文'); expect(pmSkill).not.toHaveTextContent('输出产物'); expect(pmSkill).not.toHaveTextContent('项目计划.md'); expect(pmSkill).not.toHaveTextContent('保护规则'); }); it('keeps the course skill list visible when opencode returns no installed skills yet', async () => { let resolveSkills: ((value: { skills: unknown[] }) => void) | null = null; useOpencodeStore.setState({ status: { state: 'running', port: 4096, url: 'http://127.0.0.1:4096' }, activeProject, projects: [activeProject], }); hostApiFetchMock.mockImplementation(async (path: string) => { if (path === '/api/opencode/skills') { return await new Promise((resolve) => { resolveSkills = resolve as (value: { skills: unknown[] }) => void; }); } throw new Error(`Unexpected path ${path}`); }); render( , ); fireEvent.click(screen.getByTestId('resource-card-skills')); const drawer = await screen.findByRole('dialog'); await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith('/api/opencode/skills')); await act(async () => { resolveSkills?.({ skills: [] }); }); expect(getSkillCountLabel(drawer, 12)).toBeInTheDocument(); expect(within(drawer).getByTestId('skill-library-card-youth-ai-product-course')).toBeInTheDocument(); expect(within(drawer).getByTestId('skill-library-card-pm-project-plan')).toBeInTheDocument(); }); it('opens the knowledge drawer and handles knowledge file actions', async () => { render( , ); fireEvent.click(screen.getByTestId('resource-card-knowledge')); const drawer = await screen.findByRole('dialog'); expect(within(drawer).getByTestId('knowledge-file-kid-html')).toBeInTheDocument(); expect(within(drawer).getByRole('button', { name: '上传知识文件' })).toBeInTheDocument(); expect(within(drawer).getByTestId('knowledge-file-kid-html')).toHaveTextContent('HTML入门小卡片.md'); const fileInput = within(drawer).getByTestId('knowledge-file-input') as HTMLInputElement; const inputClickSpy = vi.spyOn(fileInput, 'click'); fireEvent.click(within(drawer).getByRole('button', { name: '上传知识文件' })); expect(inputClickSpy).toHaveBeenCalledTimes(1); fireEvent.change(fileInput, { target: { files: [new File(['# classroom notes'], 'classroom-notes.md', { type: 'text/markdown' })], }, }); expect(within(drawer).getByText('classroom-notes.md')).toBeInTheDocument(); expect(within(drawer).getByText('已上传知识文件:classroom-notes.md')).toBeInTheDocument(); const dropZone = within(drawer).getByTestId('knowledge-drop-zone'); fireEvent.dragOver(dropZone, { dataTransfer: { files: [new File(['travel'], 'travel-guide.pdf', { type: 'application/pdf' })], }, }); fireEvent.drop(dropZone, { dataTransfer: { files: [new File(['travel'], 'travel-guide.pdf', { type: 'application/pdf' })], }, }); expect(within(drawer).getByText('travel-guide.pdf')).toBeInTheDocument(); expect(within(drawer).getByText('已上传知识文件:travel-guide.pdf')).toBeInTheDocument(); fireEvent.click(within(drawer).getByRole('button', { name: '删除 HTML入门小卡片.md' })); expect(within(drawer).queryByTestId('knowledge-file-kid-html')).not.toBeInTheDocument(); expect(within(drawer).getByText('已从本地知识库移除:HTML入门小卡片.md')).toBeInTheDocument(); }); it('edits nickname, description, and avatar from the partner config drawer', async () => { usePartnerProfilesStore.setState({ subagents: [{ id: 'nitu-pm-test', displayName: '我的 PM', templateRoleId: 'pm', filePath: 'D:/repo/.opencode/agent/nitu-pm-test.md', createdAt: '2026-06-29T00:00:00.000Z', updatedAt: '2026-06-29T00:00:00.000Z', }], profilesById: {}, }); render( , ); fireEvent.click(screen.getByTestId('partner-subagent-card-nitu-pm-test')); const drawer = await screen.findByTestId('partner-profile-editor-nitu-pm-test'); fireEvent.change(within(drawer).getByLabelText('伙伴昵称'), { target: { value: '小计划官' }, }); fireEvent.change(within(drawer).getByLabelText('伙伴描述'), { target: { value: 'Gentle planning helper.' }, }); fireEvent.click(within(drawer).getByTestId('partner-avatar-tone-nitu-pm-test-1')); fireEvent.click(within(drawer).getByRole('button', { name: '保存配置' })); await waitFor(() => { const card = screen.getByTestId('partner-subagent-card-nitu-pm-test'); expect(card).toHaveTextContent('小计划官'); expect(card).toHaveTextContent('Gentle planning helper.'); expect(card).toHaveTextContent('项目经理 · 思路整理'); }); }); it('edits runtime subagent skill bindings from the partner config drawer', async () => { useOpencodeStore.setState({ activeProject, projects: [activeProject], }); usePartnerProfilesStore.setState({ subagents: [{ id: 'nitu-pm-test', displayName: '我的 PM', templateRoleId: 'pm', skillIds: ['youth-ai-product-course', 'pm-project-plan'], filePath: 'D:/repo/.opencode/agent/nitu-pm-test.md', createdAt: '2026-06-29T00:00:00.000Z', updatedAt: '2026-06-29T00:00:00.000Z', }], projectRuntimeConfigsByProjectId: { [activeProject.id]: { 'nitu-pm-test': { filePath: 'D:/repo/.opencode/agent/nitu-pm-test.md', skillIds: ['youth-ai-product-course', 'pm-project-plan'], updatedAt: '2026-06-29T00:00:00.000Z', }, }, }, profilesById: {}, }); hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => { if (path === '/api/opencode/agents/nitu-pm-test' && init?.method === 'PATCH') { return { success: true, subagent: { id: 'nitu-pm-test', displayName: '我的 PM', templateRoleId: 'pm', skillIds: ['youth-ai-product-course', 'ui-ux-course-quality'], filePath: 'D:/repo/.opencode/agent/nitu-pm-test.md', createdAt: '2026-06-29T00:00:00.000Z', updatedAt: '2026-07-02T00:00:00.000Z', }, }; } throw new Error(`Unexpected path ${path}`); }); render( , ); fireEvent.click(screen.getByTestId('partner-subagent-card-nitu-pm-test')); const drawer = await screen.findByTestId('partner-profile-editor-nitu-pm-test'); const pmSkill = within(drawer).getByLabelText('绑定技能:通用软件项目规划') as HTMLInputElement; const uiSkill = within(drawer).getByLabelText('绑定技能:界面体验质检') as HTMLInputElement; expect(pmSkill.checked).toBe(true); expect(uiSkill.checked).toBe(false); fireEvent.click(pmSkill); fireEvent.click(uiSkill); fireEvent.click(within(drawer).getByRole('button', { name: '保存配置' })); await waitFor(() => { expect(hostApiFetchMock).toHaveBeenCalledWith('/api/opencode/agents/nitu-pm-test', { method: 'PATCH', body: JSON.stringify({ displayName: '我的 PM', templateRoleId: 'pm', skillIds: ['youth-ai-product-course', 'ui-ux-course-quality'], }), }); }); expect(usePartnerProfilesStore.getState().subagents[0].skillIds).toEqual([ 'youth-ai-product-course', 'ui-ux-course-quality', ]); expect((usePartnerProfilesStore.getState() as any).projectRuntimeConfigsByProjectId[activeProject.id]['nitu-pm-test']).toMatchObject({ filePath: 'D:/repo/.opencode/agent/nitu-pm-test.md', skillIds: ['youth-ai-product-course', 'ui-ux-course-quality'], }); expect(screen.getByTestId('partner-subagent-card-nitu-pm-test')).toHaveTextContent('2 个技能'); }); it('locks the game Skill for a mini-game template and keeps it in the project runtime save', async () => { const gameTemplate = createProjectTemplateSnapshot('web-mini-game-course', '2026-07-01T00:00:00.000Z'); useProjectTemplatesStore.getState().setProjectTemplateSnapshot(activeProject.id, gameTemplate); useOpencodeStore.setState({ activeProject, projects: [activeProject], }); usePartnerProfilesStore.setState({ subagents: [{ id: 'nitu-game-dev-test', displayName: '我的游戏开发师', templateRoleId: 'game-dev', skillIds: ['dev-build-test', 'ui-ux-course-quality'], filePath: 'D:/repo/.opencode/agent/nitu-game-dev-test.md', createdAt: '2026-07-01T00:00:00.000Z', updatedAt: '2026-07-01T00:00:00.000Z', }], projectRuntimeConfigsByProjectId: { [activeProject.id]: { 'nitu-game-dev-test': { filePath: 'D:/repo/.opencode/agent/nitu-game-dev-test.md', skillIds: ['dev-build-test', 'ui-ux-course-quality'], updatedAt: '2026-07-01T00:00:00.000Z', }, }, }, profilesById: {}, }); hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => { if (path === '/api/opencode/skills') return { skills: [] }; if (path === '/api/opencode/agents/nitu-game-dev-test' && init?.method === 'PATCH') { const body = JSON.parse(String(init.body)) as { skillIds: string[] }; return { success: true, subagent: { id: 'nitu-game-dev-test', displayName: '我的游戏开发师', templateRoleId: 'game-dev', skillIds: [...body.skillIds, 'nianxxgame-skill'], filePath: 'D:/repo/.opencode/agent/nitu-game-dev-test.md', createdAt: '2026-07-01T00:00:00.000Z', updatedAt: '2026-07-01T00:01:00.000Z', }, }; } throw new Error(`Unexpected path ${path}`); }); render( , ); fireEvent.click(screen.getByTestId('partner-subagent-card-nitu-game-dev-test')); const drawer = await screen.findByTestId('partner-profile-editor-nitu-game-dev-test'); const gameSkill = await within(drawer).findByLabelText('绑定技能:网页游戏开发') as HTMLInputElement; expect(gameSkill.checked).toBe(true); expect(gameSkill.disabled).toBe(true); expect(within(drawer).getByText('游戏模板必需')).toBeInTheDocument(); fireEvent.click(within(drawer).getByRole('button', { name: '保存配置' })); await waitFor(() => { const saveCall = hostApiFetchMock.mock.calls.find(([path, init]) => ( path === '/api/opencode/agents/nitu-game-dev-test' && (init as RequestInit | undefined)?.method === 'PATCH' )); expect(saveCall).toBeDefined(); const body = JSON.parse(String((saveCall?.[1] as RequestInit).body)) as { skillIds: string[] }; expect(body.skillIds).toEqual([ 'dev-build-test', 'ui-ux-course-quality', ]); }); expect(usePartnerProfilesStore.getState().subagents[0]?.skillIds).not.toContain('nianxxgame-skill'); expect( usePartnerProfilesStore.getState().projectRuntimeConfigsByProjectId[activeProject.id]?.['nitu-game-dev-test']?.skillIds, ).toContain('nianxxgame-skill'); }); it('leaves the game Skill manually selectable for a standard project template', async () => { const standardTemplate = createProjectTemplateSnapshot('standard-dev', '2026-07-01T00:00:00.000Z'); useProjectTemplatesStore.getState().setProjectTemplateSnapshot(activeProject.id, standardTemplate); useOpencodeStore.setState({ activeProject, projects: [activeProject], }); usePartnerProfilesStore.setState({ subagents: [{ id: 'nitu-manual-game-dev-test', displayName: '手动游戏开发师', templateRoleId: 'game-dev', skillIds: ['dev-build-test'], filePath: 'D:/repo/.opencode/agent/nitu-manual-game-dev-test.md', createdAt: '2026-07-01T00:00:00.000Z', updatedAt: '2026-07-01T00:00:00.000Z', }], projectRuntimeConfigsByProjectId: { [activeProject.id]: { 'nitu-manual-game-dev-test': { filePath: 'D:/repo/.opencode/agent/nitu-manual-game-dev-test.md', skillIds: ['dev-build-test'], updatedAt: '2026-07-01T00:00:00.000Z', }, }, }, profilesById: {}, }); hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => { if (path === '/api/opencode/skills') return { skills: [] }; if (path === '/api/opencode/agents/nitu-manual-game-dev-test' && init?.method === 'PATCH') { const body = JSON.parse(String(init.body)) as { skillIds: string[] }; return { success: true, subagent: { id: 'nitu-manual-game-dev-test', displayName: '手动游戏开发师', templateRoleId: 'game-dev', skillIds: body.skillIds, filePath: 'D:/repo/.opencode/agent/nitu-manual-game-dev-test.md', createdAt: '2026-07-01T00:00:00.000Z', updatedAt: '2026-07-01T00:01:00.000Z', }, }; } throw new Error(`Unexpected path ${path}`); }); render( , ); fireEvent.click(screen.getByTestId('partner-subagent-card-nitu-manual-game-dev-test')); const drawer = await screen.findByTestId('partner-profile-editor-nitu-manual-game-dev-test'); const gameSkill = await within(drawer).findByLabelText('绑定技能:网页游戏开发') as HTMLInputElement; expect(gameSkill.checked).toBe(false); expect(gameSkill.disabled).toBe(false); expect(within(drawer).queryByText('游戏模板必需')).not.toBeInTheDocument(); fireEvent.click(gameSkill); expect(gameSkill.checked).toBe(true); fireEvent.click(within(drawer).getByRole('button', { name: '保存配置' })); await waitFor(() => { const saveCall = hostApiFetchMock.mock.calls.find(([path, init]) => ( path === '/api/opencode/agents/nitu-manual-game-dev-test' && (init as RequestInit | undefined)?.method === 'PATCH' )); expect(saveCall).toBeDefined(); const body = JSON.parse(String((saveCall?.[1] as RequestInit).body)) as { skillIds: string[] }; expect(body.skillIds).toContain('nianxxgame-skill'); }); }); it('shows installed opencode skills and allows binding them to a partner', async () => { useOpencodeStore.setState({ status: { state: 'running', port: 4096, url: 'http://127.0.0.1:4096' }, activeProject, projects: [activeProject], }); usePartnerProfilesStore.setState({ subagents: [{ id: 'nitu-pm-test', displayName: '我的 PM', templateRoleId: 'pm', skillIds: ['youth-ai-product-course'], filePath: 'D:/repo/.opencode/agent/nitu-pm-test.md', createdAt: '2026-06-29T00:00:00.000Z', updatedAt: '2026-06-29T00:00:00.000Z', }], projectRuntimeConfigsByProjectId: { [activeProject.id]: { 'nitu-pm-test': { filePath: 'D:/repo/.opencode/agent/nitu-pm-test.md', skillIds: ['youth-ai-product-course'], updatedAt: '2026-06-29T00:00:00.000Z', }, }, }, profilesById: {}, }); hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => { if (path === '/api/opencode/skills') { return { skills: [ { name: 'repo-audit', description: 'Use when auditing repository structure.', location: 'D:/repo/packages/ui/.opencode/skills/repo-audit/SKILL.md', content: '# Repo audit', }, { name: 'youth-ai-product-course', description: 'Use when guiding youth AI product course work.', location: 'D:/repo/packages/ui/.opencode/skills/youth-ai-product-course/SKILL.md', content: '# Course rules', }, ], }; } if (path === '/api/opencode/agents/nitu-pm-test' && init?.method === 'PATCH') { return { success: true, subagent: { id: 'nitu-pm-test', displayName: '我的 PM', templateRoleId: 'pm', skillIds: ['youth-ai-product-course', 'repo-audit'], filePath: 'D:/repo/.opencode/agent/nitu-pm-test.md', createdAt: '2026-06-29T00:00:00.000Z', updatedAt: '2026-07-05T00:00:00.000Z', }, }; } throw new Error(`Unexpected path ${path}`); }); render( , ); fireEvent.click(screen.getByText('技能库')); expect(await screen.findByTestId('skill-library-card-repo-audit')).toHaveTextContent('自定义技能'); expect(screen.getByTestId('skill-library-card-repo-audit')).not.toHaveTextContent('repo-audit'); expect(screen.getByTestId('skill-library-card-youth-ai-product-course')).toHaveTextContent('青少年人工智能产品课程'); expect(screen.getByTestId('skill-library-card-youth-ai-product-course')).not.toHaveTextContent('youth-ai-product-course'); fireEvent.click(screen.getByTestId('partner-subagent-card-nitu-pm-test')); const drawer = await screen.findByTestId('partner-profile-editor-nitu-pm-test'); const repoAuditSkill = await within(drawer).findByLabelText('绑定技能:自定义技能') as HTMLInputElement; expect(repoAuditSkill.checked).toBe(false); fireEvent.click(repoAuditSkill); fireEvent.click(within(drawer).getByRole('button', { name: '保存配置' })); await waitFor(() => { expect(hostApiFetchMock).toHaveBeenCalledWith('/api/opencode/agents/nitu-pm-test', { method: 'PATCH', body: JSON.stringify({ displayName: '我的 PM', templateRoleId: 'pm', skillIds: ['youth-ai-product-course', 'repo-audit'], }), }); }); }); it('refreshes the active project runtime subagent when saving unchanged skill bindings', async () => { useOpencodeStore.setState({ activeProject, projects: [activeProject], }); usePartnerProfilesStore.setState({ subagents: [{ id: 'nitu-pm-test', displayName: '我的 PM', templateRoleId: 'pm', skillIds: ['youth-ai-product-course', 'pm-project-plan'], filePath: 'D:/repo/.opencode/agent/nitu-pm-test.md', createdAt: '2026-06-29T00:00:00.000Z', updatedAt: '2026-06-29T00:00:00.000Z', }], projectRuntimeConfigsByProjectId: { [activeProject.id]: { 'nitu-pm-test': { filePath: 'D:/repo/.opencode/agent/nitu-pm-test.md', skillIds: ['youth-ai-product-course', 'pm-project-plan'], updatedAt: '2026-06-29T00:00:00.000Z', }, }, }, profilesById: {}, }); hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => { if (path === '/api/opencode/agents/nitu-pm-test' && init?.method === 'PATCH') { return { success: true, subagent: { id: 'nitu-pm-test', displayName: '我的 PM', templateRoleId: 'pm', skillIds: ['youth-ai-product-course', 'pm-project-plan'], filePath: 'D:/repo/.opencode/agent/nitu-pm-test.md', createdAt: '2026-06-29T00:00:00.000Z', updatedAt: '2026-07-04T00:00:00.000Z', }, }; } throw new Error(`Unexpected path ${path}`); }); render( , ); fireEvent.click(screen.getByTestId('partner-subagent-card-nitu-pm-test')); const drawer = await screen.findByTestId('partner-profile-editor-nitu-pm-test'); fireEvent.click(within(drawer).getByRole('button', { name: '保存配置' })); await waitFor(() => { expect(hostApiFetchMock).toHaveBeenCalledWith('/api/opencode/agents/nitu-pm-test', { method: 'PATCH', body: JSON.stringify({ displayName: '我的 PM', templateRoleId: 'pm', skillIds: ['youth-ai-product-course', 'pm-project-plan'], }), }); }); expect((usePartnerProfilesStore.getState() as any).projectRuntimeConfigsByProjectId[activeProject.id]['nitu-pm-test']).toMatchObject({ filePath: 'D:/repo/.opencode/agent/nitu-pm-test.md', skillIds: ['youth-ai-product-course', 'pm-project-plan'], updatedAt: '2026-07-04T00:00:00.000Z', }); }); it('shows edited partner names in project selectors without implicitly binding course roles', async () => { usePartnerProfilesStore.setState({ subagents: [{ id: 'nitu-pm-test', displayName: '小计划官', templateRoleId: 'pm', filePath: 'D:/repo/.opencode/agent/nitu-pm-test.md', createdAt: '2026-06-29T00:00:00.000Z', updatedAt: '2026-06-29T00:00:00.000Z', }], profilesById: {}, }); const scene = render( } /> } /> , ); act(() => { window.dispatchEvent(new Event('nitu:create-project')); }); const sheet = await screen.findByRole('dialog'); expect(within(sheet).getAllByText('小计划官').length).toBeGreaterThan(0); expect(within(sheet).queryByText(/^项目经理$/)).not.toBeInTheDocument(); fireEvent.click(within(sheet).getByRole('button', { name: '取消' })); scene.unmount(); render( , ); expect(await screen.findByText('当前Agent:项目经理 · 思路整理')).toBeInTheDocument(); expect(screen.queryByText('当前Agent:小计划官 · 思路整理')).not.toBeInTheDocument(); }); });