Files
Genarrative/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx
T
k88936 594063a1e0 测试:修正 AGC 测试文件的类型错误
- 按 vitest 0.34 的参数元组口径改写 mock 泛型
- 修正 noUncheckedIndexedAccess 下的下标访问
- 补齐与当前类型脱节的测试桩字段
- 按 scripts/*.mjs 的运行时契约补局部类型声明
2026-10-03 17:41:30 +08:00

326 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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';
type TauriInvoke = NonNullable<
NonNullable<NonNullable<Window['__TAURI__']>['core']>['invoke']
>;
type TauriListen = NonNullable<
NonNullable<NonNullable<Window['__TAURI__']>['event']>['listen']
>;
function asTauriInvoke(
invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>,
): TauriInvoke {
// 泛型 invoke 与具体的非泛型 mock 结构上不可兼容,只能用 as unknown as 收敛。
return invoke as unknown as TauriInvoke;
}
function asTauriListen(
listen: (
event: string,
handler: (event: { payload: unknown }) => void,
) => Promise<unknown>,
): TauriListen {
// 泛型 listen 与携带具体负载的 mock 结构上不可兼容,只能用 as unknown as 收敛。
return listen as unknown as TauriListen;
}
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: asTauriInvoke(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: asTauriInvoke(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: asTauriInvoke(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: asTauriInvoke(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: asTauriInvoke(invoke) },
event: {
listen: asTauriListen(
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: asTauriInvoke(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);
});
});