09a11ac215
M2b 之后取件有两种形态:工程源包解压即可编辑工程(没有 reference/ 参考副本),成品包才放在 reference/<gameId>/ 作可玩参考。原来的成功提示写死「参考产物在 reference/<gameId>/ 下」, 对工程源包形态是错的。改为两种情形都说明,并同步测试断言(仍保留「改编自 X」这条来源事实)。
268 lines
9.0 KiB
TypeScript
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('可玩参考');
|
|
});
|
|
});
|