Files
Genarrative/apps/ai-game-creator-shell/tests/homeProjectNamingAsync.test.tsx
T
suzmii a554b0c7ca 把 AI 项目命名接进「生成任务」体系:账本 v2 任务类型、命名任务入队与推进、面板命名行
- 账本 v2:记录新增 taskType(asset-generation / project-naming,缺省 asset-generation,旧壳按素材任务读回),kind 改为可选;schema 升 agc-asset-generation-task.v2,读取同时接受 v1 与 v2。
- 新增 enqueue_local_project_naming_task / update_local_project_naming_task:命名任务入队时登记 live、终态写盘后摘除(写盘失败只回滚本轮插入),中断残留按命名口径收口为 failed(文案不含「目标素材」)。
- 首页命名链路改为推进这条任务:入队 → 命名中 → 已完成(已应用 AI 名称 / 建议名与现状一致 / 用户已手动改名而跳过)或失败(无可用名称 / 自动改名失败,均保留兜底名);账本只作展示旁路,推进失败只写诊断日志。
- 前端模型与面板支持命名行:按 taskType 把命名记录从素材任务分支剔除,命名行固定展示名「AI 项目命名」、只在终态显示结论、不渲染缩略图/提示词/定位动作;命令名写字面量以满足 check-config 静态枚举门禁。
- 工作台订阅账本变更事件做一次受控快照读取刷新命名行(不新增固定频率轮询);另修:命名行的 useLiveNow 收进独立组件,避免行种类变化导致 hooks 数量变化。
- 测试:homeProjectNamingAsync 12 例(新增入队→命名中→终态推进序列断言)、projectNamingGenerationTaskRow 3 例、appSurface 建项后命名行可见 + 事件刷新出终态结论 + 标题/列表同步;Rust asset_generation_task 22 例(含 naming_task_stays_in_flight_across_ledger_reads_until_terminal、legacy_v1_ledger_reads_back_*)。
- 文档:decision-log 改写为「生成任务体系里的后台任务」、pitfalls 新增 taskType/live 集合条目、实施计划专题补任务接入口径。
- 门禁:AGC 全量 1925 passed / 17 skipped;cargo 定向 22 + 6;npm run agc:typecheck(含 check:tests:types 与 check-config);cargo fmt --check;check:encoding;git diff --check。
2026-10-04 16:59:59 +08:00

497 lines
16 KiB
TypeScript

// @vitest-environment jsdom
import { act, cleanup, renderHook } from '@testing-library/react';
import { afterEach, expect, it, vi } from 'vitest';
import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
import type { HomeProjectCreationController } from '../src/features/app-shell/useHomeProjectCreation';
import { useHomeProjectCreation } from '../src/features/app-shell/useHomeProjectCreation';
import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake';
const PROJECT_PATH = 'C:/test/async-naming-game';
const PROJECT_ID = 'gameagent-1a2b3c4d';
/** 宿主在建项时给的兜底名(`GameAgent 项目 <8 位短 id>`)。 */
const FALLBACK_NAME = 'GameAgent 项目 1a2b3c4d';
const SUGGESTED_NAME = '星河远征';
afterEach(() => {
cleanup();
resetTauriRuntime();
});
/** 手控闸门:测试自己决定命名请求何时返回,不靠 sleep 赌时序。 */
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
}
function mount(options: {
naming: Promise<string | null> | string;
/** 条件改名命令的返回;缺省表示调用它就是测试失败。 */
conditionalRename?: () => Promise<unknown>;
/** 其它命令的按名处理;用于把某一步(如进项目时的预览核验)卡在手控闸门上。 */
extraCommands?: Record<
string,
(args?: Record<string, unknown>) => Promise<unknown>
>;
}) {
const order: string[] = [];
const namingTaskCalls: Array<{
command: string;
args: Record<string, unknown>;
}> = [];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'capture_analytics_context') return {};
if (command === 'preflight_web_game_creation') return { status: 'ready' };
if (
command === 'enqueue_local_project_naming_task' ||
command === 'update_local_project_naming_task'
) {
namingTaskCalls.push({ command, args: args ?? {} });
return null;
}
if (command === 'suggest_automatic_project_name') {
order.push(command);
return options.naming;
}
if (command === 'create_automatic_local_game_project') {
order.push(command);
return {
projectPath: PROJECT_PATH,
manifestPath: `${PROJECT_PATH}/.agent/manifest.json`,
manifest: createGameCreationAppManifest(PROJECT_ID, FALLBACK_NAME),
};
}
if (command === 'get_local_game_project_revision') return { revision: 1 };
if (command === 'rename_local_game_project_if_unchanged') {
if (!options.conditionalRename) {
throw new Error('未预期的条件改名调用');
}
return options.conditionalRename();
}
const extra = options.extraCommands?.[command];
if (extra) {
return extra();
}
return null;
},
);
const refreshRecentWorkspace = vi.fn(async () => {});
installTauriRuntime({ core: { invoke } });
const hook = renderHook(() =>
useHomeProjectCreation({
setStatus: vi.fn(),
showTransientNotice: vi.fn(),
setLauncherView: vi.fn(),
rememberRecentWorkspace: vi.fn(),
refreshRecentWorkspace,
}),
);
return { ...hook, invoke, order, namingTaskCalls, refreshRecentWorkspace };
}
/**
* 「AI 项目命名」任务在账本里的推进序列:`命令:状态`。
*
* 三个断言点共用同一份口径:入队(无 status)→ running → 终态。
*/
function namingTaskStatusTrace(
calls: ReadonlyArray<{ command: string; args: Record<string, unknown> }>,
): string[] {
return calls.map(
(call) =>
`${call.command.replace('_local_project_naming_task', '')}:${
typeof call.args.status === 'string' ? call.args.status : ''
}`,
);
}
function createAutomatically(
result: { current: HomeProjectCreationController },
startMode: 'direct-build' | 'planning' = 'direct-build',
) {
return result.current.createHomeDraftAutomatically(
{ creationType: 'game', prompt: '做一个小游戏', attachments: [] },
startMode,
);
}
it('enters the workspace even though the AI naming request never returns', async () => {
const naming = deferred<string | null>();
const { result, invoke, order, namingTaskCalls } = mount({
naming: naming.promise,
});
let creation!: Promise<string>;
act(() => {
creation = createAutomatically(result);
});
let status = '';
// 命名闸门一直关着。若建项链路还在等命名,这里会一直挂到用例超时——
// 这正是"`create_automatic_local_game_project` 不再等待命名"的判定方式。
await act(async () => {
status = await creation;
});
expect(status).toBe('已创建工作区并进入项目开发');
expect(result.current.projectPath).toBe(PROJECT_PATH);
expect(result.current.currentProjectContext?.projectName).toBe(FALLBACK_NAME);
// 命名请求先打出、建项随后并行,两者都不等对方。
expect(order).toEqual([
'suggest_automatic_project_name',
'create_automatic_local_game_project',
]);
expect(invoke).toHaveBeenCalledWith('create_automatic_local_game_project', {
name: null,
planning: false,
});
expect(invoke).not.toHaveBeenCalledWith(
'rename_local_game_project_if_unchanged',
expect.anything(),
);
// 命名还没回来:任务已经入队并标成「命名中」,用户能在生成任务里看到它在跑。
expect(namingTaskStatusTrace(namingTaskCalls)).toEqual([
'enqueue:',
'update:running',
]);
await act(async () => {
naming.resolve(null);
await naming.promise;
});
});
it('applies a valid AI name after it arrives and refreshes the recent row', async () => {
const naming = deferred<string | null>();
const { result, invoke, namingTaskCalls, refreshRecentWorkspace } = mount({
naming: naming.promise,
conditionalRename: async () => ({
renamed: true,
manifest: createGameCreationAppManifest(PROJECT_ID, SUGGESTED_NAME),
revision: 7,
}),
});
await act(async () => {
await createAutomatically(result);
});
expect(result.current.currentProjectContext?.projectName).toBe(FALLBACK_NAME);
await act(async () => {
naming.resolve(` ${SUGGESTED_NAME} `);
await naming.promise;
});
expect(invoke).toHaveBeenCalledWith(
'rename_local_game_project_if_unchanged',
{
projectPath: PROJECT_PATH,
// 条件改名的判据由前端原样转述:项目 ID + 这次创建的兜底名称。
expectedProjectId: PROJECT_ID,
expectedName: FALLBACK_NAME,
name: SUGGESTED_NAME,
},
);
expect(result.current.currentProjectContext?.projectName).toBe(
SUGGESTED_NAME,
);
expect(result.current.currentProjectContext?.manifest.name).toBe(
SUGGESTED_NAME,
);
expect(result.current.currentProjectContext?.projectRevision).toBe(7);
expect(refreshRecentWorkspace).toHaveBeenCalledWith(PROJECT_PATH);
// 「生成任务」列表里那条「AI 项目命名」按入队 → 命名中 → 已完成推进,结论可自解释。
expect(namingTaskStatusTrace(namingTaskCalls)).toEqual([
'enqueue:',
'update:running',
'update:completed',
]);
expect(namingTaskCalls.map((call) => call.args.taskId)).toEqual([
`project-naming:${PROJECT_ID}`,
`project-naming:${PROJECT_ID}`,
`project-naming:${PROJECT_ID}`,
]);
expect(String(namingTaskCalls.at(-1)?.args.phaseDetail)).toContain(
SUGGESTED_NAME,
);
});
it('keeps the AI name when the naming result lands before the project is entered', async () => {
const renamedManifest = createGameCreationAppManifest(
PROJECT_ID,
SUGGESTED_NAME,
);
// 进项目时的会话预览核验卡住:AI 改名先落定,进项目还没写完上下文。
let releasePreview!: () => void;
const previewGate = new Promise<void>((resolve) => {
releasePreview = resolve;
});
let markRenameInvoked!: () => void;
const renameInvoked = new Promise<void>((resolve) => {
markRenameInvoked = resolve;
});
const { result } = mount({
// 命名瞬时返回(最快的模型情况),建项与改名之间没有等待。
naming: SUGGESTED_NAME,
conditionalRename: async () => {
markRenameInvoked();
return { renamed: true, manifest: renamedManifest, revision: 1 };
},
extraCommands: {
get_local_game_preview_status: async () => {
await previewGate;
return null;
},
},
});
let creation!: Promise<string>;
act(() => {
creation = createAutomatically(result);
});
await act(async () => {
await renameInvoked;
});
// 此刻改名已经落定,但工作区上下文还没建立。
expect(result.current.currentProjectContext).toBeNull();
await act(async () => {
releasePreview();
await creation;
});
// 进项目写下的兜底上下文之后,后台改名结果必须仍然生效,不能被覆盖回兜底名。
expect(result.current.currentProjectContext?.projectName).toBe(
SUGGESTED_NAME,
);
expect(result.current.currentProjectContext?.manifest.name).toBe(
SUGGESTED_NAME,
);
});
it.each(['', ' ', null])(
'keeps the fallback name for a non-usable AI response (%s)',
async (response) => {
const naming = deferred<string | null>();
const { result, invoke, namingTaskCalls } = mount({
naming: naming.promise,
conditionalRename: async () => {
throw new Error('空/非法 AI 名称不应触发改名');
},
});
await act(async () => {
await createAutomatically(result);
});
await act(async () => {
naming.resolve(response);
await naming.promise;
});
expect(invoke).not.toHaveBeenCalledWith(
'rename_local_game_project_if_unchanged',
expect.anything(),
);
expect(result.current.currentProjectContext?.projectName).toBe(
FALLBACK_NAME,
);
// 失败/超时/非法内容:任务按 failed 收口,兜底名保留。
expect(namingTaskStatusTrace(namingTaskCalls)).toEqual([
'enqueue:',
'update:running',
'update:failed',
]);
expect(String(namingTaskCalls.at(-1)?.args.phaseDetail)).toContain(
'保留兜底名',
);
},
);
it('does not overwrite a name the user already changed', async () => {
const naming = deferred<string | null>();
const { result, namingTaskCalls } = mount({
naming: naming.promise,
// 用户已经手动改过名:宿主按「当前名称不再是兜底名」整体跳过。
conditionalRename: async () => ({
renamed: false,
manifest: createGameCreationAppManifest(PROJECT_ID, '我自己起的名字'),
revision: 9,
}),
});
await act(async () => {
await createAutomatically(result);
});
await act(async () => {
naming.resolve(SUGGESTED_NAME);
await naming.promise;
});
expect(result.current.currentProjectContext?.projectName).toBe(FALLBACK_NAME);
// 跳过不是失败:任务按 completed 收口,并且结论自己说清「为什么没改名」。
expect(namingTaskStatusTrace(namingTaskCalls)).toEqual([
'enqueue:',
'update:running',
'update:completed',
]);
expect(String(namingTaskCalls.at(-1)?.args.phaseDetail)).toContain(
'已跳过 AI 命名',
);
});
it('keeps the created workspace when the conditional rename fails', async () => {
const naming = deferred<string | null>();
const { result, namingTaskCalls } = mount({
naming: naming.promise,
conditionalRename: async () => {
throw new Error('项目正在被其他写操作占用');
},
});
let status = '';
await act(async () => {
status = await createAutomatically(result);
});
await act(async () => {
naming.resolve(SUGGESTED_NAME);
await naming.promise;
});
expect(status).toBe('已创建工作区并进入项目开发');
expect(result.current.projectPath).toBe(PROJECT_PATH);
expect(result.current.currentProjectContext?.projectName).toBe(FALLBACK_NAME);
expect(namingTaskStatusTrace(namingTaskCalls)).toEqual([
'enqueue:',
'update:running',
'update:failed',
]);
expect(String(namingTaskCalls.at(-1)?.args.phaseDetail)).toContain(
'已保留兜底名',
);
});
it('never starts automatic naming for the 做方案 flow', async () => {
const naming = deferred<string | null>();
const { result, invoke } = mount({ naming: naming.promise });
await act(async () => {
await createAutomatically(result, 'planning');
});
expect(invoke).not.toHaveBeenCalledWith(
'suggest_automatic_project_name',
expect.anything(),
);
expect(invoke).toHaveBeenCalledWith('create_automatic_local_game_project', {
name: null,
planning: true,
});
await act(async () => {
naming.resolve(null);
await naming.promise;
});
});
it('does not hijack the project the user already moved to when the AI name lands', async () => {
const naming = deferred<string | null>();
const { result, refreshRecentWorkspace } = mount({
naming: naming.promise,
conditionalRename: async () => ({
renamed: true,
manifest: createGameCreationAppManifest(PROJECT_ID, SUGGESTED_NAME),
revision: 1,
}),
});
await act(async () => {
await createAutomatically(result);
});
// 用户已经切到另一个工作区(看门狗解围后打开别项目走的是同一条「上下文已被别人接管」判据)。
await act(async () => {
result.current.setCurrentProjectContext({
projectPath: 'C:/test/other-project',
projectName: '另一个项目',
projectKind: 'web',
manifest: createGameCreationAppManifest('other-project', '另一个项目'),
projectRevision: 3,
creationType: null,
startMode: null,
initialPrompt: '',
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
createdAt: 1,
});
});
await act(async () => {
naming.resolve(SUGGESTED_NAME);
await naming.promise;
});
// 后台改名不能把用户当前所在的项目上下文换成刚建好的那个工作区。
expect(result.current.currentProjectContext?.projectPath).toBe(
'C:/test/other-project',
);
expect(result.current.currentProjectContext?.projectName).toBe('另一个项目');
// 但刚刚建好的工作区确实改名了:最近项目行照旧按目录重检。
expect(refreshRecentWorkspace).toHaveBeenCalledWith(PROJECT_PATH);
});
it('does not write the project context after the launcher unmounted', async () => {
const naming = deferred<string | null>();
const { result, unmount, refreshRecentWorkspace } = mount({
naming: naming.promise,
conditionalRename: async () => ({
renamed: true,
manifest: createGameCreationAppManifest(PROJECT_ID, SUGGESTED_NAME),
revision: 1,
}),
});
await act(async () => {
await createAutomatically(result);
});
unmount();
await act(async () => {
naming.resolve(SUGGESTED_NAME);
await naming.promise;
});
// 卸载后迟到的命名结果不再写上下文,也就不该走到上下文写入后面的最近项目重检。
// (卸载后 `result.current` 冻结在最后一次渲染,只有这条副作用能观测到越界写入。)
expect(refreshRecentWorkspace).not.toHaveBeenCalled();
});
it('does not write the project context after the window was hidden', async () => {
const naming = deferred<string | null>();
const { result, refreshRecentWorkspace } = mount({
naming: naming.promise,
conditionalRename: async () => ({
renamed: true,
manifest: createGameCreationAppManifest(PROJECT_ID, SUGGESTED_NAME),
revision: 1,
}),
});
await act(async () => {
await createAutomatically(result);
});
// `pagehide` 是壳自己的作废信号(关窗 / 切走),与卸载走同一枚 mounted + 代次守卫。
await act(async () => {
window.dispatchEvent(new Event('pagehide'));
});
await act(async () => {
naming.resolve(SUGGESTED_NAME);
await naming.promise;
});
expect(result.current.currentProjectContext?.projectName).toBe(FALLBACK_NAME);
expect(refreshRecentWorkspace).not.toHaveBeenCalled();
});