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
生成游戏封面请求补充 AGC 客户端来源标记,确保队列 worker 返回 result。 补充封面生成请求断言,锁定平台素材 ID 回填链路。 同步记录缺陷根因与修复约定。
349 lines
11 KiB
TypeScript
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',
|
|
);
|
|
});
|