Files
Genarrative/apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx
T
kdletters cb44081040
Project CI / AI game creator shell Rust crates (push) Successful in 1m31s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m59s
Project CI / Backend tests (push) Successful in 4m24s
Project CI / Frontend tests (push) Successful in 2m17s
Project CI / Native shell tests (push) Successful in 6m27s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m56s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 9m24s
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
发布资料建议的项目上下文改为脱敏:素材只带文件名
- GameDistributionPublishPanel:buildPublishProjectContext 不再原样拼接 asset.localPath,新增 contextSafeAssetLabel(统一分隔符、取最后一段、限长 80),绝对路径(含用户目录)不再进入提示词;上下文整体仍截到 6,000 字符
- 新增用例「发布资料建议只带脱敏且有界的项目上下文」:40 个绝对路径素材 + 超长版本说明,断言长度在 5,000~6,000、含文件名、不含 C:\Users 与项目目录;13 tests passed
- 变异验证:改回 `asset.localPath` 原样拼接后该用例立即以 expected ... to not include 'C:\Users' 变红
- 游戏分发里程碑第 C2 条补记该客户端侧口径与剩余边界(GUI 与真实 Provider 仍待验收)
2026-09-29 00:27:07 +08:00

556 lines
18 KiB
TypeScript
Raw 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 () => {
const assets = Array.from({ length: 40 }, (_, index) => ({
kind: 'image',
localPath: `C:\\Users\\author\\secret-project\\assets\\shot-${index}.png`,
name: `shot-${index}.png`,
}));
installTauriInvoke(async () => undefined);
renderPanel({
manifest: {
...MANIFEST,
assets,
tasks: Array.from({ length: 24 }, (_, index) => ({
title: `任务 ${index}`,
status: 'completed',
})),
// 超长版本说明用于验证上下文真的被截到上限,而不是只在短样例里碰巧合规。
versions: Array.from({ length: 6 }, () => ({
editPrompt: '长'.repeat(3_000),
})),
} as unknown as GameCreationAppManifest,
});
await waitFor(() => {
expect(suggestGameDistributionPublishMetadata).toHaveBeenCalled();
});
const [request] = vi.mocked(suggestGameDistributionPublishMetadata).mock
.calls[0];
const context = String(request.context ?? '');
expect(context.length).toBeGreaterThanOrEqual(5_000);
expect(context.length).toBeLessThanOrEqual(6_000);
expect(context).toContain('shot-0.png');
// 脱敏:绝对路径里的用户目录与项目目录不得进入提示词。
expect(context).not.toContain('C:\\Users');
expect(context).not.toContain('secret-project');
});
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();
});
});