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>
138 lines
5.6 KiB
C#
138 lines
5.6 KiB
C#
using NUnit.Framework;
|
||
using UnityEngine;
|
||
using BaseGames.AI;
|
||
using BaseGames.Enemies;
|
||
|
||
namespace BaseGames.Tests.EditMode.AI
|
||
{
|
||
public class PerceptionRecipeSoTests
|
||
{
|
||
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 FakeAiContext();
|
||
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);
|
||
}
|
||
}
|
||
}
|