feat: remove Learning module
This commit is contained in:
@@ -231,7 +231,7 @@ test.describe('AI Design V2 workspace', () => {
|
||||
e2eGlobal.__designE2eAuthReady = true;
|
||||
return respond({
|
||||
success: true,
|
||||
moduleAccess: { programming: true, design: true, learning: true, robot: true },
|
||||
moduleAccess: { programming: true, design: true, robot: true },
|
||||
});
|
||||
}
|
||||
if (path === '/api/works/user/agent-profile') {
|
||||
@@ -389,7 +389,7 @@ test.describe('AI Design V2 workspace', () => {
|
||||
deptId: null,
|
||||
authorities: [],
|
||||
},
|
||||
moduleAccess: { programming: true, design: true, learning: true, robot: true },
|
||||
moduleAccess: { programming: true, design: true, robot: true },
|
||||
},
|
||||
version: 2,
|
||||
}));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron';
|
||||
|
||||
test.describe('Makelore module navigation without setup flow', () => {
|
||||
test('keeps the four module entries in a proportional vertical stack while resizing the window', async ({ launchElectronApp }) => {
|
||||
test('keeps the three module entries in a proportional vertical stack while resizing the window', async ({ launchElectronApp }) => {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
|
||||
try {
|
||||
@@ -23,7 +23,7 @@ test.describe('Makelore module navigation without setup flow', () => {
|
||||
}));
|
||||
|
||||
const initialMetrics = await readCardMetrics();
|
||||
expect(initialMetrics).toHaveLength(4);
|
||||
expect(initialMetrics).toHaveLength(3);
|
||||
await expect(moduleCards.first().locator('.module-option-card-arrow')).toHaveCount(0);
|
||||
await expect(moduleCards.first().locator('.module-option-artwork')).toHaveCount(0);
|
||||
await expect.poll(async () => await moduleCards.first().evaluate((element) => {
|
||||
@@ -75,7 +75,7 @@ test.describe('Makelore module navigation without setup flow', () => {
|
||||
await expect.poll(async () => (await readCardMetrics())[0]?.width ?? 0).toBeLessThan(initialWidth - 1);
|
||||
|
||||
const resizedMetrics = await readCardMetrics();
|
||||
expect(resizedMetrics).toHaveLength(4);
|
||||
expect(resizedMetrics).toHaveLength(3);
|
||||
expect(Math.max(...resizedMetrics.map((card) => card.left)) - Math.min(...resizedMetrics.map((card) => card.left))).toBeLessThan(1);
|
||||
expect(resizedMetrics.map((card) => card.top)).toEqual(
|
||||
[...resizedMetrics].sort((top, bottom) => top.top - bottom.top).map((card) => card.top),
|
||||
@@ -108,23 +108,18 @@ test.describe('Makelore module navigation without setup flow', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('opens Learning from the module chooser and returns through the module switcher', async ({ launchElectronApp }) => {
|
||||
test('omits Learning from the module chooser and redirects its retired route', async ({ launchElectronApp }) => {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
|
||||
try {
|
||||
const page = await getStableWindow(app);
|
||||
await expect(page.getByTestId('ai-module-selection-page')).toBeVisible();
|
||||
|
||||
const learningOption = page.getByTestId('ai-module-option-learning');
|
||||
await expect(learningOption).toBeVisible();
|
||||
await expect(learningOption).toBeEnabled();
|
||||
await learningOption.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/learning$/);
|
||||
await expect(page.getByTestId('learning-home')).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: '学习项目' })).toBeVisible();
|
||||
|
||||
await page.getByTestId('sidebar-module-switcher-trigger').click();
|
||||
await expect(page.locator('[data-testid^="ai-module-option-"]')).toHaveCount(3);
|
||||
await expect(page.getByTestId('ai-module-option-learning')).toHaveCount(0);
|
||||
await page.evaluate(() => {
|
||||
window.location.hash = '#/learning';
|
||||
});
|
||||
await expect(page).toHaveURL(/\/module-select$/);
|
||||
await expect(page.getByTestId('ai-module-selection-page')).toBeVisible();
|
||||
} finally {
|
||||
|
||||
@@ -47,7 +47,7 @@ test.describe('Unified plugin workspace', () => {
|
||||
user: {
|
||||
username: 'plugins-e2e', userId: 'plugins-e2e-user', tenantId: null, deptId: null, authorities: [],
|
||||
},
|
||||
moduleAccess: { programming: true, design: true, learning: true, robot: true },
|
||||
moduleAccess: { programming: true, design: true, robot: true },
|
||||
});
|
||||
if (requestPath === '/api/works/user/agent-profile') return result({
|
||||
success: true,
|
||||
@@ -239,7 +239,7 @@ test.describe('Unified plugin workspace', () => {
|
||||
user: {
|
||||
username: 'plugins-e2e', userId: 'plugins-e2e-user', tenantId: null, deptId: null, authorities: [],
|
||||
},
|
||||
moduleAccess: { programming: true, design: true, learning: true, robot: true },
|
||||
moduleAccess: { programming: true, design: true, robot: true },
|
||||
});
|
||||
if (requestPath === '/api/works/user/agent-profile') return result({
|
||||
success: true,
|
||||
|
||||
@@ -63,7 +63,6 @@ describe('App programming provider initialization gate', () => {
|
||||
moduleAccess: {
|
||||
programming: true,
|
||||
design: true,
|
||||
learning: true,
|
||||
robot: true,
|
||||
},
|
||||
init: vi.fn(),
|
||||
@@ -116,7 +115,6 @@ describe('App programming provider initialization gate', () => {
|
||||
moduleAccess: {
|
||||
programming: true,
|
||||
design: true,
|
||||
learning: true,
|
||||
robot: true,
|
||||
},
|
||||
});
|
||||
@@ -131,7 +129,6 @@ describe('App programming provider initialization gate', () => {
|
||||
moduleAccess: {
|
||||
programming: false,
|
||||
design: true,
|
||||
learning: true,
|
||||
robot: true,
|
||||
},
|
||||
});
|
||||
@@ -147,8 +144,6 @@ describe('App programming provider initialization gate', () => {
|
||||
['/chat', 'programming'],
|
||||
['/image-canvas', 'design'],
|
||||
['/image-prompts/example', 'design'],
|
||||
['/learning', 'learning'],
|
||||
['/learning/project/project-1', 'learning'],
|
||||
['/ai-hardware', 'robot'],
|
||||
['/ai-hardware/device-1', 'robot'],
|
||||
] as const)('redirects disabled %s routes before mounting their module', async (pathname, accessKey) => {
|
||||
@@ -156,7 +151,6 @@ describe('App programming provider initialization gate', () => {
|
||||
moduleAccess: {
|
||||
programming: true,
|
||||
design: true,
|
||||
learning: true,
|
||||
robot: true,
|
||||
[accessKey]: false,
|
||||
},
|
||||
@@ -171,12 +165,22 @@ describe('App programming provider initialization gate', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['/learning', '/learning/project/project-1'])(
|
||||
'redirects retired Learning route %s to the module chooser',
|
||||
async (pathname) => {
|
||||
await renderAt(pathname);
|
||||
|
||||
expect(await screen.findByText('Module chooser')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('main-layout')).not.toBeInTheDocument();
|
||||
expect(initProviders).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps global settings available when Programming is disabled', async () => {
|
||||
useAuthStore.setState({
|
||||
moduleAccess: {
|
||||
programming: false,
|
||||
design: true,
|
||||
learning: true,
|
||||
robot: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -115,10 +115,10 @@ describe('auth host api routes', () => {
|
||||
moduleAccess: {
|
||||
programming: false,
|
||||
design: true,
|
||||
learning: false,
|
||||
robot: true,
|
||||
},
|
||||
});
|
||||
expect(response.json()).not.toHaveProperty('moduleAccess.learning');
|
||||
expect(JSON.stringify(response.json())).not.toContain('must-not-reach-renderer');
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://square.nianxx.cn/api/auth/me',
|
||||
@@ -161,7 +161,6 @@ describe('auth host api routes', () => {
|
||||
moduleAccess: {
|
||||
programming: true,
|
||||
design: true,
|
||||
learning: true,
|
||||
robot: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -24,7 +24,6 @@ function resetAuthStore() {
|
||||
moduleAccess: {
|
||||
programming: true,
|
||||
design: true,
|
||||
learning: true,
|
||||
robot: true,
|
||||
},
|
||||
});
|
||||
@@ -88,7 +87,6 @@ describe('auth store', () => {
|
||||
moduleAccess: {
|
||||
programming: true,
|
||||
design: false,
|
||||
learning: true,
|
||||
robot: false,
|
||||
},
|
||||
});
|
||||
@@ -129,7 +127,6 @@ describe('auth store', () => {
|
||||
expect(state.moduleAccess).toEqual({
|
||||
programming: true,
|
||||
design: false,
|
||||
learning: true,
|
||||
robot: false,
|
||||
});
|
||||
expect(window.localStorage.getItem('niancode-auth')).not.toContain(
|
||||
@@ -158,7 +155,7 @@ describe('auth store', () => {
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
moduleAccess: { learning: false },
|
||||
moduleAccess: { robot: false },
|
||||
});
|
||||
|
||||
await useAuthStore.getState().loginWithMobile({
|
||||
@@ -179,8 +176,7 @@ describe('auth store', () => {
|
||||
moduleAccess: {
|
||||
programming: true,
|
||||
design: true,
|
||||
learning: false,
|
||||
robot: true,
|
||||
robot: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -217,7 +213,6 @@ describe('auth store', () => {
|
||||
moduleAccess: {
|
||||
programming: true,
|
||||
design: true,
|
||||
learning: true,
|
||||
robot: true,
|
||||
},
|
||||
});
|
||||
@@ -261,7 +256,6 @@ describe('auth store', () => {
|
||||
expect(useAuthStore.getState().moduleAccess).toEqual({
|
||||
programming: true,
|
||||
design: false,
|
||||
learning: true,
|
||||
robot: true,
|
||||
});
|
||||
});
|
||||
@@ -315,7 +309,6 @@ describe('auth store', () => {
|
||||
moduleAccess: {
|
||||
programming: true,
|
||||
design: false,
|
||||
learning: true,
|
||||
robot: true,
|
||||
},
|
||||
});
|
||||
@@ -422,7 +415,6 @@ describe('auth store', () => {
|
||||
moduleAccess: {
|
||||
programming: true,
|
||||
design: false,
|
||||
learning: true,
|
||||
robot: true,
|
||||
},
|
||||
user: {
|
||||
@@ -445,7 +437,6 @@ describe('auth store', () => {
|
||||
moduleAccess: {
|
||||
programming: true,
|
||||
design: true,
|
||||
learning: true,
|
||||
robot: true,
|
||||
},
|
||||
});
|
||||
@@ -488,7 +479,6 @@ describe('auth store', () => {
|
||||
moduleAccess: {
|
||||
programming: true,
|
||||
design: true,
|
||||
learning: true,
|
||||
robot: true,
|
||||
},
|
||||
});
|
||||
@@ -952,7 +942,6 @@ describe('auth store', () => {
|
||||
moduleAccess: {
|
||||
programming: true,
|
||||
design: true,
|
||||
learning: true,
|
||||
robot: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -64,7 +64,7 @@ describe('BackgroundLifecycleController', () => {
|
||||
onStopRuntime,
|
||||
});
|
||||
|
||||
controller.setActivity({ visible: false, module: 'learning' });
|
||||
controller.setActivity({ visible: false, module: 'painting' });
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(onSleep).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -90,7 +90,7 @@ describe('BackgroundLifecycleController', () => {
|
||||
onStopRuntime,
|
||||
});
|
||||
|
||||
controller.setActivity({ visible: false, module: 'learning' });
|
||||
controller.setActivity({ visible: false, module: 'robot' });
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
controller.setActivity({ visible: true, module: 'programming' });
|
||||
await vi.advanceTimersByTimeAsync(90_000);
|
||||
|
||||
@@ -29,4 +29,19 @@ describe('Host API transport selection', () => {
|
||||
expect(data).toMatchObject({ status: 200, ok: true });
|
||||
expect(data.json).toMatchObject({ runtime: 'pi', diagnostics: { revision: 3 } });
|
||||
});
|
||||
|
||||
it('leaves the retired Learning Host API unregistered', async () => {
|
||||
const data = await dispatchHostApiRequest({} as never, {
|
||||
path: '/api/works/learning/projects',
|
||||
});
|
||||
|
||||
expect(data).toMatchObject({
|
||||
status: 404,
|
||||
ok: false,
|
||||
json: {
|
||||
success: false,
|
||||
error: 'No route for GET /api/works/learning/projects',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
downloadLearningProject,
|
||||
fetchLearningProject,
|
||||
fetchLearningProjectMedia,
|
||||
fetchLearningProjects,
|
||||
openLearningExternalLink,
|
||||
} from '@/lib/learning';
|
||||
|
||||
const hostApiFetchMock = vi.hoisted(() => vi.fn());
|
||||
const invokeIpcMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/host-api', () => ({
|
||||
hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api-client', () => ({
|
||||
invokeIpc: (...args: unknown[]) => invokeIpcMock(...args),
|
||||
}));
|
||||
|
||||
describe('Learning project renderer client', () => {
|
||||
beforeEach(() => {
|
||||
hostApiFetchMock.mockReset();
|
||||
invokeIpcMock.mockReset();
|
||||
});
|
||||
|
||||
it('reads paged projects and project detail through the Host API', async () => {
|
||||
const page = { items: [], nextCursor: 'next' };
|
||||
const detail = { id: 'project-1', name: '机械臂' };
|
||||
hostApiFetchMock
|
||||
.mockResolvedValueOnce({ success: true, data: page })
|
||||
.mockResolvedValueOnce({ success: true, data: detail });
|
||||
|
||||
await expect(fetchLearningProjects({ cursor: 'cursor one', limit: 24 })).resolves.toBe(page);
|
||||
await expect(fetchLearningProject('project/one')).resolves.toBe(detail);
|
||||
|
||||
expect(hostApiFetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/api/works/learning/projects?cursor=cursor+one&limit=24',
|
||||
undefined,
|
||||
);
|
||||
expect(hostApiFetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/api/works/learning/projects/project%2Fone',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('starts the native Main-owned download through a fixed Host route', async () => {
|
||||
hostApiFetchMock.mockResolvedValue({ success: true, data: { status: 'saved' } });
|
||||
|
||||
await expect(downloadLearningProject('project-1')).resolves.toEqual({ status: 'saved' });
|
||||
|
||||
expect(hostApiFetchMock).toHaveBeenCalledWith(
|
||||
'/api/works/learning/projects/project-1/download',
|
||||
{ method: 'POST' },
|
||||
);
|
||||
expect(invokeIpcMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts only fixed project media paths and validated raster data', async () => {
|
||||
hostApiFetchMock.mockResolvedValue({ mimeType: 'image/png', dataBase64: 'AQID' });
|
||||
|
||||
await expect(fetchLearningProjectMedia(
|
||||
'/api/learning/projects/project-1/media/readme-1',
|
||||
)).resolves.toBe('data:image/png;base64,AQID');
|
||||
expect(hostApiFetchMock).toHaveBeenCalledWith(
|
||||
'/api/works/learning/projects/project-1/media/readme-1',
|
||||
);
|
||||
|
||||
await expect(fetchLearningProjectMedia('https://tracker.example/image.png')).rejects.toMatchObject({
|
||||
code: 'LEARNING_INVALID_MEDIA_URL',
|
||||
});
|
||||
hostApiFetchMock.mockResolvedValueOnce({ mimeType: 'image/svg+xml', dataBase64: 'AQID' });
|
||||
await expect(fetchLearningProjectMedia(
|
||||
'/api/learning/projects/project-1/media/readme-2',
|
||||
)).rejects.toMatchObject({ code: 'LEARNING_INVALID_MEDIA_RESPONSE' });
|
||||
});
|
||||
|
||||
it('opens only credential-free HTTPS README links through the IPC facade', async () => {
|
||||
invokeIpcMock.mockResolvedValue(undefined);
|
||||
|
||||
await openLearningExternalLink('https://example.com/guide');
|
||||
expect(invokeIpcMock).toHaveBeenCalledWith('shell:openExternal', 'https://example.com/guide');
|
||||
await expect(openLearningExternalLink('http://example.com')).rejects.toMatchObject({
|
||||
code: 'LEARNING_INVALID_LINK',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,120 +0,0 @@
|
||||
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'));
|
||||
});
|
||||
});
|
||||
@@ -1,150 +0,0 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
saveLearningProjectArchive,
|
||||
safeLearningProjectArchiveFileName,
|
||||
} from '@electron/services/learning-project-download';
|
||||
|
||||
const archive = Buffer.from([0x50, 0x4b, 0x03, 0x04, 1, 2, 3, 4]);
|
||||
const archiveSha256 = createHash('sha256').update(archive).digest('hex');
|
||||
const binding = { accountKey: 'a'.repeat(64), epoch: 1 };
|
||||
let root = '';
|
||||
|
||||
function project(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'project-1',
|
||||
name: '机械臂',
|
||||
summary: '项目介绍',
|
||||
cover: { url: '/api/learning/projects/project-1/media/cover', alt: '封面' },
|
||||
tags: [],
|
||||
version: null,
|
||||
archiveBytes: archive.length,
|
||||
publishedAt: '2026-08-01T00:00:00Z',
|
||||
updatedAt: '2026-08-01T00:00:00Z',
|
||||
readmeMarkdown: '# 机械臂',
|
||||
archiveFileName: 'project.zip',
|
||||
archiveSha256,
|
||||
...overrides,
|
||||
} as never;
|
||||
}
|
||||
|
||||
describe('Learning project verified download', () => {
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'makelore-learning-project-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('streams, verifies and atomically saves a ZIP without returning its path', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(new Response(archive, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/zip', 'Content-Length': String(archive.length) },
|
||||
}));
|
||||
const destinationPath = join(root, '机械臂.zip');
|
||||
|
||||
await saveLearningProjectArchive({
|
||||
project: project(),
|
||||
destinationPath,
|
||||
binding,
|
||||
fetchImpl,
|
||||
getAccessToken: vi.fn().mockResolvedValue('works-token'),
|
||||
isCurrentAccountBinding: () => true,
|
||||
apiBaseUrl: 'https://square.example',
|
||||
});
|
||||
|
||||
await expect(readFile(destinationPath)).resolves.toEqual(archive);
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
'https://square.example/api/learning/projects/project-1/archive',
|
||||
expect.objectContaining({
|
||||
headers: { Accept: 'application/zip', Authorization: 'Bearer works-token' },
|
||||
redirect: 'manual',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('downloads without comparing metadata or transport-reported archive sizes', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(new Response(archive, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/zip', 'Content-Length': String(archive.length + 100) },
|
||||
}));
|
||||
const destinationPath = join(root, 'large-project.zip');
|
||||
|
||||
await saveLearningProjectArchive({
|
||||
project: project({ archiveBytes: 512 * 1024 * 1024 + 1 }),
|
||||
destinationPath,
|
||||
binding,
|
||||
fetchImpl,
|
||||
getAccessToken: vi.fn().mockResolvedValue('works-token'),
|
||||
isCurrentAccountBinding: () => true,
|
||||
apiBaseUrl: 'https://square.example',
|
||||
});
|
||||
|
||||
await expect(readFile(destinationPath)).resolves.toEqual(archive);
|
||||
});
|
||||
|
||||
it('downloads when the archive response omits Content-Length', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(new Response(archive, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/zip' },
|
||||
}));
|
||||
const destinationPath = join(root, 'unknown-size-project.zip');
|
||||
|
||||
await saveLearningProjectArchive({
|
||||
project: project({ archiveBytes: 1 }),
|
||||
destinationPath,
|
||||
binding,
|
||||
fetchImpl,
|
||||
getAccessToken: vi.fn().mockResolvedValue('works-token'),
|
||||
isCurrentAccountBinding: () => true,
|
||||
apiBaseUrl: 'https://square.example',
|
||||
});
|
||||
|
||||
await expect(readFile(destinationPath)).resolves.toEqual(archive);
|
||||
});
|
||||
|
||||
it('rejects an unsafe redirect and never forwards Bearer credentials to redirects', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: 'https://storage.example/private.zip' },
|
||||
}));
|
||||
|
||||
await expect(saveLearningProjectArchive({
|
||||
project: project(),
|
||||
destinationPath: join(root, 'project.zip'),
|
||||
binding,
|
||||
fetchImpl,
|
||||
getAccessToken: vi.fn().mockResolvedValue('works-token'),
|
||||
isCurrentAccountBinding: () => true,
|
||||
apiBaseUrl: 'https://square.example',
|
||||
})).rejects.toMatchObject({ code: 'LEARNING_DOWNLOAD_REDIRECT_INVALID' });
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('removes partial files after an integrity failure', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(new Response(archive, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/zip', 'Content-Length': String(archive.length) },
|
||||
}));
|
||||
|
||||
await expect(saveLearningProjectArchive({
|
||||
project: project({ archiveSha256: 'f'.repeat(64) }),
|
||||
destinationPath: join(root, 'project.zip'),
|
||||
binding,
|
||||
fetchImpl,
|
||||
getAccessToken: vi.fn().mockResolvedValue('works-token'),
|
||||
isCurrentAccountBinding: () => true,
|
||||
apiBaseUrl: 'https://square.example',
|
||||
})).rejects.toMatchObject({ code: 'LEARNING_ARCHIVE_HASH_MISMATCH' });
|
||||
await expect(readdir(root)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('normalizes untrusted archive names to a ZIP file name', () => {
|
||||
expect(safeLearningProjectArchiveFileName('../bad.exe', 'project:1')).toBe('Makelore-project-1.zip');
|
||||
expect(safeLearningProjectArchiveFileName('机械臂.zip', 'project-1')).toBe('机械臂.zip');
|
||||
});
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ProjectImage } from '@/pages/Learning/ProjectImage';
|
||||
|
||||
describe('Learning ProjectImage', () => {
|
||||
it('renders a credential-free HTTPS image only when direct loading is allowed', () => {
|
||||
const { rerender } = render(
|
||||
<ProjectImage
|
||||
src="https://images.example.com/diagram.svg"
|
||||
alt="项目结构图"
|
||||
allowHttps
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByAltText('项目结构图')).toHaveAttribute(
|
||||
'src',
|
||||
'https://images.example.com/diagram.svg',
|
||||
);
|
||||
|
||||
rerender(
|
||||
<ProjectImage
|
||||
src="https://images.example.com/diagram.svg"
|
||||
alt="项目结构图"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByAltText('项目结构图')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('img', { name: '项目结构图加载失败' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,303 +0,0 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createLearningRouteHandler } from '@electron/api/routes/learning';
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: vi.fn(() => 'C:\\Downloads') },
|
||||
dialog: { showSaveDialog: vi.fn() },
|
||||
}));
|
||||
|
||||
const ARCHIVE_HASH = 'a'.repeat(64);
|
||||
|
||||
function project(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'project-1',
|
||||
name: '机械臂入门',
|
||||
summary: '从零搭建桌面机械臂。',
|
||||
cover: {
|
||||
url: '/api/learning/projects/project-1/media/cover',
|
||||
alt: '机械臂封面',
|
||||
width: 1600,
|
||||
height: 900,
|
||||
internalKey: 'private/cover.webp',
|
||||
},
|
||||
tags: ['机器人', 'Python'],
|
||||
version: '1.2.0',
|
||||
archiveBytes: 1024,
|
||||
publishedAt: '2026-08-01T00:00:00Z',
|
||||
updatedAt: '2026-08-18T00:00:00Z',
|
||||
readmeMarkdown: '# 机械臂入门',
|
||||
archiveFileName: 'robot-arm.zip',
|
||||
archiveSha256: ARCHIVE_HASH,
|
||||
objectStorageKey: 'private/project.zip',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function envelope(data: unknown): Response {
|
||||
return new Response(JSON.stringify({ success: true, data }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
function createResponse() {
|
||||
const chunks: string[] = [];
|
||||
const res = {
|
||||
statusCode: 0,
|
||||
setHeader: vi.fn(),
|
||||
end: vi.fn((chunk?: string) => {
|
||||
if (chunk) chunks.push(chunk);
|
||||
}),
|
||||
} as unknown as ServerResponse;
|
||||
return {
|
||||
res,
|
||||
get json() {
|
||||
return JSON.parse(chunks.join('')) as Record<string, unknown>;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('Learning project Main route boundary', () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>();
|
||||
const getAccessToken = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
fetchImpl.mockReset();
|
||||
getAccessToken.mockReset();
|
||||
});
|
||||
|
||||
it.each([
|
||||
'/api/works/projects',
|
||||
'/api/works/learning/courses',
|
||||
'/api/works/learning/projects/project-1/unknown',
|
||||
])('does not claim old or unrelated route %s', async (pathname) => {
|
||||
const handler = createLearningRouteHandler({ fetchImpl, getAccessToken });
|
||||
const response = createResponse();
|
||||
|
||||
await expect(handler(
|
||||
{ method: 'GET' } as IncomingMessage,
|
||||
response.res,
|
||||
new URL(`http://127.0.0.1${pathname}`),
|
||||
{} as never,
|
||||
)).resolves.toBe(false);
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forwards bounded pagination and strictly projects project cards', async () => {
|
||||
getAccessToken.mockResolvedValue('works-token');
|
||||
fetchImpl.mockResolvedValue(envelope({
|
||||
items: [project()],
|
||||
nextCursor: 'next-page',
|
||||
total: 1,
|
||||
internalFacet: 'drop-me',
|
||||
}));
|
||||
const handler = createLearningRouteHandler({ fetchImpl, getAccessToken, apiBaseUrl: 'https://square.example/' });
|
||||
const response = createResponse();
|
||||
|
||||
await handler(
|
||||
{ method: 'GET' } as IncomingMessage,
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/learning/projects?cursor=opaque&limit=24&unknown=no'),
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
'https://square.example/api/learning/projects?cursor=opaque&limit=24',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json', Authorization: 'Bearer works-token' },
|
||||
redirect: 'manual',
|
||||
}),
|
||||
);
|
||||
expect(response.json).toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
items: [{
|
||||
id: 'project-1',
|
||||
name: '机械臂入门',
|
||||
summary: '从零搭建桌面机械臂。',
|
||||
cover: {
|
||||
url: '/api/learning/projects/project-1/media/cover',
|
||||
alt: '机械臂封面',
|
||||
width: 1600,
|
||||
height: 900,
|
||||
},
|
||||
tags: ['机器人', 'Python'],
|
||||
version: '1.2.0',
|
||||
archiveBytes: 1024,
|
||||
publishedAt: '2026-08-01T00:00:00Z',
|
||||
updatedAt: '2026-08-18T00:00:00Z',
|
||||
}],
|
||||
nextCursor: 'next-page',
|
||||
total: 1,
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(response.json)).not.toContain('objectStorageKey');
|
||||
});
|
||||
|
||||
it('projects detail Markdown and archive integrity metadata without storage keys', async () => {
|
||||
getAccessToken.mockResolvedValue('works-token');
|
||||
fetchImpl.mockResolvedValue(envelope(project()));
|
||||
const handler = createLearningRouteHandler({ fetchImpl, getAccessToken });
|
||||
const response = createResponse();
|
||||
|
||||
await handler(
|
||||
{ method: 'GET' } as IncomingMessage,
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/learning/projects/project-1'),
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(response.json).toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
id: 'project-1',
|
||||
readmeMarkdown: '# 机械臂入门',
|
||||
archiveFileName: 'robot-arm.zip',
|
||||
archiveSha256: ARCHIVE_HASH,
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(response.json)).not.toContain('private/project.zip');
|
||||
});
|
||||
|
||||
it('accepts project metadata above the former client archive-size limit', async () => {
|
||||
getAccessToken.mockResolvedValue('works-token');
|
||||
const archiveBytes = 512 * 1024 * 1024 + 1;
|
||||
fetchImpl.mockResolvedValue(envelope(project({ archiveBytes })));
|
||||
const handler = createLearningRouteHandler({ fetchImpl, getAccessToken });
|
||||
const response = createResponse();
|
||||
|
||||
await handler(
|
||||
{ method: 'GET' } as IncomingMessage,
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/learning/projects/project-1'),
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(response.json).toMatchObject({
|
||||
success: true,
|
||||
data: { id: 'project-1', archiveBytes },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects malformed identities and unsafe media URLs in successful DTOs', async () => {
|
||||
getAccessToken.mockResolvedValue('works-token');
|
||||
fetchImpl.mockResolvedValue(envelope({
|
||||
items: [project({ cover: { url: 'http://internal.example/cover.png', alt: '不安全' } })],
|
||||
nextCursor: null,
|
||||
}));
|
||||
const handler = createLearningRouteHandler({ fetchImpl, getAccessToken });
|
||||
const response = createResponse();
|
||||
|
||||
await handler(
|
||||
{ method: 'GET' } as IncomingMessage,
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/learning/projects'),
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(response.res.statusCode).toBe(502);
|
||||
expect(response.json).toMatchObject({ code: 'LEARNING_INVALID_RESPONSE' });
|
||||
});
|
||||
|
||||
it('proxies only fixed raster media paths with Works authentication', async () => {
|
||||
getAccessToken.mockResolvedValue('works-token');
|
||||
fetchImpl.mockResolvedValue(new Response(Uint8Array.from([1, 2, 3]), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'image/webp', 'Content-Length': '3' },
|
||||
}));
|
||||
const handler = createLearningRouteHandler({ fetchImpl, getAccessToken, apiBaseUrl: 'https://square.example' });
|
||||
const response = createResponse();
|
||||
|
||||
await handler(
|
||||
{ method: 'GET' } as IncomingMessage,
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/learning/projects/project-1/media/readme-1'),
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
'https://square.example/api/learning/projects/project-1/media/readme-1',
|
||||
expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer works-token' }) }),
|
||||
);
|
||||
expect(response.json).toEqual({ dataBase64: 'AQID', mimeType: 'image/webp' });
|
||||
});
|
||||
|
||||
it('uses project metadata and a native destination before delegating verified download', async () => {
|
||||
getAccessToken.mockResolvedValue('works-token');
|
||||
fetchImpl.mockResolvedValue(envelope(project()));
|
||||
const chooseDestination = vi.fn().mockResolvedValue('D:\\Downloads\\robot-arm.zip');
|
||||
const saveArchive = vi.fn().mockResolvedValue(undefined);
|
||||
const binding = { accountKey: 'b'.repeat(64), epoch: 1 };
|
||||
const handler = createLearningRouteHandler({
|
||||
fetchImpl,
|
||||
getAccessToken,
|
||||
getAccountBinding: () => binding,
|
||||
isCurrentAccountBinding: () => true,
|
||||
chooseDestination,
|
||||
saveArchive,
|
||||
apiBaseUrl: 'https://square.example',
|
||||
});
|
||||
const response = createResponse();
|
||||
|
||||
await handler(
|
||||
{ method: 'POST' } as IncomingMessage,
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/learning/projects/project-1/download'),
|
||||
{ mainWindow: null } as never,
|
||||
);
|
||||
|
||||
expect(chooseDestination).toHaveBeenCalledWith(expect.anything(), 'robot-arm.zip');
|
||||
expect(saveArchive).toHaveBeenCalledWith(expect.objectContaining({
|
||||
destinationPath: 'D:\\Downloads\\robot-arm.zip',
|
||||
binding,
|
||||
project: expect.objectContaining({ id: 'project-1', archiveSha256: ARCHIVE_HASH }),
|
||||
}));
|
||||
expect(response.json).toEqual({ success: true, data: { status: 'saved' } });
|
||||
});
|
||||
|
||||
it('does not download when the user cancels the native save dialog', async () => {
|
||||
getAccessToken.mockResolvedValue('works-token');
|
||||
fetchImpl.mockResolvedValue(envelope(project()));
|
||||
const saveArchive = vi.fn();
|
||||
const handler = createLearningRouteHandler({
|
||||
fetchImpl,
|
||||
getAccessToken,
|
||||
getAccountBinding: () => ({ accountKey: 'b'.repeat(64), epoch: 1 }),
|
||||
isCurrentAccountBinding: () => true,
|
||||
chooseDestination: vi.fn().mockResolvedValue(null),
|
||||
saveArchive,
|
||||
});
|
||||
const response = createResponse();
|
||||
|
||||
await handler(
|
||||
{ method: 'POST' } as IncomingMessage,
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/learning/projects/project-1/download'),
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(saveArchive).not.toHaveBeenCalled();
|
||||
expect(response.json).toEqual({ success: true, data: { status: 'cancelled' } });
|
||||
});
|
||||
|
||||
it('refreshes one upstream 401 and redacts unavailable-service details', async () => {
|
||||
getAccessToken.mockResolvedValueOnce('expired').mockResolvedValueOnce('fresh');
|
||||
fetchImpl
|
||||
.mockResolvedValueOnce(new Response('expired secret', { status: 401 }))
|
||||
.mockResolvedValueOnce(envelope({ items: [], nextCursor: null }));
|
||||
const handler = createLearningRouteHandler({ fetchImpl, getAccessToken });
|
||||
const response = createResponse();
|
||||
|
||||
await handler(
|
||||
{ method: 'GET' } as IncomingMessage,
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/learning/projects'),
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(getAccessToken).toHaveBeenNthCalledWith(2, { fetchImpl, forceRefresh: true });
|
||||
expect(response.json).toEqual({ success: true, data: { items: [], nextCursor: null } });
|
||||
});
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
|
||||
import { LearningSidebar } from '@/components/layout/LearningSidebar';
|
||||
|
||||
function LocationProbe() {
|
||||
const location = useLocation();
|
||||
return <output data-testid="location-path">{location.pathname}</output>;
|
||||
}
|
||||
|
||||
describe('LearningSidebar', () => {
|
||||
it('shows only the new project catalog navigation and returns detail pages to the list', () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/learning/project/project-1']}>
|
||||
<Routes>
|
||||
<Route path="*" element={<><LearningSidebar sidebarCollapsed={false} /><LocationProbe /></>} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('navigation', { name: 'AI 学习导航' })).toHaveTextContent('学习项目');
|
||||
expect(screen.queryByText('生成课程')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('我的课程')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '学习项目' }));
|
||||
expect(screen.getByTestId('location-path')).toHaveTextContent('/learning');
|
||||
});
|
||||
|
||||
it('keeps the collapsed navigation compact', () => {
|
||||
render(<MemoryRouter><LearningSidebar sidebarCollapsed /></MemoryRouter>);
|
||||
expect(screen.getByRole('button', { name: '学习项目' })).toBeInTheDocument();
|
||||
expect(screen.queryByText('阅读项目说明并下载到电脑实践。')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import { useProjectConfigStore } from '@/stores/project-config';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
import { createCodingProjectConfigV2 } from '@electron/coding-projects/project-config';
|
||||
|
||||
const learningIpcMock = vi.hoisted(() => vi.fn());
|
||||
const routeRenderMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/components/layout/Sidebar', () => ({
|
||||
Sidebar: ({ sidebarCollapsedOverride }: { sidebarCollapsedOverride?: boolean }) => (
|
||||
@@ -26,8 +26,8 @@ vi.mock('@/components/layout/TitleBar', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
function LearningRouteContent() {
|
||||
learningIpcMock();
|
||||
function RouteContent() {
|
||||
routeRenderMock();
|
||||
return <div data-testid="route-content" />;
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ function renderLayout(path: string) {
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<Routes>
|
||||
<Route element={<MainLayout />}>
|
||||
<Route path="*" element={<LearningRouteContent />} />
|
||||
<Route path="*" element={<RouteContent />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
@@ -45,7 +45,7 @@ function renderLayout(path: string) {
|
||||
|
||||
describe('MainLayout module isolation', () => {
|
||||
beforeEach(() => {
|
||||
learningIpcMock.mockReset();
|
||||
routeRenderMock.mockReset();
|
||||
const project = {
|
||||
id: 'local-project',
|
||||
path: '/tmp/local-project',
|
||||
@@ -118,16 +118,4 @@ describe('MainLayout module isolation', () => {
|
||||
expect(screen.getByTestId('main-content')).toHaveClass('basis-0', 'overflow-hidden', 'p-0', 'sm:p-6');
|
||||
});
|
||||
|
||||
it.each(['/learning', '/learning/project/project-1'])(
|
||||
'keeps the project catalog route %s in the standard integrated shell',
|
||||
(path) => {
|
||||
renderLayout(path);
|
||||
|
||||
expect(screen.getByTestId('sidebar-stub')).toBeVisible();
|
||||
expect(screen.getByTestId('titlebar-stub')).toHaveAttribute('data-integrated', 'true');
|
||||
expect(screen.getByTestId('main-content')).toHaveClass('p-5', 'sm:p-6');
|
||||
expect(screen.getByTestId('route-content')).toBeVisible();
|
||||
expect(screen.queryByTestId('learning-profile-sync-error-gate')).not.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
33
tests/unit/module-access.test.ts
Normal file
33
tests/unit/module-access.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
DEFAULT_MODULE_ACCESS,
|
||||
MODULE_ACCESS_KEYS,
|
||||
normalizeModuleAccess,
|
||||
} from '../../shared/module-access';
|
||||
|
||||
describe('module access projection', () => {
|
||||
it('contains only the three supported product modules', () => {
|
||||
expect(MODULE_ACCESS_KEYS).toEqual(['programming', 'design', 'robot']);
|
||||
expect(DEFAULT_MODULE_ACCESS).toEqual({
|
||||
programming: true,
|
||||
design: true,
|
||||
robot: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('drops the retired Learning key from legacy or upstream policy data', () => {
|
||||
const access = normalizeModuleAccess({
|
||||
programming: false,
|
||||
design: true,
|
||||
learning: false,
|
||||
robot: false,
|
||||
});
|
||||
|
||||
expect(access).toEqual({
|
||||
programming: false,
|
||||
design: true,
|
||||
robot: false,
|
||||
});
|
||||
expect(access).not.toHaveProperty('learning');
|
||||
});
|
||||
});
|
||||
@@ -52,7 +52,7 @@ describe('AI module navigation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the four module choices and routes Canvas from the chooser', async () => {
|
||||
it('shows only the three supported module choices and routes Canvas from the chooser', async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/module-select']}>
|
||||
<Routes>
|
||||
@@ -68,9 +68,11 @@ describe('AI module navigation', () => {
|
||||
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) {
|
||||
for (const moduleId of ['programming', 'painting', 'robot'] as const) {
|
||||
expect(screen.getByTestId(`ai-module-option-${moduleId}`).querySelector('.module-option-artwork')).toBeNull();
|
||||
}
|
||||
expect(screen.getAllByTestId(/^ai-module-option-/u)).toHaveLength(3);
|
||||
expect(screen.queryByTestId('ai-module-option-learning')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('先选一个入口。进入后,你还可以从左下角随时切换到其他模块。')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('选择创作空间。你可以随时从侧边栏切换。')).not.toBeInTheDocument();
|
||||
expect(screen.getByAltText('Makelore')).toHaveClass('h-8', 'w-40', 'object-cover');
|
||||
@@ -82,12 +84,6 @@ describe('AI module navigation', () => {
|
||||
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');
|
||||
@@ -95,8 +91,8 @@ describe('AI module navigation', () => {
|
||||
expect(robotOption).toHaveTextContent('制作你的第一个机器人小伙伴');
|
||||
expect(screen.getByAltText('青少年管理机器人硬件与设备绑定')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(learningOption);
|
||||
expect(await screen.findByTestId('location-path')).toHaveTextContent('/learning');
|
||||
fireEvent.click(screen.getByTestId('ai-module-option-painting'));
|
||||
expect(await screen.findByTestId('location-path')).toHaveTextContent('/image-canvas');
|
||||
});
|
||||
|
||||
it('routes Robot from the chooser to the AI hardware workspace', async () => {
|
||||
@@ -117,8 +113,7 @@ describe('AI module navigation', () => {
|
||||
useAuthStore.setState({
|
||||
moduleAccess: {
|
||||
programming: true,
|
||||
design: true,
|
||||
learning: false,
|
||||
design: false,
|
||||
robot: true,
|
||||
},
|
||||
user: {
|
||||
@@ -139,12 +134,12 @@ describe('AI module navigation', () => {
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const learningOption = screen.getByTestId('ai-module-option-learning');
|
||||
expect(learningOption).toBeDisabled();
|
||||
expect(learningOption).toHaveClass('module-option-card-disabled');
|
||||
expect(learningOption).toHaveAttribute('aria-disabled', 'true');
|
||||
const canvasOption = screen.getByTestId('ai-module-option-painting');
|
||||
expect(canvasOption).toBeDisabled();
|
||||
expect(canvasOption).toHaveClass('module-option-card-disabled');
|
||||
expect(canvasOption).toHaveAttribute('aria-disabled', 'true');
|
||||
|
||||
fireEvent.click(learningOption);
|
||||
fireEvent.click(canvasOption);
|
||||
expect(screen.getByTestId('ai-module-selection-page')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('location-path')).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -274,20 +269,6 @@ describe('AI module navigation', () => {
|
||||
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={['/chat']}>
|
||||
|
||||
Reference in New Issue
Block a user