feat(learning): replace courses with project catalog
This commit is contained in:
@@ -1,431 +1,117 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
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 {
|
||||
GenerationWorkbench,
|
||||
LEARNING_GENERATION_TASK_STORAGE_KEY,
|
||||
} from '@/pages/Learning/GenerationWorkbench';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { LearningProjectDetail } from '@/pages/Learning/ProjectDetail';
|
||||
|
||||
const fetchLearningCoursesMock = vi.hoisted(() => vi.fn());
|
||||
const fetchMyLearningCoursesMock = vi.hoisted(() => vi.fn());
|
||||
const listInstalledLearningCoursesMock = vi.hoisted(() => vi.fn());
|
||||
const downloadLearningCourseMock = vi.hoisted(() => vi.fn());
|
||||
const startLearningGenerationMock = vi.hoisted(() => vi.fn());
|
||||
const fetchLearningGenerationMock = vi.hoisted(() => vi.fn());
|
||||
const finalizeLearningGenerationMock = vi.hoisted(() => vi.fn());
|
||||
const cancelLearningGenerationMock = vi.hoisted(() => vi.fn());
|
||||
const resumeLearningGenerationMock = vi.hoisted(() => vi.fn());
|
||||
const transcribeLearningSpeechMock = vi.hoisted(() => vi.fn());
|
||||
const originalMediaDevices = Object.getOwnPropertyDescriptor(navigator, 'mediaDevices');
|
||||
|
||||
class MockMediaRecorder {
|
||||
static latest: MockMediaRecorder | null = null;
|
||||
|
||||
state: RecordingState = 'inactive';
|
||||
mimeType = 'audio/webm';
|
||||
ondataavailable: ((event: BlobEvent) => void) | null = null;
|
||||
onstop: (() => void) | null = null;
|
||||
|
||||
constructor(_stream: MediaStream) {
|
||||
MockMediaRecorder.latest = this;
|
||||
}
|
||||
|
||||
start = vi.fn(() => {
|
||||
this.state = 'recording';
|
||||
});
|
||||
|
||||
stop = vi.fn(() => {
|
||||
this.state = 'inactive';
|
||||
this.onstop?.();
|
||||
});
|
||||
|
||||
emit(data: Blob): void {
|
||||
this.ondataavailable?.({ data } as BlobEvent);
|
||||
}
|
||||
}
|
||||
|
||||
function generationTaskStorageKey(accountKey: string): string {
|
||||
return `${LEARNING_GENERATION_TASK_STORAGE_KEY}:${encodeURIComponent(accountKey)}`;
|
||||
}
|
||||
|
||||
function persistedTask(jobId: string, phase = 'paused') {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
jobId,
|
||||
phase,
|
||||
generation: {
|
||||
contractVersion: 2,
|
||||
jobId,
|
||||
status: phase === 'paused' ? 'paused' : 'running',
|
||||
mode: 'single',
|
||||
done: false,
|
||||
},
|
||||
courseId: null,
|
||||
updatedAt: '2026-08-16T00:00:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
function setAuthAccount(userId: string | null, username = userId ?? ''): void {
|
||||
useAuthStore.setState({
|
||||
initialized: true,
|
||||
loading: false,
|
||||
error: null,
|
||||
user: userId === null ? null : {
|
||||
username,
|
||||
userId,
|
||||
tenantId: null,
|
||||
deptId: null,
|
||||
authorities: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
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', () => ({
|
||||
DEFAULT_LEARNING_GENERATION_OPTIONS: {
|
||||
enableWebSearch: false,
|
||||
enableImageGeneration: false,
|
||||
enableVideoGeneration: false,
|
||||
enableTTS: true,
|
||||
interactiveMode: true,
|
||||
taskEngineMode: false,
|
||||
},
|
||||
fetchLearningCourses: fetchLearningCoursesMock,
|
||||
fetchMyLearningCourses: fetchMyLearningCoursesMock,
|
||||
listInstalledLearningCourses: listInstalledLearningCoursesMock,
|
||||
downloadLearningCourse: downloadLearningCourseMock,
|
||||
startLearningGeneration: startLearningGenerationMock,
|
||||
fetchLearningGeneration: fetchLearningGenerationMock,
|
||||
finalizeLearningGeneration: finalizeLearningGenerationMock,
|
||||
cancelLearningGeneration: cancelLearningGenerationMock,
|
||||
resumeLearningGeneration: resumeLearningGenerationMock,
|
||||
transcribeLearningSpeech: transcribeLearningSpeechMock,
|
||||
fetchLearningProjects: fetchLearningProjectsMock,
|
||||
fetchLearningProject: fetchLearningProjectMock,
|
||||
downloadLearningProject: downloadLearningProjectMock,
|
||||
openLearningExternalLink: openLearningExternalLinkMock,
|
||||
}));
|
||||
|
||||
describe('Learning page', () => {
|
||||
vi.mock('@/pages/Learning/ProjectImage', () => ({
|
||||
ProjectImage: ({ src, alt }: { src: string; alt: string }) => <img src={src} alt={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 <output data-testid="location-path">{location.pathname}</output>;
|
||||
}
|
||||
|
||||
describe('Learning project pages', () => {
|
||||
beforeEach(() => {
|
||||
fetchLearningCoursesMock.mockReset();
|
||||
fetchMyLearningCoursesMock.mockReset();
|
||||
listInstalledLearningCoursesMock.mockReset();
|
||||
downloadLearningCourseMock.mockReset();
|
||||
startLearningGenerationMock.mockReset();
|
||||
fetchLearningGenerationMock.mockReset();
|
||||
finalizeLearningGenerationMock.mockReset();
|
||||
cancelLearningGenerationMock.mockReset();
|
||||
resumeLearningGenerationMock.mockReset();
|
||||
transcribeLearningSpeechMock.mockReset();
|
||||
window.localStorage.clear();
|
||||
setAuthAccount('user-a');
|
||||
listInstalledLearningCoursesMock.mockResolvedValue([]);
|
||||
fetchMyLearningCoursesMock.mockResolvedValue([]);
|
||||
fetchLearningProjectsMock.mockReset();
|
||||
fetchLearningProjectMock.mockReset();
|
||||
downloadLearningProjectMock.mockReset();
|
||||
openLearningExternalLinkMock.mockReset();
|
||||
openLearningExternalLinkMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
MockMediaRecorder.latest = null;
|
||||
if (originalMediaDevices) {
|
||||
Object.defineProperty(navigator, 'mediaDevices', originalMediaDevices);
|
||||
} else {
|
||||
Reflect.deleteProperty(navigator, 'mediaDevices');
|
||||
}
|
||||
});
|
||||
|
||||
it('renders published courses returned by Works Square', async () => {
|
||||
fetchLearningCoursesMock.mockResolvedValue([{
|
||||
id: 'course-1',
|
||||
origin: 'ops_large',
|
||||
status: 'published',
|
||||
title: 'Python 基础入门',
|
||||
summary: '通过互动讲解掌握 Python。',
|
||||
language: 'zh-CN',
|
||||
contentHash: 'a'.repeat(64),
|
||||
archiveSha256: 'b'.repeat(64),
|
||||
archiveBytes: 1024,
|
||||
formatVersion: 1,
|
||||
minPlayerVersion: '1.0.0',
|
||||
sceneCount: 10,
|
||||
capabilities: { sceneKinds: ['slide', 'interactive', 'quiz'], hasAudio: false, hasWhiteboard: true, hasAgent: true },
|
||||
createdAt: '2026-08-16T00:00:00Z',
|
||||
publishedAt: '2026-08-16T00:00:00Z',
|
||||
}]);
|
||||
|
||||
render(<MemoryRouter><Learning /></MemoryRouter>);
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent('正在读取课程');
|
||||
expect(await screen.findByTestId('learning-course-course-1')).toHaveTextContent('Python 基础入门');
|
||||
expect(screen.getByText('10 个学习场景')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a retryable error state', async () => {
|
||||
fetchLearningCoursesMock.mockRejectedValue(new Error('连接失败'));
|
||||
|
||||
render(<MemoryRouter><Learning /></MemoryRouter>);
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('连接失败'));
|
||||
expect(screen.getByRole('button', { name: '重试' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('opens the full single-course workbench only from the generate route', async () => {
|
||||
fetchLearningCoursesMock.mockResolvedValue([]);
|
||||
render(<MemoryRouter initialEntries={['/learning?generate=1']}><Learning /></MemoryRouter>);
|
||||
await screen.findByText('暂时还没有已发布课程');
|
||||
|
||||
expect(screen.getByRole('dialog')).toHaveTextContent('今天想学会什么?');
|
||||
expect(screen.getByLabelText('课程需求')).toBeEnabled();
|
||||
expect(screen.getByRole('button', { name: '后台生成' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: '联网搜索' })).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(screen.getByRole('button', { name: '语音讲解' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('button', { name: '互动模式' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('button', { name: '职教任务' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '语音输入' })).toBeInTheDocument();
|
||||
expect(screen.queryByText(/供应商选择/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('submits every canonical option explicitly to the background job', async () => {
|
||||
fetchLearningCoursesMock.mockResolvedValue([]);
|
||||
startLearningGenerationMock.mockResolvedValue({
|
||||
contractVersion: 2,
|
||||
jobId: 'job-1',
|
||||
status: 'queued',
|
||||
mode: 'single',
|
||||
done: false,
|
||||
});
|
||||
fetchLearningGenerationMock.mockImplementation(() => new Promise(() => undefined));
|
||||
render(<MemoryRouter initialEntries={['/learning?generate=1']}><Learning /></MemoryRouter>);
|
||||
|
||||
fireEvent.change(await screen.findByLabelText('课程需求'), { target: { value: '用互动练习教我二次函数' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '联网搜索' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成图片' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '职教任务' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '后台生成' }));
|
||||
|
||||
await waitFor(() => expect(startLearningGenerationMock).toHaveBeenCalledWith({
|
||||
requirement: '用互动练习教我二次函数',
|
||||
enableWebSearch: true,
|
||||
enableImageGeneration: true,
|
||||
enableVideoGeneration: false,
|
||||
enableTTS: true,
|
||||
interactiveMode: true,
|
||||
taskEngineMode: true,
|
||||
}, []));
|
||||
});
|
||||
|
||||
it('switches generation ownership from account A to account B and ignores A poll results', async () => {
|
||||
const accountAPoll = Promise.withResolvers<{
|
||||
contractVersion: number;
|
||||
jobId: string;
|
||||
status: string;
|
||||
mode: string;
|
||||
done: boolean;
|
||||
courseId: string;
|
||||
}>();
|
||||
window.localStorage.setItem(
|
||||
generationTaskStorageKey('user-a'),
|
||||
JSON.stringify(persistedTask('job-a', 'generating')),
|
||||
);
|
||||
window.localStorage.setItem(
|
||||
generationTaskStorageKey('user-b'),
|
||||
JSON.stringify(persistedTask('job-b')),
|
||||
);
|
||||
fetchLearningGenerationMock.mockReturnValue(accountAPoll.promise);
|
||||
|
||||
it('renders project cards and opens a project detail route', async () => {
|
||||
fetchLearningProjectsMock.mockResolvedValue({ items: [project], nextCursor: null });
|
||||
render(
|
||||
<GenerationWorkbench
|
||||
onInstalled={vi.fn()}
|
||||
onOpenCourse={vi.fn()}
|
||||
/>,
|
||||
<MemoryRouter initialEntries={['/learning']}>
|
||||
<Routes><Route path="*" element={<><Learning /><LocationProbe /></>} /></Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(await screen.findByTestId('learning-generation-task')).toHaveTextContent('job-a');
|
||||
await waitFor(() => expect(fetchLearningGenerationMock).toHaveBeenCalledWith('job-a'));
|
||||
|
||||
act(() => setAuthAccount('user-b'));
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('learning-generation-task')).toHaveTextContent('job-b'));
|
||||
expect(screen.queryByText(/job-a/)).not.toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
accountAPoll.resolve({
|
||||
contractVersion: 2,
|
||||
jobId: 'job-a',
|
||||
status: 'succeeded',
|
||||
mode: 'single',
|
||||
done: true,
|
||||
courseId: 'course-a',
|
||||
});
|
||||
await accountAPoll.promise;
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('learning-generation-task')).toHaveTextContent('job-b');
|
||||
expect(finalizeLearningGenerationMock).not.toHaveBeenCalled();
|
||||
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('clears account A form materials options and active recording when switching to account B', async () => {
|
||||
const stopTrack = vi.fn();
|
||||
const getUserMedia = vi.fn().mockResolvedValue({
|
||||
getTracks: () => [{ stop: stopTrack }],
|
||||
});
|
||||
Object.defineProperty(navigator, 'mediaDevices', {
|
||||
configurable: true,
|
||||
value: { getUserMedia },
|
||||
});
|
||||
vi.stubGlobal('MediaRecorder', MockMediaRecorder);
|
||||
|
||||
render(<GenerationWorkbench onInstalled={vi.fn()} onOpenCourse={vi.fn()} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('课程需求'), { target: { value: '账号 A 的私有需求' } });
|
||||
fireEvent.change(screen.getByTestId('learning-material-input'), {
|
||||
target: { files: [new File(['private'], 'account-a.txt', { type: 'text/plain' })] },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '联网搜索' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '语音输入' }));
|
||||
|
||||
expect(await screen.findByRole('button', { name: '停止语音输入' })).toBeEnabled();
|
||||
expect(screen.getByText(/account-a\.txt/)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '联网搜索' })).toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
act(() => setAuthAccount('user-b'));
|
||||
|
||||
await waitFor(() => expect(screen.getByLabelText('课程需求')).toHaveValue(''));
|
||||
expect(screen.queryByText(/account-a\.txt/)).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '联网搜索' })).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(screen.getByRole('button', { name: '语音输入' })).toBeEnabled();
|
||||
expect(MockMediaRecorder.latest?.stop).toHaveBeenCalledTimes(1);
|
||||
expect(stopTrack).toHaveBeenCalled();
|
||||
expect(transcribeLearningSpeechMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drops account A transcription that resolves after switching to account B', async () => {
|
||||
const transcription = Promise.withResolvers<{ text: string }>();
|
||||
const stopTrack = vi.fn();
|
||||
Object.defineProperty(navigator, 'mediaDevices', {
|
||||
configurable: true,
|
||||
value: { getUserMedia: vi.fn().mockResolvedValue({ getTracks: () => [{ stop: stopTrack }] }) },
|
||||
});
|
||||
vi.stubGlobal('MediaRecorder', MockMediaRecorder);
|
||||
transcribeLearningSpeechMock.mockReturnValue(transcription.promise);
|
||||
|
||||
render(<GenerationWorkbench onInstalled={vi.fn()} onOpenCourse={vi.fn()} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('课程需求'), { target: { value: '账号 A 已有文本' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '语音输入' }));
|
||||
expect(await screen.findByRole('button', { name: '停止语音输入' })).toBeEnabled();
|
||||
act(() => MockMediaRecorder.latest?.emit(new Blob(['voice'], { type: 'audio/webm' })));
|
||||
fireEvent.click(screen.getByRole('button', { name: '停止语音输入' }));
|
||||
await waitFor(() => expect(transcribeLearningSpeechMock).toHaveBeenCalledTimes(1));
|
||||
|
||||
act(() => setAuthAccount('user-b'));
|
||||
await waitFor(() => expect(screen.getByLabelText('课程需求')).toHaveValue(''));
|
||||
|
||||
await act(async () => {
|
||||
transcription.resolve({ text: '不应写入账号 B' });
|
||||
await transcription.promise;
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText('课程需求')).toHaveValue('');
|
||||
expect(screen.queryByText('不应写入账号 B')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not upload account A material after its file read finishes under account B', async () => {
|
||||
const fileRead = Promise.withResolvers<ArrayBuffer>();
|
||||
const material = new File(['private'], 'account-a.txt', { type: 'text/plain' });
|
||||
vi.spyOn(material, 'arrayBuffer').mockReturnValue(fileRead.promise);
|
||||
|
||||
render(<GenerationWorkbench onInstalled={vi.fn()} onOpenCourse={vi.fn()} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('课程需求'), { target: { value: '账号 A 的私有需求' } });
|
||||
fireEvent.change(screen.getByTestId('learning-material-input'), { target: { files: [material] } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '后台生成' }));
|
||||
await waitFor(() => expect(material.arrayBuffer).toHaveBeenCalledTimes(1));
|
||||
|
||||
act(() => setAuthAccount('user-b'));
|
||||
await waitFor(() => expect(screen.getByLabelText('课程需求')).toHaveValue(''));
|
||||
|
||||
await act(async () => {
|
||||
fileRead.resolve(new Uint8Array([1, 2, 3]).buffer);
|
||||
await fileRead.promise;
|
||||
});
|
||||
|
||||
expect(startLearningGenerationMock).not.toHaveBeenCalled();
|
||||
expect(screen.getByLabelText('课程需求')).toHaveValue('');
|
||||
});
|
||||
|
||||
it('does not persist or finalize a stale poll result after unmount', async () => {
|
||||
const poll = Promise.withResolvers<{
|
||||
contractVersion: number;
|
||||
jobId: string;
|
||||
status: string;
|
||||
mode: string;
|
||||
done: boolean;
|
||||
courseId: string;
|
||||
}>();
|
||||
const storageKey = generationTaskStorageKey('user-a');
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(persistedTask('job-a', 'generating')));
|
||||
fetchLearningGenerationMock.mockReturnValue(poll.promise);
|
||||
const onInstalled = vi.fn();
|
||||
|
||||
const view = render(
|
||||
<GenerationWorkbench
|
||||
onInstalled={onInstalled}
|
||||
onOpenCourse={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(fetchLearningGenerationMock).toHaveBeenCalledWith('job-a'));
|
||||
view.unmount();
|
||||
|
||||
await act(async () => {
|
||||
poll.resolve({
|
||||
contractVersion: 2,
|
||||
jobId: 'job-a',
|
||||
status: 'succeeded',
|
||||
mode: 'single',
|
||||
done: true,
|
||||
courseId: 'course-a',
|
||||
});
|
||||
await poll.promise;
|
||||
});
|
||||
|
||||
expect(JSON.parse(window.localStorage.getItem(storageKey) || '{}')).toMatchObject({
|
||||
jobId: 'job-a',
|
||||
phase: 'generating',
|
||||
});
|
||||
expect(finalizeLearningGenerationMock).not.toHaveBeenCalled();
|
||||
expect(onInstalled).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('restores the persisted generation task when the same account remounts', async () => {
|
||||
const storageKey = generationTaskStorageKey('user-a');
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(persistedTask('job-a')));
|
||||
|
||||
const first = render(
|
||||
<GenerationWorkbench
|
||||
onInstalled={vi.fn()}
|
||||
onOpenCourse={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(await screen.findByTestId('learning-generation-task')).toHaveTextContent('job-a');
|
||||
first.unmount();
|
||||
|
||||
render(
|
||||
<GenerationWorkbench
|
||||
onInstalled={vi.fn()}
|
||||
onOpenCourse={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByTestId('learning-generation-task')).toHaveTextContent('job-a');
|
||||
expect(window.localStorage.getItem(LEARNING_GENERATION_TASK_STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the course plaza without adding another sidebar', async () => {
|
||||
fetchLearningCoursesMock.mockResolvedValue([]);
|
||||
|
||||
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>);
|
||||
|
||||
await screen.findByText('暂时还没有已发布课程');
|
||||
expect(screen.getByRole('region', { name: '课程广场' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('complementary')).not.toBeInTheDocument();
|
||||
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 keeps remote README media on the controlled path', 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',
|
||||
'/api/learning/projects/robot-arm/media/wiring',
|
||||
);
|
||||
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'));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user