// @vitest-environment jsdom import { afterEach, beforeEach, expect, test, vi } from 'vitest'; const fetchClientHttp = vi.fn(); vi.mock('../src/services/clientHttp', () => ({ AGC_DEVELOPMENT_API_BASE_URL: 'https://dev.genarrative.world', fetchClientHttp: (...args: unknown[]) => fetchClientHttp(...args), getClientServerBaseUrl: () => 'https://dev.genarrative.world', readClientHttpResponseText: (response: Response) => response.text(), })); vi.mock('../src/services/errorReporting', () => ({ captureClientError: vi.fn(), })); // 原生侧上传需要登录凭据;这里只钉住「取到了 token」这一件事。 vi.mock('../src/services/clientApi', async (importOriginal) => ({ ...(await importOriginal()), getStoredAuthAccessToken: () => 'test-access-token', })); import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; import { generateGameDistributionCover, publishLocalProjectGame, readGameCoverGenerationPrice, readGamePublishAvailability, suggestGameDistributionPublishMetadata, } from '../src/services/gameDistributionPublish'; const MANIFEST = { projectId: 'local-proj-1', name: '星轨防线', goal: '守住轨道城', } as unknown as GameCreationAppManifest; /** 归一化发行包的暂存摘要;发布链路只应传递它,不再传整包字节。 */ const STAGED_PACKAGE = { stagingPath: 'C:/app-data/game-package-staging/aaaa.zip', packageSha256: 'a'.repeat(64), packageSizeBytes: 1024, packageFileCount: 1, }; function jsonResponse(payload: unknown) { return new Response( JSON.stringify({ ok: true, data: payload, error: null, meta: { apiVersion: 'v1' }, }), { status: 200, headers: { 'Content-Type': 'application/json' }, }, ); } beforeEach(() => { fetchClientHttp.mockReset(); window.localStorage.clear(); }); afterEach(() => { vi.useRealTimers(); }); test('发布时携带本地项目标识,让重复发布复用同一个平台游戏', async () => { fetchClientHttp .mockResolvedValueOnce( jsonResponse({ id: 'game_1', publicationRevision: 0 }), ) .mockResolvedValueOnce( jsonResponse({ gameId: 'game_1', versionId: 'gamever_1', versionNumber: 1, status: 'awaiting_upload', }), ) .mockResolvedValueOnce( jsonResponse({ version: { status: 'pending_review' } }), ); const invokeCalls: Array<{ command: string; args: unknown }> = []; const result = await publishLocalProjectGame({ invoke: (async (command: string, args?: Record) => { invokeCalls.push({ command, args }); if (command === 'prepare_local_project_game_package') { return STAGED_PACKAGE; } if (command === 'upload_local_project_game_package') { return { versionId: 'gamever_1', status: 'uploaded', uploadedBytes: STAGED_PACKAGE.packageSizeBytes, }; } throw new Error(`未预期的命令:${command}`); }) as never, projectPath: '/tmp/project', packageRelativePath: 'exports/playtest-package-1.zip', manifest: MANIFEST, metadata: { coverAssetId: 'asset_cover', screenshots: ['asset_shot_1', 'asset_shot_2'], }, }); const createGameCall = fetchClientHttp.mock.calls[0]; expect(createGameCall?.[0]).toBe('/api/game-distribution/games'); const createGameBody = JSON.parse( String((createGameCall?.[1] as RequestInit).body), ); expect(createGameBody.localProjectId).toBe('local-proj-1'); expect(createGameBody.coverAssetId).toBe('asset_cover'); expect(createGameBody.screenshots).toEqual(['asset_shot_1', 'asset_shot_2']); const createVersionCall = fetchClientHttp.mock.calls[1]; expect(createVersionCall?.[0]).toBe( '/api/game-distribution/games/game_1/versions', ); expect( JSON.parse(String((createVersionCall?.[1] as RequestInit).body)) .localProjectId, ).toBe('local-proj-1'); expect(result.gameId).toBe('game_1'); expect(result.versionId).toBe('gamever_1'); // 关键回归:整包字节不再经过 IPC,上传交给原生侧按版本 ID + 暂存路径完成。 const uploadCall = invokeCalls.find( (call) => call.command === 'upload_local_project_game_package', ); expect(uploadCall?.args).toMatchObject({ stagingPath: STAGED_PACKAGE.stagingPath, versionId: 'gamever_1', apiBaseUrl: 'https://dev.genarrative.world', accessToken: 'test-access-token', }); expect(Object.keys(uploadCall?.args ?? {})).not.toContain('packageBytes'); // 三次 HTTP:创建游戏、创建版本、送审;上传不再占用一条 HTTP 调用。 expect(fetchClientHttp).toHaveBeenCalledTimes(3); }); test('缺少本地项目标识时在发起请求前失败关闭', async () => { await expect( publishLocalProjectGame({ invoke: (async () => STAGED_PACKAGE) as never, projectPath: '/tmp/project', packageRelativePath: 'exports/playtest-package-1.zip', manifest: { ...MANIFEST, projectId: ' ' } as GameCreationAppManifest, metadata: { coverAssetId: 'asset_cover' }, }), ).rejects.toThrow('发布需要本地项目标识'); expect(fetchClientHttp).not.toHaveBeenCalled(); }); test('缺少封面时在创建游戏前失败关闭', async () => { await expect( publishLocalProjectGame({ invoke: (async () => STAGED_PACKAGE) as never, projectPath: '/tmp/project', packageRelativePath: 'exports/playtest-package-1.zip', manifest: MANIFEST, metadata: { coverAssetId: ' ' }, }), ).rejects.toThrow('请先选择游戏封面'); expect(fetchClientHttp).not.toHaveBeenCalled(); }); test('截图超过 6 张时在创建游戏前失败关闭', async () => { await expect( publishLocalProjectGame({ invoke: (async () => STAGED_PACKAGE) as never, projectPath: '/tmp/project', packageRelativePath: 'exports/playtest-package-1.zip', manifest: MANIFEST, metadata: { coverAssetId: 'asset_cover', screenshots: Array.from({ length: 7 }, (_, index) => `asset_${index}`), }, }), ).rejects.toThrow('游戏截图最多 6 张'); expect(fetchClientHttp).not.toHaveBeenCalled(); }); test('发布灰度按后端运行时配置判定,命中才开放入口', async () => { fetchClientHttp.mockResolvedValueOnce( jsonResponse({ gameDistributionPublishEnabled: true }), ); await expect(readGamePublishAvailability()).resolves.toBe(true); expect(fetchClientHttp.mock.calls[0]?.[0]).toBe( '/api/runtime/frontend-config', ); fetchClientHttp.mockResolvedValueOnce( jsonResponse({ gameDistributionPublishEnabled: false }), ); await expect(readGamePublishAvailability()).resolves.toBe(false); // 老后端/字段缺失时按未命中处理,入口不暴露。 fetchClientHttp.mockResolvedValueOnce(jsonResponse({})); await expect(readGamePublishAvailability()).resolves.toBe(false); }); test('发布灰度读取失败时抛出,由调用方按不开放处理', async () => { fetchClientHttp.mockRejectedValueOnce(new Error('network down')); await expect(readGamePublishAvailability()).rejects.toThrow(); }); test('免费发布资料建议只上传脱敏上下文并返回白名单分类', async () => { fetchClientHttp.mockResolvedValueOnce( jsonResponse({ summary: '驾驶炮台守住轨道城', category: '策略' }), ); await expect( suggestGameDistributionPublishMetadata({ name: '星轨防线', goal: '守住轨道城', context: '当前任务与状态:原型已完成;现有素材:image:assets/hero.png', }), ).resolves.toEqual({ summary: '驾驶炮台守住轨道城', category: '策略', }); expect(fetchClientHttp.mock.calls[0]?.[0]).toBe( '/api/game-distribution/publish-metadata/suggestions', ); const init = fetchClientHttp.mock.calls[0]?.[1] as RequestInit; expect(JSON.parse(String(init.body))).toEqual({ name: '星轨防线', goal: '守住轨道城', context: '当前任务与状态:原型已完成;现有素材:image:assets/hero.png', }); }); test('封面生成直接使用返回的平台素材 ID,不再次上传', async () => { fetchClientHttp.mockResolvedValueOnce( jsonResponse({ imageSrc: 'https://assets.example.com/generated-cover.png', assetObjectId: 'asset_generated_cover', taskId: 'task_cover_1', model: 'gpt-image-2', }), ); await expect( generateGameDistributionCover({ prompt: '为《星轨防线》生成游戏封面', model: 'gpt-image-2', aspectRatio: '16:9', imageSize: '2K', }), ).resolves.toEqual({ assetObjectId: 'asset_generated_cover', previewUrl: 'https://assets.example.com/generated-cover.png', taskId: 'task_cover_1', model: 'gpt-image-2', }); expect(fetchClientHttp.mock.calls[0]?.[0]).toBe( '/api/editor/images/generations', ); const body = JSON.parse( String((fetchClientHttp.mock.calls[0]?.[1] as RequestInit).body), ); expect(body).toEqual({ prompt: '为《星轨防线》生成游戏封面', kind: 'publication-material', assetKind: 'publication-material', model: 'gpt-image-2', aspectRatio: '16:9', imageSize: '2K', assetLabel: '游戏封面', generationInputs: { source: 'ai-game-creator-client' }, }); }); test('封面生成进入队列后轮询完成结果并返回同一平台素材', async () => { vi.useFakeTimers(); fetchClientHttp .mockResolvedValueOnce( jsonResponse({ queueState: { operationId: 'external-job-1', status: 'queued', phaseDetail: '排队中。', }, }), ) .mockResolvedValueOnce( jsonResponse({ job: { operationId: 'external-job-1', status: 'completed', result: { imageSrc: 'https://assets.example.com/queued-cover.png', assetObjectId: 'asset_queued_cover', taskId: 'task_queued_cover', model: 'gpt-image-2', }, }, }), ); const generation = generateGameDistributionCover({ prompt: '为《星轨防线》生成游戏封面', model: 'gpt-image-2', }); await vi.advanceTimersByTimeAsync(1_600); await expect(generation).resolves.toEqual({ assetObjectId: 'asset_queued_cover', previewUrl: 'https://assets.example.com/queued-cover.png', taskId: 'task_queued_cover', model: 'gpt-image-2', }); expect(fetchClientHttp.mock.calls[1]?.[0]).toBe( '/api/runtime/external-generation/jobs/external-job-1', ); }); test('封面价格读取后端运行时定价,不在前端硬编码', async () => { fetchClientHttp.mockResolvedValueOnce( jsonResponse({ models: { 'gpt-image-2': { unit: 'perGeneration', prices: { '2K': 5 }, }, }, }), ); await expect( readGameCoverGenerationPrice({ model: 'gpt-image-2', imageSize: '2K', }), ).resolves.toBe(5); expect(fetchClientHttp.mock.calls[0]?.[0]).toBe( '/api/editor/generation-pricing', ); });