Files
Genarrative/apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts
kdletters e0c324c0ac
Project CI / AI game creator shell Rust crates (push) Successful in 1m33s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m2s
Project CI / Backend tests (push) Successful in 4m7s
Project CI / Frontend tests (push) Successful in 2m10s
Project CI / Native shell tests (push) Successful in 6m12s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m17s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 9m37s
Project CI / AI game creator shell web tests (push) Successful in 1m34s
Project CI / Repository checks (push) Successful in 2m1s
修复游戏封面生成素材 ID 回传
生成游戏封面请求补充 AGC 客户端来源标记,确保队列 worker 返回 result。

补充封面生成请求断言,锁定平台素材 ID 回填链路。

同步记录缺陷根因与修复约定。
2026-09-23 21:29:18 +08:00

349 lines
11 KiB
TypeScript

// @vitest-environment jsdom
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
const fetchClientHttp = vi.fn();
vi.mock('../src/services/clientHttp', () => ({
AGC_DEVELOPMENT_API_BASE_URL: 'https://dev.genarrative.world',
fetchClientHttp: (...args: unknown[]) => fetchClientHttp(...args),
getClientServerBaseUrl: () => 'https://dev.genarrative.world',
readClientHttpResponseText: (response: Response) => response.text(),
}));
vi.mock('../src/services/errorReporting', () => ({
captureClientError: vi.fn(),
}));
// 原生侧上传需要登录凭据;这里只钉住「取到了 token」这一件事。
vi.mock('../src/services/clientApi', async (importOriginal) => ({
...(await importOriginal<typeof import('../src/services/clientApi')>()),
getStoredAuthAccessToken: () => 'test-access-token',
}));
import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
import {
generateGameDistributionCover,
publishLocalProjectGame,
readGameCoverGenerationPrice,
readGamePublishAvailability,
suggestGameDistributionPublishMetadata,
} from '../src/services/gameDistributionPublish';
const MANIFEST = {
projectId: 'local-proj-1',
name: '星轨防线',
goal: '守住轨道城',
} as unknown as GameCreationAppManifest;
/** 归一化发行包的暂存摘要;发布链路只应传递它,不再传整包字节。 */
const STAGED_PACKAGE = {
stagingPath: 'C:/app-data/game-package-staging/aaaa.zip',
packageSha256: 'a'.repeat(64),
packageSizeBytes: 1024,
packageFileCount: 1,
};
function jsonResponse(payload: unknown) {
return new Response(
JSON.stringify({
ok: true,
data: payload,
error: null,
meta: { apiVersion: 'v1' },
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
);
}
beforeEach(() => {
fetchClientHttp.mockReset();
window.localStorage.clear();
});
afterEach(() => {
vi.useRealTimers();
});
test('发布时携带本地项目标识,让重复发布复用同一个平台游戏', async () => {
fetchClientHttp
.mockResolvedValueOnce(
jsonResponse({ id: 'game_1', publicationRevision: 0 }),
)
.mockResolvedValueOnce(
jsonResponse({
gameId: 'game_1',
versionId: 'gamever_1',
versionNumber: 1,
status: 'awaiting_upload',
}),
)
.mockResolvedValueOnce(
jsonResponse({ version: { status: 'pending_review' } }),
);
const invokeCalls: Array<{ command: string; args: unknown }> = [];
const result = await publishLocalProjectGame({
invoke: (async (command: string, args?: Record<string, unknown>) => {
invokeCalls.push({ command, args });
if (command === 'prepare_local_project_game_package') {
return STAGED_PACKAGE;
}
if (command === 'upload_local_project_game_package') {
return {
versionId: 'gamever_1',
status: 'uploaded',
uploadedBytes: STAGED_PACKAGE.packageSizeBytes,
};
}
throw new Error(`未预期的命令:${command}`);
}) as never,
projectPath: '/tmp/project',
packageRelativePath: 'exports/playtest-package-1.zip',
manifest: MANIFEST,
metadata: {
coverAssetId: 'asset_cover',
screenshots: ['asset_shot_1', 'asset_shot_2'],
},
});
const createGameCall = fetchClientHttp.mock.calls[0];
expect(createGameCall?.[0]).toBe('/api/game-distribution/games');
const createGameBody = JSON.parse(
String((createGameCall?.[1] as RequestInit).body),
);
expect(createGameBody.localProjectId).toBe('local-proj-1');
expect(createGameBody.coverAssetId).toBe('asset_cover');
expect(createGameBody.screenshots).toEqual(['asset_shot_1', 'asset_shot_2']);
const createVersionCall = fetchClientHttp.mock.calls[1];
expect(createVersionCall?.[0]).toBe(
'/api/game-distribution/games/game_1/versions',
);
expect(
JSON.parse(String((createVersionCall?.[1] as RequestInit).body))
.localProjectId,
).toBe('local-proj-1');
expect(result.gameId).toBe('game_1');
expect(result.versionId).toBe('gamever_1');
// 关键回归:整包字节不再经过 IPC,上传交给原生侧按版本 ID + 暂存路径完成。
const uploadCall = invokeCalls.find(
(call) => call.command === 'upload_local_project_game_package',
);
expect(uploadCall?.args).toMatchObject({
stagingPath: STAGED_PACKAGE.stagingPath,
versionId: 'gamever_1',
apiBaseUrl: 'https://dev.genarrative.world',
accessToken: 'test-access-token',
});
expect(Object.keys(uploadCall?.args ?? {})).not.toContain('packageBytes');
// 三次 HTTP:创建游戏、创建版本、送审;上传不再占用一条 HTTP 调用。
expect(fetchClientHttp).toHaveBeenCalledTimes(3);
});
test('缺少本地项目标识时在发起请求前失败关闭', async () => {
await expect(
publishLocalProjectGame({
invoke: (async () => STAGED_PACKAGE) as never,
projectPath: '/tmp/project',
packageRelativePath: 'exports/playtest-package-1.zip',
manifest: { ...MANIFEST, projectId: ' ' } as GameCreationAppManifest,
metadata: { coverAssetId: 'asset_cover' },
}),
).rejects.toThrow('发布需要本地项目标识');
expect(fetchClientHttp).not.toHaveBeenCalled();
});
test('缺少封面时在创建游戏前失败关闭', async () => {
await expect(
publishLocalProjectGame({
invoke: (async () => STAGED_PACKAGE) as never,
projectPath: '/tmp/project',
packageRelativePath: 'exports/playtest-package-1.zip',
manifest: MANIFEST,
metadata: { coverAssetId: ' ' },
}),
).rejects.toThrow('请先选择游戏封面');
expect(fetchClientHttp).not.toHaveBeenCalled();
});
test('截图超过 6 张时在创建游戏前失败关闭', async () => {
await expect(
publishLocalProjectGame({
invoke: (async () => STAGED_PACKAGE) as never,
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(fetchClientHttp).not.toHaveBeenCalled();
});
test('发布灰度按后端运行时配置判定,命中才开放入口', async () => {
fetchClientHttp.mockResolvedValueOnce(
jsonResponse({ gameDistributionPublishEnabled: true }),
);
await expect(readGamePublishAvailability()).resolves.toBe(true);
expect(fetchClientHttp.mock.calls[0]?.[0]).toBe(
'/api/runtime/frontend-config',
);
fetchClientHttp.mockResolvedValueOnce(
jsonResponse({ gameDistributionPublishEnabled: false }),
);
await expect(readGamePublishAvailability()).resolves.toBe(false);
// 老后端/字段缺失时按未命中处理,入口不暴露。
fetchClientHttp.mockResolvedValueOnce(jsonResponse({}));
await expect(readGamePublishAvailability()).resolves.toBe(false);
});
test('发布灰度读取失败时抛出,由调用方按不开放处理', async () => {
fetchClientHttp.mockRejectedValueOnce(new Error('network down'));
await expect(readGamePublishAvailability()).rejects.toThrow();
});
test('免费发布资料建议只上传脱敏上下文并返回白名单分类', async () => {
fetchClientHttp.mockResolvedValueOnce(
jsonResponse({ summary: '驾驶炮台守住轨道城', category: '策略' }),
);
await expect(
suggestGameDistributionPublishMetadata({
name: '星轨防线',
goal: '守住轨道城',
context: '当前任务与状态:原型已完成;现有素材:image:assets/hero.png',
}),
).resolves.toEqual({
summary: '驾驶炮台守住轨道城',
category: '策略',
});
expect(fetchClientHttp.mock.calls[0]?.[0]).toBe(
'/api/game-distribution/publish-metadata/suggestions',
);
const init = fetchClientHttp.mock.calls[0]?.[1] as RequestInit;
expect(JSON.parse(String(init.body))).toEqual({
name: '星轨防线',
goal: '守住轨道城',
context: '当前任务与状态:原型已完成;现有素材:image:assets/hero.png',
});
});
test('封面生成直接使用返回的平台素材 ID,不再次上传', async () => {
fetchClientHttp.mockResolvedValueOnce(
jsonResponse({
imageSrc: 'https://assets.example.com/generated-cover.png',
assetObjectId: 'asset_generated_cover',
taskId: 'task_cover_1',
model: 'gpt-image-2',
}),
);
await expect(
generateGameDistributionCover({
prompt: '为《星轨防线》生成游戏封面',
model: 'gpt-image-2',
aspectRatio: '16:9',
imageSize: '2K',
}),
).resolves.toEqual({
assetObjectId: 'asset_generated_cover',
previewUrl: 'https://assets.example.com/generated-cover.png',
taskId: 'task_cover_1',
model: 'gpt-image-2',
});
expect(fetchClientHttp.mock.calls[0]?.[0]).toBe(
'/api/editor/images/generations',
);
const body = JSON.parse(
String((fetchClientHttp.mock.calls[0]?.[1] as RequestInit).body),
);
expect(body).toEqual({
prompt: '为《星轨防线》生成游戏封面',
kind: 'publication-material',
assetKind: 'publication-material',
model: 'gpt-image-2',
aspectRatio: '16:9',
imageSize: '2K',
assetLabel: '游戏封面',
generationInputs: { source: 'ai-game-creator-client' },
});
});
test('封面生成进入队列后轮询完成结果并返回同一平台素材', async () => {
vi.useFakeTimers();
fetchClientHttp
.mockResolvedValueOnce(
jsonResponse({
queueState: {
operationId: 'external-job-1',
status: 'queued',
phaseDetail: '排队中。',
},
}),
)
.mockResolvedValueOnce(
jsonResponse({
job: {
operationId: 'external-job-1',
status: 'completed',
result: {
imageSrc: 'https://assets.example.com/queued-cover.png',
assetObjectId: 'asset_queued_cover',
taskId: 'task_queued_cover',
model: 'gpt-image-2',
},
},
}),
);
const generation = generateGameDistributionCover({
prompt: '为《星轨防线》生成游戏封面',
model: 'gpt-image-2',
});
await vi.advanceTimersByTimeAsync(1_600);
await expect(generation).resolves.toEqual({
assetObjectId: 'asset_queued_cover',
previewUrl: 'https://assets.example.com/queued-cover.png',
taskId: 'task_queued_cover',
model: 'gpt-image-2',
});
expect(fetchClientHttp.mock.calls[1]?.[0]).toBe(
'/api/runtime/external-generation/jobs/external-job-1',
);
});
test('封面价格读取后端运行时定价,不在前端硬编码', async () => {
fetchClientHttp.mockResolvedValueOnce(
jsonResponse({
models: {
'gpt-image-2': {
unit: 'perGeneration',
prices: { '2K': 5 },
},
},
}),
);
await expect(
readGameCoverGenerationPrice({
model: 'gpt-image-2',
imageSize: '2K',
}),
).resolves.toBe(5);
expect(fetchClientHttp.mock.calls[0]?.[0]).toBe(
'/api/editor/generation-pricing',
);
});