using NUnit.Framework; using BaseGames.AI; using BaseGames.Enemies; namespace BaseGames.Tests.EditMode.AI { /// /// Build() 必须挡住「非终态却没有出口」的状态。 /// /// 既有校验只做正向——边指向的态是否已声明(RequireState 的同款关切); /// 反向从不检查。一个已声明却没有任何出边的非终态会让敌人永久停在那里, /// 且全程零报错,与 RequireState 要防的静默失败是同一类,只是方向相反。 /// public class BrainBuilderDeadEndTests { [Test] public void Build_NonTerminalStateWithNoOutgoing_Throws() { var b = new BrainBuilder(); b.Entry("A"); b.DeclareState("A").To("B").When(c => true, "go"); b.DeclareState("B"); // 忘了挂出边 var ex = Assert.Throws(() => b.Build()); StringAssert.Contains("B", ex.Message, "报错必须点名是哪个态,否则大图里无从查起"); } [Test] public void Build_TerminalState_IsAllowedToHaveNoOutgoing() { var b = new BrainBuilder(); b.Entry("A"); b.DeclareState("A").To("Dead").When(c => true, "die"); b.DeclareState("Dead").Terminal(); Assert.DoesNotThrow(() => b.Build()); } [Test] public void Build_StateWithOnlySelfTransition_Throws() { // 自转换在 AiRuntime.Switch 里是 no-op(Target == 当前态直接 return false), // 出不去——等同于没有出边,不能算作出口。 var b = new BrainBuilder(); b.Entry("A"); b.DeclareState("A").To("B").When(c => true, "go"); b.DeclareState("B").To("B").When(c => true, "self"); var ex = Assert.Throws(() => b.Build()); StringAssert.Contains("B", ex.Message); } [Test] public void Build_GlobalTransition_DoesNotCountAsOutgoing() { // 全局事件边要外部推信号才触发,不是自主出口;把它算作出口, // 等于让「只有死了才出得去」的死角通过校验——那正是要暴露的东西。 var b = new BrainBuilder(); b.Entry("A"); b.DeclareState("A").To("B").When(c => true, "go"); b.DeclareState("B"); b.DeclareState("Dead").Terminal(); b.Global().To("Dead").OnEvent(AiSignal.Died); Assert.Throws(() => b.Build()); } [Test] public void AiStateFragments_Terminal_MarksStateAsTerminal() { // 生产入口:骨架与 Boss 图都经这个片段声明死亡终态, // 它必须自带标记,否则每个图都要额外记得手动标一次。 var b = new BrainBuilder(); b.Entry("A"); b.DeclareState("A").To("Death").When(c => true, "die"); AiStateFragments.Terminal(b, "Death"); Assert.DoesNotThrow(() => b.Build()); } } }