1255 lines
46 KiB
TypeScript
1255 lines
46 KiB
TypeScript
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();
|
||
});
|
||
|
||
});
|