3aa3e33ceb
Project CI / AI game creator shell Rust crates (pull_request) Failing after 56s
Project CI / Backend tests (pull_request) Failing after 23s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 5m7s
Project CI / Repository checks (pull_request) Failing after 21s
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
以 projectVersion 作为 AGC 唯一用户版本,支持同版本重复提交和版本回退。 保留 publicationRevision 并发校验与旧客户端自动递增兼容。
892 lines
30 KiB
TypeScript
892 lines
30 KiB
TypeScript
// @vitest-environment jsdom
|
||
/**
|
||
* AGC「发布到游戏广场」面板的行为边界。
|
||
*
|
||
* 这里只覆盖面板自身(预填资料、必填门禁、防重复点击、成功/失败态与无 Tauri 宿主),
|
||
* 真实的 HTTP 链路由 `gameDistributionPublishLive.test.ts` 覆盖。
|
||
*/
|
||
import {
|
||
act,
|
||
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,
|
||
readGameDistributionPublication,
|
||
saveGameDistributionProjectVersion,
|
||
suggestGameDistributionPublishMetadata,
|
||
} from '../src/services/gameDistributionPublish';
|
||
import { GAME_PACKAGE_UPLOAD_PROGRESS_EVENT } from '../src/services/gamePackageUploadProgress';
|
||
import { createTauriEventFake, type TauriEventFake } from './tauriEventFake';
|
||
|
||
let eventFake: TauriEventFake | null = null;
|
||
|
||
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(),
|
||
readGameDistributionPublication: vi.fn(),
|
||
saveGameDistributionProjectVersion: 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),
|
||
);
|
||
// 默认按「首次发布」恢复,保持既有用例的提交路径不变;更新模式用例单独覆盖。
|
||
vi.mocked(readGameDistributionPublication).mockResolvedValue({
|
||
state: 'first-publish',
|
||
binding: null,
|
||
refreshed: true,
|
||
draft: null,
|
||
visibility: null,
|
||
});
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup();
|
||
eventFake?.restore();
|
||
eventFake = null;
|
||
vi.mocked(generateGameDistributionCover).mockReset();
|
||
vi.mocked(publishLocalProjectGame).mockReset();
|
||
vi.mocked(readGameCoverGenerationPrice).mockReset();
|
||
vi.mocked(readGameDistributionPublication).mockReset();
|
||
vi.mocked(saveGameDistributionProjectVersion).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'],
|
||
});
|
||
// 根幂等键由原生发布链路按「账号 + 本地项目 + 包摘要」的账本解析并持久化,
|
||
// 渲染层不再自己铸键,因此这里不带 idempotencyKey。
|
||
expect(args?.idempotencyKey).toBeUndefined();
|
||
expect(invoke).toBeDefined();
|
||
|
||
expect(await screen.findByText('已提交审核')).not.toBeNull();
|
||
expect(screen.getByText(/项目版本/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 () => {
|
||
let resolvePublish: (value: unknown) => void = () => undefined;
|
||
vi.mocked(publishLocalProjectGame).mockImplementation(() => {
|
||
const pending = Promise.withResolvers<unknown>();
|
||
resolvePublish = pending.resolve;
|
||
return pending.promise as never;
|
||
});
|
||
eventFake = createTauriEventFake();
|
||
eventFake.install();
|
||
renderPanel();
|
||
await selectCover();
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '发布游戏' }));
|
||
await waitFor(() =>
|
||
expect(eventFake?.pendingRegistrationEvals()).toBeGreaterThan(0),
|
||
);
|
||
// 订阅发生在发起 native 命令之前,事件不会丢;这里让注册 eval 落地后才能投递。
|
||
eventFake.flushRegistrationEvals();
|
||
|
||
// 导出打包已经完成,因此构建步骤是「已完成」,当前停在准备步骤。
|
||
const steps = await screen.findByRole('list', { name: '发布阶段' });
|
||
expect(within(steps).getByText('构建').closest('li')?.className).toContain(
|
||
'is-done',
|
||
);
|
||
expect(
|
||
within(steps)
|
||
.getByText('准备')
|
||
.closest('li')
|
||
?.getAttribute('aria-current'),
|
||
).toBe('step');
|
||
|
||
// 上传阶段:百分比只由「已收字节 / 总字节」换算。
|
||
act(() => {
|
||
eventFake?.emit(GAME_PACKAGE_UPLOAD_PROGRESS_EVENT, {
|
||
versionId: 'gamever_1',
|
||
publishId: 'agc-publish-unit',
|
||
phase: 'upload',
|
||
message: '正在上传发行包…',
|
||
receivedBytes: 5 * 1024 * 1024,
|
||
totalBytes: 10 * 1024 * 1024,
|
||
});
|
||
});
|
||
expect(await screen.findByText('正在上传发行包…')).not.toBeNull();
|
||
expect(
|
||
(
|
||
await screen.findByRole('progressbar', { name: '上传进度' })
|
||
).getAttribute('aria-valuenow'),
|
||
).toBe('50');
|
||
|
||
// 校验与送审阶段没有可换算的总量:进度条隐去,阶段照常前进。
|
||
act(() => {
|
||
eventFake?.emit(GAME_PACKAGE_UPLOAD_PROGRESS_EVENT, {
|
||
versionId: 'gamever_1',
|
||
publishId: 'agc-publish-unit',
|
||
phase: 'verify',
|
||
message: '正在校验发行包…',
|
||
receivedBytes: 0,
|
||
totalBytes: 0,
|
||
});
|
||
});
|
||
expect(await screen.findByText('正在校验发行包…')).not.toBeNull();
|
||
expect(screen.queryByRole('progressbar')).toBeNull();
|
||
|
||
act(() => {
|
||
eventFake?.emit(GAME_PACKAGE_UPLOAD_PROGRESS_EVENT, {
|
||
versionId: 'gamever_1',
|
||
publishId: 'agc-publish-unit',
|
||
phase: 'submit',
|
||
message: '正在提交审核…',
|
||
receivedBytes: 0,
|
||
totalBytes: 0,
|
||
});
|
||
});
|
||
expect(await screen.findByText('正在提交审核…')).not.toBeNull();
|
||
expect(
|
||
within(screen.getByRole('list', { name: '发布阶段' }))
|
||
.getByText('送审')
|
||
.closest('li')
|
||
?.getAttribute('aria-current'),
|
||
).toBe('step');
|
||
|
||
// 不认识的阶段不允许把进度打回错误位置,也不显示它的文案。
|
||
act(() => {
|
||
eventFake?.emit(GAME_PACKAGE_UPLOAD_PROGRESS_EVENT, {
|
||
versionId: 'gamever_1',
|
||
publishId: 'agc-publish-unit',
|
||
phase: 'unexpected-phase',
|
||
message: '不该出现的阶段',
|
||
receivedBytes: 0,
|
||
totalBytes: 0,
|
||
});
|
||
});
|
||
expect(screen.queryByText('不该出现的阶段')).toBeNull();
|
||
expect(screen.getByText('正在提交审核…')).not.toBeNull();
|
||
|
||
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(
|
||
await screen.findByRole('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(
|
||
await screen.findByRole('button', { name: '发布游戏' }),
|
||
).toHaveProperty('disabled', true);
|
||
expect(screen.queryByLabelText('发行包摘要')).toBeNull();
|
||
});
|
||
|
||
test('已有绑定时进入更新模式,回填线上资料并按项目版本提交原 gameId', async () => {
|
||
installTauriInvoke(async () => undefined);
|
||
vi.mocked(readGameDistributionPublication).mockResolvedValue({
|
||
state: 'update',
|
||
binding: {
|
||
accountId: 'user-1',
|
||
apiBaseUrl: 'https://dev.test',
|
||
gameId: 'game_9',
|
||
revision: 7,
|
||
latestVersionId: 'gamever_2',
|
||
latestVersionNumber: 2,
|
||
status: 'pending_review',
|
||
},
|
||
refreshed: true,
|
||
draft: {
|
||
title: '星轨防线(线上)',
|
||
summary: '线上简介',
|
||
description: '线上详介',
|
||
category: '策略',
|
||
tags: ['塔防'],
|
||
cover: {
|
||
assetId: 'asset_cover_old',
|
||
previewUrl: 'https://assets.test/cover.png',
|
||
},
|
||
screenshots: [
|
||
{
|
||
assetId: 'asset_shot_old',
|
||
previewUrl: 'https://assets.test/shot.png',
|
||
},
|
||
],
|
||
deviceSupport: { desktop: true, mobile: false, touch: false },
|
||
inputModes: ['keyboard', 'mouse'],
|
||
orientation: 'landscape',
|
||
},
|
||
visibility: 'published',
|
||
});
|
||
vi.mocked(publishLocalProjectGame).mockResolvedValue({
|
||
gameId: 'game_9',
|
||
versionId: 'gamever_3',
|
||
versionNumber: 2,
|
||
status: 'pending_review',
|
||
packageSha256: 'a'.repeat(64),
|
||
packageSizeBytes: 3,
|
||
fileCount: 2,
|
||
});
|
||
const { onPublished } = renderPanel();
|
||
|
||
// 回填线上冻结资料;项目版本按绑定回填成线上最近提交的版本标签(旧清单缺字段时用它)。
|
||
await screen.findByRole('button', { name: '更新游戏' });
|
||
expect(screen.getByLabelText('游戏名称')).toHaveProperty(
|
||
'value',
|
||
'星轨防线(线上)',
|
||
);
|
||
expect(screen.getByLabelText('一句话简介')).toHaveProperty(
|
||
'value',
|
||
'线上简介',
|
||
);
|
||
expect(screen.getByLabelText('项目版本')).toHaveProperty('value', '2');
|
||
expect(screen.getByText('v2')).not.toBeNull();
|
||
expect(
|
||
screen.getByText(/线上最近提交 v2 · 审核中/u),
|
||
).not.toBeNull();
|
||
expect(
|
||
screen.getByText(/版本标签不要求递增/u),
|
||
).not.toBeNull();
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '更新游戏' }));
|
||
await waitFor(() =>
|
||
expect(publishLocalProjectGame).toHaveBeenCalledTimes(1),
|
||
);
|
||
const args = vi.mocked(publishLocalProjectGame).mock.calls[0]?.[0];
|
||
expect(args?.gameId).toBe('game_9');
|
||
// 同一个版本标签可以重复提交:不做递增改写,提交的就是清单里的 projectVersion。
|
||
expect(args?.versionNumber).toBe(2);
|
||
expect(args?.expectedPublicationRevision).toBe(7);
|
||
// 冻结素材 ID 直接复用,不要求作者重新上传封面/截图。
|
||
expect(args?.metadata?.coverAssetId).toBe('asset_cover_old');
|
||
expect(args?.metadata?.screenshots).toEqual(['asset_shot_old']);
|
||
// 设备/输入/方向声明沿用线上冻结值,不被创建请求默认值改写。
|
||
expect(args?.metadata?.deviceSupport).toEqual({
|
||
desktop: true,
|
||
mobile: false,
|
||
touch: false,
|
||
});
|
||
expect(args?.metadata?.inputModes).toEqual(['keyboard', 'mouse']);
|
||
expect(args?.metadata?.orientation).toBe('landscape');
|
||
expect(await screen.findByText('已提交审核')).not.toBeNull();
|
||
expect(onPublished).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
test('清单里的项目版本优先于线上版本,回退到更小版本也能提交', async () => {
|
||
installTauriInvoke(async () => undefined);
|
||
vi.mocked(readGameDistributionPublication).mockResolvedValue({
|
||
state: 'update',
|
||
binding: {
|
||
accountId: 'user-1',
|
||
apiBaseUrl: 'https://dev.test',
|
||
gameId: 'game_9',
|
||
revision: 7,
|
||
latestVersionId: 'gamever_5',
|
||
latestVersionNumber: 5,
|
||
status: 'published',
|
||
},
|
||
refreshed: true,
|
||
draft: null,
|
||
visibility: 'published',
|
||
});
|
||
vi.mocked(publishLocalProjectGame).mockResolvedValue({
|
||
gameId: 'game_9',
|
||
versionId: 'gamever_6',
|
||
versionNumber: 2,
|
||
status: 'pending_review',
|
||
packageSha256: 'a'.repeat(64),
|
||
packageSizeBytes: 3,
|
||
fileCount: 2,
|
||
});
|
||
renderPanel({
|
||
manifest: {
|
||
...MANIFEST,
|
||
projectVersion: 2,
|
||
} as unknown as GameCreationAppManifest,
|
||
});
|
||
await screen.findByRole('button', { name: '更新游戏' });
|
||
|
||
// 清单写过的项目版本胜出:不被线上最大版本号悄悄改成 5 或 6。
|
||
expect(screen.getByLabelText('项目版本')).toHaveProperty('value', '2');
|
||
await selectCover();
|
||
fireEvent.click(screen.getByRole('button', { name: '更新游戏' }));
|
||
|
||
await waitFor(() =>
|
||
expect(publishLocalProjectGame).toHaveBeenCalledTimes(1),
|
||
);
|
||
expect(
|
||
vi.mocked(publishLocalProjectGame).mock.calls[0]?.[0].versionNumber,
|
||
).toBe(2);
|
||
});
|
||
|
||
test('修改项目版本后失焦写回清单并回调工作台', async () => {
|
||
installTauriInvoke(async () => undefined);
|
||
const savedManifest = {
|
||
...MANIFEST,
|
||
projectVersion: 4,
|
||
} as unknown as GameCreationAppManifest;
|
||
vi.mocked(saveGameDistributionProjectVersion).mockResolvedValue(
|
||
savedManifest,
|
||
);
|
||
const onManifestUpdated = vi.fn();
|
||
renderPanel({ onManifestUpdated });
|
||
await screen.findByLabelText('项目版本');
|
||
|
||
const input = screen.getByLabelText('项目版本');
|
||
fireEvent.change(input, { target: { value: '4' } });
|
||
expect(screen.getByText('v4')).not.toBeNull();
|
||
fireEvent.blur(input);
|
||
|
||
await waitFor(() =>
|
||
expect(saveGameDistributionProjectVersion).toHaveBeenCalledWith({
|
||
projectPath: '/tmp/authorized-game',
|
||
projectVersion: 4,
|
||
}),
|
||
);
|
||
expect(onManifestUpdated).toHaveBeenCalledWith(savedManifest);
|
||
});
|
||
|
||
test('项目版本非法时失焦回退到已保存值且不写盘', async () => {
|
||
installTauriInvoke(async () => undefined);
|
||
renderPanel();
|
||
const input = await screen.findByLabelText('项目版本');
|
||
expect(input).toHaveProperty('value', '1');
|
||
|
||
fireEvent.change(input, { target: { value: '0' } });
|
||
fireEvent.blur(input);
|
||
|
||
expect(
|
||
await screen.findByText('项目版本必须是大于 0 的整数'),
|
||
).not.toBeNull();
|
||
expect(saveGameDistributionProjectVersion).not.toHaveBeenCalled();
|
||
await waitFor(() => {
|
||
expect(screen.getByLabelText('项目版本')).toHaveProperty('value', '1');
|
||
});
|
||
});
|
||
|
||
test('恢复失败(网络/5xx)时禁用提交,不按首次发布继续', async () => {
|
||
installTauriInvoke(async () => undefined);
|
||
vi.mocked(readGameDistributionPublication).mockResolvedValue({
|
||
state: 'unavailable',
|
||
binding: null,
|
||
refreshed: false,
|
||
draft: null,
|
||
visibility: null,
|
||
message: '无法刷新线上状态:无法连接登录服务',
|
||
});
|
||
renderPanel();
|
||
|
||
const submit = await screen.findByRole('button', {
|
||
name: '无法确认发布状态',
|
||
});
|
||
expect(submit).toHaveProperty('disabled', true);
|
||
expect(
|
||
screen.getByText('无法刷新线上状态:无法连接登录服务'),
|
||
).not.toBeNull();
|
||
expect(publishLocalProjectGame).not.toHaveBeenCalled();
|
||
});
|
||
});
|