Files
zeling_v2/Assets/Tests/EditMode/AI/PerceptionSkeletonTests.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

270 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 NUnit.Framework;
using BaseGames.AI;
using BaseGames.Enemies;
namespace BaseGames.Tests.EditMode.AI
{
public class PerceptionSkeletonTests
{
static AiRuntime Build(FakeAiContext ctx, IUnawareModule unaware = null, IEngagementModule engagement = null)
{
var b = new BrainBuilder();
PerceptionSkeleton.Add(b,
unaware ?? new SinglePost(LocomotionMode.Patrol),
engagement ?? new RushEngagement("rush", RushExit.OnLostTarget));
return new AiRuntime(b.Build(), ctx);
}
// ── 升级 ──────────────────────────────────────────────────────────
[Test]
public void Unaware_ToEngagement_WhenInChaseZone()
{
var ctx = new FakeAiContext();
var rt = Build(ctx);
ctx.S.Chase = true;
rt.Tick(0.1f);
Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName);
}
[Test]
public void Unaware_ToAlert_WhenInVision_AndHasAlert()
{
var ctx = new FakeAiContext { HasAlert = true };
var rt = Build(ctx);
ctx.S.Vision = true;
rt.Tick(0.1f);
Assert.AreEqual(PerceptionSkeleton.Alert, rt.CurrentStateName);
}
[Test]
public void Unaware_StaysUnaware_WhenInVision_ButNoAlertState()
{
var ctx = new FakeAiContext { HasAlert = false };
var rt = Build(ctx);
ctx.S.Vision = true;
rt.Tick(0.1f);
Assert.AreEqual(SinglePost.Post, rt.CurrentStateName);
}
[Test]
public void ChaseZone_BeatsAlert_WhenBothActive()
{
var ctx = new FakeAiContext { HasAlert = true };
var rt = Build(ctx);
ctx.S.Chase = true; ctx.S.Vision = true;
rt.Tick(0.1f);
Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName);
}
// 升级边必须优先于未发现层内部计时边
[Test]
public void UpgradeEdge_BeatsUnawareInternalDwellEdge()
{
var ctx = new FakeAiContext();
var rt = Build(ctx, new AlternatingIdlePatrol(idleDwell: 1f, patrolDwell: 1f));
Assert.AreEqual(AlternatingIdlePatrol.Idle, rt.CurrentStateName);
ctx.S.Chase = true;
rt.Tick(5f); // 计时早已到点,但追击边优先
Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName);
}
// ── 警觉 ──────────────────────────────────────────────────────────
[Test]
public void Alert_ToEngagement_WhenEnteringChaseZone()
{
var ctx = new FakeAiContext();
var rt = Build(ctx);
ctx.S.Vision = true; rt.Tick(0.1f);
Assert.AreEqual(PerceptionSkeleton.Alert, rt.CurrentStateName);
ctx.S.Chase = true; rt.Tick(0.1f);
Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName);
}
[Test]
public void Alert_ToRest_WhenLeavingVision()
{
var ctx = new FakeAiContext();
var rt = Build(ctx);
ctx.S.Vision = true; rt.Tick(0.1f);
ctx.S.Vision = false; rt.Tick(0.1f);
Assert.AreEqual(SinglePost.Post, rt.CurrentStateName);
}
[Test]
public void Alert_FacesLastKnown()
{
var ctx = new FakeAiContext();
ctx.S.Last = new UnityEngine.Vector2(7f, 2f);
var rt = Build(ctx);
ctx.S.Vision = true; rt.Tick(0.1f);
Assert.AreEqual(new UnityEngine.Vector2(7f, 2f), ctx.L.FacedAt);
}
// ── 降级:追击后永不回警觉 ─────────────────────────────────────────
[Test]
public void AfterEngagement_GoesToRest_NeverBackToAlert()
{
var ctx = new FakeAiContext();
var rt = Build(ctx);
ctx.S.Chase = true; rt.Tick(0.1f);
Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName);
ctx.S.Chase = false; ctx.S.Vision = false; rt.Tick(0.1f);
Assert.AreEqual(SinglePost.Post, rt.CurrentStateName); // 不是 Alert
}
// ── 死亡 ──────────────────────────────────────────────────────────
[Test]
public void Died_TransitionsToDeath_FromAnyState()
{
var ctx = new FakeAiContext();
var rt = Build(ctx);
ctx.S.Chase = true; rt.Tick(0.1f);
Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName);
rt.Send(AiSignal.Died);
rt.Tick(0.1f);
Assert.AreEqual(PerceptionSkeleton.Death, rt.CurrentStateName);
}
// 真实死亡路径:PerformDeath 先 ForceState(Dead) 再发 Died
// 所以信号被处理时 IsControllable 已为假。事件边必须仍能通过。
[Test]
public void Died_WorksEvenWhenNotControllable()
{
var ctx = new FakeAiContext();
var rt = Build(ctx);
ctx.V.Controllable = false; // 已是 Dead 物理态
rt.Send(AiSignal.Died);
rt.Tick(0.1f);
Assert.AreEqual(PerceptionSkeleton.Death, rt.CurrentStateName);
}
[Test]
public void Death_IsTerminal_NoConditionEdgesEvaluatedAfterwards()
{
var ctx = new FakeAiContext();
var rt = Build(ctx);
rt.Send(AiSignal.Died);
rt.Tick(0.1f);
ctx.V.Controllable = false; // 死后不可控
ctx.S.Chase = true; // 即便玩家又进追逐区
for (int i = 0; i < 10; i++) rt.Tick(0.1f);
Assert.AreEqual(PerceptionSkeleton.Death, rt.CurrentStateName); // 也不复活
}
// ── 校验 ──────────────────────────────────────────────────────────
sealed class BadUnaware : IUnawareModule
{
public string Entry => "X";
public string Rest => "NotInStates";
public IReadOnlyList<string> States => new[] { "X" };
public void Declare(BrainBuilder b) => AiStateFragments.Locomotion(b, "X", LocomotionMode.Idle);
public void Link(BrainBuilder b) { }
}
sealed class UndeclaredUnaware : IUnawareModule
{
public string Entry => "Y";
public string Rest => "Y";
public IReadOnlyList<string> States => new[] { "Y" };
public void Declare(BrainBuilder b) { } // 忘了声明行为
public void Link(BrainBuilder b) { }
}
[Test]
public void Throws_WhenRestNotInStates()
{
var b = new BrainBuilder();
Assert.Throws<System.InvalidOperationException>(() => PerceptionSkeleton.Add(
b, new BadUnaware(), new RushEngagement("rush")));
}
[Test]
public void Throws_WhenUnawareStateNotDeclared()
{
var b = new BrainBuilder();
Assert.Throws<System.InvalidOperationException>(() => PerceptionSkeleton.Add(
b, new UndeclaredUnaware(), new RushEngagement("rush")));
}
[Test]
public void Throws_WhenModuleIsNull()
{
var b = new BrainBuilder();
Assert.Throws<System.ArgumentNullException>(() => PerceptionSkeleton.Add(
b, null, new RushEngagement("rush")));
}
// ── 模块组合:抽象的存在理由,必须端到端验证 ─────────────────────
[Test]
public void Skeleton_WithApproachAttack_EscalatesAndDisengages()
{
var ctx = new FakeAiContext();
var rt = Build(ctx, new SinglePost(LocomotionMode.Patrol), new ApproachAttackEngagement());
Assert.AreEqual(SinglePost.Post, rt.CurrentStateName);
ctx.S.Chase = true; rt.Tick(0.1f);
Assert.AreEqual(ApproachAttackEngagement.Approach, rt.CurrentStateName);
ctx.C.Eligible = true; rt.Tick(0.1f);
Assert.AreEqual(ApproachAttackEngagement.Attack, rt.CurrentStateName);
ctx.C.Running = null; rt.Tick(0.1f); // 招式打完
Assert.AreEqual(ApproachAttackEngagement.Approach, rt.CurrentStateName);
ctx.S.Chase = false; ctx.S.Vision = false; ctx.C.Eligible = false;
rt.Tick(0.1f);
Assert.AreEqual(SinglePost.Post, rt.CurrentStateName); // 脱战回 Rest
}
[Test]
public void Skeleton_WithDisguiseThenPatrol_DisengagesToPatrol_NotDisguise()
{
// 唯一 Entry != Rest 的未发现层:出生伪装静止,脱战只回巡逻,伪装态从此不可达。
var ctx = new FakeAiContext();
var rt = Build(ctx, new DisguiseThenPatrol());
Assert.AreEqual(DisguiseThenPatrol.Disguise, rt.CurrentStateName);
ctx.S.Chase = true; rt.Tick(0.1f);
Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName);
ctx.S.Chase = false; ctx.S.Vision = false; rt.Tick(0.1f);
Assert.AreEqual(DisguiseThenPatrol.Patrol, rt.CurrentStateName); // 不是 Disguise
// 再交战一轮,仍然只回巡逻——伪装态不可逆
ctx.S.Chase = true; rt.Tick(0.1f);
Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName);
ctx.S.Chase = false; rt.Tick(0.1f);
Assert.AreEqual(DisguiseThenPatrol.Patrol, rt.CurrentStateName);
}
[Test]
public void EntryCanBeOverridden_ForEnemiesWithPrefixStates()
{
// 骨架注释承诺的出路:掉落链 / 出场链这类前置态,调 Add 之后再覆盖 Entry。
var ctx = new FakeAiContext();
var b = new BrainBuilder();
PerceptionSkeleton.Add(b, new SinglePost(LocomotionMode.Patrol),
new RushEngagement("rush", RushExit.OnLostTarget));
AiStateFragments.Locomotion(b, "Ceiling", LocomotionMode.Idle)
.To(SinglePost.Post).When(x => x.Sensor.SeesPlayer(), "landed");
b.Entry("Ceiling"); // 覆盖骨架设的入口
var rt = new AiRuntime(b.Build(), ctx);
Assert.AreEqual("Ceiling", rt.CurrentStateName);
ctx.S.Sees = true; rt.Tick(0.1f);
Assert.AreEqual(SinglePost.Post, rt.CurrentStateName); // 落地后并入感知层
ctx.S.Chase = true; rt.Tick(0.1f);
Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName); // 升级边照常生效
}
}
}