266 lines
11 KiB
TypeScript
266 lines
11 KiB
TypeScript
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
import { beforeEach, describe, expect, it } from 'vitest';
|
|
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
|
|
import { ModuleSwitcher } from '@/components/layout/ModuleSwitcher';
|
|
import { ModuleSelection } from '@/pages/ModuleSelection';
|
|
import { useAuthStore } from '@/stores/auth';
|
|
import { useOpencodeStore } from '@/stores/opencode';
|
|
import { useUserProfileStore } from '@/stores/user-profile';
|
|
|
|
const { hostApiFetchMock } = vi.hoisted(() => ({
|
|
hostApiFetchMock: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('@/lib/host-api', () => ({
|
|
hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args),
|
|
}));
|
|
|
|
function LocationProbe() {
|
|
const location = useLocation();
|
|
return <output data-testid="location-path">{location.pathname}</output>;
|
|
}
|
|
|
|
describe('AI module navigation', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
useAuthStore.setState({
|
|
initialized: true,
|
|
loading: false,
|
|
accessToken: 'access-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 60_000,
|
|
lastActiveAt: Date.now(),
|
|
canRefresh: true,
|
|
legacyRefreshToken: null,
|
|
user: { username: 'zhangsan@example.com', userId: 'user-1', tenantId: null, deptId: null, authorities: [] },
|
|
});
|
|
useOpencodeStore.setState({ activeProject: null });
|
|
useUserProfileStore.setState({ profilesByUserId: {}, syncStatesByUserId: {} });
|
|
hostApiFetchMock.mockResolvedValue({
|
|
success: true,
|
|
profile: {
|
|
display_name: '小明',
|
|
age: null,
|
|
gender: null,
|
|
share_age_with_agents: false,
|
|
share_gender_with_agents: false,
|
|
analysis_enabled: true,
|
|
completed: true,
|
|
version: 1,
|
|
updated_at: '2026-08-16T00:00:00Z',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('shows the four module choices and routes Canvas from the chooser', async () => {
|
|
render(
|
|
<MemoryRouter initialEntries={['/module-select']}>
|
|
<Routes>
|
|
<Route path="/module-select" element={<ModuleSelection />} />
|
|
<Route path="*" element={<LocationProbe />} />
|
|
</Routes>
|
|
</MemoryRouter>,
|
|
);
|
|
|
|
expect(screen.getByTestId('ai-module-selection-page')).toBeInTheDocument();
|
|
expect(screen.getByRole('heading', { name: '今天,想创作什么?' })).toBeInTheDocument();
|
|
expect(screen.getByRole('heading', { name: '今天,想创作什么?' })).toHaveClass('whitespace-nowrap');
|
|
expect(screen.getByTestId('ai-module-options')).toHaveClass('module-selection-options');
|
|
expect(await screen.findByText('小明,欢迎回来!')).toBeInTheDocument();
|
|
expect(screen.getByTestId('ai-module-option-programming')).toHaveClass('module-option-card', 'module-option-card-horizontal');
|
|
for (const moduleId of ['programming', 'painting', 'learning', 'robot'] as const) {
|
|
expect(screen.getByTestId(`ai-module-option-${moduleId}`).querySelector('.module-option-artwork')).toBeNull();
|
|
}
|
|
expect(screen.queryByText('先选一个入口。进入后,你还可以从左下角随时切换到其他模块。')).not.toBeInTheDocument();
|
|
expect(screen.queryByText('选择创作空间。你可以随时从侧边栏切换。')).not.toBeInTheDocument();
|
|
expect(screen.getByAltText('Makelore')).toHaveClass('h-8', 'w-40', 'object-cover');
|
|
expect(screen.getByAltText('Makelore').closest('header')).toHaveClass('absolute', 'inset-x-0', 'top-0', 'h-16');
|
|
expect(screen.getByTestId('module-selection-drag-region')).toHaveClass('drag-region');
|
|
expect(screen.getByText('Code 编程')).toBeInTheDocument();
|
|
expect(screen.getByAltText('游戏创作工作台')).toBeInTheDocument();
|
|
expect(screen.getByTestId('ai-module-option-programming')).toHaveTextContent('把创意编程变成可运行的产品、游戏或工具');
|
|
expect(screen.getByText('Canvas 设计')).toBeInTheDocument();
|
|
expect(screen.getByAltText('数位板设计创作')).toBeInTheDocument();
|
|
expect(screen.getByTestId('ai-module-option-painting')).toHaveTextContent('用灵感描绘色彩缤纷的世界');
|
|
const learningOption = screen.getByTestId('ai-module-option-learning');
|
|
expect(learningOption).toBeEnabled();
|
|
expect(learningOption).not.toHaveClass('module-option-card-disabled');
|
|
expect(learningOption).toHaveTextContent('Learning 学习');
|
|
expect(learningOption).toHaveTextContent('用顶尖的方法解锁万物规律');
|
|
expect(screen.getByAltText('数学科技宇宙')).toBeInTheDocument();
|
|
const robotOption = screen.getByTestId('ai-module-option-robot');
|
|
expect(robotOption).toBeEnabled();
|
|
expect(robotOption).not.toHaveClass('module-option-card-disabled');
|
|
expect(robotOption).toHaveTextContent('Robot 机器');
|
|
expect(robotOption).toHaveTextContent('制作你的第一个机器人小伙伴');
|
|
expect(screen.getByAltText('青少年管理机器人硬件与设备绑定')).toBeInTheDocument();
|
|
|
|
fireEvent.click(learningOption);
|
|
expect(await screen.findByTestId('location-path')).toHaveTextContent('/learning');
|
|
});
|
|
|
|
it('routes Robot from the chooser to the AI hardware workspace', async () => {
|
|
render(
|
|
<MemoryRouter initialEntries={['/module-select']}>
|
|
<Routes>
|
|
<Route path="/module-select" element={<ModuleSelection />} />
|
|
<Route path="*" element={<LocationProbe />} />
|
|
</Routes>
|
|
</MemoryRouter>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByTestId('ai-module-option-robot'));
|
|
expect(await screen.findByTestId('location-path')).toHaveTextContent('/ai-hardware');
|
|
});
|
|
|
|
it('routes AI programming from the chooser to the project conversation entry', async () => {
|
|
render(
|
|
<MemoryRouter initialEntries={['/module-select']}>
|
|
<Routes>
|
|
<Route path="/module-select" element={<ModuleSelection />} />
|
|
<Route path="*" element={<LocationProbe />} />
|
|
</Routes>
|
|
</MemoryRouter>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByTestId('ai-module-option-programming'));
|
|
expect(await screen.findByTestId('location-path')).toHaveTextContent('/opencode-chat');
|
|
});
|
|
|
|
it('keeps the chooser public and sends signed-out users to login when they choose a module', async () => {
|
|
useAuthStore.setState({
|
|
accessToken: null,
|
|
tokenType: null,
|
|
expiresAt: null,
|
|
lastActiveAt: null,
|
|
canRefresh: false,
|
|
legacyRefreshToken: null,
|
|
user: null,
|
|
});
|
|
|
|
render(
|
|
<MemoryRouter initialEntries={['/module-select']}>
|
|
<Routes>
|
|
<Route path="/module-select" element={<ModuleSelection />} />
|
|
<Route path="*" element={<LocationProbe />} />
|
|
</Routes>
|
|
</MemoryRouter>,
|
|
);
|
|
|
|
expect(screen.getByTestId('ai-module-selection-page')).toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByTestId('ai-module-option-programming'));
|
|
expect(await screen.findByTestId('location-path')).toHaveTextContent('/login');
|
|
});
|
|
|
|
it('returns to the module chooser from the Sidebar entry', async () => {
|
|
render(
|
|
<MemoryRouter initialEntries={['/image-canvas']}>
|
|
<Routes>
|
|
<Route path="*" element={<><ModuleSwitcher sidebarCollapsed={false} /><LocationProbe /></>} />
|
|
</Routes>
|
|
</MemoryRouter>,
|
|
);
|
|
|
|
expect(screen.getByTestId('sidebar-module-switcher-trigger')).toHaveTextContent('绘画 Canvas');
|
|
expect(screen.getByTestId('sidebar-module-switcher-trigger')).toHaveAttribute('aria-label', '返回首页');
|
|
expect(screen.getByTestId('sidebar-module-switcher-trigger')).toHaveClass('p-3');
|
|
expect(screen.queryByTestId('sidebar-module-switcher-menu')).not.toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByTestId('sidebar-module-switcher-trigger'));
|
|
expect(await screen.findByTestId('location-path')).toHaveTextContent('/module-select');
|
|
});
|
|
|
|
it('requires a personal profile before the first module can be used', async () => {
|
|
hostApiFetchMock.mockImplementation(async (_path: string, init?: RequestInit) => {
|
|
if (init?.method === 'PUT') {
|
|
return {
|
|
success: true,
|
|
profile: {
|
|
display_name: '小红',
|
|
age: null,
|
|
gender: null,
|
|
share_age_with_agents: false,
|
|
share_gender_with_agents: false,
|
|
analysis_enabled: true,
|
|
completed: true,
|
|
version: 1,
|
|
updated_at: '2026-08-16T00:00:00Z',
|
|
},
|
|
};
|
|
}
|
|
return {
|
|
success: true,
|
|
profile: {
|
|
display_name: null,
|
|
age: null,
|
|
gender: null,
|
|
share_age_with_agents: false,
|
|
share_gender_with_agents: false,
|
|
analysis_enabled: true,
|
|
completed: false,
|
|
version: 0,
|
|
updated_at: null,
|
|
},
|
|
};
|
|
});
|
|
|
|
render(
|
|
<MemoryRouter initialEntries={['/module-select']}>
|
|
<Routes>
|
|
<Route path="/module-select" element={<ModuleSelection />} />
|
|
<Route path="*" element={<LocationProbe />} />
|
|
</Routes>
|
|
</MemoryRouter>,
|
|
);
|
|
|
|
expect(await screen.findByText('首次使用前请先填写名字。个人资料与登录账号信息相互独立。')).toBeInTheDocument();
|
|
expect(screen.queryByRole('button', { name: '关闭个人资料' })).not.toBeInTheDocument();
|
|
fireEvent.change(screen.getByLabelText(/名字/), { target: { value: '小红' } });
|
|
fireEvent.click(screen.getByRole('button', { name: '保存个人资料' }));
|
|
|
|
await waitFor(() => expect(screen.queryByRole('dialog', { name: '个人资料' })).not.toBeInTheDocument());
|
|
expect(screen.getByText('小红,欢迎回来!')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows Robot as the active module and returns to the chooser from the AI hardware route', async () => {
|
|
render(
|
|
<MemoryRouter initialEntries={['/ai-hardware']}>
|
|
<Routes>
|
|
<Route path="*" element={<><ModuleSwitcher sidebarCollapsed={false} /><LocationProbe /></>} />
|
|
</Routes>
|
|
</MemoryRouter>,
|
|
);
|
|
|
|
expect(screen.getByTestId('sidebar-module-switcher-trigger')).toHaveTextContent('Robot 机器');
|
|
fireEvent.click(screen.getByTestId('sidebar-module-switcher-trigger'));
|
|
expect(await screen.findByTestId('location-path')).toHaveTextContent('/module-select');
|
|
});
|
|
|
|
it('shows Learning as the active module and returns to the chooser from the learning route', async () => {
|
|
render(
|
|
<MemoryRouter initialEntries={['/learning']}>
|
|
<Routes>
|
|
<Route path="*" element={<><ModuleSwitcher sidebarCollapsed={false} /><LocationProbe /></>} />
|
|
</Routes>
|
|
</MemoryRouter>,
|
|
);
|
|
|
|
expect(screen.getByTestId('sidebar-module-switcher-trigger')).toHaveTextContent('学习 Learning');
|
|
fireEvent.click(screen.getByTestId('sidebar-module-switcher-trigger'));
|
|
expect(await screen.findByTestId('location-path')).toHaveTextContent('/module-select');
|
|
});
|
|
|
|
it('keeps breathing room around the compact module switcher trigger', () => {
|
|
render(
|
|
<MemoryRouter initialEntries={['/opencode-chat']}>
|
|
<ModuleSwitcher sidebarCollapsed={false} compact />
|
|
</MemoryRouter>,
|
|
);
|
|
|
|
const trigger = screen.getByTestId('sidebar-module-switcher-trigger');
|
|
expect(trigger).toHaveClass('inline-flex', 'w-fit', 'p-2');
|
|
});
|
|
|
|
});
|