Files
makelore/tests/unit/project-publish-action.test.tsx

421 lines
17 KiB
TypeScript

import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ProjectPublishAction } from '@/components/works/ProjectPublishAction';
const publishWorksProjectSourceMock = vi.hoisted(() => vi.fn());
const fetchCurrentWorksProjectStatusMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/works-square', () => ({
publishWorksProjectSource: (...args: unknown[]) => publishWorksProjectSourceMock(...args),
fetchCurrentWorksProjectStatus: (...args: unknown[]) => fetchCurrentWorksProjectStatusMock(...args),
}));
const project = {
id: 'prj_space_cleaner',
path: 'D:/repo/space-cleaner',
name: 'space-cleaner',
createdAt: '',
updatedAt: '',
lastOpenedAt: '',
};
const upload = {
version_id: 'version-1',
review_status: 'building',
build_job_id: 'job-1',
build_status: 'queued',
};
function projectStatus(buildStatus: string, buildErrorCode: string | null = null) {
return {
project: {
app_id: 'space-cleaner',
title: 'space-cleaner',
summary: 'A project',
status: 'draft',
},
latest_version: {
id: 'version-1',
version_name: 'v1.0.0',
review_status: buildStatus === 'succeeded' ? 'pending_review' : 'build_failed',
change_log: '首次提交',
build_job_id: 'job-1',
build_status: buildStatus,
build_error_code: buildErrorCode,
build_error_message: 'Traceback: /srv/private/builder.py token=secret',
release_id: buildStatus === 'succeeded' ? 'release-1' : null,
created_at: '2026-08-07T12:00:00Z',
},
versions: [],
};
}
async function flushSubmission() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
async function advancePoll() {
await act(async () => {
await vi.advanceTimersByTimeAsync(2_000);
});
}
const pngBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1]);
async function submitProjectMetadata({ includeCover = true }: { includeCover?: boolean } = {}) {
fetchCurrentWorksProjectStatusMock.mockRejectedValueOnce(Object.assign(
new Error('project missing'),
{ statusCode: 404 },
));
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: '一键提交审核' }));
await Promise.resolve();
});
fireEvent.change(screen.getByLabelText(/发布者姓名/), { target: { value: '小明' } });
fireEvent.change(screen.getByLabelText(/发布者年龄/), { target: { value: '12' } });
fireEvent.change(screen.getByLabelText(/项目简介/), { target: { value: '这是一个太空清洁小游戏。' } });
if (includeCover) {
fireEvent.change(screen.getByLabelText(/项目封面/), {
target: { files: [new File([pngBytes], 'space-cover.png', { type: 'image/png' })] },
});
await act(async () => {
await Promise.resolve();
});
expect(screen.getByText('space-cover.png')).toBeVisible();
expect(screen.getByAltText('项目封面预览')).toBeVisible();
expect(screen.getByText('重新选择')).toBeVisible();
}
fireEvent.click(screen.getByRole('button', { name: '提交审核' }));
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
}
describe('ProjectPublishAction', () => {
beforeEach(() => {
vi.useFakeTimers();
publishWorksProjectSourceMock.mockReset();
fetchCurrentWorksProjectStatusMock.mockReset();
publishWorksProjectSourceMock.mockResolvedValue({ package: {}, upload });
});
afterEach(() => {
vi.useRealTimers();
});
it('creates a new project with a cover DTO and locks after Builder succeeds', async () => {
fetchCurrentWorksProjectStatusMock.mockResolvedValue(projectStatus('succeeded'));
render(<ProjectPublishAction project={project} projectType="interactive_ai_app" />);
await submitProjectMetadata();
expect(screen.getByRole('button', { name: '正在等待云端检查…' })).toBeDisabled();
await flushSubmission();
expect(screen.getByTestId('project-publish-status')).toHaveTextContent(
'作品封面与本次构建结果已提交,正在等待平台校验。',
);
expect(publishWorksProjectSourceMock).toHaveBeenCalledWith({
projectId: 'prj_space_cleaner',
project: {
app_id: 'makelore-prj-space-cleaner',
title: 'space-cleaner',
summary: '这是一个太空清洁小游戏。',
description: null,
cover_url: null,
creator_name: '小明',
creator_age: 12,
category: 'interactive_ai_app',
age_band: null,
difficulty: null,
},
cover: {
fileName: 'space-cover.png',
mimeType: 'image/png',
dataBase64: 'iVBORw0KGgoB',
},
});
expect(fetchCurrentWorksProjectStatusMock).toHaveBeenCalledOnce();
await advancePoll();
expect(fetchCurrentWorksProjectStatusMock).toHaveBeenLastCalledWith(
'makelore-prj-space-cleaner',
);
expect(fetchCurrentWorksProjectStatusMock).toHaveBeenCalledTimes(2);
expect(screen.getByRole('button', { name: '已提交,等待运营审核' })).toBeDisabled();
expect(screen.getByTestId('project-publish-status')).toHaveTextContent('作品与封面已提交');
});
it('blocks first submission until a cover is selected', async () => {
render(<ProjectPublishAction project={project} projectType="interactive_ai_app" />);
await submitProjectMetadata({ includeCover: false });
expect(screen.getByRole('alert')).toHaveTextContent('首次提交必须上传');
expect(publishWorksProjectSourceMock).not.toHaveBeenCalled();
expect(screen.getByRole('dialog')).toBeVisible();
});
it('rejects unsupported and oversized cover files before confirmation', async () => {
fetchCurrentWorksProjectStatusMock.mockRejectedValue(Object.assign(new Error('missing'), { statusCode: 404 }));
render(<ProjectPublishAction project={project} projectType="interactive_ai_app" />);
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: '一键提交审核' }));
await Promise.resolve();
});
const input = screen.getByLabelText(/项目封面/);
fireEvent.change(input, { target: { files: [new File(['text'], 'cover.gif', { type: 'image/gif' })] } });
expect(screen.getByRole('alert')).toHaveTextContent('仅支持 PNG、JPEG 或 WebP');
fireEvent.change(input, {
target: { files: [new File([new Uint8Array(10 * 1024 * 1024 + 1)], 'huge.png', { type: 'image/png' })] },
});
expect(screen.getByRole('alert')).toHaveTextContent('不能超过 10 MiB');
expect(publishWorksProjectSourceMock).not.toHaveBeenCalled();
});
it('shows a friendly Builder failure without raw details', async () => {
fetchCurrentWorksProjectStatusMock.mockResolvedValue(
projectStatus('failed', 'BROWSER_SMOKE_FAILED'),
);
render(<ProjectPublishAction project={project} projectType="interactive_ai_app" />);
await submitProjectMetadata();
await flushSubmission();
await advancePoll();
const failure = screen.getByTestId('project-publish-failure');
expect(failure).toHaveTextContent('旧版提交无法继续处理');
expect(failure).toHaveTextContent('下一步:升级 Makelore 并重新提交');
expect(failure).not.toHaveTextContent('BROWSER_SMOKE_FAILED');
expect(failure).not.toHaveTextContent('Traceback');
expect(failure).not.toHaveTextContent('/srv/private');
expect(screen.getByRole('button', { name: '重新提交审核' })).toBeEnabled();
});
it('keeps an uncertain submission locked when status polling is interrupted', async () => {
fetchCurrentWorksProjectStatusMock.mockRejectedValue(
new Error('socket closed at /srv/private/status.py'),
);
render(<ProjectPublishAction project={project} projectType="interactive_ai_app" />);
await submitProjectMetadata();
await flushSubmission();
await advancePoll();
const failure = screen.getByTestId('project-publish-failure');
expect(failure).toHaveTextContent('暂时无法读取构建进度');
expect(failure).toHaveTextContent('不需要重复提交');
expect(failure).not.toHaveTextContent('socket closed');
expect(screen.getByRole('button', { name: '已提交,请稍后查看' })).toBeDisabled();
});
it('shows a safe local binding warning while continuing cloud build polling', async () => {
publishWorksProjectSourceMock.mockResolvedValueOnce({
package: {},
upload,
bindingWarning: {
code: 'LOCAL_PREVIEW_BINDING_SAVE_FAILED',
message: '已提交云端,但本机预览绑定保存失败;可重新打开项目/重新提交。',
},
});
fetchCurrentWorksProjectStatusMock.mockResolvedValue(projectStatus('succeeded'));
render(<ProjectPublishAction project={project} projectType="interactive_ai_app" />);
await submitProjectMetadata();
await flushSubmission();
expect(screen.getByTestId('project-publish-binding-warning')).toHaveTextContent(
'已提交云端,但本机预览绑定保存失败;可重新打开项目/重新提交。',
);
expect(screen.getByRole('button', { name: '正在等待云端检查…' })).toBeDisabled();
await advancePoll();
expect(fetchCurrentWorksProjectStatusMock).toHaveBeenCalledTimes(2);
expect(screen.getByRole('button', { name: '已提交,等待运营审核' })).toBeDisabled();
expect(screen.getByTestId('project-publish-binding-warning')).toBeVisible();
});
it('shows a safe timeout instead of inviting a duplicate submission', async () => {
const status = projectStatus('queued');
status.latest_version.id = 'another-version';
fetchCurrentWorksProjectStatusMock.mockResolvedValue(status);
render(<ProjectPublishAction project={project} projectType="interactive_ai_app" />);
await submitProjectMetadata();
await flushSubmission();
await act(async () => {
await vi.runAllTimersAsync();
});
const failure = screen.getByTestId('project-publish-failure');
expect(failure).toHaveTextContent('构建时间比平时更长');
expect(failure).toHaveTextContent('不需要重复提交');
expect(screen.getByRole('button', { name: '已提交,请稍后查看' })).toBeDisabled();
expect(fetchCurrentWorksProjectStatusMock).toHaveBeenCalledTimes(301);
});
it('sanitizes local and HTTP failures before rendering', async () => {
publishWorksProjectSourceMock.mockRejectedValue(Object.assign(
new Error('Traceback: failed at /srv/private/package.ts token=secret'),
{ code: 'INTERNAL_DATABASE_FAILURE', statusCode: 503 },
));
render(<ProjectPublishAction project={project} projectType="interactive_ai_app" />);
await submitProjectMetadata();
await flushSubmission();
const failure = screen.getByTestId('project-publish-failure');
expect(failure).toHaveTextContent('发布服务暂时不可用');
expect(failure).not.toHaveTextContent('Traceback');
expect(failure).not.toHaveTextContent('INTERNAL_DATABASE_FAILURE');
expect(fetchCurrentWorksProjectStatusMock).toHaveBeenCalledOnce();
});
it('explains a first-create metadata race without claiming that existing metadata was saved', async () => {
publishWorksProjectSourceMock.mockRejectedValue(Object.assign(
new Error('private current metadata token=secret'),
{ code: 'PROJECT_METADATA_CONFLICT', statusCode: 409 },
));
render(<ProjectPublishAction project={project} projectType="interactive_ai_app" />);
await submitProjectMetadata();
await flushSubmission();
const failure = screen.getByTestId('project-publish-failure');
expect(failure).toHaveTextContent('作品资料已发生变化');
expect(failure).toHaveTextContent('本次没有提交版本');
expect(failure).toHaveTextContent('重新点击提交审核');
expect(failure).not.toHaveTextContent('token=secret');
});
it('turns a local package error into instructions for the user', async () => {
publishWorksProjectSourceMock.mockRejectedValue(Object.assign(
new Error('D:/repo/space-cleaner/package-lock.json does not exist'),
{ code: 'PROJECT_FILE_MISSING' },
));
render(<ProjectPublishAction project={project} projectType="interactive_ai_app" />);
await submitProjectMetadata();
await flushSubmission();
const failure = screen.getByTestId('project-publish-failure');
expect(failure).toHaveTextContent('项目文件不完整');
expect(failure).toHaveTextContent('请让开发助手修复当前项目模板');
expect(failure).not.toHaveTextContent('D:/repo');
expect(failure).not.toHaveTextContent('PROJECT_FILE_MISSING');
expect(fetchCurrentWorksProjectStatusMock).toHaveBeenCalledOnce();
});
it('keeps generated metadata within the server limits', async () => {
const longName = `作品-${'x'.repeat(250)}`;
render(<ProjectPublishAction project={{ ...project, name: longName }} projectType="interactive_ai_app" />);
await submitProjectMetadata();
await flushSubmission();
const input = publishWorksProjectSourceMock.mock.calls[0]?.[0];
expect(input.project.app_id).toBe('makelore-prj-space-cleaner');
expect(input.project.title).toHaveLength(160);
expect(input.project.summary).toBe('这是一个太空清洁小游戏。');
expect(input.project.creator_name).toBe('小明');
expect(input.project.creator_age).toBe(12);
expect(input.project.category).toBe('interactive_ai_app');
});
it('submits an existing draft as version-only without editable metadata or replacement cover', async () => {
const draftStatus = projectStatus('queued');
draftStatus.project.title = '平台上的草稿标题';
draftStatus.project.summary = '平台上的草稿简介';
Object.assign(draftStatus.project, {
cover_url: 'coverimage/draft.jpg',
creator_name: '草稿作者',
creator_age: 10,
category: 'interactive_ai_app',
});
fetchCurrentWorksProjectStatusMock.mockResolvedValue(draftStatus);
render(<ProjectPublishAction project={project} projectType="interactive_ai_app" />);
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: '一键提交审核' }));
await Promise.resolve();
});
expect(screen.getByRole('heading', { name: '提交已有草稿的新版本' })).toBeVisible();
expect(screen.getByText(/当前发布契约不支持安全修改已有作品资料/)).toBeVisible();
expect(screen.getByText(/沿用平台现有作品资料与封面/)).toBeVisible();
expect(screen.queryByLabelText(/项目名称/)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/项目封面/)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '提交新版本' }));
await flushSubmission();
expect(publishWorksProjectSourceMock).toHaveBeenCalledWith({
projectId: project.id,
project: expect.objectContaining({
app_id: 'space-cleaner',
title: '平台上的草稿标题',
summary: '平台上的草稿简介',
cover_url: 'coverimage/draft.jpg',
creator_name: '草稿作者',
status: 'draft',
}),
});
expect(publishWorksProjectSourceMock.mock.calls[0]?.[0]).not.toHaveProperty('cover');
expect(screen.getByTestId('project-publish-status')).toHaveTextContent(
'现有作品资料与封面保持不变',
);
});
it('submits a published project as a version without requesting replacement metadata or cover', async () => {
const publishedStatus = projectStatus('succeeded');
publishedStatus.project.status = 'published';
publishedStatus.project.title = '已发布的太空清洁队';
publishedStatus.project.summary = '平台上现有的简介';
Object.assign(publishedStatus.project, {
description: '平台上现有的详细介绍',
cover_url: 'coverimage/existing.jpg',
creator_name: '原作者',
creator_age: 11,
category: 'interactive_ai_app',
age_band: '6-12岁',
difficulty: '入门',
});
fetchCurrentWorksProjectStatusMock.mockResolvedValue(publishedStatus);
render(<ProjectPublishAction project={project} projectType="interactive_ai_app" />);
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: '一键提交审核' }));
await Promise.resolve();
});
expect(screen.getByRole('heading', { name: '提交已发布作品的新版本' })).toBeVisible();
expect(screen.getByText(/现有名称、简介、作者信息和封面都会保持不变/)).toBeVisible();
expect(screen.queryByLabelText(/项目封面/)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '提交新版本' }));
await flushSubmission();
expect(publishWorksProjectSourceMock).toHaveBeenCalledWith({
projectId: project.id,
project: expect.objectContaining({
app_id: 'space-cleaner',
title: '已发布的太空清洁队',
summary: '平台上现有的简介',
cover_url: 'coverimage/existing.jpg',
creator_name: '原作者',
}),
});
expect(publishWorksProjectSourceMock.mock.calls[0]?.[0]).not.toHaveProperty('cover');
expect(screen.getByTestId('project-publish-status')).toHaveTextContent(
'现有作品资料与封面保持不变',
);
});
});