c2ec978199
Project CI / AI game creator shell Rust crates (push) Successful in 1m23s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m0s
Project CI / Backend tests (push) Successful in 4m51s
Project CI / Native shell tests (push) Successful in 5m57s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m2s
Project CI / Frontend tests (push) Successful in 2m1s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 9m7s
Project CI / AI game creator shell web tests (push) Successful in 1m20s
Project CI / Repository checks (push) Successful in 1m43s
区分首次用户请求与宿主反馈,继续时发送实际验收或错误信息 固定 GUI 和 CLI 的原始用户条目,保留历史与回合身份关联 补充结构化输入、反馈发送、图片输入和历史去重的定向回归测试 同步更新实施计划与排障记录,保持交付验收条件和预算不变 Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/500 Co-authored-by: Linghong <ink29535@proton.me> Co-committed-by: Linghong <ink29535@proton.me>
3421 lines
122 KiB
TypeScript
3421 lines
122 KiB
TypeScript
import type { ProjectChatComponentProps } from '../../src/features/app-shell/model';
|
|
import { WorkspaceLauncherShell } from '../../src/features/app-shell/WorkspaceLauncher';
|
|
import {
|
|
act,
|
|
App,
|
|
cleanup,
|
|
createGameCreationAppManifest,
|
|
createProjectChatRuntimeHarness,
|
|
expect,
|
|
findResourceSelectButton,
|
|
fireEvent,
|
|
getResourceSelectButton,
|
|
it,
|
|
nativeClipboardMock,
|
|
pickProjectFromLauncher,
|
|
queryResourceSelectButton,
|
|
React,
|
|
render,
|
|
renderLauncherAt,
|
|
renderLauncherProjectsAt,
|
|
screen,
|
|
setComposerText,
|
|
testAuthUser,
|
|
vi,
|
|
waitFor,
|
|
within,
|
|
} from './harness';
|
|
|
|
function homeDesignContinueView({
|
|
prompt,
|
|
pendingClarification = null,
|
|
}: {
|
|
prompt: string;
|
|
pendingClarification?: {
|
|
requestId: string;
|
|
question: string;
|
|
options: string[];
|
|
createdAt: number;
|
|
} | null;
|
|
}) {
|
|
return {
|
|
session: {
|
|
sessionId: 'design-session-home',
|
|
projectId: 'local-project-draft',
|
|
currentPhase: 'concept',
|
|
approvedPhases: [],
|
|
pendingApproval: null,
|
|
pendingClarification,
|
|
turnIndex: 1,
|
|
lastError: null,
|
|
},
|
|
messages: [
|
|
{ id: 'u1', role: 'user', text: prompt },
|
|
{ id: 'a1', role: 'assistant', text: '先确认核心循环。' },
|
|
],
|
|
running: false,
|
|
canRetry: false,
|
|
};
|
|
}
|
|
|
|
async function openResourceBookCategory(label: string) {
|
|
const categoryByLabel: Record<string, string> = {
|
|
'UI 交互': 'ui-interaction',
|
|
角色与对象: 'character',
|
|
场景与环境: 'scene',
|
|
音频: 'audio',
|
|
文档: 'document',
|
|
待归类: 'unclassified',
|
|
项目版本: 'version',
|
|
};
|
|
const category = categoryByLabel[label];
|
|
// 左侧栏目大纲导航已按用户要求删除:切栏目走「资源总览」的栏目缩略卡片。
|
|
if (document.querySelector('[data-resource-book-view="child"]')) {
|
|
fireEvent.click(await screen.findByRole('button', { name: '收起资源' }));
|
|
await waitFor(() =>
|
|
expect(
|
|
document.querySelector('[data-resource-book-view="main"]'),
|
|
).not.toBeNull(),
|
|
);
|
|
}
|
|
fireEvent.click(await screen.findByRole('button', { name: `打开${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('hides template navigation and recommendations when the account is outside gray release', async () => {
|
|
const invoke = vi.fn(async (command: string) => {
|
|
if (command === 'get_game_template_library_access') return false;
|
|
if (command === 'read_game_creator_app_config') return { config: {} };
|
|
throw new Error(`unexpected invoke ${command}`);
|
|
});
|
|
window.__TAURI__ = { core: { invoke } };
|
|
renderLauncherAt('/?launcher');
|
|
await waitFor(() =>
|
|
expect(invoke).toHaveBeenCalledWith('get_game_template_library_access'),
|
|
);
|
|
expect(screen.queryByRole('button', { name: '模板库' })).toBeNull();
|
|
expect(screen.queryByLabelText('模板库推荐')).toBeNull();
|
|
expect(
|
|
invoke.mock.calls.some(
|
|
([command]) => command === 'fetch_game_template_library',
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('shows the home template recommendations and opens the library without creating a project', async () => {
|
|
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
|
const templateLibrarySnapshot = {
|
|
schemaVersion: 'game-template-library.v1',
|
|
library: 'genarrative-official',
|
|
libraryVersion: 3,
|
|
updatedAt: '2026-09-17T00:00:00Z',
|
|
fetchedAtMillis: 1,
|
|
source: 'remote',
|
|
templates: [
|
|
{
|
|
id: 'lane-defense',
|
|
title: '星际防线',
|
|
summary: '塔防原型',
|
|
tags: ['塔防'],
|
|
runtime: 'phaser',
|
|
engine: 'Phaser',
|
|
engineVersion: '4.2.1',
|
|
templateVersion: '1.0.0',
|
|
updatedAt: '2026-09-17T00:00:00Z',
|
|
entry: 'index.html',
|
|
zipUrl:
|
|
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/templates/lane-defense.zip',
|
|
zipSizeBytes: 2048,
|
|
zipSha256: 'a'.repeat(64),
|
|
coverUrl:
|
|
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/templates/lane-defense.png',
|
|
coverWidth: 320,
|
|
coverHeight: 180,
|
|
installed: true,
|
|
installedVersion: '1.0.0',
|
|
installedAtMillis: 2,
|
|
},
|
|
{
|
|
id: 'cozy-farm',
|
|
title: '悠然农场',
|
|
summary: '经营原型',
|
|
tags: ['经营'],
|
|
runtime: 'phaser',
|
|
engine: 'Phaser',
|
|
engineVersion: '4.2.1',
|
|
templateVersion: '1.0.0',
|
|
updatedAt: '2026-09-17T00:00:00Z',
|
|
entry: 'index.html',
|
|
zipUrl:
|
|
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/templates/cozy-farm.zip',
|
|
zipSizeBytes: 4096,
|
|
zipSha256: 'b'.repeat(64),
|
|
coverUrl:
|
|
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/templates/cozy-farm.png',
|
|
coverWidth: 320,
|
|
coverHeight: 180,
|
|
installed: false,
|
|
installedVersion: null,
|
|
installedAtMillis: null,
|
|
},
|
|
],
|
|
};
|
|
const invoke = vi.fn(async (command: string) => {
|
|
if (command === 'read_game_creator_app_config') {
|
|
return { config: { selectedModelId: 'quality' } };
|
|
}
|
|
if (command === 'get_game_template_library_access') return true;
|
|
if (command === 'fetch_game_template_library') {
|
|
return templateLibrarySnapshot;
|
|
}
|
|
throw new Error(`unexpected invoke ${command}`);
|
|
});
|
|
window.__TAURI__ = { core: { invoke } };
|
|
renderLauncherAt('/?launcher');
|
|
|
|
// 首页推荐位只展示封面、标题、运行时与已下载徽标,本机灵感图库已随模板库上线删除。
|
|
const recommendations = await screen.findByLabelText('模板库推荐');
|
|
const recommendationCards = within(recommendations).getAllByRole('button', {
|
|
name: /^查看模板 /u,
|
|
});
|
|
expect(recommendationCards.length).toBe(2);
|
|
expect(within(recommendationCards[0]!).getByText('已下载')).not.toBeNull();
|
|
|
|
fireEvent.click(recommendationCards[0]!);
|
|
|
|
// 点击推荐位只进入模板库页面,不在首页直接下载或创建项目。
|
|
const librarySummary = await screen.findByText('共 2 个模板 · 已下载 1 个');
|
|
expect(librarySummary).not.toBeNull();
|
|
expect(screen.getByRole('button', { name: '返回' })).not.toBeNull();
|
|
expect(screen.queryByLabelText('模板库推荐')).toBeNull();
|
|
expect(
|
|
invoke.mock.calls.some(
|
|
([command]) =>
|
|
command === 'download_game_template' ||
|
|
command === 'create_automatic_local_game_project_from_template',
|
|
),
|
|
).toBe(false);
|
|
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
});
|
|
expect(
|
|
fetchSpy.mock.calls.some(
|
|
([input]) => String(input) === '/api/editor/showcase/resources',
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('projects chat 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: 'icon-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 ManifestPushingChat({
|
|
initialProjectPath,
|
|
onManifestChange,
|
|
}: ProjectChatComponentProps) {
|
|
return React.createElement(
|
|
'button',
|
|
{
|
|
type: 'button',
|
|
onClick: () =>
|
|
onManifestChange?.(initialProjectPath ?? '', updatedManifest),
|
|
},
|
|
'同步最新 manifest',
|
|
);
|
|
}
|
|
const invoke = vi.fn(
|
|
async (command: string, args?: Record<string, unknown>) => {
|
|
if (command === 'get_design_agent_runtime_mode') return null;
|
|
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(),
|
|
ProjectChat: ManifestPushingChat,
|
|
}),
|
|
);
|
|
|
|
pickProjectFromLauncher(projectPath);
|
|
const runButton = await screen.findByRole('tab', { name: '运行' });
|
|
expect(runButton.getAttribute('data-unavailable')).toBe('true');
|
|
expect(queryResourceSelectButton('live-hero.png')).toBeNull();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '同步最新 manifest' }));
|
|
|
|
await openResourceBookCategory('UI 交互');
|
|
expect(await findResourceSelectButton('live-hero.png')).not.toBeNull();
|
|
await openResourceBookCategory('项目版本');
|
|
expect(await findResourceSelectButton('版本 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 chat 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: 'icon-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 = createProjectChatRuntimeHarness({
|
|
projectPath,
|
|
});
|
|
let manifestChanged = false;
|
|
const invoke = vi.fn(
|
|
async (command: string, args?: Record<string, unknown>) => {
|
|
if (command === 'get_design_agent_runtime_mode') return null;
|
|
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(),
|
|
ProjectChat: App,
|
|
}),
|
|
);
|
|
|
|
pickProjectFromLauncher(projectPath);
|
|
const runButton = await screen.findByRole('tab', { name: '运行' });
|
|
expect(runButton.getAttribute('data-unavailable')).toBe('true');
|
|
expect(queryResourceSelectButton('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('UI 交互');
|
|
expect(
|
|
await findResourceSelectButton('runtime-live-hero.png', {
|
|
timeout: 5_000,
|
|
}),
|
|
).not.toBeNull();
|
|
await openResourceBookCategory('项目版本');
|
|
expect(await findResourceSelectButton('版本 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: 'icon-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: 'icon-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 = createProjectChatRuntimeHarness({
|
|
projectPath: firstProjectPath,
|
|
});
|
|
const invoke = vi.fn(
|
|
async (command: string, args?: Record<string, unknown>) => {
|
|
const requestedPath = String(args?.projectPath ?? '');
|
|
if (command === 'get_design_agent_runtime_mode') return null;
|
|
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(),
|
|
ProjectChat: 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('UI 交互');
|
|
expect(await findResourceSelectButton('second.png')).not.toBeNull();
|
|
|
|
await act(async () => {
|
|
resolveStaleRefresh(staleFirstManifest);
|
|
await staleRefresh;
|
|
});
|
|
expect(queryResourceSelectButton('stale-first.png')).toBeNull();
|
|
expect(getResourceSelectButton('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 === 'get_design_agent_runtime_mode') return null;
|
|
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();
|
|
});
|
|
}
|
|
|
|
export function registerHomeProjectCreationTests() {
|
|
it('refreshes recent project status before entering the project chat surface', async () => {
|
|
let inspectCount = 0;
|
|
const invoke = vi.fn(
|
|
async (command: string, args?: Record<string, unknown>) => {
|
|
if (command === 'get_design_agent_runtime_mode') return null;
|
|
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('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 === 'get_design_agent_runtime_mode') return null;
|
|
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 === 'get_design_agent_runtime_mode') return null;
|
|
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 === 'get_design_agent_runtime_mode') return null;
|
|
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 chatHarness = createProjectChatRuntimeHarness({
|
|
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,
|
|
};
|
|
}
|
|
// DirectProject 聊天容器自己订阅项目清单(项目身份与 `@` 引用素材都来自它),
|
|
// 首轮需求要等清单到位才认领,所以这里必须提供清单读数。
|
|
if (command === 'get_local_game_manifest') {
|
|
return manifest;
|
|
}
|
|
if (command === 'chat_with_game_creator_direct_codex') {
|
|
chatHarness.completeDirectThreadTurn({
|
|
turnId: String(args?.clientTurnId ?? ''),
|
|
prompt: '你好,今天多少号',
|
|
reply: '别这么骂自己,具体发生什么了?',
|
|
});
|
|
return '别这么骂自己,具体发生什么了?';
|
|
}
|
|
return chatHarness.invoke(command, args);
|
|
},
|
|
);
|
|
window.__TAURI__ = {
|
|
core: { invoke },
|
|
event: { listen: chatHarness.listen },
|
|
};
|
|
renderLauncherAt('/?launcher', 'home', true);
|
|
|
|
const creationTypes = screen.getByRole('group', { name: '创作类型' });
|
|
const gameType = within(creationTypes).getByRole('button', {
|
|
name: '做游戏',
|
|
});
|
|
const documentType = within(creationTypes).getByRole('button', {
|
|
name: '做方案',
|
|
});
|
|
expect(gameType.getAttribute('aria-pressed')).toBe('true');
|
|
expect(
|
|
within(creationTypes).queryByRole('button', { name: '做素材' }),
|
|
).toBeNull();
|
|
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(gameType);
|
|
expect(gameType.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,
|
|
planning: false,
|
|
projectsRoot: null,
|
|
});
|
|
expect(invoke).toHaveBeenCalledWith('chat_with_game_creator_direct_codex', {
|
|
projectPath: automaticProjectPath,
|
|
creationType: 'game',
|
|
clientTurnId: expect.any(String),
|
|
analyticsAttemptId: expect.any(String),
|
|
userItem: {
|
|
id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/),
|
|
type: 'message',
|
|
role: 'user',
|
|
content: [{ type: 'input_text', text: '你好,今天多少号' }],
|
|
},
|
|
});
|
|
// 这一轮走的是项目聊天命令:首页那条直连命令不出现在调用列表里。
|
|
expect(
|
|
invoke.mock.calls
|
|
.map(([command]) => String(command))
|
|
.filter((command) => command.includes('direct_codex')),
|
|
).toEqual(['chat_with_game_creator_direct_codex']);
|
|
// 首页那条直连命令没有出现,界面也已经交给项目对话面板。
|
|
expect(await screen.findByLabelText('陶泥儿项目对话')).not.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 chatHarness = createProjectChatRuntimeHarness({
|
|
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 === 'get_local_game_manifest') {
|
|
return 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') {
|
|
chatHarness.completeDirectThreadTurn({
|
|
turnId: String(args?.clientTurnId ?? ''),
|
|
prompt: '按这个角色做游戏',
|
|
reply: '附件已经进入当前项目。',
|
|
});
|
|
return '附件已经进入当前项目。';
|
|
}
|
|
return chatHarness.invoke(command, args);
|
|
},
|
|
);
|
|
window.__TAURI__ = {
|
|
core: { invoke },
|
|
event: { listen: chatHarness.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: '角色参考游戏',
|
|
planning: false,
|
|
projectsRoot: null,
|
|
});
|
|
expect(invoke).toHaveBeenCalledWith('upload_local_asset', {
|
|
projectPath: automaticProjectPath,
|
|
fileName: '角色参考.png',
|
|
mediaType: 'image/png',
|
|
bytes: fileBytes,
|
|
});
|
|
// 首轮需求由 DirectProject 聊天容器认领:它要先读到项目清单(项目身份就绪)才发送,
|
|
// 所以这里等一等,不能在「工作台已出现」的同一帧断言。
|
|
await waitFor(() => {
|
|
expect(invoke).toHaveBeenCalledWith(
|
|
'chat_with_game_creator_direct_codex',
|
|
{
|
|
projectPath: automaticProjectPath,
|
|
creationType: 'game',
|
|
clientTurnId: expect.any(String),
|
|
analyticsAttemptId: expect.any(String),
|
|
userItem: {
|
|
id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/),
|
|
type: 'message',
|
|
role: 'user',
|
|
// 首页附件随首轮一起进 canonical content:它就是这一轮输入的一部分。
|
|
content: [
|
|
{ type: 'input_text', text: '按这个角色做游戏' },
|
|
{
|
|
type: 'agc_attachment_reference',
|
|
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,
|
|
clientTurnId: expect.any(String),
|
|
analyticsAttemptId: expect.any(String),
|
|
userItem: {
|
|
id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/),
|
|
type: 'message',
|
|
role: 'user',
|
|
content: [{ type: 'input_text', text: '再补一句玩法' }],
|
|
},
|
|
});
|
|
// 第二次 invoke 就是这条后续消息:回合入参里没有客户端投影文本可供筛选。
|
|
const followUpPayload = invoke.mock.calls
|
|
.filter(([command]) => command === 'chat_with_game_creator_direct_codex')
|
|
.at(-1)?.[1] as Record<string, unknown> | undefined;
|
|
// 后续这一轮没有附件:canonical content 里只有文本 part。
|
|
expect(
|
|
(followUpPayload?.userItem as { content?: unknown[] } | undefined)
|
|
?.content,
|
|
).toEqual([{ type: 'input_text', text: '再补一句玩法' }]);
|
|
});
|
|
|
|
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 === 'preflight_web_game_creation') return { status: 'ready' };
|
|
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.getByText('正在创建工作区')).not.toBeNull();
|
|
|
|
// The launcher owns the operation, so navigating away and back must not
|
|
// release the duplicate-create guard or lose the in-progress status.
|
|
fireEvent.click(screen.getByRole('button', { name: '项目组' }));
|
|
expect(await screen.findByLabelText('项目列表')).not.toBeNull();
|
|
fireEvent.click(screen.getByRole('button', { name: '首页' }));
|
|
const returnedCreateButton = await screen.findByRole('button', {
|
|
name: '开启创作',
|
|
});
|
|
expect((returnedCreateButton as HTMLButtonElement).disabled).toBe(true);
|
|
expect(screen.getByText('正在创建工作区')).not.toBeNull();
|
|
expect(
|
|
invoke.mock.calls.filter(
|
|
([command]) => command === 'create_automatic_local_game_project',
|
|
),
|
|
).toHaveLength(1);
|
|
|
|
await act(async () => {
|
|
rejectAutomaticProject?.(new Error('自动创建测试结束'));
|
|
await automaticProject.catch(() => undefined);
|
|
});
|
|
await waitFor(() => {
|
|
expect((returnedCreateButton as HTMLButtonElement).disabled).toBe(false);
|
|
});
|
|
expect(screen.queryByText('自动创建测试结束')).toBeNull();
|
|
});
|
|
|
|
it('unblocks the home launcher when automatic creation never returns', async () => {
|
|
vi.useFakeTimers();
|
|
const automaticProjectPath = '/tmp/home-stalled-project';
|
|
const manifest = createGameCreationAppManifest(
|
|
'home-stalled-project',
|
|
'卡住的自动建项',
|
|
);
|
|
let resolveAutomaticProject:
|
|
| ((result: Record<string, unknown>) => void)
|
|
| null = null;
|
|
const invoke = vi.fn(async (command: string) => {
|
|
if (command === 'preflight_web_game_creation') return { status: 'ready' };
|
|
if (command === 'create_automatic_local_game_project') {
|
|
return await new Promise((resolve) => {
|
|
resolveAutomaticProject = resolve;
|
|
});
|
|
}
|
|
throw new Error(`unexpected invoke ${command}`);
|
|
});
|
|
window.__TAURI__ = { core: { invoke } };
|
|
renderLauncherAt('/?launcher', 'home', true);
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
});
|
|
|
|
const promptInput = screen.getByLabelText('创作想法');
|
|
nativeClipboardMock.text = '做一个挂机游戏';
|
|
fireEvent.paste(promptInput);
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: '开启创作' }));
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
});
|
|
expect(
|
|
(screen.getByRole('button', { name: '开启创作' }) as HTMLButtonElement)
|
|
.disabled,
|
|
).toBe(true);
|
|
|
|
// 超过兜底期限:首页入口必须解围,不能永远卡在"正在创建工作区"。
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(10 * 60_000);
|
|
});
|
|
expect(screen.getByText(/工作区创建超过 10 分钟/)).not.toBeNull();
|
|
expect(
|
|
(screen.getByRole('button', { name: '开启创作' }) as HTMLButtonElement)
|
|
.disabled,
|
|
).toBe(false);
|
|
|
|
// 解围不等于放弃底层创建,也不等于允许再建第二个工作区。
|
|
fireEvent.click(screen.getByRole('button', { name: '开启创作' }));
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
});
|
|
expect(screen.getByText(/上一次工作区创建仍未返回/)).not.toBeNull();
|
|
expect(
|
|
invoke.mock.calls.filter(
|
|
([command]) => command === 'create_automatic_local_game_project',
|
|
),
|
|
).toHaveLength(1);
|
|
|
|
// 迟到的成功仍然要进项目:用户不该因为慢就丢掉这次创建。
|
|
await act(async () => {
|
|
resolveAutomaticProject?.({
|
|
projectPath: automaticProjectPath,
|
|
manifestPath: `${automaticProjectPath}/.agent/manifest.json`,
|
|
manifest,
|
|
});
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
});
|
|
expect(screen.getByLabelText('项目开发工作台')).not.toBeNull();
|
|
cleanup();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('keeps the created workspace recoverable when design runtime setup fails', async () => {
|
|
const projectPath = '/tmp/home-design-mode-failure';
|
|
const manifest = createGameCreationAppManifest(
|
|
'home-design-mode-failure',
|
|
'策划初始化失败项目',
|
|
);
|
|
const chatHarness = createProjectChatRuntimeHarness({
|
|
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 === 'set_design_agent_runtime_mode') {
|
|
throw new Error('design runtime unavailable');
|
|
}
|
|
if (command === 'inspect_local_project_directory') {
|
|
return {
|
|
exists: true,
|
|
isDirectory: true,
|
|
isGameCreatorProject: true,
|
|
isCocosProject: false,
|
|
godotProjectRoot: null,
|
|
};
|
|
}
|
|
if (command === 'get_local_game_manifest') {
|
|
return manifest;
|
|
}
|
|
return chatHarness.invoke(command, args);
|
|
},
|
|
);
|
|
window.__TAURI__ = {
|
|
core: { invoke },
|
|
event: { listen: chatHarness.listen },
|
|
};
|
|
renderLauncherAt('/?launcher', 'home', true);
|
|
|
|
// 做方案走立项链路:建项之后还要写策划运行时,这一步失败时项目目录已经存在。
|
|
fireEvent.click(screen.getByRole('button', { name: '做方案' }));
|
|
const promptInput = screen.getByLabelText('创作想法');
|
|
nativeClipboardMock.text = '做一个塔防游戏';
|
|
fireEvent.paste(promptInput);
|
|
await waitFor(() => {
|
|
expect(promptInput.textContent).toContain('做一个塔防游戏');
|
|
});
|
|
fireEvent.keyDown(promptInput, { key: 'Enter', code: 'Enter' });
|
|
|
|
expect(await screen.findByText('创建未完成,请重试')).not.toBeNull();
|
|
expect(screen.getByText(`已创建的工作区:${projectPath}`)).not.toBeNull();
|
|
await waitFor(() => {
|
|
expect(invoke).toHaveBeenCalledWith('inspect_local_project_directory', {
|
|
projectPath,
|
|
});
|
|
});
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '打开已创建的工作区' }));
|
|
expect(await screen.findByLabelText('项目开发工作台')).not.toBeNull();
|
|
});
|
|
|
|
it.each([
|
|
['做方案', false],
|
|
['做方案', true],
|
|
] as const)(
|
|
'routes %s %s creation to the design agent',
|
|
async (modeLabel, automatic) => {
|
|
const projectPath = `/tmp/home-${modeLabel}-${automatic ? 'enter' : 'submit'}`;
|
|
const manifest = createGameCreationAppManifest(
|
|
'local-project-draft',
|
|
'home-entry-route',
|
|
);
|
|
const chatHarness = createProjectChatRuntimeHarness({
|
|
projectPath,
|
|
designAgentContinueView: homeDesignContinueView({
|
|
prompt: '整理一个可玩原型',
|
|
}),
|
|
});
|
|
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 chatHarness.invoke(command, args);
|
|
},
|
|
);
|
|
window.__TAURI__ = {
|
|
core: { invoke },
|
|
event: { listen: chatHarness.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(
|
|
'continue_design_agent_session',
|
|
expect.objectContaining({
|
|
projectPath,
|
|
input: expect.objectContaining({
|
|
type: 'message',
|
|
text: '整理一个可玩原型',
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
const startCall = invoke.mock.calls.find(
|
|
([command]) => command === 'continue_design_agent_session',
|
|
);
|
|
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.each(['success', 'partial-failure', 'files-only'])(
|
|
'imports 做方案 references into the workspace: %s',
|
|
async (scenario) => {
|
|
const partialFailure = scenario === 'partial-failure';
|
|
const filesOnly = scenario === 'files-only';
|
|
const projectPath = `/tmp/home-planning-attachment-${scenario}`;
|
|
const manifest = createGameCreationAppManifest(
|
|
'home-planning-attachment',
|
|
'首页策划附件',
|
|
);
|
|
const chatHarness = createProjectChatRuntimeHarness({
|
|
projectPath,
|
|
designAgentContinueView: homeDesignContinueView({
|
|
prompt: '整理一份可玩原型',
|
|
}),
|
|
});
|
|
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 === 'import_design_workspace_file') {
|
|
if (args?.fileName === '失败.txt') throw new Error('磁盘写入失败');
|
|
return 'references/角色参考.png';
|
|
}
|
|
return chatHarness.invoke(command, args);
|
|
},
|
|
);
|
|
window.__TAURI__ = {
|
|
core: { invoke },
|
|
event: { listen: chatHarness.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,
|
|
});
|
|
const failedAttachment = new File(['失败'], '失败.txt');
|
|
Object.defineProperty(failedAttachment, 'arrayBuffer', {
|
|
value: async () => new Uint8Array([1]).buffer,
|
|
});
|
|
fireEvent.change(fileInput!, {
|
|
target: {
|
|
files: partialFailure ? [attachment, failedAttachment] : [attachment],
|
|
},
|
|
});
|
|
|
|
const promptInput = screen.getByLabelText('创作想法');
|
|
if (!filesOnly) {
|
|
nativeClipboardMock.text = '整理一份可玩原型';
|
|
fireEvent.paste(promptInput);
|
|
}
|
|
await waitFor(() => {
|
|
if (!filesOnly)
|
|
expect(promptInput.textContent).toContain('整理一份可玩原型');
|
|
expect(promptInput.textContent).toContain('角色参考.png');
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: '进入立项策划' }));
|
|
|
|
await screen.findByText(
|
|
partialFailure
|
|
? '已导入 1 个文件到策划工作区;1 个文件导入失败:失败.txt:磁盘写入失败。可重新选择失败文件重试。'
|
|
: '已导入 1 个文件到策划工作区',
|
|
);
|
|
if (!filesOnly)
|
|
await waitFor(() => {
|
|
expect(invoke).toHaveBeenCalledWith(
|
|
'continue_design_agent_session',
|
|
expect.objectContaining({
|
|
projectPath,
|
|
input: expect.objectContaining({ type: 'message' }),
|
|
}),
|
|
);
|
|
});
|
|
expect(invoke).toHaveBeenCalledWith('import_design_workspace_file', {
|
|
projectPath,
|
|
fileName: '角色参考.png',
|
|
bytes: fileBytes,
|
|
});
|
|
expect(
|
|
invoke.mock.calls.some(([command]) => command === 'upload_local_asset'),
|
|
).toBe(false);
|
|
expect(
|
|
screen.getByText(
|
|
partialFailure
|
|
? '已导入 1 个文件到策划工作区;1 个文件导入失败:失败.txt:磁盘写入失败。可重新选择失败文件重试。'
|
|
: '已导入 1 个文件到策划工作区',
|
|
),
|
|
).not.toBeNull();
|
|
const commands = invoke.mock.calls.map(([command]) => command);
|
|
if (filesOnly) {
|
|
expect(commands).not.toContain('continue_design_agent_session');
|
|
} else {
|
|
expect(
|
|
commands.lastIndexOf('import_design_workspace_file'),
|
|
).toBeLessThan(commands.indexOf('continue_design_agent_session'));
|
|
}
|
|
const startCall = invoke.mock.calls.find(
|
|
([command]) => command === 'continue_design_agent_session',
|
|
);
|
|
expect(startCall?.[1] ?? {}).not.toHaveProperty('attachments');
|
|
expect(JSON.stringify(startCall?.[1] ?? {})).not.toContain(
|
|
'本轮用户附件',
|
|
);
|
|
expect(JSON.stringify(startCall?.[1] ?? {})).not.toContain(
|
|
'references/角色参考.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 chatHarness = createProjectChatRuntimeHarness({
|
|
projectPath,
|
|
designAgentContinueView: homeDesignContinueView({
|
|
prompt: '2D射击游戏',
|
|
pendingClarification: {
|
|
requestId: 'visual_direction',
|
|
question: '首版角色规范图采用哪种美术方向?',
|
|
options: ['像素', '扁平'],
|
|
createdAt: 1,
|
|
},
|
|
}),
|
|
});
|
|
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 chatHarness.invoke(command, args);
|
|
},
|
|
);
|
|
window.__TAURI__ = {
|
|
core: { invoke },
|
|
event: { listen: chatHarness.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(
|
|
'continue_design_agent_session',
|
|
expect.objectContaining({
|
|
projectPath,
|
|
input: expect.objectContaining({
|
|
type: 'message',
|
|
text: '2D射击游戏',
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
await screen.findByText('首版角色规范图采用哪种美术方向?');
|
|
expect(screen.getByRole('button', { name: '像素' })).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 eventProjectPath = `\\\\?\\${projectPath}`;
|
|
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 = createProjectChatRuntimeHarness({
|
|
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: 'scene',
|
|
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: 'icon-spritesheet',
|
|
mediaType: 'image/png',
|
|
localPath: 'assets/art-spritesheet.png',
|
|
source: {
|
|
kind: 'generated' as const,
|
|
taskId: 'direct-codex-art-art-spritesheet',
|
|
},
|
|
},
|
|
].map((asset) => ({ ...asset, category: 'ui-interaction' as const }));
|
|
|
|
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),
|
|
};
|
|
runtimeHarness.setProjectRevision(index + 1);
|
|
act(() => {
|
|
runtimeHarness.emitManifestInvalidated(
|
|
'direct-codex-art',
|
|
eventProjectPath,
|
|
);
|
|
});
|
|
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);
|
|
}
|
|
await openResourceBookCategory('UI 交互');
|
|
expect(getResourceSelectButton('art-spec.png')).not.toBeNull();
|
|
expect(
|
|
getResourceSelectButton('direct-game-background.png'),
|
|
).not.toBeNull();
|
|
expect(getResourceSelectButton('art-spritesheet.png')).not.toBeNull();
|
|
|
|
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 === 'read_project_permission_policy') {
|
|
return {
|
|
path: '.agent/policy.json',
|
|
policy: { deniedCommands: [], confirmCommands: [] },
|
|
};
|
|
}
|
|
if (command === 'read_direct_project_history_slice') {
|
|
expect(args).toEqual({ projectPath, limit: 20 });
|
|
return {
|
|
items: [...persistedMessages],
|
|
hasMore: false,
|
|
firstItemId: null,
|
|
};
|
|
}
|
|
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(
|
|
{
|
|
itemType: 'message',
|
|
itemId: `direct-codex:${clientTurnId}:user`,
|
|
role: 'user',
|
|
text: '继续修改已有项目',
|
|
at: 9_000,
|
|
},
|
|
{
|
|
itemType: 'message',
|
|
itemId: `direct-codex:${clientTurnId}:assistant`,
|
|
role: 'assistant',
|
|
text: 'DIRECT_EXISTING_PROJECT_OK',
|
|
at: 9_001,
|
|
},
|
|
);
|
|
return 'DIRECT_EXISTING_PROJECT_OK';
|
|
}
|
|
throw new Error(`unexpected invoke ${command}`);
|
|
},
|
|
);
|
|
window.__TAURI__ = { core: { invoke } };
|
|
|
|
render(
|
|
React.createElement(App, {
|
|
initialProjectPath: projectPath,
|
|
initialProjectManifest: manifest,
|
|
initialPlanningPrompt: '继续修改已有项目',
|
|
}),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(invoke).toHaveBeenCalledWith(
|
|
'chat_with_game_creator_direct_codex',
|
|
{
|
|
projectPath,
|
|
clientTurnId: expect.any(String),
|
|
analyticsAttemptId: expect.any(String),
|
|
userItem: {
|
|
id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/),
|
|
type: 'message',
|
|
role: 'user',
|
|
content: [{ type: 'input_text', text: '继续修改已有项目' }],
|
|
},
|
|
},
|
|
);
|
|
});
|
|
// 首轮只发一轮 DirectProject 回合:其余命令都由上面的 stub 逐条接住,
|
|
// 未列出的命令(含浏览器侧会话写入)在 stub 里直接抛错。
|
|
expect(
|
|
invoke.mock.calls.filter(
|
|
([command]) => command === 'chat_with_game_creator_direct_codex',
|
|
),
|
|
).toHaveLength(1);
|
|
});
|
|
|
|
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_direct_project_history_slice') {
|
|
expect(args).toEqual({ projectPath, limit: 20 });
|
|
return {
|
|
items: [...persistedMessages],
|
|
hasMore: false,
|
|
firstItemId: null,
|
|
};
|
|
}
|
|
if (command === 'append_local_permission_log') {
|
|
return {};
|
|
}
|
|
if (command === 'read_project_permission_policy') {
|
|
return {
|
|
path: '.agent/policy.json',
|
|
policy: { deniedCommands: [], confirmCommands: [] },
|
|
};
|
|
}
|
|
if (command === 'chat_with_game_creator_direct_codex') {
|
|
const clientTurnId = String(args?.clientTurnId ?? '');
|
|
expect(args).toEqual({
|
|
projectPath,
|
|
clientTurnId: expect.any(String),
|
|
analyticsAttemptId: expect.any(String),
|
|
userItem: {
|
|
id: `direct-codex:${clientTurnId}:user`,
|
|
type: 'message',
|
|
role: 'user',
|
|
content: [{ type: 'input_text', text: '生成一个游戏' }],
|
|
},
|
|
});
|
|
// Rust 落盘的原始条目在回读时已经是投影后的形状:身份只有一个 itemId。
|
|
persistedMessages.push(
|
|
{
|
|
itemType: 'message',
|
|
itemId: `direct-codex:${clientTurnId}:user`,
|
|
role: 'user',
|
|
text: '生成一个游戏',
|
|
at: 9_000,
|
|
},
|
|
{
|
|
itemType: 'message',
|
|
itemId: `direct-codex:${clientTurnId}:assistant`,
|
|
role: 'assistant',
|
|
text: '陶泥儿智能创作 鉴权失败,请重新登录后重试',
|
|
at: 9_001,
|
|
},
|
|
);
|
|
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,
|
|
initialPlanningPrompt: '生成一个游戏',
|
|
}),
|
|
);
|
|
|
|
expect(
|
|
await screen.findAllByText('陶泥儿智能创作 鉴权失败,请重新登录后重试'),
|
|
).not.toBeNull();
|
|
await waitFor(() => {
|
|
expect(persistedMessages).toHaveLength(2);
|
|
});
|
|
expect(persistedMessages).toEqual([
|
|
{
|
|
itemType: 'message',
|
|
itemId: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/),
|
|
role: 'user',
|
|
text: '生成一个游戏',
|
|
at: 9_000,
|
|
},
|
|
{
|
|
itemType: 'message',
|
|
itemId: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:assistant$/),
|
|
role: 'assistant',
|
|
text: '陶泥儿智能创作 鉴权失败,请重新登录后重试',
|
|
at: 9_001,
|
|
},
|
|
]);
|
|
expect(JSON.stringify(persistedMessages)).not.toContain(
|
|
'codex-app-server-error:unauthorized',
|
|
);
|
|
cleanup();
|
|
|
|
render(
|
|
React.createElement(App, {
|
|
initialProjectPath: projectPath,
|
|
initialProjectManifest: manifest,
|
|
}),
|
|
);
|
|
expect(await screen.findByText('生成一个游戏')).not.toBeNull();
|
|
expect(
|
|
await screen.findAllByText('陶泥儿智能创作 鉴权失败,请重新登录后重试'),
|
|
).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 === 'get_design_agent_runtime_mode') return null;
|
|
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 === 'get_design_agent_runtime_mode') return null;
|
|
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 === 'get_design_agent_runtime_mode') return null;
|
|
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 === 'get_design_agent_runtime_mode') return null;
|
|
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 === 'get_design_agent_runtime_mode') return null;
|
|
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 === 'get_design_agent_runtime_mode') return null;
|
|
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(await screen.findByText('检查失败')).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 === 'get_design_agent_runtime_mode') return null;
|
|
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 === 'get_design_agent_runtime_mode') return null;
|
|
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);
|
|
});
|
|
|
|
it('creates the automatic workspace inside the project creation directory picked in settings', async () => {
|
|
const automaticProjectPath =
|
|
'F:\\Projects\\我的游戏\\gameagent-chosen-directory';
|
|
const creationDirectory = 'F:\\Projects\\我的游戏';
|
|
const manifest = createGameCreationAppManifest(
|
|
'home-creation-directory-project',
|
|
'自选目录项目',
|
|
);
|
|
const supervisorHarness = createProjectChatRuntimeHarness({
|
|
projectPath: automaticProjectPath,
|
|
initialSessionExists: false,
|
|
});
|
|
const invoke = vi.fn(
|
|
async (command: string, args?: Record<string, unknown>) => {
|
|
if (command === 'read_game_creator_app_config') {
|
|
return {
|
|
path: 'C:\\Users\\tester\\AppData\\Roaming\\genarrative\\config.json',
|
|
config: {
|
|
agentMode: 'codex_app_server',
|
|
llm: {
|
|
apiKey: '',
|
|
baseUrl: 'https://llm.example.test/v1',
|
|
model: 'gpt-creation-directory',
|
|
apiKind: 'openai_responses',
|
|
reasoningEffort: 'high',
|
|
stream: true,
|
|
webSearchEnabled: false,
|
|
contextWindowTokens: 128000,
|
|
autoCompactTokenLimit: 64000,
|
|
toolOutputTokenLimit: 12000,
|
|
requestTimeoutMs: 180000,
|
|
maxRetries: 2,
|
|
retryBackoffMs: 500,
|
|
},
|
|
agentLlm: {},
|
|
editorApi: { baseUrl: 'http://127.0.0.1:8082', apiKey: '' },
|
|
},
|
|
};
|
|
}
|
|
if (command === 'pick_local_project_directory') {
|
|
return creationDirectory;
|
|
}
|
|
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);
|
|
|
|
// 设置 → 工作区:默认位置就是不选目录,仍然落在 AGC 管理的应用数据目录。
|
|
fireEvent.click(screen.getByRole('button', { name: '配置' }));
|
|
const settings = await screen.findByRole('dialog', { name: '运行时配置' });
|
|
fireEvent.click(within(settings).getByRole('button', { name: /工作区/ }));
|
|
expect(within(settings).getByText('默认位置')).not.toBeNull();
|
|
fireEvent.click(within(settings).getByRole('button', { name: '选择目录' }));
|
|
await waitFor(() => {
|
|
expect(within(settings).getByText(creationDirectory)).not.toBeNull();
|
|
});
|
|
expect(invoke).toHaveBeenCalledWith('pick_local_project_directory', {
|
|
title: '选择项目创建目录',
|
|
});
|
|
expect(
|
|
window.localStorage.getItem(
|
|
'genarrative-ai-game-creator.project-creation-directory.v1',
|
|
),
|
|
).toBe(JSON.stringify(creationDirectory));
|
|
fireEvent.click(
|
|
within(settings).getByRole('button', { name: '关闭 Agent 设置' }),
|
|
);
|
|
|
|
const promptInput = screen.getByLabelText('创作想法');
|
|
nativeClipboardMock.text = '做一个花园经营游戏';
|
|
fireEvent.paste(promptInput);
|
|
await waitFor(() => {
|
|
expect(promptInput.textContent).toContain('做一个花园经营游戏');
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: '开启创作' }));
|
|
|
|
expect(await screen.findByLabelText('项目开发工作台')).not.toBeNull();
|
|
expect(invoke).toHaveBeenCalledWith('create_automatic_local_game_project', {
|
|
name: null,
|
|
planning: false,
|
|
projectsRoot: creationDirectory,
|
|
});
|
|
});
|
|
}
|