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