// @vitest-environment jsdom /** * AGC「发布到游戏广场」面板的行为边界。 * * 这里只覆盖面板自身(预填资料、必填门禁、防重复点击、成功/失败态与无 Tauri 宿主), * 真实的 HTTP 链路由 `gameDistributionPublishLive.test.ts` 覆盖。 */ import { cleanup, fireEvent, render, screen, waitFor, within, } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; import type { LocalProjectExportPackageResult } from '../src/app/types'; import { GameDistributionPublishPanel } from '../src/components/game-distribution/GameDistributionPublishPanel'; import { uploadPlatformMediaAsset } from '../src/services/assetDirectUpload'; import { generateGameDistributionCover, publishLocalProjectGame, readGameCoverGenerationPrice, suggestGameDistributionPublishMetadata, } from '../src/services/gameDistributionPublish'; vi.mock('../src/services/gameDistributionPublish', async (importOriginal) => { const actual = await importOriginal< typeof import('../src/services/gameDistributionPublish') >(); return { ...actual, generateGameDistributionCover: vi.fn(), publishLocalProjectGame: vi.fn(), readGameCoverGenerationPrice: vi.fn(), suggestGameDistributionPublishMetadata: vi.fn(), }; }); // 面板只负责选图与调用上传;这里替换掉真实直传,避免测试触达 Tauri/OSS。 vi.mock('../src/services/assetDirectUpload', () => ({ uploadPlatformMediaAsset: vi.fn(), })); type TauriInvoke = ( command: string, args?: Record, ) => Promise; function installTauriInvoke(invoke: TauriInvoke) { const mock = vi.fn(invoke); ( window as unknown as { __TAURI__?: { core?: { invoke?: typeof mock } }; } ).__TAURI__ = { core: { invoke: mock } }; return mock; } const MANIFEST = { projectId: 'local-proj-1', name: '星轨防线', goal: '守住轨道城', } as unknown as GameCreationAppManifest; const PACKAGE_RESULT = { packageRelativePath: 'exports/playtest-package-unit.zip', packageBytes: [1, 2, 3], packageSha256: 'a'.repeat(64), packageSizeBytes: 3, fileCount: 2, totalBytes: 3, } as unknown as LocalProjectExportPackageResult; function buildImageFile(name: string) { return new File(['cover-bytes'], name, { type: 'image/png' }); } /** 选择封面并等待上传完成;面板只有在素材拿到 ID 之后才允许发布。 */ async function selectCover( file: File = buildImageFile('cover.png'), assetObjectId = 'asset_cover', ) { vi.mocked(uploadPlatformMediaAsset).mockResolvedValueOnce({ assetObjectId, objectKey: `game-distribution/cover/${file.name}`, }); fireEvent.change(screen.getByLabelText(/游戏封面/u), { target: { files: [file] }, }); await screen.findByText(new RegExp(`已选择「${file.name}」`, 'u')); } function renderPanel( overrides: Partial[0]> = {}, ) { const onClose = vi.fn(); const onPublished = vi.fn(); render( , ); return { onClose, onPublished }; } beforeEach(() => { vi.mocked(readGameCoverGenerationPrice).mockImplementation( () => new Promise(() => undefined), ); vi.mocked(suggestGameDistributionPublishMetadata).mockImplementation( () => new Promise(() => undefined), ); }); afterEach(() => { cleanup(); vi.mocked(generateGameDistributionCover).mockReset(); vi.mocked(publishLocalProjectGame).mockReset(); vi.mocked(readGameCoverGenerationPrice).mockReset(); vi.mocked(suggestGameDistributionPublishMetadata).mockReset(); vi.mocked(uploadPlatformMediaAsset).mockReset(); delete (window as unknown as { __TAURI__?: unknown }).__TAURI__; window.localStorage.clear(); }); describe('GameDistributionPublishPanel', () => { test('打开时预填游戏资料且不展示发行包技术摘要', async () => { installTauriInvoke(async () => undefined); renderPanel(); expect(screen.getByLabelText('游戏名称')).toHaveProperty( 'value', '星轨防线', ); expect(screen.getByLabelText('一句话简介')).toHaveProperty( 'value', '守住轨道城', ); expect(screen.queryByLabelText('发行包摘要')).toBeNull(); expect( screen.queryByText(/exports\/playtest-package-unit\.zip/u), ).toBeNull(); expect(screen.queryByText(/只上传已导出的 ZIP/u)).toBeNull(); await screen.findByText(/简介和分类/u); }); test('打开时根据创作上下文补全简介和分类', async () => { vi.mocked(suggestGameDistributionPublishMetadata).mockResolvedValueOnce({ summary: '驾驶星轨炮台守住轨道城', category: '策略', }); installTauriInvoke(async () => undefined); renderPanel(); await waitFor(() => { expect(screen.getByLabelText('一句话简介')).toHaveProperty( 'value', '驾驶星轨炮台守住轨道城', ); }); expect(screen.getByLabelText('分类')).toHaveProperty('value', '策略'); expect( screen.getByText( 'AI 已根据创作内容生成简介和分类,免费;你可以直接修改。', ), ).not.toBeNull(); }); test('确认后基于项目上下文生成封面并作为发布素材', async () => { vi.mocked(readGameCoverGenerationPrice).mockResolvedValueOnce(5); installTauriInvoke(async () => undefined); vi.mocked(generateGameDistributionCover).mockResolvedValue({ assetObjectId: 'asset_generated_cover', previewUrl: 'https://assets.example.com/generated-cover.png', taskId: 'task_cover_1', model: 'gpt-image-2', }); vi.mocked(publishLocalProjectGame).mockResolvedValue({ gameId: 'game_1', versionId: 'gamever_1', versionNumber: 1, status: 'pending_review', packageSha256: 'a'.repeat(64), packageSizeBytes: 3, fileCount: 2, }); renderPanel(); fireEvent.click( await screen.findByRole('button', { name: 'AI 生成封面(5 泥点)' }), ); const confirm = await screen.findByRole('dialog', { name: '确认生成游戏封面', }); expect(confirm.textContent ?? '').toContain('本次生成预计消耗 5 泥点'); fireEvent.click(screen.getByRole('button', { name: '生成封面' })); await waitFor(() => expect(generateGameDistributionCover).toHaveBeenCalledWith( expect.objectContaining({ model: 'gpt-image-2', aspectRatio: '16:9', imageSize: '2K', }), ), ); expect( await screen.findByText( '封面已生成并自动设为发布封面;重新生成会再次消耗泥点。', ), ).not.toBeNull(); fireEvent.click(screen.getByRole('button', { name: '发布游戏' })); await waitFor(() => expect(publishLocalProjectGame).toHaveBeenCalledTimes(1), ); expect( vi.mocked(publishLocalProjectGame).mock.calls[0]?.[0].metadata ?.coverAssetId, ).toBe('asset_generated_cover'); }); test('提交时带上项目路径、发行包与资料,成功后展示审核状态', async () => { const invoke = installTauriInvoke(async () => undefined); vi.mocked(publishLocalProjectGame).mockResolvedValue({ gameId: 'game_1', versionId: 'gamever_1', versionNumber: 1, status: 'pending_review', packageSha256: 'a'.repeat(64), packageSizeBytes: 3, fileCount: 2, }); const { onPublished } = renderPanel(); fireEvent.change(screen.getByLabelText('游戏名称'), { target: { value: '星轨防线二' }, }); fireEvent.change(screen.getByLabelText('分类'), { target: { value: '动作' }, }); await selectCover(); vi.mocked(uploadPlatformMediaAsset).mockResolvedValueOnce({ assetObjectId: 'asset_shot_1', objectKey: 'game-distribution/screenshot/shot-1.png', }); fireEvent.change(screen.getByLabelText(/游戏截图/u), { target: { files: [buildImageFile('shot-1.png')] }, }); await screen.findByRole('button', { name: '删除截图 1' }); fireEvent.click(screen.getByRole('button', { name: '发布游戏' })); await waitFor(() => expect(publishLocalProjectGame).toHaveBeenCalledTimes(1), ); const args = vi.mocked(publishLocalProjectGame).mock.calls[0]?.[0]; expect(args?.invoke).toBeDefined(); expect(args?.projectPath).toBe('/tmp/authorized-game'); expect(args?.packageRelativePath).toBe('exports/playtest-package-unit.zip'); expect(args?.manifest).toBe(MANIFEST); expect(args?.metadata).toEqual({ title: '星轨防线二', summary: '守住轨道城', category: '动作', coverAssetId: 'asset_cover', screenshots: ['asset_shot_1'], }); expect(String(args?.idempotencyKey)).toMatch(/^agc-publish-/u); expect(invoke).toBeDefined(); expect(await screen.findByText('已提交审核')).not.toBeNull(); expect(screen.getByText(/版本 1/u)).not.toBeNull(); expect(onPublished).toHaveBeenCalledTimes(1); }); test('连续点击只提交一次,上传中禁用动作按钮', async () => { installTauriInvoke(async () => undefined); let resolvePublish: (value: unknown) => void = () => undefined; vi.mocked(publishLocalProjectGame).mockImplementation( () => new Promise((resolve) => { resolvePublish = resolve as (value: unknown) => void; }) as never, ); renderPanel(); await selectCover(); const submit = screen.getByRole('button', { name: '发布游戏' }); fireEvent.click(submit); fireEvent.click(submit); expect(await screen.findByText('上传中…')).not.toBeNull(); expect(publishLocalProjectGame).toHaveBeenCalledTimes(1); resolvePublish({ gameId: 'game_1', versionId: 'gamever_1', versionNumber: 1, status: 'pending_review', packageSha256: 'a'.repeat(64), packageSizeBytes: 3, fileCount: 2, }); expect(await screen.findByText('已提交审核')).not.toBeNull(); }); test('服务端失败时保留面板并展示可读错误', async () => { installTauriInvoke(async () => undefined); vi.mocked(publishLocalProjectGame).mockRejectedValue( new Error('上传游戏发行包失败:游戏分发服务暂不可用(503)'), ); renderPanel(); await selectCover(); fireEvent.click(screen.getByRole('button', { name: '发布游戏' })); expect( await screen.findByText( '上传游戏发行包失败:游戏分发服务暂不可用(503)', ), ).not.toBeNull(); expect(screen.queryByText('已提交审核')).toBeNull(); }); test('没有选择封面时不发起发布并给出可操作提示', async () => { installTauriInvoke(async () => undefined); renderPanel(); fireEvent.click(screen.getByRole('button', { name: '发布游戏' })); expect( await screen.findByText('请先选择游戏封面(JPG/PNG/WebP)'), ).not.toBeNull(); expect(publishLocalProjectGame).not.toHaveBeenCalled(); }); test('封面与截图都先上传成平台素材,重复提交复用同一素材 ID', async () => { installTauriInvoke(async () => undefined); vi.mocked(publishLocalProjectGame).mockRejectedValue(new Error('先失败')); const coverFile = buildImageFile('cover.png'); renderPanel(); await selectCover(coverFile, 'asset_cover_cached'); expect(vi.mocked(uploadPlatformMediaAsset).mock.calls[0]?.[0]).toEqual( expect.objectContaining({ assetKind: 'game_distribution_cover', entityId: 'game-distribution-cover', }), ); fireEvent.click(screen.getByRole('button', { name: '发布游戏' })); await waitFor(() => expect(publishLocalProjectGame).toHaveBeenCalledTimes(1), ); // 再次选择同一个文件对象不应重新上传(面板按文件签名复用素材 ID)。 fireEvent.change(screen.getByLabelText(/游戏封面/u), { target: { files: [coverFile] }, }); await screen.findByText(/已选择「cover.png」/u); expect(uploadPlatformMediaAsset).toHaveBeenCalledTimes(1); }); test('截图并行上传,单张失败只标记该张并在发布时跳过', async () => { installTauriInvoke(async () => undefined); let resolveShot1: (() => void) | undefined; let resolveShot3: (() => void) | undefined; let rejectShot2: (() => void) | undefined; vi.mocked(uploadPlatformMediaAsset).mockImplementation((input) => { const file = input.file; if (file.name === 'cover.png') { return Promise.resolve({ assetObjectId: 'asset_cover', objectKey: 'game-distribution/cover/cover.png', }); } if (file.name === 'shot-1.png') { return new Promise((resolve) => { resolveShot1 = () => resolve({ assetObjectId: 'asset_shot_1', objectKey: 'game-distribution/screenshot/shot-1.png', }); }); } if (file.name === 'shot-2.png') { return new Promise((_, reject) => { rejectShot2 = () => reject(new Error('截图 2 上传失败')); }); } return new Promise((resolve) => { resolveShot3 = () => resolve({ assetObjectId: 'asset_shot_3', objectKey: 'game-distribution/screenshot/shot-3.png', }); }); }); vi.mocked(publishLocalProjectGame).mockResolvedValue({ gameId: 'game_1', versionId: 'gamever_1', versionNumber: 1, status: 'pending_review', packageSha256: 'a'.repeat(64), packageSizeBytes: 3, fileCount: 2, }); renderPanel(); await selectCover(); fireEvent.change(screen.getByLabelText(/游戏截图/u), { target: { files: [ buildImageFile('shot-1.png'), buildImageFile('shot-2.png'), buildImageFile('shot-3.png'), ], }, }); await waitFor(() => expect(uploadPlatformMediaAsset).toHaveBeenCalledTimes(4), ); resolveShot3?.(); rejectShot2?.(); resolveShot1?.(); const failedError = await screen.findByText('截图 2 上传失败'); expect( within( failedError.closest( '.game-distribution-publish-panel__shot-media', ) as HTMLElement, ).getByRole('button', { name: '删除截图 2' }), ).not.toBeNull(); expect( await screen.findByRole('button', { name: '删除截图 1' }), ).not.toBeNull(); expect( await screen.findByRole('button', { name: '删除截图 3' }), ).not.toBeNull(); fireEvent.click(screen.getByRole('button', { name: '预览截图 1' })); expect( await screen.findByRole('dialog', { name: '截图预览' }), ).not.toBeNull(); fireEvent.keyDown(window, { key: 'Escape' }); await waitFor(() => { expect(screen.queryByRole('dialog', { name: '截图预览' })).toBeNull(); }); fireEvent.click(screen.getByRole('button', { name: '发布游戏' })); await waitFor(() => expect(publishLocalProjectGame).toHaveBeenCalledTimes(1), ); expect( vi.mocked(publishLocalProjectGame).mock.calls[0]?.[0].metadata ?.screenshots, ).toEqual(['asset_shot_1', 'asset_shot_3']); }); test('截图超过 6 张时本地拦截且不上传', async () => { installTauriInvoke(async () => undefined); renderPanel(); fireEvent.change(screen.getByLabelText(/游戏截图/u), { target: { files: Array.from({ length: 7 }, (_, index) => buildImageFile(`shot-${index}.png`), ), }, }); expect(await screen.findByText(/游戏截图最多 6 张/u)).not.toBeNull(); expect(uploadPlatformMediaAsset).not.toHaveBeenCalled(); }); test('素材上传失败时保留面板并展示原因', async () => { installTauriInvoke(async () => undefined); vi.mocked(uploadPlatformMediaAsset).mockRejectedValueOnce( new Error('创建素材上传凭证失败'), ); renderPanel(); fireEvent.change(screen.getByLabelText(/游戏封面/u), { target: { files: [buildImageFile('cover.png')] }, }); expect(await screen.findByText('创建素材上传凭证失败')).not.toBeNull(); expect(screen.getByText('还没有封面')).not.toBeNull(); expect(publishLocalProjectGame).not.toHaveBeenCalled(); }); test('不在 Tauri 宿主或缺少试玩包时失败关闭且不调用发布接口', async () => { renderPanel(); fireEvent.click(screen.getByRole('button', { name: '发布游戏' })); expect(await screen.findByText('需要在 Tauri App 内发布')).not.toBeNull(); expect(publishLocalProjectGame).not.toHaveBeenCalled(); cleanup(); installTauriInvoke(async () => undefined); renderPanel({ packageResult: null }); expect(screen.getByRole('button', { name: '发布游戏' })).toHaveProperty( 'disabled', true, ); expect(screen.queryByLabelText('发行包摘要')).toBeNull(); }); });