Files
Genarrative/apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx
T
kdletters 4a2b270714 补齐游戏分发封面与截图的冻结、上传与展示
- 服务端:游戏表追加可空 cover_object_key / screenshots_json,版本表追加可空 metadata_json,创建版本时校验必需封面、最多 6 张截图与图片素材归属,并从素材记录派生对象键生成冻结资料。
- 服务端:审核通过时把冻结资料整体生效到游戏行;只有已公开且存在有效活动版本的游戏,其封面/截图素材才在 /api/assets/read-url 获得匿名读授权。
- 服务端:作者与管理员回读版本时追加 frozenMetadata,带回封面与截图素材 ID 供续发复用,公开投影仍只暴露对象键。
- 网页:/games/publish 新增「封面与截图」区,复用平台图片直传与 confirm 通道,缺封面或截图超量在发请求前拦截,更新版本时按冻结资料回填且不要求重新上传。
- 网页:游戏广场卡片与详情页 hero 换签展示真实封面,详情页新增可点击的截图缩略图条,无素材或换签失败时静默回退原有渐变占位。
- AGC:发布面板支持封面必选与截图最多 6 张,新增素材直传服务并在创建游戏前拦截缺封面,同一文件重复提交复用素材 ID。
- AGC:http 能力作用域新增阿里云 OSS 直传地址,客户端同时校验直传目标必须是平台素材存储。
- 契约与文档:同步 shared-contracts、packages/shared 契约,更新 SpacetimeDB 迁移注释、后端架构、玩法链路与实施计划文档,并补齐网页端与 AGC 定向测试。
2026-09-20 22:19:50 +08:00

324 lines
11 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,
} from '@testing-library/react';
import { afterEach, 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 { publishLocalProjectGame } from '../src/services/gameDistributionPublish';
vi.mock('../src/services/gameDistributionPublish', async (importOriginal) => {
const actual =
await importOriginal<
typeof import('../src/services/gameDistributionPublish')
>();
return { ...actual, publishLocalProjectGame: 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 };
}
afterEach(() => {
cleanup();
vi.mocked(publishLocalProjectGame).mockReset();
vi.mocked(uploadPlatformMediaAsset).mockReset();
delete (window as unknown as { __TAURI__?: unknown }).__TAURI__;
window.localStorage.clear();
});
describe('GameDistributionPublishPanel', () => {
test('打开时预填游戏资料并展示发行包摘要', () => {
installTauriInvoke(async () => undefined);
renderPanel();
expect(screen.getByLabelText('游戏名称')).toHaveProperty(
'value',
'星轨防线',
);
expect(screen.getByLabelText('一句话简介')).toHaveProperty(
'value',
'守住轨道城',
);
expect(screen.getByLabelText('发行包摘要').textContent).toContain(
'exports/playtest-package-unit.zip',
);
expect(screen.getByLabelText('发行包摘要').textContent).toContain(
'2 个文件',
);
});
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.findByText('移除截图 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('截图超过 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.getByLabelText('发行包摘要').textContent).toContain(
'未找到试玩包',
);
});
});