Files
Genarrative/apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx
T
suzmii 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 并发校验与旧客户端自动递增兼容。
2026-10-03 16:41:04 +08:00

892 lines
30 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 {
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();
});
});