1fc78faff2
上传链路(复用既有分片续传,不另写一套): - `PackageUploadAsset` 收拢两条资产的差异(URL 资源段 `package` / `project-bundle`、文案、 偏移错误码 `PACKAGE_UPLOAD_OFFSET_MISMATCH` / `PROJECT_BUNDLE_UPLOAD_OFFSET_MISMATCH`、 是否有创建版本时预登记大小),`upload_staged_game_package` 改名 `upload_staged_version_asset`,发行包与工程源包共用同一段主循环与重试/续传语义。 - 发布流程:建游戏 → 建版本 → 上传发行包 → 〔授权非「禁止共创」且作者没取消时〕打包工程源包 并上传 → 送审。插入点必须在送审**之前**:服务端只允许 `awaiting_upload` / `upload_failed` 档位补传工程包,送审后即 409。 - 失败关闭与不阻断:工程源包打包/上传任何失败都只写进结果的 `projectBundleWarning` (「未上传工程源码,本作品只能被产物级改编:…」),发布本身照常成功;作者的作品仍可玩可发布。 - 进度复用既有 `game-package-upload-progress` 事件(phase 仍用 upload/verify,文案区分资产), 渲染层的阶段联合是闭集,不新增阶段名。 面板与渲染层: - 新增「同时公开工程源码」勾选:仅当授权非「禁止共创」且为首次发布时出现,默认勾选, 文案写清「允许他人下载并使用你的工程源文件(含素材),且不可撤销」; 取消勾选才显式传 `includeProjectBundle: false`,其余情况交给原生按「授权非禁止即上传」判断, 渲染层不重复实现同一套默认值。 - 成功页如实展示 `projectBundleWarning`,避免作者误以为源码已随版本公开。 测试:Rust 新增「两条资产的路由段/偏移错误码/预登记大小差异」用例,并更新既有 `upload_url` 用例; TS 新增勾选默认值、取消勾选显式 false、未上传提示三条,服务层新增「true/缺省不带字段、 false 才带」用例。
460 lines
15 KiB
TypeScript
460 lines
15 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('取消公开工程源码时显式传 includeProjectBundle=false', 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' },
|
|
includeProjectBundle: false,
|
|
});
|
|
expect(calls[0]?.args).toMatchObject({ includeProjectBundle: false });
|
|
|
|
// 勾选(或未表态)时不带这个字段:默认由原生按「授权非禁止即上传」判断,
|
|
// 渲染层不重复实现同一套默认值。
|
|
await publishLocalProjectGame({
|
|
invoke,
|
|
projectPath: '/tmp/project',
|
|
packageRelativePath: 'exports/playtest-package-1.zip',
|
|
manifest: MANIFEST,
|
|
metadata: { coverAssetId: 'asset_cover' },
|
|
includeProjectBundle: true,
|
|
});
|
|
expect(calls[1]?.args).not.toHaveProperty('includeProjectBundle');
|
|
});
|
|
|
|
test('共创授权档位随创建作品请求提交,改编声明不来自渲染层', 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',
|
|
forkAuthorization: 'nonCommercial',
|
|
},
|
|
});
|
|
expect(calls[0]?.args).toMatchObject({
|
|
metadata: { forkAuthorization: 'nonCommercial' },
|
|
});
|
|
// 改编声明只能来自项目内真实取过件的 `.agent/fork-source.json`:原生发布链路在首次
|
|
// 发布时读取并入请求体,渲染层既没有入口也伪造不了。
|
|
expect(calls[0]?.args?.metadata).not.toHaveProperty('fork');
|
|
|
|
// 未选择时按契约默认值「禁止共创」提交(与省略该字段在服务端等价)。
|
|
await publishLocalProjectGame({
|
|
invoke,
|
|
projectPath: '/tmp/project',
|
|
packageRelativePath: 'exports/playtest-package-1.zip',
|
|
manifest: MANIFEST,
|
|
metadata: { coverAssetId: 'asset_cover' },
|
|
});
|
|
expect(calls[1]?.args).toMatchObject({
|
|
metadata: { forkAuthorization: 'forbidden' },
|
|
});
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|