Files
makelore/tests/unit/module-navigation.test.tsx
inman 01bee3188b
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
feat: integrate learning module
2026-08-16 20:52:16 +08:00

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');
});
});