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 定向测试。
324 lines
11 KiB
TypeScript
324 lines
11 KiB
TypeScript
// @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(
|
||
'未找到试玩包',
|
||
);
|
||
});
|
||
});
|