Files
makelore/tests/unit/nitu-character-scene.test.tsx
2026-07-29 17:22:35 +08:00

1255 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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(
<MemoryRouter>
<CharacterScene />
</MemoryRouter>,
);
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(
<MemoryRouter>
<CharacterScene />
</MemoryRouter>,
);
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(
<MemoryRouter>
<CharacterScene />
</MemoryRouter>,
);
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(
<MemoryRouter>
<CharacterScene />
</MemoryRouter>,
);
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(
<MemoryRouter>
<CharacterScene />
</MemoryRouter>,
);
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(
<MemoryRouter>
<CharacterScene />
</MemoryRouter>,
);
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(
<MemoryRouter>
<CharacterScene />
</MemoryRouter>,
);
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(
<MemoryRouter>
<CharacterScene />
</MemoryRouter>,
);
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(
<MemoryRouter>
<CharacterScene />
</MemoryRouter>,
);
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(
<MemoryRouter>
<CharacterScene />
</MemoryRouter>,
);
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(
<MemoryRouter>
<CharacterScene />
</MemoryRouter>,
);
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(
<MemoryRouter>
<CharacterScene />
</MemoryRouter>,
);
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(
<MemoryRouter>
<CharacterScene />
</MemoryRouter>,
);
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(
<MemoryRouter>
<CharacterScene />
</MemoryRouter>,
);
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(
<MemoryRouter>
<CharacterScene />
</MemoryRouter>,
);
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(
<MemoryRouter>
<CharacterScene />
</MemoryRouter>,
);
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(
<MemoryRouter>
<CharacterScene />
</MemoryRouter>,
);
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(
<MemoryRouter>
<CharacterScene />
</MemoryRouter>,
);
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(
<MemoryRouter>
<CharacterScene />
</MemoryRouter>,
);
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(
<MemoryRouter>
<CharacterScene />
</MemoryRouter>,
);
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(
<MemoryRouter>
<CharacterScene />
</MemoryRouter>,
);
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(
<MemoryRouter>
<CharacterScene />
</MemoryRouter>,
);
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(
<MemoryRouter initialEntries={['/']}>
<Routes>
<Route path="/" element={<CharacterScene />} />
<Route path="/chat" element={<ChatScene />} />
</Routes>
</MemoryRouter>,
);
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(
<MemoryRouter>
<ChatScene />
</MemoryRouter>,
);
expect(await screen.findByText('当前Agent项目经理 · 思路整理')).toBeInTheDocument();
expect(screen.queryByText('当前Agent小计划官 · 思路整理')).not.toBeInTheDocument();
});
});