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(); 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: 'mini_game', 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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('mini_program'); }); 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: 'mini_game', }); fetchCurrentWorksProjectStatusMock.mockResolvedValue(draftStatus); render(); 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: 'mini_game', age_band: '6-12岁', difficulty: '入门', }); fetchCurrentWorksProjectStatusMock.mockResolvedValue(publishedStatus); render(); 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( '现有作品资料与封面保持不变', ); }); });