完善客户端模块与工作区能力
This commit is contained in:
@@ -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');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user