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 }) => ( {alt} ), })); 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 {location.pathname}; } 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![接线图](https://images.example.com/wiring.svg)\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')); }); });