feat(游戏共创): 首页新增「从平台作品开始创作」入口(唯一可点的客户端路径)

- 入口位置:首页模板库推荐位之后新增一节「从平台作品开始创作」。与模板库同属「从现成内容
  起步」,但这条拿的是平台作品的已构建成品(可玩参考 + 素材),不是可编辑工程,因此单独成节
  而不是塞进模板库;未登录用户进不到首页,服务端也确实对任何登录用户开放取件,无需额外灰度。
- 交互:输入平台作品 ID 或作品链接 → 解析出作品 ID(裸 ID / `?id=` / `?gameId=` / `/games/<id>`,
  也容忍不带 scheme 的粘贴;解析不出就本地拦截、不发请求)→ 调
  `create_local_project_from_platform_game`(name/planning 走默认,与模板链路一致)→
  成功后复用模板那条「原生已落盘 → 登记最近项目并进入工作区」通道进项目。
- 三类结果:① 未登录/登录过期(`authentication-required:`)→「请重新登录后重试」;
  ② 403 未开放授权(`permission-denied:`)与 409 未公开/已下架(`fork-source-not-available:`)
  按服务端文案分别展示,且都不建项目、按钮可重试;③ 成功展示「改编自平台作品 <gameId>」
  与 `reference/<gameId>/` 参考产物位置,让来源事实可见。
- 为此给取件的 404/409 补了与 401/403 同族的稳定前缀(`fork-source-not-found:` /
  `fork-source-not-available:`):渲染层按前缀精确分类,不做文案子串猜测。
- 加载态:取件期间按钮禁用 + 明确提示「下载、校验、解压需要几分钟」,并用 ref 守卫挡住同一
  tick 的重复提交(只认最后一次请求)。
- 文案严守产品口径:拿到的是「已构建成品」= 可玩参考 + 素材,不能直接再次发布,需在新工程里
  改造后发布;不出现「一键复刻工程」这类说法。
- 门禁登记:命令已接回 App 入口,因此从 `scripts/check-config.mjs` 的 native-only 白名单移除
  (该脚本要求「已接入」与「未接入」两个集合互斥)。
- 测试:新增 `tests/platformGameFork.test.tsx`(纯解析/失败分类 + 组件:空与非法输入不发请求、
  403/409/401 各自提示且不建项目、加载态与重复提交、成功时命令参数与「改编自」展示)。
This commit is contained in:
2026-10-05 04:16:54 +08:00
parent 4626aeefd4
commit d89e21ffd5
7 changed files with 593 additions and 8 deletions
@@ -176,9 +176,6 @@ const allowedUncalledTauriCommands = [
'sync_local_project_snapshot',
'reset_design_agent_session',
'stop_local_game_preview_if_matches',
// 「从平台作品开始创作」本轮只落 native 能力:取件、摘要校验与建项都在 Rust 完成,
// App 前端的改编入口(作品详情页「改造这个作品」/ 首页入口)尚未接线,先按 native-only 登记。
'create_local_project_from_platform_game',
'start_game_creator_external_mcp',
'stop_game_creator_external_mcp',
'list_agc_plugins',
@@ -514,6 +514,10 @@ const FORK_SOURCE_PACKAGE_MAX_BYTES: u64 = 512 * 1024 * 1024;
/// 状态码本身就是服务端合同(`fork_source_target`):行不存在 → 404;不可用作来源
/// (未公开 / 已软删除 / 没有当前公开版本 / 缺版本元数据)→ 409;授权禁止或未知 → 403;
/// 未登录 → 401。因此这里按状态映射,不依赖 `error.code`。
///
/// 四个分类各自带稳定前缀(`authentication-required:` / `permission-denied:` /
/// `fork-source-not-found:` / `fork-source-not-available:`):渲染层按前缀区分「需要登录 /
/// 未开放授权 / 作品不存在 / 作品不可用」并按需要去掉前缀展示,不做子串猜测。
fn map_fork_source_http_error(status: StatusCode, body: &str) -> String {
crate::platform_maintenance::watch_platform_response(status.as_u16(), body);
let (code, message) = parse_error_payload(body);
@@ -524,9 +528,10 @@ fn map_fork_source_http_error(status: StatusCode, body: &str) -> String {
StatusCode::FORBIDDEN => {
"permission-denied: 作者没有开放这个作品的共创授权,无法改编".to_string()
}
StatusCode::NOT_FOUND => "该作品不存在或已被删除,无法改编".to_string(),
StatusCode::NOT_FOUND => "fork-source-not-found: 该作品不存在或已被删除,无法改编".to_string(),
StatusCode::CONFLICT => {
"该作品当前不能作为改编来源(未公开、已下架或没有公开版本)".to_string()
"fork-source-not-available: 该作品当前不能作为改编来源(未公开、已下架或没有公开版本)"
.to_string()
}
_ => format!(
"读取改编来源失败:{}",
@@ -1894,14 +1899,14 @@ mod tests {
StatusCode::NOT_FOUND,
r#"{"error":{"code":"FORK_SOURCE_NOT_FOUND"}}"#
),
"该作品不存在或已被删除,无法改编"
"fork-source-not-found: 该作品不存在或已被删除,无法改编"
);
assert_eq!(
map_fork_source_http_error(
StatusCode::CONFLICT,
r#"{"error":{"code":"FORK_SOURCE_NOT_AVAILABLE"}}"#
),
"该作品当前不能作为改编来源(未公开、已下架或没有公开版本)"
"fork-source-not-available: 该作品当前不能作为改编来源(未公开、已下架或没有公开版本)"
);
// 其它失败保留原始 detail,便于排障,但仍是可读文案而不是裸 HTTP 码。
assert_eq!(
@@ -807,6 +807,19 @@ export function WorkspaceLauncherShell({
}
templateLibraryError={templateLibrary.error}
onTemplateLibraryOpen={() => setLauncherView('template-library')}
// 平台作品改编建出的项目与模板项目一样「原生已落盘」,复用同一条进项目通道;
// 项目来源事实(改编自哪个作品)由命令写入 .agent/fork-source.json,首页负责展示。
onPlatformGameProjectCreated={async (
result,
isCurrent,
analytics,
) => {
await homeProject.enterCreatedTemplateProject(
result,
isCurrent,
analytics,
);
}}
/>
) : launcherView === 'projects' ? (
<ProjectsPage
@@ -0,0 +1,104 @@
/**
* 「从平台作品开始创作」的纯逻辑:输入解析与失败分类。
*
* 与原生取件的分工:这里只做**能在渲染层判定的格式问题**(空、非法字符、链接里找不到作品
* ID)——判不了就不发请求;作品的可见性、授权与存在性一律以服务端为准(原生命令的
* 404 / 409 / 403 / 401 已折成带稳定前缀的文案,见 `classifyPlatformForkFailure`)。
*/
/** 平台作品 ID 的字符集,与原生 `fetch_platform_game_fork_source` 的路径安全判据一致。 */
const GAME_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
/** 链接里可能承载作品 ID 的查询参数名(网页端详情页用 `id`,其余命名一并容忍)。 */
const GAME_ID_QUERY_KEYS = ['gameId', 'gameid', 'game_id', 'id'] as const;
/**
* 从「作品 ID 或作品链接」解析出平台作品 ID;无法确定时返回 `null`(调用方不得发请求)。
*
* 两条口径,顺序固定:
* 1. 裸 ID:整串都落在作品 ID 字符集内即原样使用(存在性由服务端回答,不做本地白名单);
* 2. 链接:先看查询参数,再看 `/games/` 之后的首个路径段(`/games/detail?id=` 已被第 1 步命中)。
* 不解析 deep link、不依赖服务端:解析失败就是「请检查输入」,不是请求失败。
*/
export function parsePlatformGameId(input: string): string | null {
const trimmed = input.trim();
if (!trimmed) {
return null;
}
if (GAME_ID_PATTERN.test(trimmed)) {
return trimmed;
}
// 用户常直接粘贴不带 scheme 的地址;补一个占位 scheme 只用于解析,不参与任何请求。
const withScheme = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//u.test(trimmed)
? trimmed
: `https://${trimmed.replace(/^\/+/u, '')}`;
let url: URL;
try {
url = new URL(withScheme);
} catch {
return null;
}
for (const key of GAME_ID_QUERY_KEYS) {
const candidate = url.searchParams.get(key)?.trim() ?? '';
if (candidate && GAME_ID_PATTERN.test(candidate)) {
return candidate;
}
}
const segments = url.pathname.split('/').filter(Boolean);
const gamesIndex = segments.indexOf('games');
if (gamesIndex < 0) {
return null;
}
// `/games/detail?id=` 已被上面的查询参数命中;这里取 `/games/<id>` 的首个非 detail 段。
const candidate = segments
.slice(gamesIndex + 1)
.find((segment) => segment !== 'detail');
return candidate && GAME_ID_PATTERN.test(candidate) ? candidate : null;
}
export type PlatformForkFailureKind =
/** 未登录 / 登录态过期:可操作提示是「重新登录」。 */
| 'auth'
/** 403:作者没有开放共创授权。 */
| 'forbidden'
/** 404:作品不存在或已被删除。 */
| 'notFound'
/** 409:作品未公开 / 已下架 / 没有公开版本。 */
| 'unavailable'
/** 其它失败(网络、超时、未预期状态):原样展示原生文案。 */
| 'other';
export type PlatformForkFailure = {
kind: PlatformForkFailureKind;
/** 去掉机器前缀后的用户可见文案。 */
message: string;
};
const FAILURE_PREFIXES: readonly [string, PlatformForkFailureKind][] = [
['authentication-required:', 'auth'],
['permission-denied:', 'forbidden'],
['fork-source-not-found:', 'notFound'],
['fork-source-not-available:', 'unavailable'],
];
/**
* 把原生取件失败折成「可区分 + 可直接展示」的两段式结果。
*
* 前缀是原生错误契约的一部分(`map_fork_source_http_error`),因此这里按前缀精确匹配,
* 绝不用文案子串猜测错误类型;未命中前缀的一律按 `other` 原样展示,不吞掉排障信息。
*/
export function classifyPlatformForkFailure(error: unknown): PlatformForkFailure {
const raw = error instanceof Error ? error.message : String(error);
const text = raw.trim();
for (const [prefix, kind] of FAILURE_PREFIXES) {
if (text.startsWith(prefix)) {
const message = text.slice(prefix.length).trim();
return { kind, message: message || raw.trim() };
}
}
return { kind: 'other', message: text };
}
/** 输入非法(解析不出作品 ID)时的统一提示:本地拦截,不发请求。 */
export const PLATFORM_FORK_INPUT_ERROR =
'请输入平台作品 ID 或作品链接(例如 game_ab12… 或 https://…/games/detail?id=game_ab12…)';
@@ -0,0 +1,179 @@
/**
* 首页「从平台作品开始创作」入口:输入平台作品 ID 或作品链接,取件并新建本地项目。
*
* 产品口径(与 `docs/【技术方案】游戏共创与作品Fork-2026-10-03.md` §2.5 / §3.5.1 一致):
* 平台下发的是**已构建的发行成品包**,只能当「可玩参考 + 素材来源」,**不是可编辑的工程**,
* 也不能直接再次发布;参考产物铺在新项目的 `reference/<作品ID>/` 下,用户要在新建的合规工程
* (Phaser 4 + Vite 脚手架)里自己改造后发布。这里绝不写成「一键复刻工程」。
*
* 取件、摘要校验、解压与来源记录全在原生命令里(`create_local_project_from_platform_game`),
* 渲染层只做输入解析、加载态与失败分类。
*/
import { useRef, useState } from 'react';
import type { FormEvent } from 'react';
import type { InitLocalProjectResult } from '../../app/types';
import { resolveTauriInvoke } from '../../app/tauri';
import {
classifyPlatformForkFailure,
parsePlatformGameId,
PLATFORM_FORK_INPUT_ERROR,
} from '../../features/platform-fork/platformGameForkModel';
import {
beginProjectOpenAnalytics,
type ProjectOpenAnalytics,
} from '../../services/clientAnalytics';
type PlatformGameForkSectionProps = {
/**
* 建项成功后的进项目通道:与模板库共用同一条「原生已建好 → 登记最近项目并进入工作区」路径,
* 因此这里只透传结果、代次判据与项目打开埋点,不自己碰工作区状态。
*/
onProjectCreated: (
result: InitLocalProjectResult,
isCurrent: () => boolean,
analytics: ProjectOpenAnalytics,
) => Promise<void> | void;
};
type PlatformGameForkSuccess = {
gameId: string;
projectPath: string;
projectName: string;
};
export default function PlatformGameForkSection({
onProjectCreated,
}: PlatformGameForkSectionProps) {
const [input, setInput] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState<PlatformGameForkSuccess | null>(null);
/** 只认最后一次提交:迟到的结果不再改状态,也不进入项目。 */
const requestSequenceRef = useRef(0);
/** 同一 tick 内的重复提交靠 ref 拦截:`busy` 状态要等下一次渲染才生效。 */
const busyRef = useRef(false);
async function submitForkRequest(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (busyRef.current) {
return;
}
const gameId = parsePlatformGameId(input);
if (!gameId) {
setSuccess(null);
setError(PLATFORM_FORK_INPUT_ERROR);
return;
}
const invoke = resolveTauriInvoke();
if (!invoke) {
setSuccess(null);
setError('需要在陶泥儿客户端内运行');
return;
}
const sequence = requestSequenceRef.current + 1;
requestSequenceRef.current = sequence;
const isCurrent = () => requestSequenceRef.current === sequence;
busyRef.current = true;
setBusy(true);
setError('');
setSuccess(null);
const analytics = beginProjectOpenAnalytics(invoke, 'create');
try {
const result = await invoke<InitLocalProjectResult>(
'create_local_project_from_platform_game',
{ gameId, name: null, planning: false },
);
if (!isCurrent()) {
return;
}
setSuccess({
gameId,
projectPath: result.projectPath,
projectName: result.manifest.name || gameId,
});
await onProjectCreated(result, isCurrent, analytics);
} catch (nextError) {
if (!isCurrent()) {
return;
}
setError(classifyPlatformForkFailure(nextError).message);
} finally {
if (isCurrent()) {
busyRef.current = false;
setBusy(false);
}
}
}
return (
<section
className="mx-auto mt-5.5 grid w-[min(814px,calc(100vw-122px))] gap-3.5 max-[760px]:w-[min(100%,calc(100vw-76px))]"
aria-label="从平台作品开始创作"
>
<header className="flex items-center justify-start gap-3">
<h2 className="m-0 text-[15px] text-(--platform-text-strong)">
从平台作品开始创作
</h2>
<span className="rounded-full bg-(--platform-neutral-bg) px-2 py-0.5 text-[11px] text-(--platform-warm-text)">
参考改编
</span>
</header>
{/*
口径必须写清楚:拿到的是成品包(可玩参考 + 素材),不是可编辑工程,也不能直接再发布。
否则用户会以为「一键复刻」之后可以直接上架。
*/}
<p className="m-0 text-[12px] leading-5 text-(--platform-text-soft)">
输入平台上已开放共创的作品 ID 或作品链接。我们会取回该作品的
<strong className="text-(--platform-text-strong)">已构建成品</strong>
作为可玩参考与素材,放进新项目(合规的 Phaser 4 + Vite 工程)的{' '}
<code>reference/&lt;作品ID&gt;/</code>{' '}
目录。成品包里没有源码,不能直接再次发布,请在新工程里改造后发布。
</p>
<form
className="flex items-center gap-2.5 max-[760px]:flex-wrap"
onSubmit={submitForkRequest}
>
<input
className="min-w-0 flex-1 rounded-lg border border-(--platform-subpanel-border) bg-(--platform-neutral-bg) px-3 py-2 text-[12.5px] text-(--platform-text-strong) outline-none disabled:opacity-60"
aria-label="平台作品 ID 或链接"
value={input}
disabled={busy}
placeholder="例如 game_ab12… 或 https://…/games/detail?id=game_ab12…"
onChange={(event) => setInput(event.target.value)}
/>
<button
className="shrink-0 cursor-pointer rounded-lg border-0 bg-(image:--platform-button-primary-fill) px-3.5 py-2 text-[12px] font-bold text-(--platform-button-primary-text) disabled:cursor-not-allowed disabled:opacity-55"
type="submit"
disabled={busy || input.trim().length === 0}
>
{busy ? '正在取回…' : '取回并创建项目'}
</button>
</form>
{busy ? (
<p
className="m-0 text-[12px] leading-5 text-(--platform-text-soft)"
role="status"
>
正在取回作品成品、校验摘要并创建项目;发行包较大时下载与解压需要几分钟,请不要关闭客户端。
</p>
) : null}
{error ? (
<p className="m-0 text-[12px] leading-5 text-[#b42318]" role="alert">
{error}
</p>
) : null}
{success ? (
<p
className="m-0 text-[12px] leading-5 text-(--platform-text-strong)"
role="status"
>
已创建项目《{success.projectName}》,改编自平台作品 {success.gameId}
(来源已记录在项目的 .agent/fork-source.json)。参考产物在 reference/
{success.gameId}/ 下,请在新工程里改造后发布。
</p>
) : null}
</section>
);
}
@@ -12,10 +12,15 @@ import type { FormEvent } from 'react';
import { useRef, useState } from 'react';
import BRAND_ICON from '../../../../../packages/shared/src/icons/taonier-product-ip.png';
import type { ProjectStartMode } from '../../app/types';
import type {
InitLocalProjectResult,
ProjectStartMode,
} from '../../app/types';
import { resolveHomeCreationFailureMessage } from '../../features/app-shell/homeCreationFailure';
import { ConversationModelSelect } from '../../features/project-workspace/ConversationModelSelect';
import type { GameTemplateEntry } from '../../features/template-library/templateLibraryModel';
import type { ProjectOpenAnalytics } from '../../services/clientAnalytics';
import PlatformGameForkSection from './PlatformGameForkSection';
import RichInputArea, { UploadButton } from './components/RichInputArea';
import {
richTextToAttachments,
@@ -124,6 +129,15 @@ type HomeViewProps = {
templateLibraryLoading: boolean;
templateLibraryError: string;
onTemplateLibraryOpen: () => void;
/**
* 「从平台作品开始创作」建成项目后的进项目通道:与模板库共用同一条原生已建好 → 登记最近项目
* 并进入工作区的路径,首页只透传结果与项目打开埋点。
*/
onPlatformGameProjectCreated: (
result: InitLocalProjectResult,
isCurrent: () => boolean,
analytics: ProjectOpenAnalytics,
) => Promise<void> | void;
};
export default function HomeView({
@@ -141,6 +155,7 @@ export default function HomeView({
templateLibraryLoading,
templateLibraryError,
onTemplateLibraryOpen,
onPlatformGameProjectCreated,
}: HomeViewProps) {
const homeCreationType = useLauncherHomeDraftStore(
(state) => state.creationType,
@@ -455,6 +470,12 @@ export default function HomeView({
/>
</section>
) : null}
{/*
「从平台作品开始创作」放在模板库之后:两者都是「从现成内容起步」,但这条走平台作品的
已授权成品包(可玩参考 + 素材),不是可编辑工程,因此与模板库分开成节。
*/}
<PlatformGameForkSection onProjectCreated={onPlatformGameProjectCreated} />
</div>
);
}
@@ -0,0 +1,266 @@
// @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('请在新工程里改造后发布');
});
});