Files
zeling_v2/Assets/Tests/EditMode/AI/PerceptionRecipeSoTests.cs
T
joywayerandClaude Opus 5 3665504a58 feat(ai): 新增 IBossControl facet——Boss 阶段状态经 IAiContext 供图读取
非 Boss 敌人访问 IAiContext.Boss 显式抛 InvalidOperationException,
不返回空对象掩盖小怪图误挂 Boss 边的错误。BossResource 补 IsFull 供图条件边判定。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 12:15:54 +08:00

155 lines
6.3 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 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 FakeBossControl B = new FakeBossControl();
public ISensor Sensor => S;
public IEnemyLocomotion Locomotion => L;
public ICombatant Combat => C;
public IActorVitals Vitals => V;
public Blackboard Blackboard => BB;
public IBossControl Boss => B;
public bool HasAlertState => true;
}
static PerceptionRecipeSO MakeE001Recipe()
{
var so = ScriptableObject.CreateInstance<PerceptionRecipeSO>();
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<PerceptionRecipeSO>();
var ex = Assert.Throws<System.InvalidOperationException>(() => 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<BaseGames.Core.ValidationResult> Validate(PerceptionRecipeSO so)
=> new System.Collections.Generic.List<BaseGames.Core.ValidationResult>(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<PerceptionRecipeSO>();
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<BaseGames.Enemies.Abilities.EnemyAbilitySO>();
ability.abilityId = "e001_chase";
ability.cooldown = 0f;
var so = ScriptableObject.CreateInstance<PerceptionRecipeSO>();
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);
}
}
}