Files
zeling_v2/Assets/Tests/EditMode/AI/PerceptionStateMachineTests.cs
T
joywayer 55b6255ce6 @
test(ai): BrainGraph 全面测试——框架边界 + 全感知矩阵(70 tests 全绿)

保证自研 AI 状态机(BrainGraph)稳定与功能正确性。EditMode AI 测试从 31 增至 70。

新增 AiFrameworkTests(17)——框架健壮性/边界(与 AiRuntimeTests 主干互补):
- 构图校验:Entry 未设/未声明、全局转换目标未声明、GlobalBuilder 顺序错用抛异常
- 转换优先级:同态多条件首声明胜、全局条件转换、全局优先于局部
- 事件 vs 条件:事件优先(Tick 首步)、未匹配信号被消费不阻塞、多信号首个匹配胜
- 时序:条件自转换 no-op、转换当帧跳过 OnTick、Tick 双参传 dt
- trace 环形上限 16、事件推进后清挂起、State 重声明累加转换

重写 PerceptionStateMachineTests(22)——完整感知↔状态矩阵:
- 待机/巡逻/警觉/追击/死亡 全转换 + HasAlert 分支(无警觉态不进警觉)
- 追击优先于警觉;追击永不回警觉、脱离全部感知才降级 R(=Patrol)
- 视野单独/追逐区单独仍维持追击;locomotion 意图(Idle/Patrol/Face)
- 追击触发能力、离开中断能力;死亡为终态不复活

验证:反射调用全部 [Test] 方法(负对照确认能捕获失败),70/70 通过。
去重:Blackboard/局部转换目标校验已由 BlackboardTests/BrainBuilderTests 覆盖,不重复。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-07-20 11:11:54 +08:00

297 lines
11 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 System.Collections.Generic;
using UnityEngine;
using NUnit.Framework;
using BaseGames.AI;
using BaseGames.Enemies;
namespace BaseGames.Tests.EditMode.AI
{
/// <summary>
/// PerceptionStateMachine 全感知↔状态矩阵测试(spec《敌人感知↔状态》):
/// 待机/巡逻(未发现) → 追击(优先)/警觉(次);警觉 → 追击/降级R/保持;
/// 追击 → 脱离全部感知才降级R(永不回警觉);死亡=全局事件。
/// 覆盖 HasAlert 分支、locomotion 意图、追击能力触发/中断、de-escalation。
/// </summary>
public class PerceptionStateMachineTests
{
// 实现 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 = entry, Rest = "Patrol",
IdleMode = LocomotionMode.Idle,
PatrolMode = LocomotionMode.Patrol,
AlertMode = LocomotionMode.Face,
ChaseAbilityId = "chase",
});
return b.Build();
}
// ── 入口 / 待机 ───────────────────────────────────────────────────
[Test]
public void Entry_IsIdle_WithIdleLocomotion()
{
var ctx = new Ctx();
var rt = new AiRuntime(Graph(), ctx);
Assert.AreEqual("Idle", rt.CurrentStateName);
Assert.AreEqual(LocomotionMode.Idle, ctx.L.CurrentMode);
}
[Test]
public void Idle_Stays_WhenNoPerception()
{
var ctx = new Ctx();
var rt = new AiRuntime(Graph(), ctx);
rt.Tick(0.1f);
Assert.AreEqual("Idle", rt.CurrentStateName);
}
[Test]
public void Idle_ToChase_WhenInChaseZone()
{
var ctx = new Ctx();
var rt = new AiRuntime(Graph(), ctx);
ctx.S.Chase = true;
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 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 → ROnExit 中断能力
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);
}
}
}