自审收口 #599:空 expected 失败关闭、异步回填生命周期守卫、合并 master 后过测试类型门禁
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m0s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 3m49s
Project CI / Backend tests (pull_request) Failing after 3m43s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 6m23s
Project CI / Frontend tests (pull_request) Successful in 3m11s
Project CI / Repository checks (pull_request) Failing after 4m13s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m5s
Project CI / Native shell tests (pull_request) Successful in 7m13s

- 合并 origin/master tip ccd271572(merge commit eeac2c759),无冲突;本分支不修 master 既有共享红(check:nginx-spa-routes / external_mcp::semantic 32 与 30 / http_tracing 偶发 / AGC Rust shard 4/4,由 fix/ci-master-red 处理)。
- 按 master 新增的测试类型门禁(check:tests:types)把两处直接赋值 window.__TAURI__ 迁到 installTauriRuntime / resetTauriRuntime(tests/tauriRuntimeFake.ts 是全仓唯一安装点),否则 appSurface/home.suite.ts 报 TS2322。
- Rust:rename_local_game_project_if_unchanged_at 对空 expectedProjectId / expectedName 按仓库同类入口口径失败关闭(不再静默 renamed:false);非空但不匹配仍是正常跳过。新增 1 条单测,含「非空不匹配不是错误」的反向保护。
- 前端:applyAutomaticProjectName 在 await entrySettled 之后加壳生命周期守卫(mounted + 代次,与 enterProjectDevelopment 同口径):关窗 / 卸载后只保留已落盘的改名,不再写当前项目上下文、不再重检最近项目。新增 2 条 hook 用例(unmount / pagehide),去掉守卫即变红。
- 注释口径:把 suggestName 判据与 ProjectStartMode 的映射写清(planning 覆盖「做方案」与勾选「策划补全」的「做游戏」,见 view/home/homeStartMode.ts),行为不变。
- 文档:decision-log 的验证数字更新为 12 例 / 6 例 / 全量 1922 passed,并补记生命周期守卫与空 expected 口径。
- 定向验证:homeProjectNamingAsync 12 passed;appSurface.test.ts 197 passed / 9 skipped;AGC 全量 1922 passed / 17 skipped;cargo conditional_project_rename_tests 6 passed;cargo fmt --check、npm run agc:typecheck(含 check:tests:types)、check:encoding、git diff --check 全过。
This commit is contained in:
2026-10-04 00:38:34 +08:00
parent eeac2c759a
commit 4144c04b1c
5 changed files with 130 additions and 10 deletions
@@ -522,12 +522,20 @@ pub(crate) fn rename_local_game_project_if_unchanged_at(
name: &str,
) -> Result<ConditionalRenameLocalProjectResult, String> {
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");
@@ -447,6 +447,7 @@ export function useHomeProjectCreation({
created: InitLocalProjectResult,
entrySettled: Promise<void>,
) {
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',
});
}
@@ -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]),
@@ -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<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();
});
@@ -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)