feat: remove Learning module

This commit is contained in:
inman
2026-09-04 12:01:42 +08:00
parent bdc290c2cf
commit bd0873f348
39 changed files with 104 additions and 2278 deletions

View File

@@ -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,
}));

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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,
},
});

View File

@@ -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,
},
});

View File

@@ -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,
},
});

View File

@@ -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);

View File

@@ -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',
},
});
});
});

View File

@@ -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',
});
});
});

View File

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

View File

@@ -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');
});
});

View File

@@ -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();
});
});

View File

@@ -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 } });
});
});

View File

@@ -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();
});
});

View File

@@ -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();
},
);
});

View 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');
});
});

View File

@@ -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']}>