Files
Genarrative/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts
T
suzmii 53afc85319 修正 AGC 测试按新的 Lexical 聊天输入区驱动与断言
- appSurface/harness 补齐三处 jsdom 缺口:ClipboardEvent、DragEvent、Range.prototype.getBoundingClientRect;否则 Lexical 的粘贴通路直接抛 ReferenceError / TypeError
- appSurface/harness 的 submitChat 改为 async:全选并让编辑器吸收选区、清空、走产品真实 paste 通路写入,再让出一帧让 React 追平 draft,最后点发送
- 新增 composerText / composerValue / composerDisabled / setComposerText 助手,按原生控件与 Lexical contenteditable 两种 DOM 口径读写输入区
- 15 个测试文件中 116 处 toHaveProperty('value', …) 断言等义改写为 await composerText() / await composerValue();1 处 placeholder 断言改查输入区占位文案;2 处 disabled 断言改用 composerDisabled(同时覆盖 data-disabled 与 contenteditable=false);9 处 fireEvent.change 写入改用 setComposerText
- 328 处 submitChat 调用补 await,9 个非 async 用例补 async
- Godot 输入区键盘语义用例按 Lexical 实际行为改写:Shift+Enter 与组合态 Enter 由编辑器消化并插入换行、不发送、草稿保留
- 未改动 src 下任何产品代码,未放宽或删除断言
2026-09-10 11:46:02 +08:00

3266 lines
114 KiB
TypeScript

import type { ProjectSupervisorComponentProps } from '../../src/features/app-shell/model';
import { useHomeProjectCreation } from '../../src/features/app-shell/useHomeProjectCreation';
import { WorkspaceLauncherShell } from '../../src/features/app-shell/WorkspaceLauncher';
import type { LauncherView } from '../../src/view/layout';
import {
act,
App,
cleanup,
createGameCreationAppManifest,
createProjectSupervisorRuntimeHarness,
expect,
findResourceDetailButton,
fireEvent,
getResourceDetailButton,
it,
nativeClipboardMock,
pickProjectFromLauncher,
queryResourceDetailButton,
React,
render,
renderAppAt,
renderLauncherAgentChatAt,
renderLauncherAt,
renderLauncherProjectsAt,
screen,
selectDeveloperAgentChatMode,
setComposerText,
testAuthUser,
vi,
waitFor,
within,
} from './harness';
function emptyPlanningV2StartResult(projectId = 'local-project-draft') {
return {
session: {
schemaVersion: 'planning-session.v2',
engine: 'planning-session-v2',
sessionId: 'home-planning-v2-session',
projectId,
mode: 'gdd',
status: 'planning',
turnIndex: 1,
questionCount: 0,
questionLimit: 8,
revisionCount: 0,
currentArtifactVersion: null,
currentQuestion: null,
capabilities: { tools: [], skills: [] },
processingSeconds: 0.5,
createdAtUtc: '2026-09-03T00:00:00Z',
updatedAtUtc: '2026-09-03T00:00:01Z',
lastError: null,
},
result: null,
currentArtifact: null,
replayed: false,
};
}
function ApprovedGddStartHarness() {
const [, setLauncherView] = React.useState<LauncherView>(
'project-development',
);
const [, setStatus] = React.useState('');
const [, setAgentChatProjectPath] = React.useState('');
const controller = useHomeProjectCreation({
setStatus,
setLauncherView,
setAgentChatProjectPath,
rememberRecentWorkspace: () => undefined,
});
if (controller.currentProjectContext) {
return React.createElement(
'p',
{ 'aria-label': '已进入自动游戏项目' },
controller.currentProjectContext.initialPrompt,
);
}
return React.createElement(
'button',
{
type: 'button',
onClick: () =>
void controller.startGameFromApprovedGdd('/tmp/planning-project'),
},
'直接用已批准 GDD 开始建造',
);
}
async function openResourceBookCategory(label: string) {
const categoryByLabel: Record<string, string> = {
设计文档: 'document',
美术资源: 'art',
音乐音效: 'audio',
项目版本: 'version',
游戏代码: 'code',
};
const category = categoryByLabel[label];
const outline = await screen.findByLabelText('资源栏目大纲');
fireEvent.click(
within(outline).getByRole('button', { name: new RegExp(label) }),
);
await waitFor(() => {
const manager = document.querySelector('[data-resource-book-view="child"]');
expect(manager).not.toBeNull();
if (category) {
expect(
manager?.querySelector(
`.game-resource-book-scene-titlebar.is-active[data-resource-book-category="${category}"]`,
),
).not.toBeNull();
}
});
}
export function registerClientHomeTests() {
it('adds a model selector to the home composer and shows the default model', async () => {
const invoke = vi.fn(async (command: string, args?: unknown) => {
if (command === 'read_game_creator_app_config') {
return { config: { selectedModelId: 'quality' } };
}
if (command === 'select_game_creator_model') {
return {
config: { selectedModelId: (args as { modelId: string }).modelId },
};
}
throw new Error(`unexpected invoke ${command}`);
});
window.__TAURI__ = { core: { invoke } };
renderLauncherAt('/?launcher');
// 挂载即加载目录,触发按钮直接落在默认模型上,不出现「选择模型」空态。
await waitFor(() =>
expect(invoke).toHaveBeenCalledWith('read_game_creator_app_config'),
);
const modelTrigger = await screen.findByRole('button', {
name: '对话模型',
});
await waitFor(() => expect(modelTrigger.textContent).toContain('高质量'));
const createButton = screen.getByRole('button', { name: '开启创作' });
expect(createButton).toHaveProperty('disabled', false);
fireEvent.click(modelTrigger);
await waitFor(() =>
expect(screen.getByRole('option', { name: '快速' })).not.toBeNull(),
);
fireEvent.click(screen.getByRole('option', { name: '快速' }));
await waitFor(() =>
expect(invoke).toHaveBeenCalledWith('select_game_creator_model', {
modelId: 'fast',
isDefault: false,
}),
);
expect(modelTrigger.textContent).toContain('快速');
expect(createButton).toHaveProperty('disabled', false);
});
it('anchors the empty home input placeholder to the editor while the page scrolls', () => {
renderLauncherAt('/?launcher');
const placeholder = screen.getByText('今天想把什么灵感做成游戏');
expect(placeholder.classList.contains('absolute')).toBe(true);
expect(placeholder.classList.contains('top-0')).toBe(true);
expect(placeholder.parentElement?.classList.contains('relative')).toBe(
true,
);
});
it('shows the built-in inspiration masonry gallery and opens a dismissible preview without requesting the retired feed', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch');
renderLauncherAt('/?launcher');
const inspiration = screen.getByLabelText('灵感推荐');
const inspirationImages = within(inspiration).getAllByRole('button', {
name: /查看灵感图片/,
});
expect(inspirationImages.length).toBeGreaterThan(0);
expect(inspiration.closest('.overflow-y-auto')).not.toBeNull();
expect(screen.queryByText('暂无灵感')).toBeNull();
fireEvent.click(inspirationImages[0]!);
const preview = screen.getByRole('dialog', { name: '查看灵感图片' });
fireEvent.click(screen.getByAltText('放大的灵感图片'));
expect(screen.getByRole('dialog', { name: '查看灵感图片' })).not.toBeNull();
fireEvent.click(preview);
expect(screen.queryByRole('dialog', { name: '查看灵感图片' })).toBeNull();
await act(async () => {
await Promise.resolve();
});
expect(
fetchSpy.mock.calls.some(
([input]) => String(input) === '/api/editor/showcase/resources',
),
).toBe(false);
});
it('projects Supervisor manifest updates into the open workbench without reopening the project', async () => {
const projectPath = '/tmp/live-manifest-workbench';
const initialManifest = createGameCreationAppManifest(
'live-manifest-project',
'实时清单项目',
);
const updatedManifest = {
...initialManifest,
tasks: initialManifest.tasks.map((task) =>
task.id === 'code-prototype'
? { ...task, status: 'completed' as const }
: task,
),
assets: [
{
id: 'live-hero',
kind: 'art-spritesheet',
mediaType: 'image/png',
localPath: 'assets/live-hero.png',
source: { kind: 'generated' as const, taskId: 'art-asset-plan' },
},
],
versions: [
{
versionId: 'version-live-1',
parentVersionId: null,
projectRevision: 1,
resourceBindings: [{ slotId: 'hero', resourceId: 'live-hero' }],
createdReason: 'initial' as const,
createdAt: 1,
},
],
};
function ManifestPushingSupervisor({
initialProjectPath,
onManifestChange,
}: ProjectSupervisorComponentProps) {
return React.createElement(
'button',
{
type: 'button',
onClick: () =>
onManifestChange?.(initialProjectPath ?? '', updatedManifest),
},
'同步最新 manifest',
);
}
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: '实时清单项目',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_manifest') {
return initialManifest;
}
if (command === 'read_local_project_resource_graph') {
return {
resourceIds: (args?.resources as Array<{ resourceId: string }>).map(
(resource) => resource.resourceId,
),
referenceEdges: [],
taskFlows: [],
connectionIndex: [],
producerAssignments: [],
dependencyDepths: [],
unresolvedReferenceResourceIds: [],
cyclicResourceIds: [],
cyclicTaskIds: [],
producerMappingTruncated: false,
};
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: 'live-manifest-project',
mode: args?.mode,
revision: 0,
positions: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: 'live-manifest-project',
mode: 'dependency',
revision: 1,
positions: args?.positions,
updatedAt: 1,
},
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
render(
React.createElement(WorkspaceLauncherShell, {
currentUser: testAuthUser,
initialView: 'projects',
onLogout: vi.fn(),
ProjectSupervisor: ManifestPushingSupervisor,
}),
);
pickProjectFromLauncher(projectPath);
const runButton = await screen.findByRole('tab', { name: '运行' });
expect(runButton.getAttribute('data-unavailable')).toBe('true');
expect(queryResourceDetailButton('live-hero.png')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '同步最新 manifest' }));
await openResourceBookCategory('美术资源');
expect(await findResourceDetailButton('live-hero.png')).not.toBeNull();
await openResourceBookCategory('项目版本');
expect(
await screen.findByRole('button', { name: /版本 1/ }),
).not.toBeNull();
expect(runButton.getAttribute('data-unavailable')).toBeNull();
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
'read_local_project_resource_graph',
expect.objectContaining({
resources: expect.arrayContaining([
expect.objectContaining({
resourceId: 'asset:live-hero',
manifestAssetId: 'live-hero',
}),
expect.objectContaining({
resourceId: 'version:version-live-1',
}),
]),
}),
);
});
});
it('re-reads the live manifest from a non-Supervisor Runtime event without reopening the project', async () => {
const projectPath = '/tmp/live-runtime-manifest-workbench';
const initialManifest = createGameCreationAppManifest(
'live-runtime-manifest-project',
'真实事件清单项目',
);
const updatedManifest = {
...initialManifest,
tasks: initialManifest.tasks.map((task) =>
task.id === 'code-prototype'
? { ...task, status: 'completed' as const }
: task,
),
assets: [
{
id: 'runtime-live-hero',
kind: 'art-spritesheet',
mediaType: 'image/png',
localPath: 'assets/runtime-live-hero.png',
source: {
kind: 'canvas' as const,
taskId: 'art-asset-plan',
resourceId: 'canvas-runtime-live-hero',
},
},
],
versions: [
{
versionId: 'version-runtime-live-1',
parentVersionId: null,
projectRevision: 1,
resourceBindings: [
{ slotId: 'hero', resourceId: 'runtime-live-hero' },
],
createdReason: 'initial' as const,
createdAt: 1,
},
],
};
const runtimeHarness = createProjectSupervisorRuntimeHarness({
projectPath,
});
let manifestChanged = false;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: '真实事件清单项目',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_manifest') {
return manifestChanged ? updatedManifest : initialManifest;
}
if (command === 'read_local_project_resource_graph') {
return {
resourceIds: (args?.resources as Array<{ resourceId: string }>).map(
(resource) => resource.resourceId,
),
referenceEdges: [],
taskFlows: [],
connectionIndex: [],
producerAssignments: [],
dependencyDepths: [],
unresolvedReferenceResourceIds: [],
cyclicResourceIds: [],
cyclicTaskIds: [],
producerMappingTruncated: false,
};
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: 'live-runtime-manifest-project',
mode: args?.mode,
revision: 0,
positions: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: 'live-runtime-manifest-project',
mode: args?.mode,
revision: 1,
positions: args?.positions,
updatedAt: 1,
},
};
}
return runtimeHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: runtimeHarness.listen },
};
render(
React.createElement(WorkspaceLauncherShell, {
currentUser: testAuthUser,
initialView: 'projects',
onLogout: vi.fn(),
ProjectSupervisor: App,
}),
);
pickProjectFromLauncher(projectPath);
const runButton = await screen.findByRole('tab', { name: '运行' });
expect(runButton.getAttribute('data-unavailable')).toBe('true');
expect(queryResourceDetailButton('runtime-live-hero.png')).toBeNull();
await waitFor(() => {
expect(runtimeHarness.listen).toHaveBeenCalledWith(
'game-creator-manifest-invalidated',
expect.any(Function),
);
});
await waitFor(() => {
expect(
invoke.mock.calls.filter(
([command]) => command === 'inspect_local_project_directory',
),
).toHaveLength(2);
});
const inspectionsBeforeEvent = invoke.mock.calls.filter(
([command]) => command === 'inspect_local_project_directory',
).length;
manifestChanged = true;
runtimeHarness.setProjectRevision(1);
act(() => {
runtimeHarness.emitManifestInvalidated('art-asset-plan');
});
await openResourceBookCategory('美术资源');
expect(
await findResourceDetailButton('runtime-live-hero.png', {
timeout: 5_000,
}),
).not.toBeNull();
await openResourceBookCategory('项目版本');
expect(
await screen.findByRole('button', { name: /版本 1/ }),
).not.toBeNull();
expect(runButton.getAttribute('data-unavailable')).toBeNull();
await waitFor(() => {
expect(
invoke.mock.calls.filter(
([command]) => command === 'get_local_game_manifest',
).length,
).toBeGreaterThan(0);
});
expect(
invoke.mock.calls.filter(
([command]) => command === 'inspect_local_project_directory',
).length,
).toBe(inspectionsBeforeEvent);
});
it('does not let a late manifest refresh from the previous project replace the active project', async () => {
const firstProjectPath = '/tmp/live-manifest-project-first';
const secondProjectPath = '/tmp/live-manifest-project-second';
const firstManifest = createGameCreationAppManifest(
'live-manifest-project-first',
'旧项目',
);
const staleFirstManifest = {
...firstManifest,
assets: [
{
id: 'stale-first-asset',
kind: 'art-spritesheet',
mediaType: 'image/png',
localPath: 'assets/stale-first.png',
source: { kind: 'generated' as const },
},
],
};
const secondManifest = createGameCreationAppManifest(
'live-manifest-project-second',
'新项目',
);
secondManifest.assets = [
{
id: 'second-asset',
kind: 'art-spritesheet',
mediaType: 'image/png',
localPath: 'assets/second.png',
source: { kind: 'generated' },
},
];
let resolveStaleRefresh!: (manifest: typeof staleFirstManifest) => void;
const staleRefresh = new Promise<typeof staleFirstManifest>((resolve) => {
resolveStaleRefresh = resolve;
});
let holdFirstRefresh = false;
const runtimeHarness = createProjectSupervisorRuntimeHarness({
projectPath: firstProjectPath,
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
const requestedPath = String(args?.projectPath ?? '');
if (command === 'inspect_local_project_directory') {
return {
projectPath: requestedPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName:
requestedPath === secondProjectPath ? '新项目' : '旧项目',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_manifest') {
if (requestedPath === secondProjectPath) {
return secondManifest;
}
if (holdFirstRefresh) {
return staleRefresh;
}
return firstManifest;
}
if (command === 'read_local_project_resource_graph') {
return {
resourceIds: (args?.resources as Array<{ resourceId: string }>).map(
(resource) => resource.resourceId,
),
referenceEdges: [],
taskFlows: [],
connectionIndex: [],
producerAssignments: [],
dependencyDepths: [],
unresolvedReferenceResourceIds: [],
cyclicResourceIds: [],
cyclicTaskIds: [],
producerMappingTruncated: false,
};
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId:
requestedPath === secondProjectPath
? secondManifest.projectId
: firstManifest.projectId,
mode: args?.mode,
revision: 0,
positions: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: String(args?.expectedProjectId ?? ''),
mode: args?.mode,
revision: 1,
positions: args?.positions,
updatedAt: 1,
},
};
}
return runtimeHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: runtimeHarness.listen },
};
render(
React.createElement(WorkspaceLauncherShell, {
currentUser: testAuthUser,
initialView: 'projects',
onLogout: vi.fn(),
ProjectSupervisor: App,
}),
);
pickProjectFromLauncher(firstProjectPath);
await screen.findByLabelText('项目开发工作台');
await waitFor(() => {
expect(runtimeHarness.listen).toHaveBeenCalledWith(
'game-creator-manifest-invalidated',
expect.any(Function),
);
});
const firstProjectManifestReadsBeforeRefresh = invoke.mock.calls.filter(
([command, args]) =>
command === 'get_local_game_manifest' &&
args?.projectPath === firstProjectPath,
).length;
holdFirstRefresh = true;
act(() => {
runtimeHarness.emitManifestInvalidated('code-prototype');
});
await waitFor(() => {
expect(
invoke.mock.calls.filter(
([command, args]) =>
command === 'get_local_game_manifest' &&
args?.projectPath === firstProjectPath,
).length,
).toBeGreaterThan(firstProjectManifestReadsBeforeRefresh);
});
fireEvent.click(screen.getByRole('button', { name: '项目组' }));
pickProjectFromLauncher(secondProjectPath);
await openResourceBookCategory('美术资源');
expect(await findResourceDetailButton('second.png')).not.toBeNull();
await act(async () => {
resolveStaleRefresh(staleFirstManifest);
await staleRefresh;
});
expect(queryResourceDetailButton('stale-first.png')).toBeNull();
expect(getResourceDetailButton('second.png')).not.toBeNull();
});
it('starts from the client home and opens a project in the same window', async () => {
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/profile/dashboard') {
return new Response(
JSON.stringify({
walletBalance: 40,
totalPlayTimeMs: 0,
playedWorldCount: 0,
updatedAt: '2026-07-17T00:00:00.000Z',
}),
{ status: 200 },
);
}
if (url === '/api/profile/recharge-center') {
return new Response(
JSON.stringify({
walletBalance: 40,
mudPointBalance: {
totalPoints: 40,
permanentPoints: 20,
limitedPoints: 0,
limitedExpiresAt: null,
dailyFreePoints: 20,
dailyFreeResetPoints: 20,
dailyFreeResetsAt: '2026-07-18T00:00:00.000Z',
},
products: [],
membership: null,
}),
{ status: 200 },
);
}
if (url === '/api/profile/wallet-ledger') {
return new Response(
JSON.stringify({
entries: [
{
id: 'ledger-test-1',
sourceType: 'daily_task_reward',
amountDelta: 12,
balanceAfter: 40,
createdAt: '2026-07-17T00:00:00.000Z',
},
],
}),
{ status: 200 },
);
}
throw new Error(`unexpected fetch ${url}`);
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
return {
projectPath: String(args?.projectPath ?? ''),
exists: true,
isDirectory: true,
isGameCreatorProject: true,
isGodotProject: false,
godotProjectRoot: null,
projectName: 'authorized-game',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_manifest') {
return createGameCreationAppManifest(
'local-project-draft',
'authorized-game',
);
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
renderLauncherProjectsAt('/?launcher');
expect(screen.getByLabelText('GameAgent 客户端')).not.toBeNull();
expect(screen.queryByLabelText('通知')).toBeNull();
expect(screen.getByRole('button', { name: '我的' })).not.toBeNull();
expect(screen.getByRole('button', { name: '帮助' })).not.toBeNull();
expect(
within(screen.getByLabelText('账户资产')).queryByRole('button', {
name: '账户',
}),
).toBeNull();
expect(
within(screen.getByLabelText('账户资产')).getByRole('button', {
name: /^泥点 /,
}),
).not.toBeNull();
expect(
within(screen.getByLabelText('账户资产')).getByRole('button', {
name: '充值',
}),
).not.toBeNull();
fireEvent.mouseEnter(
within(screen.getByLabelText('账户资产')).getByRole('button', {
name: /^泥点 /,
}),
);
expect(screen.queryByText('已读取账户')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '使用详情' }));
const ledgerDialog = await screen.findByRole('dialog', {
name: '泥点账单',
});
expect(
await within(ledgerDialog).findByText('每日任务奖励'),
).not.toBeNull();
expect(
fetchSpy.mock.calls.filter(
([input]) => String(input) === '/api/profile/wallet-ledger',
),
).toHaveLength(1);
fireEvent.click(
within(ledgerDialog).getByRole('button', { name: '关闭泥点账单' }),
);
expect(screen.queryByRole('button', { name: 'Agent 聊天' })).toBeNull();
expect(
screen
.getByLabelText('GameAgent 客户端')
.querySelector('.launcher-main')
?.className.includes('launcher-main-with-promo'),
).toBe(false);
expect(screen.queryByLabelText('聊天')).toBeNull();
pickProjectFromLauncher('/tmp/authorized-game');
await waitFor(() => {
expect(screen.getByLabelText('陶泥儿项目对话')).not.toBeNull();
});
expect(screen.getByLabelText('项目开发工作台')).not.toBeNull();
expect(invoke).not.toHaveBeenCalledWith(
'open_game_creator_workspace_window',
expect.anything(),
);
expect(window.localStorage.length).toBe(1);
expect(
window.localStorage.getItem(window.localStorage.key(0) ?? ''),
).toContain('/tmp/authorized-game');
});
it('opens the shared recharge modal from the wallet and sidebar entries', async () => {
const rechargeCenter = {
walletBalance: 120,
mudPointBalance: {
totalPoints: 120,
permanentPoints: 100,
limitedPoints: 20,
limitedExpiresAt: '2026-08-01T00:00:00Z',
dailyFreePoints: 0,
dailyFreeResetPoints: 20,
dailyFreeResetsAt: '2026-07-18T00:00:00Z',
},
membership: {
status: 'inactive',
tier: 'normal',
startedAt: null,
expiresAt: null,
updatedAt: null,
cycleStartedAt: null,
cycleResetsAt: null,
cycleGrantedPoints: 0,
cycleRemainingPoints: 0,
cyclePeriodDays: 0,
},
pointProducts: [
{
productId: 'points_60',
title: '60泥点',
priceCents: 600,
kind: 'points',
pointsAmount: 60,
bonusPoints: 0,
durationDays: 0,
badgeLabel: '',
description: '',
tier: 'normal',
membershipPeriodPoints: 0,
membershipPeriodDays: 0,
membershipQueueLimit: 0,
membershipDiscountBps: 0,
},
],
membershipProducts: [],
benefits: [],
latestOrder: null,
hasPointsRecharged: true,
};
let rechargeOrderRequestCount = 0;
let rechargePaid = false;
let releaseLateRechargeOrder: (() => void) | null = null;
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/profile/recharge-center') {
return new Response(
JSON.stringify(
rechargePaid
? {
...rechargeCenter,
walletBalance: 180,
mudPointBalance: {
...rechargeCenter.mudPointBalance,
totalPoints: 180,
permanentPoints: 160,
},
}
: rechargeCenter,
),
{ status: 200 },
);
}
if (url === '/api/profile/recharge/orders') {
rechargeOrderRequestCount += 1;
if (rechargeOrderRequestCount === 2) {
await new Promise<void>((resolve) => {
releaseLateRechargeOrder = resolve;
});
}
return new Response(
JSON.stringify({
order: {
orderId: 'order-native-1',
productId: 'points_60',
productTitle: '60泥点',
kind: 'points',
amountCents: 600,
status: 'pending',
paymentChannel: 'wechat_native',
paidAt: null,
providerTransactionId: null,
createdAt: '2026-07-17T00:00:00Z',
pointsDelta: 0,
membershipExpiresAt: null,
},
center: rechargeCenter,
wechatNativePayment: {
codeUrl: 'weixin://pay.weixin.qq.com/native-test',
expiresAt: '2099-01-01T00:05:00Z',
},
}),
{ status: 200 },
);
}
if (
url === '/api/profile/recharge/orders/order-native-1/wechat/confirm'
) {
rechargePaid = true;
return new Response(
JSON.stringify({
order: {
orderId: 'order-native-1',
productId: 'points_60',
productTitle: '60泥点',
kind: 'points',
amountCents: 600,
status: 'paid',
paymentChannel: 'wechat_native',
paidAt: '2026-07-17T00:01:00Z',
providerTransactionId: 'wechat-transaction-1',
createdAt: '2026-07-17T00:00:00Z',
pointsDelta: 60,
membershipExpiresAt: null,
},
center: {
...rechargeCenter,
walletBalance: 180,
mudPointBalance: {
...rechargeCenter.mudPointBalance,
totalPoints: 180,
permanentPoints: 160,
},
},
}),
{ status: 200 },
);
}
throw new Error(`unexpected fetch ${url}`);
});
renderLauncherAt('/?launcher');
fireEvent.click(screen.getByRole('button', { name: '充值' }));
expect(
await screen.findByRole('dialog', { name: '购买更多泥点' }),
).not.toBeNull();
expect(screen.getByText('当前余额 120 泥点')).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '关闭购买更多泥点' }));
fireEvent.click(screen.getByRole('button', { name: '我的' }));
fireEvent.click(screen.getByRole('button', { name: '充值泥点' }));
expect(
await screen.findByRole('dialog', { name: '购买更多泥点' }),
).not.toBeNull();
expect(fetchSpy).toHaveBeenCalledWith(
'/api/profile/recharge-center',
expect.objectContaining({ method: 'GET' }),
);
fireEvent.click(screen.getByRole('button', { name: /60泥点.*购买/ }));
expect(
await screen.findByRole('dialog', { name: '微信扫码支付' }),
).not.toBeNull();
expect(fetchSpy).toHaveBeenCalledWith(
'/api/profile/recharge/orders',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
productId: 'points_60',
paymentChannel: 'wechat_native',
}),
}),
);
fireEvent.click(screen.getByRole('button', { name: '我已支付' }));
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: '微信扫码支付' })).toBeNull();
});
expect(screen.getByText('当前余额 180 泥点')).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: /60泥点.*购买/ }));
await waitFor(() => {
expect(rechargeOrderRequestCount).toBe(2);
});
fireEvent.click(screen.getByRole('button', { name: '关闭购买更多泥点' }));
await act(async () => {
releaseLateRechargeOrder?.();
});
fireEvent.click(screen.getByRole('button', { name: '充值' }));
expect(
await screen.findByRole('dialog', { name: '购买更多泥点' }),
).not.toBeNull();
expect(screen.queryByRole('dialog', { name: '微信扫码支付' })).toBeNull();
});
it('opens the help notice and account menu from the sidebar', () => {
renderLauncherAt('/?launcher');
fireEvent.click(screen.getByRole('button', { name: '帮助' }));
expect(screen.getByRole('dialog', { name: '使用指南' })).not.toBeNull();
expect(
screen.getByText('使用指南正在接入中,当前版本会先保留入口。'),
).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '知道了' }));
fireEvent.click(screen.getByRole('button', { name: '我的' }));
expect(screen.getByRole('menuitem', { name: '使用指南' })).not.toBeNull();
expect(screen.getByRole('menuitem', { name: '反馈联系' })).not.toBeNull();
expect(screen.getByRole('menuitem', { name: '退出登录' })).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '帮助' }));
expect(screen.queryByRole('menuitem', { name: '使用指南' })).toBeNull();
expect(screen.getByRole('dialog', { name: '使用指南' })).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '知道了' }));
fireEvent.click(screen.getByRole('button', { name: '我的' }));
fireEvent.pointerDown(screen.getByLabelText('首页输入'));
expect(screen.queryByRole('menuitem', { name: '使用指南' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '我的' }));
fireEvent.click(screen.getByRole('menuitem', { name: '使用指南' }));
expect(screen.getByRole('dialog', { name: '使用指南' })).not.toBeNull();
});
it('keeps and persists a completed streamed reply when its tail event fails', async () => {
const persistedMessages: Array<{
role: 'user' | 'assistant' | 'tool';
content: string;
agentId: string | null;
}> = [
{
role: 'assistant',
content: '历史:先收敛玩法方向。',
agentId: null,
},
];
const streamedReply = '流式正文已经完整结束。';
let streamHandler:
| ((event: { payload: Record<string, unknown> }) => void)
| null = null;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_conversation') {
return {
path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl',
agentId: args?.agentId,
messages: persistedMessages.map((message, index) => ({
schemaVersion: '1',
...message,
updatedAt: 1000 + index,
})),
};
}
if (command === 'chat_with_game_creator_role_agent_stream') {
streamHandler?.({
payload: {
projectPath: args?.projectPath,
agentId: args?.agentId,
runId: args?.runId,
status: 'delta',
deltaText: streamedReply,
accumulatedText: streamedReply,
finishReason: 'stop',
},
});
throw new Error('LLM SSE 响应缺少 choices[0]');
}
if (command === 'chat_with_game_creator_role_agent') {
throw new Error(
'completed stream must not fall back to another request',
);
}
if (command === 'append_local_conversation_message') {
const message = args?.message as {
role: 'user' | 'assistant' | 'tool';
content: string;
agentId: string | null;
};
persistedMessages.push(message);
return {
path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl',
agentId: args?.agentId,
messages: persistedMessages.map((record, index) => ({
schemaVersion: '1',
...record,
updatedAt: 2000 + index,
})),
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
const listen = vi.fn(
async (
eventName: string,
handler: (event: { payload: Record<string, unknown> }) => void,
) => {
if (eventName === 'game-creator-role-agent-chat-stream') {
streamHandler = handler;
}
return () => {
if (streamHandler === handler) {
streamHandler = null;
}
};
},
);
window.__TAURI__ = { core: { invoke }, event: { listen } };
renderLauncherAgentChatAt('/?agent-chat');
expect(screen.getByText('Agent 聊天')).not.toBeNull();
expect(screen.getAllByText('拆解创作方向').length).toBeGreaterThan(0);
fireEvent.change(screen.getByLabelText('Agent 聊天项目目录'), {
target: { value: '/tmp/authorized-game' },
});
fireEvent.click(screen.getByRole('button', { name: '读取历史' }));
expect(await screen.findByText('历史:先收敛玩法方向。')).not.toBeNull();
expect(invoke).toHaveBeenCalledWith('read_local_conversation', {
projectPath: '/tmp/authorized-game',
agentId: 'design-director',
});
fireEvent.change(screen.getByLabelText('Agent 聊天内容'), {
target: { value: '请单独评估这个角色设定流程' },
});
selectDeveloperAgentChatMode('chat');
fireEvent.click(screen.getByRole('button', { name: '发送' }));
expect(
await screen.findByText('请单独评估这个角色设定流程'),
).not.toBeNull();
expect(await screen.findByText(streamedReply)).not.toBeNull();
expect(invoke).toHaveBeenCalledWith('append_local_conversation_message', {
projectPath: '/tmp/authorized-game',
agentId: 'design-director',
message: {
role: 'user',
content: '请单独评估这个角色设定流程',
agentId: null,
},
});
expect(invoke).toHaveBeenCalledWith('append_local_conversation_message', {
projectPath: '/tmp/authorized-game',
agentId: 'design-director',
message: {
role: 'assistant',
content: streamedReply,
agentId: null,
},
});
expect(invoke).not.toHaveBeenCalledWith(
'chat_with_game_creator_role_agent',
expect.anything(),
);
expect(screen.queryByText(/已保存用户消息;Agent 回复失败/)).toBeNull();
expect(invoke).toHaveBeenCalledWith(
'chat_with_game_creator_role_agent_stream',
expect.objectContaining({
projectPath: '/tmp/authorized-game',
agentId: 'design-director',
prompt: '请单独评估这个角色设定流程',
}),
);
expect(listen).toHaveBeenCalledWith(
'game-creator-role-agent-chat-stream',
expect.any(Function),
);
});
}
export function registerHomeProjectCreationTests() {
it('refreshes recent project status before entering the Supervisor surface', async () => {
let inspectCount = 0;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
inspectCount += 1;
return {
projectPath: String(args?.projectPath ?? ''),
exists: true,
isDirectory: true,
isGameCreatorProject: true,
isGodotProject: false,
godotProjectRoot: null,
projectName:
inspectCount === 1 ? 'authorized-game' : 'authorized-game-fresh',
recentRunStatus: inspectCount === 1 ? null : 'passed',
recentRunStopReason: inspectCount === 1 ? null : 'evaluator-passed',
};
}
if (command === 'get_local_game_manifest') {
return createGameCreationAppManifest(
'local-project-draft',
'authorized-game-fresh',
);
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.localStorage.setItem(
'genarrative-ai-game-creator.recent-workspaces.v1',
JSON.stringify(['/tmp/authorized-game']),
);
window.__TAURI__ = { core: { invoke } };
renderLauncherProjectsAt('/?launcher');
expect(await screen.findByText('authorized-game')).not.toBeNull();
fireEvent.click(screen.getByText('authorized-game'));
expect(await screen.findByLabelText('陶泥儿项目对话')).not.toBeNull();
expect(screen.getByLabelText('项目开发工作台')).not.toBeNull();
expect(invoke).not.toHaveBeenCalledWith(
'open_game_creator_workspace_window',
expect.anything(),
);
expect(inspectCount).toBeGreaterThanOrEqual(2);
});
it('rejects unsafe main-window projectPath query values before Tauri calls', async () => {
const invoke = vi.fn(async () => undefined);
window.__TAURI__ = { core: { invoke } };
renderAppAt('/?main&projectPath=relative-game');
expect(await screen.findByText('请提供工作区绝对路径')).not.toBeNull();
expect(invoke).not.toHaveBeenCalled();
cleanup();
window.history.pushState({}, '', '/');
renderAppAt('/?main&projectPath=%2Ftmp%2Fbad%0Apath');
expect(
await screen.findByText('工作区路径不能包含控制字符'),
).not.toBeNull();
expect(invoke).not.toHaveBeenCalled();
});
it('does not open a missing or non-directory path from the open action', async () => {
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
const projectPath = String(args?.projectPath ?? '');
if (projectPath === '/tmp/broken-status') {
throw new Error('status failed');
}
return {
projectPath,
exists: projectPath !== '/tmp/missing-game',
isDirectory: projectPath !== '/tmp/not-a-folder',
isGameCreatorProject: projectPath === '/tmp/authorized-game',
projectName:
projectPath === '/tmp/authorized-game' ? 'authorized-game' : null,
recentRunStatus: null,
recentRunStopReason: null,
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
renderLauncherProjectsAt('/?launcher');
pickProjectFromLauncher('/tmp/missing-game');
expect(await screen.findByText('项目目录不存在')).not.toBeNull();
expect(invoke).not.toHaveBeenCalledWith(
'open_game_creator_workspace_window',
expect.anything(),
);
pickProjectFromLauncher('/tmp/not-a-folder');
expect(await screen.findByText('项目路径不是文件夹')).not.toBeNull();
expect(invoke).not.toHaveBeenCalledWith(
'open_game_creator_workspace_window',
expect.anything(),
);
pickProjectFromLauncher('/tmp/plain-folder');
expect(
await screen.findByText('这不是已初始化的 AI 游戏项目,请使用新建项目。'),
).not.toBeNull();
expect(invoke).not.toHaveBeenCalledWith(
'open_game_creator_workspace_window',
expect.anything(),
);
});
it('keeps only open and create project actions without exposing a Linux fallback', () => {
const invoke = vi.fn(async (command: string, args?: unknown) => {
if (command === 'read_game_creator_app_config') {
return { config: { selectedModelId: 'quality' } };
}
if (command === 'select_game_creator_model') {
return {
config: { selectedModelId: (args as { modelId: string }).modelId },
};
}
throw new Error(`unexpected invoke ${command}`);
});
window.__TAURI__ = { core: { invoke } };
renderLauncherProjectsAt('/?launcher');
expect(screen.queryByText('/tmp/genarrative-ai-game-draft')).toBeNull();
const projectActions = screen.getByLabelText('项目操作');
expect(within(projectActions).getAllByRole('button')).toHaveLength(2);
expect(
within(projectActions).getByRole('button', { name: '打开项目' }),
).not.toBeNull();
expect(
within(projectActions).getByRole('button', { name: '新建项目' }),
).not.toBeNull();
expect(screen.queryByRole('button', { name: '刷新' })).toBeNull();
expect(screen.queryByRole('button', { name: '清空' })).toBeNull();
expect(screen.queryByLabelText('项目目录')).toBeNull();
expect(screen.queryByRole('button', { name: '选择文件夹' })).toBeNull();
expect(
screen.queryByRole('button', { name: '在文件管理器中显示' }),
).toBeNull();
expect(screen.queryByRole('button', { name: /Godot 项目/ })).toBeNull();
// 首页模型选择器会在挂载时读取模型目录(read_game_creator_app_config),
// 这里只校验没有打开工作区窗口或其它项目操作被触发。
expect(invoke).not.toHaveBeenCalledWith(
'open_game_creator_workspace_window',
expect.anything(),
);
});
it('opens the directory selected by the native picker', async () => {
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'pick_local_project_directory') {
expect(args).toBeUndefined();
return '/tmp/picked-game';
}
if (command === 'inspect_local_project_directory') {
return {
projectPath: '/tmp/picked-game',
exists: true,
isDirectory: true,
isGameCreatorProject: false,
isGodotProject: false,
godotProjectRoot: null,
projectName: null,
recentRunStatus: null,
recentRunStopReason: null,
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
renderLauncherProjectsAt('/?launcher');
fireEvent.click(screen.getByRole('button', { name: '打开项目' }));
expect(
await screen.findByText('这不是已初始化的 AI 游戏项目,请使用新建项目。'),
).not.toBeNull();
});
it('keeps the project page open when an existing manifest cannot be loaded', async () => {
const invoke = vi.fn(async (command: string) => {
if (command === 'pick_local_project_directory') {
return '/tmp/broken-project';
}
if (command === 'inspect_local_project_directory') {
return {
projectPath: '/tmp/broken-project',
exists: true,
isDirectory: true,
isGameCreatorProject: true,
isGodotProject: false,
godotProjectRoot: null,
projectName: '损坏项目',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_manifest') {
throw new Error('manifest 校准失败');
}
throw new Error(`unexpected invoke ${command}`);
});
window.__TAURI__ = { core: { invoke } };
renderLauncherProjectsAt('/?launcher');
fireEvent.click(screen.getByRole('button', { name: '打开项目' }));
expect(await screen.findByText('manifest 校准失败')).not.toBeNull();
expect(screen.queryByLabelText('项目开发工作台')).toBeNull();
});
it('does not start a second project picker while selection is in progress', async () => {
let finishDirectoryPick: ((path: string | null) => void) | null = null;
const directoryPick = new Promise<string | null>((resolve) => {
finishDirectoryPick = resolve;
});
const invoke = vi.fn(async (command: string) => {
if (command === 'pick_local_project_directory') {
return directoryPick;
}
throw new Error(`unexpected invoke ${command}`);
});
window.__TAURI__ = { core: { invoke } };
renderLauncherProjectsAt('/?launcher');
const openButton = screen.getByRole('button', { name: '打开项目' });
fireEvent.click(openButton);
fireEvent.click(openButton);
expect(
invoke.mock.calls.filter(
([command]) => command === 'pick_local_project_directory',
),
).toHaveLength(1);
expect((openButton as HTMLButtonElement).disabled).toBe(true);
await act(async () => {
finishDirectoryPick?.(null);
await directoryPick;
});
expect(await screen.findByText('已取消')).not.toBeNull();
});
it('creates a project before handling every home message inside the project Codex conversation', async () => {
const automaticProjectPath =
'C:\\Users\\tester\\Documents\\Genarrative GameAgent\\gameagent-home-message';
const manifest = createGameCreationAppManifest(
'home-message-project',
'首页消息项目',
);
const supervisorHarness = createProjectSupervisorRuntimeHarness({
projectPath: automaticProjectPath,
initialSessionExists: false,
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'create_automatic_local_game_project') {
return {
projectPath: automaticProjectPath,
manifestPath: `${automaticProjectPath}\\.agent\\manifest.json`,
manifest,
};
}
if (command === 'chat_with_game_creator_direct_codex') {
return '别这么骂自己,具体发生什么了?';
}
return supervisorHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: supervisorHarness.listen },
};
renderLauncherAt('/?launcher', 'home', true);
const creationTypes = screen.getByRole('group', { name: '创作类型' });
const gameType = within(creationTypes).getByRole('button', {
name: '做游戏',
});
const artType = within(creationTypes).getByRole('button', {
name: '做素材',
});
const documentType = within(creationTypes).getByRole('button', {
name: '做方案',
});
expect(gameType.getAttribute('aria-pressed')).toBe('true');
expect(artType.getAttribute('aria-pressed')).toBe('false');
expect(documentType.getAttribute('aria-pressed')).toBe('false');
expect(screen.getByText('你的游戏创作管家')).not.toBeNull();
expect(screen.getAllByText('今天想把什么灵感做成游戏')).toHaveLength(1);
fireEvent.click(documentType);
expect(documentType.getAttribute('aria-pressed')).toBe('true');
expect(screen.getByText('你的游戏创作管家')).not.toBeNull();
expect(screen.getAllByText('今天有什么设计需要帮你整理')).toHaveLength(1);
// 做方案走立项策划链路,建项按钮在这一档改叫「进入立项策划」。
fireEvent.click(screen.getByRole('button', { name: '进入立项策划' }));
expect(screen.queryByText('请输入方案需求或上传资料')).toBeNull();
expect(invoke).not.toHaveBeenCalledWith(
'create_automatic_local_game_project',
);
fireEvent.click(artType);
expect(gameType.getAttribute('aria-pressed')).toBe('false');
expect(artType.getAttribute('aria-pressed')).toBe('true');
expect(screen.getByText('你的游戏创作管家')).not.toBeNull();
expect(screen.getAllByText('今天想做什么样的美术素材')).toHaveLength(1);
const promptInput = screen.getByLabelText('创作想法');
nativeClipboardMock.text = '你好,今天多少号';
fireEvent.paste(promptInput);
await waitFor(() => {
expect(promptInput.textContent).toContain('你好,今天多少号');
});
fireEvent.keyDown(promptInput, {
key: 'Enter',
code: 'Enter',
shiftKey: true,
});
expect(invoke).not.toHaveBeenCalledWith(
'create_automatic_local_game_project',
);
fireEvent.keyDown(promptInput, { key: 'Enter', code: 'Enter' });
expect(await screen.findByLabelText('项目开发工作台')).not.toBeNull();
const projectConversation = screen.getByLabelText('陶泥儿项目对话');
await waitFor(() => {
expect(projectConversation.textContent).toContain('你好,今天多少号');
expect(projectConversation.textContent).not.toContain('初始意图');
expect(projectConversation.textContent).toContain(
'别这么骂自己,具体发生什么了?',
);
});
expect(
invoke.mock.calls.filter(
([command]) => command === 'create_automatic_local_game_project',
),
).toHaveLength(1);
expect(invoke).toHaveBeenCalledWith('create_automatic_local_game_project', {
name: null,
});
expect(invoke).toHaveBeenCalledWith('chat_with_game_creator_direct_codex', {
projectPath: automaticProjectPath,
prompt: '你好,今天多少号',
creationType: 'art',
clientTurnId: expect.any(String),
});
expect(invoke).not.toHaveBeenCalledWith(
'chat_with_game_creator_home_direct_codex',
expect.anything(),
);
expect(screen.queryByLabelText('陶泥儿首页对话')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '首页' }));
const resetTypes = screen.getByRole('group', { name: '创作类型' });
expect(
within(resetTypes)
.getByRole('button', { name: '做游戏' })
.getAttribute('aria-pressed'),
).toBe('true');
});
it('imports home attachments into the automatic project before the project Codex turn', async () => {
const automaticProjectPath =
'C:\\Users\\tester\\Documents\\Genarrative GameAgent\\gameagent-home-attachment';
const manifest = createGameCreationAppManifest(
'home-attachment-project',
'首页附件项目',
);
const supervisorHarness = createProjectSupervisorRuntimeHarness({
projectPath: automaticProjectPath,
initialSessionExists: false,
});
const fileBytes = Array.from(new TextEncoder().encode('png'));
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'create_automatic_local_game_project') {
return {
projectPath: automaticProjectPath,
manifestPath: `${automaticProjectPath}\\.agent\\manifest.json`,
manifest,
};
}
if (command === 'upload_local_asset') {
return {
id: 'asset-upload-1',
localPath: 'assets/uploads/reference.png',
absolutePath: `${automaticProjectPath}\\assets\\uploads\\reference.png`,
manifestPath: `${automaticProjectPath}\\.agent\\manifest.json`,
};
}
if (command === 'suggest_automatic_project_name') {
expect(args).toEqual({
prompt: '按这个角色做游戏',
});
return '角色参考游戏';
}
if (command === 'chat_with_game_creator_direct_codex') {
return '附件已经进入当前项目。';
}
return supervisorHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: supervisorHarness.listen },
};
renderLauncherAt('/?launcher', 'home', true);
const fileInput =
document.querySelector<HTMLInputElement>('input[type="file"]');
expect(fileInput).not.toBeNull();
const attachment = new File(['png'], '角色参考.png', {
type: 'image/png',
lastModified: 1,
});
Object.defineProperty(attachment, 'arrayBuffer', {
value: async () => new Uint8Array(fileBytes).buffer,
});
fireEvent.change(fileInput!, { target: { files: [attachment] } });
const promptInput = screen.getByLabelText('创作想法');
nativeClipboardMock.text = '按这个角色做游戏';
fireEvent.paste(promptInput);
await waitFor(() => {
expect(promptInput.textContent).toContain('按这个角色做游戏');
expect(promptInput.textContent).toContain('角色参考.png');
});
fireEvent.click(screen.getByRole('button', { name: '开启创作' }));
expect(await screen.findByLabelText('项目开发工作台')).not.toBeNull();
expect(invoke).toHaveBeenCalledWith('create_automatic_local_game_project', {
name: '角色参考游戏',
});
expect(invoke).toHaveBeenCalledWith('upload_local_asset', {
projectPath: automaticProjectPath,
fileName: '角色参考.png',
mediaType: 'image/png',
bytes: fileBytes,
});
expect(invoke).toHaveBeenCalledWith('chat_with_game_creator_direct_codex', {
projectPath: automaticProjectPath,
prompt: '按这个角色做游戏',
creationType: 'game',
clientTurnId: expect.any(String),
attachments: [
{
name: '角色参考.png',
mediaType: 'image/png',
size: attachment.size,
localPath: 'assets/uploads/reference.png',
status: 'imported',
},
],
});
expect(invoke).not.toHaveBeenCalledWith(
'chat_with_game_creator_home_direct_codex',
expect.anything(),
);
expect(await screen.findByText('附件已经进入当前项目。')).not.toBeNull();
const followUpInput = screen.getByLabelText('陶泥儿对话内容');
await setComposerText(followUpInput, '再补一句玩法');
fireEvent.submit(followUpInput.closest('form') as HTMLFormElement);
await waitFor(() => {
expect(
invoke.mock.calls.filter(
([command]) => command === 'chat_with_game_creator_direct_codex',
),
).toHaveLength(2);
});
expect(invoke).toHaveBeenCalledWith('chat_with_game_creator_direct_codex', {
projectPath: automaticProjectPath,
prompt: '再补一句玩法',
clientTurnId: expect.any(String),
});
const followUpPayload = invoke.mock.calls.find(
([command, args]) =>
command === 'chat_with_game_creator_direct_codex' &&
(args as Record<string, unknown> | undefined)?.prompt ===
'再补一句玩法',
)?.[1] as Record<string, unknown> | undefined;
expect(followUpPayload).not.toHaveProperty('attachments');
});
it('starts an automatic game project from the approved GDD', async () => {
const automaticProjectPath = '/tmp/approved-gdd-game';
const manifest = createGameCreationAppManifest(
'approved-gdd-game',
'已批准 GDD 游戏',
);
const gddContent = '# Fast GDD\n\n批准后的方案内容';
const invoke = vi.fn(async (command: string) => {
if (command === 'read_local_project_file') {
return {
path: 'game/fast_gdd.md',
absolutePath: '/tmp/planning-project/game/fast_gdd.md',
content: gddContent,
};
}
if (command === 'create_automatic_local_game_project') {
return {
projectPath: automaticProjectPath,
manifestPath: `${automaticProjectPath}/.agent/manifest.json`,
manifest,
};
}
if (command === 'upload_local_asset') {
return {
id: 'approved-gdd-asset',
localPath: 'assets/uploads/fast_gdd.md',
absolutePath: `${automaticProjectPath}/assets/uploads/fast_gdd.md`,
manifestPath: `${automaticProjectPath}/.agent/manifest.json`,
};
}
throw new Error(`unexpected invoke ${command}`);
});
window.__TAURI__ = { core: { invoke } };
render(React.createElement(ApprovedGddStartHarness));
fireEvent.click(
screen.getByRole('button', { name: '直接用已批准 GDD 开始建造' }),
);
fireEvent.click(
screen.getByRole('button', { name: '直接用已批准 GDD 开始建造' }),
);
await waitFor(() => {
expect(
invoke.mock.calls.filter(
([command]) => command === 'create_automatic_local_game_project',
),
).toHaveLength(1);
});
expect(screen.getByLabelText('已进入自动游戏项目').textContent).toContain(
'请按照附件中的已批准 GDD 开始建造这款游戏。',
);
expect(invoke).toHaveBeenCalledWith('read_local_project_file', {
projectPath: '/tmp/planning-project',
relativePath: 'game/fast_gdd.md',
commandId: 'file.read',
});
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith('upload_local_asset', {
projectPath: automaticProjectPath,
fileName: 'fast_gdd.md',
mediaType: 'text/markdown',
bytes: Array.from(new TextEncoder().encode(gddContent)),
});
});
});
it('keeps the home composer out of chat mode while automatic project creation is pending', async () => {
let rejectAutomaticProject: ((error: Error) => void) | null = null;
const automaticProject = new Promise<never>((_resolve, reject) => {
rejectAutomaticProject = reject;
});
const invoke = vi.fn(async (command: string) => {
if (command === 'create_automatic_local_game_project') {
return automaticProject;
}
throw new Error(`unexpected invoke ${command}`);
});
window.__TAURI__ = { core: { invoke } };
renderLauncherAt('/?launcher', 'home', true);
const promptInput = screen.getByLabelText('创作想法');
nativeClipboardMock.text = '做一个三消游戏';
fireEvent.paste(promptInput);
await waitFor(() => {
expect(promptInput.textContent).toContain('做一个三消游戏');
});
const createButton = screen.getByRole('button', { name: '开启创作' });
fireEvent.click(createButton);
fireEvent.click(createButton);
await waitFor(() => {
expect(
invoke.mock.calls.filter(
([command]) => command === 'create_automatic_local_game_project',
),
).toHaveLength(1);
});
expect((createButton as HTMLButtonElement).disabled).toBe(true);
expect(screen.queryByLabelText('陶泥儿首页对话')).toBeNull();
expect(screen.getByLabelText('最近项目').textContent).not.toContain(
'选择一个项目继续创作',
);
expect(screen.getByLabelText('最近项目').textContent).not.toContain(
'正在创建工作区',
);
expect(screen.queryByText('正在创建工作区')).toBeNull();
await act(async () => {
rejectAutomaticProject?.(new Error('自动创建测试结束'));
await automaticProject.catch(() => undefined);
});
await waitFor(() => {
expect((createButton as HTMLButtonElement).disabled).toBe(false);
});
expect(screen.queryByText('自动创建测试结束')).toBeNull();
});
it.each([
['做方案', false],
['做方案', true],
] as const)(
'routes %s %s creation to Planning Session V2',
async (modeLabel, automatic) => {
const projectPath = `/tmp/home-${modeLabel}-${automatic ? 'enter' : 'submit'}`;
const manifest = createGameCreationAppManifest(
'local-project-draft',
'home-entry-route',
);
const supervisorHarness = createProjectSupervisorRuntimeHarness({
projectPath,
planningV2StartResult: emptyPlanningV2StartResult(),
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'pick_local_project_directory') {
return projectPath;
}
if (command === 'is_local_project_directory_non_empty') {
return false;
}
if (command === 'create_automatic_local_game_project') {
return {
projectPath,
manifestPath: `${projectPath}/.agent/manifest.json`,
manifest,
};
}
return supervisorHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: supervisorHarness.listen },
};
renderLauncherAt('/?launcher', 'home', true);
if (modeLabel === '做方案') {
fireEvent.click(screen.getByRole('button', { name: modeLabel }));
}
const promptInput = screen.getByLabelText('创作想法');
nativeClipboardMock.text = '整理一个可玩原型';
fireEvent.paste(promptInput);
await waitFor(() => {
expect(promptInput.textContent).toContain('整理一个可玩原型');
});
if (automatic) {
fireEvent.keyDown(promptInput, { key: 'Enter', code: 'Enter' });
} else {
fireEvent.click(
screen.getByRole('button', {
name: '进入立项策划',
}),
);
}
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
'start_planning_session_v2',
expect.objectContaining({
projectPath,
mode: 'gdd',
}),
);
});
const startCall = invoke.mock.calls.find(
([command]) => command === 'start_planning_session_v2',
);
expect(startCall?.[1]).not.toHaveProperty('attachments');
expect(JSON.stringify(startCall?.[1] ?? {})).not.toContain(
'本轮用户附件',
);
expect(invoke).not.toHaveBeenCalledWith(
'chat_with_game_creator_direct_codex',
expect.anything(),
);
expect(invoke).not.toHaveBeenCalledWith(
'chat_with_game_creator_agent',
expect.anything(),
);
expect(invoke).not.toHaveBeenCalledWith(
'suggest_automatic_project_name',
expect.anything(),
);
expect(
invoke.mock.calls.filter(
([command]) => command === 'create_automatic_local_game_project',
),
).toHaveLength(1);
},
);
it('keeps 做方案 first turn on Supervisor without a Direct attachment sidecar', async () => {
const projectPath = '/tmp/home-planning-attachment';
const manifest = createGameCreationAppManifest(
'home-planning-attachment',
'首页策划附件',
);
const supervisorHarness = createProjectSupervisorRuntimeHarness({
projectPath,
planningV2StartResult: emptyPlanningV2StartResult(
'home-planning-attachment',
),
});
const fileBytes = Array.from(new TextEncoder().encode('png'));
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'create_automatic_local_game_project') {
return {
projectPath,
manifestPath: `${projectPath}/.agent/manifest.json`,
manifest,
};
}
if (command === 'upload_local_asset') {
return {
id: 'asset-upload-plan-1',
localPath: 'assets/uploads/reference.png',
absolutePath: `${projectPath}/assets/uploads/reference.png`,
manifestPath: `${projectPath}/.agent/manifest.json`,
};
}
return supervisorHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: supervisorHarness.listen },
};
renderLauncherAt('/?launcher', 'home', true);
fireEvent.click(screen.getByRole('button', { name: '做方案' }));
const fileInput =
document.querySelector<HTMLInputElement>('input[type="file"]');
expect(fileInput).not.toBeNull();
const attachment = new File(['png'], '角色参考.png', {
type: 'image/png',
lastModified: 1,
});
Object.defineProperty(attachment, 'arrayBuffer', {
value: async () => new Uint8Array(fileBytes).buffer,
});
fireEvent.change(fileInput!, { target: { files: [attachment] } });
const promptInput = screen.getByLabelText('创作想法');
nativeClipboardMock.text = '整理一份可玩原型';
fireEvent.paste(promptInput);
await waitFor(() => {
expect(promptInput.textContent).toContain('整理一份可玩原型');
expect(promptInput.textContent).toContain('角色参考.png');
});
fireEvent.click(screen.getByRole('button', { name: '进入立项策划' }));
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
'start_planning_session_v2',
expect.objectContaining({
projectPath,
mode: 'gdd',
}),
);
});
expect(invoke).toHaveBeenCalledWith('upload_local_asset', {
projectPath,
fileName: '角色参考.png',
mediaType: 'image/png',
bytes: fileBytes,
});
const startCall = invoke.mock.calls.find(
([command]) => command === 'start_planning_session_v2',
);
expect(startCall?.[1]).not.toHaveProperty('attachments');
expect(JSON.stringify(startCall?.[1] ?? {})).not.toContain('本轮用户附件');
expect(JSON.stringify(startCall?.[1] ?? {})).not.toContain(
'assets/uploads/reference.png',
);
expect(invoke).not.toHaveBeenCalledWith(
'chat_with_game_creator_direct_codex',
expect.anything(),
);
expect(invoke).not.toHaveBeenCalledWith(
'chat_with_game_creator_agent',
expect.anything(),
);
});
it('surfaces the planning clarification card after 做方案 creates the project from home', async () => {
// 上面那条只断言到「run 起来了、source 对」。真实故障恰好落在它之后:plan 根 run
// 停在 waiting-for-user-input 并带回澄清请求,而工作台一直停在前端本地的占位文案,
// 澄清卡永远不出现。策划链路现有用例全部走「打开已有项目 + 直接注入 initialRuntime」,
// 正好绕开首页自动建项目这条路,所以这个缺口一直没人守。
const projectPath = '/tmp/home-planning-clarification';
const manifest = createGameCreationAppManifest(
'local-project-draft',
'home-planning-clarification',
);
const supervisorHarness = createProjectSupervisorRuntimeHarness({
projectPath,
expectedRunProfile: 'standard',
});
supervisorHarness.setPlanningV2StartResult({
session: {
schemaVersion: 'planning-session.v2',
engine: 'planning-session-v2',
sessionId: 'home-planning-v2-session',
projectId: 'local-project-draft',
mode: 'gdd',
status: 'awaiting_user',
turnIndex: 1,
questionCount: 1,
questionLimit: 8,
revisionCount: 0,
currentArtifactVersion: null,
currentQuestion: {
id: 'visual_direction',
header: '当前要决定:首版美术方向',
question: '首版角色规范图采用哪种美术方向?',
options: [
{ label: '像素', description: '低成本像素风。' },
{ label: '扁平', description: '清晰的扁平插画风。' },
],
},
capabilities: { tools: [], skills: [] },
processingSeconds: 1,
createdAtUtc: '2026-09-03T00:00:00Z',
updatedAtUtc: '2026-09-03T00:00:01Z',
lastError: null,
},
result: {
schemaVersion: 'planning-turn-result.v2',
kind: 'question',
payload: {
question: {
id: 'visual_direction',
header: '当前要决定:首版美术方向',
question: '首版角色规范图采用哪种美术方向?',
options: [
{ label: '像素', description: '低成本像素风。' },
{ label: '扁平', description: '清晰的扁平插画风。' },
],
},
},
},
currentArtifact: null,
replayed: false,
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'pick_local_project_directory') {
return projectPath;
}
if (command === 'is_local_project_directory_non_empty') {
return false;
}
if (command === 'create_automatic_local_game_project') {
return {
projectPath,
manifestPath: `${projectPath}/.agent/manifest.json`,
manifest,
};
}
return supervisorHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: supervisorHarness.listen },
};
renderLauncherAt('/?launcher', 'home', true);
fireEvent.click(screen.getByRole('button', { name: '做方案' }));
const promptInput = screen.getByLabelText('创作想法');
nativeClipboardMock.text = '2D射击游戏';
fireEvent.paste(promptInput);
await waitFor(() => {
expect(promptInput.textContent).toContain('2D射击游戏');
});
fireEvent.keyDown(promptInput, { key: 'Enter', code: 'Enter' });
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
'start_planning_session_v2',
expect.objectContaining({ prompt: '2D射击游戏', mode: 'gdd' }),
);
});
const strip = await screen.findByLabelText('立项策划运行状态');
expect(
within(strip).getByText('首版角色规范图采用哪种美术方向?'),
).not.toBeNull();
});
it('refreshes Direct Codex art commits while the turn is still running and after a later failure', async () => {
const projectPath =
'C:\\Users\\tester\\Documents\\Genarrative GameAgent\\live-direct-art';
const manifest = createGameCreationAppManifest(
'live-direct-art',
'直连美术实时刷新',
);
let currentManifest = manifest;
let rejectDirectTurn!: (reason?: unknown) => void;
const directTurn = new Promise<string>((_resolve, reject) => {
rejectDirectTurn = reject;
});
const runtimeHarness = createProjectSupervisorRuntimeHarness({
projectPath,
initialSessionExists: false,
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'create_automatic_local_game_project') {
return {
projectPath,
manifestPath: `${projectPath}\\.agent\\manifest.json`,
manifest,
};
}
if (command === 'get_local_game_manifest') {
expect(args).toEqual({ projectPath });
return currentManifest;
}
if (command === 'chat_with_game_creator_direct_codex') {
return directTurn;
}
return runtimeHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: runtimeHarness.listen },
};
renderLauncherAt('/?launcher', 'home', true);
const promptInput = screen.getByLabelText('创作想法');
nativeClipboardMock.text = '生成一个带三张美术素材的横版游戏';
fireEvent.paste(promptInput);
await waitFor(() => {
expect(promptInput.textContent).toContain(
'生成一个带三张美术素材的横版游戏',
);
});
fireEvent.click(screen.getByRole('button', { name: '开启创作' }));
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
'chat_with_game_creator_direct_codex',
expect.objectContaining({ projectPath }),
);
expect(runtimeHarness.listen).toHaveBeenCalledWith(
'game-creator-manifest-invalidated',
expect.any(Function),
);
});
const committedAssets = [
{
id: 'direct-art-spec',
kind: 'icon-spec',
mediaType: 'image/png',
localPath: 'assets/art-spec.png',
source: {
kind: 'generated' as const,
taskId: 'direct-codex-art-art-spec',
},
},
{
id: 'direct-game-background',
kind: 'game-background',
mediaType: 'image/png',
localPath: 'assets/direct-game-background.png',
source: {
kind: 'generated' as const,
taskId: 'direct-codex-art-game-background',
},
},
{
id: 'direct-art-spritesheet',
kind: 'art-spritesheet',
mediaType: 'image/png',
localPath: 'assets/art-spritesheet.png',
source: {
kind: 'generated' as const,
taskId: 'direct-codex-art-art-spritesheet',
},
},
];
for (let index = 0; index < committedAssets.length; index += 1) {
const refreshCountBeforeEvent = invoke.mock.calls.filter(
([command]) => command === 'get_local_game_manifest',
).length;
currentManifest = {
...currentManifest,
assets: committedAssets.slice(0, index + 1),
};
act(() => {
runtimeHarness.emitManifestInvalidated('direct-codex-art');
});
await waitFor(() => {
expect(
invoke.mock.calls.filter(
([command]) => command === 'get_local_game_manifest',
).length,
).toBeGreaterThan(refreshCountBeforeEvent);
});
expect(
invoke.mock.calls.filter(
([command]) => command === 'chat_with_game_creator_direct_codex',
),
).toHaveLength(1);
}
const refreshCountBeforeFailure = invoke.mock.calls.filter(
([command]) => command === 'get_local_game_manifest',
).length;
await act(async () => {
rejectDirectTurn(new Error('代码生成等待超时'));
await directTurn.catch(() => undefined);
});
await waitFor(() => {
expect(
invoke.mock.calls.filter(
([command]) => command === 'get_local_game_manifest',
).length,
).toBeGreaterThan(refreshCountBeforeFailure);
});
expect(currentManifest.assets).toEqual(committedAssets);
expect(
invoke.mock.calls.filter(
([command]) => command === 'start_local_game_preview',
),
).toHaveLength(0);
});
it('hydrates an existing project before sending a direct Codex turn', async () => {
const projectPath =
'C:\\Users\\tester\\Documents\\Genarrative GameAgent\\existing-project';
const manifest = createGameCreationAppManifest(
'existing-project',
'已有项目',
);
const persistedMessages: Array<Record<string, unknown>> = [];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'get_local_game_manifest') {
expect(args).toEqual({ projectPath });
return manifest;
}
if (command === 'append_local_permission_log') {
return {};
}
if (command === 'hydrate_game_creator_plan_gdd_state') {
return null;
}
if (command === 'read_project_permission_policy') {
return {
path: '.agent/policy.json',
policy: { deniedCommands: [], confirmCommands: [] },
};
}
if (
command === 'read_local_conversation' ||
command === 'read_direct_project_conversation'
) {
return {
path: `${projectPath}/.agent/conversations/project.jsonl`,
agentId: null,
sessionId: null,
messages: [...persistedMessages],
};
}
if (command === 'append_local_conversation_message') {
throw new Error(
'DirectProject must not use browser conversation writer',
);
}
if (command === 'chat_with_game_creator_direct_codex') {
const clientTurnId = String(args?.clientTurnId ?? '');
persistedMessages.push(
{
role: 'user',
content: String(args?.prompt ?? ''),
messageId: `direct-codex:${clientTurnId}:user`,
},
{
role: 'assistant',
content: 'DIRECT_EXISTING_PROJECT_OK',
messageId: `direct-codex:${clientTurnId}:assistant`,
},
);
return 'DIRECT_EXISTING_PROJECT_OK';
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
render(
React.createElement(App, {
initialProjectPath: projectPath,
initialProjectManifest: manifest,
projectSupervisorOnly: true,
initialSupervisorMessage: '继续修改已有项目',
}),
);
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
'chat_with_game_creator_direct_codex',
{
projectPath,
prompt: '继续修改已有项目',
clientTurnId: expect.any(String),
},
);
});
const directTurnCall = invoke.mock.calls.findIndex(
([command]) => command === 'chat_with_game_creator_direct_codex',
);
const directTurnArgs = invoke.mock.calls[directTurnCall]?.[1] as
| Record<string, unknown>
| undefined;
expect(directTurnCall).toBeGreaterThanOrEqual(0);
expect(invoke).not.toHaveBeenCalledWith(
'append_local_conversation_message',
expect.anything(),
);
expect(directTurnArgs?.clientTurnId).toEqual(expect.any(String));
expect(invoke).not.toHaveBeenCalledWith(
'create_automatic_local_game_project',
);
expect(invoke).not.toHaveBeenCalledWith(
'start_game_creator_supervisor_runtime_task',
expect.anything(),
);
});
it('shows a readable reason when the direct Codex turn is rejected', async () => {
const projectPath =
'C:\\Users\\tester\\Documents\\Genarrative GameAgent\\failed-direct-project';
const manifest = createGameCreationAppManifest(
'failed-direct-project',
'直连失败项目',
);
const persistedMessages: Array<Record<string, unknown>> = [];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'get_local_game_manifest') {
expect(args).toEqual({ projectPath });
return manifest;
}
if (
command === 'read_local_conversation' ||
command === 'read_direct_project_conversation'
) {
return {
path: `${projectPath}/.agent/conversations/project.jsonl`,
agentId: null,
sessionId: null,
messages: [...persistedMessages],
};
}
if (command === 'append_local_permission_log') {
return {};
}
if (command === 'read_project_permission_policy') {
return {
path: '.agent/policy.json',
policy: { deniedCommands: [], confirmCommands: [] },
};
}
if (command === 'append_local_conversation_message') {
const message = args?.message as Record<string, unknown>;
const messageId = String(args?.messageId ?? '');
if (
!messageId ||
!persistedMessages.some(
(candidate) => candidate.messageId === messageId,
)
) {
persistedMessages.push({
schemaVersion: 'game-creator-conversation.v1',
...message,
messageId,
updatedAt: Number(
message.updatedAt ?? persistedMessages.length + 1,
),
});
}
return {
path: `${projectPath}/.agent/conversations/project.jsonl`,
agentId: null,
sessionId: null,
messages: [...persistedMessages],
};
}
if (command === 'chat_with_game_creator_direct_codex') {
const clientTurnId = String(args?.clientTurnId ?? '');
persistedMessages.push(
{
schemaVersion: 'game-creator-conversation.v1',
role: 'user',
content: String(args?.prompt ?? ''),
messageId: `direct-codex:${clientTurnId}:user`,
updatedAt: 1,
},
{
schemaVersion: 'game-creator-conversation.v1',
role: 'assistant',
content: '陶泥儿智能创作 鉴权失败,请重新登录后重试',
messageId: `direct-codex:${clientTurnId}:assistant`,
updatedAt: 2,
},
);
throw new Error('codex-app-server-error:unauthorized');
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
render(
React.createElement(App, {
initialProjectPath: projectPath,
initialProjectManifest: manifest,
projectSupervisorOnly: true,
initialSupervisorMessage: '生成一个游戏',
}),
);
expect(
await screen.findByText('陶泥儿智能创作 鉴权失败,请重新登录后重试'),
).not.toBeNull();
await waitFor(() => {
expect(persistedMessages).toHaveLength(2);
});
expect(persistedMessages).toEqual(
expect.arrayContaining([
expect.objectContaining({ role: 'user', content: '生成一个游戏' }),
expect.objectContaining({
role: 'assistant',
content: '陶泥儿智能创作 鉴权失败,请重新登录后重试',
}),
]),
);
expect(JSON.stringify(persistedMessages)).not.toContain(
'codex-app-server-error:unauthorized',
);
cleanup();
render(
React.createElement(App, {
initialProjectPath: projectPath,
initialProjectManifest: manifest,
projectSupervisorOnly: true,
}),
);
expect(await screen.findByText('生成一个游戏')).not.toBeNull();
expect(
await screen.findByText('陶泥儿智能创作 鉴权失败,请重新登录后重试'),
).not.toBeNull();
expect(
invoke.mock.calls.filter(
([command]) => command === 'chat_with_game_creator_direct_codex',
),
).toHaveLength(1);
});
}
export function registerRecentProjectsTests() {
function openProjectMoreMenu(projectName: string) {
fireEvent.click(
screen.getByRole('button', { name: `${projectName}的更多操作` }),
);
return screen.getByRole('menu');
}
it('renders and filters a compact project table with GameAgent and Godot metadata', async () => {
const paths = [
'C:\\Projects\\Skyrail',
'C:\\Projects\\RootGodot',
'C:\\Projects\\StudioWorkspace',
];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
const projectPath = String(args?.projectPath ?? '');
if (projectPath === paths[0]) {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
isGodotProject: false,
godotProjectRoot: null,
projectName: '星轨工坊',
recentRunStatus: 'done',
recentRunStopReason: 'preview-running',
};
}
if (projectPath === paths[1]) {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: false,
isGodotProject: true,
godotProjectRoot: '.',
projectName: null,
recentRunStatus: null,
recentRunStopReason: null,
};
}
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
isGodotProject: true,
godotProjectRoot: 'engine',
projectName: '云岛工作区',
recentRunStatus: 'failed',
recentRunStopReason: null,
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
window.localStorage.setItem(
'genarrative-ai-game-creator.recent-workspaces.v1',
JSON.stringify(paths),
);
renderLauncherProjectsAt('/?launcher');
expect(await screen.findByText('星轨工坊')).not.toBeNull();
expect(screen.getByRole('heading', { name: '项目' })).not.toBeNull();
expect(screen.getByLabelText('项目列表')).not.toBeNull();
expect(screen.getByText('GameAgent')).not.toBeNull();
expect(screen.getByText('Godot')).not.toBeNull();
expect(screen.getByText('Godot · engine')).not.toBeNull();
expect(screen.getByText('已完成 · 预览运行中')).not.toBeNull();
expect(screen.getByText('运行失败')).not.toBeNull();
expect(
(
screen.getByRole('button', {
name: '打开项目 RootGodot',
}) as HTMLButtonElement
).disabled,
).toBe(false);
const search = screen.getByRole('searchbox', { name: '搜索项目' });
fireEvent.change(search, { target: { value: 'godot' } });
expect(screen.queryByText('星轨工坊')).toBeNull();
expect(screen.getByText('RootGodot')).not.toBeNull();
expect(screen.getByText('云岛工作区')).not.toBeNull();
fireEvent.change(search, { target: { value: 'engine' } });
expect(screen.queryByText('RootGodot')).toBeNull();
expect(screen.getByText('云岛工作区')).not.toBeNull();
fireEvent.change(search, { target: { value: '不存在的项目' } });
expect(screen.getByText('没有匹配的项目')).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '清除搜索' }));
expect(screen.getByText('星轨工坊')).not.toBeNull();
expect(screen.getByText('RootGodot')).not.toBeNull();
});
it('removes recent launcher projects without opening them', () => {
const invoke = vi.fn(async () => undefined);
window.__TAURI__ = { core: { invoke } };
window.localStorage.setItem(
'genarrative-ai-game-creator.recent-workspaces.v1',
JSON.stringify([
'/tmp/recent-one',
' /tmp/recent-one ',
' ',
42,
'relative-game',
'/tmp/recent-two',
]),
);
renderLauncherAt('/?launcher');
expect(screen.getByLabelText('最近项目')).not.toBeNull();
expect(screen.getByText('暂无最近项目')).not.toBeNull();
expect(
screen.queryByRole('button', { name: 'recent-one的更多操作' }),
).toBeNull();
expect(screen.queryByRole('button', { name: '刷新' })).toBeNull();
expect(screen.queryByRole('button', { name: '清空' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '项目组' }));
expect(screen.getAllByText('/tmp/recent-one')).toHaveLength(1);
expect(screen.getByText('/tmp/recent-two')).not.toBeNull();
expect(screen.queryByText('relative-game')).toBeNull();
expect(invoke).not.toHaveBeenCalledWith('inspect_local_project_directory', {
projectPath: 'relative-game',
});
fireEvent.click(
within(openProjectMoreMenu('recent-one')).getByRole('menuitem', {
name: '从列表移除',
}),
);
expect(screen.queryByText('/tmp/recent-one')).toBeNull();
expect(screen.getByText('/tmp/recent-two')).not.toBeNull();
expect(invoke).not.toHaveBeenCalledWith(
'open_game_creator_workspace_window',
expect.anything(),
);
fireEvent.click(
within(openProjectMoreMenu('recent-two')).getByRole('menuitem', {
name: '从列表移除',
}),
);
expect(screen.getByText('暂无项目')).not.toBeNull();
expect(window.localStorage.length).toBe(0);
});
it('renames a recent project and refreshes the inspected manifest name', async () => {
const projectPath = '/tmp/rename-game';
const renamedManifest = createGameCreationAppManifest(
'rename-game',
'星轨夜航',
);
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
const renamed =
invoke.mock.calls.filter(
([candidate]) => candidate === 'rename_local_game_project',
).length > 0;
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: renamed ? '星轨夜航' : '旧项目名',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'rename_local_game_project') {
expect(args).toEqual({
projectPath,
name: '星轨夜航',
});
return { manifest: renamedManifest, revision: 3 };
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
window.localStorage.setItem(
'genarrative-ai-game-creator.recent-workspaces.v1',
JSON.stringify([projectPath]),
);
renderLauncherProjectsAt('/?launcher');
expect(await screen.findByText('旧项目名')).not.toBeNull();
fireEvent.click(
within(openProjectMoreMenu('旧项目名')).getByRole('menuitem', {
name: '重命名',
}),
);
const nameInput = screen.getByLabelText('项目名称');
fireEvent.change(nameInput, { target: { value: '星轨夜航' } });
fireEvent.click(screen.getByRole('button', { name: '保存' }));
expect(await screen.findByText('星轨夜航')).not.toBeNull();
expect(screen.queryByText('旧项目名')).toBeNull();
expect(invoke).toHaveBeenCalledWith('rename_local_game_project', {
projectPath,
name: '星轨夜航',
});
});
it('keeps rename editing open when the native command fails', async () => {
const projectPath = '/tmp/rename-failed-game';
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: '原项目',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'rename_local_game_project') {
expect(args?.name).toBe('失败后的名称');
throw new Error('manifest 写入失败');
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
window.localStorage.setItem(
'genarrative-ai-game-creator.recent-workspaces.v1',
JSON.stringify([projectPath]),
);
renderLauncherProjectsAt('/?launcher');
expect(await screen.findByText('原项目')).not.toBeNull();
fireEvent.click(
within(openProjectMoreMenu('原项目')).getByRole('menuitem', {
name: '重命名',
}),
);
fireEvent.change(screen.getByLabelText('项目名称'), {
target: { value: '失败后的名称' },
});
fireEvent.click(screen.getByRole('button', { name: '保存' }));
expect(await screen.findByRole('alert')).not.toBeNull();
expect(screen.getByRole('alert').textContent).toBe('manifest 写入失败');
expect((screen.getByLabelText('项目名称') as HTMLInputElement).value).toBe(
'失败后的名称',
);
});
it('opens a recent launcher project directory in the system file manager', async () => {
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
return {
projectPath: String(args?.projectPath ?? ''),
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: '最近的项目',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'open_local_project_directory') {
return undefined;
}
if (command === 'open_game_creator_workspace_window') {
return undefined;
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
window.localStorage.setItem(
'genarrative-ai-game-creator.recent-workspaces.v1',
JSON.stringify(['/tmp/recent-one']),
);
renderLauncherAt('/?launcher');
expect(await screen.findByText('最近的项目')).not.toBeNull();
expect(
screen.queryByRole('button', { name: '最近的项目的更多操作' }),
).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '项目组' }));
fireEvent.click(
within(openProjectMoreMenu('最近的项目')).getByRole('menuitem', {
name: '显示目录',
}),
);
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith('open_local_project_directory', {
projectPath: '/tmp/recent-one',
});
});
expect(invoke).not.toHaveBeenCalledWith(
'open_game_creator_workspace_window',
expect.anything(),
);
expect(await screen.findByText('已打开项目目录')).not.toBeNull();
expect(window.localStorage.length).toBe(1);
});
it('does not remember a launcher project when project inspection fails', async () => {
const invoke = vi.fn(async (command: string) => {
if (command === 'inspect_local_project_directory') {
throw new Error('inspect failed');
}
throw new Error(`unexpected invoke ${command}`);
});
window.__TAURI__ = { core: { invoke } };
renderLauncherProjectsAt('/?launcher');
pickProjectFromLauncher('/tmp/open-failed-game');
expect(await screen.findByText('inspect failed')).not.toBeNull();
expect(window.localStorage.length).toBe(0);
});
it('marks missing recent launcher projects and does not reopen them', async () => {
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
const projectPath = String(args?.projectPath ?? '');
if (projectPath === '/tmp/broken-status') {
throw new Error('status failed');
}
return {
projectPath,
exists: projectPath !== '/tmp/missing-game',
isDirectory: projectPath !== '/tmp/not-a-folder',
isGameCreatorProject: projectPath === '/tmp/ok-game',
isGodotProject: false,
godotProjectRoot: null,
projectName: projectPath === '/tmp/ok-game' ? '厨房突围' : null,
manifestError:
projectPath === '/tmp/broken-manifest'
? '解析 manifest 失败'
: null,
recentRunStatus: projectPath === '/tmp/ok-game' ? 'done' : null,
recentRunStopReason:
projectPath === '/tmp/ok-game' ? 'preview-running' : null,
};
}
if (command === 'get_local_game_manifest') {
return createGameCreationAppManifest(
'local-project-draft',
'厨房突围',
);
}
if (command === 'open_game_creator_workspace_window') {
return undefined;
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
window.localStorage.setItem(
'genarrative-ai-game-creator.recent-workspaces.v1',
JSON.stringify([
'/tmp/missing-game',
'/tmp/not-a-folder',
'/tmp/plain-folder',
'/tmp/broken-manifest',
'/tmp/broken-status',
'/tmp/ok-game',
]),
);
renderLauncherAt('/?launcher');
expect(await screen.findByText('厨房突围')).not.toBeNull();
expect(
within(screen.getByLabelText('最近项目')).getByText('厨房突围'),
).not.toBeNull();
expect(
within(screen.getByLabelText('最近项目')).queryByText('missing-game'),
).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '项目组' }));
expect(screen.getByText('未找到')).not.toBeNull();
expect(screen.getByText('不是文件夹')).not.toBeNull();
expect(screen.getAllByText('未初始化').length).toBeGreaterThan(0);
expect(screen.getByText('无法读取')).not.toBeNull();
expect(screen.getByText('检查失败')).not.toBeNull();
expect(screen.getByText('厨房突围')).not.toBeNull();
expect(screen.getByText('已完成 · 预览运行中')).not.toBeNull();
expect(
(screen.getByText('missing-game').closest('button') as HTMLButtonElement)
.disabled,
).toBe(true);
const missingOpenButton = screen
.getByText('missing-game')
.closest('button') as HTMLButtonElement;
const plainFolderOpenButton = screen
.getByText('plain-folder')
.closest('button') as HTMLButtonElement;
const brokenStatusOpenButton = screen
.getByText('broken-status')
.closest('button') as HTMLButtonElement;
const brokenManifestOpenButton = screen
.getByText('broken-manifest')
.closest('button') as HTMLButtonElement;
const missingRevealButton = within(
openProjectMoreMenu('missing-game'),
).getByRole('menuitem', { name: '显示目录' }) as HTMLButtonElement;
fireEvent.keyDown(document, { key: 'Escape' });
const notAFolderRevealButton = within(
openProjectMoreMenu('not-a-folder'),
).getByRole('menuitem', { name: '显示目录' }) as HTMLButtonElement;
fireEvent.keyDown(document, { key: 'Escape' });
const plainFolderRevealButton = within(
openProjectMoreMenu('plain-folder'),
).getByRole('menuitem', { name: '显示目录' }) as HTMLButtonElement;
fireEvent.keyDown(document, { key: 'Escape' });
const brokenManifestRevealButton = within(
openProjectMoreMenu('broken-manifest'),
).getByRole('menuitem', { name: '显示目录' }) as HTMLButtonElement;
fireEvent.keyDown(document, { key: 'Escape' });
const brokenStatusRevealButton = within(
openProjectMoreMenu('broken-status'),
).getByRole('menuitem', { name: '显示目录' }) as HTMLButtonElement;
fireEvent.keyDown(document, { key: 'Escape' });
expect(missingOpenButton.disabled).toBe(true);
expect(plainFolderOpenButton.disabled).toBe(true);
expect(brokenManifestOpenButton.disabled).toBe(true);
expect(brokenStatusOpenButton.disabled).toBe(true);
expect(missingRevealButton.disabled).toBe(true);
expect(notAFolderRevealButton.disabled).toBe(true);
expect(plainFolderRevealButton.disabled).toBe(false);
expect(brokenManifestRevealButton.disabled).toBe(false);
expect(brokenStatusRevealButton.disabled).toBe(true);
fireEvent.click(missingOpenButton);
fireEvent.click(plainFolderOpenButton);
fireEvent.click(brokenManifestOpenButton);
fireEvent.click(brokenStatusOpenButton);
expect(invoke).not.toHaveBeenCalledWith(
'open_game_creator_workspace_window',
{ projectPath: '/tmp/missing-game' },
);
expect(invoke).not.toHaveBeenCalledWith(
'open_game_creator_workspace_window',
{ projectPath: '/tmp/plain-folder' },
);
expect(invoke).not.toHaveBeenCalledWith(
'open_game_creator_workspace_window',
{ projectPath: '/tmp/broken-manifest' },
);
expect(invoke).not.toHaveBeenCalledWith(
'open_game_creator_workspace_window',
{ projectPath: '/tmp/broken-status' },
);
fireEvent.click(screen.getByRole('button', { name: '打开项目 厨房突围' }));
await waitFor(() => {
expect(screen.getByLabelText('陶泥儿项目对话')).not.toBeNull();
});
expect(screen.getByLabelText('项目开发工作台')).not.toBeNull();
expect(invoke).not.toHaveBeenCalledWith(
'open_game_creator_workspace_window',
expect.anything(),
);
});
it('checks recent launcher project status automatically without changing the list', async () => {
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
const projectPath = String(args?.projectPath ?? '');
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
isGodotProject: false,
godotProjectRoot: null,
projectName: '自动检查后的项目',
recentRunStatus: 'done',
recentRunStopReason: 'preview-running',
};
}
if (command === 'open_game_creator_workspace_window') {
return undefined;
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
window.localStorage.setItem(
'genarrative-ai-game-creator.recent-workspaces.v1',
JSON.stringify(['/tmp/refreshable-game']),
);
renderLauncherAt('/?launcher');
expect(screen.getByLabelText('最近项目')).not.toBeNull();
expect(screen.getByText('暂无最近项目')).not.toBeNull();
expect(screen.queryByRole('button', { name: '刷新' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '项目组' }));
expect(await screen.findByText('自动检查后的项目')).not.toBeNull();
expect(screen.getByText('已完成 · 预览运行中')).not.toBeNull();
expect(
(
screen
.getByText('自动检查后的项目')
.closest('button') as HTMLButtonElement
).disabled,
).toBe(false);
expect(screen.queryByRole('button', { name: '刷新' })).toBeNull();
expect(screen.queryByRole('button', { name: '清空' })).toBeNull();
expect(window.localStorage.length).toBe(1);
});
it('does not restore a removed recent launcher project after a slow refresh', async () => {
let finishRefresh:
| ((status: {
projectPath: string;
exists: boolean;
isDirectory: boolean;
isGameCreatorProject: boolean;
projectName: string;
recentRunStatus: string;
recentRunStopReason: string;
}) => void)
| null = null;
const invoke = vi.fn(async (command: string) => {
if (command === 'inspect_local_project_directory') {
return await new Promise((resolve) => {
finishRefresh = resolve;
});
}
throw new Error(`unexpected invoke ${command}`);
});
window.__TAURI__ = { core: { invoke } };
window.localStorage.setItem(
'genarrative-ai-game-creator.recent-workspaces.v1',
JSON.stringify(['/tmp/slow-refresh-game']),
);
renderLauncherAt('/?launcher');
expect(screen.getByLabelText('最近项目')).not.toBeNull();
expect(screen.getByText('暂无最近项目')).not.toBeNull();
expect(
screen.queryByRole('button', { name: 'slow-refresh-game的更多操作' }),
).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '项目组' }));
expect(await screen.findByText('检查中')).not.toBeNull();
fireEvent.click(
within(openProjectMoreMenu('slow-refresh-game')).getByRole('menuitem', {
name: '从列表移除',
}),
);
expect(screen.queryByText('/tmp/slow-refresh-game')).toBeNull();
await act(async () => {
finishRefresh?.({
projectPath: '/tmp/slow-refresh-game',
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: '慢速刷新旧项目',
recentRunStatus: 'done',
recentRunStopReason: 'late',
});
});
expect(screen.queryByText('/tmp/slow-refresh-game')).toBeNull();
expect(screen.queryByText('慢速刷新旧项目')).toBeNull();
expect(screen.queryByText('已完成 · late')).toBeNull();
expect(screen.getByText('暂无项目')).not.toBeNull();
});
it('keeps the project page unchanged when native directory picking is cancelled', async () => {
const invoke = vi.fn(async (command: string) => {
if (command === 'pick_local_project_directory') {
return null;
}
throw new Error(`unexpected invoke ${command}`);
});
window.__TAURI__ = { core: { invoke } };
renderLauncherProjectsAt('/?launcher');
fireEvent.click(screen.getByRole('button', { name: '打开项目' }));
expect(await screen.findByText('已取消')).not.toBeNull();
expect(screen.queryByLabelText('项目目录')).toBeNull();
expect(screen.getByText('暂无项目')).not.toBeNull();
});
it('warns when creating in a picked non-empty folder', async () => {
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'pick_local_project_directory') {
return '/tmp/picked-non-empty-game';
}
if (command === 'is_local_project_directory_non_empty') {
expect(args).toEqual({
projectPath: '/tmp/picked-non-empty-game',
});
return true;
}
if (command === 'init_local_game_project') {
throw new Error('should wait for explicit confirmation');
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
renderLauncherProjectsAt('/?launcher');
fireEvent.click(screen.getByRole('button', { name: '新建项目' }));
expect(
await screen.findByRole('dialog', { name: '文件夹不是空的' }),
).not.toBeNull();
expect(screen.getByText('/tmp/picked-non-empty-game')).not.toBeNull();
expect(invoke).not.toHaveBeenCalledWith(
'init_local_game_project',
expect.anything(),
);
});
it('warns before creating a project in a non-empty folder', async () => {
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'pick_local_project_directory') {
return '/tmp/non-empty-game';
}
if (command === 'is_local_project_directory_non_empty') {
expect(args).toEqual({
projectPath: '/tmp/non-empty-game',
});
return true;
}
if (command === 'init_local_game_project') {
return {
projectPath: String(args?.projectPath ?? ''),
manifestPath: `${String(args?.projectPath ?? '')}/.agent/manifest.json`,
manifest: createGameCreationAppManifest(
'local-project-draft',
'non-empty-game',
),
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false);
window.__TAURI__ = { core: { invoke } };
renderLauncherProjectsAt('/?launcher');
fireEvent.click(screen.getByRole('button', { name: '新建项目' }));
expect(
await screen.findByRole('dialog', { name: '文件夹不是空的' }),
).not.toBeNull();
expect(screen.getByText('/tmp/non-empty-game')).not.toBeNull();
fireEvent.keyDown(window, { key: 'Escape' });
expect(await screen.findByText('已取消')).not.toBeNull();
expect(confirm).not.toHaveBeenCalled();
expect(screen.queryByRole('dialog', { name: '文件夹不是空的' })).toBeNull();
expect(invoke).not.toHaveBeenCalledWith(
'open_game_creator_workspace_window',
expect.anything(),
);
expect(window.localStorage.length).toBe(0);
});
it('creates in a non-empty folder after the user confirms the warning', async () => {
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'pick_local_project_directory') {
return '/tmp/non-empty-game';
}
if (command === 'is_local_project_directory_non_empty') {
return true;
}
if (command === 'init_local_game_project') {
return {
projectPath: String(args?.projectPath ?? ''),
manifestPath: `${String(args?.projectPath ?? '')}/.agent/manifest.json`,
manifest: createGameCreationAppManifest(
'local-project-draft',
'non-empty-game',
),
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true);
window.__TAURI__ = { core: { invoke } };
renderLauncherProjectsAt('/?launcher');
fireEvent.click(screen.getByRole('button', { name: '新建项目' }));
expect(
await screen.findByRole('dialog', { name: '文件夹不是空的' }),
).not.toBeNull();
expect(invoke).not.toHaveBeenCalledWith(
'open_game_creator_workspace_window',
expect.anything(),
);
fireEvent.click(screen.getByRole('button', { name: '继续新建' }));
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith('init_local_game_project', {
projectPath: '/tmp/non-empty-game',
projectId: expect.stringMatching(/^local-project-/),
name: 'non-empty-game',
});
expect(screen.getByLabelText('陶泥儿项目对话')).not.toBeNull();
});
expect(invoke).not.toHaveBeenCalledWith(
'open_game_creator_workspace_window',
expect.anything(),
);
expect(confirm).not.toHaveBeenCalled();
expect(window.localStorage.length).toBe(1);
});
it('uses the selected folder name as the default project name', async () => {
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'pick_local_project_directory') {
return '/tmp/folder-named-game/';
}
if (command === 'is_local_project_directory_non_empty') {
return false;
}
if (command === 'init_local_game_project') {
return {
projectPath: String(args?.projectPath ?? ''),
manifestPath: `${String(args?.projectPath ?? '')}/.agent/manifest.json`,
manifest: createGameCreationAppManifest(
'local-project-draft',
String(args?.name ?? ''),
),
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
renderLauncherProjectsAt('/?launcher');
fireEvent.click(screen.getByRole('button', { name: '新建项目' }));
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith('init_local_game_project', {
projectPath: '/tmp/folder-named-game/',
projectId: expect.stringMatching(/^local-project-/),
name: 'folder-named-game',
});
});
});
it('keeps the launcher open when project creation fails', async () => {
const invoke = vi.fn(async (command: string) => {
if (command === 'pick_local_project_directory') {
return '/tmp/new-game';
}
if (command === 'is_local_project_directory_non_empty') {
return false;
}
if (command === 'init_local_game_project') {
throw new Error('初始化失败');
}
throw new Error(`unexpected invoke ${command}`);
});
window.__TAURI__ = { core: { invoke } };
renderLauncherProjectsAt('/?launcher');
fireEvent.click(screen.getByRole('button', { name: '新建项目' }));
expect(await screen.findByText('初始化失败')).not.toBeNull();
expect(invoke).not.toHaveBeenCalledWith(
'open_game_creator_workspace_window',
expect.anything(),
);
expect(window.localStorage.length).toBe(0);
});
}