121 lines
5.1 KiB
TypeScript
121 lines
5.1 KiB
TypeScript
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
|
|
import { Learning } from '@/pages/Learning';
|
|
import { LearningProjectDetail } from '@/pages/Learning/ProjectDetail';
|
|
|
|
const fetchLearningProjectsMock = vi.hoisted(() => vi.fn());
|
|
const fetchLearningProjectMock = vi.hoisted(() => vi.fn());
|
|
const downloadLearningProjectMock = vi.hoisted(() => vi.fn());
|
|
const openLearningExternalLinkMock = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock('@/lib/learning', () => ({
|
|
fetchLearningProjects: fetchLearningProjectsMock,
|
|
fetchLearningProject: fetchLearningProjectMock,
|
|
downloadLearningProject: downloadLearningProjectMock,
|
|
openLearningExternalLink: openLearningExternalLinkMock,
|
|
}));
|
|
|
|
vi.mock('@/pages/Learning/ProjectImage', () => ({
|
|
ProjectImage: ({ src, alt, allowHttps }: { src: string; alt: string; allowHttps?: boolean }) => (
|
|
<img src={src} alt={alt} data-allow-https={allowHttps ? 'true' : 'false'} />
|
|
),
|
|
}));
|
|
|
|
const project = {
|
|
id: 'robot-arm',
|
|
name: '桌面机械臂',
|
|
summary: '从零搭建一个可以抓取积木的桌面机械臂。',
|
|
cover: {
|
|
url: '/api/learning/projects/robot-arm/media/cover',
|
|
alt: '桌面机械臂封面',
|
|
},
|
|
tags: ['机器人', 'Python'],
|
|
version: '1.2.0',
|
|
archiveBytes: 12 * 1024 * 1024,
|
|
publishedAt: '2026-08-01T00:00:00Z',
|
|
updatedAt: '2026-08-18T00:00:00Z',
|
|
};
|
|
|
|
function LocationProbe() {
|
|
const location = useLocation();
|
|
return <output data-testid="location-path">{location.pathname}</output>;
|
|
}
|
|
|
|
describe('Learning project pages', () => {
|
|
beforeEach(() => {
|
|
fetchLearningProjectsMock.mockReset();
|
|
fetchLearningProjectMock.mockReset();
|
|
downloadLearningProjectMock.mockReset();
|
|
openLearningExternalLinkMock.mockReset();
|
|
openLearningExternalLinkMock.mockResolvedValue(undefined);
|
|
});
|
|
|
|
it('renders project cards and opens a project detail route', async () => {
|
|
fetchLearningProjectsMock.mockResolvedValue({ items: [project], nextCursor: null });
|
|
render(
|
|
<MemoryRouter initialEntries={['/learning']}>
|
|
<Routes><Route path="*" element={<><Learning /><LocationProbe /></>} /></Routes>
|
|
</MemoryRouter>,
|
|
);
|
|
|
|
expect(await screen.findByTestId('learning-project-robot-arm')).toHaveTextContent('桌面机械臂');
|
|
expect(screen.getByAltText('桌面机械臂封面')).toHaveAttribute('src', project.cover.url);
|
|
fireEvent.click(screen.getByRole('button', { name: '查看项目:桌面机械臂' }));
|
|
expect(screen.getByTestId('location-path')).toHaveTextContent('/learning/project/robot-arm');
|
|
});
|
|
|
|
it('loads more projects with the opaque cursor', async () => {
|
|
fetchLearningProjectsMock
|
|
.mockResolvedValueOnce({ items: [project], nextCursor: 'cursor-2' })
|
|
.mockResolvedValueOnce({ items: [{ ...project, id: 'robot-car', name: '智能小车' }], nextCursor: null });
|
|
render(<MemoryRouter><Learning /></MemoryRouter>);
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: '加载更多' }));
|
|
expect(await screen.findByText('智能小车')).toBeInTheDocument();
|
|
expect(fetchLearningProjectsMock).toHaveBeenNthCalledWith(2, { cursor: 'cursor-2', limit: 24 });
|
|
});
|
|
|
|
it('renders Markdown without raw HTML and allows direct HTTPS README images', async () => {
|
|
fetchLearningProjectMock.mockResolvedValue({
|
|
...project,
|
|
archiveFileName: 'robot-arm.zip',
|
|
archiveSha256: 'a'.repeat(64),
|
|
readmeMarkdown: '# 开始搭建\n\n\n\n<script>bad()</script>\n\n[参考资料](https://example.com/guide)',
|
|
});
|
|
render(
|
|
<MemoryRouter initialEntries={['/learning/project/robot-arm']}>
|
|
<Routes><Route path="/learning/project/:projectId" element={<LearningProjectDetail />} /></Routes>
|
|
</MemoryRouter>,
|
|
);
|
|
|
|
expect(await screen.findByRole('heading', { name: '开始搭建' })).toBeInTheDocument();
|
|
expect(screen.getByAltText('接线图')).toHaveAttribute(
|
|
'src',
|
|
'https://images.example.com/wiring.svg',
|
|
);
|
|
expect(screen.getByAltText('接线图')).toHaveAttribute('data-allow-https', 'true');
|
|
expect(document.querySelector('script')).toBeNull();
|
|
fireEvent.click(screen.getByRole('link', { name: '参考资料' }));
|
|
expect(openLearningExternalLinkMock).toHaveBeenCalledWith('https://example.com/guide');
|
|
});
|
|
|
|
it('downloads only after the detail page action', async () => {
|
|
fetchLearningProjectMock.mockResolvedValue({
|
|
...project,
|
|
archiveFileName: 'robot-arm.zip',
|
|
archiveSha256: 'a'.repeat(64),
|
|
readmeMarkdown: '# 项目介绍',
|
|
});
|
|
downloadLearningProjectMock.mockResolvedValue({ status: 'saved' });
|
|
render(
|
|
<MemoryRouter initialEntries={['/learning/project/robot-arm']}>
|
|
<Routes><Route path="/learning/project/:projectId" element={<LearningProjectDetail />} /></Routes>
|
|
</MemoryRouter>,
|
|
);
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: '下载项目' }));
|
|
await waitFor(() => expect(downloadLearningProjectMock).toHaveBeenCalledWith('robot-arm'));
|
|
});
|
|
});
|