diff --git a/Assets/Tests/EditMode/AI/AiFrameworkTests.cs b/Assets/Tests/EditMode/AI/AiFrameworkTests.cs new file mode 100644 index 00000000..5e3cd237 --- /dev/null +++ b/Assets/Tests/EditMode/AI/AiFrameworkTests.cs @@ -0,0 +1,243 @@ +using NUnit.Framework; +using BaseGames.AI; + +namespace BaseGames.Tests.EditMode.AI +{ + /// + /// BrainGraph 框架健壮性/边界测试:构图校验、转换优先级、全局转换、 + /// 事件与条件优先、队列信号、自转换、trace 上限、Blackboard、dt 传递。 + /// 与 AiRuntimeTests(主干行为)互补,覆盖易回归的边角。 + /// + public class AiFrameworkTests + { + // ── 构图校验(Build 应对错误声明抛异常,尽早暴露)────────────────── + + [Test] + public void Build_Throws_WhenEntryNotSet() + { + var b = new BrainBuilder(); + b.State("A"); + Assert.Throws(() => b.Build()); + } + + [Test] + public void Build_Throws_WhenEntryStateUndeclared() + { + var b = new BrainBuilder(); + b.Entry("Missing"); + b.State("A"); + Assert.Throws(() => b.Build()); + } + + // 局部转换指向未声明状态 → BrainBuilderTests.Build_UnknownTransitionTarget_Throws 已覆盖,此处不重复。 + + [Test] + public void Build_Throws_WhenGlobalTransitionTargetUndeclared() + { + var b = new BrainBuilder(); + b.Entry("A"); + b.State("A"); + b.Global().To("Ghost").OnEvent(AiSignal.Died); + Assert.Throws(() => b.Build()); + } + + [Test] + public void GlobalBuilder_Throws_WhenOnEventBeforeTo() + { + var b = new BrainBuilder(); + Assert.Throws(() => b.Global().OnEvent(AiSignal.Died)); + } + + [Test] + public void GlobalBuilder_Throws_WhenWhenBeforeTo() + { + var b = new BrainBuilder(); + Assert.Throws(() => b.Global().When(c => true, "x")); + } + + // ── 转换优先级 / 全局转换 ───────────────────────────────────────── + + [Test] + public void ConditionTransition_FirstDeclaredWins() + { + var b = new BrainBuilder(); + b.Entry("A"); + b.State("A") + .To("B").When(c => true, "first") + .To("C").When(c => true, "second"); // 两者都真,先声明的 B 胜 + b.State("B"); b.State("C"); + var rt = new AiRuntime(b.Build(), new FakeAiContext()); + rt.Tick(0.1f); + Assert.AreEqual("B", rt.CurrentStateName); + } + + [Test] + public void GlobalConditionTransition_Fires() + { + var b = new BrainBuilder(); + b.Entry("A"); + b.State("A"); b.State("Panic"); + b.Global().To("Panic").When(c => c.Vitals.HpBelow(0.3f), "lowHp"); + var ctx = new FakeAiContext(); + ctx.V.Hp = 0.1f; + var rt = new AiRuntime(b.Build(), ctx); + rt.Tick(0.1f); + Assert.AreEqual("Panic", rt.CurrentStateName); + } + + [Test] + public void GlobalConditionTransition_TakesPriorityOverLocal() + { + var b = new BrainBuilder(); + b.Entry("A"); + b.State("A").To("B").When(c => true, "local"); + b.State("B"); b.State("G"); + b.Global().To("G").When(c => true, "global"); // 全局先评估 + var rt = new AiRuntime(b.Build(), new FakeAiContext()); + rt.Tick(0.1f); + Assert.AreEqual("G", rt.CurrentStateName); + } + + // ── 事件 vs 条件优先 / 队列信号 ─────────────────────────────────── + + [Test] + public void EventTransition_TakesPriorityOverCondition_SameTick() + { + var b = new BrainBuilder(); + b.Entry("A"); + b.State("A") + .To("ByCond").When(c => true, "cond") // 条件恒真 + .To("ByEvent").OnEvent(AiSignal.Parried); // 事件优先(Tick 第1步) + b.State("ByCond"); b.State("ByEvent"); + var rt = new AiRuntime(b.Build(), new FakeAiContext()); + rt.Send(AiSignal.Parried); + rt.Tick(0.1f); + Assert.AreEqual("ByEvent", rt.CurrentStateName); + } + + [Test] + public void UnmatchedSignal_ConsumedThenConditionEvaluated() + { + var b = new BrainBuilder(); + b.Entry("A"); + b.State("A").To("B").When(c => c.Sensor.SeesPlayer(), "sees"); + b.State("B"); + var ctx = new FakeAiContext(); + ctx.S.Sees = true; + var rt = new AiRuntime(b.Build(), ctx); + rt.Send(AiSignal.Parried); // A 无 Parried 转换 → 被消费掉,不阻塞条件评估 + rt.Tick(0.1f); + Assert.AreEqual("B", rt.CurrentStateName); + } + + [Test] + public void MultipleQueuedSignals_FirstMatchWins_RemainingConsumed() + { + var b = new BrainBuilder(); + b.Entry("A"); + b.State("A") + .To("B").OnEvent(AiSignal.Parried) + .To("C").OnEvent(AiSignal.Staggered); + b.State("B"); b.State("C"); + var rt = new AiRuntime(b.Build(), new FakeAiContext()); + rt.Send(AiSignal.Parried); + rt.Send(AiSignal.Staggered); + rt.Tick(0.1f); + Assert.AreEqual("B", rt.CurrentStateName); // 第一个匹配的 Parried 先转 B + rt.Tick(0.1f); + Assert.AreEqual("B", rt.CurrentStateName); // 残留 Staggered 在 B 无匹配 → 被消费,仍 B + } + + // ── 自转换 / OnTick 时序 ────────────────────────────────────────── + + [Test] + public void ConditionSelfTransition_DoesNotReenter() + { + var b = new BrainBuilder(); + b.Entry("A"); + b.State("A") + .OnEnter(c => c.Blackboard.Set("enters", c.Blackboard.Get("enters") + 1)) + .To("A").When(c => true, "self"); // 自转换 + var ctx = new FakeAiContext(); + var rt = new AiRuntime(b.Build(), ctx); // 构造进入 → enters=1 + rt.Tick(0.1f); // 自转换应 no-op,不重跑 OnEnter + Assert.AreEqual(1, ctx.BB.Get("enters")); + Assert.AreEqual("A", rt.CurrentStateName); + } + + [Test] + public void TransitionWins_SkipsOnTick_SameFrame() + { + var b = new BrainBuilder(); + b.Entry("A"); + b.State("A") + .Tick(c => c.Blackboard.Set("ticked", true)) + .To("B").When(c => true, "go"); + b.State("B"); + var ctx = new FakeAiContext(); + var rt = new AiRuntime(b.Build(), ctx); + rt.Tick(0.1f); // 转换发生 → 当帧不应执行 A 的 OnTick + Assert.AreEqual("B", rt.CurrentStateName); + Assert.IsFalse(ctx.BB.Has("ticked")); + } + + [Test] + public void Tick_TwoArg_ReceivesDeltaTime() + { + var b = new BrainBuilder(); + b.Entry("A"); + b.State("A").Tick((c, dt) => c.Blackboard.Set("dt", dt)); // 无转换 + var ctx = new FakeAiContext(); + var rt = new AiRuntime(b.Build(), ctx); + rt.Tick(0.25f); + Assert.AreEqual(0.25f, ctx.BB.Get("dt"), 1e-5f); + } + + // ── trace 上限 / Blackboard / 挂起标志 ──────────────────────────── + + [Test] + public void Trace_CapsAtSixteen() + { + var b = new BrainBuilder(); + b.Entry("A"); + b.State("A").To("B").When(c => true, "toB"); + b.State("B").To("A").When(c => true, "toA"); // A↔B 每帧一次转换 + var rt = new AiRuntime(b.Build(), new FakeAiContext()); + for (int i = 0; i < 20; i++) rt.Tick(0.1f); + Assert.AreEqual(16, rt.Trace.Count); // 环形缓冲上限 16 + } + + // Blackboard 基础 Set/Get/Has/Clear/Snapshot → BlackboardTests 已覆盖,此处不重复。 + + [Test] + public void IsSuspended_ClearedAfterEventWhileNotControllable() + { + var b = new BrainBuilder(); + b.Entry("A"); + b.State("A"); b.State("Dead"); + b.Global().To("Dead").OnEvent(AiSignal.Died); + var ctx = new FakeAiContext(); + ctx.V.Controllable = false; // 受击中(若无事件本会挂起) + var rt = new AiRuntime(b.Build(), ctx); + rt.Send(AiSignal.Died); + rt.Tick(0.1f); + Assert.AreEqual("Dead", rt.CurrentStateName); + Assert.IsFalse(rt.IsSuspended); // 事件推进转换后清挂起 + } + + [Test] + public void State_Redeclaration_AccumulatesTransitions() + { + var b = new BrainBuilder(); + b.Entry("A"); + b.State("A").To("B").When(c => false, "t1"); // 第一次声明 + b.State("A").To("C").When(c => true, "t2"); // 再次 State("A") 应取同一状态,累加转换 + b.State("B"); b.State("C"); + var graph = b.Build(); + Assert.AreEqual(2, graph.GetState("A").Transitions.Count); + var rt = new AiRuntime(graph, new FakeAiContext()); + rt.Tick(0.1f); // t1 假、t2 真 → C + Assert.AreEqual("C", rt.CurrentStateName); + } + } +} diff --git a/Assets/Tests/EditMode/AI/AiFrameworkTests.cs.meta b/Assets/Tests/EditMode/AI/AiFrameworkTests.cs.meta new file mode 100644 index 00000000..e1f10499 --- /dev/null +++ b/Assets/Tests/EditMode/AI/AiFrameworkTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 54589f3969d282e44afc08d638e55512 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/EditMode/AI/PerceptionStateMachineTests.cs b/Assets/Tests/EditMode/AI/PerceptionStateMachineTests.cs index 0b60e833..2cc68623 100644 --- a/Assets/Tests/EditMode/AI/PerceptionStateMachineTests.cs +++ b/Assets/Tests/EditMode/AI/PerceptionStateMachineTests.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using UnityEngine; using NUnit.Framework; using BaseGames.AI; using BaseGames.Enemies; @@ -6,18 +7,38 @@ using BaseGames.Enemies; namespace BaseGames.Tests.EditMode.AI { /// - /// 回归测试:PerceptionStateMachine 的待机/巡逻/警觉声明 locomotion 意图, - /// 追击走能力(不直接驱动 locomotion)。转换规则见 spec《敌人感知↔状态》。 + /// PerceptionStateMachine 全感知↔状态矩阵测试(spec《敌人感知↔状态》): + /// 待机/巡逻(未发现) → 追击(优先)/警觉(次);警觉 → 追击/降级R/保持; + /// 追击 → 脱离全部感知才降级R(永不回警觉);死亡=全局事件。 + /// 覆盖 HasAlert 分支、locomotion 意图、追击能力触发/中断、de-escalation。 /// public class PerceptionStateMachineTests { - static AiGraph Graph() + // 实现 IAiContext + IEnemyActor(HasAlertState 可配),复用 FakeAiContext.cs 的子 fake。 + 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 bool HasAlert = true; + + public ISensor Sensor => S; + public IEnemyLocomotion Locomotion => L; + public ICombatant Combat => C; + public IActorVitals Vitals => V; + public Blackboard Blackboard => BB; + public bool HasAlertState => HasAlert; + } + + static AiGraph Graph(string entry = "Idle") { var b = new BrainBuilder(); PerceptionStateMachine.Add(b, new PerceptionStateMachine.Config { - Idle = "Idle", Patrol = "Patrol", Alert = "Alert", Chase = "Chase", - Death = "Death", Entry = "Idle", Rest = "Patrol", + Idle = "Idle", Patrol = "Patrol", Alert = "Alert", Chase = "Chase", Death = "Death", + Entry = entry, Rest = "Patrol", IdleMode = LocomotionMode.Idle, PatrolMode = LocomotionMode.Patrol, AlertMode = LocomotionMode.Face, @@ -26,37 +47,250 @@ namespace BaseGames.Tests.EditMode.AI return b.Build(); } + // ── 入口 / 待机 ─────────────────────────────────────────────────── + [Test] - public void EntryIdleState_SetsIdleLocomotionMode() + public void Entry_IsIdle_WithIdleLocomotion() { - var ctx = new FakeAiContext(); + var ctx = new Ctx(); var rt = new AiRuntime(Graph(), ctx); Assert.AreEqual("Idle", rt.CurrentStateName); - Assert.AreEqual(LocomotionMode.Idle, ctx.L.CurrentMode); // 进入待机即声明 Idle 意图 + Assert.AreEqual(LocomotionMode.Idle, ctx.L.CurrentMode); } [Test] - public void InChaseZone_TransitionsToChase_AndTriggersChaseAbility() + public void Idle_Stays_WhenNoPerception() { - var ctx = new FakeAiContext(); + var ctx = new Ctx(); var rt = new AiRuntime(Graph(), ctx); - ctx.S.Chase = true; // 进入追逐区 rt.Tick(0.1f); - Assert.AreEqual("Chase", rt.CurrentStateName); - CollectionAssert.Contains(ctx.C.Used, "chase"); // 追击由能力触发 + Assert.AreEqual("Idle", rt.CurrentStateName); } [Test] - public void ChaseState_DoesNotDeclareLocomotionIntent() + public void Idle_ToChase_WhenInChaseZone() { - // 追击移动归能力;AI 在 Chase 态不应再声明 SetMode/Approach/Face。 - var ctx = new FakeAiContext(); + var ctx = new Ctx(); var rt = new AiRuntime(Graph(), ctx); ctx.S.Chase = true; - rt.Tick(0.1f); // Idle → Chase(退出 Idle 会调用一次 Stop) + rt.Tick(0.1f); + Assert.AreEqual("Chase", rt.CurrentStateName); + } + + [Test] + public void Idle_ToAlert_WhenInVision_AndHasAlert() + { + var ctx = new Ctx { HasAlert = true }; + var rt = new AiRuntime(Graph(), ctx); + ctx.S.Vision = true; + rt.Tick(0.1f); + Assert.AreEqual("Alert", rt.CurrentStateName); + } + + [Test] + public void Idle_StaysIdle_WhenInVision_ButNoAlertState() + { + var ctx = new Ctx { HasAlert = false }; // 无警觉态 → 不进警觉 + var rt = new AiRuntime(Graph(), ctx); + ctx.S.Vision = true; + rt.Tick(0.1f); + Assert.AreEqual("Idle", rt.CurrentStateName); + } + + [Test] + public void Idle_ChaseBeatsAlert_WhenBothZones() + { + var ctx = new Ctx { HasAlert = true }; + var rt = new AiRuntime(Graph(), ctx); + ctx.S.Chase = true; ctx.S.Vision = true; // 两者都满足 → 追击优先 + rt.Tick(0.1f); + Assert.AreEqual("Chase", rt.CurrentStateName); + } + + // ── 巡逻(entry=Patrol):与待机同规则 ───────────────────────────── + + [Test] + public void Patrol_ToChase_WhenInChaseZone() + { + var ctx = new Ctx(); + var rt = new AiRuntime(Graph("Patrol"), ctx); + Assert.AreEqual("Patrol", rt.CurrentStateName); + Assert.AreEqual(LocomotionMode.Patrol, ctx.L.CurrentMode); + ctx.S.Chase = true; + rt.Tick(0.1f); + Assert.AreEqual("Chase", rt.CurrentStateName); + } + + [Test] + public void Patrol_ToAlert_WhenInVision_AndHasAlert() + { + var ctx = new Ctx { HasAlert = true }; + var rt = new AiRuntime(Graph("Patrol"), ctx); + ctx.S.Vision = true; + rt.Tick(0.1f); + Assert.AreEqual("Alert", rt.CurrentStateName); + } + + // ── 警觉 ────────────────────────────────────────────────────────── + + Ctx InAlert(out AiRuntime rt) + { + var ctx = new Ctx { HasAlert = true }; + rt = new AiRuntime(Graph(), ctx); + ctx.S.Vision = true; // Idle → Alert + rt.Tick(0.1f); + Assert.AreEqual("Alert", rt.CurrentStateName); + return ctx; + } + + [Test] + public void Alert_AppliesFaceLocomotion() + { + var ctx = InAlert(out var rt); + Assert.AreEqual(LocomotionMode.Face, ctx.L.CurrentMode); + CollectionAssert.Contains(ctx.L.Calls, "Face"); + } + + [Test] + public void Alert_ToChase_WhenInChaseZone() + { + var ctx = InAlert(out var rt); + ctx.S.Chase = true; + rt.Tick(0.1f); + Assert.AreEqual("Chase", rt.CurrentStateName); + } + + [Test] + public void Alert_ToRest_WhenLeftVision() + { + var ctx = InAlert(out var rt); + ctx.S.Vision = false; // 脱离视野 → 降级 R(=Patrol) + rt.Tick(0.1f); + Assert.AreEqual("Patrol", rt.CurrentStateName); + } + + [Test] + public void Alert_Stays_WhenStillInVision() + { + var ctx = InAlert(out var rt); + ctx.S.Vision = true; ctx.S.Chase = false; + rt.Tick(0.1f); + Assert.AreEqual("Alert", rt.CurrentStateName); + } + + // ── 追击 ────────────────────────────────────────────────────────── + + Ctx InChase(out AiRuntime rt) + { + var ctx = new Ctx(); + rt = new AiRuntime(Graph(), ctx); + ctx.S.Chase = true; // Idle → Chase + rt.Tick(0.1f); + Assert.AreEqual("Chase", rt.CurrentStateName); + return ctx; + } + + [Test] + public void Chase_TriggersAbility_NotLocomotion() + { + var ctx = InChase(out var rt); + CollectionAssert.Contains(ctx.C.Used, "chase"); // 触发追击能力 + // 进 Chase 后不应再有 locomotion 意图声明(追击移动归能力) int lastStop = ctx.L.Calls.FindLastIndex(s => s == "Stop"); - var afterEnteringChase = ctx.L.Calls.GetRange(lastStop + 1, ctx.L.Calls.Count - lastStop - 1); - CollectionAssert.IsEmpty(afterEnteringChase); + var afterEnter = ctx.L.Calls.GetRange(lastStop + 1, ctx.L.Calls.Count - lastStop - 1); + CollectionAssert.IsEmpty(afterEnter); + } + + [Test] + public void Chase_ToRest_WhenLeftAllZones() + { + var ctx = InChase(out var rt); + ctx.S.Chase = false; ctx.S.Vision = false; // 追逐区且视野区都不在 → R + rt.Tick(0.1f); + Assert.AreEqual("Patrol", rt.CurrentStateName); + } + + [Test] + public void Chase_Stays_WhenInChaseZoneOnly() + { + var ctx = InChase(out var rt); + ctx.S.Chase = true; ctx.S.Vision = false; // 视野被墙挡漏检,但仍在追逐区 + rt.Tick(0.1f); + Assert.AreEqual("Chase", rt.CurrentStateName); + } + + [Test] + public void Chase_Stays_WhenInVisionZoneOnly() + { + var ctx = InChase(out var rt); + ctx.S.Chase = false; ctx.S.Vision = true; // 出追逐区但仍在视野 → 不降级 + rt.Tick(0.1f); + Assert.AreEqual("Chase", rt.CurrentStateName); + } + + [Test] + public void Chase_NeverGoesToAlert() + { + // 追击中即便"在视野且有警觉态",也绝不回警觉——只在脱离全部感知时降级 R。 + var ctx = InChase(out var rt); + ctx.HasAlert = true; + ctx.S.Chase = false; ctx.S.Vision = true; + rt.Tick(0.1f); + Assert.AreEqual("Chase", rt.CurrentStateName); // 不是 Alert + Assert.AreNotEqual("Alert", rt.CurrentStateName); + } + + [Test] + public void Chase_OnExit_InterruptsAbility() + { + var ctx = InChase(out var rt); + Assert.AreEqual("chase", ctx.C.Running); // 追击能力运行中 + ctx.S.Chase = false; ctx.S.Vision = false; + rt.Tick(0.1f); // Chase → R,OnExit 中断能力 + Assert.IsNull(ctx.C.Running); + } + + [Test] + public void RestIsPatrol_AfterChaseDeescalation() + { + var ctx = InChase(out var rt); + ctx.S.Chase = false; ctx.S.Vision = false; + rt.Tick(0.1f); + Assert.AreEqual("Patrol", rt.CurrentStateName); // R = Patrol + Assert.AreEqual(LocomotionMode.Patrol, ctx.L.CurrentMode); // 落地即巡逻移动意图 + } + + // ── 死亡(全局事件,任意态可达)─────────────────────────────────── + + [Test] + public void Death_OnDiedSignal_FromIdle() + { + var ctx = new Ctx(); + var rt = new AiRuntime(Graph(), ctx); + rt.Send(AiSignal.Died); + rt.Tick(0.1f); + Assert.AreEqual("Death", rt.CurrentStateName); + } + + [Test] + public void Death_OnDiedSignal_FromChase() + { + var ctx = InChase(out var rt); + rt.Send(AiSignal.Died); + rt.Tick(0.1f); + Assert.AreEqual("Death", rt.CurrentStateName); + } + + [Test] + public void Death_IsTerminal_NoTransitionOut() + { + var ctx = new Ctx(); + var rt = new AiRuntime(Graph(), ctx); + rt.Send(AiSignal.Died); rt.Tick(0.1f); + Assert.AreEqual("Death", rt.CurrentStateName); + ctx.S.Chase = true; ctx.S.Vision = true; // 即便重新满足感知也不复活 + rt.Tick(0.1f); + Assert.AreEqual("Death", rt.CurrentStateName); } } }