1e62d8317a
- 活动回合事实源统一到 Direct 线程管理器:删除 direct_runtime 的第二份快照,接单、进度内容变化与收口各广播一次活动回合变更事件
- 平台维护态判定移入 Rust 并在渲染层只订阅单一事件:新增 platform_maintenance 模块与各平台 facade 错误分支的分类入口
- 封面生成请求补 generationInputs.source,保持队列回填后仍能拿到平台素材 ID
- 渲染层按 master 5398a53e6 退役诊断详情入口:删除 agentRuntimeErrorDetail 与「查看详情」交互及其专属用例
- 冲突收口:nginx SPA 白名单、.gitignore、mobile 检查脚本、capabilities 描述取上游,两个已退役计划随上游删除,文档保留双方条目
- 新增并回写本里程碑取证、decision-log 与 pitfalls 的 2026-09-28 记录
299 lines
9.0 KiB
TypeScript
299 lines
9.0 KiB
TypeScript
// @vitest-environment jsdom
|
||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||
import { afterEach, expect, test, vi } from 'vitest';
|
||
|
||
import { useRecentProjects } from '../src/features/app-shell/useRecentProjects';
|
||
|
||
const READY_PROJECT = {
|
||
projectPath: '/tmp/ready-project',
|
||
exists: true,
|
||
isDirectory: true,
|
||
isGameCreatorProject: true,
|
||
isGodotProject: false,
|
||
godotProjectRoot: null,
|
||
isCocosProject: false,
|
||
cocosProjectRoot: null,
|
||
isUnityProject: false,
|
||
unityProjectRoot: null,
|
||
projectName: '正常项目',
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
};
|
||
|
||
afterEach(() => {
|
||
delete window.__TAURI__;
|
||
window.localStorage.clear();
|
||
});
|
||
|
||
test('刷新坏项目时保留其它项目已确认的正常状态', async () => {
|
||
let finishPendingInspection: (() => void) | null = null;
|
||
const pendingInspection = new Promise<typeof READY_PROJECT>((resolve) => {
|
||
finishPendingInspection = () =>
|
||
resolve({
|
||
...READY_PROJECT,
|
||
projectPath: '/tmp/slow-broken-project',
|
||
projectName: '慢速坏项目',
|
||
});
|
||
});
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command !== 'inspect_local_project_directory') {
|
||
throw new Error(`unexpected invoke ${command}`);
|
||
}
|
||
const projectPath = String(args?.projectPath ?? '');
|
||
if (projectPath === '/tmp/slow-broken-project') {
|
||
return pendingInspection;
|
||
}
|
||
return READY_PROJECT;
|
||
},
|
||
);
|
||
window.__TAURI__ = { core: { invoke } };
|
||
window.localStorage.setItem(
|
||
'genarrative-ai-game-creator.recent-workspaces.v1',
|
||
JSON.stringify(['/tmp/ready-project']),
|
||
);
|
||
|
||
const { result } = renderHook(() => useRecentProjects(vi.fn()));
|
||
await waitFor(() => {
|
||
expect(result.current.projectRows[0]).toMatchObject({
|
||
name: '正常项目',
|
||
status: '本地项目',
|
||
canOpen: true,
|
||
});
|
||
});
|
||
|
||
act(() => {
|
||
result.current.rememberRecentWorkspace('/tmp/slow-broken-project');
|
||
});
|
||
|
||
await waitFor(() => {
|
||
expect(result.current.projectRows).toHaveLength(2);
|
||
const readyRow = result.current.projectRows.find(
|
||
(row) => row.path === '/tmp/ready-project',
|
||
);
|
||
const slowRow = result.current.projectRows.find(
|
||
(row) => row.path === '/tmp/slow-broken-project',
|
||
);
|
||
expect(readyRow).toMatchObject({
|
||
name: '正常项目',
|
||
status: '本地项目',
|
||
canOpen: true,
|
||
});
|
||
expect(slowRow).toMatchObject({
|
||
name: 'slow-broken-project',
|
||
status: '检查中',
|
||
canOpen: false,
|
||
});
|
||
});
|
||
|
||
await act(async () => {
|
||
finishPendingInspection?.();
|
||
await pendingInspection;
|
||
});
|
||
});
|
||
|
||
test('单次目录检查失败会就地重试,不会把整行钉成「检查失败」', async () => {
|
||
let attempts = 0;
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command !== 'inspect_local_project_directory') {
|
||
throw new Error(`unexpected invoke ${command}`);
|
||
}
|
||
attempts += 1;
|
||
if (attempts === 1) {
|
||
throw new Error('Tauri IPC 瞬时失败');
|
||
}
|
||
return READY_PROJECT;
|
||
},
|
||
);
|
||
window.__TAURI__ = { core: { invoke } };
|
||
window.localStorage.setItem(
|
||
'genarrative-ai-game-creator.recent-workspaces.v1',
|
||
JSON.stringify(['/tmp/ready-project']),
|
||
);
|
||
|
||
const { result } = renderHook(() => useRecentProjects(vi.fn()));
|
||
|
||
await waitFor(() => {
|
||
expect(result.current.projectRows[0]).toMatchObject({
|
||
status: '本地项目',
|
||
canOpen: true,
|
||
});
|
||
});
|
||
expect(attempts).toBe(2);
|
||
expect(result.current.projectRows[0]?.status).not.toBe('检查失败');
|
||
});
|
||
|
||
test('失败结果不跨轮保留:刷新时该项回到「检查中」并重新检查', async () => {
|
||
const inspectCounts: Record<string, number> = {};
|
||
let secondRoundPending: (() => void) | null = null;
|
||
const secondRoundInspection = new Promise<typeof READY_PROJECT>((resolve) => {
|
||
secondRoundPending = () => resolve(READY_PROJECT);
|
||
});
|
||
let brokenRound = 0;
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command !== 'inspect_local_project_directory') {
|
||
throw new Error(`unexpected invoke ${command}`);
|
||
}
|
||
const projectPath = String(args?.projectPath ?? '');
|
||
inspectCounts[projectPath] = (inspectCounts[projectPath] ?? 0) + 1;
|
||
if (projectPath === '/tmp/broken-project') {
|
||
brokenRound += 1;
|
||
if (brokenRound > 2) {
|
||
return secondRoundInspection;
|
||
}
|
||
throw new Error('Tauri IPC 持续失败');
|
||
}
|
||
return { ...READY_PROJECT, projectPath };
|
||
},
|
||
);
|
||
window.__TAURI__ = { core: { invoke } };
|
||
window.localStorage.setItem(
|
||
'genarrative-ai-game-creator.recent-workspaces.v1',
|
||
JSON.stringify(['/tmp/broken-project']),
|
||
);
|
||
|
||
const { result } = renderHook(() => useRecentProjects(vi.fn()));
|
||
|
||
await waitFor(() => {
|
||
expect(result.current.projectRows[0]?.status).toBe('检查失败');
|
||
});
|
||
expect(inspectCounts['/tmp/broken-project']).toBe(2);
|
||
|
||
act(() => {
|
||
result.current.rememberRecentWorkspace('/tmp/ready-project');
|
||
});
|
||
|
||
// 上一轮的失败结果不进新投影:第二轮在途时该项目显示「检查中」而不是沿用「检查失败」。
|
||
await waitFor(() => {
|
||
expect(
|
||
result.current.projectRows.find(
|
||
(row) => row.path === '/tmp/broken-project',
|
||
)?.status,
|
||
).toBe('检查中');
|
||
});
|
||
|
||
await act(async () => {
|
||
secondRoundPending?.();
|
||
await secondRoundInspection;
|
||
});
|
||
});
|
||
|
||
test('提权类失败不重试:不放大 UAC 弹窗', async () => {
|
||
let attempts = 0;
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command !== 'inspect_local_project_directory') {
|
||
throw new Error(`unexpected invoke ${command}`);
|
||
}
|
||
attempts += 1;
|
||
throw new Error(
|
||
'读取待修复私有对象失败:C:\\p\\.agent(DACL 不包含当前用户)',
|
||
);
|
||
},
|
||
);
|
||
window.__TAURI__ = { core: { invoke } };
|
||
window.localStorage.setItem(
|
||
'genarrative-ai-game-creator.recent-workspaces.v1',
|
||
JSON.stringify(['/tmp/elevation-project']),
|
||
);
|
||
|
||
const { result } = renderHook(() => useRecentProjects(vi.fn()));
|
||
|
||
await waitFor(() => {
|
||
expect(result.current.projectRows[0]?.status).toBe('检查失败');
|
||
});
|
||
expect(attempts).toBe(1);
|
||
});
|
||
|
||
test('Rust 偏好事件与 command 返回同时到达时只检查新增项目一次', async () => {
|
||
let snapshot = {
|
||
projectCreationDirectory: null,
|
||
recentWorkspaces: ['/tmp/ready-project'],
|
||
};
|
||
let emitPreferenceEvent:
|
||
| ((event: { payload: typeof snapshot }) => void)
|
||
| null = null;
|
||
const inspectCounts: Record<string, number> = {};
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'read_workspace_preferences') {
|
||
return snapshot;
|
||
}
|
||
if (command === 'remember_recent_workspace') {
|
||
const projectPath = String(args?.projectPath ?? '');
|
||
snapshot = {
|
||
...snapshot,
|
||
recentWorkspaces: [projectPath, ...snapshot.recentWorkspaces],
|
||
};
|
||
emitPreferenceEvent?.({ payload: snapshot });
|
||
return snapshot;
|
||
}
|
||
if (command === 'inspect_local_project_directory') {
|
||
const projectPath = String(args?.projectPath ?? '');
|
||
inspectCounts[projectPath] = (inspectCounts[projectPath] ?? 0) + 1;
|
||
return { ...READY_PROJECT, projectPath };
|
||
}
|
||
throw new Error('unexpected invoke ' + command);
|
||
},
|
||
);
|
||
window.__TAURI__ = {
|
||
core: { invoke },
|
||
event: {
|
||
listen: vi.fn(async (_event, handler) => {
|
||
emitPreferenceEvent = handler;
|
||
return () => {
|
||
emitPreferenceEvent = null;
|
||
};
|
||
}),
|
||
},
|
||
};
|
||
|
||
const { result } = renderHook(() => useRecentProjects(vi.fn()));
|
||
await waitFor(() => {
|
||
expect(result.current.projectRows[0]).toMatchObject({
|
||
path: '/tmp/ready-project',
|
||
canOpen: true,
|
||
});
|
||
});
|
||
|
||
act(() => {
|
||
result.current.rememberRecentWorkspace('/tmp/new-project');
|
||
});
|
||
await waitFor(() => {
|
||
expect(result.current.projectRows[0]).toMatchObject({
|
||
path: '/tmp/new-project',
|
||
canOpen: true,
|
||
});
|
||
});
|
||
expect(inspectCounts['/tmp/new-project']).toBe(1);
|
||
|
||
test('Rust 侧取消 UAC 的稳定标记同样不触发重试', async () => {
|
||
let attempts = 0;
|
||
const invoke = vi.fn(
|
||
async (command: string, _args?: Record<string, unknown>) => {
|
||
if (command !== 'inspect_local_project_directory') {
|
||
throw new Error(`unexpected invoke ${command}`);
|
||
}
|
||
attempts += 1;
|
||
throw new Error(
|
||
'AGC_ACL_ELEVATION_DENIED:AGC ACL 提权修复被用户取消(exit code Some(1223))',
|
||
);
|
||
},
|
||
);
|
||
window.__TAURI__ = { core: { invoke } };
|
||
window.localStorage.setItem(
|
||
'genarrative-ai-game-creator.recent-workspaces.v1',
|
||
JSON.stringify(['/tmp/denied-elevation-project']),
|
||
);
|
||
|
||
const { result } = renderHook(() => useRecentProjects(vi.fn()));
|
||
|
||
await waitFor(() => {
|
||
expect(result.current.projectRows[0]?.status).toBe('检查失败');
|
||
});
|
||
expect(attempts).toBe(1);
|
||
});
|
||
});
|