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 }) => (
),
}));
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 ;
}
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(
>} />
,
);
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();
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\n\n[参考资料](https://example.com/guide)',
});
render(
} />
,
);
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(
} />
,
);
fireEvent.click(await screen.findByRole('button', { name: '下载项目' }));
await waitFor(() => expect(downloadLearningProjectMock).toHaveBeenCalledWith('robot-arm'));
});
});