using NUnit.Framework; using UnityEngine; using UnityEngine.TestTools; using BaseGames.Enemies; using BaseGames.Enemies.States; namespace BaseGames.Tests.EditMode.Enemies { /// /// 受击类状态(Hurt / Stagger / KnockUp)在**没有对应动画 Clip** 时, /// 必须仍然安排回到 Controlled——否则敌人被打一次就永久停在该态, /// IsControllable 恒假、AI 永久停摆。 /// /// 这是本项目「禁止下游兜底掩盖问题」的反面:早退本身不是兜底, /// 但早退**同时丢掉了恢复安排**,把「动画没配」这个可恢复的缺失 /// 变成了「敌人永久失能」这个不可恢复的故障。 /// public class EnemyHitStateRecoveryTests { /// /// 记录 ScheduleStateRecovery 调用而不真正启协程——EditMode 下没有播放循环, /// 协程不会推进,直接断言"是否安排了恢复"才是这个缺陷的可测形态。 /// private sealed class RecordingEnemy : EnemyBase { public int Calls; public EnemyStateType LastFromState; public float LastDelay; public override void ScheduleStateRecovery(EnemyStateType fromState, float delay) { Calls++; LastFromState = fromState; LastDelay = delay; } } private GameObject _host; [SetUp] public void SetUp() => LogAssert.ignoreFailingMessages = true; [TearDown] public void TearDown() { // 项目关闭了 Domain/Scene Reload:断言失败会跳过用例后续语句, // 清理必须放在 TearDown,否则失败一次就往编辑器场景里漏一个对象。 if (_host != null) Object.DestroyImmediate(_host); _host = null; LogAssert.ignoreFailingMessages = false; } /// /// EditMode 下 AddComponent 不触发 Awake,动画引用保持为空—— /// 这正好等价于"美术未接入"的生产场景,无需再造假配置。 /// 前提用 AnimConfig 断言(而非 Animancer):后者会把 Animancer 程序集 /// 拖进测试程序集的引用,而 AnimConfig 为空已足以让三个状态都走无动画分支。 /// private RecordingEnemy MakeEnemyWithoutAnimation() { _host = new GameObject("enemy-without-anim"); var e = _host.AddComponent(); Assert.IsNull(e.AnimConfig, "前提:本用例要覆盖的是无动画路径"); return e; } [Test] public void HurtState_WithoutClip_SchedulesRecovery() { var enemy = MakeEnemyWithoutAnimation(); new EnemyHurtState().Enter(enemy); Assert.AreEqual(1, enemy.Calls, "无受击动画时必须安排回到 Controlled,否则敌人被打一次即永久失能"); Assert.AreEqual(EnemyStateType.Hurt, enemy.LastFromState, "恢复必须限定为从 Hurt 态出发,避免覆盖其后可能已切换的状态"); } [Test] public void StaggerState_WithoutClip_SchedulesRecovery() { var enemy = MakeEnemyWithoutAnimation(); new EnemyStaggerState().Enter(enemy); Assert.AreEqual(1, enemy.Calls, "无僵直动画时必须安排回到 Controlled——霸体被破也是 TakeDamage 的常规分支"); Assert.AreEqual(EnemyStateType.Stagger, enemy.LastFromState); Assert.Greater(enemy.LastDelay, 0f, "僵直是给玩家的惩罚窗口,时长必须为正,否则等同于没有僵直"); } [Test] public void KnockUpState_WithoutClip_StillSchedulesRecovery() { // 回归保护:KnockUp 本来就有 else 分支,本次修复不得破坏它 var enemy = MakeEnemyWithoutAnimation(); new EnemyKnockUpState().Enter(enemy); Assert.AreEqual(1, enemy.Calls); Assert.AreEqual(EnemyStateType.KnockUp, enemy.LastFromState); Assert.Greater(enemy.LastDelay, 0f); } } }