Files
Genarrative/apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx
kdletters 297d7bf1e7
Project CI / AI game creator shell Rust crates (push) Successful in 1m24s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m56s
Project CI / Backend tests (push) Successful in 4m39s
Project CI / Native shell tests (push) Successful in 5m49s
Project CI / Frontend tests (push) Successful in 2m1s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m29s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 8m31s
Project CI / AI game creator shell web tests (push) Successful in 1m53s
Project CI / Repository checks (push) Successful in 2m25s
优化 AGC 发布资料与截图交互
发布门禁阻断提示改为独立弹窗且不写入聊天记录
移除发布面板发行包技术摘要
新增免费发布简介与分类建议接口和前端回填
接入封面泥点定价、确认与生成流程
截图改为并行上传,单张失败隔离且不阻断发布
截图操作改为图标,失败项内嵌错误与删除
同步发布合同、实施计划和决策记录
2026-09-23 17:40:08 +08:00

520 lines
17 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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<string, unknown>,
) => Promise<unknown>;
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<Parameters<typeof GameDistributionPublishPanel>[0]> = {},
) {
const onClose = vi.fn();
const onPublished = vi.fn();
render(
<GameDistributionPublishPanel
open
projectPath="/tmp/authorized-game"
manifest={MANIFEST}
packageResult={PACKAGE_RESULT}
onClose={onClose}
onPublished={onPublished}
{...overrides}
/>,
);
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();
});
});