Files
Genarrative/apps/ai-game-creator-shell/tests/platformGameFork.test.tsx
T
suzmii 09a11ac215 fix(游戏共创): 取件成功提示按两种取件形态如实措辞
M2b 之后取件有两种形态:工程源包解压即可编辑工程(没有 reference/ 参考副本),成品包才放在
reference/<gameId>/ 作可玩参考。原来的成功提示写死「参考产物在 reference/<gameId>/ 下」,
对工程源包形态是错的。改为两种情形都说明,并同步测试断言(仍保留「改编自 X」这条来源事实)。
2026-10-05 17:30:49 +08:00

268 lines
9.0 KiB
TypeScript

// @vitest-environment jsdom
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { InitLocalProjectResult } from '../src/app/types';
import {
classifyPlatformForkFailure,
parsePlatformGameId,
} from '../src/features/platform-fork/platformGameForkModel';
import PlatformGameForkSection from '../src/view/home/PlatformGameForkSection';
const FORK_COMMAND = 'create_local_project_from_platform_game';
const PROJECT_RESULT = {
projectPath: 'C:\\projects\\gameagent-fork-1',
manifestPath: 'C:\\projects\\gameagent-fork-1\\.agent\\manifest.json',
manifest: { name: '改编项目 1' },
} as unknown as InitLocalProjectResult;
type InvokeCall = { command: string; args?: Record<string, unknown> };
function installInvoke(
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);
},
);
(window as unknown as { __TAURI__?: unknown }).__TAURI__ = { core: { invoke } };
return calls;
}
function renderSection() {
const onProjectCreated = vi.fn();
render(<PlatformGameForkSection onProjectCreated={onProjectCreated} />);
return { onProjectCreated };
}
function typeGameInput(value: string) {
fireEvent.change(screen.getByLabelText('平台作品 ID 或链接'), {
target: { value },
});
}
/** 直接提交表单:空输入时按钮是 disabled,只有提交表单才能覆盖「本地拦截」这条路径。 */
function submitForm() {
const form = screen
.getByLabelText('平台作品 ID 或链接')
.closest('form') as HTMLFormElement;
fireEvent.submit(form);
}
function forkCalls(calls: InvokeCall[]) {
return calls.filter((call) => call.command === FORK_COMMAND);
}
afterEach(() => {
cleanup();
delete (window as unknown as { __TAURI__?: unknown }).__TAURI__;
});
describe('游戏共创入口输入解析', () => {
it('接受裸作品 ID 与常见作品链接形态', () => {
expect(parsePlatformGameId(' game_ab12cd ')).toBe('game_ab12cd');
expect(parsePlatformGameId('https://platform.test/games/detail?id=game_1')).toBe(
'game_1',
);
expect(parsePlatformGameId('https://platform.test/games/detail?gameId=game_2')).toBe(
'game_2',
);
expect(parsePlatformGameId('platform.test/games/game_3?from=share')).toBe(
'game_3',
);
expect(parsePlatformGameId('https://platform.test/games/game_3/play')).toBe(
'game_3',
);
});
it('解析不出作品 ID 时返回 null(本地拦截,不发请求)', () => {
for (const input of [
'',
' ',
'game/1',
'game 1',
'https://platform.test/other/path',
'https://platform.test/games/detail',
'https://platform.test/games',
]) {
expect(parsePlatformGameId(input), input).toBeNull();
}
});
it('按原生前缀分类失败,并去掉机器前缀后展示', () => {
expect(
classifyPlatformForkFailure(
new Error('authentication-required: 陶泥儿登录态已过期,请重新登录后重试'),
),
).toEqual({
kind: 'auth',
message: '陶泥儿登录态已过期,请重新登录后重试',
});
expect(
classifyPlatformForkFailure(
'permission-denied: 作者没有开放这个作品的共创授权,无法改编',
),
).toEqual({
kind: 'forbidden',
message: '作者没有开放这个作品的共创授权,无法改编',
});
expect(
classifyPlatformForkFailure(
'fork-source-not-found: 该作品不存在或已被删除,无法改编',
),
).toEqual({ kind: 'notFound', message: '该作品不存在或已被删除,无法改编' });
expect(
classifyPlatformForkFailure('fork-source-not-available: 该作品当前不能作为改编来源(未公开、已下架或没有公开版本)'),
).toEqual({
kind: 'unavailable',
message: '该作品当前不能作为改编来源(未公开、已下架或没有公开版本)',
});
// 未命中前缀:原样展示,不吞掉排障信息。
expect(classifyPlatformForkFailure(new Error('网络不可用'))).toEqual({
kind: 'other',
message: '网络不可用',
});
});
});
describe('PlatformGameForkSection', () => {
it('空或非法输入不发请求,并给出可操作提示', () => {
const calls = installInvoke(() => PROJECT_RESULT);
renderSection();
submitForm();
expect(forkCalls(calls)).toHaveLength(0);
expect(screen.getByRole('alert').textContent).toContain(
'请输入平台作品 ID 或作品链接',
);
typeGameInput(' ');
submitForm();
expect(forkCalls(calls)).toHaveLength(0);
typeGameInput('https://platform.test/other/path');
submitForm();
expect(forkCalls(calls)).toHaveLength(0);
expect(screen.getByRole('alert').textContent).toContain(
'请输入平台作品 ID 或作品链接',
);
});
it('403 未开放授权:展示服务端文案、不建项目且可重试', async () => {
installInvoke(() => {
throw new Error('permission-denied: 作者没有开放这个作品的共创授权,无法改编');
});
const { onProjectCreated } = renderSection();
typeGameInput('game_locked');
submitForm();
expect((await screen.findByRole('alert')).textContent).toBe(
'作者没有开放这个作品的共创授权,无法改编',
);
expect(onProjectCreated).not.toHaveBeenCalled();
expect(
screen.getByRole('button', { name: '取回并创建项目' }),
).toHaveProperty('disabled', false);
});
it('409 作品不可用与 401 未登录:各自给出对应提示且不建项目', async () => {
let failure = new Error(
'fork-source-not-available: 该作品当前不能作为改编来源(未公开、已下架或没有公开版本)',
);
installInvoke(() => {
throw failure;
});
const { onProjectCreated } = renderSection();
typeGameInput('game_private');
submitForm();
expect((await screen.findByRole('alert')).textContent).toContain(
'该作品当前不能作为改编来源',
);
failure = new Error(
'authentication-required: 陶泥儿登录态已过期,请重新登录后重试',
);
typeGameInput('game_private');
submitForm();
expect((await screen.findByRole('alert')).textContent).toBe(
'陶泥儿登录态已过期,请重新登录后重试',
);
expect(onProjectCreated).not.toHaveBeenCalled();
});
it('取件期间展示加载态、禁止重复提交,完成后回到可提交状态', async () => {
// `Promise.withResolvers` 需要 lib ES2024,本仓 AGC 走 ES2022(同
// `gameDistributionPublishPanel.test.tsx` 的既有写法),因此手写等价 deferred。
let resolveFork: (value: unknown) => void = () => undefined;
const calls = installInvoke((command) =>
command === FORK_COMMAND
? new Promise((resolve) => {
resolveFork = resolve as (value: unknown) => void;
})
: null,
);
renderSection();
typeGameInput('game_parent_1');
submitForm();
expect(await screen.findByText(/正在取回作品成品/u)).not.toBeNull();
expect(screen.getByRole('button', { name: '正在取回…' })).toHaveProperty(
'disabled',
true,
);
// 同一 tick 内重复提交:ref 守卫必须挡住第二次请求。
submitForm();
expect(forkCalls(calls)).toHaveLength(1);
resolveFork(PROJECT_RESULT);
await waitFor(() =>
expect(screen.queryByText(/正在取回作品成品/u)).toBeNull(),
);
});
it('成功:用解析出的作品 ID 调命令、进入新项目并展示「改编自」事实', async () => {
const calls = installInvoke((command) =>
command === FORK_COMMAND ? PROJECT_RESULT : null,
);
const { onProjectCreated } = renderSection();
typeGameInput('https://platform.test/games/detail?id=game_parent_1');
submitForm();
await waitFor(() => expect(onProjectCreated).toHaveBeenCalledTimes(1));
expect(forkCalls(calls)).toEqual([
{
command: FORK_COMMAND,
args: { gameId: 'game_parent_1', name: null, planning: false },
},
]);
const [result, isCurrent, analytics] = onProjectCreated.mock.calls[0] ?? [];
expect(result).toBe(PROJECT_RESULT);
expect(typeof isCurrent).toBe('function');
expect(analytics).not.toBeNull();
const statusText = screen
.getAllByRole('status')
.map((node) => node.textContent ?? '')
.join('\n');
expect(statusText).toContain('改编自平台作品 game_parent_1');
expect(statusText).toContain('reference/game_parent_1/');
// 两种取件形态都要说清:工程源包直接就是可编辑工程,成品包只作可玩参考。
expect(statusText).toContain('可编辑工程');
expect(statusText).toContain('可玩参考');
});
});