diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 6ce43229b..e98373ab7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -522,12 +522,20 @@ pub(crate) fn rename_local_game_project_if_unchanged_at( name: &str, ) -> Result { let next_name = normalize_game_creation_project_name(name.trim())?; + let expected_project_id = expected_project_id.trim(); + if expected_project_id.is_empty() { + return Err("项目改名 expectedProjectId 不能为空".to_string()); + } + let expected_name = expected_name.trim(); + if expected_name.is_empty() { + return Err("项目改名 expectedName 不能为空".to_string()); + } enforce_project_permission_policy(root, "project.rename")?; let _lock = acquire_project_write_lock(root, "project.rename")?; let current = read_existing_manifest_for_project(root)?; let revision = read_game_creator_agent_runtime_project_revision(root)?.revision; // 项目身份不匹配:目录换了项目或身份被改写,一律不改名。 - if current.project_id != expected_project_id.trim() { + if current.project_id != expected_project_id { return Ok(ConditionalRenameLocalProjectResult { renamed: false, manifest: current, @@ -535,7 +543,7 @@ pub(crate) fn rename_local_game_project_if_unchanged_at( }); } // 用户已经手动改过名(或别的流程改过):兜底名不再成立,绝不覆盖。 - if current.name != expected_name.trim() { + if current.name != expected_name { return Ok(ConditionalRenameLocalProjectResult { renamed: false, manifest: current, @@ -696,6 +704,50 @@ mod conditional_project_rename_tests { assert_eq!(project_revision(root), revision_before); } + #[test] + fn conditional_rename_rejects_empty_expectations_without_touching_the_project() { + let project = fixture_root("conditional-rename-empty-expectation"); + let root = project.path(); + let revision_before = project_revision(root); + + // 空 expectedProjectId / expectedName 与其它按 expected 判等的入口同口径:失败关闭, + // 而不是静默地按「不匹配」跳过——空串永远不可能是调用方真正想表达的身份。 + for (expected_project_id, expected_name, marker) in [ + ("", FALLBACK_NAME, "expectedProjectId"), + (" ", FALLBACK_NAME, "expectedProjectId"), + ("conditional-rename-empty-expectation", "", "expectedName"), + ( + "conditional-rename-empty-expectation", + " ", + "expectedName", + ), + ] { + let error = rename_local_game_project_if_unchanged_at( + root, + expected_project_id, + expected_name, + "星轨夜航", + ) + .expect_err("empty expectation must fail closed"); + assert!(error.contains(marker), "{error} 应指出 {marker}"); + } + + assert_eq!(manifest_name(root), FALLBACK_NAME); + assert_eq!(project_revision(root), revision_before); + + // 非空但不匹配仍然是「跳过」这条正常路径:用户已改名不是错误。 + let skipped = rename_local_game_project_if_unchanged_at( + root, + "conditional-rename-empty-expectation", + "用户自己起的名字", + "星轨夜航", + ) + .expect("stale but non-empty expectation is not an error"); + assert!(!skipped.renamed); + assert_eq!(skipped.manifest.name, FALLBACK_NAME); + assert_eq!(manifest_name(root), FALLBACK_NAME); + } + #[test] fn conditional_rename_reports_false_when_name_already_matches() { let project = fixture_root("conditional-rename-same-name"); diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts index aba425bf2..abcfce24c 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts @@ -447,6 +447,7 @@ export function useHomeProjectCreation({ created: InitLocalProjectResult, entrySettled: Promise, ) { + const generation = lifecycleRef.current.generation; let suggested: string | null = null; try { suggested = (await naming)?.trim() || null; @@ -478,6 +479,19 @@ export function useHomeProjectCreation({ return; } await entrySettled; + /** + * 等待期间壳可能已经被卸载(关窗 / 切走 / StrictMode 重放)。 + * + * 落盘的改名是项目级事实(换台机器打开也是 AI 名),照旧保留;但「当前项目上下文」与 + * 「最近项目行重检」都是本壳的 UI 投影,卸载后一律不写——与 `enterProjectDevelopment` + * 的守卫同口径(`mounted` + 代次),否则迟到的命名结果会写进已经作废的壳。 + */ + if ( + !lifecycleRef.current.mounted || + generation !== lifecycleRef.current.generation + ) { + return; + } setCurrentProjectContext((current) => current && current.projectPath === created.projectPath ? { @@ -914,8 +928,9 @@ export function useHomeProjectCreation({ startMode: ProjectStartMode, ) { return createHomeDraftAutomaticallyWithOptions(draft, startMode, { - // 做方案的首轮还要调用一次策划 Provider;项目命名对它没有价值, - // 「做方案」也从不发起自动项目命名请求。 + // 判据就是 `ProjectStartMode`:'planning' 覆盖「做方案」和勾了「策划补全」的「做游戏」 + // (见 view/home/homeStartMode.ts),这一档只调策划 Provider,项目命名对它没有价值; + // 只有 'direct-build' 才发起自动项目命名请求。 suggestName: startMode !== 'planning', }); } diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index 74019c7a3..02c685ccd 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -1878,10 +1878,10 @@ export function registerHomeProjectCreationTests() { return chatHarness.invoke(command, args); }, ); - window.__TAURI__ = { + installTauriRuntime({ core: { invoke }, event: { listen: chatHarness.listen }, - }; + }); window.localStorage.setItem( 'genarrative-ai-game-creator.recent-workspaces.v1', JSON.stringify([projectPath]), diff --git a/apps/ai-game-creator-shell/tests/homeProjectNamingAsync.test.tsx b/apps/ai-game-creator-shell/tests/homeProjectNamingAsync.test.tsx index c23103b50..f259c5981 100644 --- a/apps/ai-game-creator-shell/tests/homeProjectNamingAsync.test.tsx +++ b/apps/ai-game-creator-shell/tests/homeProjectNamingAsync.test.tsx @@ -5,6 +5,7 @@ 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'; @@ -14,7 +15,7 @@ const SUGGESTED_NAME = '星河远征'; afterEach(() => { cleanup(); - delete window.__TAURI__; + resetTauriRuntime(); }); /** 手控闸门:测试自己决定命名请求何时返回,不靠 sleep 赌时序。 */ @@ -66,7 +67,7 @@ function mount(options: { return null; }); const refreshRecentWorkspace = vi.fn(async () => {}); - window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__; + installTauriRuntime({ core: { invoke } }); const hook = renderHook(() => useHomeProjectCreation({ setStatus: vi.fn(), @@ -365,3 +366,55 @@ it('does not hijack the project the user already moved to when the AI name lands // 但刚刚建好的工作区确实改名了:最近项目行照旧按目录重检。 expect(refreshRecentWorkspace).toHaveBeenCalledWith(PROJECT_PATH); }); + +it('does not write the project context after the launcher unmounted', async () => { + const naming = deferred(); + 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(); + 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(); +}); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index ac2121eca..f8abf4607 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4,9 +4,9 @@ - 背景:首页「开启创作」原先串行执行「Web 预检 → `await suggest_automatic_project_name` → `create_automatic_local_game_project`」。项目名称不是创建工作区、导入附件或发起首轮创作的前置条件,命名请求(`AUTOMATIC_PROJECT_NAME_TIMEOUT_MS = 15s`,正常请求同样占时)却把用户按在「正在创建工作区」上。 - 决策:建项与命名拆成两条并行链路。建项固定传 `name: null`,由宿主既有兜底名(`GameAgent 项目 <8 位短 id>` / `策划项目 <8 位短 id>`)落盘并立即进入项目;命名请求在建项前打出、结果交给后台任务。后台拿到合法名称后调用新增的 `rename_local_game_project_if_unchanged(projectPath, expectedProjectId, expectedName, name)`:只有「项目 ID 相同」且「当前名称仍是本次创建的兜底名」才改名,返回 `renamed: true/false`(跳过时不写盘、不是错误)。用户已手动改名、项目 ID 不符、名称与现状相同一律跳过;空名 / 控制字符 / 超长由 `normalize_game_creation_project_name` 失败关闭。 -- 边界:改名是簿记写入,与既有 `rename_local_game_project` 同口径**不推进项目 revision**(推 revision 会让运行时验证凭证无故漂移);返回的 `revision` 是当时盘上的值。后台改名结果写「当前项目上下文」与「最近项目行重检」必须等建项主体收尾(`entrySettled`):AI 比进项目更快时直接写上下文会被随后的 `enterProjectDevelopment` 用兜底名覆盖,最近项目行也要等进项目登记过才会被重检。「做方案」与手动选目录建项不发起自动命名。 +- 边界:改名是簿记写入,与既有 `rename_local_game_project` 同口径**不推进项目 revision**(推 revision 会让运行时验证凭证无故漂移);返回的 `revision` 是当时盘上的值。后台改名结果写「当前项目上下文」与「最近项目行重检」必须等建项主体收尾(`entrySettled`):AI 比进项目更快时直接写上下文会被随后的 `enterProjectDevelopment` 用兜底名覆盖,最近项目行也要等进项目登记过才会被重检;写入前再过壳的生命周期守卫(`mounted` + 代次),关窗/卸载后只保留已落盘的改名、不写 UI 投影。「做方案」与手动选目录建项不发起自动命名。条件改名的 `expectedProjectId` / `expectedName` 为空时按仓库同类入口口径失败关闭(而不是静默 `renamed: false`)。 - 影响范围:`apps/ai-game-creator-shell/src-tauri/src/{commands.rs,desktop.rs}`、`apps/ai-game-creator-shell/src/{app/types.ts,features/app-shell/{useHomeProjectCreation.ts,WorkspaceLauncher.tsx}}`、`apps/ai-game-creator-shell/tests/{homeProjectNamingAsync.test.tsx,appSurface/home.suite.ts}`。 -- 验证:`npx vitest run apps/ai-game-creator-shell/tests/homeProjectNamingAsync.test.tsx`(9 passed,含「命名请求永不返回仍进工作区」「AI 结果先于进项目落定仍不被兜底名覆盖」「手动改名不被覆盖」「非法/空响应保留兜底名」「做方案不发起命名」);AGC 全量 `npm run test -- apps/ai-game-creator-shell/tests`(1910 passed / 17 skipped);`cargo test --features=cocos-editor-execute,unity-editor-execute,godot-editor-execute conditional_project_rename_tests`(5 passed);`npm run agc:typecheck`、`npm run check:encoding`、`git diff --check`。 +- 验证:`npx vitest run apps/ai-game-creator-shell/tests/homeProjectNamingAsync.test.tsx`(12 passed,含「命名请求永不返回仍进工作区」「AI 结果先于进项目落定仍不被兜底名覆盖」「手动改名不被覆盖」「非法/空响应保留兜底名」「做方案不发起命名」「切到别的工作区不被劫持」「卸载/pagehide 后不写上下文」);AGC 全量 `npm run test -- apps/ai-game-creator-shell/tests`(1922 passed / 17 skipped,合并 `origin/master` tip `ccd271572` 后复测);`cargo test --features=cocos-editor-execute,unity-editor-execute,godot-editor-execute conditional_project_rename_tests`(6 passed,含空 `expectedProjectId` / `expectedName` 失败关闭);`npm run agc:typecheck`(含 `check:tests:types`)、`cargo fmt --check`、`npm run check:encoding`、`git diff --check`。 ## 2026-10-03 AGC 栏目画布上传素材按入口栏目登记(Issue 359)