using PkmnLib.Dynamic.Events; using PkmnLib.Dynamic.Models; using PkmnLib.Dynamic.ScriptHandling; using PkmnLib.Plugin.Gen7.Scripts.Abilities; using PkmnLib.Static.Species; using PkmnLib.Static.Utils; namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Abilities; /// /// Tests for the Aftermath ability. /// public class AftermathTests { /// /// Creates a fully mocked test setup for Aftermath tests. /// private static (Aftermath aftermath, IExecutingMove move, IPokemon target, IPokemon user, EventHook eventHook, IBattle battle) CreateFullTestSetup(bool isContact, uint userMaxHealth = 100) { var aftermath = new Aftermath(); var move = Substitute.For(); var target = Substitute.For(); var hitData = Substitute.For(); hitData.IsContact.Returns(isContact); move.GetHitData(target, 0).Returns(hitData); var eventHook = new EventHook(); var battle = Substitute.For(); battle.EventHook.Returns(eventHook); // Setup empty sides by default (no Damp on field) var side = Substitute.For(); side.Pokemon.Returns(new List()); battle.Sides.Returns(new[] { side }); var battleData = Substitute.For(); battleData.Battle.Returns(battle); var user = Substitute.For(); user.IsUsable.Returns(true); user.MaxHealth.Returns(userMaxHealth); // Configure BattleData return value using NSubstitute's callback pattern // First return null to suppress auto-substitution, then configure actual value user.BattleData.Returns(battleData); move.User.Returns(user); return (aftermath, move, target, user, eventHook, battle); } /// /// Helper to extract the damage amount from a substitute's received Damage calls. /// private static uint GetDamageDealt(IPokemon user) { var damageCall = user.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Damage"); return damageCall != null ? (uint)damageCall.GetArguments()[0]! : 0; } /// /// Helper to extract the damage source from a substitute's received Damage calls. /// private static DamageSource? GetDamageSource(IPokemon user) { var damageCall = user.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Damage"); return damageCall != null ? (DamageSource)damageCall.GetArguments()[1]! : null; } /// /// Bulbapedia: "When a Pokémon with this Ability faints from damage caused by a move that makes contact, /// the attacking Pokémon takes damage equal to ¼ of its own maximum HP." /// This test verifies that contact moves trigger the ability and deal 1/4 max HP damage. /// [Test] public async Task OnFaint_ContactMove_DealsQuarterMaxHpDamage() { // Arrange var (aftermath, move, target, user, _, _) = CreateFullTestSetup(true, 100); // Act aftermath.OnIncomingHit(move, target, 0); aftermath.OnFaint(target, DamageSource.MoveDamage); // Assert - Should deal 25 damage (1/4 of 100) await Assert.That(GetDamageDealt(user)).IsEqualTo(25u); } /// /// Bulbapedia: "the attacking Pokémon takes damage equal to ¼ of its own maximum HP." /// Tests various max HP values to ensure proper integer division. /// [Test, Arguments(100u, 25u), Arguments(200u, 50u), Arguments(44u, 11u), Arguments(99u, 24u), Arguments(1u, 0u)] public async Task OnFaint_ContactMove_DamageCalculation(uint maxHealth, uint expectedDamage) { // Arrange var (aftermath, move, target, user, _, _) = CreateFullTestSetup(true, maxHealth); // Act aftermath.OnIncomingHit(move, target, 0); aftermath.OnFaint(target, DamageSource.MoveDamage); // Assert await Assert.That(GetDamageDealt(user)).IsEqualTo(expectedDamage); } /// /// Bulbapedia: Aftermath triggers "from damage caused by a move that makes contact". /// Non-contact moves should not trigger the ability. /// [Test] public async Task OnFaint_NonContactMove_DoesNotDealDamage() { // Arrange var (aftermath, move, target, user, _, _) = CreateFullTestSetup(false); // Act aftermath.OnIncomingHit(move, target, 0); aftermath.OnFaint(target, DamageSource.MoveDamage); // Assert - Damage should never be called await Assert.That(user.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Damage")).IsFalse(); } /// /// Bulbapedia: Aftermath triggers when the Pokémon "faints from damage". /// If the faint is not from move damage, the ability should not trigger. /// [Test] public async Task OnFaint_NotFromMoveDamage_DoesNotTrigger() { // Arrange var (aftermath, move, target, user, _, _) = CreateFullTestSetup(true); aftermath.OnIncomingHit(move, target, 0); // Act - Faint from non-move damage source aftermath.OnFaint(target, DamageSource.Misc); // Assert - Damage should never be called await Assert.That(user.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Damage")).IsFalse(); } /// /// Bulbapedia: "the attacking Pokémon takes damage". /// If the attacker has already fainted (IsUsable = false), no damage should be dealt. /// [Test] public async Task OnFaint_AttackerNotUsable_DoesNotDealDamage() { // Arrange var aftermath = new Aftermath(); var move = Substitute.For(); var target = Substitute.For(); var hitData = Substitute.For(); hitData.IsContact.Returns(true); move.GetHitData(target, 0).Returns(hitData); var user = Substitute.For(); user.IsUsable.Returns(false); // Attacker already fainted move.User.Returns(user); // Act aftermath.OnIncomingHit(move, target, 0); aftermath.OnFaint(target, DamageSource.MoveDamage); // Assert - Damage should never be called because IsUsable check fails await Assert.That(user.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Damage")).IsFalse(); } /// /// Bulbapedia: "the attacking Pokémon takes damage". /// If the attacker has no battle data, no damage should be dealt. /// [Test] public async Task OnFaint_AttackerHasNoBattleData_DoesNotDealDamage() { // Arrange var aftermath = new Aftermath(); var move = Substitute.For(); var target = Substitute.For(); var hitData = Substitute.For(); hitData.IsContact.Returns(true); move.GetHitData(target, 0).Returns(hitData); var user = Substitute.For(); user.IsUsable.Returns(true); user.BattleData.Returns((IPokemonBattleData?)null); move.User.Returns(user); // Act aftermath.OnIncomingHit(move, target, 0); aftermath.OnFaint(target, DamageSource.MoveDamage); // Assert - Damage should never be called because BattleData is null await Assert.That(user.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Damage")).IsFalse(); } /// /// Technical test: Verifies OnFaint handles the case where no attack was received. /// [Test] public void OnFaint_NoLastAttackStored_DoesNotThrow() { // Arrange var aftermath = new Aftermath(); var target = Substitute.For(); // Act & Assert - Should not throw when _lastAttack is null aftermath.OnFaint(target, DamageSource.MoveDamage); } /// /// Bulbapedia: Aftermath triggers on "a move that makes contact". /// When multiple hits occur, only the last hit determines whether Aftermath triggers. /// This tests that a contact hit after a non-contact hit will trigger. /// [Test] public async Task OnIncomingHit_MultipleHits_LastContactHitTriggers() { // Arrange var aftermath = new Aftermath(); var target = Substitute.For(); var battle = Substitute.For(); var eventHook = new EventHook(); battle.EventHook.Returns(eventHook); // Setup empty sides (no Damp on field) var side = Substitute.For(); side.Pokemon.Returns(new List()); battle.Sides.Returns(new[] { side }); var battleData = Substitute.For(); battleData.Battle.Returns(battle); // First hit - non-contact var move1 = Substitute.For(); var hitData1 = Substitute.For(); hitData1.IsContact.Returns(false); move1.GetHitData(target, 0).Returns(hitData1); // Second hit - contact (should be stored and trigger) var move2 = Substitute.For(); var hitData2 = Substitute.For(); hitData2.IsContact.Returns(true); move2.GetHitData(target, 0).Returns(hitData2); var user = Substitute.For(); user.IsUsable.Returns(true); user.BattleData.Returns(battleData); user.MaxHealth.Returns(100u); move2.User.Returns(user); // Act aftermath.OnIncomingHit(move1, target, 0); aftermath.OnIncomingHit(move2, target, 0); aftermath.OnFaint(target, DamageSource.MoveDamage); // Assert - Should deal 25 damage from the last contact hit await Assert.That(GetDamageDealt(user)).IsEqualTo(25u); } /// /// Bulbapedia: Aftermath triggers on "a move that makes contact". /// When the last hit is non-contact, Aftermath should not trigger. /// [Test] public async Task OnIncomingHit_MultipleHits_LastNonContactDoesNotTrigger() { // Arrange var aftermath = new Aftermath(); var target = Substitute.For(); // First hit - contact var move1 = Substitute.For(); var hitData1 = Substitute.For(); hitData1.IsContact.Returns(true); move1.GetHitData(target, 0).Returns(hitData1); // Second hit - non-contact (overwrites, sets to null) var move2 = Substitute.For(); var hitData2 = Substitute.For(); hitData2.IsContact.Returns(false); move2.GetHitData(target, 0).Returns(hitData2); // Act aftermath.OnIncomingHit(move1, target, 0); aftermath.OnIncomingHit(move2, target, 0); aftermath.OnFaint(target, DamageSource.MoveDamage); // Assert - Neither move's User should be accessed (_lastAttack is null) await Assert.That(move1.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "get_User")).IsFalse(); await Assert.That(move2.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "get_User")).IsFalse(); } /// /// Bulbapedia: "When a Pokémon with this Ability faints..." /// Verifies that the AbilityTriggerEvent is fired when Aftermath triggers. /// [Test] public async Task OnFaint_ContactMove_FiresAbilityTriggerEvent() { // Arrange var (aftermath, move, target, user, eventHook, _) = CreateFullTestSetup(true); AbilityTriggerEvent? capturedEvent = null; eventHook.Handler += (sender, args) => { if (args is AbilityTriggerEvent ate) capturedEvent = ate; }; // Act aftermath.OnIncomingHit(move, target, 0); aftermath.OnFaint(target, DamageSource.MoveDamage); // Assert - AbilityTriggerEvent should be fired with the target (Aftermath owner) await Assert.That(capturedEvent).IsNotNull(); await Assert.That(capturedEvent!.Pokemon).IsEqualTo(target); } /// /// Bulbapedia: "the attacking Pokémon takes damage equal to ¼ of its own maximum HP." /// Verifies that the damage source is DamageSource.Misc (not MoveDamage). /// [Test] public async Task OnFaint_ContactMove_UsesMiscDamageSource() { // Arrange var (aftermath, move, target, user, _, _) = CreateFullTestSetup(true); // Act aftermath.OnIncomingHit(move, target, 0); aftermath.OnFaint(target, DamageSource.MoveDamage); // Assert await Assert.That(GetDamageSource(user)).IsEqualTo(DamageSource.Misc); } /// /// Bulbapedia: Aftermath does not activate "if a Pokémon with Damp is on the field". /// This test verifies that Aftermath is suppressed when any Pokémon on the field has Damp. /// [Test] public async Task OnFaint_DampOnField_DoesNotTrigger() { // Arrange var (aftermath, move, target, user, _, battle) = CreateFullTestSetup(true, 100); // Create a Pokemon with Damp ability on the field var dampPokemon = Substitute.For(); var dampAbility = Substitute.For(); dampAbility.Name.Returns(new StringKey("damp")); dampPokemon.ActiveAbility.Returns(dampAbility); // Update battle sides to include the Damp Pokemon var side = Substitute.For(); side.Pokemon.Returns(new List { dampPokemon }); battle.Sides.Returns(new[] { side }); // Act aftermath.OnIncomingHit(move, target, 0); aftermath.OnFaint(target, DamageSource.MoveDamage); // Assert - Damage should never be called because Damp prevents Aftermath await Assert.That(user.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Damage")).IsFalse(); } }