diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs index 57c8d3bd1..5058c504b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs @@ -1729,3 +1729,97 @@ pub(crate) fn acquire_game_creator_agent_provider_plan_project_write_lock_with_w ) -> Result { acquire_game_creator_agent_runtime_project_write_lock_with_wait(root, command_id) } + +/// M1C-1 把 `userRevisionPending` 同时加进了 `StaticDelegateCompletionBarrier::detail()` +/// 的输出和本模块的三个解析门,两边靠一个字段名字符串隔空对齐,中间没有共享 schema。 +/// +/// 当时生产侧只有 `delegation.rs` 一条 `detail().contains("userRevisionPending=1")`, +/// 解析侧一条测试都没有,两者之间也没有任何东西相连。字段名一改,生产侧那条照过,而这 +/// 三个门会静默返回 false——父 run 于是越过用户修订边界收束。同样,往 `has_waiting()` +/// 里加一个计数而忘了加进解析器(或反之),也没有任何用例会报警。 +/// +/// 所以这里锁的不是拼写,是**等价关系**:对七个计数的全部组合,解析门的判定必须与 +/// barrier 自己的语义谓词逐一相等。 +#[cfg(test)] +mod static_delegate_barrier_detail_gate_tests { + use super::*; + use crate::delegation::StaticDelegateCompletionBarrier; + + fn assert_gates_agree_with_barrier(barrier: StaticDelegateCompletionBarrier) { + let detail = barrier.detail(); + assert_eq!( + static_delegate_barrier_has_waiting_deliveries(&detail), + barrier.has_waiting(), + "has_waiting() 与 detail 解析必须等价:{barrier:?}\ndetail={detail}" + ); + assert_eq!( + static_delegate_barrier_requires_repair(&detail), + barrier.repair_required_count > 0, + "repairRequired 往返失真:{barrier:?}\ndetail={detail}" + ); + assert_eq!( + static_delegate_barrier_requires_user_revision(&detail), + barrier.user_revision_pending_count > 0, + "userRevisionPending 往返失真:{barrier:?}\ndetail={detail}" + ); + } + + #[test] + fn barrier_detail_round_trips_through_every_gate_for_all_count_combinations() { + let mut checked = 0usize; + for bits in 0u32..(1 << 7) { + let present = |index: u32| usize::from(bits & (1 << index) != 0); + assert_gates_agree_with_barrier(StaticDelegateCompletionBarrier { + waiting_count: present(0), + ready_unclaimed_count: present(1), + unobserved_claim_count: present(2), + repair_required_count: present(3), + user_input_required_count: present(4), + user_revision_pending_count: present(5), + unknown_contract_status_count: present(6), + }); + checked += 1; + } + assert_eq!(checked, 128, "必须覆盖七个计数的全部 0/1 组合"); + } + + /// 门读的是计数而不是「等于 1」:`detail()` 里出现多位数时不能失配。 + #[test] + fn barrier_detail_gates_read_multi_digit_counts() { + assert_gates_agree_with_barrier(StaticDelegateCompletionBarrier { + waiting_count: 12, + ready_unclaimed_count: 34, + unobserved_claim_count: 56, + repair_required_count: 78, + user_input_required_count: 90, + user_revision_pending_count: 123, + unknown_contract_status_count: 456, + }); + } + + /// 没有任何键是另一个键的前缀——否则 `strip_prefix` 会读到隔壁字段的值。 + /// 这条是给未来改名加的护栏:等价关系测试能抓到读错值,但抓不到「读对了值却 + /// 是因为两个键碰巧不冲突」这层前提何时被打破。 + #[test] + fn barrier_detail_keys_are_prefix_free() { + let detail = StaticDelegateCompletionBarrier::default().detail(); + let keys = detail + .split_whitespace() + .filter_map(|part| part.split_once('=').map(|(key, _)| key)) + .collect::>(); + assert!( + keys.len() >= 7, + "detail 必须仍以 key=value 形式给出全部计数:{detail}" + ); + for (index, key) in keys.iter().enumerate() { + for (other_index, other) in keys.iter().enumerate() { + if index != other_index { + assert!( + !other.starts_with(key), + "detail 键 `{key}` 是 `{other}` 的前缀,strip_prefix 会读错字段" + ); + } + } + } + } +}