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 决策自动让位");
}
}
}