完善客户端模块与工作区能力
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled

This commit is contained in:
inman
2026-08-13 19:45:30 +08:00
parent 0148b91a15
commit 22add3f01f
97 changed files with 4756 additions and 3226 deletions

View File

@@ -1,6 +1,7 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ImageWorkspaceSidebar } from '@/components/layout/ImageWorkspaceSidebar';
import { Sidebar } from '@/components/layout/Sidebar';
import { useOpencodeStore } from '@/stores/opencode';
import { useProjectConfigStore } from '@/stores/project-config';
@@ -11,17 +12,24 @@ import { useImageWorkspaceStore } from '@/stores/image-workspace';
import { useUserProfileStore } from '@/stores/user-profile';
import { getAgentAvatarSrc } from '@/lib/agent-avatars';
import { createProjectConfig } from '../../shared/project-config';
import type { DesignConversationSummary, DesignWorkspace } from '../../shared/image-workspace';
const hostApiFetchMock = vi.fn();
const invokeIpcMock = vi.fn();
const navigateMock = vi.fn();
const prepareUserAvatarMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/host-api', () => ({ hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args), ensureHostApiToken: vi.fn().mockResolvedValue('test-token') }));
vi.mock('@/lib/api-client', () => ({ invokeIpc: (...args: unknown[]) => invokeIpcMock(...args) }));
vi.mock('@/lib/user-avatar', async (importOriginal) => ({
...await importOriginal<typeof import('@/lib/user-avatar')>(),
prepareUserAvatar: (...args: unknown[]) => prepareUserAvatarMock(...args),
}));
vi.mock('react-router-dom', async (importOriginal) => ({ ...(await importOriginal<typeof import('react-router-dom')>()), useNavigate: () => navigateMock }));
describe('Sidebar project initialization flow', () => {
beforeEach(() => {
vi.clearAllMocks();
prepareUserAvatarMock.mockReset();
window.electron.imageWorkspaceLocalDevelopment = false;
useSettingsStore.setState({ sidebarCollapsed: false });
useAuthStore.setState({ initialized: true, loading: false, error: null, accessToken: null, refreshToken: null, tokenType: null, expiresAt: null, user: null });
@@ -96,6 +104,205 @@ describe('Sidebar project initialization flow', () => {
await waitFor(() => expect(useUserProfileStore.getState().profilesByUserId['user-1']).toMatchObject({ displayName: '小明', age: 16, version: 1 }));
});
it('renders the synced cloud avatar in the account surfaces and profile dialog', async () => {
useAuthStore.setState({
user: { username: 'login@example.com', userId: 'user-avatar', tenantId: null, deptId: null, authorities: [] },
accessToken: 'token',
});
hostApiFetchMock.mockImplementation(async (path: string) => {
if (path === '/api/opencode/projects') return { projects: [], activeProject: null };
if (path === '/api/works/billing/token-usage') return {};
if (path === '/api/works/user/agent-profile') {
return {
success: true,
profile: {
display_name: '头像用户',
age: null,
gender: null,
avatar_url: 'https://cdn.example/avatar.webp',
share_age_with_agents: false,
share_gender_with_agents: false,
analysis_enabled: true,
completed: true,
version: 1,
updated_at: '2026-08-13T00:00:00Z',
},
};
}
return {};
});
render(<MemoryRouter><Sidebar /></MemoryRouter>);
const accountAvatar = await screen.findByTestId('sidebar-account-avatar');
await waitFor(() => expect(accountAvatar.querySelector('img')).toHaveAttribute('src', 'https://cdn.example/avatar.webp'));
fireEvent.error(accountAvatar.querySelector('img')!);
expect(accountAvatar).toHaveTextContent('L');
fireEvent.click(screen.getByTestId('sidebar-member-menu-trigger'));
expect(screen.getByTestId('sidebar-account-menu-avatar').querySelector('img')).toHaveAttribute('src', 'https://cdn.example/avatar.webp');
fireEvent.click(screen.getByRole('menuitem', { name: '个人资料' }));
await waitFor(() => expect(hostApiFetchMock.mock.calls.filter(([path]) => path === '/api/works/user/agent-profile')).toHaveLength(2));
expect(await screen.findByTestId('profile-avatar-preview')).toContainElement(
screen.getByRole('img', { name: '个人头像预览' }),
);
expect(screen.getByRole('img', { name: '个人头像预览' })).toHaveAttribute('src', 'https://cdn.example/avatar.webp');
});
it('stages a new avatar and uploads it together with the profile save', async () => {
useAuthStore.setState({
user: { username: 'login@example.com', userId: 'user-upload', tenantId: null, deptId: null, authorities: [] },
accessToken: 'token',
});
prepareUserAvatarMock.mockResolvedValue({
fileName: 'avatar.webp',
mimeType: 'image/webp',
dataBase64: 'QUJD',
previewUrl: 'data:image/webp;base64,QUJD',
width: 512,
height: 512,
bytes: 3,
});
let profilePutAttempts = 0;
hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
if (path === '/api/opencode/projects') return { projects: [], activeProject: null };
if (path === '/api/works/billing/token-usage') return {};
if (path === '/api/works/user/agent-profile') {
if (init?.method === 'PUT') {
profilePutAttempts += 1;
if (profilePutAttempts === 1) {
return {
success: false,
status: 503,
code: 'profile_storage_unavailable',
error: 'Profile service unavailable',
};
}
return {
success: true,
profile: {
display_name: '头像用户',
age: null,
gender: null,
avatar_url: 'https://cdn.example/uploaded.webp',
share_age_with_agents: false,
share_gender_with_agents: false,
analysis_enabled: true,
completed: true,
version: 2,
updated_at: '2026-08-13T00:00:00Z',
},
};
}
return {
success: true,
profile: {
display_name: '头像用户',
age: null,
gender: null,
avatar_url: null,
share_age_with_agents: false,
share_gender_with_agents: false,
analysis_enabled: true,
completed: true,
version: 1,
updated_at: '2026-08-13T00:00:00Z',
},
};
}
if (path === '/api/works/user/avatar' && init?.method === 'POST') {
return { success: true, avatar_url: 'https://cdn.example/uploaded.webp' };
}
return {};
});
render(<MemoryRouter><Sidebar /></MemoryRouter>);
fireEvent.click(await screen.findByTestId('sidebar-member-menu-trigger'));
fireEvent.click(screen.getByRole('menuitem', { name: '个人资料' }));
const input = screen.getByTestId('profile-avatar-upload');
fireEvent.change(input, { target: { files: [new File(['source'], 'portrait.png', { type: 'image/png' })] } });
await waitFor(() => expect(screen.getByRole('img', { name: '个人头像预览' })).toHaveAttribute('src', 'data:image/webp;base64,QUJD'));
expect(hostApiFetchMock.mock.calls.some(([path]) => path === '/api/works/user/avatar')).toBe(false);
fireEvent.click(screen.getByRole('button', { name: '保存个人资料' }));
await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith('/api/works/user/avatar', {
method: 'POST',
body: JSON.stringify({ fileName: 'avatar.webp', mimeType: 'image/webp', dataBase64: 'QUJD' }),
headers: { 'X-NianCode-Access-Token': 'token' },
}));
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('个人资料服务暂时不可用'));
expect(screen.getByRole('dialog', { name: '个人资料' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '保存个人资料' }));
await waitFor(() => expect(screen.queryByRole('dialog', { name: '个人资料' })).not.toBeInTheDocument());
expect(profilePutAttempts).toBe(2);
expect(hostApiFetchMock.mock.calls.filter(([path, init]) => path === '/api/works/user/avatar' && init?.method === 'POST')).toHaveLength(1);
expect(useUserProfileStore.getState().profilesByUserId['user-upload']?.avatarUrl).toBe('https://cdn.example/uploaded.webp');
});
it('stages avatar removal until the profile is saved', async () => {
useAuthStore.setState({
user: { username: 'login@example.com', userId: 'user-remove', tenantId: null, deptId: null, authorities: [] },
accessToken: 'token',
});
hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
if (path === '/api/opencode/projects') return { projects: [], activeProject: null };
if (path === '/api/works/billing/token-usage') return {};
if (path === '/api/works/user/avatar' && init?.method === 'DELETE') return { success: true, avatar_url: null };
if (path === '/api/works/user/agent-profile') {
if (init?.method === 'PUT') {
return {
success: true,
profile: {
display_name: '头像用户',
age: null,
gender: null,
avatar_url: null,
share_age_with_agents: false,
share_gender_with_agents: false,
analysis_enabled: true,
completed: true,
version: 2,
updated_at: '2026-08-13T00:00:00Z',
},
};
}
return {
success: true,
profile: {
display_name: '头像用户',
age: null,
gender: null,
avatar_url: 'https://cdn.example/avatar.webp',
share_age_with_agents: false,
share_gender_with_agents: false,
analysis_enabled: true,
completed: true,
version: 1,
updated_at: '2026-08-13T00:00:00Z',
},
};
}
return {};
});
render(<MemoryRouter><Sidebar /></MemoryRouter>);
fireEvent.click(await screen.findByTestId('sidebar-member-menu-trigger'));
fireEvent.click(screen.getByRole('menuitem', { name: '个人资料' }));
fireEvent.click(await screen.findByTestId('profile-avatar-remove'));
expect(screen.getByTestId('profile-avatar-preview')).not.toContainElement(screen.queryByRole('img', { name: '个人头像预览' }));
expect(hostApiFetchMock.mock.calls.some(([path]) => path === '/api/works/user/avatar')).toBe(false);
fireEvent.click(screen.getByRole('button', { name: '保存个人资料' }));
await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith('/api/works/user/avatar', {
method: 'DELETE',
headers: { 'X-NianCode-Access-Token': 'token' },
}));
await waitFor(() => expect(screen.queryByRole('dialog', { name: '个人资料' })).not.toBeInTheDocument());
expect(useUserProfileStore.getState().profilesByUserId['user-remove']?.avatarUrl).toBeNull();
});
it('shows create project first and removes redundant project navigation actions', async () => {
hostApiFetchMock.mockResolvedValue({ projects: [], activeProject: null });
render(<MemoryRouter><Sidebar /></MemoryRouter>);
@@ -405,6 +612,7 @@ describe('Sidebar project initialization flow', () => {
conversationId: 'cloud-conversation',
workspaceId: 'cloud-project',
title: '主会话',
latestMessagePreview: '第一条历史消息',
turnRevision: 0,
phase: 'shaping',
brief: {
@@ -424,6 +632,7 @@ describe('Sidebar project initialization flow', () => {
...conversation,
conversationId: 'cloud-conversation-two',
title: '第二会话',
latestMessagePreview: '第二条历史消息',
messages: [],
createdAt: '2026-07-31T10:05:00Z',
updatedAt: '2026-07-31T10:05:00Z',
@@ -496,7 +705,30 @@ describe('Sidebar project initialization flow', () => {
render(<MemoryRouter initialEntries={['/image-canvas']}><Sidebar /></MemoryRouter>);
expect(await screen.findByTestId('sidebar-image-project-cloud-project')).toHaveTextContent('云端概念设计');
const projectCard = await screen.findByTestId('sidebar-image-project-cloud-project');
expect(projectCard).toHaveTextContent('云端概念设计');
expect(projectCard).not.toHaveTextContent('1 个会话');
expect(projectCard).not.toHaveTextContent('设计会话');
expect(screen.getByRole('button', { name: '收起 云端概念设计 的会话' })).toBeVisible();
expect(screen.getByRole('button', { name: '重命名 云端概念设计' })).toBeVisible();
const projectHeader = screen.getByTestId('sidebar-image-project-header-cloud-project');
const projectActions = [
screen.getByRole('button', { name: '收起 云端概念设计 的会话' }),
screen.getByRole('button', { name: '重命名 云端概念设计' }),
within(projectHeader).getByRole('button', { name: '新建设计会话' }),
];
projectActions.forEach((action) => {
expect(action).toHaveClass('opacity-0', 'pointer-events-none', 'group-hover/project:opacity-100', 'focus-visible:opacity-100');
});
expect(projectHeader).toHaveClass('items-center');
expect(projectHeader).not.toHaveClass('justify-center');
expect(projectHeader).toHaveClass('min-h-9');
expect(within(screen.getByTestId('sidebar-image-conversations-cloud-project')).queryByRole('button', { name: '新建设计会话' }))
.not.toBeInTheDocument();
expect(screen.getByTestId('sidebar-image-project-toggle-icon-cloud-project')).toHaveClass('rotate-180');
expect(screen.getByTestId('sidebar-image-conversations-cloud-project'))
.not.toHaveClass('border-t');
expect(screen.getByTestId('sidebar-module-switcher-trigger')).toBeVisible();
const createImageProject = screen.getByTestId('sidebar-create-image-project');
expect(createImageProject).toBeEnabled();
expect(createImageProject).toHaveClass('bg-transparent', 'border-0', 'shadow-none');
@@ -505,8 +737,12 @@ describe('Sidebar project initialization flow', () => {
expect(screen.queryByText('项目模板')).not.toBeInTheDocument();
expect(hostApiFetchMock.mock.calls.some(([path]) => path === '/api/opencode/projects')).toBe(false);
expect(screen.queryByText(/添加 Agent/)).not.toBeInTheDocument();
expect(screen.getByTestId('sidebar-image-conversation-cloud-conversation'))
expect(await screen.findByTestId('sidebar-image-conversation-cloud-conversation'))
.toHaveAttribute('aria-current', 'page');
const firstConversation = screen.getByTestId('sidebar-image-conversation-cloud-conversation');
expect(firstConversation).toHaveTextContent('第一条历史消息');
expect(firstConversation).not.toHaveTextContent('主会话');
expect(screen.getByRole('button', { name: '新建设计会话' })).toBeEnabled();
fireEvent.click(screen.getByRole('button', { name: '新建设计会话' }));
await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith(
@@ -515,6 +751,10 @@ describe('Sidebar project initialization flow', () => {
));
expect(await screen.findByTestId('sidebar-image-conversation-cloud-conversation-two'))
.toHaveAttribute('aria-current', 'page');
const secondConversationButton = screen.getByTestId('sidebar-image-conversation-cloud-conversation-two');
expect(secondConversationButton).toHaveTextContent('第二条历史消息');
expect(secondConversationButton).not.toHaveTextContent('第二会话');
fireEvent.click(screen.getByTestId('sidebar-image-conversation-cloud-conversation'));
await waitFor(() => expect(screen.getByTestId('sidebar-image-conversation-cloud-conversation'))
.toHaveAttribute('aria-current', 'page'));
@@ -640,4 +880,64 @@ describe('Sidebar project initialization flow', () => {
expect(await screen.findByText('角色设计')).toBeInTheDocument();
});
it('keeps older design conversations behind a more-conversations disclosure', () => {
const conversations: DesignConversationSummary[] = Array.from({ length: 7 }, (_, index) => ({
conversationId: `history-conversation-${index + 1}`,
workspaceId: 'history-project',
title: `历史会话 ${index + 1}`,
latestMessagePreview: `历史消息 ${index + 1}`,
turnRevision: index,
phase: 'shaping',
brief: {
version: index,
status: 'draft',
medium: null,
summary: '正在建立作品的视觉方向',
ready: false,
missingDecision: '作品形式',
},
createdAt: `2026-07-31T10:${String(6 - index).padStart(2, '0')}:00Z`,
updatedAt: `2026-07-31T10:${String(6 - index).padStart(2, '0')}:00Z`,
}));
const workspace: DesignWorkspace = {
workspaceId: 'history-project',
title: '历史设计项目',
viewRevision: 7,
conversationCount: conversations.length,
phase: 'shaping',
updatedAt: '2026-07-31T10:06:00Z',
conversations,
};
useImageWorkspaceStore.setState({
status: 'ready',
bootstrap: {
capabilities: { conversation: true, generation: true, image: true, video: true },
workspaces: [{
workspaceId: workspace.workspaceId,
title: workspace.title,
viewRevision: workspace.viewRevision,
conversationCount: workspace.conversationCount,
phase: workspace.phase,
updatedAt: workspace.updatedAt,
}],
},
activeWorkspaceId: workspace.workspaceId,
activeConversationId: conversations[0].conversationId,
workspace,
});
render(<MemoryRouter><ImageWorkspaceSidebar sidebarCollapsed={false} /></MemoryRouter>);
expect(screen.getByTestId('sidebar-image-workspace')).toHaveClass('font-sans');
expect(screen.getByTestId('sidebar-image-conversation-history-conversation-1'))
.toHaveTextContent('历史消息 1');
expect(screen.getByTestId('sidebar-image-more-conversations-history-project'))
.toHaveAttribute('data-state', 'closed');
fireEvent.click(screen.getByRole('button', { name: '更多会话2' }));
expect(screen.getByTestId('sidebar-image-more-conversations-history-project'))
.toHaveAttribute('data-state', 'open');
expect(screen.getByTestId('sidebar-image-conversation-history-conversation-7'))
.toHaveTextContent('历史消息 7');
});
});