using System.Reflection; using NUnit.Framework; using UnityEngine; using UnityEngine.TestTools; using BaseGames.Combat; using BaseGames.Parry; using BaseGames.Enemies; namespace BaseGames.Tests.EditMode.Combat { /// /// 弹反反制接线:玩家弹反成功时,发起这次攻击的敌人必须硬直。 /// /// EnemyBase.ReceiveParry 早就存在且形态正确(强制 Stagger + 打断能力 + 定时恢复), /// 但一直零调用者——弹反管线(HurtBox → ParrySystem.ConsumeParry)拿不到攻击者引用。 /// 硬直时长的权威也早就存在:ParryConfigSO.StaggerDuration,注释写着 /// 「被弹反敌人的受击硬直时长」,同样零消费者。本次是把这两端接上,不新造语义。 /// public class ParryCounterWiringTests { /// 攻击方探针:记录是否被通知、以及拿到的硬直时长。 private sealed class SpyAttacker : MonoBehaviour, IParryable { public int Calls; public float LastDuration = -1f; public void ReceiveParry(float staggerDuration) { Calls++; LastDuration = staggerDuration; } } /// 受击方:HurtBox.Awake 要在父级找到 IDamageable,否则流水线首步就返回。 private sealed class FakeVictim : MonoBehaviour, IDamageable { public bool IsAlive => true; public bool IsInvincible => false; public int Defense => 0; public void TakeDamage(DamageInfo info) { } } private GameObject _victim; private GameObject _attacker; private ParryConfigSO _config; [SetUp] public void SetUp() => LogAssert.ignoreFailingMessages = true; [TearDown] public void TearDown() { // ParrySystem 的完美弹反会改 Time.timeScale 后 yield,编辑模式下协程不会恢复。 // 本用例已用配置避开那条路径,这里再兜一道,避免任何意外把编辑器留在慢放状态。 Time.timeScale = 1f; // 项目关闭了 Domain/Scene Reload:断言失败会跳过用例后续语句, // 清理必须放在 TearDown,否则失败一次就往编辑器场景里漏一个对象。 if (_victim != null) Object.DestroyImmediate(_victim); if (_attacker != null) Object.DestroyImmediate(_attacker); if (_config != null) Object.DestroyImmediate(_config); _victim = null; _attacker = null; _config = null; LogAssert.ignoreFailingMessages = false; } // 反射只用来「喂配置」与「跑真实的 Awake」,被测逻辑全程走生产代码。 // 与 BossPhaseAbilityGateTests 里给私有序列化字段喂数据是同一手法。 private static void SetPrivateField(object target, string field, object value) => target.GetType() .GetField(field, BindingFlags.NonPublic | BindingFlags.Instance) .SetValue(target, value); private static void InvokeAwake(object target) => target.GetType() .GetMethod("Awake", BindingFlags.NonPublic | BindingFlags.Instance) .Invoke(target, null); /// 建一个「弹反窗口已开启」的受击方。 private HurtBox MakeParryingVictim() { _config = ScriptableObject.CreateInstance(); // 完美弹反会 StartCoroutine 改 timeScale;窗口刚开时 elapsed==0 必然判定为完美。 // 阈值设为负数即可让 IsInPerfectWindow 恒假,把用例限制在普通弹反这条路径上。 _config.PerfectParryThreshold = -1f; _config.StaggerDuration = 1.25f; // 与默认值不同,才能证明读的是配置而非常量 _victim = new GameObject("victim"); _victim.AddComponent(); // HurtBox 的 RequireComponent 指向抽象 Collider2D _victim.AddComponent(); var hurtBox = _victim.AddComponent(); Assert.IsNotNull(hurtBox, "HurtBox 需要先有具体 Collider2D 才能挂载"); InvokeAwake(hurtBox); // 解析 _owner 并建 HurtBoxOwnerGuard var parry = _victim.AddComponent(); SetPrivateField(parry, "_config", _config); hurtBox.SetParrySystem(parry); parry.OpenParryWindow(); // 生产 API:动画事件走的就是这个 Assert.IsTrue(parry.IsParrying, "前提:弹反窗口必须已开启,否则测不到弹反分支"); return hurtBox; } private static DamageInfo ParryableHit(IParryable attacker) => new DamageInfo.Builder() .SetRaw(10) .SetFlags(DamageFlags.CanBeParried) .SetAttacker(attacker) .Build(); [Test] public void ParrySuccess_StaggersTheAttacker_WithConfiguredDuration() { var hurtBox = MakeParryingVictim(); _attacker = new GameObject("attacker"); var spy = _attacker.AddComponent(); hurtBox.ReceiveDamage(ParryableHit(spy)); Assert.AreEqual(1, spy.Calls, "弹反成功必须反制发起这次攻击的那一方——这正是此前缺失的那根线"); Assert.AreEqual(_config.StaggerDuration, spy.LastDuration, "硬直时长要取 ParryConfigSO.StaggerDuration,不得另立常量"); } [Test] public void ParrySuccess_WithoutAttacker_DoesNotThrow() { // 陷阱 / 环境伤害等旁路没有攻击者,弹反它们不该炸。 var hurtBox = MakeParryingVictim(); Assert.DoesNotThrow(() => hurtBox.ReceiveDamage(ParryableHit(null))); } [Test] public void EnemyBase_IsParryable_AndParryForcesStagger() { // 接收端:EnemyBase 必须能被 Combat 层当作 IParryable 拿到 // (Combat 不能反向依赖 Enemies,所以接缝只能是接口)。 _attacker = new GameObject("enemy"); var enemy = _attacker.AddComponent(); Assert.IsInstanceOf(enemy, "EnemyBase 应实现 IParryable"); ((IParryable)enemy).ReceiveParry(0.5f); Assert.AreEqual(EnemyStateType.Stagger, enemy.CurrentState, "被弹反应强制进入 Stagger——期间 IsControllable 为假,AI 决策自动让位"); } } }