Files
Genarrative/apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts
T
suzmii daae35a1a1 AGC 发布版本标签改由工程内部版本派生,拆除 projectVersion 写链路
- shared contracts:resolveGameCreationAppProjectVersion 改为按 manifest.versions 条数派生,删除 isGameCreationAppProjectVersion,projectVersion 注释降级为遗留兼容位
- Rust shared-contracts:删除 resolve_game_creation_app_project_version / is_game_creation_app_project_version / GAME_CREATION_APP_FIRST_PROJECT_VERSION,project_version 注释改遗留位并重写往返测试
- AGC 发布面板:项目版本改为 useMemo 派生只读输入框,删除本地 state、失焦落盘与 onManifestUpdated 回调
- AGC 发布服务:删除 saveGameDistributionProjectVersion,提交版本号缺省改为按 versions 派生
- AGC 原生:发布成功回写与绑定回填不再写 project_version(置空),删除 update_game_distribution_project_version 命令
- AGC 命令注册与白名单:desktop.rs 注销命令、check-config.mjs 移除白名单条目
- AGC 前端装配:App.tsx 移除两处 onManifestUpdated 传递
- AGC 测试:publish/panel 两条测试改为覆盖派生口径,删除四条写回用例
- 注释对齐:game_publish_attempt.rs / project/manifest.rs / styles.css / shared-contracts 的 game_distribution.rs 把版本标签口径改为「由 versions[] 派生」,清掉 projectVersion 遗留位表述
- 文档:decision-log 追加本决策条目,两份 2026-10-02 计划加取代标注,新增 2026-10-03 里程碑与实施计划
2026-10-04 00:16:14 +08:00

401 lines
13 KiB
TypeScript

// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
import type { TauriInvoke } from '../src/app/types';
import {
generateGameDistributionCover,
publishLocalProjectGame,
readGameCoverGenerationPrice,
readGameDistributionPublication,
suggestGameDistributionPublishMetadata,
} from '../src/services/gameDistributionPublish';
const MANIFEST = {
projectId: 'local-proj-1',
name: '星轨防线',
goal: '守住轨道城',
} as unknown as GameCreationAppManifest;
const PUBLISH_RESULT = {
gameId: 'game_1',
versionId: 'gamever_1',
versionNumber: 1,
status: 'pending_review',
packageSha256: 'a'.repeat(64),
packageSizeBytes: 1024,
fileCount: 1,
};
type InvokeCall = {
command: string;
args: Record<string, unknown> | undefined;
};
function installNativeInvoke(
implementation: (command: string, args?: Record<string, unknown>) => unknown,
) {
const calls: InvokeCall[] = [];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
calls.push({ command, args });
return implementation(command, args);
},
) as unknown as TauriInvoke;
return { invoke, calls };
}
beforeEach(() => {
window.localStorage.clear();
});
afterEach(() => {
vi.useRealTimers();
delete (window as unknown as { __TAURI__?: unknown }).__TAURI__;
});
describe('Rust 发布 facade', () => {
test('发布只通过一个 native command 传递本地路径、资料和幂等键', async () => {
const { invoke, calls } = installNativeInvoke((command) => {
if (command === 'publish_local_project_game') return PUBLISH_RESULT;
throw new Error(`未预期的命令:${command}`);
});
const result = await publishLocalProjectGame({
invoke,
projectPath: '/tmp/project',
packageRelativePath: 'exports/playtest-package-1.zip',
manifest: MANIFEST,
metadata: {
coverAssetId: 'asset_cover',
screenshots: ['asset_shot_1', 'asset_shot_2'],
},
idempotencyKey: 'agc-publish-test',
});
expect(result).toEqual(PUBLISH_RESULT);
expect(calls).toHaveLength(1);
expect(calls[0]?.command).toBe('publish_local_project_game');
expect(calls[0]?.args).toMatchObject({
projectPath: '/tmp/project',
packageRelativePath: 'exports/playtest-package-1.zip',
idempotencyKey: 'agc-publish-test',
metadata: {
localProjectId: 'local-proj-1',
coverAssetId: 'asset_cover',
screenshots: ['asset_shot_1', 'asset_shot_2'],
},
});
expect(calls[0]?.args).not.toHaveProperty('accessToken');
expect(calls[0]?.args).not.toHaveProperty('apiBaseUrl');
expect(calls[0]?.args).not.toHaveProperty('packageBytes');
});
test('未显式给出根幂等键时不生成键,交由原生账本解析', async () => {
const { invoke, calls } = installNativeInvoke((command) => {
if (command === 'publish_local_project_game') return PUBLISH_RESULT;
throw new Error(`未预期的命令:${command}`);
});
await publishLocalProjectGame({
invoke,
projectPath: '/tmp/project',
packageRelativePath: 'exports/playtest-package-1.zip',
manifest: MANIFEST,
metadata: { coverAssetId: 'asset_cover' },
});
expect(calls).toHaveLength(1);
// 渲染层不铸键也不落盘:Rust 按「账号 + 本地项目 + 包摘要」的账本解析根幂等键,
// 这样响应丢失、重启与分片续传才会落在同一次服务端尝试上。
expect(calls[0]?.args).not.toHaveProperty('idempotencyKey');
expect(calls[0]?.args).toMatchObject({
projectPath: '/tmp/project',
packageRelativePath: 'exports/playtest-package-1.zip',
metadata: { localProjectId: 'local-proj-1' },
});
});
test('缺少本地项目标识时在发起 native command 前失败关闭', async () => {
const invoke = vi.fn() as unknown as TauriInvoke;
await expect(
publishLocalProjectGame({
invoke,
projectPath: '/tmp/project',
packageRelativePath: 'exports/playtest-package-1.zip',
manifest: { ...MANIFEST, projectId: ' ' } as GameCreationAppManifest,
metadata: { coverAssetId: 'asset_cover' },
}),
).rejects.toThrow('发布需要本地项目标识');
expect(invoke).not.toHaveBeenCalled();
});
test('缺少封面时在创建 native 发布任务前失败关闭', async () => {
const invoke = vi.fn() as unknown as TauriInvoke;
await expect(
publishLocalProjectGame({
invoke,
projectPath: '/tmp/project',
packageRelativePath: 'exports/playtest-package-1.zip',
manifest: MANIFEST,
metadata: { coverAssetId: ' ' },
}),
).rejects.toThrow('请先选择游戏封面');
expect(invoke).not.toHaveBeenCalled();
});
test('截图超过 6 张时在 native command 前失败关闭', async () => {
const invoke = vi.fn() as unknown as TauriInvoke;
await expect(
publishLocalProjectGame({
invoke,
projectPath: '/tmp/project',
packageRelativePath: 'exports/playtest-package-1.zip',
manifest: MANIFEST,
metadata: {
coverAssetId: 'asset_cover',
screenshots: Array.from(
{ length: 7 },
(_, index) => `asset_${index}`,
),
},
}),
).rejects.toThrow('游戏截图最多 6 张');
expect(invoke).not.toHaveBeenCalled();
});
test('资料建议和价格均通过 native command', async () => {
const { invoke, calls } = installNativeInvoke((command) => {
if (command === 'suggest_game_distribution_publish_metadata') {
return { summary: '驾驶炮台守住轨道城', category: '策略' };
}
if (command === 'read_game_cover_generation_price') return 5;
throw new Error(`未预期的命令:${command}`);
});
await expect(
suggestGameDistributionPublishMetadata(
{
name: '星轨防线',
goal: '守住轨道城',
context:
'当前任务与状态:原型已完成;现有素材:image:assets/hero.png',
},
invoke,
),
).resolves.toEqual({ summary: '驾驶炮台守住轨道城', category: '策略' });
await expect(
readGameCoverGenerationPrice(
{ model: 'gpt-image-2', imageSize: '2K' },
invoke,
),
).resolves.toBe(5);
expect(calls).toEqual([
{
command: 'suggest_game_distribution_publish_metadata',
args: {
name: '星轨防线',
goal: '守住轨道城',
context:
'当前任务与状态:原型已完成;现有素材:image:assets/hero.png',
},
},
{
command: 'read_game_cover_generation_price',
args: { model: 'gpt-image-2', imageSize: '2K' },
},
]);
});
test('封面生成由 Rust 等待队列后一次性返回已登记素材', async () => {
const { invoke, calls } = installNativeInvoke((command) => {
if (command === 'generate_game_distribution_cover') {
return {
assetObjectId: 'asset_generated_cover',
previewUrl: 'https://assets.example.com/generated-cover.png',
taskId: 'task_cover_1',
model: 'gpt-image-2',
};
}
throw new Error(`未预期的命令:${command}`);
});
await expect(
generateGameDistributionCover(
{
prompt: '为《星轨防线》生成游戏封面',
model: 'gpt-image-2',
aspectRatio: '16:9',
imageSize: '2K',
},
invoke,
),
).resolves.toEqual({
assetObjectId: 'asset_generated_cover',
previewUrl: 'https://assets.example.com/generated-cover.png',
taskId: 'task_cover_1',
model: 'gpt-image-2',
});
expect(calls).toEqual([
{
command: 'generate_game_distribution_cover',
args: {
prompt: '为《星轨防线》生成游戏封面',
model: 'gpt-image-2',
aspectRatio: '16:9',
imageSize: '2K',
assetLabel: '游戏封面',
},
},
]);
});
test('发布状态恢复通过单个 native command 传递项目路径与三态结果', async () => {
const updateResult = {
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: null,
visibility: 'published',
};
const { invoke, calls } = installNativeInvoke((command) => {
if (command === 'read_game_distribution_publication') {
return updateResult;
}
throw new Error(`未预期的命令:${command}`);
});
await expect(
readGameDistributionPublication({ projectPath: '/tmp/project' }, invoke),
).resolves.toEqual(updateResult);
expect(calls).toEqual([
{
command: 'read_game_distribution_publication',
args: { projectPath: '/tmp/project' },
},
]);
});
test('恢复结果缺少已知三态时失败关闭,不把未知结果当成首次发布', async () => {
const { invoke } = installNativeInvoke(() => undefined);
await expect(
readGameDistributionPublication({ projectPath: '/tmp/project' }, invoke),
).rejects.toThrow('发布状态回读结果无效');
});
test('更新发布会带上原 gameId、项目版本与公开修订号', async () => {
const { invoke, calls } = installNativeInvoke(() => PUBLISH_RESULT);
await publishLocalProjectGame({
invoke,
projectPath: '/tmp/project',
packageRelativePath: 'exports/playtest-package-1.zip',
manifest: MANIFEST,
metadata: { coverAssetId: 'asset_cover' },
gameId: 'game_9',
versionNumber: 3,
expectedPublicationRevision: 7,
});
expect(calls[0]?.args).toMatchObject({
gameId: 'game_9',
versionNumber: 3,
expectedPublicationRevision: 7,
});
});
test('未显式给出项目版本时按工程内部版本条数派生', async () => {
const { invoke, calls } = installNativeInvoke(() => PUBLISH_RESULT);
// 没有任何内部版本记录:首版回落 1,不把「没写」变成服务端自动递增。
await publishLocalProjectGame({
invoke,
projectPath: '/tmp/project',
packageRelativePath: 'exports/playtest-package-1.zip',
manifest: MANIFEST,
metadata: { coverAssetId: 'asset_cover' },
});
expect(calls[0]?.args).toMatchObject({ versionNumber: 1 });
// 4 条内部版本 → 标签就是最新那张卡的序数 4;平台绑定里的版本号不参与派生。
const withVersions = {
...MANIFEST,
versions: [
{ versionId: 'initial-13', parentVersionId: null, projectRevision: 13 },
{
versionId: 'agent-22',
parentVersionId: 'initial-13',
projectRevision: 22,
},
{
versionId: 'agent-42',
parentVersionId: 'agent-22',
projectRevision: 42,
},
{
versionId: 'agent-46',
parentVersionId: 'agent-42',
projectRevision: 46,
},
],
projectVersion: 6,
publication: { latestVersionNumber: 6 },
} as unknown as GameCreationAppManifest;
await publishLocalProjectGame({
invoke,
projectPath: '/tmp/project',
packageRelativePath: 'exports/playtest-package-1.zip',
manifest: withVersions,
metadata: { coverAssetId: 'asset_cover' },
gameId: 'game_9',
expectedPublicationRevision: 7,
});
expect(calls[1]?.args).toMatchObject({
gameId: 'game_9',
versionNumber: 4,
expectedPublicationRevision: 7,
});
});
test('更新发布缺少公开修订号时在 native command 前失败关闭', async () => {
const invoke = vi.fn() as unknown as TauriInvoke;
await expect(
publishLocalProjectGame({
invoke,
projectPath: '/tmp/project',
packageRelativePath: 'exports/playtest-package-1.zip',
manifest: MANIFEST,
metadata: { coverAssetId: 'asset_cover' },
gameId: 'game_9',
versionNumber: 3,
}),
).rejects.toThrow('缺少公开修订号');
expect(invoke).not.toHaveBeenCalled();
});
test('项目版本不是正整数安全整数时在 native command 前失败关闭', async () => {
const invoke = vi.fn() as unknown as TauriInvoke;
for (const versionNumber of [0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1]) {
await expect(
publishLocalProjectGame({
invoke,
projectPath: '/tmp/project',
packageRelativePath: 'exports/playtest-package-1.zip',
manifest: MANIFEST,
metadata: { coverAssetId: 'asset_cover' },
versionNumber,
}),
).rejects.toThrow('项目版本必须是大于 0 的安全整数');
}
expect(invoke).not.toHaveBeenCalled();
});
});