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();
});
});