Files
zeling_v2/Assets/Tests/EditMode/AI/PerceptionRecipeSoTests.cs
T
joywayerandClaude Opus 5 2a1ada4388 fix(ai): 应用 Task 1+2 代码审查意见
ResourceFull 未挂 BossResource 时改为显式抛——原先静默返回 false,
是 CLAUDE.md 第 6 条禁止的下游兜底,且与紧邻的 Boss facet 行为不一致。
FakeBossControl.DistanceToAnchor 不再忽略 index。测试改用 TearDown 清理
(本项目关闭了 Domain/Scene Reload,失败断言会把物体永久留在打开的场景里)。
BeginPhaseTransition 去掉死的默认参数;接口补充重入契约说明。

顺带去重:FakeAiContext 实现 IEnemyActor,删除两个测试内的本地 Ctx 副本
——一个接口成员改动要同步三份是复发性成本。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 12:31:39 +08:00

138 lines
5.6 KiB
C#
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.
using NUnit.Framework;
using UnityEngine;
using BaseGames.AI;
using BaseGames.Enemies;
namespace BaseGames.Tests.EditMode.AI
{
public class PerceptionRecipeSoTests
{
static PerceptionRecipeSO MakeE001Recipe()
{
var so = ScriptableObject.CreateInstance<PerceptionRecipeSO>();
so.AssignModules(
new DisguiseThenPatrol(),
new RushEngagement("e001_chase", RushExit.Committed));
return so;
}
[Test]
public void Graph_IsCachedAcrossCalls_Flyweight()
{
var so = MakeE001Recipe();
Assert.AreSame(so.GetOrBuildGraph(), so.GetOrBuildGraph());
Object.DestroyImmediate(so);
}
[Test]
public void Graph_MatchesEquivalentHandWrittenAssembly()
{
var so = MakeE001Recipe();
var fromRecipe = so.GetOrBuildGraph();
var b = new BrainBuilder();
PerceptionSkeleton.Add(b,
new DisguiseThenPatrol(),
new RushEngagement("e001_chase", RushExit.Committed));
var handWritten = b.Build();
Assert.AreEqual(handWritten.EntryState, fromRecipe.EntryState);
Object.DestroyImmediate(so);
}
[Test]
public void Graph_BehavesLikeE001()
{
var so = MakeE001Recipe();
var ctx = new FakeAiContext();
var rt = new AiRuntime(so.GetOrBuildGraph(), ctx);
Assert.AreEqual(DisguiseThenPatrol.Disguise, rt.CurrentStateName);
ctx.S.Chase = true;
rt.Tick(0.1f);
Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName);
Object.DestroyImmediate(so);
}
[Test]
public void UnconfiguredRecipe_ThrowsClearError_NotSilentlyInert()
{
// 新建的空配方默认用 RushEngagement 但尚未配能力。建图时必须**显式报错**,
// 而不是静默产出一张"永远不会出手"的图——第 6 条:暴露问题,不掩盖。
// 注:策划建完资产到配好模块之间没有任何东西会调 GetOrBuildGraph()
// 它只在 EnemyAiBrain.Start() 运行时被调,所以这里报错不影响编辑期体验。
var so = ScriptableObject.CreateInstance<PerceptionRecipeSO>();
var ex = Assert.Throws<System.InvalidOperationException>(() => so.GetOrBuildGraph());
StringAssert.Contains("RushEngagement", ex.Message);
Object.DestroyImmediate(so);
}
[Test]
public void AssignModules_InvalidatesCachedGraph()
{
// 不失效的话,装配后拿到的还是旧图——静默的陈旧配置。
var so = MakeE001Recipe();
var first = so.GetOrBuildGraph();
so.AssignModules(new SinglePost(LocomotionMode.Idle),
new RushEngagement("other", RushExit.OnLostTarget));
var second = so.GetOrBuildGraph();
Assert.AreNotSame(first, second);
Assert.AreEqual(SinglePost.Post, second.EntryState); // 新模块生效
Object.DestroyImmediate(so);
}
static System.Collections.Generic.List<BaseGames.Core.ValidationResult> Validate(PerceptionRecipeSO so)
=> new System.Collections.Generic.List<BaseGames.Core.ValidationResult>(so.Validate());
[Test]
public void Validate_Clean_WhenFullyConfigured()
{
var so = MakeE001Recipe();
CollectionAssert.IsEmpty(Validate(so));
Object.DestroyImmediate(so);
}
[Test]
public void Validate_ReportsError_WhenAbilityMissing()
{
// 默认配方:RushEngagement 未配能力
var so = ScriptableObject.CreateInstance<PerceptionRecipeSO>();
var results = Validate(so);
Assert.IsTrue(results.Exists(r => r.Severity == BaseGames.Core.ValidationSeverity.Error));
Object.DestroyImmediate(so);
}
[Test]
public void Validate_DoesNotDisturbCachedGraph()
{
// 校验用一次性 builder 探测,不得动共享缓存——否则同一配方的下一只敌人
// 会拿到不同的图实例,破坏 flyweight 不变量。
var so = MakeE001Recipe();
var before = so.GetOrBuildGraph(); // 先把缓存捂热(不经 AssignModules
Validate(so);
var after = so.GetOrBuildGraph();
Assert.AreSame(before, after); // 校验前后必须是同一张图
Object.DestroyImmediate(so);
}
[Test]
public void Validate_ReportsError_WhenCommittedRushHasZeroCooldown()
{
// Committed 的防抖动保证依赖能力自身有冷却;cooldown<=0 时冲锋态↔未发现态
// 每帧抖动,这是配置期就能发现的错误,必须在校验里显式报出。
var ability = ScriptableObject.CreateInstance<BaseGames.Enemies.Abilities.EnemyAbilitySO>();
ability.abilityId = "e001_chase";
ability.cooldown = 0f;
var so = ScriptableObject.CreateInstance<PerceptionRecipeSO>();
so.AssignModules(new DisguiseThenPatrol(), new RushEngagement(ability, RushExit.Committed));
var results = Validate(so);
Assert.IsTrue(results.Exists(r =>
r.Severity == BaseGames.Core.ValidationSeverity.Error && r.Message.Contains("cooldown")));
Object.DestroyImmediate(so);
Object.DestroyImmediate(ability);
}
}
}