using NUnit.Framework; using UnityEngine; using BaseGames.AI; using BaseGames.Enemies; namespace BaseGames.Tests.EditMode.AI { public class PerceptionRecipeSoTests { sealed class Ctx : IAiContext, IEnemyActor { public FakeSensor S = new FakeSensor(); public FakeLocomotion L = new FakeLocomotion(); public FakeCombat C = new FakeCombat(); public FakeVitals V = new FakeVitals(); public Blackboard BB = new Blackboard(); public ISensor Sensor => S; public IEnemyLocomotion Locomotion => L; public ICombatant Combat => C; public IActorVitals Vitals => V; public Blackboard Blackboard => BB; public bool HasAlertState => true; } static PerceptionRecipeSO MakeE001Recipe() { var so = ScriptableObject.CreateInstance(); 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 Ctx(); 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(); var ex = Assert.Throws(() => 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 Validate(PerceptionRecipeSO so) => new System.Collections.Generic.List(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(); 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(); ability.abilityId = "e001_chase"; ability.cooldown = 0f; var so = ScriptableObject.CreateInstance(); 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); } } }